import asyncio import traceback import uuid from contextlib import asynccontextmanager, suppress from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from jose import JWTError from slowapi import _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded from sqlalchemy import select from starlette.middleware.base import BaseHTTPMiddleware from app.config import settings from app.database import AsyncSessionLocal, engine from app.services.experiments import sync_experiment_registry from app.services.notifications import notification_automation_loop from app.middleware.rate_limit import limiter from app.middleware.logging import RequestLogMiddleware from app.routers import auth, pages, posts, settings as settings_router, sections from app.routers import analytics as analytics_router from app.routers import audit as audit_router from app.routers import contact as contact_router from app.routers import experiments as experiments_router from app.routers import members as members_router @asynccontextmanager async def lifespan(app: FastAPI): async with AsyncSessionLocal() as session: await sync_experiment_registry(session) await session.commit() notification_task = asyncio.create_task(notification_automation_loop()) try: yield finally: notification_task.cancel() with suppress(asyncio.CancelledError): await notification_task await engine.dispose() class SecurityHeadersMiddleware(BaseHTTPMiddleware): """Apply baseline browser-facing hardening headers to every response.""" async def dispatch(self, request: Request, call_next): response = await call_next(request) response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" response.headers["Content-Security-Policy"] = ( "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'" ) response.headers["Strict-Transport-Security"] = ( "max-age=31536000; includeSubDomains" ) response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" return response app = FastAPI( title="Goodwalk CMS API", version="1.0.0", description="CMS API for the Goodwalk marketing site", lifespan=lifespan, docs_url="/docs" if settings.ENABLE_DOCS else None, redoc_url="/redoc" if settings.ENABLE_DOCS else None, ) # Rate limiter app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # CORS app.add_middleware( CORSMiddleware, allow_origins=settings.allowed_origins_list, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.add_middleware(SecurityHeadersMiddleware) app.add_middleware(RequestLogMiddleware) # Routers — all under /api/v1/ API_PREFIX = "/api/v1" app.include_router(pages.router, prefix=API_PREFIX) app.include_router(posts.router, prefix=API_PREFIX) app.include_router(settings_router.router, prefix=API_PREFIX) app.include_router(auth.router, prefix=API_PREFIX) # Legacy-compatible section endpoints (no /api/v1 prefix — paths match existing frontend) app.include_router(sections.router) # Analytics — ingest endpoint is public (/api/analytics/event), summary is authed (/api/v1/analytics/summary) app.include_router(analytics_router.router) app.include_router(contact_router.router) app.include_router(experiments_router.router) app.include_router(members_router.router, prefix=API_PREFIX) app.include_router(audit_router.router, prefix=API_PREFIX) @app.exception_handler(Exception) async def unhandled_exception_handler(request: Request, exc: Exception): """Catch unhandled exceptions, log them for authenticated members, return 500.""" auth_header = request.headers.get("Authorization", "") if auth_header.startswith("Bearer "): token = auth_header[7:] try: from app.auth.jwt import verify_access_token from app.models.member import Member from app.services.audit import log_audit payload = verify_access_token(token) if payload.get("role") == "member": member_uuid = uuid.UUID(payload["sub"]) async with AsyncSessionLocal() as session: result = await session.execute( select(Member).where(Member.id == member_uuid) ) member = result.scalars().first() await log_audit( session, member_id=member_uuid, member_email=member.email if member else None, action_type="error", area=str(request.url.path), description=f"Unhandled error: {type(exc).__name__}", status="error", error_message=str(exc)[:500], error_detail=traceback.format_exc()[:4000], ip_address=request.client.host if request.client else None, user_agent=request.headers.get("User-Agent"), ) await session.commit() except (JWTError, ValueError, Exception): pass # Never let audit logging suppress the original error response return JSONResponse(status_code=500, content={"error": "Internal server error"}) @app.get("/health", tags=["Health"]) async def health_check(): return {"status": "ok"}