cdnV2

git clone https://github.com/notamitgamer/cdnV2.git
· rebuilt hourlyOpen on GitHub
root / app / admin.py

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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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?'&#10003;':'&#10007; 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>"""