Fix static asset serving in devserver mode.
This commit is contained in:
@@ -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
@@ -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")
|
||||
|
||||
@@ -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/")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user