134 lines
6.1 KiB
JavaScript
134 lines
6.1 KiB
JavaScript
/* ============================================================================
|
|
* p2p-routes.js — device registration + holdings (docs/p2p-architecture.md
|
|
* flows 3 and 5). Mounted by server.js; every route answers 404 when
|
|
* P2P_ENABLED=0.
|
|
*
|
|
* GET /api/p2p/config { ok, enabled, staleDays }
|
|
* POST /api/p2p/device { fingerprint?, profile? } → { ok, deviceId, secret }
|
|
* POST /api/p2p/holdings (device) { items:[{cid,size,videoId}], share } → { ok, accepted, unknown, challenges }
|
|
* POST /api/p2p/challenge (device) { answers:[{cid,offset,length,sha256}] } → { ok, passed, failed }
|
|
* GET /api/p2p/holders?v=<id>|cid= availability — added by plan 014
|
|
*
|
|
* Device auth: header `X-Device: <deviceId>.<secret>`; only sha256(secret) is
|
|
* stored. A holdings report is always the device's FULL list: anything it held
|
|
* before and no longer lists is marked removed. Holder rows never expire by
|
|
* time — last_verified_at is refreshed by each report (see the architecture doc).
|
|
* ========================================================================== */
|
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
|
|
const CID_RE = /^[0-9a-f]{64}$/;
|
|
const DEV_RE = /^dev_[0-9a-f]{16}$/;
|
|
const MAX_ITEMS = 5000;
|
|
const MAX_CHALLENGES = 5;
|
|
const CHALLENGE_LEN = 64 * 1024;
|
|
|
|
const sha = (s) => createHash('sha256').update(String(s)).digest('hex');
|
|
const same = (a, b) => { const x = Buffer.from(String(a)), y = Buffer.from(String(b)); return x.length === y.length && timingSafeEqual(x, y); };
|
|
export const peerIdOf = (deviceId) => sha('peer:' + deviceId).slice(0, 12);
|
|
|
|
export function registerP2pRoutes(app, deps) {
|
|
const { cfg, p2pDb, fileForCid, sha256Range, now = () => Date.now(), log = console } = deps;
|
|
const pending = new Map(); // `${deviceId}|${cid}` -> { offset, length, at }
|
|
const regLog = new Map(); // ip -> [ms]
|
|
|
|
const gate = async (c, next) => {
|
|
if (!cfg.enabled) return c.json({ ok: false, error: 'p2p disabled' }, 404);
|
|
await next();
|
|
};
|
|
|
|
async function deviceOf(c) {
|
|
const m = String(c.req.header('x-device') || '').match(/^(dev_[0-9a-f]{16})\.([0-9a-f]{64})$/);
|
|
if (!m) return null;
|
|
const d = await p2pDb.getDevice(m[1]);
|
|
if (!d || !same(d.secret_hash, sha(m[2]))) return null;
|
|
return d;
|
|
}
|
|
const requireDevice = async (c, next) => {
|
|
const d = await deviceOf(c);
|
|
if (!d) return c.json({ ok: false, error: 'unknown device' }, 401);
|
|
c.set('device', d);
|
|
await next();
|
|
};
|
|
|
|
app.get('/api/p2p/config', (c) => c.json({ ok: true, enabled: cfg.enabled, staleDays: cfg.staleDays }));
|
|
|
|
app.post('/api/p2p/device', gate, async (c) => {
|
|
const ip = (c.req.header('x-forwarded-for') || '').split(',')[0].trim() || 'local';
|
|
const t = now();
|
|
const recent = (regLog.get(ip) || []).filter((x) => t - x < 3600_000);
|
|
if (recent.length >= 20) return c.json({ ok: false, error: 'too many registrations' }, 429);
|
|
recent.push(t);
|
|
regLog.set(ip, recent);
|
|
if (regLog.size > 10000) regLog.clear();
|
|
const body = await c.req.json().catch(() => ({}));
|
|
const deviceId = 'dev_' + randomBytes(8).toString('hex');
|
|
const secret = randomBytes(32).toString('hex');
|
|
await p2pDb.createDevice({
|
|
deviceId, secretHash: sha(secret),
|
|
fingerprint: String(body.fingerprint || '').slice(0, 128) || null,
|
|
profile: String(body.profile || '').slice(0, 64) || null,
|
|
now: t,
|
|
});
|
|
return c.json({ ok: true, deviceId, secret });
|
|
});
|
|
|
|
app.post('/api/p2p/holdings', gate, requireDevice, async (c) => {
|
|
const d = c.get('device');
|
|
const body = await c.req.json().catch(() => ({}));
|
|
const t = now();
|
|
const share = body.share !== false;
|
|
await p2pDb.touchDevice(d.device_id, { now: t, share, profile: String(body.profile || '').slice(0, 64) || undefined });
|
|
const raw = Array.isArray(body.items) ? body.items.slice(0, MAX_ITEMS) : [];
|
|
const items = share ? raw.filter((x) => x && CID_RE.test(String(x.cid))) : [];
|
|
const known = await p2pDb.knownCids(items.map((x) => x.cid));
|
|
const accepted = [];
|
|
const unknown = [];
|
|
for (const it of items) {
|
|
if (!known.has(it.cid)) { unknown.push(it.cid); continue; }
|
|
await p2pDb.upsertHolder({ cid: it.cid, deviceId: d.device_id, now: t });
|
|
accepted.push(it.cid);
|
|
}
|
|
await p2pDb.removeHoldersExcept({ deviceId: d.device_id, keep: accepted, now: t });
|
|
// Spot-check a few holdings against the server's own copy when it has one.
|
|
const challenges = [];
|
|
for (const cid of accepted) {
|
|
if (challenges.length >= MAX_CHALLENGES) break;
|
|
const f = await fileForCid(cid);
|
|
if (!f || !(f.size > CHALLENGE_LEN)) continue;
|
|
const offset = Math.floor(Math.random() * (f.size - CHALLENGE_LEN));
|
|
pending.set(d.device_id + '|' + cid, { offset, length: CHALLENGE_LEN, at: t });
|
|
challenges.push({ cid, offset, length: CHALLENGE_LEN });
|
|
}
|
|
if (pending.size > 50000) pending.clear();
|
|
return c.json({ ok: true, accepted, unknown, challenges });
|
|
});
|
|
|
|
app.post('/api/p2p/challenge', gate, requireDevice, async (c) => {
|
|
const d = c.get('device');
|
|
const body = await c.req.json().catch(() => ({}));
|
|
const t = now();
|
|
const passed = [];
|
|
const failed = [];
|
|
for (const a of (Array.isArray(body.answers) ? body.answers : []).slice(0, MAX_CHALLENGES)) {
|
|
const key = d.device_id + '|' + a.cid;
|
|
const p = pending.get(key);
|
|
if (!p || p.offset !== a.offset || p.length !== a.length || t - p.at > 10 * 60_000) continue;
|
|
pending.delete(key);
|
|
const f = await fileForCid(a.cid);
|
|
if (!f) continue;
|
|
const want = await sha256Range(f.path, p.offset, p.length);
|
|
if (String(a.sha256) === want) {
|
|
await p2pDb.setHolderTrust({ cid: a.cid, deviceId: d.device_id, trust: 'challenged', now: t });
|
|
passed.push(a.cid);
|
|
} else {
|
|
await p2pDb.removeHolder({ cid: a.cid, deviceId: d.device_id, now: t });
|
|
failed.push(a.cid);
|
|
log.warn?.(`[p2p] ${d.device_id} failed challenge for ${a.cid.slice(0, 12)}`);
|
|
}
|
|
}
|
|
return c.json({ ok: true, passed, failed });
|
|
});
|
|
|
|
return { deviceOf, peerIdOf, requireDevice, gate };
|
|
}
|