Utility module for accessing frontend in backend code.
This commit is contained in:
parent
dd20e7e7f8
commit
3cd6a59b26
@ -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
|
||||
|
@ -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"},
|
||||
)
|
||||
|
@ -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
60
passkey/util/frontend.py
Normal 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)
|
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user