Spaces:
Running
Running
| """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 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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.""" | |
| 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"}) | |
| 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)}) | |
| async def handle_auth(r: Request, e: AuthError) -> JSONResponse: | |
| return JSONResponse(status_code=401, content={"detail": str(e)}) | |
| async def handle_invalid_token(r: Request, e: InvalidTokenError) -> JSONResponse: | |
| return JSONResponse(status_code=401, content={"detail": "Invalid or malformed token"}) | |
| async def handle_expired_token(r: Request, e: ExpiredTokenError) -> JSONResponse: | |
| return JSONResponse(status_code=401, content={"detail": "Token has expired"}) | |
| async def handle_locked(r: Request, e: AccountLockedError) -> JSONResponse: | |
| return JSONResponse(status_code=423, content={"detail": str(e)}) | |
| async def handle_sub_expired(r: Request, e: SubscriptionExpiredError) -> JSONResponse: | |
| return JSONResponse(status_code=403, content={"detail": "This chatbot service is currently unavailable"}) | |
| async def handle_revoked(r: Request, e: AccessRevokedError) -> JSONResponse: | |
| return JSONResponse(status_code=403, content={"detail": "This chatbot service is currently unavailable"}) | |
| async def handle_bad_sub_token(r: Request, e: InvalidSubscriptionTokenError) -> JSONResponse: | |
| return JSONResponse(status_code=401, content={"detail": "Invalid or expired subscription token"}) | |
| async def handle_no_sub(r: Request, e: SubscriptionNotFoundError) -> JSONResponse: | |
| return JSONResponse(status_code=403, content={"detail": "No active subscription. Contact support."}) | |
| 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") | |
| async def root() -> RedirectResponse: | |
| return RedirectResponse(url="/author") | |
| 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) | |
| async def get_admin_redirect() -> RedirectResponse: | |
| """Legacy redirect β /admin β /author.""" | |
| return RedirectResponse(url="/author", status_code=301) | |
| 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) | |
| 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"}, | |
| ) | |
| async def health_check() -> dict: | |
| """System health β verifies DB, Redis, ChromaDB.""" | |
| from app.dependencies import check_health | |
| return await check_health() | |
| 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") | |
| async def favicon(): | |
| """Silence browser favicon 404 noise.""" | |
| from starlette.responses import Response | |
| return Response(status_code=204) | |
| # ββ Application Instance ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app = create_app() | |