Files

208 lines
6.9 KiB
Python

"""FastAPI application entry point.
Wires up:
- CORS middleware (whitelist, NO wildcard)
- Security headers middleware (CSP, X-Frame-Options, X-Content-Type-Options, HSTS in prod)
- Global exception handlers (HTTPException, RequestValidationError, SQLAlchemyError)
- Startup/shutdown events (DB connection check, log level)
- Versioned API router mounts under /api/v1
- Root-level /health endpoint for Coolify healthcheck
"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from starlette.exceptions import HTTPException as StarletteHTTPException
from app import __version__
from app.api.v1 import (
accounts,
activities,
auth,
contacts,
dashboard,
deals,
health,
notes,
tags,
users,
)
from app.core.config import get_settings
from app.core.db import AsyncSessionLocal, dispose_engine
logger = logging.getLogger(__name__)
# === Lifespan ===
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan: startup and shutdown hooks."""
settings = get_settings()
logging.basicConfig(level=settings.LOG_LEVEL)
logger.info(
"Starting CRM System v%s in %s mode",
__version__,
settings.ENVIRONMENT,
)
# Verify DB connection on startup
try:
async with AsyncSessionLocal() as session:
await session.execute(text("SELECT 1"))
logger.info("Database connection OK (%s)", settings.DATABASE_URL.split("://", 1)[0])
except Exception as e:
logger.error("Database connection FAILED on startup: %s", e)
# Don't crash — let /health report the issue so Coolify can restart
yield
# Shutdown
logger.info("Shutting down CRM System")
await dispose_engine()
# === App ===
def create_app() -> FastAPI:
"""Application factory (allows easy testing override)."""
settings = get_settings()
app = FastAPI(
title="CRM System",
version=__version__,
docs_url="/docs",
redoc_url="/redoc",
openapi_url="/openapi.json",
lifespan=lifespan,
)
# === CORS ===
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# === Security Headers ===
@app.middleware("http")
async def security_headers_middleware(request: Request, call_next):
"""Inject CSP, X-Frame-Options, X-Content-Type-Options, and HSTS headers."""
response = await call_next(request)
settings_local = get_settings()
if settings_local.is_production:
# Prod: strict CSP (no unsafe-inline for scripts; TODO v1.1 add nonce)
csp = (
"default-src 'self'; "
"script-src 'self' https://cdn.tailwindcss.com; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"object-src 'none';"
)
else:
# Dev: allow inline scripts for fast iteration (Alpine.js x-data blocks)
csp = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"object-src 'none';"
)
response.headers["Content-Security-Policy"] = csp
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
if settings_local.is_production:
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains"
)
return response
# === Exception Handlers ===
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(
request: Request, exc: StarletteHTTPException
) -> JSONResponse:
"""Format HTTPException responses consistently."""
# Distinguish token-expired for FR-1.7 acceptance criterion
if exc.status_code == 401:
detail = exc.detail
if detail == "Could not validate credentials":
# Token invalid or expired — callers can detect this
return JSONResponse(
status_code=exc.status_code,
content={"detail": "token_expired_or_invalid"},
headers=exc.headers,
)
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
headers=exc.headers,
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
"""Format Pydantic validation errors consistently."""
from fastapi.encoders import jsonable_encoder
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={"detail": jsonable_encoder(exc.errors())},
)
@app.exception_handler(SQLAlchemyError)
async def sqlalchemy_exception_handler(
request: Request, exc: SQLAlchemyError
) -> JSONResponse:
"""Log DB errors and return a 500 without leaking internals."""
logger.exception("Database error on %s %s", request.method, request.url)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "Database error"},
)
# === Routers ===
# Health is mounted at both /health (root) and /api/v1/health (versioned)
app.include_router(health.router)
app.include_router(auth.router, prefix="/api/v1")
app.include_router(users.router, prefix="/api/v1")
# Phase 4b business routers
app.include_router(accounts.router, prefix="/api/v1")
app.include_router(contacts.router, prefix="/api/v1")
app.include_router(deals.router, prefix="/api/v1")
app.include_router(activities.router, prefix="/api/v1")
app.include_router(notes.router, prefix="/api/v1")
app.include_router(tags.router, prefix="/api/v1")
app.include_router(dashboard.router, prefix="/api/v1")
# === Static Files (Frontend) ===
# Mount app/webui/ on root AFTER all routers, so /api/v1/* still matches first.
# StaticFiles mit html=True servt index.html on `/` und alle .html files direkt.
webui_dir = Path(__file__).parent / "webui"
if webui_dir.exists():
app.mount("/", StaticFiles(directory=str(webui_dir), html=True), name="webui")
else:
logger.warning("webui directory not found at %s — frontend not served", webui_dir)
return app
app = create_app()