cdnV2

git clone https://github.com/notamitgamer/cdnV2.git
· rebuilt hourlyOpen on GitHub
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:
Mapp/admin.py | 135++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mapp/main.py | 2++
Aapp/stats.py | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/storage.py | 2++
Mapp/templates/_navbar.html | 3+++
Aapp/templates/stats.html | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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?'&#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/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">&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>