""" app.py — LibBee v3.5 FastAPI entry point: CORS, middleware, lifespan, router mounting. Changes over v3.1: - Maintenance mode middleware now reads from JsonRuntimeStore (admin-editable) instead of settings.maintenance_mode (env var, static at startup). - CORS origins updated: added ku-library.github.io. - Section headers added throughout. """ # ── Imports ──────────────────────────────────────────────────────────────────── import asyncio import logging import time from contextlib import asynccontextmanager from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse try: from cachetools import TTLCache _ttl_cache_available = True except ImportError: _ttl_cache_available = False from src.config import get_settings, LIBBEE_VERSION from src.services.cache_service import CacheService from src.services.metrics_service import MetricsService from src.services.rag_service import RAGService logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # ── Service Instances ────────────────────────────────────────────────────────── rag_service = RAGService() cache_service = CacheService() metrics_service = MetricsService(get_settings().metrics_path) # ── Rate-limit Request Log ───────────────────────────────────────────────────── # TTLCache auto-evicts entries older than ttl seconds. # Falls back to a plain deque-based dict if cachetools is not installed. if _ttl_cache_available: _request_log = TTLCache(maxsize=10_000, ttl=120) _request_log_lock = asyncio.Lock() else: from collections import defaultdict, deque _request_log = defaultdict(deque) # type: ignore[assignment] _request_log_lock = asyncio.Lock() # ── Public Accessors ─────────────────────────────────────────────────────────── # Routers import these instead of importing module-level globals directly, # which avoids circular-import issues at import time. def get_rag_service() -> RAGService: return rag_service def get_metrics_service() -> MetricsService: return metrics_service # ── Background Tasks ─────────────────────────────────────────────────────────── async def _metrics_flush_loop(interval: int = 30) -> None: """Flush in-memory metric counters to disk every `interval` seconds.""" while True: await asyncio.sleep(interval) try: await metrics_service.flush() except Exception as exc: logger.warning("Metrics flush error: %s", exc) # ── Lifespan ─────────────────────────────────────────────────────────────────── @asynccontextmanager async def lifespan(app: FastAPI): from src.services.staff_service import build_staff_index settings = get_settings() # Build staff index try: build_staff_index() logger.info("Staff index built") except Exception as exc: logger.error("Staff index build failed: %s", exc) # Initialise RAG service if settings.openai_api_key: try: await rag_service.initialize(openai_api_key=settings.openai_api_key) logger.info("RAG service ready — chunks: %d", len(rag_service.bm25_corpus)) except Exception as exc: logger.error("RAG initialization failed: %s", exc, exc_info=True) else: logger.warning("OPENAI_API_KEY not set — RAG disabled") # Start background metrics flush flush_task = asyncio.create_task(_metrics_flush_loop(interval=30)) logger.info("LibBee startup complete") yield # Graceful shutdown — flush remaining metrics flush_task.cancel() try: await asyncio.wait_for(metrics_service.flush(), timeout=5.0) except Exception: pass logger.info("LibBee shutting down") # ── FastAPI App ──────────────────────────────────────────────────────────────── app = FastAPI( title="LibBee - KU Library AI", description="Khalifa University Library AI Assistant", version=LIBBEE_VERSION, lifespan=lifespan, ) # ── CORS Middleware ──────────────────────────────────────────────────────────── app.add_middleware( CORSMiddleware, allow_origins=[ "https://nikeshn.github.io", "https://ku-library.github.io", # added v3.5 "http://localhost:8080", "http://localhost:3000", "http://127.0.0.1:5500", ], allow_credentials=True, allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], allow_headers=["*"], max_age=86400, ) # ── Operational Guardrails Middleware ────────────────────────────────────────── @app.middleware("http") async def security_headers(request: Request, call_next): """ Add security headers to every response. X-Content-Type-Options — prevents MIME sniffing X-Frame-Options — prevents clickjacking Referrer-Policy — limits referrer leakage X-XSS-Protection — legacy XSS filter (belt-and-braces) Permissions-Policy — disables unused browser features """ response = await call_next(request) response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "SAMEORIGIN" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" response.headers["X-XSS-Protection"] = "1; mode=block" response.headers["Permissions-Policy"] = ( "geolocation=(), microphone=(), camera=(), payment=()" ) return response @app.middleware("http") async def operational_guardrails(request: Request, call_next): settings = get_settings() # ── Maintenance mode ─────────────────────────────────────────────────────── # FIX v3.5: read from JsonRuntimeStore (admin-editable) rather than # settings.maintenance_mode (env var, static at startup). This means # toggling maintenance via the admin dashboard actually takes effect. _maintenance_on = False try: from src.services.runtime_store import JsonRuntimeStore _store = JsonRuntimeStore(settings.config_path, default={"maintenance_mode": False}) _maintenance_on = bool(_store.load().get("maintenance_mode", False)) except Exception: # If store is unreadable fall back to env-var setting _maintenance_on = settings.maintenance_mode _exempt_paths = {"/", "/config", "/admin", "/admin/login", "/admin/auth"} if _maintenance_on and request.url.path not in _exempt_paths: return JSONResponse( status_code=503, content={"detail": "LibBee is in maintenance mode. Please try again shortly."}, ) # ── Rate limiting ────────────────────────────────────────────────────────── client_ip = request.client.host if request.client else "unknown" now = time.time() async with _request_log_lock: if _ttl_cache_available: timestamps = _request_log.get(client_ip, []) timestamps = [t for t in timestamps if now - t < 60] if len(timestamps) >= settings.rate_limit_per_minute: metrics_service.incr_bucket("errors", "rate_limit") return JSONResponse( status_code=429, content={"detail": "Too many requests. Please slow down and try again."}, ) timestamps.append(now) _request_log[client_ip] = timestamps else: from collections import deque bucket = _request_log[client_ip] # type: ignore[index] while bucket and now - bucket[0] > 60: bucket.popleft() if len(bucket) >= settings.rate_limit_per_minute: metrics_service.incr_bucket("errors", "rate_limit") return JSONResponse( status_code=429, content={"detail": "Too many requests. Please slow down and try again."}, ) bucket.append(now) # ── Request execution ────────────────────────────────────────────────────── try: response = await call_next(request) return response except Exception as exc: logger.exception("Unhandled application error: %s", exc) metrics_service.incr_bucket("errors", "unhandled_exception") return JSONResponse(status_code=500, content={"detail": "Unexpected server error."}) # ── Router Mounting ──────────────────────────────────────────────────────────── from src.api import admin, agent, feedback, search # noqa: E402 app.include_router(agent.router, prefix="/agent", tags=["Agent"]) app.include_router(search.router, prefix="/search", tags=["Search"]) app.include_router(admin.router, prefix="/admin", tags=["Admin"]) app.include_router(feedback.router, prefix="/feedback", tags=["Feedback"]) # ── Health & Public Endpoints ────────────────────────────────────────────────── @app.get("/favicon.ico", include_in_schema=False) async def favicon(): """ Return 204 No Content for favicon requests. Prevents 404 errors in browser console — the backend has no favicon, the frontend (GitHub Pages) serves its own at assets/libbee-mascot.png. """ from fastapi.responses import Response return Response(status_code=204) @app.get("/") def health_check(): settings = get_settings() return { "status": "ok", "version": LIBBEE_VERSION, "service": "LibBee KU Library AI", "rag_ready": rag_service.is_ready(), "maintenance_mode": settings.maintenance_mode, "endpoints": ["/agent", "/search", "/admin", "/feedback"], } @app.get("/config") def public_config(): """Legacy public config endpoint — frontend uses /admin/public-config instead.""" settings = get_settings() return { "welcome_message": "Hi! I'm LibBee, the Khalifa University Library AI Assistant.", "max_results": settings.max_results, "maintenance_mode": settings.maintenance_mode, } @app.get("/year") def get_year(): from datetime import datetime now = datetime.utcnow() return {"year": now.year, "month": now.month, "date": now.strftime("%Y-%m-%d")}