Files
ytplayer/plans/done/019-admin-p2p-panel-4dc623.md
2026-09-30 08:02:52 +00:00

9.7 KiB
Raw Permalink Blame History

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
018-server-rehydrate-from-peer-4fb8bd
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 verified cids).

Context the executor must NOT rediscover

  • Admin auth middleware: notes.requireAdminOrToken (returned by registerNoteRoutes, server/server.js ~line 1823). Used like app.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.js has rowsOf, 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.body is JSON-encoded), classes tbl, scroll, muted, row, spacer.
    • async function boot() calls loadTokens(); loadRevs(); loadUploads();.

Steps

  1. 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 }));
    }
    
  2. server/server.js — directly after the const 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 });
    });
    
  3. 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>
    
    
  4. frontend/admin.html script — directly ABOVE async 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();
      });
    
  5. frontend/admin.html boot() — loadUploads(); appears 3 times in the file; use ONLY the one inside async function boot(), i.e. this exact pair of lines:
        loadUploads();
        const want = videoIdFrom(new URLSearchParams(location.search).get('v') || '');
    
    and insert loadP2p(); between them.

Out of scope / do NOT touch

  • No editing of config from the page (env vars stay the source of truth).
  • admin.html is never cached by the SW — no sw.js change.

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:

  1. git diff (unified) of all changes.
  2. Raw output of the Verification commands.
  3. Findings: — max 10 lines.

Do not commit. Do not push. Do not touch files outside the Steps.

Execution log

  • Executor: in-session Agent (haiku). Attempts: 1. Fix rounds: 0.
  • Orchestrator re-ran Verification: only the 3 planned files changed; loadP2p appears 4 times and is called between loadUploads(); and videoIdFrom(...) inside boot(); all 15 server test files 0 fail earlier in the run and SERVER_OK. Real Chromium test on /admin: login, panel renders P2P ON · malware scan off · stale after 7 d ..., one seeded file listed with a Revoke button, clicking Revoke changed the summary from 1 verified (0 revoked) to 0 verified (1 revoked), no JS errors. Unauthenticated /api/admin/p2p returns 401 (executor run). No leftover processes.
  • Executor Findings (verbatim): All 5 steps executed verbatim as specified in the plan. Build succeeded, all tests pass (92 total: 0 fail). Server endpoints respond correctly with proper authentication gating (401 unauthenticated), config/stats, and CID validation. P2P panel HTML section added before Recent edits. loadP2p() function added to script with event listeners and revoke logic. loadP2p() call inserted in boot() between loadUploads() and videoIdFrom(). Four occurrences of "loadP2p" in admin.html as expected.