From 228a103d7386638887469fbc60bea72a5bd31785 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Wed, 18 Feb 2026 22:19:18 +0000 Subject: [PATCH] Fix static asset serving in devserver mode. --- paskia/fastapi/admin.py | 5 ++-- paskia/fastapi/api.py | 28 +++++++------------ paskia/fastapi/mainapp.py | 23 ++++++---------- paskia/util/frontend.py | 2 +- paskia/util/htmlutil.py | 39 +++++++++++++++++++++++++++ paskia/util/startupbox.py | 2 +- paskia/util/vitedev.py | 57 ++++++++++++++++++--------------------- tests/test_admin.py | 19 ------------- tests/test_api.py | 28 ------------------- 9 files changed, 87 insertions(+), 116 deletions(-) diff --git a/paskia/fastapi/admin.py b/paskia/fastapi/admin.py index 74ffc63..c55506f 100644 --- a/paskia/fastapi/admin.py +++ b/paskia/fastapi/admin.py @@ -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 -------------------- diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py index e14cb6c..5b27e5f 100644 --- a/paskia/fastapi/api.py +++ b/paskia/fastapi/api.py @@ -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") diff --git a/paskia/fastapi/mainapp.py b/paskia/fastapi/mainapp.py index 46b5039..88382fa 100644 --- a/paskia/fastapi/mainapp.py +++ b/paskia/fastapi/mainapp.py @@ -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/") diff --git a/paskia/util/frontend.py b/paskia/util/frontend.py index 505f280..421bcb1 100644 --- a/paskia/util/frontend.py +++ b/paskia/util/frontend.py @@ -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: diff --git a/paskia/util/htmlutil.py b/paskia/util/htmlutil.py index ed42e9f..611b6e1 100644 --- a/paskia/util/htmlutil.py +++ b/paskia/util/htmlutil.py @@ -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 tag. diff --git a/paskia/util/startupbox.py b/paskia/util/startupbox.py index 01313e8..f86daf2 100644 --- a/paskia/util/startupbox.py +++ b/paskia/util/startupbox.py @@ -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}")) diff --git a/paskia/util/vitedev.py b/paskia/util/vitedev.py index f4b34a0..cdbb384 100644 --- a/paskia/util/vitedev.py +++ b/paskia/util/vitedev.py @@ -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) diff --git a/tests/test_admin.py b/tests/test_admin.py index e26878d..ee05ec6 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -188,25 +188,6 @@ class TestExceptionHandlers: 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 -------------------- diff --git a/tests/test_api.py b/tests/test_api.py index eb86632..d951f30 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -355,34 +355,6 @@ class TestErrorHandling: class TestForwardAuthHtmlResponse: """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 async def test_forward_with_expired_session_clears_cookie( self, client: httpx.AsyncClient