"""Service des fichiers statiques du frontend (SPA) par FastAPI.
Le build Vite est copie dans `settings.static_dir`. Toute route qui n'est ni
/api/... ni un fichier existant retourne index.html, afin que le routeur
cote client puisse gerer /map/:id et /s/:token.
"""
from __future__ import annotations
import logging
from pathlib import Path
from fastapi import APIRouter, FastAPI, Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
logger = logging.getLogger(__name__)
_PLACEHOLDER = """
Frontend non compile
Frontend non compile
L'API est disponible sur /api/health.
En developpement, lancez npm run dev dans frontend/.
"""
# Fichiers servis a la racine et qui ne doivent pas etre mis en cache longtemps.
_NO_CACHE_FILES = {"index.html", "sw.js", "manifest.webmanifest"}
def _safe_resolve(static_dir: Path, relative_path: str) -> Path | None:
"""Empeche toute sortie du dossier statique (path traversal)."""
candidate = (static_dir / relative_path.lstrip("/")).resolve()
try:
candidate.relative_to(static_dir.resolve())
except ValueError:
return None
return candidate
def mount_frontend(app: FastAPI, static_dir_path: str) -> None:
static_dir = Path(static_dir_path)
router = APIRouter(include_in_schema=False)
if (static_dir / "assets").is_dir():
# Les assets Vite sont hashes : cache long sans risque.
app.mount(
"/assets",
StaticFiles(directory=static_dir / "assets"),
name="assets",
)
@router.api_route("/{full_path:path}", methods=["GET", "HEAD"])
def serve_spa(full_path: str, request: Request): # type: ignore[no-untyped-def]
if full_path.startswith("api/"):
return JSONResponse(status_code=404, content={"detail": "Ressource introuvable."})
if not static_dir.is_dir():
return HTMLResponse(_PLACEHOLDER, status_code=200)
if full_path:
candidate = _safe_resolve(static_dir, full_path)
if candidate is not None and candidate.is_file():
headers = (
{"Cache-Control": "no-cache"}
if candidate.name in _NO_CACHE_FILES
else {"Cache-Control": "public, max-age=3600"}
)
return FileResponse(candidate, headers=headers)
index = static_dir / "index.html"
if index.is_file():
return FileResponse(index, headers={"Cache-Control": "no-cache"})
return HTMLResponse(_PLACEHOLDER, status_code=200)
app.include_router(router)