Files
ledgerdock/backend/app/main.py

124 lines
4.5 KiB
Python

"""FastAPI entrypoint for the DMS backend service."""
from typing import Awaitable, Callable
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.api.router import api_router
from app.core.config import get_settings
from app.db.base import init_db
from app.services.app_settings import ensure_app_settings
from app.services.authentication import ensure_bootstrap_users
from app.services.handwriting_style import ensure_handwriting_style_collection
from app.services.storage import ensure_storage
from app.services.typesense_index import ensure_typesense_collection
settings = get_settings()
UPLOAD_ENDPOINT_PATH = "/api/v1/documents/upload"
UPLOAD_ENDPOINT_METHOD = "POST"
CORS_DEVELOPMENT_PRIVATE_ORIGIN_REGEX = (
r"^https?://("
r"localhost"
r"|127\.0\.0\.1"
r"|10\.\d{1,3}\.\d{1,3}\.\d{1,3}"
r"|192\.168\.\d{1,3}\.\d{1,3}"
r"|172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}"
r")(?::\d{1,5})?$"
)
def _is_upload_size_guard_target(request: Request) -> bool:
"""Returns whether upload request-size enforcement applies to this request.
Upload-size validation is intentionally scoped to the upload POST endpoint so CORS
preflight OPTIONS requests can pass through CORSMiddleware.
"""
return request.method.upper() == UPLOAD_ENDPOINT_METHOD and request.url.path == UPLOAD_ENDPOINT_PATH
def _resolve_cors_origin_regex() -> str | None:
"""Returns development-only private-network origin regex when explicitly enabled."""
app_env = settings.app_env.strip().lower()
if app_env not in {"development", "dev"}:
return None
allow_private_dev_origins = bool(getattr(settings, "cors_allow_development_private_network_origins", False))
if not allow_private_dev_origins:
return None
return CORS_DEVELOPMENT_PRIVATE_ORIGIN_REGEX
def create_app() -> FastAPI:
"""Builds and configures the FastAPI application instance."""
app = FastAPI(title="DCM DMS API", version="0.1.0")
allowed_origins = [origin.strip() for origin in settings.cors_origins if isinstance(origin, str) and origin.strip()]
allowed_origin_regex = _resolve_cors_origin_regex()
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
allow_origin_regex=allowed_origin_regex,
allow_credentials=bool(getattr(settings, "cors_allow_credentials", False)),
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix="/api/v1")
@app.middleware("http")
async def enforce_upload_request_size(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
"""Rejects only POST upload bodies without deterministic length or with oversized request totals."""
if _is_upload_size_guard_target(request):
content_length = request.headers.get("content-length", "").strip()
if not content_length:
return JSONResponse(
status_code=411,
content={"detail": "Content-Length header is required for document uploads"},
)
try:
content_length_value = int(content_length)
except ValueError:
return JSONResponse(status_code=400, content={"detail": "Invalid Content-Length header"})
if content_length_value <= 0:
return JSONResponse(status_code=400, content={"detail": "Content-Length must be a positive integer"})
if content_length_value > settings.max_upload_request_size_bytes:
return JSONResponse(
status_code=413,
content={
"detail": (
"Upload request exceeds total size limit "
f"({content_length_value} > {settings.max_upload_request_size_bytes} bytes)"
)
},
)
return await call_next(request)
@app.on_event("startup")
def startup_event() -> None:
"""Initializes storage directories and database schema on service startup."""
ensure_storage()
init_db()
ensure_bootstrap_users()
ensure_app_settings()
try:
ensure_typesense_collection()
except Exception:
pass
try:
ensure_handwriting_style_collection()
except Exception:
pass
return app
app = create_app()