8.5 KiB
8.5 KiB
id, title, created, depends_on, est_files
| id | title | created | depends_on | est_files | |
|---|---|---|---|---|---|
| 019-admin-p2p-panel-4dc623 | Add a P2P panel to the admin page | 2026-09-29 |
|
3 |
019 — Admin P2P panel
Objective
Give the admin one place to see and steer peer-to-peer sharing
(docs/p2p-architecture.md). After this plan /admin has a Peer-to-peer section showing:
- config: P2P on/off, malware scan on/off (with a note that it is OFF by default and hashing + validation always run), stale-after days, retention thresholds;
- counts: verified content, revoked, devices, active holder rows, cids with ≥1 holder, devices online now, open/active intake uploads;
- the 30 most recently verified files (video, cid prefix, origin, scan, size, verified at,
holder count) with a Revoke button (revoked cids are never offered to or accepted
from devices again — plan 013 only accepts
verifiedcids).
Context the executor must NOT rediscover
- Admin auth middleware:
notes.requireAdminOrToken(returned byregisterNoteRoutes,server/server.js~line 1823). Used likeapp.get('/api/admin/media', requireAdminOrToken, async (c) => …). - In server.js (plans 008–018):
P2P(config),p2pDb(module),p2pHub.onlineCount(),intake.openTickets(),intake.active(). server/p2p-db.jshasrowsOf,p2pStats(),revokeContent(cid).frontend/admin.html:- sections are
<section>…</section>inside<div id="app">; the "Recent edits" section (~line 190) starts with<section> <div class="row" style="margin-bottom:10px"> <h2 style="margin:0">Recent edits</h2> - helpers inside the script IIFE:
$(id),esc(s),api(path, opts)(JSON in/out,opts.bodyis JSON-encoded), classestbl,scroll,muted,row,spacer. async function boot()callsloadTokens(); loadRevs(); loadUploads();.
- sections are
Steps
server/p2p-db.js— append:// Admin panel (plan 019): newest verified/revoked files with their holder counts. export async function recentContent(limit = 30) { const r = await db.execute({ sql: `SELECT c.cid, c.video_id, c.size, c.height, c.vcodec, c.origin, c.status, c.scan, c.verified_at, c.meta, (SELECT COUNT(*) FROM p2p_holders h WHERE h.cid = c.cid AND h.status = 'active') AS holders FROM p2p_content c ORDER BY c.verified_at DESC LIMIT ?`, args: [limit], }); return rowsOf(r).map((x) => ({ ...x, holders: Number(x.holders) || 0 })); }server/server.js— directly after theconst p2pRehydrate = createRehydrator({…});statement add:// Admin: P2P overview + revoke (frontend/admin.html → Peer-to-peer). app.get('/api/admin/p2p', notes.requireAdminOrToken, async (c) => c.json({ ok: true, config: { enabled: P2P.enabled, malwareScan: P2P.malwareScan, staleDays: P2P.staleDays, keepMinViews: P2P.keepMinViews, keepDays: P2P.keepDays, keepRecentDays: P2P.keepRecentDays, }, stats: { ...(await p2pDb.p2pStats()), online: p2pHub.onlineCount(), intakeOpen: intake.openTickets(), intakeActive: intake.active() }, recent: await p2pDb.recentContent(30), }, 200, { 'Cache-Control': 'no-store' })); app.post('/api/admin/p2p/revoke', notes.requireAdminOrToken, async (c) => { const body = await c.req.json().catch(() => ({})); const cid = String(body.cid || '').toLowerCase(); if (!/^[0-9a-f]{64}$/.test(cid)) return c.json({ ok: false, error: 'bad cid' }, 400); await p2pDb.revokeContent(cid); console.warn(`[p2p] admin revoked ${cid.slice(0, 12)}`); return c.json({ ok: true }); });frontend/admin.html— directly BEFORE the "Recent edits"<section>insert:<section> <div class="row" style="margin-bottom:10px"> <h2 style="margin:0">Peer-to-peer</h2> <span class="spacer"></span> <button id="p2pRefreshBtn">↻ Refresh</button> </div> <p class="muted">Devices share verified copies with each other (docs/p2p-architecture.md). A file's hash is only added after the server itself hashed and validated it. The malware scan is off by default (<code>P2P_MALWARE_SCAN=1</code> to enable). Holders are never expired — ones not re-checked for the stale period are only marked stale.</p> <div id="p2pSummary" class="msg"></div> <div class="scroll"><table class="tbl" id="p2pTable"></table></div> </section>frontend/admin.htmlscript — directly ABOVEasync function boot() {add:async function loadP2p() { const j = await api('/api/admin/p2p'); if (!j.ok) { $('p2pSummary').textContent = j.error || 'P2P unavailable'; return; } const c = j.config, s = j.stats; $('p2pSummary').textContent = `P2P ${c.enabled ? 'ON' : 'OFF'} · malware scan ${c.malwareScan ? 'ON' : 'off'} · stale after ${c.staleDays} d · ` + `keep ≥${c.keepMinViews} views/${c.keepDays} d or played in ${c.keepRecentDays} d — ` + `${s.content} verified (${s.revoked} revoked) · ${s.devices} devices, ${s.online} online · ` + `${s.holders} holdings over ${s.heldCids} files · intake ${s.intakeActive} running / ${s.intakeOpen} open`; const mb = (b) => (Number(b) / 1048576).toFixed(1) + ' MB'; $('p2pTable').innerHTML = '<tr><th>Video</th><th>cid</th><th>Origin</th><th>Scan</th><th>Size</th><th>Verified</th><th>Holders</th><th></th></tr>' + j.recent.map((r) => { let title = ''; try { title = JSON.parse(r.meta || '{}').title || ''; } catch { /* no meta */ } return `<tr${r.status === 'revoked' ? ' class="muted"' : ''}><td>${esc(title || r.video_id)}<br><small>${esc(r.video_id)}</small></td>` + `<td><code>${esc(r.cid.slice(0, 12))}</code></td><td>${esc(r.origin)}</td><td>${esc(r.scan)}</td><td>${mb(r.size)}</td>` + `<td>${esc(new Date(Number(r.verified_at)).toLocaleString())}</td><td>${r.holders}</td>` + `<td>${r.status === 'verified' ? `<button data-revoke="${esc(r.cid)}">Revoke</button>` : 'revoked'}</td></tr>`; }).join(''); } $('p2pRefreshBtn').addEventListener('click', loadP2p); $('p2pTable').addEventListener('click', async (e) => { const b = e.target.closest('[data-revoke]'); if (!b || !confirm('Revoke this file? Devices will stop sharing it and the server will never accept it again.')) return; const j = await api('/api/admin/p2p/revoke', { method: 'POST', body: { cid: b.dataset.revoke } }); if (!j.ok) alert(j.error || 'failed'); loadP2p(); });frontend/admin.htmlboot()—loadUploads();appears 3 times in the file; use ONLY the one insideasync function boot(), i.e. this exact pair of lines:and insertloadUploads(); const want = videoIdFrom(new URLSearchParams(location.search).get('v') || '');loadP2p();between them.
Out of scope / do NOT touch
- No editing of config from the page (env vars stay the source of truth).
admin.htmlis never cached by the SW — nosw.jschange.
Verification
cd /home/user/ytplayer/server && bun install >/dev/null 2>&1 && [ -e public ] || ln -s ../frontend public
bun build server.js --target=bun --outdir=/tmp/ytp-check >/dev/null && echo SERVER_OK
bun run test 2>&1 | grep -E "^ *[0-9]+ (pass|fail)"
DBDIR=$(mktemp -d); DB_PATH=$DBDIR/t.db MEDIA_DIR=$DBDIR/media ADMIN_PASSWORD=test-pass PORT=3995 bun server.js >/tmp/ytp019.log 2>&1 & SRV=$!; sleep 4
curl -s -c /tmp/ytp019.jar -H 'Content-Type: application/json' -d '{"password":"test-pass"}' http://localhost:3995/api/admin/login
echo
curl -s -b /tmp/ytp019.jar http://localhost:3995/api/admin/p2p | head -c 400; echo
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3995/api/admin/p2p
curl -s -b /tmp/ytp019.jar -H 'Content-Type: application/json' -d '{"cid":"nope"}' http://localhost:3995/api/admin/p2p/revoke
echo
kill $SRV; true
grep -c "loadP2p" ../frontend/admin.html
Expected: SERVER_OK; every test file 0 fail; login {"ok":true…}; the p2p call returns
{"ok":true,"config":{"enabled":true,"malwareScan":false,"staleDays":7,…},"stats":{"content":0,…},"recent":[]};
without the cookie 401; bad cid {"ok":false,"error":"bad cid"}; grep ≥ 3.
Report format (executor: follow exactly)
Output ONLY the following, no other prose:
git diff(unified) of all changes.- Raw output of the Verification commands.
Findings:— max 10 lines.
Do not commit. Do not push. Do not touch files outside the Steps.