Upload 5 files
Browse files- src/api/admin.py +142 -37
src/api/admin.py
CHANGED
|
@@ -1,22 +1,26 @@
|
|
| 1 |
-
"""
|
| 2 |
-
admin.py β LibBee
|
| 3 |
-
Admin dashboard: session management, config store, RAG rebuild, metrics, system status.
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
- Login lockout: 5 failed attempts β 15 minute lockout per IP
|
| 7 |
-
- Remaining attempts shown in error message
|
| 8 |
-
- secure=True on session cookie
|
| 9 |
-
- Login page JS shows server error message (lockout, remaining attempts)
|
| 10 |
-
|
|
|
|
| 11 |
|
| 12 |
# ββ Imports ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 13 |
|
| 14 |
-
import
|
| 15 |
-
import
|
| 16 |
-
import
|
| 17 |
-
import
|
| 18 |
-
import
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
from fastapi import APIRouter, HTTPException, Request, Response
|
| 22 |
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
@@ -138,6 +142,39 @@ def _escape_untrusted_json(value: Any) -> Any:
|
|
| 138 |
if isinstance(value, dict):
|
| 139 |
return {key: _escape_untrusted_json(item) for key, item in value.items()}
|
| 140 |
return value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
|
| 143 |
# ββ Pydantic Models ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -395,13 +432,65 @@ tr:last-child td{{border-bottom:none}}
|
|
| 395 |
|
| 396 |
</div>
|
| 397 |
<script>
|
| 398 |
-
//
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
}}
|
| 406 |
|
| 407 |
async function loadStats(){{
|
|
@@ -814,8 +903,8 @@ setInterval(loadRecentQueries,60000);
|
|
| 814 |
|
| 815 |
# ββ API Endpoints ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 816 |
|
| 817 |
-
@router.get("/public-config")
|
| 818 |
-
async def public_config():
|
| 819 |
"""No auth β returns public fields for the chat frontend."""
|
| 820 |
try:
|
| 821 |
cfg = _load_config()
|
|
@@ -826,7 +915,26 @@ async def public_config():
|
|
| 826 |
"announcement": cfg.get("announcement", ""),
|
| 827 |
"maintenance_mode": cfg.get("maintenance_mode", False),
|
| 828 |
"maintenance_message": cfg.get("maintenance_message", ""),
|
| 829 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 830 |
|
| 831 |
|
| 832 |
@router.get("/config")
|
|
@@ -1110,17 +1218,14 @@ async def admin_worker_health(request: Request):
|
|
| 1110 |
# Interpretation
|
| 1111 |
if "error_type" in ver and "error_type" in out["probes"]["hours"]:
|
| 1112 |
et = ver.get("error_type")
|
| 1113 |
-
if et in ("ConnectTimeout", "ConnectError", "ReadTimeout"):
|
| 1114 |
-
out["verdict"] = (
|
| 1115 |
-
f"{et}: the
|
| 1116 |
-
f"
|
| 1117 |
-
f"
|
| 1118 |
-
f"
|
| 1119 |
-
f"
|
| 1120 |
-
|
| 1121 |
-
f"(Worker > Settings > Domains & Routes > Add custom domain) and "
|
| 1122 |
-
f"point CLOUDFLARE_WORKER_URL at that instead."
|
| 1123 |
-
)
|
| 1124 |
else:
|
| 1125 |
out["verdict"] = (
|
| 1126 |
f"No HTTP response from the Worker ({et}). Check that "
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
admin.py β LibBee v4.1
|
| 3 |
+
Admin dashboard: session management, config store, RAG rebuild, metrics, system status.
|
| 4 |
+
|
| 5 |
+
Security features:
|
| 6 |
+
- Login lockout: 5 failed attempts β 15 minute lockout per IP
|
| 7 |
+
- Remaining attempts shown in error message
|
| 8 |
+
- secure=True on session cookie
|
| 9 |
+
- Login page JS shows server error message (lockout, remaining attempts)
|
| 10 |
+
- Short-lived read-only browser tokens for Worker analytics
|
| 11 |
+
"""
|
| 12 |
|
| 13 |
# ββ Imports ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 14 |
|
| 15 |
+
import base64
|
| 16 |
+
import hashlib
|
| 17 |
+
import hmac
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
import secrets
|
| 21 |
+
import time
|
| 22 |
+
from typing import Any, Dict, Optional
|
| 23 |
+
from urllib.parse import urlsplit
|
| 24 |
|
| 25 |
from fastapi import APIRouter, HTTPException, Request, Response
|
| 26 |
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
|
|
| 142 |
if isinstance(value, dict):
|
| 143 |
return {key: _escape_untrusted_json(item) for key, item in value.items()}
|
| 144 |
return value
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _clean_worker_url(raw: str) -> str:
|
| 148 |
+
"""Return a browser-safe HTTPS Worker base URL or an empty string."""
|
| 149 |
+
value = str(raw or "").strip().strip('"').strip("'").rstrip("/")
|
| 150 |
+
try:
|
| 151 |
+
parsed = urlsplit(value)
|
| 152 |
+
except ValueError:
|
| 153 |
+
return ""
|
| 154 |
+
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password:
|
| 155 |
+
return ""
|
| 156 |
+
if parsed.path not in {"", "/"} or parsed.query or parsed.fragment:
|
| 157 |
+
return ""
|
| 158 |
+
return value
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _make_worker_browser_token(secret: str, now: Optional[int] = None) -> tuple[str, int]:
|
| 162 |
+
"""Create a 90-second read-only token accepted by the v4.1 Worker."""
|
| 163 |
+
issued_at = int(time.time() if now is None else now)
|
| 164 |
+
expires_at = issued_at + 90
|
| 165 |
+
payload = {
|
| 166 |
+
"aud": "libbee-worker-admin",
|
| 167 |
+
"scope": "analytics:read",
|
| 168 |
+
"iat": issued_at,
|
| 169 |
+
"exp": expires_at,
|
| 170 |
+
"nonce": secrets.token_urlsafe(12),
|
| 171 |
+
}
|
| 172 |
+
raw_payload = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
| 173 |
+
encoded_payload = base64.urlsafe_b64encode(raw_payload).decode("ascii").rstrip("=")
|
| 174 |
+
signing_input = f"lb1.{encoded_payload}"
|
| 175 |
+
signature = hmac.new(secret.encode("utf-8"), signing_input.encode("ascii"), hashlib.sha256).digest()
|
| 176 |
+
encoded_signature = base64.urlsafe_b64encode(signature).decode("ascii").rstrip("=")
|
| 177 |
+
return f"{signing_input}.{encoded_signature}", expires_at
|
| 178 |
|
| 179 |
|
| 180 |
# ββ Pydantic Models ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 432 |
|
| 433 |
</div>
|
| 434 |
<script>
|
| 435 |
+
// The permanent Worker secret stays server-side. A logged-in administrator
|
| 436 |
+
// receives only a read-only token that expires after 90 seconds.
|
| 437 |
+
let CF_BROWSER_AUTH={{token:'',workerUrl:'',expiresAt:0}};
|
| 438 |
+
|
| 439 |
+
function cfEscape(value){{
|
| 440 |
+
return String(value==null?'':value).replace(/[&<>"']/g, character=>({{
|
| 441 |
+
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
| 442 |
+
}})[character]);
|
| 443 |
+
}}
|
| 444 |
+
|
| 445 |
+
function cfEscapeData(value){{
|
| 446 |
+
if(typeof value==='string') return cfEscape(value);
|
| 447 |
+
if(Array.isArray(value)) return value.map(cfEscapeData);
|
| 448 |
+
if(value&&typeof value==='object'){{
|
| 449 |
+
const out={{}};
|
| 450 |
+
Object.keys(value).forEach(key=>{{out[key]=cfEscapeData(value[key]);}});
|
| 451 |
+
return out;
|
| 452 |
+
}}
|
| 453 |
+
return value;
|
| 454 |
+
}}
|
| 455 |
+
|
| 456 |
+
async function cfBrowserAuth(forceRefresh){{
|
| 457 |
+
const now=Math.floor(Date.now()/1000);
|
| 458 |
+
if(!forceRefresh&&CF_BROWSER_AUTH.token&&CF_BROWSER_AUTH.expiresAt>now+10) return CF_BROWSER_AUTH;
|
| 459 |
+
const response=await fetch('/admin/worker-browser-token',{{
|
| 460 |
+
credentials:'same-origin',cache:'no-store',headers:{{'Accept':'application/json'}}
|
| 461 |
+
}});
|
| 462 |
+
const data=await response.json();
|
| 463 |
+
if(!response.ok||!data.token||!data.worker_url){{
|
| 464 |
+
throw new Error('HTTP '+response.status+' '+cfEscape(data.detail||'Worker authorization unavailable'));
|
| 465 |
+
}}
|
| 466 |
+
const target=new URL(data.worker_url);
|
| 467 |
+
if(target.protocol!=='https:'||target.pathname!=='/') throw new Error('Invalid Worker URL');
|
| 468 |
+
CF_BROWSER_AUTH={{token:data.token,workerUrl:data.worker_url.replace(/\\/$/,''),expiresAt:Number(data.expires_at||0)}};
|
| 469 |
+
return CF_BROWSER_AUTH;
|
| 470 |
+
}}
|
| 471 |
+
|
| 472 |
+
async function cfFetch(path,retried){{
|
| 473 |
+
if(!/^\\/(?:analytics|reports|canary)(?:\\/|\\?|$)/.test(path)) throw new Error('Unsupported Worker path');
|
| 474 |
+
const auth=await cfBrowserAuth(Boolean(retried));
|
| 475 |
+
let response;
|
| 476 |
+
try{{
|
| 477 |
+
response=await fetch(auth.workerUrl+path,{{
|
| 478 |
+
method:'GET',credentials:'omit',cache:'no-store',
|
| 479 |
+
headers:{{'Accept':'application/json','Authorization':'Bearer '+auth.token}}
|
| 480 |
+
}});
|
| 481 |
+
}}catch(error){{
|
| 482 |
+
throw new Error('Browser could not reach the Worker. Check ALLOWED_ORIGINS and CORS.');
|
| 483 |
+
}}
|
| 484 |
+
if(response.status===401&&!retried){{
|
| 485 |
+
CF_BROWSER_AUTH={{token:'',workerUrl:'',expiresAt:0}};
|
| 486 |
+
return cfFetch(path,true);
|
| 487 |
+
}}
|
| 488 |
+
const text=await response.text();
|
| 489 |
+
let data={{}};
|
| 490 |
+
try{{data=JSON.parse(text);}}catch(error){{throw new Error('Worker returned invalid JSON');}}
|
| 491 |
+
if(!response.ok||data.detail) throw new Error('HTTP '+response.status+' '+cfEscape(data.detail||data.error?.message||''));
|
| 492 |
+
if(data.error) throw new Error(cfEscape(data.error.message||JSON.stringify(data.error)));
|
| 493 |
+
return {{data:cfEscapeData(data),via:'short-lived browser token'}};
|
| 494 |
}}
|
| 495 |
|
| 496 |
async function loadStats(){{
|
|
|
|
| 903 |
|
| 904 |
# ββ API Endpoints ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 905 |
|
| 906 |
+
@router.get("/public-config")
|
| 907 |
+
async def public_config():
|
| 908 |
"""No auth β returns public fields for the chat frontend."""
|
| 909 |
try:
|
| 910 |
cfg = _load_config()
|
|
|
|
| 915 |
"announcement": cfg.get("announcement", ""),
|
| 916 |
"maintenance_mode": cfg.get("maintenance_mode", False),
|
| 917 |
"maintenance_message": cfg.get("maintenance_message", ""),
|
| 918 |
+
}
|
| 919 |
+
|
| 920 |
+
|
| 921 |
+
@router.get("/worker-browser-token")
|
| 922 |
+
async def worker_browser_token(request: Request):
|
| 923 |
+
"""Issue a short-lived read-only Worker token to a logged-in administrator."""
|
| 924 |
+
_require_session(request)
|
| 925 |
+
settings = get_settings()
|
| 926 |
+
worker_url = _clean_worker_url(settings.cloudflare_worker_url)
|
| 927 |
+
if not worker_url:
|
| 928 |
+
raise HTTPException(status_code=503, detail="CLOUDFLARE_WORKER_URL is missing or invalid")
|
| 929 |
+
if not settings.cloudflare_admin_token:
|
| 930 |
+
raise HTTPException(status_code=503, detail="CLOUDFLARE_ADMIN_TOKEN is not configured")
|
| 931 |
+
token, expires_at = _make_worker_browser_token(settings.cloudflare_admin_token)
|
| 932 |
+
return {
|
| 933 |
+
"worker_url": worker_url,
|
| 934 |
+
"token": token,
|
| 935 |
+
"expires_at": expires_at,
|
| 936 |
+
"scope": "analytics:read",
|
| 937 |
+
}
|
| 938 |
|
| 939 |
|
| 940 |
@router.get("/config")
|
|
|
|
| 1218 |
# Interpretation
|
| 1219 |
if "error_type" in ver and "error_type" in out["probes"]["hours"]:
|
| 1220 |
et = ver.get("error_type")
|
| 1221 |
+
if et in ("ConnectTimeout", "ConnectError", "ReadTimeout"):
|
| 1222 |
+
out["verdict"] = (
|
| 1223 |
+
f"{et}: the HF server cannot open a connection to workers.dev. "
|
| 1224 |
+
f"In v4.1 this legacy probe may continue to fail: the logged-in "
|
| 1225 |
+
f"admin browser reads D1 with a 90-second token instead, and HF "
|
| 1226 |
+
f"calls LibCal directly. Verify the dashboard rather than using "
|
| 1227 |
+
f"this server-to-server probe as the acceptance test."
|
| 1228 |
+
)
|
|
|
|
|
|
|
|
|
|
| 1229 |
else:
|
| 1230 |
out["verdict"] = (
|
| 1231 |
f"No HTTP response from the Worker ({et}). Check that "
|