""" admin.py — LibBee v4.1 Admin dashboard: session management, config store, RAG rebuild, metrics, system status. Security features: - Login lockout: 5 failed attempts → 15 minute lockout per IP - Remaining attempts shown in error message - secure=True on session cookie - Login page JS shows server error message (lockout, remaining attempts) - Short-lived read-only browser tokens for Worker analytics """ # ── Imports ──────────────────────────────────────────────────────────────────── import base64 import hashlib import hmac import json import logging import secrets import time from typing import Any, Dict, Optional from urllib.parse import urlsplit from fastapi import APIRouter, HTTPException, Request, Response from fastapi.responses import HTMLResponse, RedirectResponse from pydantic import BaseModel, ConfigDict, Field from src.config import get_settings, LIBBEE_VERSION from src.services.runtime_store import JsonRuntimeStore router = APIRouter() logger = logging.getLogger(__name__) _START_TIME = time.time() _CONFIG_DEFAULTS: Dict[str, Any] = { "max_results": 5, "maintenance_mode": False, "welcome_message": "Hi! I'm LibBee, the Khalifa University Library AI Assistant.", "custom_instructions": "", "announcement": "", "maintenance_message": "LibBee is currently under maintenance. Please try again shortly.", } # ── Session Persistence ──────────────────────────────────────────────────────── admin_sessions: Dict[str, float] = {} # ── Admin login lockout ──────────────────────────────────────────────────────── # Tracks failed login attempts per IP. # After _MAX_LOGIN_ATTEMPTS failures within _LOCKOUT_WINDOW seconds, # that IP is locked out for _LOCKOUT_DURATION seconds. _login_attempts: Dict[str, list] = {} # ip → [timestamp, ...] _login_lockouts: Dict[str, float] = {} # ip → absolute expiry (separate from attempt window) _MAX_LOGIN_ATTEMPTS = 5 _LOCKOUT_WINDOW = 300 # 5 minutes — window in which attempts are counted _LOCKOUT_DURATION = 900 # 15 minutes — lockout duration after max attempts def _sessions_file() -> str: return str(get_settings().runtime_path / "admin_sessions.json") def _load_sessions() -> None: global admin_sessions try: from pathlib import Path data = JsonRuntimeStore(Path(_sessions_file())).load() now = time.time() valid = {t: exp for t, exp in data.items() if isinstance(exp, (int, float)) and exp > now} admin_sessions.clear() admin_sessions.update(valid) except Exception: pass def _save_sessions() -> None: try: from pathlib import Path now = time.time() valid = {t: exp for t, exp in admin_sessions.items() if exp > now} JsonRuntimeStore(Path(_sessions_file())).save(valid) except Exception as exc: logger.error("Could not persist admin sessions: %s", type(exc).__name__) raise HTTPException(status_code=503, detail="Session storage unavailable") from exc def _cleanup_sessions() -> None: now = time.time() expired = [t for t, exp in admin_sessions.items() if exp <= now] for t in expired: admin_sessions.pop(t, None) try: _load_sessions() except Exception: pass def _require_session(request: Request) -> None: _cleanup_sessions() token = request.cookies.get("admin_session") if not token or token not in admin_sessions: raise HTTPException(status_code=401, detail="Not authenticated") def _has_session(request: Request) -> bool: _cleanup_sessions() token = request.cookies.get("admin_session") return bool(token and token in admin_sessions) # ── Config Store ─────────────────────────────────────────────────────────────── def _config_store() -> JsonRuntimeStore: settings = get_settings() return JsonRuntimeStore(settings.config_path, default=settings.runtime_defaults) def _load_config() -> Dict[str, Any]: data = _config_store().load() for k, v in _CONFIG_DEFAULTS.items(): if k not in data: data[k] = v return data def _esc(s: Any) -> str: return ( str(s or "") .replace("&", "&") .replace("<", "<") .replace(">", ">") .replace('"', """) ) def _escape_untrusted_json(value: Any) -> Any: """Escape Worker/D1 strings before dashboard code inserts them as HTML.""" if isinstance(value, str): return _esc(value) if isinstance(value, list): return [_escape_untrusted_json(item) for item in value] if isinstance(value, dict): return {key: _escape_untrusted_json(item) for key, item in value.items()} return value def _clean_worker_url(raw: str) -> str: """Return a browser-safe HTTPS Worker base URL or an empty string.""" value = str(raw or "").strip().strip('"').strip("'").rstrip("/") try: parsed = urlsplit(value) except ValueError: return "" if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password: return "" if parsed.path not in {"", "/"} or parsed.query or parsed.fragment: return "" return value def _make_worker_browser_token(secret: str, now: Optional[int] = None) -> tuple[str, int]: """Create a 90-second read-only token accepted by the v4.1 Worker.""" issued_at = int(time.time() if now is None else now) expires_at = issued_at + 90 payload = { "aud": "libbee-worker-admin", "scope": "analytics:read", "iat": issued_at, "exp": expires_at, "nonce": secrets.token_urlsafe(12), } raw_payload = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") encoded_payload = base64.urlsafe_b64encode(raw_payload).decode("ascii").rstrip("=") signing_input = f"lb1.{encoded_payload}" signature = hmac.new(secret.encode("utf-8"), signing_input.encode("ascii"), hashlib.sha256).digest() encoded_signature = base64.urlsafe_b64encode(signature).decode("ascii").rstrip("=") return f"{signing_input}.{encoded_signature}", expires_at # ── Pydantic Models ──────────────────────────────────────────────────────────── class LoginRequest(BaseModel): model_config = ConfigDict(extra="forbid") password: str = Field(min_length=1, max_length=256) class ConfigUpdate(BaseModel): model_config = ConfigDict(extra="forbid") key: str = Field(min_length=1, max_length=40) value: str = Field(max_length=5000) class ConfigBulkUpdate(BaseModel): model_config = ConfigDict(extra="forbid") settings: Dict[str, Any] = Field(max_length=6) def _validated_config_values(values: Dict[str, Any]) -> Dict[str, Any]: limits = {"welcome_message": 500, "custom_instructions": 5000, "announcement": 500, "maintenance_message": 300} cleaned = {} for key, value in values.items(): if key == "max_results": try: # Reject fractional numbers instead of silently rounding. number = int(str(value)) except (ValueError, TypeError): raise HTTPException(status_code=422, detail="max_results must be an integer from 1 to 20") if not 1 <= number <= 20: raise HTTPException(status_code=422, detail="max_results must be from 1 to 20") cleaned[key] = number elif key == "maintenance_mode": normalized = str(value).lower().strip() if normalized not in {"1", "true", "yes", "on", "0", "false", "no", "off"}: raise HTTPException(status_code=422, detail="maintenance_mode must be true or false") cleaned[key] = normalized in {"1", "true", "yes", "on"} elif key in limits: if not isinstance(value, str) or len(value) > limits[key]: raise HTTPException(status_code=422, detail=f"{key} must be text of at most {limits[key]} characters") cleaned[key] = value else: raise HTTPException(status_code=422, detail=f"Unsupported setting: {key}") return cleaned # ── Auth Endpoints ───────────────────────────────────────────────────────────── @router.post("/auth") async def admin_auth(req: LoginRequest, request: Request): settings = get_settings() if not settings.admin_password: raise HTTPException(status_code=503, detail="Admin login is not configured") client_ip = request.client.host if request.client else "unknown" now = time.time() # ── Lockout check ───────────────────────────────────────────────────────── # The lockout survives the shorter rolling attempt window. Previously the # fifth attempt was forgotten after five minutes, releasing a 15-minute ban. for ip, expiry in list(_login_lockouts.items()): if expiry <= now: _login_lockouts.pop(ip, None) for ip, previous in list(_login_attempts.items()): recent = [t for t in previous if now - t < _LOCKOUT_WINDOW] if recent: _login_attempts[ip] = recent else: _login_attempts.pop(ip, None) if client_ip in _login_lockouts: wait = max(1, int(_login_lockouts[client_ip] - now)) raise HTTPException( status_code=429, detail=f"Too many failed login attempts. Try again in {wait} seconds.", headers={"Retry-After": str(wait)}, ) attempts = _login_attempts.get(client_ip, []) # ── Password check ──────────────────────────────────────────────────────── if not settings.admin_password or not secrets.compare_digest( req.password.encode(), settings.admin_password.encode() ): attempts.append(now) _login_attempts[client_ip] = attempts remaining = _MAX_LOGIN_ATTEMPTS - len(attempts) if remaining <= 0: _login_lockouts[client_ip] = now + _LOCKOUT_DURATION detail = f"Too many failed login attempts. Try again in {int(_LOCKOUT_DURATION / 60)} minutes." else: detail = f"Invalid password. {remaining} attempt{'s' if remaining != 1 else ''} remaining before lockout." raise HTTPException(status_code=429 if remaining <= 0 else 401, detail=detail) # ── Success — clear attempts and issue session ───────────────────────────── _login_attempts.pop(client_ip, None) _cleanup_sessions() token = secrets.token_hex(32) admin_sessions[token] = time.time() + 86400 _save_sessions() resp = Response(content='{"status":"ok"}', media_type="application/json") resp.set_cookie(key="admin_session", value=token, httponly=True, max_age=86400, samesite="lax", secure=settings.environment == "production") return resp @router.post("/logout") async def admin_logout_post(request: Request): _cleanup_sessions() token = request.cookies.get("admin_session") if token: admin_sessions.pop(token, None) _save_sessions() resp = Response(content='{"status":"ok"}', media_type="application/json") resp.delete_cookie("admin_session") return resp @router.get("/logout") async def admin_logout_get(request: Request): _cleanup_sessions() token = request.cookies.get("admin_session") if token: admin_sessions.pop(token, None) _save_sessions() resp = RedirectResponse(url="/admin/login") resp.delete_cookie("admin_session") return resp # ── Login Page ───────────────────────────────────────────────────────────────── @router.get("/login", response_class=HTMLResponse) async def admin_login_page(request: Request): if _has_session(request): return RedirectResponse(url="/admin") return HTMLResponse(""" KU Library AI — Admin Login

🔐 Admin Login

KU Library AI Dashboard
Incorrect password. Try again.
""") # ── Admin Dashboard ──────────────────────────────────────────────────────────── @router.get("", response_class=HTMLResponse) async def admin_dashboard(request: Request): if not _has_session(request): return RedirectResponse(url="/admin/login") try: cfg = _load_config() except Exception: cfg = dict(_CONFIG_DEFAULTS) mm_on = "selected" if cfg.get("maintenance_mode") else "" mm_off = "" if cfg.get("maintenance_mode") else "selected" return HTMLResponse(f""" KU Library AI — Admin

🐝 KU Library AI — Admin Dashboard

Logout

📊 Analytics

Loading…

⚙️ Bot Configuration

✅ Saved!

🔄 Index Management

Rebuild the FAISS + BM25 hybrid index from knowledge files in the KB directory.

Loading RAG status…

📋 Request Metrics

Loading…

📈 Cloudflare Analytics (Persistent)

Loading…

📋 Recent Queries (Cloudflare D1)

Loading…

📊 Reports

Choose a report and click "Show report".

🔌 System Status

Loading…
""") # ── API Endpoints ────────────────────────────────────────────────────────────── @router.get("/public-config") async def public_config(): """No auth — returns public fields for the chat frontend.""" try: cfg = _load_config() except Exception: cfg = get_settings().runtime_defaults return { "welcome_message": str(cfg.get("welcome_message", ""))[:500], "announcement": str(cfg.get("announcement", ""))[:500], "maintenance_mode": bool(cfg.get("maintenance_mode", False)), "maintenance_message": str(cfg.get("maintenance_message", ""))[:300], } @router.get("/worker-browser-token") async def worker_browser_token(request: Request): """Issue a short-lived read-only Worker token to a logged-in administrator.""" _require_session(request) settings = get_settings() worker_url = _clean_worker_url(settings.cloudflare_worker_url) if not worker_url: raise HTTPException(status_code=503, detail="CLOUDFLARE_WORKER_URL is missing or invalid") if not settings.cloudflare_admin_token: raise HTTPException(status_code=503, detail="CLOUDFLARE_ADMIN_TOKEN is not configured") token, expires_at = _make_worker_browser_token(settings.cloudflare_admin_token) return { "worker_url": worker_url, "token": token, "expires_at": expires_at, "scope": "analytics:read", } @router.get("/config") async def get_config(request: Request): _require_session(request) return _load_config() @router.post("/config") async def update_config(req: ConfigUpdate, request: Request): _require_session(request) cleaned = _validated_config_values({req.key: req.value}) data = _config_store().load() data.update(cleaned) _config_store().save(data) return {"status": "ok", "updated": req.key, "value": cleaned[req.key]} @router.post("/config-bulk") async def update_config_bulk(req: ConfigBulkUpdate, request: Request): _require_session(request) # Validate everything before writing; invalid values used to be skipped # silently even while the dashboard displayed "Saved". cleaned = _validated_config_values(req.settings) data = _config_store().load() data.update(cleaned) _config_store().save(data) return {"status": "ok", "updated": sorted(cleaned)} # ── RAG Management Endpoints ─────────────────────────────────────────────────── @router.get("/rag-status") async def rag_status(request: Request): _require_session(request) try: from app import get_rag_service return get_rag_service().get_stats() except Exception as e: return {"error": str(e), "ready": False} @router.post("/rebuild") async def rebuild_rag(request: Request): _require_session(request) try: from app import get_rag_service svc = get_rag_service() settings = get_settings() await svc.initialize(openai_api_key=settings.openai_api_key, force_rebuild=True) if not svc.is_ready(): return {"status": "error", "error": "No usable knowledge index. Check knowledge files and server logs.", "ready": False} return {"status": "ok", "chunks": len(svc.bm25_corpus), "ready": True, "retrieval_mode": svc.get_stats().get("retrieval_mode", "unknown")} except Exception as e: logger.error("Admin index rebuild failed: %s", type(e).__name__) return {"status": "error", "error": "Index rebuild failed. Check server logs."} # ── Metrics & Status Endpoints ───────────────────────────────────────────────── @router.get("/metrics") async def metrics(request: Request): _require_session(request) try: from app import get_metrics_service return get_metrics_service().snapshot() except Exception as e: return {"error": str(e)} @router.get("/status") async def system_status(request: Request): _require_session(request) settings = get_settings() mem_used_mb = mem_pct = 0 try: with open("/sys/fs/cgroup/memory.current") as f: used = int(f.read().strip()) with open("/sys/fs/cgroup/memory.max") as f: val = f.read().strip() total = int(val) if val != "max" else 16 * 1024 ** 3 mem_used_mb = round(used / 1024 / 1024) mem_pct = round(used / total * 100) except Exception: try: with open("/sys/fs/cgroup/memory/memory.usage_in_bytes") as f: used = int(f.read().strip()) with open("/sys/fs/cgroup/memory/memory.limit_in_bytes") as f: total = int(f.read().strip()) mem_used_mb = round(used / 1024 / 1024) mem_pct = round(used / total * 100) except Exception: pass rag_ready = False rag_chunks = 0 kb_files = 0 try: from app import get_rag_service svc = get_rag_service() rag_ready = svc.is_ready() rag_chunks = len(svc.bm25_corpus) kb_dir = settings.kb_dir kb_files = len(list(kb_dir.glob("*.txt"))) if kb_dir.exists() else 0 except Exception: pass cfg = _load_config() return { "status": "ok", "rag_ready": rag_ready, "rag_chunks": rag_chunks, "kb_files": kb_files, "openai": bool(settings.openai_api_key), "anthropic": bool(settings.anthropic_api_key), "primo": bool(settings.primo_api_key), "mem_used_mb": mem_used_mb, "mem_pct": mem_pct, "uptime_secs": round(time.time() - _START_TIME), "maintenance": bool(cfg.get("maintenance_mode")), "version": LIBBEE_VERSION, } # ── Daily KB-gap / relevance reports (generated by the Cloudflare Worker) ───── # The Worker's daily cron writes one report row per day; these endpoints proxy # them into the admin dashboard. Session-protected like every admin route, and # the Worker itself additionally requires the shared bearer token. @router.get("/reports") async def admin_reports(request: Request, days: int = 14): _require_session(request) return await _worker_get("/reports", {"days": max(1, min(days, 60))}) @router.post("/reports/{report_id}/reviewed") async def admin_report_reviewed(report_id: int, request: Request): _require_session(request) import httpx as _hx from src.config import get_settings as _gs settings = _gs() worker = _clean_worker_url(settings.cloudflare_worker_url) if not worker: raise HTTPException(status_code=503, detail="CLOUDFLARE_WORKER_URL not configured") headers = {} if settings.cloudflare_admin_token: headers["Authorization"] = f"Bearer {settings.cloudflare_admin_token}" try: async with _hx.AsyncClient(timeout=10) as client: r = await client.post(f"{worker}/reports/{int(report_id)}/reviewed", headers=headers) if r.status_code >= 400: raise HTTPException(status_code=502, detail=f"Worker returned HTTP {r.status_code}") return r.json() except Exception as exc: raise HTTPException(status_code=502, detail="Worker unavailable. Check the configured URL and server logs.") # ── Analytics proxies ──────────────────────────────────────────────────────── # The dashboard fetches Worker analytics through these session-protected # proxies so the Worker URL and ADMIN_TOKEN never reach the browser. async def _worker_get(path: str, params: Optional[dict] = None): from src.services.http_client import make_client as _mk from src.config import get_settings as _gs settings = _gs() worker = _clean_worker_url(settings.cloudflare_worker_url) if not worker: raise HTTPException(status_code=503, detail="CLOUDFLARE_WORKER_URL not configured") headers = {} if settings.cloudflare_admin_token: headers["Authorization"] = f"Bearer {settings.cloudflare_admin_token}" try: async with _mk(timeout=8) as client: r = await client.get(f"{worker}{path}", params=params or {}, headers=headers) if r.status_code >= 400: raise HTTPException(status_code=502, detail=f"Worker returned HTTP {r.status_code}") return _escape_untrusted_json(r.json()) except HTTPException: raise except Exception as exc: raise HTTPException(status_code=502, detail="Worker unavailable. Check the configured URL and server logs.") @router.get("/analytics") async def admin_analytics(request: Request): _require_session(request) return await _worker_get("/analytics") @router.get("/analytics/recent") async def admin_analytics_recent(request: Request): _require_session(request) return await _worker_get("/analytics/recent") @router.get("/worker-health") async def admin_worker_health(request: Request): """Diagnose the backend -> Worker -> D1 chain. Every probe runs independently and records its own outcome, including the exception type, so one failure never hides the rest. Also reports the exact URL being used (whitespace or quotes in the secret are a common cause). """ _require_session(request) import asyncio as _aio from src.services.http_client import make_client as _mk from src.config import get_settings as _gs, LIBBEE_VERSION as _ver settings = _gs() raw = settings.cloudflare_worker_url or "" worker = _clean_worker_url(raw) token = (settings.cloudflare_admin_token or "").strip() out = { "backend_version": _ver, "worker_url_configured": bool(worker), "worker_url_used": worker, "worker_url_had_whitespace_or_quotes": raw != worker + ("/" if raw.endswith("/") else ""), "worker_token_configured": bool(token), "worker_token_length": len(token), "probes": {}, "verdict": "", } if not worker: out["verdict"] = "CLOUDFLARE_WORKER_URL is not set in the Space secrets." return out headers = {"Authorization": f"Bearer {token}"} if token else {} async def probe(name, path, use_auth=True): """One probe, its own short timeout, never raises.""" entry = {"path": path} try: async with _mk(timeout=8) as client: r = await client.get(f"{worker}{path}", headers=headers if use_auth else {}) entry["status"] = r.status_code entry["body"] = r.text[:300] except Exception as exc: entry["error_type"] = type(exc).__name__ entry["error"] = repr(exc)[:200] or type(exc).__name__ out["probes"][name] = entry return entry # Run all probes concurrently so the endpoint returns in ~6s, not ~48s. ver, _hours, ana, sch = await _aio.gather( probe("version", "/version", use_auth=False), probe("hours", "/hours", use_auth=False), probe("analytics", "/analytics"), probe("schema", "/analytics/schema"), ) if request.query_params.get("test_write") == "1": out["canary_note"] = "Use POST /admin/canary-run to run a canary. This diagnostic GET is read-only." # Interpretation if "error_type" in ver and "error_type" in out["probes"]["hours"]: et = ver.get("error_type") if et in ("ConnectTimeout", "ConnectError", "ReadTimeout"): out["verdict"] = ( f"{et}: the HF server cannot open a connection to workers.dev. " f"In v4.1 this legacy probe may continue to fail: the logged-in " f"admin browser reads D1 with a 90-second token instead, and HF " f"calls LibCal directly. Verify the dashboard rather than using " f"this server-to-server probe as the acceptance test." ) else: out["verdict"] = ( f"No HTTP response from the Worker ({et}). Check that " f"worker_url_used above is exactly your workers.dev address." ) elif ver.get("status") == 404: out["verdict"] = ( "Worker responds but has no /version route. Deploy the v4 secure gateway." ) elif ana.get("status") == 401: out["verdict"] = ( "Analytics returns 401. CLOUDFLARE_ADMIN_TOKEN does not match the " "Worker's ADMIN_TOKEN (check for trailing spaces in either)." ) elif ana.get("status") == 200: migrated = None if sch.get("status") == 200: try: import json as _json migrated = (_json.loads(sch["body"]) or {}).get("migration_applied") except Exception: migrated = None if migrated is False: out["verdict"] = ("Connected, but the D1 migration is not applied " "(answer_excerpt/source_ids missing).") else: out["verdict"] = "OK - backend, Worker and D1 analytics are connected." else: out["verdict"] = (f"Unexpected analytics status {ana.get('status')}. " f"See probes for details.") return out @router.post("/canary-run") async def admin_canary_run(request: Request): """Explicit admin action; never start a paid model request from a GET.""" _require_session(request) settings = get_settings() worker = _clean_worker_url(settings.cloudflare_worker_url) if not worker or not settings.cloudflare_admin_token: raise HTTPException(status_code=503, detail="Worker admin access is not configured") from src.services.http_client import make_client try: async with make_client(timeout=190, retries=0) as client: response = await client.post( f"{worker}/canary/run", headers={"Authorization": f"Bearer {settings.cloudflare_admin_token}"}, ) if response.status_code >= 400: raise HTTPException(status_code=502, detail=f"Worker returned HTTP {response.status_code}") return response.json() except HTTPException: raise except Exception as exc: logger.warning("Worker canary request failed: %s", type(exc).__name__) raise HTTPException(status_code=502, detail="Worker canary unavailable") from exc @router.get("/log-status") async def admin_log_status(request: Request): """Outcome of recent analytics writes, without needing container logs. 'recent_attempts' is newest-first. Read it like this: ok -> the write reached the Worker (HTTP 2xx) throttled -> HTTP 403/429: the host is being rate-limited by Cloudflare unreachable -> no HTTP response; exception type is in 'detail' rejected_401 -> CLOUDFLARE_ADMIN_TOKEN does not match ADMIN_TOKEN Buffered rows are retried automatically on later queries. """ _require_session(request) from src.agentcore.orchestrator import analytics_log_status from src.config import get_settings as _gs st = _gs() data = analytics_log_status() data["worker_url_configured"] = bool(st.cloudflare_worker_url) data["worker_token_configured"] = bool(st.cloudflare_admin_token) if not data["recent_attempts"]: data["hint"] = ("No write attempts recorded since the last restart. " "Send a query through LibBee, then reload this page.") return data @router.get("/canary-status") async def admin_canary_status(request: Request): """Last 14 daily canary runs (the Worker's scheduled health probe). Each run is one real question through the full pipeline, chosen from a fixed 14-question pool rotating by day-of-year. Canary traffic is excluded from analytics, so these runs never appear in the D1 usage data. """ _require_session(request) return await _worker_get("/canary/status")