Utility module for accessing frontend in backend code.

This commit is contained in:
Leo Vasanko 2025-09-02 16:05:20 -06:00
parent dd20e7e7f8
commit 3cd6a59b26
5 changed files with 75 additions and 58 deletions

View File

@ -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

View File

@ -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"},
)

View File

@ -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=<token>. 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)

60
passkey/util/frontend.py Normal file
View File

@ -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)

View File

@ -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"