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 import logging
from uuid import UUID 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 fastapi.responses import JSONResponse
from paskia import aaguid as aaguid_mod 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.operations import _UNSET
from paskia.db.structs import Client from paskia.db.structs import Client
from paskia.fastapi import authz from paskia.fastapi import authz
from paskia.fastapi.front import frontend
from paskia.fastapi.response import MsgspecResponse from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE from paskia.fastapi.session import AUTH_COOKIE
from paskia.globals import passkey from paskia.globals import passkey
@@ -78,7 +79,7 @@ async def general_exception_handler(_request, exc: Exception): # pragma: no cov
@app.get("/") @app.get("/")
async def adminapp(request: Request, auth=AUTH_COOKIE): 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 -------------------- # -------------------- 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.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
from paskia.globals import passkey as global_passkey 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 from paskia.util.apistructs import ApiSettings, ApiTokenInfo, ApiValidateResponse
bearer_auth = HTTPBearer(auto_error=False) bearer_auth = HTTPBearer(auto_error=False)
@@ -161,25 +161,15 @@ async def forward_authentication(
# Clear cookie only if session is invalid (not for reauth) # Clear cookie only if session is invalid (not for reauth)
if e.clear_session: if e.clear_session:
session.clear_session_cookie(response) session.clear_session_cookie(response)
# Browser request? - return full-page HTML with metadata patched into data attrs
# Check Accept header to decide response format if "text/html" in request.headers.get("accept", ""):
accept = request.headers.get("accept", "") return await htmlutil.patched_html_response(
wants_html = "text/html" in accept request, "/int/forward/", e.status_code, mode=e.mode, **e.metadata
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),
) )
# 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") @app.get("/settings")
+8 -15
View File
@@ -6,13 +6,15 @@ from pathlib import Path
from fastapi import FastAPI, HTTPException, Request, Response from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, RedirectResponse from fastapi.responses import FileResponse, RedirectResponse
from fastapi_vue import Frontend
from paskia import authcode, globals from paskia import authcode, globals
from paskia.__main__ import DEVMODE from paskia.__main__ import DEVMODE
from paskia.db import start_background, stop_background from paskia.db import start_background, stop_background
from paskia.db.logging import configure_db_logging from paskia.db.logging import configure_db_logging
from paskia.fastapi import admin, api, auth_host, oid, ws 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.logging import AccessLogMiddleware, configure_access_logging
from paskia.fastapi.session import AUTH_COOKIE from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import hostutil, passphrase, vitedev from paskia.util import hostutil, passphrase, vitedev
@@ -23,14 +25,6 @@ configure_db_logging()
_access_logger = logging.getLogger("paskia.access") _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 # Path to examples/index.html when running from source tree
_EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples" _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/iframe")
@app.get("/auth/restricted/oidc") @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.""" """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 # 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. The frontend handles mode detection (host mode vs full profile) based on settings.
Access control is handled via APIs. 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) @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 # Frontend static files - must be before /{token} catch-all routes
# (actual routes registered during lifespan after frontend.load())
frontend.route(app, "/") frontend.route(app, "/")
# Note: this catch-all handler must be the last route defined # Note: this catch-all handler must be the last route defined
@app.get("/{token}") @app.get("/{token}")
@app.get("/auth/{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). """Serve the reset app for reset tokens (password reset / device addition).
The frontend will validate the token via /auth/api/token-info. 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): if not passphrase.is_well_formed(token):
raise HTTPException(status_code=404) 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: def _get_dev_server() -> str | None:
"""Get the dev server URL from environment, or None if not in dev mode.""" """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: def _resolve_static_dir() -> Path:
+39
View File
@@ -2,6 +2,45 @@
import re 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: def patch_html_data_attrs(html: bytes, **data_attrs: str) -> bytes:
"""Patch HTML by adding data attributes to the <html> tag. """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}")) lines.append(line(f"Auth Host: {config.auth_host}"))
# Show frontend URL if in dev mode # Show frontend URL if in dev mode
devmode = os.environ.get("FASTAPI_VUE_FRONTEND_URL") devmode = os.environ.get("PASKIA_VITE_URL")
if devmode: if devmode:
lines.append(line(f"Dev Frontend: {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. """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. In production, reads from the static build directory.
This complements fastapi_vue.Frontend which handles static file serving This complements fastapi_vue.Frontend which handles static file serving
but doesn't provide server-side fetching of HTML content. but doesn't provide server-side fetching of HTML content.
""" """
import asyncio
import mimetypes import mimetypes
import os import os
from importlib import resources from importlib import resources
from pathlib import Path from pathlib import Path
import httpx import httpx
from fastapi import Response
__all__ = ["read"] __all__ = ["handle"]
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
def _resolve_static_dir() -> Path: def _resolve_static_dir() -> Path:
"""Resolve the static files directory."""
# Try packaged path via importlib.resources (works for wheel/installed). # Try packaged path via importlib.resources (works for wheel/installed).
try: # pragma: no cover - trivial path resolution pkg_dir = resources.files("paskia") / "frontend-build"
pkg_dir = resources.files("paskia") / "frontend-build" fs_path = Path(str(pkg_dir))
fs_path = Path(str(pkg_dir)) if fs_path.is_dir():
if fs_path.is_dir(): return fs_path
return fs_path
except Exception: # pragma: no cover - defensive
pass
# Fallback for editable/development before build. # Fallback for editable/development before build.
return Path(__file__).parent.parent / "frontend-build" return Path(__file__).parent.parent / "frontend-build"
@@ -41,31 +31,36 @@ def _resolve_static_dir() -> Path:
_static_dir: Path = _resolve_static_dir() _static_dir: Path = _resolve_static_dir()
async def read(filepath: str) -> tuple[bytes, int, dict[str, str]]: async def handle(request, frontend, filepath: str):
"""Read file content and return response tuple. """Read file content and return Response.
In dev mode, fetches from the Vite dev server. In dev mode, fetches from the Vite dev server.
In production, reads from the static build directory. In production, uses frontend.handle.
Args: Args:
request: The FastAPI Request object
frontend: The fastapi_vue.Frontend instance
filepath: Path relative to frontend root, e.g. "/auth/index.html" filepath: Path relative to frontend root, e.g. "/auth/index.html"
Returns: Returns:
Tuple of (content, status_code, headers) suitable for FastAPI Response object.
FastAPI Response(*args).
""" """
dev_server = _get_dev_server() if dev_server := os.environ.get("PASKIA_VITE_URL"):
if dev_server:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.get(f"{dev_server}{filepath}") resp = await client.get(f"{dev_server}{filepath}")
resp.raise_for_status() resp.raise_for_status()
mime = resp.headers.get("content-type", "application/octet-stream") mime = resp.headers.get("content-type", "application/octet-stream")
# Strip charset suffix if present # Strip charset suffix if present
mime = mime.split(";")[0].strip() mime = mime.split(";")[0].strip()
return resp.content, resp.status_code, {"content-type": mime} return Response(resp.content, resp.status_code, {"content-type": mime})
else:
# Production: read from static build # Read from frontend cache directly to bypass any compression/processing
file_path = _static_dir / filepath.lstrip("/") cached_content = getattr(frontend, "_files", {}).get(filepath)
content = await asyncio.to_thread(file_path.read_bytes) if cached_content is not None:
mime, _ = mimetypes.guess_type(str(file_path)) mime, _ = mimetypes.guess_type(filepath)
return content, 200, {"content-type": mime or "application/octet-stream"} return Response(
cached_content, 200, {"content-type": mime or "application/octet-stream"}
)
# Fallback to frontend.handle for cache negotiation
return frontend.handle(request, filepath)
-19
View File
@@ -188,25 +188,6 @@ class TestExceptionHandlers:
assert "iframe" in data["auth"] assert "iframe" in data["auth"]
# -------------------- Admin App Root --------------------
class TestAdminAppRoot:
"""Tests for the admin app root endpoint"""
@pytest.mark.asyncio
async def test_admin_app_root_with_auth(
self, client: httpx.AsyncClient, session_token: str
):
"""Admin app root returns HTML when authenticated."""
response = await client.get(
"/auth/api/admin/",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
assert "text/html" in response.headers.get("content-type", "")
# -------------------- Organization Tests -------------------- # -------------------- Organization Tests --------------------
-28
View File
@@ -355,34 +355,6 @@ class TestErrorHandling:
class TestForwardAuthHtmlResponse: class TestForwardAuthHtmlResponse:
"""Tests for forward auth HTML responses""" """Tests for forward auth HTML responses"""
@pytest.mark.asyncio
async def test_forward_401_html_response(self, client: httpx.AsyncClient):
"""Forward auth 401 should return HTML page for browser requests."""
response = await client.get(
"/auth/api/forward",
headers={"Accept": "text/html"},
)
assert response.status_code == 401
assert "text/html" in response.headers.get("content-type", "")
# HTML response should contain the mode data attribute
assert b"data-mode" in response.content or b"mode" in response.content
@pytest.mark.asyncio
async def test_forward_403_html_response(
self, client: httpx.AsyncClient, regular_session_token: str
):
"""Forward auth 403 should return HTML page for browser requests."""
response = await client.get(
"/auth/api/forward?perm=auth:admin",
headers={
**auth_headers(regular_session_token),
"Host": "localhost:4401",
"Accept": "text/html",
},
)
assert response.status_code == 403
assert "text/html" in response.headers.get("content-type", "")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_forward_with_expired_session_clears_cookie( async def test_forward_with_expired_session_clears_cookie(
self, client: httpx.AsyncClient self, client: httpx.AsyncClient