Arag / app /main.py
AuthorBot
Move growth features to SuperAdmin; rename /admin to /author.
d00f7c1
Raw
History Blame Contribute Delete
14.3 kB
"""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()