v1
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
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"}
|
||||
Reference in New Issue
Block a user