Percent-encoded traversal (..%2f) survived routing and let an unauthenticated request read files outside static/, including the database and session secret on the data volume. Paths are now resolved and contained to the frontend build directory; anything that escapes falls back to index.html.
110 lines
4.4 KiB
Python
110 lines
4.4 KiB
Python
"""Ten31Portal FastAPI application."""
|
|
|
|
import os
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.staticfiles import StaticFiles
|
|
from starlette.middleware.gzip import GZipMiddleware
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
from starlette.responses import FileResponse
|
|
|
|
from ten31portal.config import SESSION_SECRET
|
|
from ten31portal.db_init import run_migrations
|
|
from ten31portal.routers.auth_router import router as auth_router
|
|
from ten31portal.routers.audit_router import router as audit_router
|
|
from ten31portal.routers.entity_router import router as entity_router
|
|
from ten31portal.routers.holding_router import router as holding_router
|
|
from ten31portal.routers.position_router import router as position_router
|
|
from ten31portal.routers.round_router import router as round_router
|
|
from ten31portal.routers.import_router import router as import_router
|
|
from ten31portal.routers.user_router import router as user_router
|
|
from ten31portal.routers.document_router import router as document_router
|
|
from ten31portal.routers.capital_account_router import router as capital_account_router
|
|
from ten31portal.routers.capital_import_router import router as capital_import_router
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
run_migrations()
|
|
from ten31portal.storage import ensure_docs_dir
|
|
ensure_docs_dir()
|
|
yield
|
|
|
|
|
|
app = FastAPI(title="Ten31Portal", version="0.1.0", lifespan=lifespan)
|
|
app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET)
|
|
# Compress HTML/JS/CSS/JSON over the wire (the 320KB JS bundle gzips to ~80KB). Added after
|
|
# SessionMiddleware so it sits outermost and compresses the final response. minimum_size skips
|
|
# tiny payloads (health checks, small JSON) where compression isn't worth it.
|
|
app.add_middleware(GZipMiddleware, minimum_size=500)
|
|
app.include_router(auth_router)
|
|
app.include_router(audit_router)
|
|
app.include_router(entity_router)
|
|
app.include_router(holding_router)
|
|
app.include_router(position_router)
|
|
app.include_router(round_router)
|
|
app.include_router(import_router)
|
|
app.include_router(user_router)
|
|
app.include_router(document_router)
|
|
app.include_router(capital_account_router)
|
|
app.include_router(capital_import_router)
|
|
|
|
|
|
@app.get("/api/health")
|
|
def health() -> dict[str, str]:
|
|
return {"status": "ok"}
|
|
|
|
|
|
def _contained_static_path(static_root: Path, path: str) -> Path | None:
|
|
"""Resolve `path` under `static_root`, returning the file only if it stays
|
|
within the root. Percent-encoded traversal (..%2f) survives routing and
|
|
would otherwise let an unauthenticated caller read files outside static/
|
|
(e.g. the DB or session secret on the data volume). Returns None if the
|
|
resolved path escapes the root."""
|
|
root = static_root.resolve()
|
|
candidate = (root / path).resolve()
|
|
if candidate != root and root not in candidate.parents:
|
|
return None
|
|
return candidate
|
|
|
|
|
|
# Serve built frontend in production (when static/ dir exists next to the app)
|
|
_static_dir = Path(__file__).resolve().parent.parent / "static"
|
|
if _static_dir.is_dir():
|
|
# index.html must always be revalidated so a new build is picked up right after an
|
|
# upgrade (its hashed asset references change). Hashed assets themselves are immutable.
|
|
_NO_CACHE = {"Cache-Control": "no-cache, must-revalidate"}
|
|
# Vite content-hashes asset filenames (index-UPbvqVP1.js), so they can cache forever.
|
|
_IMMUTABLE = {"Cache-Control": "public, max-age=31536000, immutable"}
|
|
_ONE_DAY = {"Cache-Control": "public, max-age=86400"}
|
|
|
|
def _index() -> FileResponse:
|
|
return FileResponse(_static_dir / "index.html", headers=_NO_CACHE)
|
|
|
|
@app.api_route("/{path:path}", methods=["GET", "HEAD"])
|
|
async def serve_spa(path: str):
|
|
file = _contained_static_path(_static_dir, path)
|
|
if file is None:
|
|
return _index()
|
|
if file.is_file():
|
|
# Don't let the HTML entrypoint get cached; fingerprinted assets can cache.
|
|
if file.name == "index.html":
|
|
return _index()
|
|
if path.startswith("assets/"):
|
|
return FileResponse(file, headers=_IMMUTABLE)
|
|
if path in ("ten31-logo.png", "favicon.svg", "favicon.ico"):
|
|
return FileResponse(file, headers=_ONE_DAY)
|
|
return FileResponse(file)
|
|
return _index()
|
|
|
|
|
|
def cli() -> None:
|
|
import uvicorn
|
|
uvicorn.run("ten31portal.main:app", host="0.0.0.0", port=8000, reload=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
cli()
|