cdnV2

git clone https://github.com/notamitgamer/cdnV2.git
· rebuilt hourlyOpen on GitHub
commit f5afc3459d8f26fe2a23df9421fb1229f25af35a
parent 68c61e717bdb6ae057a8bf1078dd68b7b8c2089e
Author: Amit Dutta <mail@amit.is-a.dev>
Date:   Tue,  6 Oct 2026 13:47:59 +0530

Merge pull request #29 from notamitgamer/migration

Migrate to HF Storage Buckets; add admin dashboard, GitHub sync allowlist, stats page and upload hardening
Diffstat:
Aapp/admin.py | 467+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/gh_oidc.py | 1+
Mapp/main.py | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mapp/shortener.py | 42+++++++++++++-----------------------------
Aapp/stats.py | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/storage.py | 155++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mapp/templates/_navbar.html | 3+++
Mapp/templates/documentation.html | 2+-
Mapp/templates/gh_sync_docs.html | 5++++-
Mapp/templates/index.html | 7-------
Aapp/templates/stats.html | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/upload_guard.py | 313+++++++++++++------------------------------------------------------------------
Mrender.yaml | 14+++++++++++---
Mrequirements.txt | 5++---
14 files changed, 1033 insertions(+), 356 deletions(-)

diff --git a/app/admin.py b/app/admin.py @@ -0,0 +1,467 @@ +"""Admin dashboard: who uploaded what, IP bans, and the GitHub-sync allowlist. + +State is kept in memory and saved as ONE encrypted blob in the Hugging Face +bucket (_admin/state.enc), so it survives restarts and redeploys. The bucket can +be public, so the blob is Fernet-encrypted; the key comes from ADMIN_DATA_KEY +(preferred) or is derived from ADMIN_TOKEN. With no ADMIN_TOKEN the dashboard, +tracking, bans and the allowlist are all off. +""" +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import hmac +import ipaddress +import json +import os +import time +import uuid +from collections import deque +from urllib.parse import unquote, urlparse + +import httpx +from cryptography.fernet import Fernet, InvalidToken +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import HTMLResponse +from pydantic import BaseModel + +from . import storage +from .storage import bucket_url, auth_headers, _do_upload_bytes + +router = APIRouter() + +ADMIN_TOKEN = os.getenv("ADMIN_TOKEN", "") +_SECRET = os.getenv("ADMIN_DATA_KEY", "") or ADMIN_TOKEN +GH_ALWAYS_ALLOW = {o.strip().lower() for o in os.getenv("GH_ALWAYS_ALLOW", "notamitgamer").split(",") if o.strip()} +STATE_PATH = "_admin/state.enc" +MAX_LOG_ROWS = 2000 +SAVE_INTERVAL = 30 + +_state: dict = {"log": [], "bans": {}, "gh": {}} +_loaded = False +_retry_at = 0.0 +_load_error = "" +_dirty = False +_load_lock = asyncio.Lock() +_save_lock = asyncio.Lock() +_loop_task = None +_auth_fails: dict[str, deque] = {} + + +def enabled() -> bool: + return bool(ADMIN_TOKEN) + + +def _fernet() -> Fernet: + return Fernet(base64.urlsafe_b64encode(hashlib.sha256(_SECRET.encode()).digest())) + + +# ---------------------------------------------------------------- persistence + +async def _fetch() -> dict | None: + """Saved state, {} if none exists yet, None if it could not be read.""" + try: + async with httpx.AsyncClient(follow_redirects=True, timeout=20.0) as c: + r = await c.get(bucket_url(STATE_PATH), headers=auth_headers()) + except httpx.HTTPError: + return None + if r.status_code == 404: + return {} + if r.status_code != 200: + return None + return json.loads(_fernet().decrypt(r.content)) + + +async def ensure_loaded() -> bool: + global _loaded, _load_error, _loop_task, _retry_at + if not enabled(): + return False + if _loaded: + return True + if time.time() < _retry_at: + return False + async with _load_lock: + if _loaded: + return True + _retry_at = time.time() + 60 # don't hammer the bucket (or stall uploads) while it's failing + try: + saved = await _fetch() + except (InvalidToken, ValueError): + _load_error = "Saved state could not be decrypted - ADMIN_DATA_KEY/ADMIN_TOKEN changed? Saving is paused so it is not overwritten." + return False + if saved is None: + _load_error = "Could not reach the bucket; running in memory only and retrying." + return False + _state["log"] = saved.get("log", []) + _state["log"] + _state["bans"] = {**saved.get("bans", {}), **_state["bans"]} + _state["gh"] = {**saved.get("gh", {}), **_state["gh"]} + _loaded, _load_error, _retry_at = True, "", 0.0 + if _loop_task is None: + _loop_task = asyncio.create_task(_save_loop()) + return True + + +async def save() -> bool: + global _dirty + if not (enabled() and _loaded): + return False + async with _save_lock: + _dirty = False + _state["log"] = _state["log"][-MAX_LOG_ROWS:] + blob = _fernet().encrypt(json.dumps(_state).encode()) + try: + await asyncio.to_thread(_do_upload_bytes, blob, STATE_PATH) + return True + except Exception: + _dirty = True + return False + + +async def _save_loop() -> None: + while True: + await asyncio.sleep(SAVE_INTERVAL) + if _dirty: + await save() + + +# ------------------------------------------------------------------ recording + +def _row(ip: str, name: str, path: str, size: int, method: str, ua: str = "", status: str = "ok", extra: str = "") -> None: + global _dirty + _state["log"].append({ + "id": uuid.uuid4().hex[:8], "t": int(time.time()), "ip": ip, "name": (name or "")[:200], + "path": path, "size": size, "method": method, "ua": (ua or "")[:200], "status": status, "extra": extra[:100], + }) + _dirty = True + + +async def record_upload(ctx, path: str) -> None: + if await ensure_loaded(): + _row(ctx.ip, ctx.filename, path, ctx.size, ctx.method, ctx.user_agent) + + +async def record_blocked(ctx, reason: str) -> None: + if await ensure_loaded(): + _row(ctx.ip, ctx.filename, "", ctx.size, ctx.method, ctx.user_agent, status=reason) + + +async def is_banned(ip: str) -> bool: + return enabled() and await ensure_loaded() and ip in _state["bans"] + + +# ------------------------------------------------------- GitHub sync allowlist + +def _gh_key(owner: str, owner_id: str) -> str: + return owner_id or owner.lower() + + +async def gh_check(owner: str, owner_id: str) -> None: + """Raise unless this GitHub account may sync. A new account gets one free sync.""" + if not await ensure_loaded(): + return + rec = _state["gh"].get(_gh_key(owner, owner_id)) + if rec and rec["status"] == "banned": + raise HTTPException(status_code=403, detail="This GitHub account is banned from the CDN.") + if owner.lower() in GH_ALWAYS_ALLOW or not rec or rec["status"] == "allowed": + return + if rec["syncs"] >= 1: + raise HTTPException( + status_code=403, + detail=f"First sync from '{owner}' was accepted; further syncs need approval from the CDN owner.", + ) + + +async def gh_record(owner: str, owner_id: str, repo: str, actor: str, ip: str, size: int, dest: str) -> None: + global _dirty + if not await ensure_loaded(): + return + now = int(time.time()) + key = _gh_key(owner, owner_id) + status = "allowed" if owner.lower() in GH_ALWAYS_ALLOW else "pending" + rec = _state["gh"].setdefault(key, {"status": status, "first": now, "syncs": 0, "repos": []}) + rec.update(owner=owner, owner_id=owner_id, last=now, last_ip=ip, last_actor=actor) + rec["syncs"] += 1 + if repo not in rec["repos"]: + rec["repos"] = (rec["repos"] + [repo])[-20:] + _row(ip, f"{owner}/{repo}", dest, size, "github", status="ok", extra=f"actor {actor}") + _dirty = True + if rec["syncs"] == 1: + await save() # new account: persist right away so it shows up for approval + + +# ------------------------------------------------------------------ dashboard + +def _require_admin(request: Request) -> None: + if not enabled(): + raise HTTPException(status_code=404, detail="Not found") + from .upload_guard import get_client_ip # lazy: upload_guard imports this module + ip = get_client_ip(request) + now = time.time() + fails = _auth_fails.setdefault(ip, deque()) + while fails and now - fails[0] > 60: + fails.popleft() + if len(fails) >= 8: + raise HTTPException(status_code=429, detail="Too many attempts. Wait a minute.") + if not hmac.compare_digest(request.headers.get("authorization", ""), f"Bearer {ADMIN_TOKEN}"): + fails.append(now) + raise HTTPException(status_code=401, detail="Unauthorized") + + +class IpBody(BaseModel): + ip: str + note: str = "" + + +class GhBody(BaseModel): + key: str + action: str # allow | revoke | ban + + +class PathBody(BaseModel): + path: str + + +@router.get("/admin", response_class=HTMLResponse) +async def admin_page(): + if not enabled(): + raise HTTPException(status_code=404, detail="Not found") + return HTMLResponse(_PAGE, headers={"Cache-Control": "no-store", "X-Robots-Tag": "noindex"}) + + +def _search(q: str) -> list: + """Case-insensitive search over file name, CDN path, IP, GitHub repo/actor, method and status. + Every word must match. A pasted CDN link works too (only its path is used).""" + q = (q or "").strip() + if q.lower().startswith(("http://", "https://")): + q = unquote(urlparse(q).path).strip("/") + terms = q.lower().split() + if not terms: + return _state["log"] + out = [] + for r in _state["log"]: + hay = " ".join((r["name"], r["path"], r["ip"], r["method"], r["status"], r.get("extra", ""))).lower() + if all(t in hay for t in terms): + out.append(r) + return out + + +@router.get("/api/admin/state") +async def admin_state(request: Request, q: str = ""): + _require_admin(request) + await ensure_loaded() + from .upload_guard import get_client_ip, is_cloudflare_ip, is_public_ip, TRUST_CF_CONNECTING_IP, TRUSTED_PROXY_HOPS + h = request.headers + me = get_client_ip(request) + rows = _search(q) + return { + "whoami": { + "detected": me, "ok": is_public_ip(me) and not is_cloudflare_ip(me), + "x_forwarded_for": h.get("x-forwarded-for"), "cf_connecting_ip": h.get("cf-connecting-ip"), + "true_client_ip": h.get("true-client-ip"), "socket_peer": request.client.host if request.client else None, + "trust_cf_header": TRUST_CF_CONNECTING_IP, "proxy_hops": TRUSTED_PROXY_HOPS, + }, + "now": int(time.time()), "error": _load_error, + "log": rows[-300:][::-1], "matched": len(rows), "total": len(_state["log"]), + "blocked": sum(1 for r in _state["log"] if r["status"] not in ("ok", "deleted")), "bans": _state["bans"], + "gh": sorted(_state["gh"].items(), key=lambda kv: kv[1].get("last", 0), reverse=True), + "always_allow": sorted(GH_ALWAYS_ALLOW), + } + + +async def _mutate_and_save() -> dict: + if not await save(): + raise HTTPException(status_code=503, detail=_load_error or "Could not save to the bucket - try again.") + return {"ok": True} + + +@router.post("/api/admin/ban") +async def admin_ban(request: Request, body: IpBody): + _require_admin(request) + if not await ensure_loaded(): + raise HTTPException(status_code=503, detail=_load_error) + try: + ip = str(ipaddress.ip_address(body.ip.strip())) + except ValueError: # also stops "unknown" (undetectable IP) from banning everyone + raise HTTPException(status_code=400, detail="Not a valid IP address.") + from .upload_guard import is_cloudflare_ip + if not ipaddress.ip_address(ip).is_global or is_cloudflare_ip(ip): + raise HTTPException( + status_code=400, + 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.", + ) + _state["bans"][ip] = {"t": int(time.time()), "note": body.note[:100]} + return await _mutate_and_save() + + +@router.post("/api/admin/unban") +async def admin_unban(request: Request, body: IpBody): + _require_admin(request) + if not await ensure_loaded(): + raise HTTPException(status_code=503, detail=_load_error) + _state["bans"].pop(body.ip.strip(), None) + return await _mutate_and_save() + + +@router.post("/api/admin/gh") +async def admin_gh(request: Request, body: GhBody): + _require_admin(request) + if not await ensure_loaded(): + raise HTTPException(status_code=503, detail=_load_error) + rec = _state["gh"].get(body.key) + if not rec or body.action not in {"allow", "revoke", "ban"}: + raise HTTPException(status_code=400, detail="Unknown account or action.") + rec["status"] = {"allow": "allowed", "revoke": "pending", "ban": "banned"}[body.action] + return await _mutate_and_save() + + +@router.post("/api/admin/delete") +async def admin_delete(request: Request, body: PathBody): + _require_admin(request) + if not await ensure_loaded(): + raise HTTPException(status_code=503, detail=_load_error) + path = body.path.strip().strip("/") + parts = path.split("/") + if len(parts) < 2 or ".." in parts or path.startswith("_"): + raise HTTPException(status_code=400, detail="Invalid path.") + await asyncio.to_thread(storage.delete_object, path) + for r in _state["log"]: + if r["path"] == path: + r["status"] = "deleted" + return await _mutate_and_save() + + +_PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8"> +<meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>CDN admin</title> +<style> +:root{--bg:#0d1117;--sf:#161b22;--bd:#30363d;--ln:#21262d;--tx:#e6edf3;--dim:#9da7b3;--red:#ff7b72;--grn:#56d364;--amb:#d29922;--blu:#58a6ff} +*{box-sizing:border-box} +body{font:15px/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%} +header.bar{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px} +h1{font-size:19px;margin:0}h2{font-size:15px;margin:24px 0 8px;color:var(--dim);font-weight:600;scroll-margin-top:12px} +a{color:var(--blu);text-decoration:none} +.links{display:flex;gap:8px;align-items:center} +.tiles{display:grid;grid-template-columns:repeat(2,1fr);gap:8px} +.tile{display:block;background:var(--sf);border:1px solid var(--bd);border-radius:10px;padding:10px 12px;color:var(--tx)} +.tile b{display:block;font-size:21px;line-height:1.2}.tile span{font-size:12px;color:var(--dim)}.tile.amb{border-color:var(--amb)}.tile.amb b{color:var(--amb)} +table{width:100%;border-collapse:collapse;font-size:13px} +th,td{text-align:left;padding:8px;border-bottom:1px solid var(--ln);vertical-align:top}th{color:var(--dim);font-weight:600} +.act{white-space:nowrap}.name{overflow-wrap:anywhere} +button{font:inherit;font-size:13px;min-height:34px;padding:5px 12px;border-radius:8px;border:1px solid var(--bd);background:var(--sf);color:var(--tx);cursor:pointer} +button.red{border-color:#da3633;color:var(--red)}button.green{border-color:#238636;color:var(--grn)}button:active{background:var(--ln)} +input{font:inherit;font-size:16px;padding:10px 12px;border-radius:8px;border:1px solid var(--bd);background:var(--bg);color:var(--tx);width:100%;max-width:420px} +.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;overflow-wrap:break-word}.nw{white-space:nowrap} +.dim{color:var(--dim)}.bad{color:var(--red)}.ok{color:var(--grn)}.warn{color:var(--amb)} +.banner{background:#3d1d1d;border:1px solid #da3633;padding:9px 12px;border-radius:8px;margin-top:10px} +.sec{border-radius:10px;scroll-margin-top:10px}.sec.attn{border-left:3px solid var(--amb);padding-left:10px} +.flash{animation:flash 1.8s ease-out}@keyframes flash{0%{box-shadow:0 0 0 4px var(--amb);background:#2a2110}100%{box-shadow:0 0 0 4px transparent;background:transparent}} +@media(prefers-reduced-motion:reduce){.flash{animation:none}} +.who{margin-top:22px;background:var(--sf);border:1px solid var(--bd);border-radius:10px;padding:10px 12px}.who summary{cursor:pointer} +.who table{margin-top:8px}.who p{margin:8px 0 0;font-size:12px} +#login{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:20px}#login input{flex:1 1 220px} +#qrow{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:8px}#qrow input{flex:1 1 260px} +@media(min-width:721px){.tiles{grid-template-columns:repeat(4,1fr)}th,td{padding:8px 10px}} +@media(max-width:720px){ + body{padding:12px 12px 64px} + table.cards,table.cards tbody{display:block} + table.cards tr{display:block;background:var(--sf);border:1px solid var(--bd);border-radius:10px;padding:10px 12px;margin:0 0 10px} + table.cards tr:first-child{display:none} + table.cards td{display:flex;gap:14px;justify-content:space-between;align-items:baseline;border:0;padding:3px 0;text-align:right;overflow-wrap:anywhere} + table.cards td::before{content:attr(data-l);color:var(--dim);flex:none;text-align:left} + table.cards td.name{display:block;text-align:left;font-size:14px;font-weight:600;padding-bottom:6px}table.cards td.name::before,table.cards td.act::before{display:none} + table.cards td:empty{display:none} + table.cards td.empty{display:block;text-align:left;color:var(--dim)} + table.cards td.act{display:flex;justify-content:stretch;gap:8px;margin-top:8px;padding-top:10px;border-top:1px solid var(--ln)} + table.cards td.act button{flex:1;min-height:44px;font-size:14px} + table.kv tr{display:block;padding:6px 0;border-bottom:1px solid var(--ln)}table.kv td{display:block;border:0;padding:1px 0}table.kv td:first-child{color:var(--dim);font-size:12px} +} +</style></head><body> +<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> +<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> +<div id="app" style="display:none"> +<div id="top"></div> +<h2 id="uploads-h">Recent uploads</h2> +<div id="qrow"><input id="q" type="search" placeholder="Search file name, link, IP, GitHub repo, status..." autocomplete="off"><span id="qinfo" class="dim"></span></div> +<div id="uploads"></div> +<div id="rest"></div> +</div> +<script> +const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); +const fmt=t=>new Date(t*1000).toLocaleString([],{dateStyle:'short',timeStyle:'short'}),nf=n=>Number(n).toLocaleString(); +const sz=n=>n>=1048576?(n/1048576).toFixed(1)+' MB':n>=1024?(n/1024).toFixed(1)+' KB':n+' B'; +let T=sessionStorage.getItem('t')||'',timer=null,Q='',qt=null; +document.getElementById('tok').value=''; +async function api(p,body){ + const r=await fetch(p,{method:body?'POST':'GET',headers:{Authorization:'Bearer '+T,'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined}); + if(r.status===401||r.status===429){sessionStorage.removeItem('t');throw new Error((await r.json()).detail)} + const j=await r.json();if(!r.ok)throw new Error(j.detail||r.status);return j; +} +function go(){T=document.getElementById('tok').value.trim();sessionStorage.setItem('t',T);load(true)} +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>`; +document.addEventListener('click',e=>{ + const a=e.target.closest('a.tile[href^="#"]');if(!a)return; + const el=document.querySelector(a.getAttribute('href'));if(!el)return; + e.preventDefault();el.scrollIntoView({behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'auto':'smooth',block:'start'}); + el.classList.remove('flash');void el.offsetWidth;el.classList.add('flash'); +}); +document.addEventListener('click',async e=>{ + const b=e.target.closest('button[data-p]');if(!b)return; + if(b.dataset.ask&&!confirm(b.dataset.ask))return; + try{await api(b.dataset.p,JSON.parse(b.dataset.b));load()}catch(err){alert(err.message)} +}); +async function load(first){ + try{ + const s=await api('/api/admin/state?q='+encodeURIComponent(Q)); + document.getElementById('login').style.display='none';document.getElementById('app').style.display='block';document.getElementById('nav').style.display='flex'; + render(s);if(!timer)timer=setInterval(load,20000); + }catch(e){ + if(first||!T){document.getElementById('lerr').textContent=e.message} + if(timer){clearInterval(timer);timer=null} + document.getElementById('login').style.display='flex';document.getElementById('app').style.display='none';document.getElementById('nav').style.display='none'; + } +} +const GH='/api/admin/gh'; +let WHO=false; +const cell=(l,v,c)=>`<td data-l="${l}"${c?` class="${c}"`:''}>${v}</td>`; +const empty=(n,t)=>`<tr><td class="empty" colspan="${n}">${t}</td></tr>`; +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'} +function render(s){ + const banned=s.bans,nb=Object.keys(banned).length,pend=s.gh.filter(([k,g])=>g.status==='pending'&&g.syncs>=1); + const tile=(n,l,c,href)=>`<${href?`a href="${href}"`:'div'} class="tile ${c||''}"><b>${n}</b><span>${l}</span></${href?'a':'div'}>`; + 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>`; + if(s.error)t+=`<div class="banner">${esc(s.error)}</div>`; + t+=`<section id="pending" class="sec${pend.length?' attn':''}"><h2>Waiting for approval</h2>`; + if(pend.length){ + t+='<table class="cards"><tr><th>GitHub account</th><th>Repos</th><th>Syncs</th><th>Last sync</th><th></th></tr>'; + for(const [k,g] of pend)t+=`<tr>${cell('Account',`<a href="https://github.com/${encodeURIComponent(g.owner)}" target="_blank" rel="noopener">${esc(g.owner)}</a>`,'name')}${cell('Repos',esc((g.repos||[]).join(', ')))}${cell('Syncs',g.syncs)}${cell('Last sync',fmt(g.last))}<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>`; + t+='</table>'; + }else t+='<p class="dim" style="margin:0">Nothing waiting. A new GitHub account shows up here after its first sync.</p>'; + t+='</section>'; + document.getElementById('top').innerHTML=t; + + let u='<table class="cards"><tr><th>File</th><th>Time</th><th>IP</th><th>Size</th><th>Via</th><th>Status</th><th></th></tr>'; + for(const r of s.log){ + const st=r.status==='ok'?'ok':r.status==='deleted'?'dim':'bad',live=r.path&&r.status==='ok'; + const acts=(r.method==='github'?'':r.ip in banned?'<span class="dim">IP 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?'):''); + u+=`<tr>${cell('File',(live?`<a href="/${encodeURI(r.path)}" target="_blank" rel="noopener">${esc(r.name)}</a>`:esc(r.name))+(r.extra?`<div class="dim" style="font-weight:400">${esc(r.extra)}</div>`:''),'name')}${cell('Time',fmt(r.t),'nw')}${cell('IP',esc(r.ip),'mono')}${cell('Size',sz(r.size),'nw')}${cell('Via',esc(r.method))}${cell('Status',esc(r.status),st)}${acts?`<td class="act">${acts}</td>`:'<td class="act" style="display:none"></td>'}</tr>`; + } + if(!s.log.length)u+=empty(7,Q?'Nothing matches your search.':'No uploads recorded yet.'); + document.getElementById('uploads').innerHTML=u+'</table>'; + document.getElementById('qinfo').textContent=Q?`${s.matched} match${s.matched===1?'':'es'} of ${s.total}${s.matched>s.log.length?' (showing newest '+s.log.length+')':''}`:`${nf(s.total)} saved`; + + let r='<h2>GitHub accounts</h2><table class="cards"><tr><th>Account</th><th>Status</th><th>Repos</th><th>Syncs</th><th>Last IP</th><th>Last sync</th><th></th></tr>'; + for(const [k,g] of s.gh){const c=g.status==='allowed'?'ok':g.status==='banned'?'bad':'warn'; + r+=`<tr>${cell('Account',esc(g.owner),'name')}${cell('Status',esc(g.status),c)}${cell('Repos',esc((g.repos||[]).join(', ')))}${cell('Syncs',g.syncs)}${cell('Last IP',esc(g.last_ip),'mono')}${cell('Last sync',fmt(g.last))}<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>`} + if(!s.gh.length)r+=empty(7,'No GitHub syncs yet.'); + r+=`</table><p class="dim">Always allowed: ${esc(s.always_allow.join(', ')||'none')}</p><section id="bans" class="sec"><h2>Banned IPs</h2><table class="cards"><tr><th>IP</th><th>Since</th><th>Note</th><th></th></tr>`; + for(const ip in banned)r+=`<tr>${cell('IP',esc(ip),'name mono')}${cell('Since',fmt(banned[ip].t))}${cell('Note',esc(banned[ip].note||''))}<td class="act">${btn('Unban','','/api/admin/unban',{ip})}</td></tr>`; + if(!nb)r+=empty(4,'None.'); + const w=s.whoami,kv=(a,b)=>`<tr><td>${a}</td><td class="mono">${esc(b==null||b===''?'-':b)}</td></tr>`; + r+=`</table></section><details id="who" class="who"${WHO||!w.ok?' open':''}><summary>Your IP as the server sees it: <span class="mono ${w.ok?'ok':'bad'}">${esc(w.detected)}</span> ${w.ok?'&#10003;':'&#10007; looks wrong'}</summary><table class="kv">${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><p class="dim">This must be your real public IP, or bans will hit the wrong address.</p></details>`; + document.getElementById('rest').innerHTML=r; + document.getElementById('who').addEventListener('toggle',e=>{WHO=e.target.open}); +} +document.getElementById('q').addEventListener('input',e=>{clearTimeout(qt);qt=setTimeout(()=>{Q=e.target.value.trim();load()},250)}); +document.getElementById('tok').addEventListener('keydown',e=>{if(e.key==='Enter')go()}); +if(T)load(true); +</script></body></html>""" diff --git a/app/gh_oidc.py b/app/gh_oidc.py @@ -71,6 +71,7 @@ def verify_actions_token(token: str) -> dict: return { "owner": owner, + "owner_id": str(claims.get("repository_owner_id", "")), "repo": repo_name, "actor": claims.get("actor", ""), "ref": claims.get("ref", ""), diff --git a/app/main.py b/app/main.py @@ -30,15 +30,20 @@ from .storage import ( search_files, write_batch_manifest, get_batch_manifest, - HF_REPO_ID, + HF_BUCKET_ID, + bucket_url, + auth_headers, format_size, ) from .gh_oidc import verify_actions_token from .shortener import shorten_url, get_destination_url -from .upload_guard import router as upload_guard_router, get_client_ip, inspect_upload, record_upload +from .upload_guard import get_client_ip, inspect_upload, is_video_upload +from .stats import router as stats_router +from .admin import router as admin_router, record_upload, is_banned, gh_check, gh_record app = FastAPI() -app.include_router(upload_guard_router) +app.include_router(admin_router) +app.include_router(stats_router) templates = Jinja2Templates(directory="app/templates") STATIC_DIR = Path(__file__).parent / "static" @@ -146,9 +151,9 @@ def _resolve_content_type(filename: str, upstream_content_type: str | None) -> s return upstream_content_type async def stream_raw(path: str, request: Request): - hf_url = f"https://huggingface.co/datasets/{HF_REPO_ID}/resolve/main/{path}" + hf_url = bucket_url(path) client = httpx.AsyncClient(follow_redirects=True) - req_headers = {} + req_headers = dict(auth_headers()) range_header = request.headers.get("range") if range_header: req_headers["Range"] = range_header @@ -184,9 +189,9 @@ async def stream_raw(path: str, request: Request): @app.get("/api/get/{path:path}") async def download_file(path: str, request: Request): - hf_url = f"https://huggingface.co/datasets/{HF_REPO_ID}/resolve/main/{path}" + hf_url = bucket_url(path) client = httpx.AsyncClient(follow_redirects=True) - req_headers = {} + req_headers = dict(auth_headers()) range_header = request.headers.get("range") if range_header: req_headers["Range"] = range_header @@ -253,6 +258,38 @@ class _UploadRateLimiter: _upload_limiter = _UploadRateLimiter() + + +class _CountRateLimiter: + """Sliding-window request counter per key (e.g. per client IP).""" + + def __init__(self, per_minute: int, per_hour: int): + self.per_minute = per_minute + self.per_hour = per_hour + self._hits: dict[str, deque] = {} + + def check_and_record(self, key: str): + now = time.time() + dq = self._hits.setdefault(key, deque()) + while dq and now - dq[0] > 3600: + dq.popleft() + if sum(1 for t in dq if now - t <= 60) >= self.per_minute: + raise HTTPException( + status_code=429, + detail=f"Rate limit exceeded: {self.per_minute} requests/minute. Try again shortly.", + ) + if len(dq) >= self.per_hour: + raise HTTPException( + status_code=429, + detail=f"Rate limit exceeded: {self.per_hour} requests/hour. Try again later.", + ) + dq.append(now) + if len(self._hits) > 10000: + self._hits = {k: v for k, v in self._hits.items() if v and now - v[-1] <= 3600} + + +_shorten_limiter = _CountRateLimiter(per_minute=10, per_hour=60) +SHORTEN_MAX_URL_LENGTH = 2048 BATCH_MIN_FILES = 2 ALLOWED_UPLOAD_FOLDERS = {"uploads", "third-party"} @@ -278,7 +315,7 @@ async def handle_upload( results = [] for file in files: - temp_path = f"/tmp/{uuid.uuid4()}-{file.filename}" + temp_path = f"/tmp/{uuid.uuid4()}" with open(temp_path, "wb") as f: shutil.copyfileobj(file.file, f) @@ -372,6 +409,10 @@ async def gh_sync( owner, repo = claims["owner"], claims["repo"] client_ip = get_client_ip(request) + if await is_banned(client_ip): + raise HTTPException(status_code=403, detail="You are banned from this CDN.") + await gh_check(owner, claims["owner_id"]) + contents = await archive.read() _gh_sync_limiter.check_and_record( f"gh-sync:{owner}/{repo}:{client_ip}", @@ -393,12 +434,23 @@ async def gh_sync( detail="Could not read archive (expected a .tar.gz).", ) + for root, _dirs, names in os.walk(tmp_extract): + for name in names: + full = os.path.join(root, name) + if is_video_upload(name, full): + raise HTTPException( + status_code=415, + detail=f"Video files are not allowed on this CDN (found {name}). Nothing was synced.", + ) + dest_prefix = await upload_folder_scoped( tmp_extract, owner, repo, ) + await gh_record(owner, claims["owner_id"], repo, claims["actor"], client_ip, len(contents), dest_prefix) + return { "synced_to": dest_prefix, "cdn_url": f"{CDN_BASE_URL}/{dest_prefix}/", @@ -421,6 +473,15 @@ class ShortenRequest(BaseModel): @app.post("/api/shorten") async def api_shorten(request: Request, body: ShortenRequest): url = body.url.strip() + if len(url) > SHORTEN_MAX_URL_LENGTH: + raise HTTPException( + status_code=400, + detail=f"URL too long (max {SHORTEN_MAX_URL_LENGTH} characters).", + ) + client_ip = get_client_ip(request) + if await is_banned(client_ip): + raise HTTPException(status_code=403, detail="You are banned from this CDN.") + _shorten_limiter.check_and_record(client_ip) _assert_public_url(url) short_id = await shorten_url(url) @@ -520,7 +581,7 @@ async def handle_upload_from_url( or f"download-{uuid.uuid4().hex[:8]}" ) - temp_path = f"/tmp/{uuid.uuid4()}-{filename}" + temp_path = f"/tmp/{uuid.uuid4()}" downloaded = 0 async with httpx.AsyncClient( @@ -547,7 +608,6 @@ async def handle_upload_from_url( if "filename=" in cd: filename = cd.split("filename=")[-1].strip('"; ') or filename - temp_path = f"/tmp/{uuid.uuid4()}-{filename}" with open(temp_path, "wb") as f: async for chunk in r.aiter_bytes(): @@ -670,13 +730,10 @@ async def download_zip(path: str): semaphore = asyncio.Semaphore(ZIP_FETCH_CONCURRENCY) async def fetch(client: httpx.AsyncClient, f: dict): - hf_url = ( - f"https://huggingface.co/datasets/" - f"{HF_REPO_ID}/resolve/main/{f['path']}" - ) + hf_url = bucket_url(f["path"]) async with semaphore: - r = await client.get(hf_url) + r = await client.get(hf_url, headers=auth_headers()) return f, r @@ -714,7 +771,7 @@ async def download_zip(path: str): zip_filename = ( clean_path.rstrip("/").split("/")[-1] if clean_path - else HF_REPO_ID.split("/")[-1] + else HF_BUCKET_ID.split("/")[-1] ) + ".zip" return StreamingResponse( @@ -818,13 +875,10 @@ async def download_zip_batch(batch_id: str): semaphore = asyncio.Semaphore(ZIP_FETCH_CONCURRENCY) async def fetch(client: httpx.AsyncClient, f: dict): - hf_url = ( - f"https://huggingface.co/datasets/" - f"{HF_REPO_ID}/resolve/main/{f['hf_path']}" - ) + hf_url = bucket_url(f["hf_path"]) async with semaphore: - r = await client.get(hf_url) + r = await client.get(hf_url, headers=auth_headers()) return f, r diff --git a/app/shortener.py b/app/shortener.py @@ -1,15 +1,10 @@ -import os -import io import random import string import asyncio import re import httpx -from huggingface_hub import HfApi -HF_REPO_ID = os.getenv("HF_REPO_ID", "notamitgamer/cdn") -HF_TOKEN = os.getenv("HF_TOKEN") -api = HfApi(token=HF_TOKEN) if HF_TOKEN else None +from .storage import HF_TOKEN, bucket_url, auth_headers, _do_upload_bytes # Reusable HTTP client for fast connection pooling & HTTP keep-alive _client = httpx.AsyncClient(follow_redirects=True, timeout=10.0) @@ -22,8 +17,8 @@ def generate_id(length: int = 8) -> str: return "".join(random.choices(chars, k=length)) async def shorten_url(destination_url: str) -> str: - """Save a destination URL under an in-memory byte payload to Hugging Face.""" - if not api: + """Save a destination URL as a tiny object in the Hugging Face bucket.""" + if not HF_TOKEN: raise RuntimeError("HF_TOKEN is not configured.") destination_url = destination_url.strip() @@ -32,8 +27,7 @@ async def shorten_url(destination_url: str) -> str: attempts = 0 while True: short_id = generate_id() - hf_url = f"https://huggingface.co/datasets/{HF_REPO_ID}/resolve/main/_shortened/{short_id}" - r = await _client.head(hf_url) + r = await _client.head(bucket_url(f"_shortened/{short_id}"), headers=auth_headers()) if r.status_code == 404: break attempts += 1 @@ -42,31 +36,22 @@ async def shorten_url(destination_url: str) -> str: short_id = generate_id(length=12) break - # Upload directly from memory without writing to disk - payload = io.BytesIO(destination_url.encode("utf-8")) - - def _upload(): - api.upload_file( - path_or_fileobj=payload, - path_in_repo=f"_shortened/{short_id}", - repo_id=HF_REPO_ID, - repo_type="dataset", - token=HF_TOKEN, - commit_message=f"shorten: {short_id}", - ) - - await asyncio.to_thread(_upload) + # Upload straight from memory without writing to disk + await asyncio.to_thread( + _do_upload_bytes, + destination_url.encode("utf-8"), + f"_shortened/{short_id}", + ) return short_id async def get_destination_url(short_id: str) -> str | None: - """Look up the destination URL directly from Hugging Face resolve endpoints.""" + """Look up the destination URL directly from the bucket's resolve endpoint.""" short_id = short_id.strip().lower() if not _ID_REGEX.match(short_id): return None - hf_url = f"https://huggingface.co/datasets/{HF_REPO_ID}/resolve/main/_shortened/{short_id}" try: - r = await _client.get(hf_url) + r = await _client.get(bucket_url(f"_shortened/{short_id}"), headers=auth_headers()) if r.status_code == 200: return r.text.strip() except httpx.RequestError: @@ -75,4 +60,4 @@ async def get_destination_url(short_id: str) -> str | None: def list_all_ids() -> list[str]: """Stub kept for router compatibility.""" - return [] -\ No newline at end of file + return [] diff --git a/app/stats.py b/app/stats.py @@ -0,0 +1,127 @@ +"""Public stats page: what is in the CDN (types, sizes, folders, growth). + +Everything is computed from the file list storage.py already caches, then kept +for STATS_TTL seconds, so viewing the page never adds load on the bucket. +Only aggregate numbers are exposed - no IPs, no upload log. +""" +from __future__ import annotations + +import asyncio +import statistics +import time +from collections import Counter, defaultdict +from datetime import datetime, timezone + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse +from fastapi.templating import Jinja2Templates + +from . import storage + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + +STATS_TTL = 600 +_cache: dict = {"at": 0.0, "data": None} +_lock = asyncio.Lock() + +CATEGORIES = [ + ("images", "Images", "png jpg jpeg gif webp avif svg ico bmp heic tiff"), + ("documents", "Documents", "pdf doc docx ppt pptx xls xlsx odt ods odp txt md rtf csv epub"), + ("code", "Code & data", "js mjs css html htm json xml yml yaml toml py ts tsx jsx c cpp h java go rs sh sql map wasm"), + ("audio", "Audio", "mp3 wav ogg flac m4a aac opus"), + ("video", "Video", "mp4 mkv webm mov avi m4v"), + ("archives", "Archives", "zip tar gz tgz 7z rar xz bz2"), + ("fonts", "Fonts", "woff woff2 ttf otf eot"), +] +_EXT_TO_CAT = {e: (k, label) for k, label, exts in CATEGORIES for e in exts.split()} +SIZE_BUCKETS = [(10 * 1024, "< 10 KB"), (100 * 1024, "10-100 KB"), (1024 ** 2, "100 KB-1 MB"), + (10 * 1024 ** 2, "1-10 MB"), (100 * 1024 ** 2, "10-100 MB"), (float("inf"), "> 100 MB")] +TOP_LEVEL_FOLDERS = {"uploads", "third-party"} + + +def _ext(name: str) -> str: + return name.rsplit(".", 1)[-1].lower() if "." in name.strip(".") else "" + + +def _folder(path: str) -> str: + parts = path.split("/") + if len(parts) == 1: + return "(root)" + return parts[0] if parts[0] in TOP_LEVEL_FOLDERS else "/".join(parts[:2]) if len(parts) > 2 else parts[0] + + +def compute(items: list[dict]) -> dict: + files = [i for i in items if not i["is_dir"] and i["name"] != ".gitattributes"] + if not files: + return {"empty": True} + + sizes = [f["size"] for f in files] + cats: dict[str, dict] = {} + exts: dict[str, list] = defaultdict(lambda: [0, 0]) + folders: dict[str, list] = defaultdict(lambda: [0, 0]) + buckets = Counter() + months: dict[str, list] = defaultdict(lambda: [0, 0]) + + for f in files: + ext = _ext(f["name"]) + key, label = _EXT_TO_CAT.get(ext, ("other", "Other")) + c = cats.setdefault(key, {"key": key, "label": label, "files": 0, "bytes": 0}) + c["files"] += 1 + c["bytes"] += f["size"] + exts[ext or "(none)"][0] += 1 + exts[ext or "(none)"][1] += f["size"] + fo = folders[_folder(f["path"])] + fo[0] += 1 + fo[1] += f["size"] + buckets[next(lbl for limit, lbl in SIZE_BUCKETS if f["size"] < limit)] += 1 + if f.get("ts"): + m = months[datetime.fromtimestamp(f["ts"], timezone.utc).strftime("%Y-%m")] + m[0] += 1 + m[1] += f["size"] + + top_ext = sorted(exts.items(), key=lambda kv: kv[1][0], reverse=True) + top_folders = sorted(folders.items(), key=lambda kv: kv[1][1], reverse=True) + largest = sorted(files, key=lambda f: f["size"], reverse=True)[:8] + month_rows = [{"m": k, "files": v[0], "bytes": v[1]} for k, v in sorted(months.items())][-12:] + + return { + "empty": False, + "generated": int(time.time()), + "totals": { + "files": len(files), "bytes": sum(sizes), "avg": int(sum(sizes) / len(sizes)), + "median": int(statistics.median(sizes)), "types": len(exts), + }, + "categories": sorted(cats.values(), key=lambda c: c["files"], reverse=True), + "extensions": [{"ext": e, "files": v[0], "bytes": v[1]} for e, v in top_ext[:10]], + "extensions_more": sum(v[0] for _, v in top_ext[10:]), + "sizes": [{"label": lbl, "files": buckets.get(lbl, 0)} for _, lbl in SIZE_BUCKETS], + "folders": [{"name": n, "files": v[0], "bytes": v[1]} for n, v in top_folders[:8]], + "largest": [{"name": f["name"], "path": f["path"], "size": f["size"]} for f in largest], + "months": month_rows, + } + + +async def get_stats() -> dict: + now = time.time() + if _cache["data"] is not None and now - _cache["at"] < STATS_TTL: + return _cache["data"] + async with _lock: + if _cache["data"] is not None and time.time() - _cache["at"] < STATS_TTL: + return _cache["data"] + items = await storage._get_full_tree() + if not items: # listing failed: keep serving the last good result if we have one + return _cache["data"] or {"empty": True} + data = await asyncio.to_thread(compute, items) + _cache.update(at=time.time(), data=data) + return data + + +@router.get("/stats") +async def stats_page(request: Request): + return templates.TemplateResponse(request, "stats.html", {"page": "stats"}) + + +@router.get("/api/stats") +async def stats_api(): + return JSONResponse(await get_stats(), headers={"Cache-Control": "public, max-age=300"}) diff --git a/app/storage.py b/app/storage.py @@ -1,6 +1,7 @@ import os import re import time +import unicodedata import uuid import json import tempfile @@ -8,8 +9,15 @@ import asyncio import httpx from huggingface_hub import HfApi -HF_REPO_ID = os.getenv("HF_REPO_ID", "notamitgamer/cdn") +# Files live in a Hugging Face Storage Bucket (mutable, non-versioned object +# storage) instead of a git-backed dataset repo: every write is a plain object +# write rather than a commit, so uploads and folder syncs avoid git overhead and +# commit rate limits. HF_REPO_ID is still honoured as a fallback so existing +# deployments keep working until HF_BUCKET_ID is set. +HF_BUCKET_ID = os.getenv("HF_BUCKET_ID") or os.getenv("HF_REPO_ID", "notamitgamer/cdn") +HF_REPO_ID = HF_BUCKET_ID # backwards-compatible alias used by older imports HF_TOKEN = os.getenv("HF_TOKEN") +BUCKET_PRIVATE = os.getenv("HF_BUCKET_PRIVATE", "0") == "1" CACHE_TTL = 60 MAX_CACHE_SIZE = 500 ZIP_MAX_FILES = 300 @@ -19,6 +27,29 @@ SEARCH_RESULT_LIMIT = 10 api = HfApi(token=HF_TOKEN) + +def bucket_url(path: str) -> str: + """Direct (CDN-backed) download URL for an object in the bucket.""" + return f"https://huggingface.co/buckets/{HF_BUCKET_ID}/resolve/{path}" + + +def auth_headers() -> dict: + """Bearer header, only needed when the bucket is private.""" + if BUCKET_PRIVATE and HF_TOKEN: + return {"Authorization": f"Bearer {HF_TOKEN}"} + return {} + + +_bucket_ready = False + + +def _ensure_bucket(): + global _bucket_ready + if _bucket_ready: + return + api.create_bucket(HF_BUCKET_ID, private=BUCKET_PRIVATE, exist_ok=True) + _bucket_ready = True + _cache = {} _key_locks: dict[str, asyncio.Lock] = {} @@ -69,9 +100,9 @@ async def get_file_info(path: str) -> dict: if cached is not None: return cached - hf_url = f"https://huggingface.co/datasets/{HF_REPO_ID}/resolve/main/{path}" + hf_url = bucket_url(path) async with httpx.AsyncClient(follow_redirects=True) as client: - r = await client.head(hf_url) + r = await client.head(hf_url, headers=auth_headers()) exists = r.status_code == 200 size = None content_type = None @@ -85,17 +116,29 @@ async def get_file_info(path: str) -> dict: _set_cache(cache_key, result) return result +def _under(path: str, items: list) -> list: + """Keep only entries inside `path` (defensive: guards against prefix over-match + such as 'docs' also matching 'docs-old/...').""" + if not path: + return items + prefix = path.rstrip("/") + "/" + return [it for it in items if it.path.startswith(prefix)] + def _fetch_tree(path: str): - return list(api.list_repo_tree( - repo_id=HF_REPO_ID, path_in_repo=path, repo_type="dataset", expand=False + path = path.strip("/") + items = list(api.list_bucket_tree( + HF_BUCKET_ID, prefix=path or None, recursive=False )) + return _under(path, items) def _fetch_tree_recursive(path: str = ""): - return list(api.list_repo_tree( - repo_id=HF_REPO_ID, path_in_repo=path, repo_type="dataset", recursive=True + path = path.strip("/") + items = list(api.list_bucket_tree( + HF_BUCKET_ID, prefix=path or None, recursive=True )) + return _under(path, items) -_HIDDEN_PREFIXES = ("_batches", "_shortened", "_logs",) +_HIDDEN_PREFIXES = ("_batches", "_shortened", "_logs", "_admin",) def _is_hidden_path(path: str) -> bool: return any(path == p or path.startswith(p + "/") for p in _HIDDEN_PREFIXES) @@ -196,11 +239,13 @@ async def _get_full_tree(): name = item.path.split("/")[-1] is_dir = not hasattr(item, "size") size = getattr(item, "size", 0) or 0 + up = getattr(item, "uploaded_at", None) items.append({ "name": name, "path": item.path, "is_dir": is_dir, "size": size, + "ts": int(up.timestamp()) if up else 0, "size_str": format_size(size) if not is_dir else "-", }) @@ -241,17 +286,54 @@ async def search_files(query: str): ] def _do_upload(temp_path: str, hf_path: str): - api.upload_file( - path_or_fileobj=temp_path, - path_in_repo=hf_path, - repo_id=HF_REPO_ID, - repo_type="dataset", - token=HF_TOKEN + _ensure_bucket() + api.batch_bucket_files( + HF_BUCKET_ID, + add=[(temp_path, hf_path)], + ) + +def _do_upload_bytes(data: bytes, hf_path: str): + _ensure_bucket() + api.batch_bucket_files( + HF_BUCKET_ID, + add=[(data, hf_path)], ) +def delete_object(path: str): + """Delete one object, or everything under path/ (for a synced owner/repo folder).""" + _ensure_bucket() + path = path.strip("/") + items = [it.path for it in api.list_bucket_tree(HF_BUCKET_ID, prefix=path, recursive=True) + if it.type == "file" and (it.path == path or it.path.startswith(path + "/"))] + if items: + api.batch_bucket_files(HF_BUCKET_ID, delete=items) + _cache.clear() + + +_UNSAFE_NAME_CHARS = re.compile(r"[^A-Za-z0-9._-]+") + + +def sanitize_filename(filename: str, max_len: int = 100) -> str: + """Turn any user-supplied filename into a safe object-key segment. + + Accepts spaces, accents, symbols, emoji and path fragments: directory parts + are dropped, accents are folded to ASCII, and anything else unsafe becomes + a single underscore. The extension is kept. + """ + name = (filename or "").replace("\\", "/").split("/")[-1] + name = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode("ascii") + stem, dot, ext = name.rpartition(".") + if not dot: + stem, ext = name, "" + stem = _UNSAFE_NAME_CHARS.sub("_", stem).strip("._") or "file" + ext = _UNSAFE_NAME_CHARS.sub("", ext)[:10] + stem = stem[: max_len - len(ext) - 1] + return f"{stem}.{ext}" if ext else stem + + async def upload_temp_file(temp_path: str, filename: str, folder: str = "uploads") -> str: slug = str(uuid.uuid4())[:8] - safe_filename = filename.replace(" ", "_") + safe_filename = sanitize_filename(filename) hf_path = f"{folder}/{slug}-{safe_filename}" await asyncio.to_thread(_do_upload, temp_path, hf_path) @@ -260,17 +342,34 @@ async def upload_temp_file(temp_path: str, filename: str, folder: str = "uploads return hf_path def _do_upload_folder_scoped(local_dir: str, dest_prefix: str): - # path_in_repo + delete_patterns are both scoped to dest_prefix, so this call - # can only ever create/update/delete files under that one folder - it never - # touches anything outside of it in the dataset repo. - api.upload_folder( - repo_id=HF_REPO_ID, - repo_type="dataset", - folder_path=local_dir, - path_in_repo=dest_prefix, - delete_patterns="*", - commit_message=f"gh-sync: update {dest_prefix}", - token=HF_TOKEN, + # Mirror local_dir into the bucket under dest_prefix. Every added path and every + # deleted path is built from dest_prefix, so this can only ever create, update + # or delete objects under that one folder - it never touches anything outside it. + _ensure_bucket() + dest_prefix = dest_prefix.strip("/") + + local_files = {} + for root, _dirs, names in os.walk(local_dir): + for name in names: + full = os.path.join(root, name) + rel = os.path.relpath(full, local_dir).replace(os.sep, "/") + local_files[f"{dest_prefix}/{rel}"] = full + + remote_files = { + it.path + for it in api.list_bucket_tree(HF_BUCKET_ID, prefix=dest_prefix, recursive=True) + if it.type == "file" and it.path.startswith(dest_prefix + "/") + } + + stale = sorted(remote_files - local_files.keys()) + adds = [(full, remote) for remote, full in sorted(local_files.items())] + + if not adds and not stale: + return + api.batch_bucket_files( + HF_BUCKET_ID, + add=adds or None, + delete=stale or None, ) async def upload_folder_scoped(local_dir: str, owner: str, repo: str) -> str: @@ -303,9 +402,9 @@ async def get_batch_manifest(batch_id: str): if cached is not None: return cached - hf_url = f"https://huggingface.co/datasets/{HF_REPO_ID}/resolve/main/_batches/{batch_id}.json" + hf_url = bucket_url(f"_batches/{batch_id}.json") async with httpx.AsyncClient(follow_redirects=True) as client: - r = await client.get(hf_url) + r = await client.get(hf_url, headers=auth_headers()) if r.status_code != 200: return None try: diff --git a/app/templates/_navbar.html b/app/templates/_navbar.html @@ -73,6 +73,7 @@ <a href="/new" class="{{ 'active' if page == 'upload' }}"{{ ' aria-current="page"'|safe if page == 'upload' }}><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg><span>Upload</span></a> <a href="/shorten" class="{{ 'active' if page == 'shorten' }}"{{ ' aria-current="page"'|safe if page == 'shorten' }}><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg><span>Shorten</span></a> <a href="/history" class="{{ 'active' if page == 'history' }}"{{ ' aria-current="page"'|safe if page == 'history' }}><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg><span>History</span></a> + <a href="/stats" class="{{ 'active' if page == 'stats' }}"{{ ' aria-current="page"'|safe if page == 'stats' }}><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="20" x2="6" y2="12"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="18" y1="20" x2="18" y2="9"/></svg><span>Stats</span></a> <a href="/gh-sync" class="{{ 'active' if page == 'gh-sync' }}"{{ ' aria-current="page"'|safe if page == 'gh-sync' }}><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg><span>Sync</span></a> <a href="/documentation" class="{{ 'active' if page == 'documentation' }}"{{ ' aria-current="page"'|safe if page == 'documentation' }}><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg><span>Docs</span></a> </nav> @@ -96,6 +97,8 @@ {% set ns.items = ns.items + [{'name': 'find', 'href': '/find'}] %} {% elif page == 'history' %} {% set ns.items = ns.items + [{'name': 'history', 'href': '/history'}] %} + {% elif page == 'stats' %} + {% set ns.items = ns.items + [{'name': 'stats', 'href': '/stats'}] %} {% elif page == 'documentation' %} {% set ns.items = ns.items + [{'name': 'documentation', 'href': '/documentation'}] %} {% elif page == 'gh-sync' %} diff --git a/app/templates/documentation.html b/app/templates/documentation.html @@ -179,7 +179,7 @@ <h3>How it works</h3> <p>Each file is streamed to a local temp path, checked against the rate limiter (50&nbsp;MB/minute, 300&nbsp;MB/hour per client IP), then uploaded to Hugging Face at <code>&lt;folder&gt;/&lt;8-char-uuid&gt;-&lt;original-filename&gt;</code> - (spaces in the filename become underscores). The random prefix means two uploads can never collide, and the + (any characters other than letters, digits, <code>.</code>, <code>_</code> and <code>-</code> become underscores, and accents are simplified). The random prefix means two uploads can never collide, and the filename portion is still readable. If 2 or more files were uploaded together, a <em>batch</em> is also created (see <a href="#batches">Batches</a> below) so the whole set can be shared/downloaded as one link.</p> <h3>Response <code>200</code></h3> diff --git a/app/templates/gh_sync_docs.html b/app/templates/gh_sync_docs.html @@ -190,7 +190,10 @@ jobs: <h2>Notes</h2> <ul> - <li>Any file type is accepted, and there's currently no limit on file size or on how many files a sync can push — send the whole repo as-is.</li> + <li>Any file type except video is accepted (a sync containing a video is rejected as a whole), and there's currently no limit on file size or on how many files a sync can push — send the whole repo as-is.</li> + <li><strong>First sync is free, later ones need approval.</strong> The first sync from a new GitHub account + goes through immediately. After that, further syncs from that account are refused until the CDN owner + approves it. Accounts that upload abusive content are banned.</li> <li>Each sync fully replaces the contents of <em>your own</em> <code>username/repo</code> folder only (delete-then-upload), so removed files disappear from the CDN too. No other folder is ever touched.</li> <li>Change <code>branches: [ main ]</code> to whichever branch you want mirrored.</li> diff --git a/app/templates/index.html b/app/templates/index.html @@ -1134,14 +1134,10 @@ const allFiles = Array.from(fileList); const files = []; const rejectedSize = []; - const rejectedName = []; - const allowedNameRegex = /^[a-zA-Z0-9_.\-]+$/; allFiles.forEach(f => { if (f.size > MAX_FILE_SIZE_BYTES) { rejectedSize.push(f.name); - } else if (!allowedNameRegex.test(f.name)) { - rejectedName.push(f.name); } else { files.push(f); } @@ -1151,9 +1147,6 @@ if (rejectedSize.length) { errorMessages.push(`Skipped ${rejectedSize.length} file(s) over ${formatBytes(MAX_FILE_SIZE_BYTES)}: ${rejectedSize.join(', ')}`); } - if (rejectedName.length) { - errorMessages.push(`Skipped ${rejectedName.length} file(s) with invalid characters (only a-z, A-Z, 0-9, _, -, . allowed): ${rejectedName.join(', ')}`); - } if (errorMessages.length) { showError(errorMessages.join(' | ')); diff --git a/app/templates/stats.html b/app/templates/stats.html @@ -0,0 +1,152 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0"> +<title>Stats · amit/cdn</title> +<meta name="description" content="What's on the CDN: file types, sizes, folders and growth."> +<link rel="icon" href="/favicon.ico" sizes="any"> +<link rel="icon" type="image/svg+xml" href="/static/icons/logo.svg"> +<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-touch-icon.png"> +<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)"> +<meta name="theme-color" content="#0d1117" media="(prefers-color-scheme: dark)"> +<meta name="color-scheme" content="light dark"> +<style> + *, *::before, *::after { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } + body { background: var(--ui-bg); color: var(--ui-text); margin: 0; } + .wrap { max-width: var(--ui-width); margin: 0 auto; padding: 28px 16px 72px; } + h1 { margin: 0 0 4px; } + h2 { font-size: 1rem; margin: 0; font-weight: 600; } + .muted { color: var(--ui-muted); font-size: .88rem; } + .tiles { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin: 20px 0 14px; } + .tile, .card { background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: 10px; } + .tile { padding: 12px 14px; } + .tile b { display: block; font-size: 1.45rem; line-height: 1.2; font-variant-numeric: tabular-nums; } + .tile span { color: var(--ui-muted); font-size: .8rem; } + .grid { display: grid; grid-template-columns: 1fr; gap: 14px; } + .card { padding: 14px 16px 16px; min-width: 0; } + .card > header { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; } + .seg { display: inline-flex; border: 1px solid var(--ui-border); border-radius: 8px; overflow: hidden; } + .seg button { font: inherit; font-size: .8rem; padding: 6px 12px; min-height: 32px; border: 0; background: transparent; color: var(--ui-muted); cursor: pointer; } + .seg button[aria-pressed="true"] { background: var(--ui-accent-solid); color: #fff; } + + .donut-wrap { display: flex; flex-wrap: wrap; align-items: center; gap: 16px 22px; } + .donut { width: 150px; height: 150px; flex: none; margin: 0 auto; position: relative; } + .donut svg { width: 100%; height: 100%; transform: rotate(-90deg); } + .donut circle { fill: none; stroke-width: 7; } + .donut .hole { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; line-height: 1.2; } + .donut .hole b { font-size: 1.15rem; font-variant-numeric: tabular-nums; } + .legend { list-style: none; margin: 0; padding: 0; flex: 1 1 200px; display: grid; gap: 7px; font-size: .88rem; } + .legend li { display: flex; align-items: center; gap: 8px; } + .legend i { width: 11px; height: 11px; border-radius: 3px; flex: none; } + .legend .n { margin-left: auto; color: var(--ui-muted); font-variant-numeric: tabular-nums; white-space: nowrap; } + + .bars { display: grid; gap: 11px; } + .bar .top { display: flex; justify-content: space-between; gap: 10px; font-size: .86rem; } + .bar .top span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } + .bar .top span:last-child { color: var(--ui-muted); white-space: nowrap; font-variant-numeric: tabular-nums; } + .track { height: 8px; border-radius: 4px; background: var(--ui-border); margin-top: 4px; overflow: hidden; } + .track i { display: block; height: 100%; border-radius: 4px; background: var(--ui-accent); min-width: 2px; } + + .cols { display: flex; align-items: flex-end; gap: 6px; height: 170px; padding-top: 18px; } + .col { flex: 1; min-width: 0; height: 100%; display: flex; flex-direction: column; justify-content: flex-end; align-items: center; } + .col .v { font-size: .68rem; color: var(--ui-muted); font-variant-numeric: tabular-nums; } + .col i { width: 100%; max-width: 44px; border-radius: 4px 4px 0 0; background: var(--ui-accent); min-height: 2px; } + .labels { display: flex; gap: 6px; margin-top: 6px; } + .labels span { flex: 1; min-width: 0; text-align: center; font-size: .66rem; color: var(--ui-muted); line-height: 1.25; overflow-wrap: anywhere; } + + .files { list-style: none; margin: 0; padding: 0; } + .files li { display: flex; gap: 12px; justify-content: space-between; padding: 9px 0; border-top: 1px solid var(--ui-border); font-size: .88rem; } + .files li:first-child { border-top: 0; padding-top: 0; } + .files a { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; color: var(--ui-accent); text-decoration: none; } + .files span { color: var(--ui-muted); white-space: nowrap; font-variant-numeric: tabular-nums; } + .note { padding: 40px 0; text-align: center; color: var(--ui-muted); } + .note button { font: inherit; margin-top: 10px; padding: 8px 16px; border-radius: 8px; border: 1px solid var(--ui-border); background: var(--ui-surface); color: var(--ui-text); cursor: pointer; } + footer { max-width: var(--ui-width); margin: 0 auto; padding: 0 16px 32px; text-align: center; } + + @media (min-width: 760px) { + .wrap { padding: 44px 20px 96px; } + .tiles { grid-template-columns: repeat(4, 1fr); } + .grid { grid-template-columns: 1fr 1fr; } + .span2 { grid-column: 1 / -1; } + .col .v { font-size: .75rem; } .labels span { font-size: .74rem; } + } + :root { --c0: #4c8dff; --c1: #3fb950; --c2: #d29922; --c3: #f85149; --c4: #a371f7; --c5: #39c5cf; --c6: #db61a2; --c7: #8b949e; } +</style> +</head> +<body> +{% include "_navbar.html" %} +<main class="wrap"> + <h1>CDN stats</h1> + <p class="muted" id="sub">Loading…</p> + <div id="out"></div> +</main> +<footer><p class="muted">&copy; 2025 - 2026 notamitgamer. All rights reserved.</p></footer> +<script> +(function () { + var C = ['--c0','--c1','--c2','--c3','--c4','--c5','--c6'], OTHER = 'var(--c7)'; + var $ = function (id) { return document.getElementById(id); }; + var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]; }); }; + var nf = new Intl.NumberFormat(); + function B(n) { var u = ['B','KB','MB','GB','TB'], i = 0; while (n >= 1024 && i < 4) { n /= 1024; i++; } return (i ? n.toFixed(n < 10 ? 1 : 0) : n) + ' ' + u[i]; } + function pct(a, b) { return b ? Math.round(a / b * 100) : 0; } + function color(i, key) { return key === 'other' ? OTHER : 'var(' + C[i % C.length] + ')'; } + + function donut(cats, mode, t) { + var total = mode === 'size' ? t.bytes : t.files, acc = 0, segs = ''; + cats.forEach(function (c, i) { + var v = mode === 'size' ? c.bytes : c.files, p = total ? v / total * 100 : 0; + if (p > 0) segs += '<circle cx="21" cy="21" r="15.9155" stroke="' + color(i, c.key) + '" stroke-dasharray="' + p.toFixed(3) + ' ' + (100 - p).toFixed(3) + '" stroke-dashoffset="' + (-acc).toFixed(3) + '"/>'; + acc += p; + }); + var legend = cats.map(function (c, i) { + var v = mode === 'size' ? c.bytes : c.files; + return '<li><i style="background:' + color(i, c.key) + '"></i>' + esc(c.label) + '<span class="n">' + (mode === 'size' ? B(v) : nf.format(v)) + ' · ' + pct(v, total) + '%</span></li>'; + }).join(''); + return '<div class="donut-wrap"><div class="donut" role="img" aria-label="File types by ' + (mode === 'size' ? 'size' : 'count') + '"><svg viewBox="0 0 42 42">' + + '<circle cx="21" cy="21" r="15.9155" stroke="var(--ui-border)"/>' + segs + '</svg><div class="hole"><b>' + (mode === 'size' ? B(total) : nf.format(total)) + + '</b><span class="muted">' + (mode === 'size' ? 'total' : 'files') + '</span></div></div><ul class="legend">' + legend + '</ul></div>'; + } + function bars(rows, label, val, num, sub) { + var max = Math.max.apply(null, rows.map(num).concat(1)); + return '<div class="bars">' + rows.map(function (r) { + return '<div class="bar"><div class="top"><span>' + esc(label(r)) + '</span><span>' + val(r) + '</span></div><div class="track"><i style="width:' + Math.max(1, num(r) / max * 100).toFixed(1) + '%"></i></div></div>'; + }).join('') + '</div>'; + } + function cols(rows, label, num, fmt, tip) { + var max = Math.max.apply(null, rows.map(num).concat(1)); + return '<div class="cols">' + rows.map(function (r) { + return '<div class="col" title="' + esc(tip(r)) + '"><span class="v">' + fmt(num(r)) + '</span><i style="height:' + Math.max(1, num(r) / max * 100).toFixed(1) + '%"></i></div>'; + }).join('') + '</div><div class="labels">' + rows.map(function (r) { return '<span>' + esc(label(r)) + '</span>'; }).join('') + '</div>'; + } + function short(n) { return n >= 10000 ? (n / 1000).toFixed(0) + 'k' : n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n); } + function mon(m) { var p = m.split('-'); return new Date(Date.UTC(+p[0], +p[1] - 1, 1)).toLocaleString(undefined, { month: 'short', timeZone: 'UTC' }) + (p[1] === '01' ? ' ’' + p[0].slice(2) : ''); } + + function render(d) { + if (d.empty) { $('sub').textContent = ''; $('out').innerHTML = '<div class="note">No stats yet - the file list could not be read, or the CDN is empty.<br><button onclick="location.reload()">Try again</button></div>'; return; } + var t = d.totals, mode = 'files'; + $('sub').textContent = 'Updated ' + new Date(d.generated * 1000).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) + ' · refreshed every few minutes'; + var h = '<div class="tiles">' + + [[nf.format(t.files), 'files'], [B(t.bytes), 'total size'], [B(t.median), 'median file'], [nf.format(t.types), 'file types']].map(function (x) { return '<div class="tile"><b>' + x[0] + '</b><span>' + x[1] + '</span></div>'; }).join('') + + '</div><div class="grid">' + + '<section class="card span2"><header><h2>What\'s on the CDN</h2><div class="seg" id="seg"><button aria-pressed="true" data-m="files">Files</button><button aria-pressed="false" data-m="size">Size</button></div></header><div id="donut"></div></section>' + + '<section class="card"><header><h2>Top file types</h2></header>' + bars(d.extensions, function (r) { return r.ext === '(none)' ? '(no extension)' : '.' + r.ext; }, function (r) { return nf.format(r.files) + ' · ' + B(r.bytes); }, function (r) { return r.files; }) + + (d.extensions_more ? '<p class="muted" style="margin:12px 0 0">+ ' + nf.format(d.extensions_more) + ' more files in rarer types</p>' : '') + '</section>' + + '<section class="card"><header><h2>File sizes</h2></header>' + cols(d.sizes, function (r) { return r.label; }, function (r) { return r.files; }, short, function (r) { return r.label + ': ' + nf.format(r.files) + ' files'; }) + '</section>' + + '<section class="card"><header><h2>Biggest folders</h2></header>' + bars(d.folders, function (r) { return r.name; }, function (r) { return B(r.bytes) + ' · ' + nf.format(r.files) + ' files'; }, function (r) { return r.bytes; }) + '</section>' + + (d.months.length > 1 ? '<section class="card"><header><h2>Files added per month</h2></header>' + cols(d.months, function (r) { return mon(r.m); }, function (r) { return r.files; }, short, function (r) { return r.m + ': ' + nf.format(r.files) + ' files, ' + B(r.bytes); }) + '</section>' : '') + + '<section class="card span2"><header><h2>Largest files</h2></header><ul class="files">' + d.largest.map(function (f) { return '<li><a href="/' + encodeURI(f.path) + '" title="' + esc(f.path) + '">' + esc(f.name) + '</a><span>' + B(f.size) + '</span></li>'; }).join('') + '</ul></section></div>'; + $('out').innerHTML = h; + function draw() { $('donut').innerHTML = donut(d.categories, mode, t); } + draw(); + $('seg').addEventListener('click', function (e) { + var b = e.target.closest('button'); if (!b) return; mode = b.dataset.m; + [].forEach.call($('seg').children, function (x) { x.setAttribute('aria-pressed', x === b); }); draw(); + }); + } + fetch('/api/stats').then(function (r) { if (!r.ok) throw 0; return r.json(); }).then(render) + .catch(function () { $('sub').textContent = ''; $('out').innerHTML = '<div class="note">Could not load the stats.<br><button onclick="location.reload()">Retry</button></div>'; }); +})(); +</script> +</body> +</html> diff --git a/app/upload_guard.py b/app/upload_guard.py @@ -1,30 +1,21 @@ """Upload safety checks. -Every upload endpoint calls inspect_upload() before the file is stored and -record_upload() after it succeeds. Keeping the logic in this module means the -rules can grow without touching the upload handlers in main.py. +Every upload endpoint calls inspect_upload() before the file is stored. +Keeping the logic in this module means the rules can grow without touching +the upload handlers in main.py. """ from __future__ import annotations -import asyncio -import hashlib import httpx -import hmac import ipaddress -import json import os -import tempfile -import threading import time from dataclasses import dataclass -from cryptography.fernet import Fernet -from fastapi import APIRouter, HTTPException, Request -from fastapi.responses import HTMLResponse, JSONResponse +from fastapi import HTTPException, Request -from .storage import _do_upload, HF_REPO_ID, HF_TOKEN +from .admin import is_banned, record_blocked -router = APIRouter() # Video containers that are refused outright, by extension. BLOCKED_VIDEO_EXTENSIONS = { @@ -40,21 +31,6 @@ _NON_VIDEO_FTYP_BRANDS = { b"jp2 ", b"jpx ", b"jpm ", } -LOG_PATH = os.getenv("UPLOAD_LOG_PATH", "/tmp/upload_log.jsonl") -LOG_SALT = os.getenv("UPLOAD_LOG_SALT", "change-me") -ADMIN_TOKEN = os.getenv("ADMIN_TOKEN", "") -_log_lock = threading.Lock() -# Persistence to the Hugging Face dataset. The dataset is publicly readable, so -# IPs are Fernet-encrypted before upload; without LOG_ENC_KEY nothing is uploaded. -# Generate a key with: python -c "from cryptography.fernet import Fernet;print(Fernet.generate_key().decode())" -LOG_ENC_KEY = os.getenv("LOG_ENC_KEY", "") -REMOTE_LOG_PATH = "_logs/uploads.jsonl" -SYNC_INTERVAL = int(os.getenv("LOG_SYNC_SECONDS", "60")) -MAX_LOG_ROWS = 50000 -_dirty = False -_ready = False -_ready_lock = asyncio.Lock() -_sync_task = None VPN_BLOCK = os.getenv("VPN_BLOCK", "1") == "1" VPN_FAIL_OPEN = os.getenv("VPN_FAIL_OPEN", "1") == "1" # allow upload if lookup fails PROXYCHECK_KEY = os.getenv("PROXYCHECK_KEY", "") @@ -74,6 +50,32 @@ def _valid_ip(value: str | None) -> str | None: return None +# Cloudflare's published edge ranges (https://www.cloudflare.com/ips/). A request that +# really arrived through Cloudflare carries the visitor's address in CF-Connecting-IP. +_CLOUDFLARE_NETS = [ipaddress.ip_network(n) for n in ( + "103.21.244.0/22", "103.22.200.0/22", "103.31.4.0/22", "104.16.0.0/13", "104.24.0.0/14", + "108.162.192.0/18", "131.0.72.0/22", "141.101.64.0/18", "162.158.0.0/15", "172.64.0.0/13", + "173.245.48.0/20", "188.114.96.0/20", "190.93.240.0/20", "197.234.240.0/22", "198.41.128.0/17", + "2400:cb00::/32", "2606:4700::/32", "2803:f800::/32", "2405:b500::/32", "2405:8100::/32", + "2a06:98c0::/29", "2c0f:f248::/32", +)] + + +def is_cloudflare_ip(value: str) -> bool: + try: + ip = ipaddress.ip_address(value) + except ValueError: + return False + return any(ip.version == n.version and ip in n for n in _CLOUDFLARE_NETS) + + +def is_public_ip(value: str) -> bool: + try: + return ipaddress.ip_address(value).is_global + except ValueError: + return False + + def get_client_ip(request: Request) -> str: """Best-effort real client IP behind Cloudflare / Render's proxy. @@ -89,6 +91,19 @@ def get_client_ip(request: Request) -> str: xff = request.headers.get("x-forwarded-for") if xff: parts = [p.strip() for p in xff.split(",") if p.strip()] + # Hosts like Render append their own internal (10.x etc.) proxy addresses to + # the right-hand end. Those are never the visitor, so drop them before counting hops. + public_parts = list(parts) + while public_parts and not is_public_ip(public_parts[-1]): + public_parts.pop() + parts = public_parts or parts + # The last remaining entry is the address that actually connected to the platform + # (appended by it, so the visitor can't forge it). If that is a Cloudflare edge, the + # request really came through Cloudflare and CF-Connecting-IP is genuine. + if parts and is_cloudflare_ip(parts[-1]): + ip = _valid_ip(request.headers.get("cf-connecting-ip")) + if ip: + return ip if parts: idx = len(parts) - TRUSTED_PROXY_HOPS ip = _valid_ip(parts[idx] if idx >= 0 else parts[0]) @@ -148,7 +163,6 @@ async def inspect_upload( method: str = "file", ) -> UploadContext: """Run all upload checks. Raises HTTPException when the upload is refused.""" - await _ensure_ready() ctx = UploadContext( ip=get_client_ip(request), filename=filename or "", @@ -158,14 +172,18 @@ async def inspect_upload( user_agent=request.headers.get("user-agent", "")[:300], ) + if await is_banned(ctx.ip): + await record_blocked(ctx, "banned") + raise HTTPException(status_code=403, detail="You are banned from uploading to this CDN.") + if VPN_BLOCK and ctx.ip not in VPN_ALLOWLIST: v = await is_vpn(ctx.ip) if v or (v is None and not VPN_FAIL_OPEN): - _log(ctx, None, "blocked_vpn") + await record_blocked(ctx, "blocked_vpn") raise HTTPException(status_code=403, detail="Uploads from VPNs/proxies are not allowed.") if is_video_upload(ctx.filename, temp_path): - _log(ctx, None, "blocked_video") + await record_blocked(ctx, "blocked_video") raise HTTPException( status_code=415, detail="Video uploads (including .mp4) are not allowed on this CDN.", @@ -197,234 +215,3 @@ async def is_vpn(ip: str) -> bool | None: _vpn_cache.clear() _vpn_cache[ip] = (time.time() + (_VPN_TTL if verdict is not None else 300), verdict) return verdict - - -def _fernet(): - if not LOG_ENC_KEY: - return None - try: - return Fernet(LOG_ENC_KEY.encode()) - except ValueError: - return None - - -async def _fetch_remote() -> str | None: - url = f"https://huggingface.co/datasets/{HF_REPO_ID}/resolve/main/{REMOTE_LOG_PATH}" - headers = {"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {} - try: - async with httpx.AsyncClient(follow_redirects=True, timeout=20.0) as c: - r = await c.get(url, headers=headers) - return r.text if r.status_code == 200 else None - except httpx.HTTPError: - return None - - -async def _restore() -> None: - """After a restart the local file is gone: rebuild it from the HF copy.""" - f = _fernet() - if not f or os.path.exists(LOG_PATH): - return - text = await _fetch_remote() - if not text: - return - out = [] - for line in text.splitlines(): - try: - row = json.loads(line) - if row.get("ip"): - row["ip"] = f.decrypt(row["ip"].encode()).decode() - out.append(json.dumps(row)) - except Exception: - continue - if out: - with _log_lock, open(LOG_PATH, "a") as fh: - fh.write("\n".join(out) + "\n") - - -async def _push() -> None: - global _dirty - f = _fernet() - if not f: - return - _dirty = False - tmp = None - try: - with _log_lock, open(LOG_PATH) as fh: - lines = fh.readlines()[-MAX_LOG_ROWS:] - out = [] - for l in lines: - if not l.strip(): - continue - row = json.loads(l) - if row.get("ip"): - row["ip"] = f.encrypt(row["ip"].encode()).decode() - out.append(json.dumps(row)) - fd, tmp = tempfile.mkstemp(suffix=".jsonl") - with os.fdopen(fd, "w") as fh: - fh.write("\n".join(out) + "\n") - await asyncio.to_thread(_do_upload, tmp, REMOTE_LOG_PATH) - except Exception: - _dirty = True # retry on the next tick - finally: - if tmp and os.path.exists(tmp): - os.remove(tmp) - - -async def _sync_loop() -> None: - while True: - await asyncio.sleep(SYNC_INTERVAL) - if _dirty: - await _push() - - -async def _ensure_ready() -> None: - global _ready, _sync_task - if _ready: - return - async with _ready_lock: - if _ready: - return - await _restore() - if _fernet(): - _sync_task = asyncio.create_task(_sync_loop()) - _ready = True - - -def _anon(ip: str) -> str: - return hmac.new(LOG_SALT.encode(), ip.encode(), hashlib.sha256).hexdigest()[:10] - - -def _log(ctx: UploadContext, hf_path: str | None, status: str) -> None: - global _dirty - row = { - "time": int(time.time()), - "status": status, - "filename": ctx.filename, - "path": hf_path, - "size": ctx.size, - "folder": ctx.folder, - "method": ctx.method, - "uploader": _anon(ctx.ip), - "ip": ctx.ip, - "user_agent": ctx.user_agent, - } - try: - with _log_lock, open(LOG_PATH, "a") as f: - f.write(json.dumps(row) + "\n") - _dirty = True - except OSError: - pass - - -async def record_upload(ctx: UploadContext, hf_path: str) -> None: - await _ensure_ready() - _log(ctx, hf_path, "ok") - - -def _read_log(limit: int) -> list[dict]: - try: - with open(LOG_PATH) as f: - lines = f.readlines()[-limit:] - except OSError: - return [] - return [json.loads(l) for l in reversed(lines) if l.strip()] - - -@router.get("/api/uploads") -async def public_upload_log(limit: int = 100): - """Public log. The uploader is an anonymous ID, never the raw IP.""" - await _ensure_ready() - rows = _read_log(max(1, min(limit, 500))) - public = ("time", "status", "filename", "path", "size", "folder", "method", "uploader") - return JSONResponse({"uploads": [{k: r.get(k) for k in public} for r in rows]}) - - -@router.get("/api/admin/uploads") -async def admin_upload_log(request: Request, limit: int = 200): - """Full log with raw IPs. Needs: Authorization: Bearer $ADMIN_TOKEN.""" - auth = request.headers.get("authorization", "") - if not ADMIN_TOKEN or not hmac.compare_digest(auth, f"Bearer {ADMIN_TOKEN}"): - raise HTTPException(status_code=401, detail="Unauthorized") - await _ensure_ready() - return JSONResponse({"uploads": _read_log(max(1, min(limit, 2000)))}) - - -@router.get("/api/admin/ip") -async def admin_ip_debug(request: Request): - """Shows which IP the server detects for YOU and the raw headers it used.""" - auth = request.headers.get("authorization", "") - if not ADMIN_TOKEN or not hmac.compare_digest(auth, f"Bearer {ADMIN_TOKEN}"): - raise HTTPException(status_code=401, detail="Unauthorized") - h = request.headers - return { - "detected_ip": get_client_ip(request), - "cf_connecting_ip": h.get("cf-connecting-ip"), - "x_forwarded_for": h.get("x-forwarded-for"), - "socket_peer": request.client.host if request.client else None, - "trust_cf_header": TRUST_CF_CONNECTING_IP, - "trusted_proxy_hops": TRUSTED_PROXY_HOPS, - } - -_ADMIN_HTML = """<!doctype html><html lang="en"><head><meta charset="utf-8"> -<meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>Upload log</title> -<style> -*{box-sizing:border-box} -body{font:14px/1.45 system-ui,sans-serif;margin:0;padding:16px;background:#0d1117;color:#e6edf3;max-width:1200px;margin-inline:auto} -h1{font-size:18px;margin:0 0 12px} -form{display:flex;gap:8px;flex-wrap:wrap} -input,select,button{font:inherit;padding:10px 12px;background:#161b22;color:inherit;border:1px solid #30363d;border-radius:8px;min-height:42px} -input{flex:1 1 220px}select{flex:1 1 110px}button{flex:0 0 auto;background:#238636;border-color:#238636;font-weight:600} -#msg{color:#f85149;margin:8px 0 0;min-height:1.4em}#count{color:#8b949e;margin:4px 0 8px;font-size:13px} -a{color:#58a6ff} -table{border-collapse:collapse;width:100%} -th,td{border-bottom:1px solid #30363d;padding:8px;text-align:left;vertical-align:top;overflow-wrap:anywhere} -th{color:#8b949e;font-weight:600;font-size:12px;text-transform:uppercase} -.st{justify-self:start;display:inline-block;padding:1px 8px;border-radius:99px;font-size:12px;font-weight:600;background:#23863633;color:#3fb950} -.st.blocked_video,.st.blocked_vpn{background:#f8514933;color:#f85149} -@media(max-width:700px){ - thead{display:none} - table,tbody,tr,td{display:block;width:100%} - tr{background:#161b22;border:1px solid #30363d;border-radius:10px;margin:0 0 10px;padding:6px 12px} - td{border:0;padding:4px 0;display:grid;grid-template-columns:78px 1fr;gap:8px} - td::before{content:attr(data-l);color:#8b949e;font-size:12px;padding-top:2px} - td.ua{font-size:12px;color:#8b949e} -} -</style></head><body> -<h1>Upload log</h1> -<form id="f"><input id="t" type="password" placeholder="Admin token" autocomplete="off"> -<select id="s"><option value="">All</option><option value="ok">OK</option><option value="blocked_video">Blocked video</option><option value="blocked_vpn">Blocked VPN</option></select> -<button>Load</button></form> -<div id="msg"></div><div id="count"></div> -<table><thead><tr><th>Time</th><th>Status</th><th>File</th><th>Size</th><th>IP</th><th>Uploader</th><th>Via</th><th>User agent</th></tr></thead><tbody id="b"></tbody></table> -<script> -const $=id=>document.getElementById(id); -const H=["Time","Status","File","Size","IP","Uploader","Via","Agent"]; -$("t").value=sessionStorage.getItem("tk")||""; -async function load(){ - $("msg").textContent=""; - const r=await fetch("/api/admin/uploads?limit=500",{headers:{Authorization:"Bearer "+$("t").value}}); - if(!r.ok){$("msg").textContent=r.status==401?"Wrong token":"Error "+r.status;return} - sessionStorage.setItem("tk",$("t").value); - const f=$("s").value,rows=(await r.json()).uploads.filter(x=>!f||x.status==f),b=$("b");b.textContent=""; - $("count").textContent=rows.length+(rows.length==1?" entry":" entries")+", newest first"; - for(const x of rows){ - const tr=b.insertRow(); - const v=[new Date(x.time*1000).toLocaleString(),x.status,x.path||x.filename,(x.size/1048576).toFixed(2)+" MB",x.ip,x.uploader,x.method,x.user_agent]; - v.forEach((t,i)=>{ - const td=tr.insertCell();td.dataset.l=H[i]; - if(i==1){const s=document.createElement("span");s.className="st "+x.status;s.textContent=x.status.replace("_"," ");td.append(s)} - else if(i==2&&x.path){const a=document.createElement("a");a.href="/"+x.path;a.textContent=t;td.append(a)} - else td.textContent=t; - if(i==7)td.className="ua"; - }); - } -} -$("f").onsubmit=e=>{e.preventDefault();load()}; -if($("t").value)load(); -</script></body></html>""" - - -@router.get("/admin", response_class=HTMLResponse) -async def admin_page(): - """Admin UI. The page itself holds no data; it asks for the token and calls the API.""" - return HTMLResponse(_ADMIN_HTML, headers={"Cache-Control": "no-store", "X-Robots-Tag": "noindex"}) diff --git a/render.yaml b/render.yaml @@ -4,7 +4,7 @@ services: env: docker plan: free envVars: - - key: HF_REPO_ID + - key: HF_BUCKET_ID value: notamitgamer/cdn - key: HF_TOKEN @@ -14,4 +14,13 @@ services: value: raw.cdn.amit.is-a.dev - key: RAW_BASE_URL - value: https://raw.cdn.amit.is-a.dev -\ No newline at end of file + value: https://raw.cdn.amit.is-a.dev + + # Admin dashboard at /admin. Leave unset to switch the dashboard, bans and + # the GitHub allowlist off. ADMIN_DATA_KEY encrypts the saved state in the + # bucket; set it separately so rotating ADMIN_TOKEN doesn't lock you out of it. + - key: ADMIN_TOKEN + sync: false + + - key: ADMIN_DATA_KEY + sync: false diff --git a/requirements.txt b/requirements.txt @@ -1,8 +1,8 @@ fastapi uvicorn -huggingface_hub +huggingface_hub>=1.5 httpx python-multipart jinja2 pyjwt[crypto] -yt-dlp -\ No newline at end of file +yt-dlp