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