Files
ponzischeme89 6d44e05de4 v1
2026-04-18 07:23:55 +12:00

146 lines
5.5 KiB
Python

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"}