"""Author RAG Chatbot SaaS — FastAPI Application Factory. Routing: /api/chat/{author_slug} — Widget chat endpoint /api/admin/{author_slug}/* — Per-author admin endpoints /api/super/* — SuperAdmin endpoints (TOTP required) /api/widget/token — Signed widget token generation /admin — Author Admin SPA /superadmin — SuperAdmin SPA /widget — Embeddable widget test page /health — Health check / — Redirect to /admin Startup logic lives in: app/core/startup/db.py — Table creation and column migrations app/core/startup/seeder.py — Superadmin account bootstrap """ from contextlib import asynccontextmanager from pathlib import Path from typing import AsyncGenerator import structlog from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from app.api.chat import router as chat_router from app.api.ingest import router as ingest_router from app.api.widget import router as widget_router from app.api.schemas_router import router as schemas_router from app.admin.router import router as admin_router from app.superadmin.router import router as superadmin_router from app.config import get_settings from app.exceptions.base import AppException from app.exceptions.auth import AuthError, InvalidTokenError, ExpiredTokenError, AccountLockedError from app.exceptions.access import ( SubscriptionExpiredError, AccessRevokedError, BudgetExhaustedError, InvalidSubscriptionTokenError, SubscriptionNotFoundError, ) from app.middleware.logging_middleware import LoggingMiddleware from app.middleware.rate_limit_middleware import RateLimitMiddleware from app.middleware.security_headers import SecurityHeadersMiddleware from app.core.startup.db import init_db from app.core.startup.seeder import seed_superadmin logger = structlog.get_logger(__name__) ROOT_DIR = Path(__file__).resolve().parent.parent STATIC_DIR = ROOT_DIR / "static" ADMIN_TEMPLATE = ROOT_DIR / "app" / "admin" / "templates" / "admin.html" SUPERADMIN_TEMPLATE = ROOT_DIR / "app" / "superadmin" / "templates" / "superadmin.html" WIDGET_TEMPLATE = ROOT_DIR / "static" / "addon.html" # ── Lifespan ────────────────────────────────────────────────────────────────── @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Application startup / shutdown lifecycle.""" cfg = get_settings() logger.info("Starting AuthorRAG", env=cfg.APP_ENV) if Path("/data").exists(): logger.info("Persistent HF storage detected at /data") else: logger.warning("No /data mount — data will not persist across rebuilds") await init_db() await seed_superadmin(cfg) yield logger.info("AuthorRAG shutting down") # ── App Factory ─────────────────────────────────────────────────────────────── def create_app() -> FastAPI: """Create and configure the FastAPI application.""" cfg = get_settings() # R-133: Disable OpenAPI docs in production to prevent API reconnaissance docs = "/docs" if cfg.APP_ENV != "production" else None redoc = "/redoc" if cfg.APP_ENV != "production" else None app = FastAPI( title="Author RAG — AI Book Sales Chatbot SaaS", description=( "Multi-tenant SaaS platform that deploys a persuasive AI chatbot " "on any author's website. Powered by a 12-step RAG pipeline." ), version="1.0.0", docs_url=docs, redoc_url=redoc, lifespan=lifespan, ) # R-100: Sentry error tracking (optional) if hasattr(cfg, "SENTRY_DSN") and cfg.SENTRY_DSN: try: import importlib sentry_sdk = importlib.import_module("sentry_sdk") sentry_sdk.init( dsn=cfg.SENTRY_DSN, traces_sample_rate=0.1, environment=cfg.APP_ENV, ) except (ImportError, Exception): logger.warning("sentry-sdk not installed — error tracking disabled") _register_middleware(app, cfg) _register_exception_handlers(app) _register_routers(app) _register_public_routes(app) return app # ── Internal Configuration Helpers ─────────────────────────────────────────── def _register_middleware(app: FastAPI, cfg) -> None: """Register middleware (outermost first).""" app.add_middleware(LoggingMiddleware) app.add_middleware(SecurityHeadersMiddleware) app.add_middleware(RateLimitMiddleware) try: from app.middleware.metrics import PrometheusMiddleware app.add_middleware(PrometheusMiddleware) except ImportError: pass cors_origins = cfg.ALLOWED_ORIGINS if cfg.APP_ENV == "production" else ["*"] app.add_middleware( CORSMiddleware, allow_origins=cors_origins, allow_credentials=False, allow_methods=["*"], allow_headers=["*"], ) def _register_exception_handlers(app: FastAPI) -> None: """Map domain exceptions to HTTP responses.""" @app.exception_handler(Exception) async def handle_generic(r: Request, e: Exception) -> JSONResponse: logger.error("Unhandled exception", error=str(e), path=r.url.path, exc_info=True) return JSONResponse(status_code=500, content={"detail": "An internal error occurred"}) @app.exception_handler(AppException) async def handle_app(r: Request, e: AppException) -> JSONResponse: logger.error("AppException", error=str(e), path=r.url.path) return JSONResponse(status_code=500, content={"detail": str(e)}) @app.exception_handler(AuthError) async def handle_auth(r: Request, e: AuthError) -> JSONResponse: return JSONResponse(status_code=401, content={"detail": str(e)}) @app.exception_handler(InvalidTokenError) async def handle_invalid_token(r: Request, e: InvalidTokenError) -> JSONResponse: return JSONResponse(status_code=401, content={"detail": "Invalid or malformed token"}) @app.exception_handler(ExpiredTokenError) async def handle_expired_token(r: Request, e: ExpiredTokenError) -> JSONResponse: return JSONResponse(status_code=401, content={"detail": "Token has expired"}) @app.exception_handler(AccountLockedError) async def handle_locked(r: Request, e: AccountLockedError) -> JSONResponse: return JSONResponse(status_code=423, content={"detail": str(e)}) @app.exception_handler(SubscriptionExpiredError) async def handle_sub_expired(r: Request, e: SubscriptionExpiredError) -> JSONResponse: return JSONResponse(status_code=403, content={"detail": "This chatbot service is currently unavailable"}) @app.exception_handler(AccessRevokedError) async def handle_revoked(r: Request, e: AccessRevokedError) -> JSONResponse: return JSONResponse(status_code=403, content={"detail": "This chatbot service is currently unavailable"}) @app.exception_handler(InvalidSubscriptionTokenError) async def handle_bad_sub_token(r: Request, e: InvalidSubscriptionTokenError) -> JSONResponse: return JSONResponse(status_code=401, content={"detail": "Invalid or expired subscription token"}) @app.exception_handler(SubscriptionNotFoundError) async def handle_no_sub(r: Request, e: SubscriptionNotFoundError) -> JSONResponse: return JSONResponse(status_code=403, content={"detail": "No active subscription. Contact support."}) @app.exception_handler(BudgetExhaustedError) async def handle_budget(r: Request, e: BudgetExhaustedError) -> JSONResponse: return JSONResponse(status_code=200, content={"message": "I'm taking a short break to recharge! Check back soon."}) def _register_routers(app: FastAPI) -> None: """Register all API routers with their URL prefixes.""" app.include_router(chat_router, prefix="/api/chat", tags=["Chat"]) app.include_router(ingest_router, prefix="/api/admin", tags=["Ingestion"]) app.include_router(widget_router, prefix="/api/widget", tags=["Widget"]) app.include_router(admin_router, prefix="/api/admin", tags=["Admin"]) app.include_router(superadmin_router, prefix="/api/super", tags=["SuperAdmin"]) app.include_router(schemas_router, prefix="/api", tags=["Auth"]) from app.api.billing import router as billing_router app.include_router(billing_router, prefix="/api", tags=["Billing"]) from app.api.auth_public import router as auth_public_router app.include_router(auth_public_router, prefix="/api", tags=["Auth – Public"]) from app.api.contact import router as contact_router app.include_router(contact_router, prefix="/api", tags=["Contact"]) from app.api.public_chat import router as public_chat_router app.include_router(public_chat_router, prefix="/api", tags=["Public Chat"]) from app.api.onboarding import router as onboarding_router app.include_router(onboarding_router, prefix="/api/admin", tags=["Onboarding"]) from app.api.conversations import router as conversations_router app.include_router(conversations_router, prefix="/api/admin", tags=["Conversations"]) from app.api.analytics_export import router as analytics_router app.include_router(analytics_router, prefix="/api/admin", tags=["Analytics"]) from app.api.ab_testing import router as ab_router app.include_router(ab_router, prefix="/api/admin", tags=["A/B Testing"]) from app.api.webhooks import router as webhooks_router app.include_router(webhooks_router, prefix="/api/admin", tags=["Webhooks"]) from app.api.referral import router as referral_router app.include_router(referral_router, prefix="/api/admin", tags=["Referral"]) from app.api.white_label import router as white_label_router app.include_router(white_label_router, prefix="/api/admin", tags=["White-Label"]) from app.api.public_api import router as public_api_router app.include_router(public_api_router, prefix="/api", tags=["Public API v1"]) from app.api.email_integrations import router as email_router app.include_router(email_router, prefix="/api/admin", tags=["Email Integrations"]) # Phase 4 — Enterprise from app.api.agency import router as agency_router app.include_router(agency_router, prefix="/api/admin", tags=["Agency"]) from app.api.gdpr import router as gdpr_router app.include_router(gdpr_router, prefix="/api/admin", tags=["GDPR"]) from app.api.stripe_connect import router as connect_router app.include_router(connect_router, prefix="/api/admin", tags=["Stripe Connect"]) from app.api.sso import router as sso_router app.include_router(sso_router, prefix="/api/admin", tags=["SSO"]) # SSO public endpoints (no /api/admin prefix — browser redirects) app.include_router(sso_router, tags=["SSO Public"]) def _register_public_routes(app: FastAPI) -> None: """Static files, SPA pages, and health endpoints.""" import os if STATIC_DIR.is_dir(): app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") covers_dir = "/data/covers" os.makedirs(covers_dir, exist_ok=True) app.mount("/covers", StaticFiles(directory=covers_dir), name="covers") @app.get("/", include_in_schema=False) async def root() -> RedirectResponse: return RedirectResponse(url="/author") @app.get("/author", include_in_schema=False) async def get_author_dashboard() -> FileResponse: """Author Dashboard SPA (was /admin).""" if not ADMIN_TEMPLATE.is_file(): raise HTTPException(404, "Author panel not found") return FileResponse(ADMIN_TEMPLATE) @app.get("/admin", include_in_schema=False) async def get_admin_redirect() -> RedirectResponse: """Legacy redirect — /admin → /author.""" return RedirectResponse(url="/author", status_code=301) @app.get("/superadmin", include_in_schema=False) async def get_superadmin() -> FileResponse: """SuperAdmin SPA (TOTP required).""" if not SUPERADMIN_TEMPLATE.is_file(): raise HTTPException(404, "SuperAdmin panel not found") return FileResponse(SUPERADMIN_TEMPLATE) @app.get("/widget", include_in_schema=False) async def get_widget() -> FileResponse: """Embeddable widget test page.""" if not WIDGET_TEMPLATE.is_file(): raise HTTPException(404, "Widget not found") return FileResponse( WIDGET_TEMPLATE, headers={"Cache-Control": "no-cache, no-store, must-revalidate"}, ) @app.get("/health", tags=["Health"]) async def health_check() -> dict: """System health — verifies DB, Redis, ChromaDB.""" from app.dependencies import check_health return await check_health() @app.get("/metrics", include_in_schema=False) async def prometheus_metrics(): """R-099: Prometheus metrics endpoint.""" from starlette.responses import Response try: from app.middleware.metrics import get_metrics_response body, content_type = get_metrics_response() return Response(content=body, media_type=content_type) except ImportError: return Response(content=b"# metrics not available\n", media_type="text/plain") @app.get("/favicon.ico", include_in_schema=False) async def favicon(): """Silence browser favicon 404 noise.""" from starlette.responses import Response return Response(status_code=204) # ── Application Instance ────────────────────────────────────────────────────── app = create_app()