M2-T11: serve React SPA from FastAPI and remove Jinja pages
- app/main.py serves the SPA build (SPA_DIST_DIR, default frontend/dist): mounts /assets and a GET catch-all returning index.html for client routes; catch-all 404s on /api/*, never swallows /docs, /openapi.json, /static, assets, ingestion/ticktick/status; skips SPA serving when dist absent (backend-only CI) - delete app/api/routes/pages.py, app/api/routes/auth.py, app/templates/ (all replaced by /api/* + SPA; auth service layer kept) - remove/replace Jinja page tests (JSON coverage already in test_api_*); add tests/test_spa_hosting.py for the fallback contract - regenerate openapi/ (Jinja paths gone) and frontend schema.d.ts
This commit is contained in:
+49
-5
@@ -1,7 +1,10 @@
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
@@ -11,8 +14,7 @@ from app import models # noqa: F401
|
||||
from app.api.routes.api.config import router as api_config_router
|
||||
from app.api.routes.api.data import router as api_data_router
|
||||
from app.api.routes.api.session import router as api_session_router
|
||||
from app.api.routes.auth import router as auth_router
|
||||
from app.api.routes import pages, status
|
||||
from app.api.routes import status
|
||||
from app.db import get_session_local
|
||||
from app.api.routes.homeassistant import router as homeassistant_router
|
||||
from app.api.routes.location import router as location_router
|
||||
@@ -25,6 +27,17 @@ from app.services.config_page import seed_missing_config_from_bootstrap, sync_ap
|
||||
from app.services.public_ip import check_public_ipv4_and_notify
|
||||
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _get_spa_dist_dir() -> Path:
|
||||
env_val = os.environ.get("SPA_DIST_DIR")
|
||||
if env_val:
|
||||
return Path(env_val)
|
||||
return _REPO_ROOT / "frontend" / "dist"
|
||||
|
||||
|
||||
def _run_scheduled_public_ip_check() -> None:
|
||||
session_local = get_session_local()
|
||||
@@ -92,8 +105,6 @@ def create_app() -> FastAPI:
|
||||
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
|
||||
app.include_router(status.router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(pages.router)
|
||||
app.include_router(api_config_router)
|
||||
app.include_router(api_data_router)
|
||||
app.include_router(api_session_router)
|
||||
@@ -102,6 +113,39 @@ def create_app() -> FastAPI:
|
||||
app.include_router(poo_router)
|
||||
app.include_router(public_ip_router)
|
||||
app.include_router(ticktick_router)
|
||||
|
||||
# SPA hosting: mount frontend/dist if it exists and has index.html.
|
||||
# If the SPA dist is absent (e.g. backend-only CI), skip SPA serving entirely
|
||||
# so that pytest stays green with only the API routes registered.
|
||||
spa_dist = _get_spa_dist_dir()
|
||||
spa_index = spa_dist / "index.html"
|
||||
if spa_dist.is_dir() and spa_index.is_file():
|
||||
spa_assets = spa_dist / "assets"
|
||||
if spa_assets.is_dir():
|
||||
app.mount("/assets", StaticFiles(directory=spa_assets), name="spa-assets")
|
||||
|
||||
# Resolve the dist root once so the containment check is fast and consistent.
|
||||
_spa_root = spa_dist.resolve()
|
||||
|
||||
@app.get("/{full_path:path}", include_in_schema=False)
|
||||
async def spa_fallback(full_path: str, request: Request) -> FileResponse: # noqa: RUF029
|
||||
# Explicit 404 for unmatched /api/* — never return index.html for API paths.
|
||||
if full_path.startswith("api/"):
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
# Resolve candidate to an absolute path and verify it stays within the SPA
|
||||
# dist root. Without this check, URL-encoded ".." sequences (e.g. "..%2f")
|
||||
# bypass Starlette's path parameter handling and allow arbitrary file reads.
|
||||
candidate = (spa_dist / full_path).resolve()
|
||||
if candidate.is_file() and candidate.is_relative_to(_spa_root):
|
||||
return FileResponse(candidate)
|
||||
# For any path outside the dist root, or for SPA client routes that don't
|
||||
# correspond to a real file, return index.html so the SPA router handles it.
|
||||
return FileResponse(spa_index)
|
||||
else:
|
||||
logger.warning(
|
||||
"SPA dist not found at %s — SPA hosting disabled (API-only mode).", spa_dist
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user