Add device diagnostics and P2P access controls

This commit is contained in:
Jonathan Sykes
2026-10-14 02:45:33 +08:00
parent e9a597c415
commit 5177a4d441
11 changed files with 147 additions and 8 deletions

View File

@@ -749,6 +749,11 @@
<div class="card"><h2>24-hour p95 history</h2><div id="opsHistory" class="analytics-grid"></div></div> <div class="card"><h2>24-hour p95 history</h2><div id="opsHistory" class="analytics-grid"></div></div>
<div id="opsAlerts" aria-live="polite"></div> <div id="opsAlerts" aria-live="polite"></div>
</section> </section>
<section id="p-devices" class="panel">
<div class="row"><div><h1 class="page">P2P devices</h1><p class="lead">Presence, stored holdings and device-level sharing controls.</p></div><span class="grow"></span><button class="btn" id="devicesRefresh" type="button">Refresh</button></div>
<div class="analytics-grid" id="deviceHealth"></div><p id="devicesStatus" class="msg" role="status" aria-live="polite"></p>
<div class="card"><div class="scroll"><table class="tbl" id="deviceTable"></table></div></div>
</section>
<section id="p-deep-search" class="panel deep-search"> <section id="p-deep-search" class="panel deep-search">
<h1 class="page">Deep search</h1> <h1 class="page">Deep search</h1>
@@ -848,6 +853,7 @@
index: '<path d="M4 19V5M4 19h16M8 15l3-4 3 2 5-7"/><circle cx="19" cy="6" r="1.5"/>', index: '<path d="M4 19V5M4 19h16M8 15l3-4 3 2 5-7"/><circle cx="19" cy="6" r="1.5"/>',
library: '<path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/>', library: '<path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/>',
peers: '<circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="M8.3 10.9l7.4-3.8M8.3 13.1l7.4 3.8"/>', peers: '<circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="M8.3 10.9l7.4-3.8M8.3 13.1l7.4 3.8"/>',
devices: '<rect x="6" y="3" width="12" height="18" rx="2"/><path d="M10 17h4"/>',
history: '<path d="M3 12a9 9 0 109-9 9 9 0 00-6.4 2.6L3 8"/><path d="M3 3v5h5M12 7v5l3 2"/>', history: '<path d="M3 12a9 9 0 109-9 9 9 0 00-6.4 2.6L3 8"/><path d="M3 3v5h5M12 7v5l3 2"/>',
backups: '<path d="M4 5h16v15H4zM8 5V3h8v2M8 11h8M8 15h5"/>', backups: '<path d="M4 5h16v15H4zM8 5V3h8v2M8 11h8M8 15h5"/>',
analytics: '<path d="M4 20h16M7 16V9M12 16V4M17 16v-5"/>', analytics: '<path d="M4 20h16M7 16V9M12 16V4M17 16v-5"/>',
@@ -856,7 +862,7 @@
audit: '<path d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"/>', audit: '<path d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"/>',
access: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/>', access: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/>',
}; };
const TABS = [['editor', 'Editor'], ['quality', 'Lyrics queue'], ['lyric-search', 'Lyrics search'], ['cache', 'Cache'], ['index', 'Search index'], ['reports', 'Reports'], ['library', 'Library'], ['peers', 'P2P'], ['history', 'History'], ['backups', 'Backups'], ['analytics', 'Stats'], ['ops', 'Performance'], ['deep-search', 'Deep search'], ['audit', 'Audit log'], ['access', 'API']]; const TABS = [['editor', 'Editor'], ['quality', 'Lyrics queue'], ['lyric-search', 'Lyrics search'], ['cache', 'Cache'], ['index', 'Search index'], ['reports', 'Reports'], ['library', 'Library'], ['peers', 'P2P'], ['devices', 'Devices'], ['history', 'History'], ['backups', 'Backups'], ['analytics', 'Stats'], ['ops', 'Performance'], ['deep-search', 'Deep search'], ['audit', 'Audit log'], ['access', 'API']];
let tab = 'editor'; let tab = 'editor';
function buildTabs() { function buildTabs() {
const html = TABS.map(([k, l]) => `<button class="tab" role="tab" data-tab="${k}" aria-selected="false"><svg viewBox="0 0 24 24">${ICONS[k]}</svg><span>${l}</span>${k === 'reports' ? '<span class="badge hidden" data-badge>0</span>' : ''}</button>`).join(''); const html = TABS.map(([k, l]) => `<button class="tab" role="tab" data-tab="${k}" aria-selected="false"><svg viewBox="0 0 24 24">${ICONS[k]}</svg><span>${l}</span>${k === 'reports' ? '<span class="badge hidden" data-badge>0</span>' : ''}</button>`).join('');
@@ -882,6 +888,7 @@
if (name === 'access') loadTokens(); if (name === 'access') loadTokens();
if (name === 'analytics') loadAnalytics(); if (name === 'analytics') loadAnalytics();
if (name === 'ops') loadOpsMetrics(); if (name === 'ops') loadOpsMetrics();
if (name === 'devices') loadDevices();
if (name === 'deep-search') loadDeepSearch(); if (name === 'deep-search') loadDeepSearch();
if (name === 'audit') loadAudit(); if (name === 'audit') loadAudit();
window.scrollTo({ top: 0 }); window.scrollTo({ top: 0 });
@@ -973,6 +980,25 @@
} catch (error) { status.textContent = error.message || 'Monitoring unavailable.'; } } catch (error) { status.textContent = error.message || 'Monitoring unavailable.'; }
} }
$('opsRefresh')?.addEventListener('click', loadOpsMetrics); $('opsRefresh')?.addEventListener('click', loadOpsMetrics);
async function loadDevices() {
$('devicesStatus').textContent = 'Loading registered devices…';
try {
const j = await api('/api/admin/p2p/devices');
$('deviceHealth').innerHTML = [['Online now',j.network.online],['Verified content',j.network.content],['Intake active',j.network.intakeActive],['Open intake',j.network.intakeOpen]].map(([a,b])=>`<div class="metric"><span>${esc(a)}</span><b>${esc(b)}</b></div>`).join('');
$('deviceTable').innerHTML = '<tr><th>Device</th><th>Presence</th><th>Holdings</th><th>Traffic</th><th>Controls</th></tr>' + (j.devices.map(d=>`<tr><td><b>${esc(d.fingerprintShort)}</b><br><small>${esc(d.profile)} · ${esc(d.userAgent)}</small></td><td>${d.banned?'Banned':d.online?'Online':'Last seen '+new Date(d.lastSeenAt).toLocaleString()}</td><td>${d.holdings} · ${bytes(d.holdingBytes)}</td><td>Sent ${bytes(d.bytesUp)} · received ${bytes(d.bytesDown)}</td><td><div class="row"><button class="btn sm" data-device-sync="${esc(d.id)}" ${!d.online?'disabled':''}>Re-announce</button><button class="btn sm" data-device-holdings="${esc(d.id)}">Revoke holdings</button><button class="btn sm dng" data-device-ban="${esc(d.id)}" data-banned="${d.banned}">${d.banned?'Unban':'Ban'}</button></div></td></tr>`).join('') || '<tr><td colspan="5" class="muted">No devices registered.</td></tr>');
$('devicesStatus').textContent = `${j.devices.length} devices shown`;
} catch (error) { $('devicesStatus').textContent = error.message || 'Could not load devices.'; }
}
$('devicesRefresh')?.addEventListener('click', loadDevices);
$('deviceTable')?.addEventListener('click', async e => {
const b=e.target.closest('button'); if(!b)return;
try {
if (b.dataset.deviceSync) await api(`/api/admin/p2p/devices/${b.dataset.deviceSync}/reannounce`,{method:'POST',body:{}});
if (b.dataset.deviceHoldings && confirm('Remove all reported holdings for this device?')) await api(`/api/admin/p2p/devices/${b.dataset.deviceHoldings}/holdings`,{method:'DELETE'});
if (b.dataset.deviceBan) await api(`/api/admin/p2p/devices/${b.dataset.deviceBan}/ban`,{method:'POST',body:{banned:b.dataset.banned!=='true'}});
await loadDevices();
} catch(error) { $('devicesStatus').textContent=error.message || 'Device action failed.'; }
});
async function loadCollections() { async function loadCollections() {
try { try {
const j = await api('/api/admin/collections'); if (!j.ok) return; const j = await api('/api/admin/collections'); if (!j.ok) return;

View File

@@ -224,6 +224,7 @@
let m; let m;
try { m = JSON.parse(e.data); } catch { return; } try { m = JSON.parse(e.data); } catch { return; }
if (m.type === 'hello') { myPeer = m.peer; wsRetry = 0; } if (m.type === 'hello') { myPeer = m.peer; wsRetry = 0; }
if (m.type === 'force-sync') sync();
emit(m); emit(m);
}; };
sock.onclose = () => { sock.onclose = () => {

35
server/p2p-admin.js Normal file
View File

@@ -0,0 +1,35 @@
import { audit } from './audit-log.js';
export function registerP2pAdminRoutes(app, { db, adminAuth, p2pDb, hub, stats = async () => ({}) }) {
app.get('/api/admin/p2p/devices', adminAuth, async c => {
const offset = Math.max(0, Number(c.req.query('offset')) || 0);
const devices = await p2pDb.listAdminDevices({ limit: 100, offset });
return c.json({ ok: true, devices: devices.map(d => ({
id: d.device_id, fingerprint: d.fingerprint || '', fingerprintShort: (d.fingerprint || d.device_id).slice(0, 12),
profile: d.profile || '', userAgent: d.user_agent || '', share: !!Number(d.share), banned: !!d.banned_at,
lastSeenAt: Number(d.last_seen_at), online: hub.isOnline(d.device_id), holdings: Number(d.holdings), holdingBytes: Number(d.holding_bytes),
bytesUp: Number(d.bytes_up), bytesDown: Number(d.bytes_down),
})), network: { online: hub.onlineCount(), ...(await stats()) }, offset }, 200, { 'Cache-Control': 'no-store' });
});
app.post('/api/admin/p2p/devices/:id/ban', adminAuth, async c => {
const id = c.req.param('id'); if (!/^dev_[0-9a-f]{16}$/.test(id)) return c.json({ ok: false, error: 'Invalid device.' }, 400);
const body = await c.req.json().catch(() => ({})); const banned = body.banned === true;
const changed = await p2pDb.setDeviceBanned(id, banned);
if (!changed) return c.json({ ok: false, error: 'Device not found.' }, 404);
if (banned) hub.disconnect(id);
await audit(db, 'admin', banned ? 'p2p.device.ban' : 'p2p.device.unban', id);
return c.json({ ok: true, banned });
});
app.post('/api/admin/p2p/devices/:id/reannounce', adminAuth, async c => {
const id = c.req.param('id'); if (!/^dev_[0-9a-f]{16}$/.test(id)) return c.json({ ok: false, error: 'Invalid device.' }, 400);
const sent = hub.send(id, { type: 'force-sync' });
await audit(db, 'admin', 'p2p.device.reannounce', id, { sent });
return c.json({ ok: true, sent });
});
app.delete('/api/admin/p2p/devices/:id/holdings', adminAuth, async c => {
const id = c.req.param('id'); if (!/^dev_[0-9a-f]{16}$/.test(id)) return c.json({ ok: false, error: 'Invalid device.' }, 400);
const removed = await p2pDb.removeDeviceHolders(id);
await audit(db, 'admin', 'p2p.device.holdings.revoke', id, { removed });
return c.json({ ok: true, removed });
});
}

View File

@@ -37,6 +37,10 @@ export async function initP2pSchema() {
fingerprint TEXT, fingerprint TEXT,
profile TEXT, profile TEXT,
share INTEGER NOT NULL DEFAULT 1, share INTEGER NOT NULL DEFAULT 1,
user_agent TEXT,
banned_at INTEGER,
bytes_up INTEGER NOT NULL DEFAULT 0,
bytes_down INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL last_seen_at INTEGER NOT NULL
); );
@@ -60,6 +64,10 @@ export async function initP2pSchema() {
PRIMARY KEY (video_id, day) PRIMARY KEY (video_id, day)
); );
`); `);
for (const [name, decl] of [['user_agent','TEXT'],['banned_at','INTEGER'],['bytes_up','INTEGER NOT NULL DEFAULT 0'],['bytes_down','INTEGER NOT NULL DEFAULT 0']]) {
try { await db.execute(`ALTER TABLE p2p_devices ADD COLUMN ${name} ${decl}`); }
catch (e) { if (!/duplicate column/i.test(String(e.message))) throw e; }
}
// media_cache.sha256 — the cid of the current <id>.<gen>.mp4 (plan 009). // media_cache.sha256 — the cid of the current <id>.<gen>.mp4 (plan 009).
try { await db.execute('ALTER TABLE media_cache ADD COLUMN sha256 TEXT'); } try { await db.execute('ALTER TABLE media_cache ADD COLUMN sha256 TEXT'); }
catch (e) { if (!/duplicate column/i.test(String(e.message))) throw e; } catch (e) { if (!/duplicate column/i.test(String(e.message))) throw e; }
@@ -117,11 +125,11 @@ export async function revokeContent(cid) {
// ---- devices ------------------------------------------------------------------ // ---- devices ------------------------------------------------------------------
export async function createDevice({ deviceId, secretHash, fingerprint, profile, now }) { export async function createDevice({ deviceId, secretHash, fingerprint, profile, userAgent, now }) {
await db.execute({ await db.execute({
sql: `INSERT INTO p2p_devices (device_id, secret_hash, fingerprint, profile, share, created_at, last_seen_at) sql: `INSERT INTO p2p_devices (device_id, secret_hash, fingerprint, profile, share, user_agent, created_at, last_seen_at)
VALUES (?, ?, ?, ?, 1, ?, ?)`, VALUES (?, ?, ?, ?, 1, ?, ?, ?)`,
args: [deviceId, secretHash, fingerprint || null, profile || null, now, now], args: [deviceId, secretHash, fingerprint || null, profile || null, userAgent || null, now, now],
}); });
} }
@@ -139,6 +147,26 @@ export async function touchDevice(deviceId, { now, share, profile } = {}) {
}); });
} }
export async function listAdminDevices({ limit = 100, offset = 0 } = {}) {
const r = await db.execute({ sql: `SELECT d.device_id,d.fingerprint,d.profile,d.share,d.user_agent,d.banned_at,d.bytes_up,d.bytes_down,d.created_at,d.last_seen_at,
COUNT(CASE WHEN h.status='active' THEN 1 END) AS holdings,
COALESCE(SUM(CASE WHEN h.status='active' THEN c.size ELSE 0 END),0) AS holding_bytes
FROM p2p_devices d LEFT JOIN p2p_holders h ON h.device_id=d.device_id LEFT JOIN p2p_content c ON c.cid=h.cid
GROUP BY d.device_id ORDER BY d.last_seen_at DESC LIMIT ? OFFSET ?`, args: [Math.max(1, Math.min(200, limit)), Math.max(0, offset)] });
return rowsOf(r);
}
export async function setDeviceBanned(deviceId, banned, now = Date.now()) {
const r = await db.execute({ sql: 'UPDATE p2p_devices SET banned_at=? WHERE device_id=?', args: [banned ? now : null, deviceId] });
return !!r.rowsAffected;
}
export async function removeDeviceHolders(deviceId, now = Date.now()) {
const r = await db.execute({ sql: "UPDATE p2p_holders SET status='removed',removed_at=? WHERE device_id=? AND status='active'", args: [now, deviceId] });
return Number(r.rowsAffected || 0);
}
export async function recordDeviceTraffic(deviceId, { up = 0, down = 0 } = {}) {
await db.execute({ sql: 'UPDATE p2p_devices SET bytes_up=bytes_up+?,bytes_down=bytes_down+? WHERE device_id=?', args: [Math.max(0, Number(up) || 0), Math.max(0, Number(down) || 0), deviceId] });
}
// ---- holders (persistent; never expired by time) ------------------------------ // ---- holders (persistent; never expired by time) ------------------------------
export async function upsertHolder({ cid, deviceId, trust = 'reported', now }) { export async function upsertHolder({ cid, deviceId, trust = 'reported', now }) {

View File

@@ -40,6 +40,22 @@ test('content upsert/get/known/revoke', async () => {
expect([...(await P.knownCids([CID2]))]).toEqual([]); expect([...(await P.knownCids([CID2]))]).toEqual([]);
}); });
test('admin device inventory, ban state, and holding revocation', async () => {
const id = 'dev_00000000000000b1';
await P.createDevice({ deviceId: id, secretHash: 'hash', fingerprint: 'phone-fp', profile: 'family', userAgent: 'iPhone', now: T0 });
const adminCid = 'c'.repeat(64);
await P.upsertContent({ cid: adminCid, videoId: 'dQw4w9WgXcQ', size: 4096, origin: 'server', now: T0 });
await P.upsertHolder({ cid: adminCid, deviceId: id, now: T0 });
let [device] = await P.listAdminDevices();
expect(device).toMatchObject({ device_id: id, user_agent: 'iPhone', holdings: 1, holding_bytes: 4096 });
expect(await P.setDeviceBanned(id, true, T0 + 100)).toBe(true);
[device] = await P.listAdminDevices(); expect(device.banned_at).toBe(T0 + 100);
expect(await P.removeDeviceHolders(id, T0 + 200)).toBe(1);
[device] = await P.listAdminDevices(); expect(device.holdings).toBe(0);
expect(await P.setDeviceBanned(id, false, T0 + 300)).toBe(true);
[device] = await P.listAdminDevices(); expect(device.banned_at).toBeNull();
});
test('holders persist, never expire, and a full report removes missing ones', async () => { test('holders persist, never expire, and a full report removes missing ones', async () => {
await P.createDevice({ deviceId: 'dev_1', secretHash: 'h', fingerprint: 'fp', now: T0 }); await P.createDevice({ deviceId: 'dev_1', secretHash: 'h', fingerprint: 'fp', now: T0 });
await P.upsertHolder({ cid: CID, deviceId: 'dev_1', now: T0 }); await P.upsertHolder({ cid: CID, deviceId: 'dev_1', now: T0 });
@@ -66,8 +82,8 @@ test('views per day and window sums', async () => {
expect(await P.viewsSince('vid00000001', T0 - 30 * 86400_000)).toBe(2); expect(await P.viewsSince('vid00000001', T0 - 30 * 86400_000)).toBe(2);
expect(await P.viewsSince('vid00000001', T0 - 50 * 86400_000)).toBe(3); expect(await P.viewsSince('vid00000001', T0 - 50 * 86400_000)).toBe(3);
const s = await P.p2pStats(); const s = await P.p2pStats();
expect(s.content).toBe(1); expect(s.content).toBe(2);
expect(s.devices).toBe(1); expect(s.devices).toBe(2);
}); });
test('listSharedContent: newest verified file per video, only with sharing holders counted', async () => { test('listSharedContent: newest verified file per video, only with sharing holders counted', async () => {

View File

@@ -47,7 +47,7 @@ export function createP2pHub({ getDevice, authorizeProfile = async () => '', ena
const deviceId = String(m.device || ''); const deviceId = String(m.device || '');
const secret = String(m.secret || ''); const secret = String(m.secret || '');
const d = /^dev_[0-9a-f]{16}$/.test(deviceId) ? await getDevice(deviceId).catch(() => null) : null; const d = /^dev_[0-9a-f]{16}$/.test(deviceId) ? await getDevice(deviceId).catch(() => null) : null;
if (!d || !same(d.secret_hash, sha(secret))) { try { ws.close(4401, 'unknown device'); } catch { /* gone */ } return; } if (!d || d.banned_at || !same(d.secret_hash, sha(secret))) { try { ws.close(d?.banned_at ? 4403 : 4401, d?.banned_at ? 'device banned' : 'unknown device'); } catch { /* gone */ } return; }
ws.data.profile = await authorizeProfile(m.profile, m.profileSecret, deviceId).catch(() => ''); ws.data.profile = await authorizeProfile(m.profile, m.profileSecret, deviceId).catch(() => '');
ws.data.inventory = []; ws.data.inventory = [];
ws.data.authed = true; ws.data.authed = true;
@@ -107,6 +107,8 @@ export function createP2pHub({ getDevice, authorizeProfile = async () => '', ena
websocket: { open, message, close }, websocket: { open, message, close },
isOnline: (deviceId) => online.has(deviceId), isOnline: (deviceId) => online.has(deviceId),
send: (deviceId, msg) => { const ws = online.get(deviceId); if (!ws) return false; send(ws, msg); return true; }, send: (deviceId, msg) => { const ws = online.get(deviceId); if (!ws) return false; send(ws, msg); return true; },
disconnect: (deviceId) => { const ws = online.get(deviceId); if (!ws) return false; try { ws.close(4403, 'device banned'); } catch { /* gone */ } return true; },
onlineDevices: () => [...online.keys()],
onlineCount: () => online.size, onlineCount: () => online.size,
}; };
} }

View File

@@ -51,6 +51,21 @@ test('auth on open, hello with opaque peer id, bad secret is closed', async () =
expect(hub.isOnline('dev_000000000000000b')).toBe(false); expect(hub.isOnline('dev_000000000000000b')).toBe(false);
}); });
test('banned devices cannot authenticate and admin disconnect closes an online peer', async () => {
await p2pDb.setDeviceBanned('dev_000000000000000a', true, NOW);
const hub = createP2pHub({ getDevice: p2pDb.getDevice });
const banned = fakeWs({ budget: [] });
await hub.websocket.message(banned, JSON.stringify({ type: 'auth', device: 'dev_000000000000000a', secret: SECRET_A }));
expect(banned.closed).toBe(4403);
await p2pDb.setDeviceBanned('dev_000000000000000a', false, NOW + 1);
const online = fakeWs({ budget: [] });
await hub.websocket.message(online, JSON.stringify({ type: 'auth', device: 'dev_000000000000000a', secret: SECRET_A }));
expect(hub.disconnect('dev_000000000000000a')).toBe(true);
expect(online.closed).toBe(4403);
hub.websocket.close(online);
expect(hub.isOnline('dev_000000000000000a')).toBe(false);
});
test('legacy unconfirmed signalling is refused; close drops presence', async () => { test('legacy unconfirmed signalling is refused; close drops presence', async () => {
const hub = createP2pHub({ getDevice: p2pDb.getDevice }); const hub = createP2pHub({ getDevice: p2pDb.getDevice });
const a = fakeWs({ budget: [] }); const a = fakeWs({ budget: [] });

View File

@@ -105,6 +105,7 @@ export function registerIntakeRoutes(app, deps) {
); );
if (!adm.ok) throw Object.assign(new Error('not admitted: ' + adm.reason), { status: 422 }); if (!adm.ok) throw Object.assign(new Error('not admitted: ' + adm.reason), { status: 422 });
await p2pDb.upsertHolder({ cid, deviceId: d.device_id, trust: 'challenged', now: now() }); await p2pDb.upsertHolder({ cid, deviceId: d.device_id, trust: 'challenged', now: now() });
await p2pDb.recordDeviceTraffic?.(d.device_id, { up: got });
let adopted = false; let adopted = false;
if (adopt) { if (adopt) {
try { adopted = !!(await adopt(tk.videoId, path, { sha256: cid, probe, meta: tk.meta })).adopted; keep = adopted; } try { adopted = !!(await adopt(tk.videoId, path, { sha256: cid, probe, meta: tk.meta })).adopted; keep = adopted; }

View File

@@ -41,10 +41,12 @@ export function registerP2pRoutes(app, deps) {
if (!m) return null; if (!m) return null;
const d = await p2pDb.getDevice(m[1]); const d = await p2pDb.getDevice(m[1]);
if (!d || !same(d.secret_hash, sha(m[2]))) return null; if (!d || !same(d.secret_hash, sha(m[2]))) return null;
if (d.banned_at) return { ...d, _banned: true };
return d; return d;
} }
const requireDevice = async (c, next) => { const requireDevice = async (c, next) => {
const d = await deviceOf(c); const d = await deviceOf(c);
if (d?._banned) return c.json({ ok: false, error: 'device access revoked' }, 403);
if (!d) return c.json({ ok: false, error: 'unknown device' }, 401); if (!d) return c.json({ ok: false, error: 'unknown device' }, 401);
c.set('device', d); c.set('device', d);
await next(); await next();
@@ -67,6 +69,7 @@ export function registerP2pRoutes(app, deps) {
deviceId, secretHash: sha(secret), deviceId, secretHash: sha(secret),
fingerprint: String(body.fingerprint || '').slice(0, 128) || null, fingerprint: String(body.fingerprint || '').slice(0, 128) || null,
profile: String(body.profile || '').slice(0, 64) || null, profile: String(body.profile || '').slice(0, 64) || null,
userAgent: String(c.req.header('user-agent') || '').slice(0, 500),
now: t, now: t,
}); });
return c.json({ ok: true, deviceId, secret }); return c.json({ ok: true, deviceId, secret });

View File

@@ -63,6 +63,15 @@ test('holdings need a valid device secret', async () => {
expect((await req('/api/p2p/holdings', { method: 'POST', body: { items: [] }, dev: { ...dev, secret: 'e'.repeat(64) } })).status).toBe(401); expect((await req('/api/p2p/holdings', { method: 'POST', body: { items: [] }, dev: { ...dev, secret: 'e'.repeat(64) } })).status).toBe(401);
}); });
test('banned device is denied with an explicit forbidden response', async () => {
const dev = await newDevice();
await p2pDb.setDeviceBanned(dev.deviceId, true);
const r = await req('/api/p2p/holdings', { method: 'POST', body: { items: [] }, dev });
expect(r.status).toBe(403);
expect(await r.json()).toMatchObject({ ok: false, error: 'device access revoked' });
await p2pDb.setDeviceBanned(dev.deviceId, false);
});
test('report accepts verified cids, returns unknown ones, and challenges the server-held file', async () => { test('report accepts verified cids, returns unknown ones, and challenges the server-held file', async () => {
const dev = await newDevice(); const dev = await newDevice();
const r = await (await req('/api/p2p/holdings', { method: 'POST', dev, body: { share: true, items: [ const r = await (await req('/api/p2p/holdings', { method: 'POST', dev, body: { share: true, items: [

View File

@@ -72,6 +72,7 @@ import { createWarmQueue } from './warm-queue.js';
import { registerDeepSearchRoutes } from './deep-search.js'; import { registerDeepSearchRoutes } from './deep-search.js';
import { dirname, join as pathJoin } from 'node:path'; import { dirname, join as pathJoin } from 'node:path';
import { createRequestMonitor, registerRequestMonitorRoutes } from './request-monitor.js'; import { createRequestMonitor, registerRequestMonitorRoutes } from './request-monitor.js';
import { registerP2pAdminRoutes } from './p2p-admin.js';
// A media proxy must not die because one client's stream hit an edge case // A media proxy must not die because one client's stream hit an edge case
// (see /api/play cancel()): log and keep serving instead of crash-looping. // (see /api/play cancel()): log and keep serving instead of crash-looping.
@@ -2381,6 +2382,8 @@ const p2pHub = createP2pHub({ getDevice: p2pDb.getDevice, enabled: () => P2P.ena
directProfileFails.set(key, { count: old && Date.now() - old.at < 900000 ? old.count + 1 : 1, at: Date.now() }); directProfileFails.set(key, { count: old && Date.now() - old.at < 900000 ? old.count + 1 : 1, at: Date.now() });
if (directProfileFails.size > 5000) directProfileFails.clear(); return ''; if (directProfileFails.size > 5000) directProfileFails.clear(); return '';
} }); } });
registerP2pAdminRoutes(app, { db: notesDb, adminAuth: notes.requireAdminOrToken, p2pDb, hub: p2pHub,
stats: async () => ({ intakeOpen: intake.openTickets(), intakeActive: intake.active(), content: (await p2pDb.p2pStats()).content }) });
// GET /api/p2p/holders?v=<videoId>|cid=<sha256> — who holds a copy. Rows are // GET /api/p2p/holders?v=<videoId>|cid=<sha256> — who holds a copy. Rows are
// persistent; `stale` flags a holder not re-verified for P2P_STALE_DAYS. // persistent; `stale` flags a holder not re-verified for P2P_STALE_DAYS.