admin.py (25704B)
1 """Admin dashboard: who uploaded what, IP bans, and the GitHub-sync allowlist. 2 3 State is kept in memory and saved as ONE encrypted blob in the Hugging Face 4 bucket (_admin/state.enc), so it survives restarts and redeploys. The bucket can 5 be public, so the blob is Fernet-encrypted; the key comes from ADMIN_DATA_KEY 6 (preferred) or is derived from ADMIN_TOKEN. With no ADMIN_TOKEN the dashboard, 7 tracking, bans and the allowlist are all off. 8 """ 9 from __future__ import annotations 10 11 import asyncio 12 import base64 13 import hashlib 14 import hmac 15 import ipaddress 16 import json 17 import os 18 import time 19 import uuid 20 from collections import deque 21 from urllib.parse import unquote, urlparse 22 23 import httpx 24 from cryptography.fernet import Fernet, InvalidToken 25 from fastapi import APIRouter, HTTPException, Request 26 from fastapi.responses import HTMLResponse 27 from pydantic import BaseModel 28 29 from . import storage 30 from .storage import bucket_url, auth_headers, _do_upload_bytes 31 32 router = APIRouter() 33 34 ADMIN_TOKEN = os.getenv("ADMIN_TOKEN", "") 35 _SECRET = os.getenv("ADMIN_DATA_KEY", "") or ADMIN_TOKEN 36 GH_ALWAYS_ALLOW = {o.strip().lower() for o in os.getenv("GH_ALWAYS_ALLOW", "notamitgamer").split(",") if o.strip()} 37 STATE_PATH = "_admin/state.enc" 38 MAX_LOG_ROWS = 2000 39 SAVE_INTERVAL = 30 40 41 _state: dict = {"log": [], "bans": {}, "gh": {}} 42 _loaded = False 43 _retry_at = 0.0 44 _load_error = "" 45 _dirty = False 46 _load_lock = asyncio.Lock() 47 _save_lock = asyncio.Lock() 48 _loop_task = None 49 _auth_fails: dict[str, deque] = {} 50 51 52 def enabled() -> bool: 53 return bool(ADMIN_TOKEN) 54 55 56 def _fernet() -> Fernet: 57 return Fernet(base64.urlsafe_b64encode(hashlib.sha256(_SECRET.encode()).digest())) 58 59 60 # ---------------------------------------------------------------- persistence 61 62 async def _fetch() -> dict | None: 63 """Saved state, {} if none exists yet, None if it could not be read.""" 64 try: 65 async with httpx.AsyncClient(follow_redirects=True, timeout=20.0) as c: 66 r = await c.get(bucket_url(STATE_PATH), headers=auth_headers()) 67 except httpx.HTTPError: 68 return None 69 if r.status_code == 404: 70 return {} 71 if r.status_code != 200: 72 return None 73 return json.loads(_fernet().decrypt(r.content)) 74 75 76 async def ensure_loaded() -> bool: 77 global _loaded, _load_error, _loop_task, _retry_at 78 if not enabled(): 79 return False 80 if _loaded: 81 return True 82 if time.time() < _retry_at: 83 return False 84 async with _load_lock: 85 if _loaded: 86 return True 87 _retry_at = time.time() + 60 # don't hammer the bucket (or stall uploads) while it's failing 88 try: 89 saved = await _fetch() 90 except (InvalidToken, ValueError): 91 _load_error = "Saved state could not be decrypted - ADMIN_DATA_KEY/ADMIN_TOKEN changed? Saving is paused so it is not overwritten." 92 return False 93 if saved is None: 94 _load_error = "Could not reach the bucket; running in memory only and retrying." 95 return False 96 _state["log"] = saved.get("log", []) + _state["log"] 97 _state["bans"] = {**saved.get("bans", {}), **_state["bans"]} 98 _state["gh"] = {**saved.get("gh", {}), **_state["gh"]} 99 _loaded, _load_error, _retry_at = True, "", 0.0 100 if _loop_task is None: 101 _loop_task = asyncio.create_task(_save_loop()) 102 return True 103 104 105 async def save() -> bool: 106 global _dirty 107 if not (enabled() and _loaded): 108 return False 109 async with _save_lock: 110 _dirty = False 111 _state["log"] = _state["log"][-MAX_LOG_ROWS:] 112 blob = _fernet().encrypt(json.dumps(_state).encode()) 113 try: 114 await asyncio.to_thread(_do_upload_bytes, blob, STATE_PATH) 115 return True 116 except Exception: 117 _dirty = True 118 return False 119 120 121 async def _save_loop() -> None: 122 while True: 123 await asyncio.sleep(SAVE_INTERVAL) 124 if _dirty: 125 await save() 126 127 128 # ------------------------------------------------------------------ recording 129 130 def _row(ip: str, name: str, path: str, size: int, method: str, ua: str = "", status: str = "ok", extra: str = "") -> None: 131 global _dirty 132 _state["log"].append({ 133 "id": uuid.uuid4().hex[:8], "t": int(time.time()), "ip": ip, "name": (name or "")[:200], 134 "path": path, "size": size, "method": method, "ua": (ua or "")[:200], "status": status, "extra": extra[:100], 135 }) 136 _dirty = True 137 138 139 async def record_upload(ctx, path: str) -> None: 140 if await ensure_loaded(): 141 _row(ctx.ip, ctx.filename, path, ctx.size, ctx.method, ctx.user_agent) 142 143 144 async def record_blocked(ctx, reason: str) -> None: 145 if await ensure_loaded(): 146 _row(ctx.ip, ctx.filename, "", ctx.size, ctx.method, ctx.user_agent, status=reason) 147 148 149 async def is_banned(ip: str) -> bool: 150 return enabled() and await ensure_loaded() and ip in _state["bans"] 151 152 153 # ------------------------------------------------------- GitHub sync allowlist 154 155 def _gh_key(owner: str, owner_id: str) -> str: 156 return owner_id or owner.lower() 157 158 159 async def gh_check(owner: str, owner_id: str) -> None: 160 """Raise unless this GitHub account may sync. A new account gets one free sync.""" 161 if not await ensure_loaded(): 162 return 163 rec = _state["gh"].get(_gh_key(owner, owner_id)) 164 if rec and rec["status"] == "banned": 165 raise HTTPException(status_code=403, detail="This GitHub account is banned from the CDN.") 166 if owner.lower() in GH_ALWAYS_ALLOW or not rec or rec["status"] == "allowed": 167 return 168 if rec["syncs"] >= 1: 169 raise HTTPException( 170 status_code=403, 171 detail=f"First sync from '{owner}' was accepted; further syncs need approval from the CDN owner.", 172 ) 173 174 175 async def gh_record(owner: str, owner_id: str, repo: str, actor: str, ip: str, size: int, dest: str) -> None: 176 global _dirty 177 if not await ensure_loaded(): 178 return 179 now = int(time.time()) 180 key = _gh_key(owner, owner_id) 181 status = "allowed" if owner.lower() in GH_ALWAYS_ALLOW else "pending" 182 rec = _state["gh"].setdefault(key, {"status": status, "first": now, "syncs": 0, "repos": []}) 183 rec.update(owner=owner, owner_id=owner_id, last=now, last_ip=ip, last_actor=actor) 184 rec["syncs"] += 1 185 if repo not in rec["repos"]: 186 rec["repos"] = (rec["repos"] + [repo])[-20:] 187 _row(ip, f"{owner}/{repo}", dest, size, "github", status="ok", extra=f"actor {actor}") 188 _dirty = True 189 if rec["syncs"] == 1: 190 await save() # new account: persist right away so it shows up for approval 191 192 193 # ------------------------------------------------------------------ dashboard 194 195 def _require_admin(request: Request) -> None: 196 if not enabled(): 197 raise HTTPException(status_code=404, detail="Not found") 198 from .upload_guard import get_client_ip # lazy: upload_guard imports this module 199 ip = get_client_ip(request) 200 now = time.time() 201 fails = _auth_fails.setdefault(ip, deque()) 202 while fails and now - fails[0] > 60: 203 fails.popleft() 204 if len(fails) >= 8: 205 raise HTTPException(status_code=429, detail="Too many attempts. Wait a minute.") 206 if not hmac.compare_digest(request.headers.get("authorization", ""), f"Bearer {ADMIN_TOKEN}"): 207 fails.append(now) 208 raise HTTPException(status_code=401, detail="Unauthorized") 209 210 211 class IpBody(BaseModel): 212 ip: str 213 note: str = "" 214 215 216 class GhBody(BaseModel): 217 key: str 218 action: str # allow | revoke | ban 219 220 221 class PathBody(BaseModel): 222 path: str 223 224 225 @router.get("/admin", response_class=HTMLResponse) 226 async def admin_page(): 227 if not enabled(): 228 raise HTTPException(status_code=404, detail="Not found") 229 return HTMLResponse(_PAGE, headers={"Cache-Control": "no-store", "X-Robots-Tag": "noindex"}) 230 231 232 def _search(q: str) -> list: 233 """Case-insensitive search over file name, CDN path, IP, GitHub repo/actor, method and status. 234 Every word must match. A pasted CDN link works too (only its path is used).""" 235 q = (q or "").strip() 236 if q.lower().startswith(("http://", "https://")): 237 q = unquote(urlparse(q).path).strip("/") 238 terms = q.lower().split() 239 if not terms: 240 return _state["log"] 241 out = [] 242 for r in _state["log"]: 243 hay = " ".join((r["name"], r["path"], r["ip"], r["method"], r["status"], r.get("extra", ""))).lower() 244 if all(t in hay for t in terms): 245 out.append(r) 246 return out 247 248 249 @router.get("/api/admin/state") 250 async def admin_state(request: Request, q: str = ""): 251 _require_admin(request) 252 await ensure_loaded() 253 from .upload_guard import get_client_ip, is_cloudflare_ip, is_public_ip, TRUST_CF_CONNECTING_IP, TRUSTED_PROXY_HOPS 254 h = request.headers 255 me = get_client_ip(request) 256 rows = _search(q) 257 return { 258 "whoami": { 259 "detected": me, "ok": is_public_ip(me) and not is_cloudflare_ip(me), 260 "x_forwarded_for": h.get("x-forwarded-for"), "cf_connecting_ip": h.get("cf-connecting-ip"), 261 "true_client_ip": h.get("true-client-ip"), "socket_peer": request.client.host if request.client else None, 262 "trust_cf_header": TRUST_CF_CONNECTING_IP, "proxy_hops": TRUSTED_PROXY_HOPS, 263 }, 264 "now": int(time.time()), "error": _load_error, 265 "log": rows[-300:][::-1], "matched": len(rows), "total": len(_state["log"]), 266 "blocked": sum(1 for r in _state["log"] if r["status"] not in ("ok", "deleted")), "bans": _state["bans"], 267 "gh": sorted(_state["gh"].items(), key=lambda kv: kv[1].get("last", 0), reverse=True), 268 "always_allow": sorted(GH_ALWAYS_ALLOW), 269 } 270 271 272 async def _mutate_and_save() -> dict: 273 if not await save(): 274 raise HTTPException(status_code=503, detail=_load_error or "Could not save to the bucket - try again.") 275 return {"ok": True} 276 277 278 @router.post("/api/admin/ban") 279 async def admin_ban(request: Request, body: IpBody): 280 _require_admin(request) 281 if not await ensure_loaded(): 282 raise HTTPException(status_code=503, detail=_load_error) 283 try: 284 ip = str(ipaddress.ip_address(body.ip.strip())) 285 except ValueError: # also stops "unknown" (undetectable IP) from banning everyone 286 raise HTTPException(status_code=400, detail="Not a valid IP address.") 287 from .upload_guard import is_cloudflare_ip 288 if not ipaddress.ip_address(ip).is_global or is_cloudflare_ip(ip): 289 raise HTTPException( 290 status_code=400, 291 detail="That is an internal or Cloudflare address, not a real visitor (banning it could block many people). The server is not seeing visitor IPs correctly - see 'How the server sees you' at the top of the dashboard.", 292 ) 293 _state["bans"][ip] = {"t": int(time.time()), "note": body.note[:100]} 294 return await _mutate_and_save() 295 296 297 @router.post("/api/admin/unban") 298 async def admin_unban(request: Request, body: IpBody): 299 _require_admin(request) 300 if not await ensure_loaded(): 301 raise HTTPException(status_code=503, detail=_load_error) 302 _state["bans"].pop(body.ip.strip(), None) 303 return await _mutate_and_save() 304 305 306 @router.post("/api/admin/gh") 307 async def admin_gh(request: Request, body: GhBody): 308 _require_admin(request) 309 if not await ensure_loaded(): 310 raise HTTPException(status_code=503, detail=_load_error) 311 rec = _state["gh"].get(body.key) 312 if not rec or body.action not in {"allow", "revoke", "ban"}: 313 raise HTTPException(status_code=400, detail="Unknown account or action.") 314 rec["status"] = {"allow": "allowed", "revoke": "pending", "ban": "banned"}[body.action] 315 return await _mutate_and_save() 316 317 318 @router.post("/api/admin/delete") 319 async def admin_delete(request: Request, body: PathBody): 320 _require_admin(request) 321 if not await ensure_loaded(): 322 raise HTTPException(status_code=503, detail=_load_error) 323 path = body.path.strip().strip("/") 324 parts = path.split("/") 325 if len(parts) < 2 or ".." in parts or path.startswith("_"): 326 raise HTTPException(status_code=400, detail="Invalid path.") 327 await asyncio.to_thread(storage.delete_object, path) 328 for r in _state["log"]: 329 if r["path"] == path: 330 r["status"] = "deleted" 331 return await _mutate_and_save() 332 _PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8"> 333 <meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>CDN admin</title> 334 <style> 335 :root{--bg:#0d1117;--sf:#161b22;--bd:#30363d;--ln:#21262d;--tx:#e6edf3;--dim:#9da7b3;--red:#ff7b72;--grn:#56d364;--amb:#d29922;--blu:#58a6ff} 336 *{box-sizing:border-box;-webkit-tap-highlight-color:transparent} 337 body{font:14px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;margin:0 auto;padding:14px 14px 64px;background:var(--bg);color:var(--tx);max-width:1200px;-webkit-text-size-adjust:100%} 338 header.bar{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px} 339 h1{font-size:18px;margin:0}h2{font-size:14px;margin:20px 0 8px;color:var(--dim);font-weight:600;scroll-margin-top:54px} 340 a{color:var(--blu);text-decoration:none} 341 .links{display:flex;gap:8px;align-items:center} 342 .tiles{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;margin-bottom:12px} 343 .tile{display:block;background:var(--sf);border:1px solid var(--bd);border-radius:8px;padding:8px 12px;color:var(--tx);text-decoration:none} 344 .tile b{display:block;font-size:20px;line-height:1.2}.tile span{font-size:11px;color:var(--dim)}.tile.amb{border-color:var(--amb)}.tile.amb b{color:var(--amb)} 345 346 /* Sticky Mobile Quick Nav */ 347 .jump-bar{position:sticky;top:0;z-index:20;background:var(--bg);padding:6px 0 8px;display:flex;gap:6px;overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none;border-bottom:1px solid var(--ln);margin-bottom:8px} 348 .jump-bar::-webkit-scrollbar{display:none} 349 .jump-bar a{white-space:nowrap;font-size:12px;background:var(--sf);border:1px solid var(--bd);padding:4px 10px;border-radius:14px;color:var(--tx)} 350 .jump-bar a.has-badge{border-color:var(--amb);color:var(--amb)} 351 352 /* Standard Horizontal Scroll Table Layout */ 353 .table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--bd);border-radius:8px;background:var(--sf);margin-bottom:10px} 354 table{width:100%;border-collapse:collapse;font-size:13px} 355 th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--ln);vertical-align:middle} 356 tr:last-child td{border-bottom:0} 357 th{color:var(--dim);font-weight:600} 358 .act{white-space:nowrap;text-align:right}.name{overflow-wrap:anywhere} 359 button{font:inherit;font-size:12px;min-height:28px;padding:3px 8px;border-radius:6px;border:1px solid var(--bd);background:var(--bg);color:var(--tx);cursor:pointer} 360 button.red{border-color:#da3633;color:var(--red)}button.green{border-color:#238636;color:var(--grn)}button:active{background:var(--ln)} 361 input{font:inherit;font-size:14px;padding:8px 10px;border-radius:6px;border:1px solid var(--bd);background:var(--bg);color:var(--tx);width:100%;max-width:420px} 362 .mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;overflow-wrap:break-word}.nw{white-space:nowrap} 363 .dim{color:var(--dim)}.bad{color:var(--red)}.ok{color:var(--grn)}.warn{color:var(--amb)} 364 .badge{display:inline-block;padding:1px 5px;border-radius:4px;font-size:10px;font-weight:600;text-transform:uppercase} 365 .badge.ok{background:#1f3522;color:var(--grn)}.badge.bad{background:#3d1d1d;color:var(--red)}.badge.dim{background:var(--ln);color:var(--dim)}.badge.warn{background:#3a2d12;color:var(--amb)} 366 .banner{background:#3d1d1d;border:1px solid #da3633;padding:8px 12px;border-radius:6px;margin:10px 0;font-size:13px} 367 368 /* Collapsible sections */ 369 details.box{margin-bottom:10px;scroll-margin-top:54px} 370 details.box summary{cursor:pointer;font-weight:600;font-size:13px;color:var(--tx);display:flex;align-items:center;justify-content:space-between;list-style:none;padding:4px 0} 371 details.box summary::-webkit-details-marker{display:none} 372 details.box summary::after{content:'+';font-size:14px;color:var(--dim)} 373 details.box[open] summary::after{content:'−'} 374 details.box .content{margin-top:6px} 375 .who p{margin:6px 0 0;font-size:12px} 376 377 #login{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:20px}#login input{flex:1 1 220px} 378 #qrow{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:10px}#qrow input{flex:1 1 240px} 379 #qinfo{font-size:12px} 380 381 @media(min-width:721px){ 382 .tiles{grid-template-columns:repeat(4,1fr)} 383 .jump-bar{display:none} 384 details.box summary{display:none} 385 details.box .content{margin-top:0} 386 } 387 @media(max-width:720px){ 388 body{padding:10px 10px 64px} 389 /* Stagit-style strict single line horizontal scrolling on mobile */ 390 th,td{white-space:nowrap} 391 .name,.mono{overflow-wrap:normal} 392 } 393 </style></head><body> 394 <header class="bar"><h1>CDN admin</h1><div class="links" id="nav" style="display:none"><a href="/stats">Stats</a><button onclick="logout()">Log out</button></div></header> 395 <div id="login"><input id="tok" type="password" placeholder="Admin token" autocomplete="current-password"> <button onclick="go()">Open</button> <span id="lerr" class="bad"></span></div> 396 <div id="app" style="display:none"> 397 <div class="jump-bar" id="jumpbar"></div> 398 <div id="top"></div> 399 <h2 id="uploads-h" style="margin-top:0">Recent uploads</h2> 400 <div id="qrow"><input id="q" type="search" placeholder="Search file name, link, IP, status..." autocomplete="off"><span id="qinfo" class="dim"></span></div> 401 <div id="uploads"></div> 402 <div id="rest"></div> 403 </div> 404 <script> 405 const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); 406 const fmt=t=>new Date(t*1000).toLocaleString([],{dateStyle:'short',timeStyle:'short'}),nf=n=>Number(n).toLocaleString(); 407 const sz=n=>n>=1048576?(n/1048576).toFixed(1)+' MB':n>=1024?(n/1024).toFixed(1)+' KB':n+' B'; 408 let T=sessionStorage.getItem('t')||'',timer=null,Q='',qt=null; 409 document.getElementById('tok').value=''; 410 async function api(p,body){ 411 const r=await fetch(p,{method:body?'POST':'GET',headers:{Authorization:'Bearer '+T,'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined}); 412 if(r.status===401||r.status===429){sessionStorage.removeItem('t');throw new Error((await r.json()).detail)} 413 const j=await r.json();if(!r.ok)throw new Error(j.detail||r.status);return j; 414 } 415 function go(){T=document.getElementById('tok').value.trim();sessionStorage.setItem('t',T);load(true)} 416 const btn=(label,cls,path,body,ask)=>`<button class="${cls}" data-p="${esc(path)}" data-b="${esc(JSON.stringify(body))}" data-ask="${esc(ask||'')}">${esc(label)}</button>`; 417 418 document.addEventListener('click',async e=>{ 419 const b=e.target.closest('button[data-p]');if(!b)return; 420 if(b.dataset.ask&&!confirm(b.dataset.ask))return; 421 try{await api(b.dataset.p,JSON.parse(b.dataset.b));load()}catch(err){alert(err.message)} 422 }); 423 424 async function load(first){ 425 try{ 426 const s=await api('/api/admin/state?q='+encodeURIComponent(Q)); 427 document.getElementById('login').style.display='none';document.getElementById('app').style.display='block';document.getElementById('nav').style.display='flex'; 428 render(s);if(!timer)timer=setInterval(load,20000); 429 }catch(e){ 430 if(first||!T){document.getElementById('lerr').textContent=e.message} 431 if(timer){clearInterval(timer);timer=null} 432 document.getElementById('login').style.display='flex';document.getElementById('app').style.display='none';document.getElementById('nav').style.display='none'; 433 } 434 } 435 const GH='/api/admin/gh'; 436 const empty=(n,t)=>`<tr><td class="empty dim" colspan="${n}">${t}</td></tr>`; 437 function logout(){sessionStorage.removeItem('t');T='';if(timer){clearInterval(timer);timer=null}document.getElementById('app').style.display='none';document.getElementById('nav').style.display='none';document.getElementById('login').style.display='flex'} 438 439 function render(s){ 440 const banned=s.bans,nb=Object.keys(banned).length,pend=s.gh.filter(([k,g])=>g.status==='pending'&&g.syncs>=1); 441 442 document.getElementById('jumpbar').innerHTML=` 443 <a href="#uploads-h">Uploads (${nf(s.matched)})</a> 444 <a href="#pending"${pend.length?' class="has-badge"':''}>Approval (${pend.length})</a> 445 <a href="#bans">Bans (${nb})</a> 446 <a href="#gh-sec">Accounts</a> 447 <a href="#who">Server IP</a> 448 `; 449 450 const tile=(n,l,c,href)=>`<${href?`a href="${href}"`:'div'} class="tile ${c||''}"><b>${n}</b><span>${l}</span></${href?'a':'div'}>`; 451 let t=`<div class="tiles">${tile(nf(s.total),'uploads saved')}${tile(nf(s.blocked),'blocked attempts',s.blocked?'amb':'')}${tile(nb,'banned IPs','','#bans')}${tile(pend.length,'awaiting approval',pend.length?'amb':'','#pending')}</div>`; 452 if(s.error)t+=`<div class="banner">${esc(s.error)}</div>`; 453 454 t+=`<details id="pending" class="box" ${pend.length?'open':''}> 455 <summary>Waiting for approval (${pend.length})</summary><div class="content">`; 456 if(pend.length){ 457 t+='<div class="table-wrap"><table><tr><th>GitHub account</th><th>Repos</th><th>Syncs</th><th>Last sync</th><th></th></tr>'; 458 for(const [k,g] of pend)t+=`<tr><td><a href="https://github.com/${encodeURIComponent(g.owner)}" target="_blank" rel="noopener" class="name">${esc(g.owner)}</a></td><td>${esc((g.repos||[]).join(', '))}</td><td>${g.syncs}</td><td>${fmt(g.last)}</td><td class="act">${btn('Allow','green',GH,{key:k,action:'allow'})} ${btn('Ban','red',GH,{key:k,action:'ban'},'Ban this GitHub account?')}</td></tr>`; 459 t+='</table></div>'; 460 }else t+='<p class="dim" style="margin:2px 0">Nothing waiting. A new GitHub account shows up here after its first sync.</p>'; 461 t+='</div></details>'; 462 463 t+=`<details id="bans" class="box"><summary>Banned IPs (${nb})</summary><div class="content"><div class="table-wrap"><table><tr><th>IP</th><th>Since</th><th>Note</th><th></th></tr>`; 464 for(const ip in banned)t+=`<tr><td class="name mono">${esc(ip)}</td><td>${fmt(banned[ip].t)}</td><td>${esc(banned[ip].note||'')}</td><td class="act">${btn('Unban','','/api/admin/unban',{ip})}</td></tr>`; 465 if(!nb)t+=empty(4,'None.'); 466 t+='</table></div></div></details>'; 467 document.getElementById('top').innerHTML=t; 468 469 // Single Line Stagit-Style Table for Uploads 470 let u='<div class="table-wrap"><table><tr><th>File</th><th>Time</th><th>IP</th><th>Size</th><th>Via</th><th>Status</th><th></th></tr>'; 471 for(const r of s.log){ 472 const stBadge=r.status==='ok'?'<span class="badge ok">OK</span>':r.status==='deleted'?'<span class="badge dim">DEL</span>':`<span class="badge bad">${esc(r.status)}</span>`; 473 const live=r.path&&r.status==='ok'; 474 const acts=(r.method==='github'?'':r.ip in banned?'<span class="dim" style="font-size:11px">Banned</span>':btn('Ban IP','red','/api/admin/ban',{ip:r.ip},'Ban '+r.ip+'?'))+(live?' '+btn('Delete','red','/api/admin/delete',{path:r.path},'Delete '+r.path+' from the CDN?'):''); 475 const nameLink=live?`<a href="/${encodeURI(r.path)}" target="_blank" rel="noopener">${esc(r.name)}</a>`:esc(r.name); 476 477 u+=`<tr> 478 <td class="name">${nameLink}${r.extra?` <span class="dim" style="font-size:11px">(${esc(r.extra)})</span>`:''}</td> 479 <td class="nw">${fmt(r.t)}</td> 480 <td class="mono">${esc(r.ip)}</td> 481 <td class="nw">${sz(r.size)}</td> 482 <td>${esc(r.method)}</td> 483 <td>${stBadge}</td> 484 <td class="act">${acts}</td> 485 </tr>`; 486 } 487 if(!s.log.length)u+=empty(7,Q?'Nothing matches your search.':'No uploads recorded yet.'); 488 document.getElementById('uploads').innerHTML=u+'</table></div>'; 489 document.getElementById('qinfo').textContent=Q?`${s.matched} match${s.matched===1?'':'es'} of ${s.total}`:`${nf(s.total)} saved`; 490 491 let r=`<details id="gh-sec" class="box"><summary>GitHub accounts (${s.gh.length})</summary><div class="content"><div class="table-wrap"><table><tr><th>Account</th><th>Status</th><th>Repos</th><th>Syncs</th><th>Last IP</th><th>Last sync</th><th></th></tr>`; 492 for(const [k,g] of s.gh){ 493 const c=g.status==='allowed'?'<span class="badge ok">OK</span>':g.status==='banned'?'<span class="badge bad">BANNED</span>':'<span class="badge warn">PENDING</span>'; 494 r+=`<tr><td class="name">${esc(g.owner)}</td><td>${c}</td><td>${esc((g.repos||[]).join(', '))}</td><td>${g.syncs}</td><td class="mono">${esc(g.last_ip)}</td><td>${fmt(g.last)}</td><td class="act">${g.status!=='allowed'?btn('Allow','green',GH,{key:k,action:'allow'}):btn('Revoke','',GH,{key:k,action:'revoke'})} ${g.status!=='banned'?btn('Ban','red',GH,{key:k,action:'ban'},'Ban this GitHub account?'):''}</td></tr>`; 495 } 496 if(!s.gh.length)r+=empty(7,'No GitHub syncs yet.'); 497 r+=`</table></div><p class="dim" style="font-size:12px;margin:8px 0 0">Always allowed: ${esc(s.always_allow.join(', ')||'none')}</p></div></details>`; 498 499 const w=s.whoami,kv=(a,b)=>`<tr><td>${a}</td><td class="mono">${esc(b==null||b===''?'-':b)}</td></tr>`; 500 r+=`<details id="who" class="box who"${!w.ok?' open':''}><summary>Your IP: <span class="mono ${w.ok?'ok':'bad'}">${esc(w.detected)}</span> ${w.ok?'✓':'✗ looks wrong'}</summary><div class="content"><div class="table-wrap"><table>${kv('X-Forwarded-For',w.x_forwarded_for)}${kv('CF-Connecting-IP',w.cf_connecting_ip)}${kv('Socket peer',w.socket_peer)}${kv('Settings','TRUST_CF_CONNECTING_IP='+(w.trust_cf_header?1:0)+', TRUSTED_PROXY_HOPS='+w.proxy_hops)}</table></div><p class="dim">Must be your real public IP, or bans will hit the wrong address.</p></div></details>`; 501 document.getElementById('rest').innerHTML=r; 502 } 503 document.getElementById('q').addEventListener('input',e=>{clearTimeout(qt);qt=setTimeout(()=>{Q=e.target.value.trim();load()},250)}); 504 document.getElementById('tok').addEventListener('keydown',e=>{if(e.key==='Enter')go()}); 505 if(T)load(true); 506 </script></body></html>"""