nikeshn commited on
Commit
42401d5
Β·
verified Β·
1 Parent(s): c8b0981

Upload 5 files

Browse files
Files changed (1) hide show
  1. src/api/admin.py +142 -37
src/api/admin.py CHANGED
@@ -1,22 +1,26 @@
1
- """
2
- admin.py β€” LibBee v3.8
3
- Admin dashboard: session management, config store, RAG rebuild, metrics, system status.
4
-
5
- Changes over v3.2:
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 json
15
- import os
16
- import secrets
17
- import secrets
18
- import time
19
- from typing import Any, Dict, Optional
 
 
 
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
- // Keep Worker credentials server-side. No admin token is serialized here.
399
- async function cfFetch(path){{
400
- const response = await fetch('/admin'+path, {{credentials:'same-origin'}});
401
- const data = await response.json();
402
- if(!response.ok||data.detail) throw new Error('HTTP '+response.status+' '+(data.detail||''));
403
- if(data.error) throw new Error(data.error.message||JSON.stringify(data.error));
404
- return {{data, via:'server proxy'}};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 Space cannot open a connection to workers.dev, even "
1116
- f"on unauthenticated routes. Other outbound calls (OpenAI, "
1117
- f"OpenAlex) work, so this is host-specific - typically an "
1118
- f"unroutable IPv6 path or Cloudflare throttling shared "
1119
- f"workers.dev addresses from datacentre IPs. IPv4 is now forced; "
1120
- f"if this persists, put the Worker behind a custom domain "
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
+ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
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 "