Fix static asset serving in devserver mode.

This commit is contained in:
2026-02-18 22:19:18 +00:00
parent e1f0fdf664
commit dfc4c76d43
9 changed files with 87 additions and 116 deletions
+3 -2
View File
@@ -1,7 +1,7 @@
import logging
from uuid import UUID
from fastapi import Body, FastAPI, HTTPException, Query, Request, Response
from fastapi import Body, FastAPI, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from paskia import aaguid as aaguid_mod
@@ -14,6 +14,7 @@ from paskia.db import User as UserDC
from paskia.db.operations import _UNSET
from paskia.db.structs import Client
from paskia.fastapi import authz
from paskia.fastapi.front import frontend
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE
from paskia.globals import passkey
@@ -78,7 +79,7 @@ async def general_exception_handler(_request, exc: Exception): # pragma: no cov
@app.get("/")
async def adminapp(request: Request, auth=AUTH_COOKIE):
return Response(*await vitedev.read("/auth/admin/index.html"))
return await vitedev.handle(request, frontend, "/auth/admin/")
# -------------------- Organizations --------------------
+9 -19
View File
@@ -20,7 +20,7 @@ from paskia.fastapi import authz, session, user
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
from paskia.globals import passkey as global_passkey
from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev
from paskia.util import hostutil, htmlutil, passphrase, userinfo
from paskia.util.apistructs import ApiSettings, ApiTokenInfo, ApiValidateResponse
bearer_auth = HTTPBearer(auto_error=False)
@@ -161,25 +161,15 @@ async def forward_authentication(
# Clear cookie only if session is invalid (not for reauth)
if e.clear_session:
session.clear_session_cookie(response)
# Check Accept header to decide response format
accept = request.headers.get("accept", "")
wants_html = "text/html" in accept
if wants_html:
# Browser request - return full-page HTML with metadata
data_attrs = {"mode": e.mode, **e.metadata}
html = (await vitedev.read("/int/forward/index.html"))[0]
html = htmlutil.patch_html_data_attrs(html, **data_attrs)
return Response(
html, status_code=e.status_code, media_type="text/html; charset=UTF-8"
)
else:
# API request - return JSON with iframe srcdoc HTML
return JSONResponse(
status_code=e.status_code,
content=await authz.auth_error_content(e),
# Browser request? - return full-page HTML with metadata patched into data attrs
if "text/html" in request.headers.get("accept", ""):
return await htmlutil.patched_html_response(
request, "/int/forward/", e.status_code, mode=e.mode, **e.metadata
)
# API request - return JSON with iframe srcdoc HTML
return JSONResponse(
status_code=e.status_code, content=await authz.auth_error_content(e)
)
@app.get("/settings")
+8 -15
View File
@@ -6,13 +6,15 @@ from pathlib import Path
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, RedirectResponse
from fastapi_vue import Frontend
from paskia import authcode, globals
from paskia.__main__ import DEVMODE
from paskia.db import start_background, stop_background
from paskia.db.logging import configure_db_logging
from paskia.fastapi import admin, api, auth_host, oid, ws
# Import frontend instance
from paskia.fastapi.front import frontend
from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging
from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import hostutil, passphrase, vitedev
@@ -23,14 +25,6 @@ configure_db_logging()
_access_logger = logging.getLogger("paskia.access")
# Vue Frontend static files
frontend = Frontend(
Path(__file__).parent.parent / "frontend-build",
cached=["/auth/assets/"],
favicon="/paskia.webp",
)
# Path to examples/index.html when running from source tree
_EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
@@ -131,9 +125,9 @@ async def openid_configuration(request: Request):
@app.get("/auth/restricted/iframe")
@app.get("/auth/restricted/oidc")
async def restricted_view():
async def restricted_view(request: Request):
"""Serve the restricted/authentication UI for iframe or OpenID Connect."""
return Response(*await vitedev.read("/auth/restricted/index.html"))
return await vitedev.handle(request, frontend, "/auth/restricted/")
# Navigable URLs are defined here. We support both / and /auth/ as the base path
@@ -148,7 +142,7 @@ async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE):
The frontend handles mode detection (host mode vs full profile) based on settings.
Access control is handled via APIs.
"""
return Response(*await vitedev.read("/auth/index.html"))
return await vitedev.handle(request, frontend, "/auth/")
@app.get("/admin", include_in_schema=False)
@@ -180,14 +174,13 @@ async def examples_page():
# Frontend static files - must be before /{token} catch-all routes
# (actual routes registered during lifespan after frontend.load())
frontend.route(app, "/")
# Note: this catch-all handler must be the last route defined
@app.get("/{token}")
@app.get("/auth/{token}")
async def token_link(token: str):
async def token_link(request: Request, token: str):
"""Serve the reset app for reset tokens (password reset / device addition).
The frontend will validate the token via /auth/api/token-info.
@@ -195,4 +188,4 @@ async def token_link(token: str):
if not passphrase.is_well_formed(token):
raise HTTPException(status_code=404)
return Response(*await vitedev.read("/int/reset/index.html"))
return await vitedev.handle(request, frontend, "/int/reset/")
+1 -1
View File
@@ -11,7 +11,7 @@ __all__ = ["path", "file", "read", "is_dev_mode"]
def _get_dev_server() -> str | None:
"""Get the dev server URL from environment, or None if not in dev mode."""
return os.environ.get("FASTAPI_VUE_FRONTEND_URL") or None
return os.environ.get("PASKIA_VITE_URL") or None
def _resolve_static_dir() -> Path:
+39
View File
@@ -2,6 +2,45 @@
import re
from paskia.fastapi.front import frontend
from paskia.util import vitedev
async def patched_html_response(request, filepath: str, status_code: int, **data_attrs):
"""Fetch HTML from vitedev and patch with data attributes.
Strips caching/compression headers from request to get raw content,
patches the HTML body with data attributes, and strips caching headers
from response.
Args:
request: The FastAPI Request object
filepath: Path to HTML file, e.g. "/int/forward/"
status_code: HTTP status code for the response
**data_attrs: Key-value pairs for data attributes
Returns:
Patched Response object, or original response if not 200.
"""
# Strip caching/compression headers to get raw uncompressed content
cache_headers = {b"if-none-match", b"if-modified-since", b"accept-encoding"}
request.scope["headers"] = [
(k, v) for k, v in request.scope["headers"] if k.lower() not in cache_headers
]
resp = await vitedev.handle(request, frontend, filepath)
# Pass through non-200 responses
if resp.status_code != 200:
return resp
# Patch HTML with data attrs and strip caching headers from response
resp.body = patch_html_data_attrs(resp.body, **data_attrs)
resp.status_code = status_code
strip_headers = {b"etag", b"last-modified", b"content-length"}
resp.raw_headers = [
(k, v) for k, v in resp.raw_headers if k.lower() not in strip_headers
]
return resp
def patch_html_data_attrs(html: bytes, **data_attrs: str) -> bytes:
"""Patch HTML by adding data attributes to the <html> tag.
+1 -1
View File
@@ -69,7 +69,7 @@ def print_startup_config(config: "PaskiaConfig") -> None:
lines.append(line(f"Auth Host: {config.auth_host}"))
# Show frontend URL if in dev mode
devmode = os.environ.get("FASTAPI_VUE_FRONTEND_URL")
devmode = os.environ.get("PASKIA_VITE_URL")
if devmode:
lines.append(line(f"Dev Frontend: {devmode}"))
+26 -31
View File
@@ -1,39 +1,29 @@
"""Vite dev server proxy for fetching frontend files during development.
In dev mode (FASTAPI_VUE_FRONTEND_URL set), fetches files from Vite.
In dev mode (PASKIA_VITE_URL set), fetches files from Vite.
In production, reads from the static build directory.
This complements fastapi_vue.Frontend which handles static file serving
but doesn't provide server-side fetching of HTML content.
"""
import asyncio
import mimetypes
import os
from importlib import resources
from pathlib import Path
import httpx
from fastapi import Response
__all__ = ["read"]
def _get_dev_server() -> str | None:
"""Get the dev server URL from environment, or None if not in dev mode."""
return os.environ.get("FASTAPI_VUE_FRONTEND_URL") or None
__all__ = ["handle"]
def _resolve_static_dir() -> Path:
"""Resolve the static files directory."""
# Try packaged path via importlib.resources (works for wheel/installed).
try: # pragma: no cover - trivial path resolution
pkg_dir = resources.files("paskia") / "frontend-build"
fs_path = Path(str(pkg_dir))
if fs_path.is_dir():
return fs_path
except Exception: # pragma: no cover - defensive
pass
pkg_dir = resources.files("paskia") / "frontend-build"
fs_path = Path(str(pkg_dir))
if fs_path.is_dir():
return fs_path
# Fallback for editable/development before build.
return Path(__file__).parent.parent / "frontend-build"
@@ -41,31 +31,36 @@ def _resolve_static_dir() -> Path:
_static_dir: Path = _resolve_static_dir()
async def read(filepath: str) -> tuple[bytes, int, dict[str, str]]:
"""Read file content and return response tuple.
async def handle(request, frontend, filepath: str):
"""Read file content and return Response.
In dev mode, fetches from the Vite dev server.
In production, reads from the static build directory.
In production, uses frontend.handle.
Args:
request: The FastAPI Request object
frontend: The fastapi_vue.Frontend instance
filepath: Path relative to frontend root, e.g. "/auth/index.html"
Returns:
Tuple of (content, status_code, headers) suitable for
FastAPI Response(*args).
FastAPI Response object.
"""
dev_server = _get_dev_server()
if dev_server:
if dev_server := os.environ.get("PASKIA_VITE_URL"):
async with httpx.AsyncClient() as client:
resp = await client.get(f"{dev_server}{filepath}")
resp.raise_for_status()
mime = resp.headers.get("content-type", "application/octet-stream")
# Strip charset suffix if present
mime = mime.split(";")[0].strip()
return resp.content, resp.status_code, {"content-type": mime}
else:
# Production: read from static build
file_path = _static_dir / filepath.lstrip("/")
content = await asyncio.to_thread(file_path.read_bytes)
mime, _ = mimetypes.guess_type(str(file_path))
return content, 200, {"content-type": mime or "application/octet-stream"}
return Response(resp.content, resp.status_code, {"content-type": mime})
# Read from frontend cache directly to bypass any compression/processing
cached_content = getattr(frontend, "_files", {}).get(filepath)
if cached_content is not None:
mime, _ = mimetypes.guess_type(filepath)
return Response(
cached_content, 200, {"content-type": mime or "application/octet-stream"}
)
# Fallback to frontend.handle for cache negotiation
return frontend.handle(request, filepath)