commit b4e8efa1f4b1e581e19c46fc71502a5330536f34
parent 3629e72df62e74ecb240eaf1032f027d3025365d
Author: claude-bot <noreply@anthropic.com>
Date: Tue, 6 Oct 2026 08:02:56 +0000
Add public /stats page; make the admin dashboard mobile-friendly
Stats (/stats, /api/stats): file types, sizes, biggest folders, files added per month, largest files.
Computed from the already-cached bucket listing, cached 10 min, drawn with CSS/inline SVG
(no chart library). Only aggregates are public. Adds a Stats link to the navbar.
Admin dashboard: on phones tables become cards with the file name as title and large
Ban IP / Delete buttons (previously those columns were off-screen); summary tiles, short dates,
collapsible IP panel (auto-opens if the detected IP looks wrong), Stats link and Log out.
Diffstat:
6 files changed, 368 insertions(+), 53 deletions(-)
diff --git a/app/admin.py b/app/admin.py
@@ -262,7 +262,8 @@ async def admin_state(request: Request, q: str = ""):
"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"]), "bans": _state["bans"],
+ "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),
}
@@ -333,28 +334,57 @@ async def admin_delete(request: Request, body: PathBody):
_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}
+: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}
+.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>
-<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>
+<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>Recent uploads</h2>
-<p><input id="q" type="search" placeholder="Search file name, link, IP, GitHub repo, status..." autocomplete="off"> <span id="qinfo" class="dim"></span></p>
-<div class="wrap" id="uploads"></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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
-const fmt=t=>new Date(t*1000).toLocaleString();
+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='';
@@ -373,55 +403,54 @@ document.addEventListener('click',async e=>{
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('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='block';document.getElementById('app').style.display='none';
+ 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,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>`;
- const w=s.whoami;
- h+=`<h2>How the server sees you</h2><div class="wrap"><table>
- <tr><td>Detected IP (used for bans)</td><td class="mono ${w.ok?'ok':'bad'}">${esc(w.detected)}</td></tr>
- <tr><td>X-Forwarded-For</td><td class="mono">${esc(w.x_forwarded_for||'-')}</td></tr>
- <tr><td>CF-Connecting-IP</td><td class="mono">${esc(w.cf_connecting_ip||'-')}</td></tr>
- <tr><td>Socket peer</td><td class="mono">${esc(w.socket_peer||'-')}</td></tr>
- <tr><td>Settings</td><td class="mono">TRUST_CF_CONNECTING_IP=${w.trust_cf_header?1:0}, TRUSTED_PROXY_HOPS=${w.proxy_hops}</td></tr></table></div>
- <p class="dim">The detected IP must be YOUR real public IP. If it is not, bans will hit the wrong address.</p>`;
- 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>';
+ 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>`;
+ if(pend.length){
+ t+='<h2 id="pending">Waiting for approval</h2><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>';
}
- 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>';
- document.getElementById('top').innerHTML=h+'</table></div>';
- h='<table><tr><th>Time</th><th>IP</th><th>File</th><th>Size</th><th>Via</th><th>Status</th><th></th></tr>';
+ 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';
- 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.method==='github'?'':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>`;
+ 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)h+=`<tr><td colspan="7" class="dim">${Q?'Nothing matches your search.':'No uploads recorded yet.'}</td></tr>`;
- document.getElementById('uploads').innerHTML=h+'</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+')':''}`:`${s.total} saved`;
+ 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><h2 id="bans">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><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?'✓':'✗ 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/main.py b/app/main.py
@@ -38,10 +38,12 @@ 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, 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(admin_router)
+app.include_router(stats_router)
templates = Jinja2Templates(directory="app/templates")
STATIC_DIR = Path(__file__).parent / "static"
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
@@ -239,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 "-",
})
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/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">© 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 {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>