diff --git a/passkey/fastapi/__main__.py b/passkey/fastapi/__main__.py index 9983413..e25ced2 100644 --- a/passkey/fastapi/__main__.py +++ b/passkey/fastapi/__main__.py @@ -1,17 +1,14 @@ import argparse import asyncio -import atexit -import contextlib import ipaddress import logging import os -import signal -import subprocess -from pathlib import Path from urllib.parse import urlparse import uvicorn +from passkey.util import frontend + DEFAULT_HOST = "localhost" DEFAULT_SERVE_PORT = 4401 DEFAULT_DEV_PORT = 4402 @@ -180,36 +177,11 @@ def main(): run_kwargs["host"] = host run_kwargs["port"] = port - bun_process: subprocess.Popen | None = None if devmode: - # Spawn frontend dev server (bun) only in the original parent (avoid duplicates on reload) + # Spawn frontend dev server (bun or npm) only once in parent process if os.environ.get("PASSKEY_BUN_PARENT") != "1": os.environ["PASSKEY_BUN_PARENT"] = "1" - frontend_dir = Path(__file__).parent.parent.parent / "frontend" - if (frontend_dir / "package.json").exists(): - try: - bun_process = subprocess.Popen( - ["bun", "--bun", "run", "dev"], cwd=str(frontend_dir) - ) - logging.info("Started bun dev server") - except FileNotFoundError: - logging.warning( - "bun not found: skipping frontend dev server (install bun)" - ) - - def _terminate_bun(): # pragma: no cover - if bun_process and bun_process.poll() is None: - with contextlib.suppress(Exception): - bun_process.terminate() - - atexit.register(_terminate_bun) - - def _signal_handler(signum, frame): # pragma: no cover - _terminate_bun() - raise SystemExit(0) - - signal.signal(signal.SIGINT, _signal_handler) - signal.signal(signal.SIGTERM, _signal_handler) + frontend.run_dev() if all_ifaces and not uds: # If reload enabled, fallback to single dual-stack attempt (::) to keep reload simple diff --git a/passkey/fastapi/admin.py b/passkey/fastapi/admin.py index e9a25ba..46c3cf2 100644 --- a/passkey/fastapi/admin.py +++ b/passkey/fastapi/admin.py @@ -11,7 +11,6 @@ from __future__ import annotations import contextlib import logging -from pathlib import Path from uuid import UUID, uuid4 from fastapi import Body, Cookie, FastAPI, HTTPException @@ -20,11 +19,9 @@ from fastapi.responses import FileResponse, JSONResponse from ..authsession import expires, get_session from ..globals import db from ..globals import passkey as global_passkey -from ..util import passphrase, querysafe, tokens +from ..util import frontend, passphrase, querysafe, tokens from ..util.tokens import session_key -STATIC_DIR = Path(__file__).parent.parent / "frontend-build" - app = FastAPI() @@ -60,9 +57,9 @@ async def admin_frontend(auth=Cookie(None)): with contextlib.suppress(ValueError): s = await get_session(auth) if s.info and s.info.get("type") == "authenticated": - return FileResponse(STATIC_DIR / "admin" / "index.html") + return FileResponse(frontend.file("admin/index.html")) return FileResponse( - STATIC_DIR / "index.html", + frontend.file("index.html"), status_code=401, headers={"WWW-Authenticate": "Bearer"}, ) diff --git a/passkey/fastapi/mainapp.py b/passkey/fastapi/mainapp.py index ef68aaa..46c9dc3 100644 --- a/passkey/fastapi/mainapp.py +++ b/passkey/fastapi/mainapp.py @@ -1,18 +1,15 @@ import logging import os from contextlib import asynccontextmanager -from pathlib import Path from fastapi import FastAPI, HTTPException, Request from fastapi.responses import FileResponse, RedirectResponse from fastapi.staticfiles import StaticFiles -from passkey.util import passphrase +from passkey.util import frontend, passphrase from . import admin, api, ws -STATIC_DIR = Path(__file__).parent.parent / "frontend-build" - @asynccontextmanager async def lifespan(app: FastAPI): # pragma: no cover - startup path @@ -52,27 +49,18 @@ app = FastAPI(lifespan=lifespan) app.mount("/auth/admin", admin.app) app.mount("/auth/api", api.app) app.mount("/auth/ws", ws.app) -app.mount("/auth/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets") +app.mount("/auth/assets", StaticFiles(directory=frontend.file("assets")), name="assets") @app.get("/auth/") -async def frontend(): +async def frontapp(): """Serve the main authentication app.""" - return FileResponse(STATIC_DIR / "index.html") + return FileResponse(frontend.file("index.html")) @app.get("/auth/{reset}") -async def reset_authentication(request: Request, reset: str): - """Validate reset token and redirect with it as query parameter (no cookies). - - After validation we 303 redirect to /auth/?reset=. The frontend will: - - Read the token from location.search - - Use it via Authorization header or websocket query param - - history.replaceState to remove it from the address bar/history - """ +async def reset_link(request: Request, reset: str): + """Pretty URL for reset links.""" if not passphrase.is_well_formed(reset): raise HTTPException(status_code=404) - return RedirectResponse(request.url_for("frontend", reset=reset), status_code=303) - - -## forward-auth endpoint moved to /auth/api/forward in api.py + return RedirectResponse(request.url_for("frontapp", reset=reset), status_code=303) diff --git a/passkey/util/frontend.py b/passkey/util/frontend.py new file mode 100644 index 0000000..13e9a06 --- /dev/null +++ b/passkey/util/frontend.py @@ -0,0 +1,60 @@ +from importlib import resources +from pathlib import Path + +__all__ = ["path", "file", "run_dev"] + + +def _resolve_static_dir() -> Path: + # Try packaged path via importlib.resources (works for wheel/installed). + try: # pragma: no cover - trivial path resolution + pkg_dir = resources.files("passkey") / "frontend-build" + fs_path = Path(str(pkg_dir)) + if fs_path.is_dir(): + return fs_path + except Exception: # pragma: no cover - defensive + pass + # Fallback for editable/development before build. + return Path(__file__).parent.parent / "frontend-build" + + +path: Path = _resolve_static_dir() + + +def file(*parts: str) -> Path: + """Return a child path under the static root.""" + return path.joinpath(*parts) + + +def run_dev(): + """Spawn the frontend dev server (bun or npm) as a background process.""" + import atexit + import shutil + import signal + import subprocess + + devpath = Path(__file__).parent.parent.parent / "frontend" + if not (devpath / "package.json").exists(): + raise RuntimeError( + "Dev frontend is only available when running from git." + if "site-packages" in devpath.parts + else f"Frontend source code not found at {devpath}" + ) + bun = shutil.which("bun") + npm = shutil.which("npm") if bun is None else None + if not bun and not npm: + raise RuntimeError("Neither bun nor npm found on PATH for dev server") + cmd: list[str] = [bun, "--bun", "run", "dev"] if bun else [npm, "run", "dev"] # type: ignore[list-item] + proc = subprocess.Popen(cmd, cwd=str(devpath)) + + def _terminate(): + if proc.poll() is None: + proc.terminate() + + atexit.register(_terminate) + + def _signal_handler(signum, frame): + _terminate() + raise SystemExit(0) + + for sig in (signal.SIGINT, signal.SIGTERM): + signal.signal(sig, _signal_handler) diff --git a/pyproject.toml b/pyproject.toml index f29f80a..33c899c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,5 +38,5 @@ isort.known-first-party = ["passkey"] passkey-auth = "passkey.fastapi.__main__:main" [tool.hatch.build] -artifacts = ["passkeyauth/frontend-static"] +artifacts = ["passkey/frontend-build"] targets.sdist.hooks.custom.path = "scripts/build-frontend.py"