cdnV2

git clone https://github.com/notamitgamer/cdnV2.git
· rebuilt hourlyOpen on GitHub
commit 90ac61ced0dcd96c1ae9c993d551526126575239
parent 17a2395097ea0b903fb6e399831ea7ebb9ea4d50
Author: claude-bot <noreply@anthropic.com>
Date:   Tue,  6 Oct 2026 02:22:39 +0000

Add admin dashboard: upload log, one-click IP ban, GitHub sync allowlist

- /admin dashboard (token login): who uploaded what, Ban IP / Unban, Delete file
- State saved as one Fernet-encrypted blob in the bucket (_admin/state.enc), survives restarts;
  refuses to overwrite saved data if it can't read or decrypt it
- GitHub sync: a new account's first sync is accepted, later syncs need approval from the
  dashboard (Allow / Revoke / Ban). Keyed by GitHub's numeric owner id. GH_ALWAYS_ALLOW (default notamitgamer)
- Banned IPs are refused on upload, URL upload, shortener and sync
- Syncs containing video files are rejected
- Whole feature is off unless ADMIN_TOKEN is set

Diffstat:
Aapp/admin.py | 378+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/gh_oidc.py | 1+
Mapp/main.py | 32++++++++++++++++++++++++++++----
Mapp/storage.py | 13++++++++++++-
Mapp/templates/gh_sync_docs.html | 5++++-
Mapp/upload_guard.py | 8++++++++
Mrender.yaml | 9+++++++++
7 files changed, 440 insertions(+), 6 deletions(-)

diff --git a/app/admin.py b/app/admin.py @@ -0,0 +1,378 @@ +"""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 + +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"}) + + +@router.get("/api/admin/state") +async def admin_state(request: Request): + _require_admin(request) + await ensure_loaded() + return { + "now": int(time.time()), "error": _load_error, + "log": _state["log"][-300:][::-1], "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.") + _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> +*{box-sizing:border-box}body{font:14px/1.45 system-ui,sans-serif;margin:0;padding:16px;background:#0d1117;color:#e6edf3;max-width:1300px;margin-inline:auto} +h1{font-size:18px;margin:0 0 12px}h2{font-size:15px;margin:22px 0 8px;color:#9da7b3} +table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:left;padding:6px 8px;border-bottom:1px solid #21262d;vertical-align:top} +th{color:#9da7b3;font-weight:600}.wrap{overflow-x:auto} +button{font:inherit;font-size:12px;padding:3px 9px;border-radius:6px;border:1px solid #30363d;background:#161b22;color:#e6edf3;cursor:pointer;margin-right:4px} +button.red{border-color:#da3633;color:#ff7b72}button.green{border-color:#238636;color:#56d364}button:hover{background:#21262d} +input{font:inherit;padding:6px 10px;border-radius:6px;border:1px solid #30363d;background:#0d1117;color:#e6edf3;width:min(360px,100%)} +.mono{font-family:ui-monospace,monospace;font-size:12px}.dim{color:#9da7b3}.bad{color:#ff7b72}.ok{color:#56d364}.warn{color:#d29922} +.banner{background:#3d1d1d;border:1px solid #da3633;padding:8px 12px;border-radius:6px;margin-bottom:12px} +a{color:#58a6ff;text-decoration:none} +</style></head><body> +<h1>CDN admin</h1> +<div id="login"><input id="tok" type="password" placeholder="Admin token" autocomplete="off"> <button onclick="go()">Open</button> <span id="lerr" class="bad"></span></div> +<div id="app" style="display:none"></div> +<script> +const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); +const fmt=t=>new Date(t*1000).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; +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',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'); + document.getElementById('login').style.display='none';document.getElementById('app').style.display='block'; + 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='block';document.getElementById('app').style.display='none'; + } +} +const GH='/api/admin/gh'; +function render(s){ + const banned=s.bans,pending=s.gh.filter(([k,g])=>g.status==='pending'&&g.syncs>=1); + let h=''; + if(s.error)h+=`<div class="banner">${esc(s.error)}</div>`; + if(pending.length){ + h+='<h2>Waiting for approval</h2><div class="wrap"><table><tr><th>GitHub account</th><th>Repos</th><th>Syncs</th><th>Last sync</th><th></th></tr>'; + for(const [k,g] of pending)h+=`<tr><td><a href="https://github.com/${encodeURIComponent(g.owner)}" target="_blank" rel="noopener">${esc(g.owner)}</a></td><td>${esc((g.repos||[]).join(', '))}</td><td>${g.syncs}</td><td>${fmt(g.last)}</td> + <td>${btn('Allow','green',GH,{key:k,action:'allow'})}${btn('Ban','red',GH,{key:k,action:'ban'},'Ban this GitHub account?')}</td></tr>`; + h+='</table></div>'; + } + h+='<h2>GitHub accounts</h2><div class="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>'; + for(const [k,g] of s.gh){const c=g.status==='allowed'?'ok':g.status==='banned'?'bad':'warn'; + h+=`<tr><td>${esc(g.owner)}</td><td class="${c}">${esc(g.status)}</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>${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)h+='<tr><td colspan="7" class="dim">No GitHub syncs yet.</td></tr>'; + h+=`</table></div><p class="dim">Always allowed: ${esc(s.always_allow.join(', ')||'none')}</p>`; + h+='<h2>Banned IPs</h2><div class="wrap"><table><tr><th>IP</th><th>Since</th><th>Note</th><th></th></tr>'; + for(const ip in banned)h+=`<tr><td class="mono">${esc(ip)}</td><td>${fmt(banned[ip].t)}</td><td>${esc(banned[ip].note)}</td><td>${btn('Unban','','/api/admin/unban',{ip})}</td></tr>`; + if(!Object.keys(banned).length)h+='<tr><td colspan="4" class="dim">None.</td></tr>'; + h+='</table></div><h2>Recent uploads</h2><div class="wrap"><table><tr><th>Time</th><th>IP</th><th>File</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'; + h+=`<tr><td>${fmt(r.t)}</td><td class="mono" title="${esc(r.ua)}">${esc(r.ip)}</td> + <td>${live?`<a href="/${encodeURI(r.path)}" target="_blank" rel="noopener">${esc(r.name)}</a>`:esc(r.name)}${r.extra?`<div class="dim">${esc(r.extra)}</div>`:''}</td> + <td>${sz(r.size)}</td><td>${esc(r.method)}</td><td class="${st}">${esc(r.status)}</td> + <td>${r.ip in banned?'<span class="dim">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?'):''}</td></tr>`; + } + if(!s.log.length)h+='<tr><td colspan="7" class="dim">No uploads recorded yet.</td></tr>'; + h+='</table></div>'; + document.getElementById('app').innerHTML=h; +} +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 @@ -37,9 +37,11 @@ from .storage import ( ) from .gh_oidc import verify_actions_token from .shortener import shorten_url, get_destination_url -from .upload_guard import get_client_ip, inspect_upload +from .upload_guard import get_client_ip, inspect_upload, is_video_upload +from .admin import router as admin_router, record_upload, is_banned, gh_check, gh_record app = FastAPI() +app.include_router(admin_router) templates = Jinja2Templates(directory="app/templates") STATIC_DIR = Path(__file__).parent / "static" @@ -319,7 +321,7 @@ async def handle_upload( size = os.path.getsize(temp_path) try: - await inspect_upload(request, file.filename, temp_path, size, folder, "file") + guard_ctx = await inspect_upload(request, file.filename, temp_path, size, folder, "file") _upload_limiter.check_and_record(client_ip, size) except HTTPException: os.remove(temp_path) @@ -331,6 +333,8 @@ async def handle_upload( if os.path.exists(temp_path): os.remove(temp_path) + await record_upload(guard_ctx, hf_path) + results.append({ "filename": file.filename, "hf_path": hf_path, @@ -403,6 +407,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}", @@ -424,12 +432,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}/", @@ -457,7 +476,10 @@ async def api_shorten(request: Request, body: ShortenRequest): status_code=400, detail=f"URL too long (max {SHORTEN_MAX_URL_LENGTH} characters).", ) - _shorten_limiter.check_and_record(get_client_ip(request)) + 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) @@ -612,7 +634,7 @@ async def handle_upload_from_url( raise try: - await inspect_upload( + guard_ctx = await inspect_upload( request, filename, temp_path, @@ -638,6 +660,8 @@ async def handle_upload_from_url( if os.path.exists(temp_path): os.remove(temp_path) + await record_upload(guard_ctx, hf_path) + return { "files": [{ "filename": filename, diff --git a/app/storage.py b/app/storage.py @@ -138,7 +138,7 @@ def _fetch_tree_recursive(path: str = ""): )) 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) @@ -297,6 +297,17 @@ def _do_upload_bytes(data: bytes, hf_path: str): 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._-]+") 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/upload_guard.py b/app/upload_guard.py @@ -14,6 +14,8 @@ from dataclasses import dataclass from fastapi import HTTPException, Request +from .admin import is_banned, record_blocked + # Video containers that are refused outright, by extension. BLOCKED_VIDEO_EXTENSIONS = { @@ -131,12 +133,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): + 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): + await record_blocked(ctx, "blocked_video") raise HTTPException( status_code=415, detail="Video uploads (including .mp4) are not allowed on this CDN.", diff --git a/render.yaml b/render.yaml @@ -15,3 +15,12 @@ services: - key: RAW_BASE_URL 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