162 lines
6.1 KiB
Python
162 lines
6.1 KiB
Python
"""The hosted application.
|
|
|
|
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1
|
|
|
|
One worker is load-bearing, not a leftover: uploads and the last analysis live
|
|
in this process's memory, so with two workers a user's upload and their analyse
|
|
request could land in different processes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
from contextlib import asynccontextmanager
|
|
from datetime import timedelta
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
from fastapi import FastAPI, Request, status
|
|
from fastapi.responses import JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from sqlalchemy import delete
|
|
|
|
from app import errors
|
|
from app.bootstrap import seed_admin
|
|
from app.config import get_settings
|
|
from app.db import SessionLocal, create_schema
|
|
from app.models import AuthSession, EmailToken, utcnow
|
|
from app.routers import admin, analysis, auth, pages
|
|
from app.services.ratelimit import limiter
|
|
from app.services.store import WorkspaceStore, set_store, store_of
|
|
|
|
log = logging.getLogger(__name__)
|
|
settings = get_settings()
|
|
|
|
WEB = Path(__file__).resolve().parent.parent / "web"
|
|
SAFE_METHODS = {"GET", "HEAD", "OPTIONS", "TRACE"}
|
|
SWEEP_INTERVAL_S = 60
|
|
TOKEN_RETENTION = timedelta(days=7)
|
|
_STARTED = time.monotonic()
|
|
|
|
|
|
def _workspace_root() -> Path:
|
|
base = settings.WORKSPACE_ROOT or tempfile.gettempdir()
|
|
return Path(base) / "oob-workspaces"
|
|
|
|
|
|
def _sweeper(store: WorkspaceStore, stop: threading.Event) -> None:
|
|
"""Housekeeping, in a thread rather than an asyncio task: removing a few
|
|
hundred megabytes of uploads is blocking work and would stall the loop."""
|
|
while not stop.wait(SWEEP_INTERVAL_S):
|
|
try:
|
|
store.sweep()
|
|
limiter.prune()
|
|
with SessionLocal() as db:
|
|
now = utcnow()
|
|
db.execute(delete(AuthSession).where(AuthSession.expires_at < now))
|
|
db.execute(delete(EmailToken).where(
|
|
EmailToken.expires_at < now - TOKEN_RETENTION))
|
|
db.commit()
|
|
except Exception: # noqa: BLE001 - never kill the sweeper
|
|
log.exception("sweep failed")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
logging.basicConfig(level=settings.LOG_LEVEL,
|
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
log.info("%s starting", settings.APP_NAME)
|
|
log.info("base url %s, cookie secure=%s", settings.base_url, settings.cookie_secure)
|
|
if not settings.cookie_secure and settings.base_url.startswith("https://"):
|
|
log.warning("COOKIE_SECURE is off but APP_BASE_URL is https -- "
|
|
"session cookies will travel without the Secure flag")
|
|
|
|
create_schema()
|
|
with SessionLocal() as db:
|
|
seed_admin(db, settings)
|
|
|
|
store = WorkspaceStore(
|
|
_workspace_root(),
|
|
idle_ttl_s=settings.WORKSPACE_IDLE_TTL_MIN * 60,
|
|
max_workspaces=settings.MAX_ACTIVE_WORKSPACES,
|
|
max_total_bytes=settings.MAX_TOTAL_WORKSPACE_BYTES,
|
|
)
|
|
store.start()
|
|
set_store(store)
|
|
|
|
stop = threading.Event()
|
|
thread = threading.Thread(target=_sweeper, args=(store, stop),
|
|
name="workspace-sweeper", daemon=True)
|
|
thread.start()
|
|
log.info("ready on %s", settings.base_url)
|
|
try:
|
|
yield
|
|
finally:
|
|
stop.set()
|
|
thread.join(timeout=5)
|
|
store.dispose_all()
|
|
log.info("stopped")
|
|
|
|
|
|
app = FastAPI(title=settings.APP_NAME, lifespan=lifespan,
|
|
docs_url=None, redoc_url=None, openapi_url=None)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def guard(request: Request, call_next):
|
|
"""Body-size ceiling, cross-origin write guard, and security headers."""
|
|
if request.method not in SAFE_METHODS:
|
|
declared = request.headers.get("content-length")
|
|
if declared and declared.isdigit() and int(declared) > settings.MAX_UPLOAD_BYTES:
|
|
# Refuse before reading the body. Starlette has no size limit of
|
|
# its own, so without this a large POST is fully buffered first.
|
|
return JSONResponse(
|
|
{"error": "That file is too large."},
|
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE)
|
|
|
|
# SameSite=Lax already blocks cross-site cookie-bearing writes; this
|
|
# covers the multipart upload route, which a plain HTML form can forge.
|
|
origin = request.headers.get("origin") or request.headers.get("referer")
|
|
if origin:
|
|
host = urlparse(origin).netloc.lower()
|
|
allowed = {urlparse(settings.base_url).netloc.lower(),
|
|
(request.headers.get("host") or "").lower()}
|
|
if host and host not in allowed:
|
|
return JSONResponse({"error": "Bad origin."},
|
|
status_code=status.HTTP_403_FORBIDDEN)
|
|
|
|
response = await call_next(request)
|
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
|
response.headers.setdefault("X-Frame-Options", "DENY")
|
|
# Reset and verification tokens ride in the query string, so no Referer.
|
|
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
|
response.headers.setdefault(
|
|
"Content-Security-Policy",
|
|
"default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "
|
|
"script-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
|
|
if request.url.path.startswith("/api/"):
|
|
response.headers["Cache-Control"] = "no-store"
|
|
return response
|
|
|
|
|
|
errors.install(app)
|
|
app.include_router(pages.router)
|
|
app.include_router(auth.router)
|
|
app.include_router(analysis.router)
|
|
app.include_router(admin.router)
|
|
|
|
# Component-wise containment, ETags and 304s for free. The local serve.py does
|
|
# this with a str.startswith prefix check, which a sibling directory would pass.
|
|
app.mount("/static", StaticFiles(directory=WEB), name="static")
|
|
|
|
|
|
@app.get("/statusz", include_in_schema=False)
|
|
def statusz() -> dict:
|
|
"""Cheap operational counters. No account data, so no auth needed."""
|
|
store = store_of()
|
|
return {"workspaces": len(store), "bytes": store.total_bytes(),
|
|
"uptime_s": int(time.monotonic() - _STARTED)}
|