From 23591cfed6dfd13f930075e2cab0b16d50c236b1 Mon Sep 17 00:00:00 2001 From: Leopoldadmin Date: Wed, 3 Jun 2026 23:52:09 +0000 Subject: [PATCH] Upload app/main.py --- app/main.py | 208 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 app/main.py diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..8931c24 --- /dev/null +++ b/app/main.py @@ -0,0 +1,208 @@ +"""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()