Files
ledgerdock/backend/app/main.py

113 lines
3.9 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.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"
DEVELOPMENT_CORS_PRIVATE_ORIGIN_REGEX = (
r"^https?://("
r"localhost"
r"|127(?:\.\d{1,3}){3}"
r"|0\.0\.0\.0"
r"|10(?:\.\d{1,3}){3}"
r"|192\.168(?:\.\d{1,3}){2}"
r"|172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2}"
r")(?::\d+)?$"
)
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 create_app() -> FastAPI:
"""Builds and configures the FastAPI application instance."""
app = FastAPI(title="DCM DMS API", version="0.1.0")
app_env = settings.app_env.strip().lower()
allow_origin_regex = (
DEVELOPMENT_CORS_PRIVATE_ORIGIN_REGEX if app_env in {"development", "dev"} else None
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_origin_regex=allow_origin_regex,
allow_credentials=True,
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()
ensure_app_settings()
init_db()
try:
ensure_typesense_collection()
except Exception:
pass
try:
ensure_handwriting_style_collection()
except Exception:
pass
return app
app = create_app()