diff --git a/.github/workflows/check-release.yml b/.github/workflows/check-release.yml index c3bd33ff..4c111740 100644 --- a/.github/workflows/check-release.yml +++ b/.github/workflows/check-release.yml @@ -44,7 +44,7 @@ jobs: - name: Install Dependencies run: | pip install -e . --no-deps - pip install -e plugins/auth_base + pip install -e jupyverse_api pip install -e plugins/frontend pip install -e plugins/jupyterlab pip install -e plugins/retrolab @@ -54,6 +54,8 @@ jobs: pip install -e plugins/nbconvert pip install -e plugins/yjs pip install -e plugins/auth + pip install -e plugins/noauth + pip install -e plugins/auth_fief pip install -e plugins/login - name: Check Release if: ${{ matrix.group == 'check_release' }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index cdddfd65..1d5d9498 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -34,15 +34,15 @@ jobs: - name: Upgrade pip run: python3 -m pip install --upgrade pip - - name: Create jupyterlab-auth dev environment + - name: Create jupyterlab dev environment run: | pip install hatch - hatch env create dev.jupyterlab-auth + hatch env create dev.jupyterlab - name: Check types run: | - hatch run dev.jupyterlab-auth:typecheck + hatch run dev.jupyterlab:typecheck - name: Run tests run: | - hatch run dev.jupyterlab-auth:test + hatch run dev.jupyterlab:test diff --git a/config.yaml b/config.yaml new file mode 100644 index 00000000..7a53db6b --- /dev/null +++ b/config.yaml @@ -0,0 +1,52 @@ +--- +component: + type: jupyverse + components: + app: + type: app + auth: + type: auth + #auth: + # type: auth_fief + #auth: + # type: noauth + contents: + type: contents + frontend: + type: frontend + lab: + type: lab + jupyterlab: + type: jupyterlab + kernels: + type: kernels + login: + type: login + nbconvert: + type: nbconvert + resource_usage: + type: resource_usage + track_cpu_percent: true + #retrolab: + # type: retrolab + terminals: + type: terminals + yjs: + type: yjs + +logging: + version: 1 + disable_existing_loggers: false + formatters: + default: + format: '[%(asctime)s %(levelname)s] %(message)s' + handlers: + console: + class: logging.StreamHandler + formatter: default + root: + handlers: [console] + level: INFO + loggers: + webnotifier: + level: DEBUG diff --git a/plugins/auth_base/fps_auth_base/py.typed b/jupyverse/py.typed similarity index 100% rename from plugins/auth_base/fps_auth_base/py.typed rename to jupyverse/py.typed diff --git a/plugins/auth_base/COPYING.md b/jupyverse_api/COPYING.md similarity index 100% rename from plugins/auth_base/COPYING.md rename to jupyverse_api/COPYING.md diff --git a/jupyverse_api/README.md b/jupyverse_api/README.md new file mode 100644 index 00000000..99cbbb09 --- /dev/null +++ b/jupyverse_api/README.md @@ -0,0 +1,3 @@ +# Jupyverse API + +The public API for Jupyverse. diff --git a/jupyverse_api/jupyverse_api/__init__.py b/jupyverse_api/jupyverse_api/__init__.py new file mode 100644 index 00000000..ae81f041 --- /dev/null +++ b/jupyverse_api/jupyverse_api/__init__.py @@ -0,0 +1,42 @@ +from typing import Dict + +from pydantic import BaseModel, Extra + +from .app import App + + +__version__ = "0.0.50" + + +class Singleton(type): + _instances: Dict = {} + + def __call__(cls, *args, **kwargs): + if cls not in cls._instances: + cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) + return cls._instances[cls] + + +class Config(BaseModel): + class Config: + extra = Extra.forbid + + +class Router: + _app: App + + def __init__( + self, + app: App, + ) -> None: + self._app = app + + @property + def _type(self): + return self.__class__.__name__ + + def include_router(self, router, **kwargs): + self._app._include_router(router, self._type, **kwargs) + + def mount(self, path: str, *args, **kwargs) -> None: + self._app._mount(path, self._type, *args, **kwargs) diff --git a/jupyverse_api/jupyverse_api/app/__init__.py b/jupyverse_api/jupyverse_api/app/__init__.py new file mode 100644 index 00000000..75e01c84 --- /dev/null +++ b/jupyverse_api/jupyverse_api/app/__init__.py @@ -0,0 +1,51 @@ +import logging +from collections import defaultdict +from typing import Dict, List + +from fastapi import FastAPI + +from ..exceptions import RedirectException, _redirect_exception_handler + + +logger = logging.getLogger("app") + + +class App: + """A wrapper around FastAPI that checks for endpoint path conflicts.""" + + _app: FastAPI + _router_paths: Dict[str, List[str]] + + def __init__(self, app: FastAPI): + self._app = app + app.add_exception_handler(RedirectException, _redirect_exception_handler) + self._router_paths = defaultdict(list) + + @property + def _paths(self): + return [path for router, paths in self._router_paths.items() for path in paths] + + def _include_router(self, router, _type, **kwargs) -> None: + new_paths = [] + for route in router.routes: + path = kwargs.get("prefix", "") + route.path + for _router, _paths in self._router_paths.items(): + if path in _paths: + raise RuntimeError( + f"{_type} adds a handler for a path that is already defined in " + f"{_router}: {path}" + ) + logger.debug("%s added handler for path: %s", _type, path) + new_paths.append(path) + self._router_paths[_type].extend(new_paths) + self._app.include_router(router, **kwargs) + + def _mount(self, path: str, _type, *args, **kwargs) -> None: + for _router, _paths in self._router_paths.items(): + if path in _paths: + raise RuntimeError( + f"{_type } mounts a path that is already defined in {_router}: {path}" + ) + self._router_paths[_type].append(path) + logger.debug("%s mounted path: %s", _type, path) + self._app.mount(path, *args, **kwargs) diff --git a/jupyverse_api/jupyverse_api/auth/__init__.py b/jupyverse_api/jupyverse_api/auth/__init__.py new file mode 100644 index 00000000..f8cd3d7b --- /dev/null +++ b/jupyverse_api/jupyverse_api/auth/__init__.py @@ -0,0 +1,27 @@ +from abc import ABC, abstractmethod +from typing import Any, Callable, Dict, List, Optional, Tuple + +from jupyverse_api import Config + +from .models import User # noqa + + +class Auth(ABC): + @abstractmethod + def current_user(self, permissions: Optional[Dict[str, List[str]]] = None) -> Callable: + ... + + @abstractmethod + async def update_user(self) -> Callable: + ... + + @abstractmethod + def websocket_auth( + self, + permissions: Optional[Dict[str, List[str]]] = None, + ) -> Callable[[], Tuple[Any, Dict[str, List[str]]]]: + ... + + +class AuthConfig(Config): + pass diff --git a/plugins/noauth/fps_noauth/models.py b/jupyverse_api/jupyverse_api/auth/models.py similarity index 91% rename from plugins/noauth/fps_noauth/models.py rename to jupyverse_api/jupyverse_api/auth/models.py index 47f1b6e2..c6337013 100644 --- a/plugins/noauth/fps_noauth/models.py +++ b/jupyverse_api/jupyverse_api/auth/models.py @@ -1,10 +1,8 @@ from typing import Optional - from pydantic import BaseModel class User(BaseModel): - anonymous: bool = True username: str = "" name: str = "" display_name: str = "" diff --git a/jupyverse_api/jupyverse_api/contents/__init__.py b/jupyverse_api/jupyverse_api/contents/__init__.py new file mode 100644 index 00000000..d5256d4e --- /dev/null +++ b/jupyverse_api/jupyverse_api/contents/__init__.py @@ -0,0 +1,38 @@ +import asyncio +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Dict, Union + +from jupyverse_api import Router + +from .models import Content, SaveContent + + +class FileIdManager(ABC): + stop_watching_files: asyncio.Event + stopped_watching_files: asyncio.Event + + @abstractmethod + async def get_path(self, file_id: str) -> str: + ... + + @abstractmethod + async def get_id(self, file_path: str) -> str: + ... + + +class Contents(Router, ABC): + @property + @abstractmethod + def file_id_manager(self) -> FileIdManager: + ... + + @abstractmethod + async def read_content( + self, path: Union[str, Path], get_content: bool, as_json: bool = False + ) -> Content: + ... + + @abstractmethod + async def write_content(self, content: Union[SaveContent, Dict]) -> None: + ... diff --git a/jupyverse_api/jupyverse_api/contents/models.py b/jupyverse_api/jupyverse_api/contents/models.py new file mode 100644 index 00000000..00b3ca79 --- /dev/null +++ b/jupyverse_api/jupyverse_api/contents/models.py @@ -0,0 +1,23 @@ +from typing import Dict, List, Optional, Union + +from pydantic import BaseModel + + +class Content(BaseModel): + name: str + path: str + last_modified: Optional[str] + created: Optional[str] + content: Optional[Union[str, Dict, List[Dict]]] + format: Optional[str] + mimetype: Optional[str] + size: Optional[int] + writable: bool + type: str + + +class SaveContent(BaseModel): + content: Optional[Union[str, Dict]] + format: str + path: str + type: str diff --git a/jupyverse_api/jupyverse_api/exceptions.py b/jupyverse_api/jupyverse_api/exceptions.py new file mode 100644 index 00000000..81bc916e --- /dev/null +++ b/jupyverse_api/jupyverse_api/exceptions.py @@ -0,0 +1,11 @@ +from fastapi import Request, Response +from fastapi.responses import RedirectResponse + + +class RedirectException(Exception): + def __init__(self, redirect_to: str): + self.redirect_to = redirect_to + + +async def _redirect_exception_handler(request: Request, exc: RedirectException) -> Response: + return RedirectResponse(url=exc.redirect_to) diff --git a/jupyverse_api/jupyverse_api/frontend/__init__.py b/jupyverse_api/jupyverse_api/frontend/__init__.py new file mode 100644 index 00000000..6a7acc62 --- /dev/null +++ b/jupyverse_api/jupyverse_api/frontend/__init__.py @@ -0,0 +1,6 @@ +from jupyverse_api import Config + + +class FrontendConfig(Config): + base_url: str = "/" + collaborative: bool = False diff --git a/jupyverse_api/jupyverse_api/jupyterlab/__init__.py b/jupyverse_api/jupyverse_api/jupyterlab/__init__.py new file mode 100644 index 00000000..373628ad --- /dev/null +++ b/jupyverse_api/jupyverse_api/jupyterlab/__init__.py @@ -0,0 +1,9 @@ +from jupyverse_api import Config, Router + + +class JupyterLab(Router): + pass + + +class JupyterLabConfig(Config): + dev_mode: bool = False diff --git a/jupyverse_api/jupyverse_api/kernels/__init__.py b/jupyverse_api/jupyverse_api/kernels/__init__.py new file mode 100644 index 00000000..83dc050c --- /dev/null +++ b/jupyverse_api/jupyverse_api/kernels/__init__.py @@ -0,0 +1,16 @@ +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Optional + +from jupyverse_api import Router, Config + + +class Kernels(Router, ABC): + @abstractmethod + async def watch_connection_files(self, path: Path) -> None: + ... + + +class KernelsConfig(Config): + default_kernel: str = "python3" + connection_path: Optional[str] = None diff --git a/plugins/kernels/fps_kernels/models.py b/jupyverse_api/jupyverse_api/kernels/models.py similarity index 100% rename from plugins/kernels/fps_kernels/models.py rename to jupyverse_api/jupyverse_api/kernels/models.py diff --git a/jupyverse_api/jupyverse_api/lab/__init__.py b/jupyverse_api/jupyverse_api/lab/__init__.py new file mode 100644 index 00000000..151dea2e --- /dev/null +++ b/jupyverse_api/jupyverse_api/lab/__init__.py @@ -0,0 +1,18 @@ +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any, Dict, List, Tuple + +from fastapi import APIRouter +from jupyverse_api import Router + + +class Lab(Router, ABC): + @abstractmethod + def init_router( + self, router: APIRouter, redirect_after_root: str + ) -> Tuple[Path, List[Dict[str, Any]]]: + ... + + @abstractmethod + def get_federated_extensions(self, extensions_dir: Path) -> Tuple[List, List]: + ... diff --git a/jupyverse_api/jupyverse_api/login/__init__.py b/jupyverse_api/jupyverse_api/login/__init__.py new file mode 100644 index 00000000..7f3b2b63 --- /dev/null +++ b/jupyverse_api/jupyverse_api/login/__init__.py @@ -0,0 +1,5 @@ +from jupyverse_api import Router + + +class Login(Router): + pass diff --git a/jupyverse_api/jupyverse_api/main/__init__.py b/jupyverse_api/jupyverse_api/main/__init__.py new file mode 100644 index 00000000..8a77dff7 --- /dev/null +++ b/jupyverse_api/jupyverse_api/main/__init__.py @@ -0,0 +1,24 @@ +from asphalt.core import Component, Context +from asphalt.web.fastapi import FastAPIComponent +from fastapi import FastAPI + +from ..app import App + + +class AppComponent(Component): + async def start( + self, + ctx: Context, + ) -> None: + app = await ctx.request_resource(FastAPI) + + _app = App(app) + ctx.add_resource(_app) + + +class JupyverseComponent(FastAPIComponent): + async def start( + self, + ctx: Context, + ) -> None: + await super().start(ctx) diff --git a/jupyverse_api/jupyverse_api/nbconvert/__init__.py b/jupyverse_api/jupyverse_api/nbconvert/__init__.py new file mode 100644 index 00000000..d4b5122c --- /dev/null +++ b/jupyverse_api/jupyverse_api/nbconvert/__init__.py @@ -0,0 +1,5 @@ +from jupyverse_api import Router + + +class Nbconvert(Router): + pass diff --git a/jupyverse_api/jupyverse_api/py.typed b/jupyverse_api/jupyverse_api/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/jupyverse_api/jupyverse_api/resource_usage/__init__.py b/jupyverse_api/jupyverse_api/resource_usage/__init__.py new file mode 100644 index 00000000..a2550e63 --- /dev/null +++ b/jupyverse_api/jupyverse_api/resource_usage/__init__.py @@ -0,0 +1,13 @@ +from jupyverse_api import Router, Config + + +class ResourceUsage(Router): + pass + + +class ResourceUsageConfig(Config): + mem_limit: int = 0 + mem_warning_threshold: int = 0 + track_cpu_percent: bool = False + cpu_limit: int = 0 + cpu_warning_threshold: int = 0 diff --git a/jupyverse_api/jupyverse_api/retrolab/__init__.py b/jupyverse_api/jupyverse_api/retrolab/__init__.py new file mode 100644 index 00000000..1110d257 --- /dev/null +++ b/jupyverse_api/jupyverse_api/retrolab/__init__.py @@ -0,0 +1,5 @@ +from jupyverse_api import Router + + +class RetroLab(Router): + pass diff --git a/jupyverse_api/jupyverse_api/terminals/__init__.py b/jupyverse_api/jupyverse_api/terminals/__init__.py new file mode 100644 index 00000000..beb52338 --- /dev/null +++ b/jupyverse_api/jupyverse_api/terminals/__init__.py @@ -0,0 +1,16 @@ +from abc import ABC, abstractmethod +from jupyverse_api import Router + + +class Terminals(Router): + pass + + +class TerminalServer(ABC): + @abstractmethod + async def serve(self, websocket, permissions): + ... + + @abstractmethod + def quit(self, websocket): + ... diff --git a/jupyverse_api/jupyverse_api/yjs/__init__.py b/jupyverse_api/jupyverse_api/yjs/__init__.py new file mode 100644 index 00000000..00d53157 --- /dev/null +++ b/jupyverse_api/jupyverse_api/yjs/__init__.py @@ -0,0 +1,7 @@ +from typing import Type + +from jupyverse_api import Router + + +class Yjs(Router): + YDocWebSocketHandler: Type diff --git a/jupyverse_api/pyproject.toml b/jupyverse_api/pyproject.toml new file mode 100644 index 00000000..62a2606c --- /dev/null +++ b/jupyverse_api/pyproject.toml @@ -0,0 +1,42 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "jupyverse_api" +description = "The public API for Jupyverse" +readme = "README.md" +requires-python = ">=3.8" +keywords = [ + "jupyverse", "api", +] +authors = [ + { name = "Jupyter Development Team", email = "jupyter@googlegroups.com" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dependencies = [ + "pydantic >=1.10.6,<2", +] +dynamic = ["version"] + +[project.license] +text = "BSD 3-Clause License" + +[project.urls] +Source = "https://github.com/jupyter-server/jupyverse/api" + +[project.entry-points."asphalt.components"] +app = "jupyverse_api.main:AppComponent" +jupyverse = "jupyverse_api.main:JupyverseComponent" + +[tool.hatch.version] +path = "jupyverse_api/__init__.py" diff --git a/plugins/__init__.py b/plugins/__init__.py deleted file mode 100644 index 30cfb9c9..00000000 --- a/plugins/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Top level fps_plugins namespace.""" -__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/plugins/auth/fps_auth/backends.py b/plugins/auth/fps_auth/backends.py index 23461a82..548357b5 100644 --- a/plugins/auth/fps_auth/backends.py +++ b/plugins/auth/fps_auth/backends.py @@ -1,9 +1,11 @@ +import logging import uuid +from dataclasses import dataclass from typing import Any, Dict, Generic, List, Optional, Tuple import httpx from fastapi import Depends, HTTPException, Response, WebSocket, status -from fastapi_users import ( # type: ignore +from fastapi_users import ( BaseUserManager, FastAPIUsers, UUIDIDMixin, @@ -17,252 +19,263 @@ from fastapi_users.authentication.strategy.base import Strategy from fastapi_users.authentication.transport.base import Transport from fastapi_users.db import SQLAlchemyUserDatabase -from fps.exceptions import RedirectException # type: ignore -from fps.logging import get_configured_logger # type: ignore -from fps_lab.config import get_lab_config # type: ignore -from httpx_oauth.clients.github import GitHubOAuth2 # type: ignore +from httpx_oauth.clients.github import GitHubOAuth2 +from jupyverse_api.exceptions import RedirectException +from jupyverse_api.frontend import FrontendConfig from starlette.requests import Request -from .config import get_auth_config -from .db import User, get_user_db, secret +from .config import _AuthConfig +from .db import User from .models import UserCreate, UserRead -logger = get_configured_logger("auth") +logger = logging.getLogger("auth") -class NoAuthTransport(Transport): - scheme = None # type: ignore - async def get_login_response(self, token: str, response: Response): - pass +@dataclass +class Res: + cookie_authentication: Any + current_user: Any + update_user: Any + fapi_users: Any + get_user_manager: Any + github_authentication: Any + github_cookie_authentication: Any + websocket_auth: Any - async def get_logout_response(self, response: Response): - pass - @staticmethod - def get_openapi_login_responses_success(): - pass +def get_backend(auth_config: _AuthConfig, frontend_config: FrontendConfig, db) -> Res: + class NoAuthTransport(Transport): + scheme = None # type: ignore - @staticmethod - def get_openapi_logout_responses_success(): - pass + async def get_login_response(self, token: str, response: Response): + pass + async def get_logout_response(self, response: Response): + pass -class NoAuthStrategy(Strategy, Generic[models.UP, models.ID]): - async def read_token( - self, token: Optional[str], user_manager: BaseUserManager[models.UP, models.ID] - ) -> Optional[models.UP]: - active_user = await user_manager.user_db.get_by_email(get_auth_config().global_email) - return active_user + @staticmethod + def get_openapi_login_responses_success(): + pass - async def write_token(self, user: models.UP): - pass + @staticmethod + def get_openapi_logout_responses_success(): + pass - async def destroy_token(self, token: str, user: models.UP): - pass + class NoAuthStrategy(Strategy, Generic[models.UP, models.ID]): + async def read_token( + self, token: Optional[str], user_manager: BaseUserManager[models.UP, models.ID] + ) -> Optional[models.UP]: + active_user = await user_manager.user_db.get_by_email(auth_config.global_email) + return active_user + async def write_token(self, user: models.UP): + pass -class GitHubTransport(CookieTransport): - async def get_login_response(self, token: str, response: Response): - await super().get_login_response(token, response) - response.status_code = status.HTTP_302_FOUND - response.headers["Location"] = "/lab" + async def destroy_token(self, token: str, user: models.UP): + pass + class GitHubTransport(CookieTransport): + async def get_login_response(self, token: str, response: Response): + await super().get_login_response(token, response) + response.status_code = status.HTTP_302_FOUND + response.headers["Location"] = "/lab" -noauth_transport = NoAuthTransport() -cookie_transport = CookieTransport(cookie_secure=get_auth_config().cookie_secure) -github_transport = GitHubTransport() + def get_noauth_strategy() -> NoAuthStrategy: + return NoAuthStrategy() + def get_jwt_strategy() -> JWTStrategy: + return JWTStrategy(secret=db.secret, lifetime_seconds=None) -def get_noauth_strategy() -> NoAuthStrategy: - return NoAuthStrategy() - - -def get_jwt_strategy() -> JWTStrategy: - return JWTStrategy(secret=secret, lifetime_seconds=None) # type: ignore - - -noauth_authentication = AuthenticationBackend( - name="noauth", - transport=noauth_transport, - get_strategy=get_noauth_strategy, -) -cookie_authentication = AuthenticationBackend( - name="cookie", - transport=cookie_transport, - get_strategy=get_jwt_strategy, -) -github_cookie_authentication = AuthenticationBackend( - name="github", - transport=github_transport, - get_strategy=get_jwt_strategy, -) -github_authentication = GitHubOAuth2( - get_auth_config().client_id, get_auth_config().client_secret.get_secret_value() -) - - -class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): - async def on_after_register(self, user: User, request: Optional[Request] = None): - for oauth_account in user.oauth_accounts: - if oauth_account.oauth_name == "github": - async with httpx.AsyncClient() as client: - r = ( - await client.get(f"https://api.github.com/user/{oauth_account.account_id}") - ).json() - - await self.user_db.update( - user, - dict( - anonymous=False, - username=r["login"], - color=None, - avatar_url=r["avatar_url"], - is_active=True, - ), - ) - - -async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)): - yield UserManager(user_db) - - -async def get_enabled_backends( - auth_config=Depends(get_auth_config), lab_config=Depends(get_lab_config) -): - if auth_config.mode == "noauth" and not lab_config.collaborative: - return [noauth_authentication, github_cookie_authentication] - else: - return [cookie_authentication, github_cookie_authentication] - - -fapi_users = FastAPIUsers[User, uuid.UUID]( - get_user_manager, - [noauth_authentication, cookie_authentication, github_cookie_authentication], -) + noauth_authentication = AuthenticationBackend( + name="noauth", + transport=NoAuthTransport(), + get_strategy=get_noauth_strategy, + ) + cookie_authentication = AuthenticationBackend( + name="cookie", + transport=CookieTransport(cookie_secure=auth_config.cookie_secure), + get_strategy=get_jwt_strategy, + ) -async def create_guest(user_manager, auth_config): - # workspace and settings are copied from global user - # but this is a new user - global_user = await user_manager.get_by_email(auth_config.global_email) - user_id = str(uuid.uuid4()) - guest = dict( - anonymous=True, - email=f"{user_id}@jupyter.com", - username=f"{user_id}@jupyter.com", - password="", - workspace=global_user.workspace, - settings=global_user.settings, - permissions={}, + github_cookie_authentication = AuthenticationBackend( + name="github", + transport=GitHubTransport(), + get_strategy=get_jwt_strategy, ) - return await user_manager.create(UserCreate(**guest)) - - -def current_user(permissions: Optional[Dict[str, List[str]]] = None): - async def _( - response: Response, - token: Optional[str] = None, - user: Optional[User] = Depends( - fapi_users.current_user(optional=True, get_enabled_backends=get_enabled_backends) - ), - user_manager: UserManager = Depends(get_user_manager), - auth_config=Depends(get_auth_config), - lab_config=Depends(get_lab_config), - ): - if auth_config.mode == "user": - # "user" authentication: check authorization - if user and permissions: - for resource, actions in permissions.items(): - user_actions_for_resource = user.permissions.get(resource, []) - if not all([a in user_actions_for_resource for a in actions]): - user = None - break + + github_authentication = GitHubOAuth2(auth_config.client_id, auth_config.client_secret) + + class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): + async def on_after_register(self, user: User, request: Optional[Request] = None): + for oauth_account in user.oauth_accounts: + if oauth_account.oauth_name == "github": + async with httpx.AsyncClient() as client: + r = ( + await client.get( + f"https://api.github.com/user/{oauth_account.account_id}" + ) + ).json() + + await self.user_db.update( + user, + dict( + anonymous=False, + username=r["login"], + color=None, + avatar_url=r["avatar_url"], + is_active=True, + ), + ) + + async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(db.get_user_db)): + yield UserManager(user_db) + + def get_enabled_backends(): + if auth_config.mode == "noauth" and not frontend_config.collaborative: + res = [noauth_authentication, github_cookie_authentication] else: - # "noauth" or "token" authentication - if lab_config.collaborative: - if not user and auth_config.mode == "noauth": - user = await create_guest(user_manager, auth_config) - await cookie_authentication.login(get_jwt_strategy(), user, response) - - elif not user and auth_config.mode == "token": - global_user = await user_manager.get_by_email(auth_config.global_email) - if global_user and global_user.username == token: - user = await create_guest(user_manager, auth_config) - await cookie_authentication.login(get_jwt_strategy(), user, response) + res = [cookie_authentication, github_cookie_authentication] + return res + + fapi_users = FastAPIUsers[User, uuid.UUID]( + get_user_manager, + [ + noauth_authentication, + cookie_authentication, + github_cookie_authentication, + ], + ) + + async def create_guest(user_manager): + # workspace and settings are copied from global user + # but this is a new user + global_user = await user_manager.get_by_email(auth_config.global_email) + user_id = str(uuid.uuid4()) + guest = dict( + anonymous=True, + email=f"{user_id}@jupyter.com", + username=f"{user_id}@jupyter.com", + password="", + workspace=global_user.workspace, + settings=global_user.settings, + permissions={}, + ) + return await user_manager.create(UserCreate(**guest)) + + def current_user(permissions: Optional[Dict[str, List[str]]] = None): + async def _( + response: Response, + token: Optional[str] = None, + user: Optional[User] = Depends( + fapi_users.current_user(optional=True, get_enabled_backends=get_enabled_backends) + ), + user_manager: BaseUserManager[User, models.ID] = Depends(get_user_manager), + ): + if auth_config.mode == "user": + # "user" authentication: check authorization + if user and permissions: + for resource, actions in permissions.items(): + user_actions_for_resource = user.permissions.get(resource, []) + if not all([a in user_actions_for_resource for a in actions]): + user = None + break else: - if auth_config.mode == "token": - global_user = await user_manager.get_by_email(auth_config.global_email) - if global_user and global_user.username == token: - user = global_user + # "noauth" or "token" authentication + if frontend_config.collaborative: + if not user and auth_config.mode == "noauth": + user = await create_guest(user_manager) await cookie_authentication.login(get_jwt_strategy(), user, response) - if user: - return user - - elif auth_config.login_url: - raise RedirectException(auth_config.login_url) + elif not user and auth_config.mode == "token": + global_user = await user_manager.get_by_email(auth_config.global_email) + if global_user and global_user.username == token: + user = await create_guest(user_manager) + await cookie_authentication.login(get_jwt_strategy(), user, response) + else: + if auth_config.mode == "token": + global_user = await user_manager.get_by_email(auth_config.global_email) + if global_user and global_user.username == token: + user = global_user + await cookie_authentication.login(get_jwt_strategy(), user, response) - else: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) - - return _ - - -def websocket_auth(permissions: Optional[Dict[str, List[str]]] = None): - """ - A function returning a dependency for the WebSocket connection. - - :param permissions: the permissions the user should be granted access to. The user should have - access to at least one of them for the WebSocket to be opened. - :returns: a dependency for the WebSocket connection. The dependency returns a tuple consisting - of the websocket and the checked user permissions if the websocket is accepted, None otherwise. - """ - - async def _( - websocket: WebSocket, - auth_config=Depends(get_auth_config), - user_manager: UserManager = Depends(get_user_manager), - ) -> Optional[Tuple[WebSocket, Optional[Dict[str, List[str]]]]]: - accept_websocket = False - checked_permissions: Optional[Dict[str, List[str]]] = None - if auth_config.mode == "noauth": - accept_websocket = True - elif "fastapiusersauth" in websocket._cookies: - token = websocket._cookies["fastapiusersauth"] - user = await get_jwt_strategy().read_token(token, user_manager) if user: - if auth_config.mode == "user": - # "user" authentication: check authorization - if permissions is None: - accept_websocket = True - else: - checked_permissions = {} - for resource, actions in permissions.items(): - user_actions_for_resource = user.permissions.get(resource) - if user_actions_for_resource is None: - continue - allowed = checked_permissions[resource] = [] - for action in actions: - if action in user_actions_for_resource: - allowed.append(action) - accept_websocket = True - else: - accept_websocket = True - if accept_websocket: - return websocket, checked_permissions - else: - await websocket.close(code=status.WS_1008_POLICY_VIOLATION) - return None + return user + + elif auth_config.login_url: + raise RedirectException(auth_config.login_url) - return _ + else: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + + return _ + + def websocket_auth(permissions: Optional[Dict[str, List[str]]] = None): + """ + A function returning a dependency for the WebSocket connection. + + :param permissions: the permissions the user should be granted access to. The user should + have access to at least one of them for the WebSocket to be opened. + :returns: a dependency for the WebSocket connection. The dependency returns a tuple + consisting of the websocket and the checked user permissions if the websocket is accepted, + None otherwise. + """ + + async def _( + websocket: WebSocket, + user_manager: BaseUserManager[models.UP, models.ID] = Depends(get_user_manager), + ) -> Optional[Tuple[WebSocket, Optional[Dict[str, List[str]]]]]: + accept_websocket = False + checked_permissions: Optional[Dict[str, List[str]]] = None + if auth_config.mode == "noauth": + accept_websocket = True + elif "fastapiusersauth" in websocket._cookies: + token = websocket._cookies["fastapiusersauth"] + user = await get_jwt_strategy().read_token(token, user_manager) + if user: + if auth_config.mode == "user": + # "user" authentication: check authorization + if permissions is None: + accept_websocket = True + else: + checked_permissions = {} + for resource, actions in permissions.items(): + user_actions_for_resource = user.permissions.get(resource) + if user_actions_for_resource is None: + continue + allowed = checked_permissions[resource] = [] + for action in actions: + if action in user_actions_for_resource: + allowed.append(action) + accept_websocket = True + else: + accept_websocket = True + if accept_websocket: + return websocket, checked_permissions + else: + await websocket.close(code=status.WS_1008_POLICY_VIOLATION) + return None + return _ -async def update_user( - user: UserRead = Depends(current_user()), user_db: SQLAlchemyUserDatabase = Depends(get_user_db) -): - async def _(data: Dict[str, Any]) -> UserRead: - await user_db.update(user, data) - return user + async def update_user( + user: UserRead = Depends(current_user()), + user_db: SQLAlchemyUserDatabase = Depends(db.get_user_db), + ): + async def _(data: Dict[str, Any]) -> UserRead: + await user_db.update(user, data) + return user - return _ + return _ + + return Res( + cookie_authentication=cookie_authentication, + current_user=current_user, + update_user=update_user, + fapi_users=fapi_users, + get_user_manager=get_user_manager, + github_authentication=github_authentication, + github_cookie_authentication=github_cookie_authentication, + websocket_auth=websocket_auth, + ) diff --git a/plugins/auth/fps_auth/config.py b/plugins/auth/fps_auth/config.py index 715e7efe..69d08caa 100644 --- a/plugins/auth/fps_auth/config.py +++ b/plugins/auth/fps_auth/config.py @@ -1,30 +1,18 @@ from typing import Optional from uuid import uuid4 -from fps.config import PluginModel, get_config # type: ignore -from fps.hooks import register_config # type: ignore -from pydantic import BaseSettings, SecretStr +from jupyverse_api.auth import AuthConfig -class AuthConfig(PluginModel, BaseSettings): +class _AuthConfig(AuthConfig): client_id: str = "" - client_secret: SecretStr = SecretStr("") + client_secret: str = "" redirect_uri: str = "" # mode: Literal["noauth", "token", "user"] = "token" mode: str = "token" - token: str = str(uuid4()) + token: str = uuid4().hex global_email: str = "guest@jupyter.com" cookie_secure: bool = False # FIXME: should default to True, and set to False for tests clear_users: bool = False test: bool = False login_url: Optional[str] = None - - class Config(PluginModel.Config): - env_prefix = "fps_auth_" - - -def get_auth_config(): - return get_config(AuthConfig) - - -c = register_config(AuthConfig) diff --git a/plugins/auth/fps_auth/db.py b/plugins/auth/fps_auth/db.py index bd7b5865..1e40c181 100644 --- a/plugins/auth/fps_auth/db.py +++ b/plugins/auth/fps_auth/db.py @@ -1,44 +1,25 @@ +import logging import secrets +from dataclasses import dataclass from pathlib import Path -from typing import AsyncGenerator, List +from typing import Any, AsyncGenerator, List from fastapi import Depends -from fastapi_users.db import SQLAlchemyBaseOAuthAccountTableUUID # type: ignore -from fastapi_users.db import ( # type: ignore +from fastapi_users.db import SQLAlchemyBaseOAuthAccountTableUUID +from fastapi_users.db import ( SQLAlchemyBaseUserTableUUID, SQLAlchemyUserDatabase, ) -from fps.config import get_config # type: ignore from sqlalchemy import JSON, Boolean, Column, String, Text # type: ignore from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine # type: ignore from sqlalchemy.ext.declarative import DeclarativeMeta, declarative_base # type: ignore from sqlalchemy.orm import relationship, sessionmaker # type: ignore -from .config import AuthConfig +from .config import _AuthConfig -auth_config = get_config(AuthConfig) -jupyter_dir = Path.home() / ".local" / "share" / "jupyter" -jupyter_dir.mkdir(parents=True, exist_ok=True) -name = "jupyverse" -if auth_config.test: - name += "_test" -secret_path = jupyter_dir / f"{name}_secret" -userdb_path = jupyter_dir / f"{name}_users.db" +logger = logging.getLogger("auth") -if auth_config.clear_users: - if userdb_path.is_file(): - userdb_path.unlink() - if secret_path.is_file(): - secret_path.unlink() - -if not secret_path.is_file(): - secret_path.write_text(secrets.token_hex(32)) - -secret = secret_path.read_text() - - -DATABASE_URL = f"sqlite+aiosqlite:///{userdb_path}" Base: DeclarativeMeta = declarative_base() @@ -61,19 +42,57 @@ class User(SQLAlchemyBaseUserTableUUID, Base): oauth_accounts: List[OAuthAccount] = relationship("OAuthAccount", lazy="joined") -engine = create_async_engine(DATABASE_URL) -async_session_maker = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - -async def create_db_and_tables(): - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - -async def get_async_session() -> AsyncGenerator[AsyncSession, None]: - async with async_session_maker() as session: - yield session - - -async def get_user_db(session: AsyncSession = Depends(get_async_session)): - yield SQLAlchemyUserDatabase(session, User, OAuthAccount) +@dataclass +class Res: + User: Any + async_session_maker: Any + create_db_and_tables: Any + get_async_session: Any + get_user_db: Any + secret: Any + + +def get_db(auth_config: _AuthConfig) -> Res: + jupyter_dir = Path.home() / ".local" / "share" / "jupyter" + jupyter_dir.mkdir(parents=True, exist_ok=True) + name = "jupyverse" + if auth_config.test: + name += "_test" + secret_path = jupyter_dir / f"{name}_secret" + userdb_path = jupyter_dir / f"{name}_users.db" + + if auth_config.clear_users: + if userdb_path.is_file(): + userdb_path.unlink() + if secret_path.is_file(): + secret_path.unlink() + + if not secret_path.is_file(): + secret_path.write_text(secrets.token_hex(32)) + + secret = secret_path.read_text() + + database_url = f"sqlite+aiosqlite:///{userdb_path}" + + engine = create_async_engine(database_url) + async_session_maker = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + async def create_db_and_tables(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async def get_async_session() -> AsyncGenerator[AsyncSession, None]: + async with async_session_maker() as session: + yield session + + async def get_user_db(session: AsyncSession = Depends(get_async_session)): + yield SQLAlchemyUserDatabase(session, User, OAuthAccount) + + return Res( + User=User, + async_session_maker=async_session_maker, + create_db_and_tables=create_db_and_tables, + get_async_session=get_async_session, + get_user_db=get_user_db, + secret=secret, + ) diff --git a/plugins/auth/fps_auth/fixtures.py b/plugins/auth/fps_auth/fixtures.py deleted file mode 100644 index 94956c6d..00000000 --- a/plugins/auth/fps_auth/fixtures.py +++ /dev/null @@ -1,84 +0,0 @@ -from uuid import uuid4 - -import pytest # type: ignore - -from .config import AuthConfig, get_auth_config - - -@pytest.fixture -def auth_mode(): - return "token" - - -@pytest.fixture -def auth_config(auth_mode): - yield AuthConfig.parse_obj({"mode": auth_mode, "test": True}) - - -@pytest.fixture -def config_override(app, auth_config): - async def override_get_config(): - return auth_config - - app.dependency_overrides[get_auth_config] = override_get_config - - -@pytest.fixture() -def permissions(): - return {} - - -@pytest.fixture() -def authenticated_client(client, permissions): - # create a new user - username = uuid4().hex - # if logged in, log out - first_time = True - while True: - response = client.get("/api/me") - if response.status_code == 403: - break - assert first_time - response = client.post("/auth/logout") - assert response.status_code == 200 - first_time = False - - # register user - register_body = { - "email": username + "@example.com", - "password": username, - "username": username, - "permissions": permissions, - } - response = client.post("/auth/register", json=register_body) - # check that we cannot register if not logged in - assert response.status_code == 403 - # log in as admin - login_body = {"username": "admin@jupyter.com", "password": "jupyverse"} - response = client.post("/auth/login", data=login_body) - assert response.status_code == 200 - # register user - response = client.post("/auth/register", json=register_body) - assert response.status_code == 201 - - # log out - response = client.post("/auth/logout") - assert response.status_code == 200 - # check that we can't get our identity, since we're not logged in - response = client.get("/api/me") - assert response.status_code == 403 - - # log in with registered user - login_body = {"username": username + "@example.com", "password": username} - response = client.post("/auth/login", data=login_body) - assert response.status_code == 200 - # we should now have a cookie - assert "fastapiusersauth" in client.cookies - # check our identity, since we're logged in - response = client.get("/api/me", params={"permissions": permissions}) - assert response.status_code == 200 - me = response.json() - assert me["identity"]["username"] == username - # check our permissions - assert me["permissions"] == permissions - yield client diff --git a/plugins/auth/fps_auth/main.py b/plugins/auth/fps_auth/main.py new file mode 100644 index 00000000..6c42a956 --- /dev/null +++ b/plugins/auth/fps_auth/main.py @@ -0,0 +1,64 @@ +import logging + +from asphalt.core import Component, Context +from fastapi_users.exceptions import UserAlreadyExists +from jupyverse_api.auth import Auth, AuthConfig +from jupyverse_api.frontend import FrontendConfig +from jupyverse_api.app import App + +from .config import _AuthConfig +from .routes import auth_factory + + +logger = logging.getLogger("auth") + + +class AuthComponent(Component): + def __init__(self, **kwargs): + self.auth_config = _AuthConfig(**kwargs) + + async def start( + self, + ctx: Context, + ) -> None: + ctx.add_resource(self.auth_config, types=AuthConfig) + + app = await ctx.request_resource(App) + frontend_config = await ctx.request_resource(FrontendConfig) + + auth = auth_factory(app, self.auth_config, frontend_config) + ctx.add_resource(auth, types=Auth) + + await auth.db.create_db_and_tables() + + if self.auth_config.test: + try: + await auth.create_user( + username="admin@jupyter.com", + email="admin@jupyter.com", + password="jupyverse", + permissions={"admin": ["read", "write"]}, + ) + except UserAlreadyExists: + pass + + try: + await auth.create_user( + username=self.auth_config.token, + email=self.auth_config.global_email, + password="", + permissions={}, + ) + except UserAlreadyExists: + global_user = await auth.get_user_by_email(self.auth_config.global_email) + await auth._update_user( + global_user, + username=self.auth_config.token, + permissions={}, + ) + + if self.auth_config.mode == "token": + logger.info("") + logger.info("To access the server, copy and paste this URL:") + logger.info(f"http://127.0.0.1:8000/?token={self.auth_config.token}") + logger.info("") diff --git a/plugins/auth/fps_auth/models.py b/plugins/auth/fps_auth/models.py index 4354d008..92295dcf 100644 --- a/plugins/auth/fps_auth/models.py +++ b/plugins/auth/fps_auth/models.py @@ -1,24 +1,13 @@ import uuid -from typing import Dict, List, Optional +from typing import Dict, List from fastapi_users import schemas -from pydantic import BaseModel +from jupyverse_api.auth import User -class Permissions(BaseModel): - permissions: Dict[str, List[str]] - - -class JupyterUser(Permissions): +class JupyterUser(User): anonymous: bool = True - username: str = "" - name: str = "" - display_name: str = "" - initials: Optional[str] = None - color: Optional[str] = None - avatar_url: Optional[str] = None - workspace: str = "{}" - settings: str = "{}" + permissions: Dict[str, List[str]] class UserRead(schemas.BaseUser[uuid.UUID], JupyterUser): diff --git a/plugins/auth/fps_auth/py.typed b/plugins/auth/fps_auth/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/plugins/auth/fps_auth/routes.py b/plugins/auth/fps_auth/routes.py index a71d2eb7..fb1bd8ca 100644 --- a/plugins/auth/fps_auth/routes.py +++ b/plugins/auth/fps_auth/routes.py @@ -1,171 +1,150 @@ import contextlib import json -from typing import Dict, List +import logging +from typing import Any, Callable, Dict, List, Optional, Tuple from fastapi import APIRouter, Depends, Request -from fastapi_users.exceptions import UserAlreadyExists -from fps.config import get_config # type: ignore -from fps.hooks import register_router # type: ignore -from fps.logging import get_configured_logger # type: ignore -from fps_uvicorn.cli import add_query_params # type: ignore -from fps_uvicorn.config import UvicornConfig # type: ignore +from jupyverse_api import Router +from jupyverse_api.app import App +from jupyverse_api.auth import Auth +from jupyverse_api.frontend import FrontendConfig from sqlalchemy import select # type: ignore -from .backends import ( - cookie_authentication, - current_user, - fapi_users, - get_user_manager, - github_authentication, - github_cookie_authentication, -) -from .config import get_auth_config -from .db import ( - User, - async_session_maker, - create_db_and_tables, - get_async_session, - get_user_db, - secret, -) -from .models import UserCreate, UserRead, UserUpdate - -logger = get_configured_logger("auth") - -auth_config = get_auth_config() -if auth_config.mode == "token": - add_query_params({"token": auth_config.token}) - -router = APIRouter() - - -get_async_session_context = contextlib.asynccontextmanager(get_async_session) -get_user_db_context = contextlib.asynccontextmanager(get_user_db) -get_user_manager_context = contextlib.asynccontextmanager(get_user_manager) +from .backends import get_backend +from .config import _AuthConfig +from .db import get_db +from .models import UserCreate, UserRead, UserUpdate -@contextlib.asynccontextmanager -async def _get_user_manager(): - async with get_async_session_context() as session: - async with get_user_db_context(session) as user_db: - async with get_user_manager_context(user_db) as user_manager: - yield user_manager +logger = logging.getLogger("auth") -async def create_user(**kwargs): - async with _get_user_manager() as user_manager: - await user_manager.create(UserCreate(**kwargs)) +def auth_factory( + app: App, + auth_config: _AuthConfig, + frontend_config: FrontendConfig, +): + db = get_db(auth_config) + backend = get_backend(auth_config, frontend_config, db) + + get_async_session_context = contextlib.asynccontextmanager(db.get_async_session) + get_user_db_context = contextlib.asynccontextmanager(db.get_user_db) + get_user_manager_context = contextlib.asynccontextmanager(backend.get_user_manager) + + @contextlib.asynccontextmanager + async def _get_user_manager(): + async with get_async_session_context() as session: + async with get_user_db_context(session) as user_db: + async with get_user_manager_context(user_db) as user_manager: + yield user_manager + + async def create_user(**kwargs): + async with _get_user_manager() as user_manager: + await user_manager.create(UserCreate(**kwargs)) + + async def update_user(user, **kwargs): + async with _get_user_manager() as user_manager: + await user_manager.update(UserUpdate(**kwargs), user) + + async def get_user_by_email(user_email): + async with _get_user_manager() as user_manager: + return await user_manager.get_by_email(user_email) + + class _Auth(Auth, Router): + def __init__(self) -> None: + super().__init__(app) + + self.db = db + + router = APIRouter() + + @router.get("/auth/users") + async def get_users( + user: UserRead = Depends(backend.current_user(permissions={"admin": ["read"]})), + ): + async with db.async_session_maker() as session: + statement = select(db.User) + users = (await session.execute(statement)).unique().all() + return [usr.User for usr in users if usr.User.is_active] + + @router.get("/api/me") + async def get_api_me( + request: Request, + user: UserRead = Depends(backend.current_user()), + ): + checked_permissions: Dict[str, List[str]] = {} + permissions = json.loads( + dict(request.query_params).get("permissions", "{}").replace("'", '"') + ) + if permissions: + user_permissions = user.permissions + for resource, actions in permissions.items(): + user_resource_permissions = user_permissions.get(resource) + if user_resource_permissions is None: + continue + allowed = checked_permissions[resource] = [] + for action in actions: + if action in user_resource_permissions: + allowed.append(action) + + keys = ["username", "name", "display_name", "initials", "avatar_url", "color"] + identity = {k: getattr(user, k) for k in keys} + return { + "identity": identity, + "permissions": checked_permissions, + } + + # redefine GET /me because we want our current_user dependency + # it is first defined in users_router and so it wins over the one in + # fapi_users.get_users_router + users_router = APIRouter() + + @users_router.get("/me") + async def get_me( + user: UserRead = Depends(backend.current_user(permissions={"admin": ["read"]})), + ): + return user + + users_router.include_router(backend.fapi_users.get_users_router(UserRead, UserUpdate)) + + # Cookie based auth login and logout + self.include_router( + backend.fapi_users.get_auth_router(backend.cookie_authentication), prefix="/auth" + ) + self.include_router( + backend.fapi_users.get_register_router(UserRead, UserCreate), + prefix="/auth", + dependencies=[Depends(backend.current_user(permissions={"admin": ["write"]}))], + ) + self.include_router(users_router, prefix="/auth/user") + + # GitHub OAuth register router + self.include_router( + backend.fapi_users.get_oauth_router( + backend.github_authentication, backend.github_cookie_authentication, db.secret + ), + prefix="/auth/github", + ) + self.include_router(router) -async def update_user(user, **kwargs): - async with _get_user_manager() as user_manager: - await user_manager.update(UserUpdate(**kwargs), user) + self.create_user = create_user + self.__update_user = update_user + self.get_user_by_email = get_user_by_email + async def _update_user(self, user, **kwargs): + return await self.__update_user(user, **kwargs) -async def get_user_by_email(user_email): - async with _get_user_manager() as user_manager: - return await user_manager.get_by_email(user_email) + def current_user(self, permissions: Optional[Dict[str, List[str]]] = None) -> Callable: + return backend.current_user(permissions) + async def update_user(self, update_user=Depends(backend.update_user)) -> Callable: + return update_user -@router.on_event("startup") -async def startup(): - await create_db_and_tables() + def websocket_auth( + self, + permissions: Optional[Dict[str, List[str]]] = None, + ) -> Callable[[], Tuple[Any, Dict[str, List[str]]]]: + return backend.websocket_auth(permissions) - if auth_config.test: - try: - await create_user( - username="admin@jupyter.com", - email="admin@jupyter.com", - password="jupyverse", - permissions={"admin": ["read", "write"]}, - ) - except UserAlreadyExists: - pass - - try: - await create_user( - username=auth_config.token, - email=auth_config.global_email, - password="", - permissions={}, - ) - except UserAlreadyExists: - global_user = await get_user_by_email(auth_config.global_email) - await update_user( - global_user, - username=auth_config.token, - permissions={}, - ) - - if auth_config.mode == "token": - uvicorn_config = get_config(UvicornConfig) - logger.info("") - logger.info("To access the server, copy and paste this URL:") - logger.info( - f"http://{uvicorn_config.host}:{uvicorn_config.port}/?token={auth_config.token}" - ) - logger.info("") - - -@router.get("/auth/users") -async def get_users(user: UserRead = Depends(current_user(permissions={"admin": ["read"]}))): - async with async_session_maker() as session: - statement = select(User) - users = (await session.execute(statement)).unique().all() - return [usr.User for usr in users if usr.User.is_active] - - -@router.get("/api/me") -async def get_api_me( - request: Request, - user: UserRead = Depends(current_user()), -): - checked_permissions: Dict[str, List[str]] = {} - permissions = json.loads(dict(request.query_params).get("permissions", "{}").replace("'", '"')) - if permissions: - user_permissions = user.permissions - for resource, actions in permissions.items(): - user_resource_permissions = user_permissions.get(resource) - if user_resource_permissions is None: - continue - allowed = checked_permissions[resource] = [] - for action in actions: - if action in user_resource_permissions: - allowed.append(action) - - keys = ["username", "name", "display_name", "initials", "avatar_url", "color"] - identity = {k: getattr(user, k) for k in keys} - return { - "identity": identity, - "permissions": checked_permissions, - } - - -# redefine GET /me because we want our current_user dependency -# it is first defined in users_router and so it wins over the one in fapi_users.get_users_router -users_router = APIRouter() - - -@users_router.get("/me") -async def get_me(user: UserRead = Depends(current_user(permissions={"admin": ["read"]}))): - return user - - -users_router.include_router(fapi_users.get_users_router(UserRead, UserUpdate)) - -# Cookie based auth login and logout -r_cookie_auth = register_router(fapi_users.get_auth_router(cookie_authentication), prefix="/auth") -r_register = register_router( - fapi_users.get_register_router(UserRead, UserCreate), - prefix="/auth", - dependencies=[Depends(current_user(permissions={"admin": ["write"]}))], -) -r_user = register_router(users_router, prefix="/auth/user") - -# GitHub OAuth register router -r_github = register_router( - fapi_users.get_oauth_router(github_authentication, github_cookie_authentication, secret), - prefix="/auth/github", -) - -r = register_router(router) + return _Auth() diff --git a/plugins/auth/pyproject.toml b/plugins/auth/pyproject.toml index be75444e..60e8b9c5 100644 --- a/plugins/auth/pyproject.toml +++ b/plugins/auth/pyproject.toml @@ -5,13 +5,10 @@ build-backend = "hatchling.build" [project] name = "fps_auth" description = "An FPS plugin for the authentication API" -keywords = ["jupyter", "server", "fastapi", "pluggy", "plugins"] +keywords = ["jupyter", "server", "fastapi", "plugins"] dynamic = ["version"] requires-python = ">=3.8" dependencies = [ - "fps[uvicorn] >=0.0.17", - "fps-lab", - "fps-login", "aiosqlite", "fastapi-users[sqlalchemy,oauth] >=10.1.4,<11", "sqlalchemy >=1,<2", @@ -37,17 +34,8 @@ ignore = [ ".*",] [tool.jupyter-releaser] skip = [ "check-links",] -[project.entry-points.fps_router] -fps-auth = "fps_auth.routes" - -[project.entry-points.fps_config] -fps-auth = "fps_auth.config" - -[project.entry-points.jupyverse_auth] -User = "fps_auth.models:UserRead" -current_user = "fps_auth.backends:current_user" -update_user = "fps_auth.backends:update_user" -websocket_auth = "fps_auth.backends:websocket_auth" +[project.entry-points."asphalt.components"] +auth = "fps_auth.main:AuthComponent" [tool.hatch.version] path = "fps_auth/__init__.py" diff --git a/plugins/auth_base/MANIFEST.in b/plugins/auth_base/MANIFEST.in deleted file mode 100644 index efa752ea..00000000 --- a/plugins/auth_base/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include *.md diff --git a/plugins/auth_base/README.md b/plugins/auth_base/README.md deleted file mode 100644 index 9abafd16..00000000 --- a/plugins/auth_base/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# fps-auth-base - -An FPS plugin for the authentication API. diff --git a/plugins/auth_base/fps_auth_base/__init__.py b/plugins/auth_base/fps_auth_base/__init__.py deleted file mode 100644 index 40495029..00000000 --- a/plugins/auth_base/fps_auth_base/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -import pkg_resources - -__version__ = "0.0.50" - -auth = {ep.name: ep.load() for ep in pkg_resources.iter_entry_points(group="jupyverse_auth")} - -try: - User = auth["User"] - current_user = auth["current_user"] - update_user = auth["update_user"] - websocket_auth = auth["websocket_auth"] -except KeyError: - raise RuntimeError( - "An auth plugin must be installed, for instance: pip install fps-auth", - ) diff --git a/plugins/auth_base/pyproject.toml b/plugins/auth_base/pyproject.toml deleted file mode 100644 index 3f063ca4..00000000 --- a/plugins/auth_base/pyproject.toml +++ /dev/null @@ -1,34 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "fps_auth_base" -description = "An FPS plugin for the authentication API" -keywords = [ "jupyter", "server", "fastapi", "pluggy", "plugins",] -requires-python = ">=3.8" -dependencies = ["fps >=0.0.17"] -dynamic = [ "version",] - -[[project.authors]] -name = "Jupyter Development Team" -email = "jupyter@googlegroups.com" - -[project.readme] -file = "README.md" -content-type = "text/markdown" - -[project.license] -text = "BSD 3-Clause License" - -[project.urls] -Homepage = "https://jupyter.org" - -[tool.check-manifest] -ignore = [ ".*",] - -[tool.jupyter-releaser] -skip = [ "check-links",] - -[tool.hatch.version] -path = "fps_auth_base/__init__.py" diff --git a/plugins/auth_fief/fps_auth_fief/backend.py b/plugins/auth_fief/fps_auth_fief/backend.py index a4d398aa..05c09871 100644 --- a/plugins/auth_fief/fps_auth_fief/backend.py +++ b/plugins/auth_fief/fps_auth_fief/backend.py @@ -4,89 +4,93 @@ from fastapi.security import APIKeyCookie from fief_client import FiefAccessTokenInfo, FiefAsync, FiefUserInfo from fief_client.integrations.fastapi import FiefAuth +from jupyverse_api.auth import User -from .config import get_auth_fief_config -from .models import UserRead +from .config import _AuthFiefConfig -class CustomFiefAuth(FiefAuth): - client: FiefAsync +class Backend: + def __init__(self, auth_fief_config: _AuthFiefConfig): + class CustomFiefAuth(FiefAuth): + client: FiefAsync - async def get_unauthorized_response(self, request: Request, response: Response): - redirect_uri = request.url_for("auth_callback") - auth_url = await self.client.auth_url(redirect_uri, scope=["openid"]) - raise HTTPException( - status_code=status.HTTP_307_TEMPORARY_REDIRECT, - headers={"Location": auth_url}, - ) - - -auth_fief_config = get_auth_fief_config() - -fief = FiefAsync( - auth_fief_config.base_url, - auth_fief_config.client_id, - auth_fief_config.client_secret.get_secret_value(), -) - -SESSION_COOKIE_NAME = "fps_auth_fief_user_session" -scheme = APIKeyCookie(name=SESSION_COOKIE_NAME, auto_error=False) -auth = CustomFiefAuth(fief, scheme) - - -async def update_user( - user: FiefUserInfo = Depends(auth.current_user()), - access_token_info: FiefAccessTokenInfo = Depends(auth.authenticated()), -): - async def _(data: Dict[str, Any]) -> FiefUserInfo: - user = await fief.update_profile(access_token_info["access_token"], {"fields": data}) - return user + async def get_unauthorized_response(self, request: Request, response: Response): + redirect_uri = str(request.url_for("auth_callback")) + auth_url = await self.client.auth_url(redirect_uri, scope=["openid"]) + raise HTTPException( + status_code=status.HTTP_307_TEMPORARY_REDIRECT, + headers={"Location": auth_url}, + ) - return _ - - -def websocket_auth(permissions: Optional[Dict[str, List[str]]] = None): - async def _( - websocket: WebSocket, - ) -> Optional[Tuple[WebSocket, Optional[Dict[str, List[str]]]]]: - accept_websocket = False - checked_permissions: Optional[Dict[str, List[str]]] = None - if SESSION_COOKIE_NAME in websocket._cookies: - access_token = websocket._cookies[SESSION_COOKIE_NAME] - if permissions is None: - accept_websocket = True - else: - checked_permissions = {} - for resource, actions in permissions.items(): - allowed = checked_permissions[resource] = [] - for action in actions: - try: - await fief.validate_access_token( - access_token, required_permissions=[f"{resource}:{action}"] - ) - except BaseException: - pass - else: - allowed.append(action) - accept_websocket = True - if accept_websocket: - return websocket, checked_permissions - else: - await websocket.close(code=status.WS_1008_POLICY_VIOLATION) - return None - - return _ - - -def current_user(permissions=None): - if permissions is not None: - permissions = [ - f"{resource}:{action}" - for resource, actions in permissions.items() - for action in actions - ] - - async def _(user: FiefUserInfo = Depends(auth.current_user(permissions=permissions))): - return UserRead(**user["fields"]) + self.fief = FiefAsync( + auth_fief_config.base_url, + auth_fief_config.client_id, + auth_fief_config.client_secret, + ) - return _ + self.SESSION_COOKIE_NAME = "fps_auth_fief_user_session" + scheme = APIKeyCookie(name=self.SESSION_COOKIE_NAME, auto_error=False) + self.auth = CustomFiefAuth(self.fief, scheme) + + async def update_user( + user: FiefUserInfo = Depends(self.auth.current_user()), + access_token_info: FiefAccessTokenInfo = Depends(self.auth.authenticated()), + ): + async def _(data: Dict[str, Any]) -> FiefUserInfo: + user = await self.fief.update_profile( + access_token_info["access_token"], {"fields": data} + ) + return user + + return _ + + def websocket_auth(permissions: Optional[Dict[str, List[str]]] = None): + async def _( + websocket: WebSocket, + ) -> Optional[Tuple[WebSocket, Optional[Dict[str, List[str]]]]]: + accept_websocket = False + checked_permissions: Optional[Dict[str, List[str]]] = None + if self.SESSION_COOKIE_NAME in websocket._cookies: + access_token = websocket._cookies[self.SESSION_COOKIE_NAME] + if permissions is None: + accept_websocket = True + else: + checked_permissions = {} + for resource, actions in permissions.items(): + allowed = checked_permissions[resource] = [] + for action in actions: + try: + await self.fief.validate_access_token( + access_token, required_permissions=[f"{resource}:{action}"] + ) + except BaseException: + pass + else: + allowed.append(action) + accept_websocket = True + if accept_websocket: + return websocket, checked_permissions + else: + await websocket.close(code=status.WS_1008_POLICY_VIOLATION) + return None + + return _ + + def current_user(permissions=None): + if permissions is not None: + permissions = [ + f"{resource}:{action}" + for resource, actions in permissions.items() + for action in actions + ] + + async def _( + user: FiefUserInfo = Depends(self.auth.current_user(permissions=permissions)), + ): + return User(**user["fields"]) + + return _ + + self.current_user = current_user + self.update_user = update_user + self.websocket_auth = websocket_auth diff --git a/plugins/auth_fief/fps_auth_fief/config.py b/plugins/auth_fief/fps_auth_fief/config.py index eb08e190..09978f8b 100644 --- a/plugins/auth_fief/fps_auth_fief/config.py +++ b/plugins/auth_fief/fps_auth_fief/config.py @@ -1,23 +1,7 @@ -from fps.config import PluginModel, get_config -from fps.hooks import register_config -from pydantic import BaseSettings, SecretStr +from jupyverse_api.auth import AuthConfig -class AuthFiefConfig(PluginModel, BaseSettings): +class _AuthFiefConfig(AuthConfig): base_url: str # Base URL of Fief tenant client_id: str # ID of Fief client - client_secret: SecretStr # Secret of Fief client - - class Config(PluginModel.Config): - env_prefix = "fps_auth_fief_" - # config can be set with environment variables, e.g.: - # export FPS_AUTH_FIEF_BASE_URL=https://jupyverse.fief.dev - # export FPS_AUTH_FIEF_CLIENT_ID=my_client_id - # export FPS_AUTH_FIEF_CLIENT_SECRET=my_client_secret - - -def get_auth_fief_config(): - return get_config(AuthFiefConfig) - - -c = register_config(AuthFiefConfig) + client_secret: str # Secret of Fief client diff --git a/plugins/auth_fief/fps_auth_fief/main.py b/plugins/auth_fief/fps_auth_fief/main.py new file mode 100644 index 00000000..f688fa54 --- /dev/null +++ b/plugins/auth_fief/fps_auth_fief/main.py @@ -0,0 +1,22 @@ +from asphalt.core import Component, Context +from jupyverse_api.auth import Auth, AuthConfig +from jupyverse_api.app import App + +from .config import _AuthFiefConfig +from .routes import _AuthFief + + +class AuthFiefComponent(Component): + def __init__(self, **kwargs): + self.auth_fief_config = _AuthFiefConfig(**kwargs) + + async def start( + self, + ctx: Context, + ) -> None: + ctx.add_resource(self.auth_fief_config, types=AuthConfig) + + app = await ctx.request_resource(App) + + auth_fief = _AuthFief(app, self.auth_fief_config) + ctx.add_resource(auth_fief, types=Auth) diff --git a/plugins/auth_fief/fps_auth_fief/models.py b/plugins/auth_fief/fps_auth_fief/models.py deleted file mode 100644 index 89786c57..00000000 --- a/plugins/auth_fief/fps_auth_fief/models.py +++ /dev/null @@ -1,18 +0,0 @@ -from typing import Dict, List, Optional - -from pydantic import BaseModel - - -class Permissions(BaseModel): - permissions: Dict[str, List[str]] - - -class UserRead(BaseModel): - username: str = "" - name: str = "" - display_name: str = "" - initials: Optional[str] = None - color: Optional[str] = None - avatar_url: Optional[str] = None - workspace: str = "{}" - settings: str = "{}" diff --git a/plugins/auth_fief/fps_auth_fief/routes.py b/plugins/auth_fief/fps_auth_fief/routes.py index 6d7cbe81..d388c49d 100644 --- a/plugins/auth_fief/fps_auth_fief/routes.py +++ b/plugins/auth_fief/fps_auth_fief/routes.py @@ -4,59 +4,70 @@ from fastapi import APIRouter, Depends, Query, Request, Response from fastapi.responses import RedirectResponse from fief_client import FiefAccessTokenInfo -from fps.hooks import register_router - -from .backend import SESSION_COOKIE_NAME, auth, current_user, fief -from .models import UserRead - -router = APIRouter() - - -@router.get("/auth-callback", name="auth_callback") -async def auth_callback(request: Request, response: Response, code: str = Query(...)): - redirect_uri = request.url_for("auth_callback") - tokens, _ = await fief.auth_callback(code, redirect_uri) - - response = RedirectResponse(request.url_for("root")) - response.set_cookie( - SESSION_COOKIE_NAME, - tokens["access_token"], - max_age=tokens["expires_in"], - httponly=True, - secure=False, - ) - - return response - - -@router.get("/api/me") -async def get_api_me( - request: Request, - user: UserRead = Depends(current_user()), - access_token_info: FiefAccessTokenInfo = Depends(auth.authenticated()), -): - checked_permissions: Dict[str, List[str]] = {} - permissions = json.loads(dict(request.query_params).get("permissions", "{}").replace("'", '"')) - if permissions: - user_permissions = {} - for permission in access_token_info["permissions"]: - resource, action = permission.split(":") - if resource not in user_permissions.keys(): - user_permissions[resource] = [] - user_permissions[resource].append(action) - for resource, actions in permissions.items(): - user_resource_permissions = user_permissions.get(resource, []) - allowed = checked_permissions[resource] = [] - for action in actions: - if action in user_resource_permissions: - allowed.append(action) - - keys = ["username", "name", "display_name", "initials", "avatar_url", "color"] - identity = {k: getattr(user, k) for k in keys} - return { - "identity": identity, - "permissions": checked_permissions, - } - - -r = register_router(router) +from jupyverse_api import Router +from jupyverse_api.app import App +from jupyverse_api.auth import Auth, User + +from .backend import Backend +from .config import _AuthFiefConfig + + +class _AuthFief(Backend, Auth, Router): + def __init__( + self, + app: App, + auth_fief_config: _AuthFiefConfig, + ) -> None: + Router.__init__(self, app) + Backend.__init__(self, auth_fief_config) + + router = APIRouter() + + @router.get("/auth-callback", name="auth_callback") + async def auth_callback(request: Request, response: Response, code: str = Query(...)): + redirect_uri = str(request.url_for("auth_callback")) + tokens, _ = await self.fief.auth_callback(code, redirect_uri) + + response = RedirectResponse(request.url_for("root")) + response.set_cookie( + self.SESSION_COOKIE_NAME, + tokens["access_token"], + max_age=tokens["expires_in"], + httponly=True, + secure=False, + ) + + return response + + @router.get("/api/me") + async def get_api_me( + request: Request, + user: User = Depends(self.current_user()), + access_token_info: FiefAccessTokenInfo = Depends(self.auth.authenticated()), + ): + checked_permissions: Dict[str, List[str]] = {} + permissions = json.loads( + dict(request.query_params).get("permissions", "{}").replace("'", '"') + ) + if permissions: + user_permissions: Dict[str, List[str]] = {} + for permission in access_token_info["permissions"]: + resource, action = permission.split(":") + if resource not in user_permissions: + user_permissions[resource] = [] + user_permissions[resource].append(action) + for resource, actions in permissions.items(): + user_resource_permissions = user_permissions.get(resource, []) + allowed = checked_permissions[resource] = [] + for action in actions: + if action in user_resource_permissions: + allowed.append(action) + + keys = ["username", "name", "display_name", "initials", "avatar_url", "color"] + identity = {k: getattr(user, k) for k in keys} + return { + "identity": identity, + "permissions": checked_permissions, + } + + self.include_router(router) diff --git a/plugins/auth_fief/pyproject.toml b/plugins/auth_fief/pyproject.toml index 7ed0d773..60f20834 100644 --- a/plugins/auth_fief/pyproject.toml +++ b/plugins/auth_fief/pyproject.toml @@ -5,12 +5,11 @@ build-backend = "hatchling.build" [project] name = "fps_auth_fief" description = "An FPS plugin for the authentication API, using Fief" -keywords = ["jupyter", "server", "fastapi", "pluggy", "plugins"] +keywords = ["jupyter", "server", "fastapi", "plugins"] dynamic = ["version"] requires-python = ">=3.8" dependencies = [ "fief-client[fastapi]", - "fps >=0.0.8" ] [[project.authors]] @@ -33,11 +32,8 @@ ignore = [ ".*",] [tool.jupyter-releaser] skip = [ "check-links",] -[project.entry-points.fps_router] -fps-auth-fief = "fps_auth_fief.routes" - -[project.entry-points.fps_config] -fps-auth-fief = "fps_auth_fief.config" +[project.entry-points."asphalt.components"] +auth_fief = "fps_auth_fief.main:AuthFiefComponent" [project.entry-points.jupyverse_auth] User = "fps_auth_fief.models:UserRead" diff --git a/plugins/contents/fps_contents/fileid.py b/plugins/contents/fps_contents/fileid.py index b2510426..89b20a93 100644 --- a/plugins/contents/fps_contents/fileid.py +++ b/plugins/contents/fps_contents/fileid.py @@ -1,14 +1,14 @@ import asyncio +import logging from typing import Dict, List, Optional from uuid import uuid4 import aiosqlite from anyio import Path -from fps.logging import get_configured_logger # type: ignore +from jupyverse_api import Singleton from watchfiles import Change, awatch -watchfiles_logger = get_configured_logger("watchfiles.main", "warning") -logger = get_configured_logger("contents") +logger = logging.getLogger("contents") class Watcher: @@ -29,121 +29,136 @@ def notify(self, change): self._event.set() -class Singleton(type): - _instances: Dict = {} - - def __call__(cls, *args, **kwargs): - if cls not in cls._instances: - cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) - return cls._instances[cls] - - class FileIdManager(metaclass=Singleton): db_path: str initialized: asyncio.Event watchers: Dict[str, List[Watcher]] + lock: asyncio.Lock def __init__(self, db_path: str = "fileid.db"): self.db_path = db_path self.initialized = asyncio.Event() self.watchers = {} - self._watch_files_task = asyncio.create_task(self.watch_files()) + self.watch_files_task = asyncio.create_task(self.watch_files()) + self.stop_watching_files = asyncio.Event() + self.stopped_watching_files = asyncio.Event() + self.lock = asyncio.Lock() async def get_id(self, path: str) -> Optional[str]: await self.initialized.wait() - async with aiosqlite.connect(self.db_path) as db: - async with db.execute("SELECT id FROM fileids WHERE path = ?", (path,)) as cursor: - async for idx, in cursor: - return idx - return None + async with self.lock: + async with aiosqlite.connect(self.db_path) as db: + async with db.execute("SELECT id FROM fileids WHERE path = ?", (path,)) as cursor: + async for idx, in cursor: + return idx + return None async def get_path(self, idx: str) -> Optional[str]: await self.initialized.wait() - async with aiosqlite.connect(self.db_path) as db: - async with db.execute("SELECT path FROM fileids WHERE id = ?", (idx,)) as cursor: - async for path, in cursor: - return path - return None + async with self.lock: + async with aiosqlite.connect(self.db_path) as db: + async with db.execute("SELECT path FROM fileids WHERE id = ?", (idx,)) as cursor: + async for path, in cursor: + return path + return None async def index(self, path: str) -> Optional[str]: await self.initialized.wait() - async with aiosqlite.connect(self.db_path) as db: - apath = Path(path) - if not await apath.exists(): - return None + async with self.lock: + async with aiosqlite.connect(self.db_path) as db: + apath = Path(path) + if not await apath.exists(): + return None - idx = uuid4().hex - mtime = (await apath.stat()).st_mtime - await db.execute("INSERT INTO fileids VALUES (?, ?, ?)", (idx, path, mtime)) - await db.commit() - return idx + idx = uuid4().hex + mtime = (await apath.stat()).st_mtime + await db.execute("INSERT INTO fileids VALUES (?, ?, ?)", (idx, path, mtime)) + await db.commit() + return idx async def watch_files(self): - async with aiosqlite.connect(self.db_path) as db: - await db.execute("DROP TABLE IF EXISTS fileids") - await db.execute( - "CREATE TABLE fileids " - "(id TEXT PRIMARY KEY, path TEXT NOT NULL UNIQUE, mtime REAL NOT NULL)" - ) - await db.commit() + async with self.lock: + async with aiosqlite.connect(self.db_path) as db: + await db.execute("DROP TABLE IF EXISTS fileids") + await db.execute( + "CREATE TABLE fileids " + "(id TEXT PRIMARY KEY, path TEXT NOT NULL UNIQUE, mtime REAL NOT NULL)" + ) + await db.commit() # index files - async with aiosqlite.connect(self.db_path) as db: - async for path in Path().rglob("*"): - idx = uuid4().hex - mtime = (await path.stat()).st_mtime - await db.execute("INSERT INTO fileids VALUES (?, ?, ?)", (idx, str(path), mtime)) - await db.commit() - self.initialized.set() - - async for changes in awatch("."): - deleted_paths = [] - added_paths = [] - for change, changed_path in changes: - # get relative path - changed_path = Path(changed_path).relative_to(await Path().absolute()) - changed_path_str = str(changed_path) - - if change == Change.deleted: - logger.debug("File %s was deleted", changed_path_str) - async with db.execute( - "SELECT COUNT(*) FROM fileids WHERE path = ?", (changed_path_str,) - ) as cursor: - if not (await cursor.fetchone())[0]: - # path is not indexed, ignore - logger.debug("File %s is not indexed, ignoring", changed_path_str) - continue - # path is indexed - await maybe_rename(db, changed_path_str, deleted_paths, added_paths, False) - elif change == Change.added: - logger.debug("File %s was added", changed_path_str) - await maybe_rename(db, changed_path_str, added_paths, deleted_paths, True) - elif change == Change.modified: - logger.debug("File %s was modified", changed_path_str) - if changed_path_str == self.db_path: - continue - async with db.execute( - "SELECT COUNT(*) FROM fileids WHERE path = ?", (changed_path_str,) - ) as cursor: - if not (await cursor.fetchone())[0]: - # path is not indexed, ignore - logger.debug("File %s is not indexed, ignoring", changed_path_str) - continue - mtime = (await changed_path.stat()).st_mtime - await db.execute( - "UPDATE fileids SET mtime = ? WHERE path = ?", (mtime, changed_path_str) - ) - - for path in deleted_paths + added_paths: - await db.execute("DELETE FROM fileids WHERE path = ?", (path,)) + async with self.lock: + async with aiosqlite.connect(self.db_path) as db: + async for path in Path().rglob("*"): + idx = uuid4().hex + mtime = (await path.stat()).st_mtime + await db.execute( + "INSERT INTO fileids VALUES (?, ?, ?)", (idx, str(path), mtime) + ) await db.commit() - - for change in changes: - changed_path = change[1] - # get relative path - changed_path = str(Path(changed_path).relative_to(await Path().absolute())) - for watcher in self.watchers.get(changed_path, []): - watcher.notify(change) + self.initialized.set() + + async for changes in awatch(".", stop_event=self.stop_watching_files): + async with self.lock: + async with aiosqlite.connect(self.db_path) as db: + deleted_paths = [] + added_paths = [] + for change, changed_path in changes: + # get relative path + changed_path = Path(changed_path).relative_to(await Path().absolute()) + changed_path_str = str(changed_path) + + if change == Change.deleted: + logger.debug("File %s was deleted", changed_path_str) + async with db.execute( + "SELECT COUNT(*) FROM fileids WHERE path = ?", (changed_path_str,) + ) as cursor: + if not (await cursor.fetchone())[0]: + # path is not indexed, ignore + logger.debug( + "File %s is not indexed, ignoring", changed_path_str + ) + continue + # path is indexed + await maybe_rename( + db, changed_path_str, deleted_paths, added_paths, False + ) + elif change == Change.added: + logger.debug("File %s was added", changed_path_str) + await maybe_rename( + db, changed_path_str, added_paths, deleted_paths, True + ) + elif change == Change.modified: + logger.debug("File %s was modified", changed_path_str) + if changed_path_str == self.db_path: + continue + async with db.execute( + "SELECT COUNT(*) FROM fileids WHERE path = ?", (changed_path_str,) + ) as cursor: + if not (await cursor.fetchone())[0]: + # path is not indexed, ignore + logger.debug( + "File %s is not indexed, ignoring", changed_path_str + ) + continue + mtime = (await changed_path.stat()).st_mtime + await db.execute( + "UPDATE fileids SET mtime = ? WHERE path = ?", + (mtime, changed_path_str), + ) + + for path in deleted_paths + added_paths: + await db.execute("DELETE FROM fileids WHERE path = ?", (path,)) + await db.commit() + + for change in changes: + changed_path = change[1] + # get relative path + changed_path = str(Path(changed_path).relative_to(await Path().absolute())) + for watcher in self.watchers.get(changed_path, []): + watcher.notify(change) + + self.stopped_watching_files.set() def watch(self, path: str) -> Watcher: watcher = Watcher(path) diff --git a/plugins/contents/fps_contents/main.py b/plugins/contents/fps_contents/main.py new file mode 100644 index 00000000..36dc5cfe --- /dev/null +++ b/plugins/contents/fps_contents/main.py @@ -0,0 +1,18 @@ +from asphalt.core import Component, Context +from jupyverse_api.app import App +from jupyverse_api.auth import Auth +from jupyverse_api.contents import Contents + +from .routes import _Contents + + +class ContentsComponent(Component): + async def start( + self, + ctx: Context, + ) -> None: + app = await ctx.request_resource(App) + auth = await ctx.request_resource(Auth) # type: ignore + + contents = _Contents(app, auth) + ctx.add_resource(contents, types=Contents) diff --git a/plugins/contents/fps_contents/models.py b/plugins/contents/fps_contents/models.py index fbedbece..5d628cc0 100644 --- a/plugins/contents/fps_contents/models.py +++ b/plugins/contents/fps_contents/models.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Optional, Union +from typing import Optional from pydantic import BaseModel @@ -8,13 +8,6 @@ class Checkpoint(BaseModel): last_modified: str -class SaveContent(BaseModel): - content: Optional[Union[str, Dict]] - format: str - path: str - type: str - - class CreateContent(BaseModel): ext: Optional[str] path: str @@ -23,16 +16,3 @@ class CreateContent(BaseModel): class RenameContent(BaseModel): path: str - - -class Content(BaseModel): - name: str - path: str - last_modified: Optional[str] - created: Optional[str] - content: Optional[Union[str, Dict, List[Dict]]] - format: Optional[str] - mimetype: Optional[str] - size: Optional[int] - writable: bool - type: str diff --git a/plugins/contents/fps_contents/routes.py b/plugins/contents/fps_contents/routes.py index cbbdea40..11304dc0 100644 --- a/plugins/contents/fps_contents/routes.py +++ b/plugins/contents/fps_contents/routes.py @@ -8,141 +8,258 @@ from anyio import open_file from fastapi import APIRouter, Depends, HTTPException, Response -from fps.hooks import register_router # type: ignore -from fps_auth_base import User, current_user # type: ignore -from starlette.requests import Request # type: ignore - -from .models import Checkpoint, Content, CreateContent, RenameContent, SaveContent - -router = APIRouter() - - -@router.post( - "/api/contents/{path:path}/checkpoints", - status_code=201, -) -async def create_checkpoint( - path, user: User = Depends(current_user(permissions={"contents": ["write"]})) -): - src_path = Path(path) - dst_path = Path(".ipynb_checkpoints") / f"{src_path.stem}-checkpoint{src_path.suffix}" - try: - dst_path.parent.mkdir(exist_ok=True) - shutil.copyfile(src_path, dst_path) - except Exception: - # FIXME: return error code? - return [] - mtime = get_file_modification_time(dst_path) - return Checkpoint(**{"id": "checkpoint", "last_modified": mtime}) - - -@router.post( - "/api/contents{path:path}", - status_code=201, -) -async def create_content( - path: Optional[str], - request: Request, - user: User = Depends(current_user(permissions={"contents": ["write"]})), -): - create_content = CreateContent(**(await request.json())) - content_path = Path(create_content.path) - if create_content.type == "notebook": - available_path = get_available_path(content_path / "Untitled.ipynb") - async with await open_file(available_path, "w") as f: - await f.write( - json.dumps({"cells": [], "metadata": {}, "nbformat": 4, "nbformat_minor": 5}) - ) - src_path = available_path - dst_path = Path(".ipynb_checkpoints") / f"{src_path.stem}-checkpoint{src_path.suffix}" - try: - dst_path.parent.mkdir(exist_ok=True) - shutil.copyfile(src_path, dst_path) - except Exception: - # FIXME: return error code? - pass - elif create_content.type == "directory": - name = "Untitled Folder" - available_path = get_available_path(content_path / name, sep=" ") - available_path.mkdir(parents=True, exist_ok=True) - else: - assert create_content.ext is not None - available_path = get_available_path(content_path / ("untitled" + create_content.ext)) - open(available_path, "w").close() - - return await read_content(available_path, False) - - -@router.get("/api/contents") -async def get_root_content( - content: int, - user: User = Depends(current_user(permissions={"contents": ["read"]})), -): - return await read_content("", bool(content)) - - -@router.get("/api/contents/{path:path}/checkpoints") -async def get_checkpoint( - path, user: User = Depends(current_user(permissions={"contents": ["read"]})) -): - src_path = Path(path) - dst_path = Path(".ipynb_checkpoints") / f"{src_path.stem}-checkpoint{src_path.suffix}" - if not dst_path.exists(): - return [] - mtime = get_file_modification_time(dst_path) - return [Checkpoint(**{"id": "checkpoint", "last_modified": mtime})] - - -@router.get("/api/contents/{path:path}") -async def get_content( - path: str, - content: int = 0, - user: User = Depends(current_user(permissions={"contents": ["read"]})), -): - return await read_content(path, bool(content)) - - -@router.put("/api/contents/{path:path}") -async def save_content( - path, - request: Request, - response: Response, - user: User = Depends(current_user(permissions={"contents": ["write"]})), -): - content = SaveContent(**(await request.json())) - try: - await write_content(content) - except Exception: - raise HTTPException(status_code=404, detail=f"Error saving {content.path}") - return await read_content(content.path, False) - - -@router.delete( - "/api/contents/{path:path}", - status_code=204, -) -async def delete_content( - path, - user: User = Depends(current_user(permissions={"contents": ["write"]})), -): - p = Path(path) - if p.exists(): - if p.is_dir(): - shutil.rmtree(p) +from jupyverse_api.app import App +from jupyverse_api.auth import Auth, User +from jupyverse_api.contents import Contents, Content, SaveContent +from starlette.requests import Request + +from .fileid import FileIdManager +from .models import Checkpoint, CreateContent, RenameContent + + +class _Contents(Contents): + def __init__(self, app: App, auth: Auth): + super().__init__(app=app) + + router = APIRouter() + + @router.post( + "/api/contents/{path:path}/checkpoints", + status_code=201, + ) + async def create_checkpoint( + path, user: User = Depends(auth.current_user(permissions={"contents": ["write"]})) + ): + src_path = Path(path) + dst_path = Path(".ipynb_checkpoints") / f"{src_path.stem}-checkpoint{src_path.suffix}" + try: + dst_path.parent.mkdir(exist_ok=True) + shutil.copyfile(src_path, dst_path) + except Exception: + # FIXME: return error code? + return [] + mtime = get_file_modification_time(dst_path) + return Checkpoint(**{"id": "checkpoint", "last_modified": mtime}) + + @router.post( + "/api/contents{path:path}", + status_code=201, + ) + async def create_content( + path: Optional[str], + request: Request, + user: User = Depends(auth.current_user(permissions={"contents": ["write"]})), + ): + create_content = CreateContent(**(await request.json())) + content_path = Path(create_content.path) + if create_content.type == "notebook": + available_path = get_available_path(content_path / "Untitled.ipynb") + async with await open_file(available_path, "w") as f: + await f.write( + json.dumps( + {"cells": [], "metadata": {}, "nbformat": 4, "nbformat_minor": 5} + ) + ) + src_path = available_path + dst_path = ( + Path(".ipynb_checkpoints") / f"{src_path.stem}-checkpoint{src_path.suffix}" + ) + try: + dst_path.parent.mkdir(exist_ok=True) + shutil.copyfile(src_path, dst_path) + except Exception: + # FIXME: return error code? + pass + elif create_content.type == "directory": + name = "Untitled Folder" + available_path = get_available_path(content_path / name, sep=" ") + available_path.mkdir(parents=True, exist_ok=True) + else: + assert create_content.ext is not None + available_path = get_available_path( + content_path / ("untitled" + create_content.ext) + ) + open(available_path, "w").close() + + return await self.read_content(available_path, False) + + @router.get("/api/contents") + async def get_root_content( + content: int, + user: User = Depends(auth.current_user(permissions={"contents": ["read"]})), + ): + return await self.read_content("", bool(content)) + + @router.get("/api/contents/{path:path}/checkpoints") + async def get_checkpoint( + path, user: User = Depends(auth.current_user(permissions={"contents": ["read"]})) + ): + src_path = Path(path) + dst_path = Path(".ipynb_checkpoints") / f"{src_path.stem}-checkpoint{src_path.suffix}" + if not dst_path.exists(): + return [] + mtime = get_file_modification_time(dst_path) + return [Checkpoint(**{"id": "checkpoint", "last_modified": mtime})] + + @router.get("/api/contents/{path:path}") + async def get_content( + path: str, + content: int = 0, + user: User = Depends(auth.current_user(permissions={"contents": ["read"]})), + ): + return await self.read_content(path, bool(content)) + + @router.put("/api/contents/{path:path}") + async def save_content( + path, + request: Request, + response: Response, + user: User = Depends(auth.current_user(permissions={"contents": ["write"]})), + ): + content = SaveContent(**(await request.json())) + try: + await self.write_content(content) + except Exception: + raise HTTPException(status_code=404, detail=f"Error saving {content.path}") + return await self.read_content(content.path, False) + + @router.delete( + "/api/contents/{path:path}", + status_code=204, + ) + async def delete_content( + path, + user: User = Depends(auth.current_user(permissions={"contents": ["write"]})), + ): + p = Path(path) + if p.exists(): + if p.is_dir(): + shutil.rmtree(p) + else: + p.unlink() + return Response(status_code=HTTPStatus.NO_CONTENT.value) + + @router.patch("/api/contents/{path:path}") + async def rename_content( + path, + request: Request, + user: User = Depends(auth.current_user(permissions={"contents": ["write"]})), + ): + rename_content = RenameContent(**(await request.json())) + Path(path).rename(rename_content.path) + return await self.read_content(rename_content.path, False) + + self.include_router(router) + + async def read_content( + self, path: Union[str, Path], get_content: bool, as_json: bool = False + ) -> Content: + if isinstance(path, str): + path = Path(path) + content: Optional[Union[str, Dict, List[Dict]]] = None + if get_content: + if path.is_dir(): + content = [ + (await self.read_content(subpath, get_content=False)).dict() + for subpath in path.iterdir() + if not subpath.name.startswith(".") + ] + elif path.is_file() or path.is_symlink(): + try: + async with await open_file(path) as f: + content = await f.read() + if as_json: + content = json.loads(content) + except Exception: + raise HTTPException(status_code=404, detail="Item not found") + format: Optional[str] = None + if path.is_dir(): + size = None + type = "directory" + format = "json" + mimetype = None + elif path.is_file() or path.is_symlink(): + size = get_file_size(path) + if path.suffix == ".ipynb": + type = "notebook" + format = None + mimetype = None + if content is not None: + nb: dict + if as_json: + content = cast(Dict, content) + nb = content + else: + content = cast(str, content) + nb = json.loads(content) + for cell in nb["cells"]: + if "metadata" not in cell: + cell["metadata"] = {} + cell["metadata"].update({"trusted": False}) + if not as_json: + content = json.dumps(nb) + elif path.suffix == ".json": + type = "json" + format = "text" + mimetype = "application/json" + else: + type = "file" + format = None + mimetype = "text/plain" else: - p.unlink() - return Response(status_code=HTTPStatus.NO_CONTENT.value) + raise HTTPException(status_code=404, detail="Item not found") + + return Content( + **{ + "name": path.name, + "path": path.as_posix(), + "last_modified": get_file_modification_time(path), + "created": get_file_creation_time(path), + "content": content, + "format": format, + "mimetype": mimetype, + "size": size, + "writable": is_file_writable(path), + "type": type, + } + ) + + async def write_content(self, content: Union[SaveContent, Dict]) -> None: + if not isinstance(content, SaveContent): + content = SaveContent(**content) + async with await open_file(content.path, "w") as f: + if content.format == "json": + dict_content = cast(Dict, content.content) + if content.type == "notebook": + # see https://github.com/jupyterlab/jupyterlab/issues/11005 + if "metadata" in dict_content and "orig_nbformat" in dict_content["metadata"]: + del dict_content["metadata"]["orig_nbformat"] + await f.write(json.dumps(dict_content, indent=2)) + else: + content.content = cast(str, content.content) + await f.write(content.content) + + @property + def file_id_manager(self): + return FileIdManager() -@router.patch("/api/contents/{path:path}") -async def rename_content( - path, - request: Request, - user: User = Depends(current_user(permissions={"contents": ["write"]})), -): - rename_content = RenameContent(**(await request.json())) - Path(path).rename(rename_content.path) - return await read_content(rename_content.path, False) +def get_available_path(path: Path, sep: str = ""): + directory = path.parent + name = Path(path.name) + i = None + while True: + if i is None: + i_str = "" + i = 1 + else: + i_str = str(i) + i += 1 + if i_str: + i_str = sep + i_str + available_path = directory / (name.stem + i_str + name.suffix) + if not available_path.exists(): + return available_path def get_file_modification_time(path: Path): @@ -169,112 +286,3 @@ def is_file_writable(path: Path) -> bool: else: return os.access(path, os.W_OK) return False - - -async def read_content(path: Union[str, Path], get_content: bool, as_json: bool = False) -> Content: - if isinstance(path, str): - path = Path(path) - content: Optional[Union[str, Dict, List[Dict]]] = None - if get_content: - if path.is_dir(): - content = [ - (await read_content(subpath, get_content=False)).dict() - for subpath in path.iterdir() - if not subpath.name.startswith(".") - ] - elif path.is_file() or path.is_symlink(): - try: - async with await open_file(path) as f: - content = await f.read() - if as_json: - content = json.loads(content) - except Exception: - raise HTTPException(status_code=404, detail="Item not found") - format: Optional[str] = None - if path.is_dir(): - size = None - type = "directory" - format = "json" - mimetype = None - elif path.is_file() or path.is_symlink(): - size = get_file_size(path) - if path.suffix == ".ipynb": - type = "notebook" - format = None - mimetype = None - if content is not None: - nb: dict - if as_json: - content = cast(Dict, content) - nb = content - else: - content = cast(str, content) - nb = json.loads(content) - for cell in nb["cells"]: - if "metadata" not in cell: - cell["metadata"] = {} - cell["metadata"].update({"trusted": False}) - if not as_json: - content = json.dumps(nb) - elif path.suffix == ".json": - type = "json" - format = "text" - mimetype = "application/json" - else: - type = "file" - format = None - mimetype = "text/plain" - else: - raise HTTPException(status_code=404, detail="Item not found") - - return Content( - **{ - "name": path.name, - "path": path.as_posix(), - "last_modified": get_file_modification_time(path), - "created": get_file_creation_time(path), - "content": content, - "format": format, - "mimetype": mimetype, - "size": size, - "writable": is_file_writable(path), - "type": type, - } - ) - - -async def write_content(content: Union[SaveContent, Dict]) -> None: - if not isinstance(content, SaveContent): - content = SaveContent(**content) - async with await open_file(content.path, "w") as f: - if content.format == "json": - dict_content = cast(Dict, content.content) - if content.type == "notebook": - # see https://github.com/jupyterlab/jupyterlab/issues/11005 - if "metadata" in dict_content and "orig_nbformat" in dict_content["metadata"]: - del dict_content["metadata"]["orig_nbformat"] - await f.write(json.dumps(dict_content, indent=2)) - else: - content.content = cast(str, content.content) - await f.write(content.content) - - -def get_available_path(path: Path, sep: str = ""): - directory = path.parent - name = Path(path.name) - i = None - while True: - if i is None: - i_str = "" - i = 1 - else: - i_str = str(i) - i += 1 - if i_str: - i_str = sep + i_str - available_path = directory / (name.stem + i_str + name.suffix) - if not available_path.exists(): - return available_path - - -r = register_router(router) diff --git a/plugins/contents/pyproject.toml b/plugins/contents/pyproject.toml index 6251e8aa..f78ee434 100644 --- a/plugins/contents/pyproject.toml +++ b/plugins/contents/pyproject.toml @@ -5,9 +5,13 @@ build-backend = "hatchling.build" [project] name = "fps_contents" description = "An FPS plugin for the contents API" -keywords = [ "jupyter", "server", "fastapi", "pluggy", "plugins",] +keywords = ["jupyter", "server", "fastapi", "plugins"] requires-python = ">=3.8" -dependencies = [ "fps >=0.0.8", "fps-auth-base", "anyio", "watchfiles >=0.16.1,<1", "aiosqlite >=0.17.0,<1", "anyio>=3.6.2,<4"] +dependencies = [ + "watchfiles >=0.18.1,<1", + "aiosqlite >=0.17.0,<1", + "anyio>=3.6.2,<4", +] dynamic = [ "version",] [[project.authors]] name = "Jupyter Development Team" @@ -29,8 +33,8 @@ ignore = [ ".*",] [tool.jupyter-releaser] skip = [ "check-links",] -[project.entry-points.fps_router] -fps-contents = "fps_contents.routes" +[project.entry-points."asphalt.components"] +contents = "fps_contents.main:ContentsComponent" [tool.hatch.version] path = "fps_contents/__init__.py" diff --git a/plugins/frontend/fps_frontend/config.py b/plugins/frontend/fps_frontend/config.py deleted file mode 100644 index 1ee422e9..00000000 --- a/plugins/frontend/fps_frontend/config.py +++ /dev/null @@ -1,13 +0,0 @@ -from fps.config import PluginModel, get_config # type: ignore -from fps.hooks import register_config # type: ignore - - -class FrontendConfig(PluginModel): - base_url: str = "/" - - -def get_frontend_config(): - return get_config(FrontendConfig) - - -c = register_config(FrontendConfig) diff --git a/plugins/frontend/fps_frontend/main.py b/plugins/frontend/fps_frontend/main.py new file mode 100644 index 00000000..1ff637a5 --- /dev/null +++ b/plugins/frontend/fps_frontend/main.py @@ -0,0 +1,13 @@ +from asphalt.core import Component, Context +from jupyverse_api.frontend import FrontendConfig + + +class FrontendComponent(Component): + def __init__(self, **kwargs): + self.frontend_config = FrontendConfig(**kwargs) + + async def start( + self, + ctx: Context, + ) -> None: + ctx.add_resource(self.frontend_config, types=FrontendConfig) diff --git a/plugins/frontend/fps_frontend/py.typed b/plugins/frontend/fps_frontend/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/plugins/frontend/pyproject.toml b/plugins/frontend/pyproject.toml index 04d8965d..ac50f827 100644 --- a/plugins/frontend/pyproject.toml +++ b/plugins/frontend/pyproject.toml @@ -5,9 +5,8 @@ build-backend = "hatchling.build" [project] name = "fps_frontend" description = "An FPS plugin for the frontend related configuration" -keywords = ["fastapi", "pluggy", "plugins", "fps"] +keywords = ["jupyter", "server", "fastapi", "plugins"] requires-python = ">=3.8" -dependencies = ["fps>=0.0.8"] dynamic = ["version"] [[project.authors]] @@ -30,8 +29,8 @@ ignore = [".*"] [tool.jupyter-releaser] skip = ["check-links"] -[project.entry-points.fps_config] -fps-frontend = "fps_frontend.config" +[project.entry-points."asphalt.components"] +frontend = "fps_frontend.main:FrontendComponent" [tool.hatch.version] path = "fps_frontend/__init__.py" diff --git a/plugins/jupyterlab/fps_jupyterlab/config.py b/plugins/jupyterlab/fps_jupyterlab/config.py deleted file mode 100644 index 73b57ec4..00000000 --- a/plugins/jupyterlab/fps_jupyterlab/config.py +++ /dev/null @@ -1,13 +0,0 @@ -from fps.config import PluginModel, get_config # type: ignore -from fps.hooks import register_config # type: ignore - - -class JupyterLabConfig(PluginModel): - dev_mode: bool = False - - -def get_jlab_config(): - return get_config(JupyterLabConfig) - - -c = register_config(JupyterLabConfig) diff --git a/plugins/jupyterlab/fps_jupyterlab/index.py b/plugins/jupyterlab/fps_jupyterlab/index.py new file mode 100644 index 00000000..d25f1631 --- /dev/null +++ b/plugins/jupyterlab/fps_jupyterlab/index.py @@ -0,0 +1,32 @@ +INDEX_HTML = """\ +