146 lines
5.5 KiB
Python
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"}
|