Utility module for accessing frontend in backend code.
This commit is contained in:
parent
dd20e7e7f8
commit
3cd6a59b26
@ -1,17 +1,14 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import atexit
|
|
||||||
import contextlib
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import signal
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
|
from passkey.util import frontend
|
||||||
|
|
||||||
DEFAULT_HOST = "localhost"
|
DEFAULT_HOST = "localhost"
|
||||||
DEFAULT_SERVE_PORT = 4401
|
DEFAULT_SERVE_PORT = 4401
|
||||||
DEFAULT_DEV_PORT = 4402
|
DEFAULT_DEV_PORT = 4402
|
||||||
@ -180,36 +177,11 @@ def main():
|
|||||||
run_kwargs["host"] = host
|
run_kwargs["host"] = host
|
||||||
run_kwargs["port"] = port
|
run_kwargs["port"] = port
|
||||||
|
|
||||||
bun_process: subprocess.Popen | None = None
|
|
||||||
if devmode:
|
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":
|
if os.environ.get("PASSKEY_BUN_PARENT") != "1":
|
||||||
os.environ["PASSKEY_BUN_PARENT"] = "1"
|
os.environ["PASSKEY_BUN_PARENT"] = "1"
|
||||||
frontend_dir = Path(__file__).parent.parent.parent / "frontend"
|
frontend.run_dev()
|
||||||
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)
|
|
||||||
|
|
||||||
if all_ifaces and not uds:
|
if all_ifaces and not uds:
|
||||||
# If reload enabled, fallback to single dual-stack attempt (::) to keep reload simple
|
# If reload enabled, fallback to single dual-stack attempt (::) to keep reload simple
|
||||||
|
@ -11,7 +11,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import Body, Cookie, FastAPI, HTTPException
|
from fastapi import Body, Cookie, FastAPI, HTTPException
|
||||||
@ -20,11 +19,9 @@ from fastapi.responses import FileResponse, JSONResponse
|
|||||||
from ..authsession import expires, get_session
|
from ..authsession import expires, get_session
|
||||||
from ..globals import db
|
from ..globals import db
|
||||||
from ..globals import passkey as global_passkey
|
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
|
from ..util.tokens import session_key
|
||||||
|
|
||||||
STATIC_DIR = Path(__file__).parent.parent / "frontend-build"
|
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
|
|
||||||
@ -60,9 +57,9 @@ async def admin_frontend(auth=Cookie(None)):
|
|||||||
with contextlib.suppress(ValueError):
|
with contextlib.suppress(ValueError):
|
||||||
s = await get_session(auth)
|
s = await get_session(auth)
|
||||||
if s.info and s.info.get("type") == "authenticated":
|
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(
|
return FileResponse(
|
||||||
STATIC_DIR / "index.html",
|
frontend.file("index.html"),
|
||||||
status_code=401,
|
status_code=401,
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
@ -1,18 +1,15 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse, RedirectResponse
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from passkey.util import passphrase
|
from passkey.util import frontend, passphrase
|
||||||
|
|
||||||
from . import admin, api, ws
|
from . import admin, api, ws
|
||||||
|
|
||||||
STATIC_DIR = Path(__file__).parent.parent / "frontend-build"
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
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/admin", admin.app)
|
||||||
app.mount("/auth/api", api.app)
|
app.mount("/auth/api", api.app)
|
||||||
app.mount("/auth/ws", ws.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/")
|
@app.get("/auth/")
|
||||||
async def frontend():
|
async def frontapp():
|
||||||
"""Serve the main authentication app."""
|
"""Serve the main authentication app."""
|
||||||
return FileResponse(STATIC_DIR / "index.html")
|
return FileResponse(frontend.file("index.html"))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/{reset}")
|
@app.get("/auth/{reset}")
|
||||||
async def reset_authentication(request: Request, reset: str):
|
async def reset_link(request: Request, reset: str):
|
||||||
"""Validate reset token and redirect with it as query parameter (no cookies).
|
"""Pretty URL for reset links."""
|
||||||
|
|
||||||
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
|
|
||||||
"""
|
|
||||||
if not passphrase.is_well_formed(reset):
|
if not passphrase.is_well_formed(reset):
|
||||||
raise HTTPException(status_code=404)
|
raise HTTPException(status_code=404)
|
||||||
return RedirectResponse(request.url_for("frontend", reset=reset), status_code=303)
|
return RedirectResponse(request.url_for("frontapp", reset=reset), status_code=303)
|
||||||
|
|
||||||
|
|
||||||
## forward-auth endpoint moved to /auth/api/forward in api.py
|
|
||||||
|
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"
|
passkey-auth = "passkey.fastapi.__main__:main"
|
||||||
|
|
||||||
[tool.hatch.build]
|
[tool.hatch.build]
|
||||||
artifacts = ["passkeyauth/frontend-static"]
|
artifacts = ["passkey/frontend-build"]
|
||||||
targets.sdist.hooks.custom.path = "scripts/build-frontend.py"
|
targets.sdist.hooks.custom.path = "scripts/build-frontend.py"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user