From 795a5aaed40aa0417e7e9999986284af6850695e Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Sat, 3 Oct 2026 20:38:51 +0800 Subject: [PATCH] Prefer direct copies for playback, saves and exports within verified device groups --- frontend/app.js | 41 +++++++++++++++++++++++++++++++------ frontend/direct-media.js | 35 ++++++++++++++++++++++++++----- frontend/direct-protocol.js | 1 + frontend/p2p-client.js | 3 ++- server/direct-relay.js | 4 ++++ server/p2p-hub.js | 26 ++++++++++++++++++++++- server/p2p-hub.test.js | 16 +++++++++++++++ server/server.js | 13 +++++++++++- 8 files changed, 125 insertions(+), 14 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index 7e64f63..b188657 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -1795,18 +1795,24 @@ function recordDeviceFile(id, res) { const state = cid ? (res.expectedSha === cid ? 'verified' : 'unverified') : 'unhashed'; const now = Date.now(); window.DeviceDB.putFile({ videoId: id, cid, size: (res && res.size) || 0, savedAt: now, lastCheckedAt: now, state }) - .then(() => { if (window.P2PClient) window.P2PClient.changed(); }) + .then(() => { if (window.P2PClient) window.P2PClient.changed(); window.DirectMedia?.publish(); }) .catch(() => {}); } // Download a video into the permanent offline cache. Safe to call repeatedly. -async function preload(video, { quiet = false, mux = false, retry = false } = {}) { +async function preload(video, { quiet = false, mux = false, retry = false, directFallback = false } = {}) { const id = video.id; // Custom (edited) videos have no YouTube source to (re)download — their // media is produced once by the editor. Never route them through the normal // cache-download path (a fake edit_… id would 404 on /api/download). if (video.custom) return; if (!id || cachedIds.has(id) || downloading.has(id)) return; + if (!directFallback && window.DirectMedia?.hasSource(id)) { + const received = await DirectMedia.obtain(id); + if (received) return; + toast('Direct transfer did not complete. Choose Use server copy in the transfer message to download through the server.'); + return; + } downloading.add(id); downloadMeta.set(id, slim(video)); markCardCacheState(id, 'downloading'); @@ -2259,6 +2265,11 @@ const Player = { _currentBlobUrl = null; } try { + if (WEB && !cachedIds.has(videoObj.id) && window.DirectMedia?.hasSource(videoObj.id)) { + const received = await DirectMedia.obtain(videoObj.id); + if (seq !== this._loadSeq) return; + if (!received) { showSpinner(false); return; } + } // Play from the offline cache when available — instant and works offline. if (!preferStream && cachedIds.has(videoObj.id)) { let localUrl = null; @@ -3104,6 +3115,7 @@ async function renderAvailability(id) { // Fetch a verified copy from another device (docs/p2p-architecture.md flow 6). // Download-then-play: on success the file is saved offline like any save. async function getFromPeers(videoObj, c, peers, onProgress) { + if (window.DirectMedia?.enabled()) { const ok = await DirectMedia.obtain(videoObj.id); if (!ok) toast('Pair the source device through Remote or use the same profile to receive its saved copy.'); return !!ok; } const id = videoObj.id; downloading.add(id); markCardCacheState(id, 'downloading'); @@ -4781,6 +4793,7 @@ const Remote = (() => { const meta = current && current.meta; const M = Player.master; return { + directAvailable: !!meta && cachedIds.has(meta.id) && DirectMedia.enabled() && data.settings.p2pShare !== false, v: meta ? { id: meta.id, title: meta.title || '', channel: meta.channel || '', thumbnail: meta.thumbnail || '' } : null, cur: M ? Math.floor((M.currentTime || 0) * 10) / 10 : 0, dur: (M && isFinite(M.duration) && M.duration) || (meta && meta.duration) || 0, @@ -5063,7 +5076,7 @@ const Remote = (() => { } function onRemoteMessage(m) { - DirectMedia.attach('remote-client', { send: rcSend, peers: () => [{ id: 'host', name: 'Paired screen' }] }); + DirectMedia.attach('remote-client', { send: rcSend, peers: () => [{ id: 'host', name: 'Paired screen', files: rc.state?.directAvailable && rc.state?.v ? [rc.state.v.id] : [] }] }); if (m.type === 'direct') { DirectMedia.handle('remote-client', m); return; } if (m.type === 'hello') { rc.online = true; @@ -5103,6 +5116,11 @@ const Remote = (() => { const v = s && s.v; $('rvTitle').textContent = v ? v.title : 'Nothing playing'; $('rvChannel').textContent = v ? v.channel : ''; + let directPlay = $('rvDirectPlay'); + if (!directPlay) { directPlay = document.createElement('button'); directPlay.id = 'rvDirectPlay'; directPlay.className = 'np-btn'; directPlay.style.minHeight = '44px'; directPlay.textContent = 'Play here from paired screen'; $('rvChannel').after(directPlay); } + directPlay.hidden = !v || !s.directAvailable || !DirectMedia.enabled(); + directPlay.onclick = () => v && playVideoAt({ ...v, duration: s.dur || 0 }, liveCur()); + const thumb = $('rvThumb'); if (v && v.thumbnail) { if (thumb.getAttribute('src') !== v.thumbnail) thumb.src = v.thumbnail; thumb.classList.remove('hidden'); } else thumb.classList.add('hidden'); @@ -6073,6 +6091,8 @@ const Presenter = (() => { st.ws = ws; ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } + if (!overlay) { DirectMedia.attach('presenter', { send: m => ws.send(JSON.stringify(m)), peers: () => [{ id: 'host', name: st.hostName || 'Screen' }] }); } + if (m.type === 'direct') { if (!overlay) DirectMedia.handle('presenter', m); return; } if (m.type === 'hello') { st.hostName = m.hostName || 'screen'; if (m.state) onState(m.state); else setStatus(`Following ${st.hostName}`); } else if (m.type === 'state') onState(m.state); else if (m.type === 'host-offline') { if (overlay) { st.all = []; LowerThird.paint([], -1); } setStatus(`${st.hostName || 'The screen'} is offline — waiting…`); } @@ -7456,7 +7476,7 @@ const Party = (() => { } function onMessage(m) { - DirectMedia.attach('party', { send, peers: () => st.members.filter(m => m.pid !== pid).map(m => ({ id: m.pid, name: m.name })) }); + DirectMedia.attach('party', { send, peers: () => st.members.filter(m => m.pid !== pid).map(m => ({ id: m.pid, name: m.name, files: m.host && st.state?.directAvailable && st.state?.v ? [st.state.v.id] : [] })) }); if (m.type === 'direct') { DirectMedia.handle('party', m); return; } if (m.type === 'hello') { st.code = m.code; @@ -7507,6 +7527,7 @@ const Party = (() => { const meta = current && current.meta; const M = Player.master; return { + directAvailable: !!meta && cachedIds.has(meta.id) && DirectMedia.enabled() && data.settings.p2pShare !== false, v: meta ? { id: meta.id, title: meta.title || '', channel: meta.channel || '', thumbnail: meta.thumbnail || '', duration: meta.duration || 0 } : null, cur: M ? M.currentTime || 0 : 0, paused: !M || M.paused, @@ -12533,10 +12554,18 @@ async function boot() { Party.boot(); PartyDJ.register(() => $('navPartyBtn').click()); SetlistImport.configure({ library: async () => { let uploads = []; try { const j = await webFetch('/api/uploads?limit=200'); uploads = j.uploads || []; } catch {} return [...data.history, ...data.playlists.flatMap(p => p.videos || []), ...uploads]; }, search: async q => (await API.search(q)).results || [], modal: showModal, close: closeModal, create: (name, videos) => { const pl = { id: uid(), name, videos: videos.map(v => ({ ...slim(v), tags: v.tags, key: v.key, chordPro: v.chordPro })) }; data.playlists.push(pl); persist(); view = { type: 'playlist', id: pl.id }; render(); toast(`Imported ${videos.length} songs`); } }); - DirectMedia.configure({ settings: () => data.settings, persist, current: () => current?.meta, notify: toast, + DirectMedia.configure({ settings: () => data.settings, persist, current: () => current?.meta, meta: id => data.history.find(v => v.id === id) || data.playlists.flatMap(p => p.videos).find(v => v.id === id), notify: toast, saved: async file => { cachedIds.add(file.id); await DeviceDB.putFile({ videoId: file.id, cid: file.cid, size: file.size, savedAt: Date.now(), state: 'unverified' }); if (!data.history.some(v => v.id === file.id)) data.history.unshift({ ...file, thumbnail: '' }); persist(); renderSidebar(); }, fallback: file => { const body = document.createElement('p'); body.textContent = 'The devices could not finish a direct connection. Keep both screens open and send again to resume, or choose the existing server download.'; showModal('Direct transfer unavailable', body, [{ label: 'Keep partial copy', onClick: closeModal }, { label: 'Use server copy', onClick: () => { closeModal(); preload(file, { retry: true, directFallback: true }); } }]); }, }); + // Resolve peer bytes before opening the existing native export sheet. + // The sheet itself and its user-gesture actions remain unchanged. + const exportWithExistingSheet = exportToDevice; + exportToDevice = async (...args) => { + const video = args[0]; + if (video?.id && !cachedIds.has(video.id) && DirectMedia.hasSource(video.id) && !await DirectMedia.obtain(video.id)) return; + return exportWithExistingSheet(...args); + }; Remote.boot(); LyricsDisplay.register(); LowerThird.register(() => $('remoteChip').click()); @@ -12571,7 +12600,7 @@ async function boot() { startCacheResyncWatch(); // Peer-to-peer: report what this device holds (on by default; settings.p2pShare). if (WEB && window.P2PClient) { - window.P2PClient.start({ getSettings: () => data.settings, getProfile: () => (data.profile && data.profile.name) || '' }); + window.P2PClient.start({ getSettings: () => data.settings, getProfile: () => (data.profile && data.profile.name) || '', getProfileSecret: () => ProfileSecret.getFor(data.profile?.name) }); if (window.P2PTransfer) window.P2PTransfer.start({ canShare: () => data.settings.p2pShare !== false }); } data.playlists.forEach(preloadPlaylist); diff --git a/frontend/direct-media.js b/frontend/direct-media.js index 7077115..069dcaa 100644 --- a/frontend/direct-media.js +++ b/frontend/direct-media.js @@ -1,6 +1,8 @@ /* Paired-room transport; signalling only goes to the server. */ (function (root) { 'use strict'; + const waiting = new Map(); + let profilePeers = []; const rooms = new Map(), transfers = new Map(), pending = new Map(); let hooks = { settings: () => ({}), persist() {}, current: () => null, saved() {}, notify() {}, fallback() {} }; const enabled = () => hooks.settings().directTransfer !== false && !!root.RTCPeerConnection; @@ -24,7 +26,7 @@ if (s.done) return; s.done = true; clearTimeout(s.timer); clearTimeout(s.idle); s.pc?.close(); transfers.delete(s.token); if (s.worker) { s.worker.postMessage({ op: 'abort' }); setTimeout(() => s.worker.terminate(), 1000); } - if (error) { hooks.notify(`${error}. Partial copy kept for resume.`); hooks.fallback(s.claim, () => offer(s.key, s.peer, s.claim).catch(e => hooks.notify(e.message))); } + if (error) { waiting.get(s.claim.id)?.resolve(false); waiting.delete(s.claim.id); hooks.notify(`${error}. Partial copy kept for resume.`); hooks.fallback(s.claim, () => offer(s.key, s.peer, s.claim).catch(e => hooks.notify(e.message))); } } function connection(s, receiver) { const pc = s.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }); @@ -77,13 +79,14 @@ const offer = await s.pc.createOffer(); await s.pc.setLocalDescription(offer); signal(key, m.token, { kind: 'offer', sdp: offer.sdp }); } else if (result.op === 'done') { send(key, { type: 'direct', action: 'complete', token: m.token }); - await hooks.saved(result.file); hooks.notify(`Saved ${result.file.title} directly from the other device`); finish(s); + await hooks.saved(result.file); waiting.get(result.file.id)?.resolve(true); waiting.delete(result.file.id); publish(); hooks.notify(`Saved ${result.file.title} directly from the other device`); finish(s); } else if (result.op === 'error') finish(s, result.error); }; s.worker.postMessage({ op: 'open', file: m.file }); } async function handle(key, m) { if (m.type !== 'direct') return; + if (m.action === 'request') { if (enabled()) offer(key, m.from, hooks.meta?.(m.id) || { id: m.id, title: m.id }).catch(e => hooks.notify(e.message)); return; } try { if (m.action === 'invite') { if (!enabled() || !root.DirectProtocol.file(m.file)) return send(key, { type: 'direct', action: 'decline', token: m.token }); @@ -114,15 +117,37 @@ const text = document.createElement('p'); text.textContent = `${m.file.title} · ${(m.file.size / 1024 ** 2).toFixed(1)} MB. Media travels directly between devices. Keep both screens open. Playback starts after the full copy is verified.`; const yes = document.createElement('button'); yes.textContent = 'Accept & save'; const no = document.createElement('button'); no.textContent = 'Decline'; - const decline = () => { send(key, { type: 'direct', action: 'decline', token: m.token }); dialog.remove(); }; + const decline = () => { waiting.get(m.file.id)?.resolve(false); waiting.delete(m.file.id); send(key, { type: 'direct', action: 'decline', token: m.token }); dialog.remove(); }; yes.onclick = () => { dialog.remove(); accept(key, m).catch(e => hooks.notify(e.message)); }; no.onclick = decline; dialog.oncancel = decline; dialog.append(heading, text, yes, no); document.body.append(dialog); dialog.showModal(); } + async function publish() { + if (!root.P2PClient?.isConnected()) return; + const files = await root.OPFS.listVideos({ strict: true }); + root.P2PClient.send({ type: 'direct-inventory', ids: enabled() && hooks.settings().p2pShare !== false ? files.map(f => f.id) : [] }); + } + function source(id) { if (!enabled()) return null; for (const [key, room] of rooms) { const peer = room.peers().find(p => p.files?.includes(id)); if (peer) return { key, peer }; } return null; } + function hasSource(id) { return !!source(id); } + function obtain(id) { + if (!hasSource(id)) return Promise.resolve(null); + if (waiting.has(id)) return waiting.get(id).promise; + const { key, peer } = source(id); + let resolve; const promise = new Promise(r => { resolve = r; }); + waiting.set(id, { promise, resolve }); + send(key, { type: 'direct', action: 'request', to: peer.id, id }); + setTimeout(() => { if (waiting.get(id)?.promise === promise) { waiting.delete(id); resolve(false); hooks.notify('The other device did not accept or is unavailable'); hooks.fallback(hooks.meta?.(id) || { id, title: id }); } }, 60000); + return promise; + } function configure(h) { hooks = { ...hooks, ...h }; + if (root.P2PClient) { + root.P2PClient.onMessage('hello', () => { attach('profile', { send: root.P2PClient.send, peers: () => profilePeers }); publish(); }); + root.P2PClient.onMessage('direct-peers', m => { profilePeers = m.peers || []; }); + root.P2PClient.onMessage('direct', m => handle('profile', m)); + } root.SettingsSections.register({ id: 'direct-transfer', title: 'Direct device transfer', cluster: 'Library & storage', summary: () => enabled() ? 'On · paired devices only' : 'Off or unsupported', icon: '', render(container) { const label = document.createElement('label'); const toggle = document.createElement('input'); toggle.type = 'checkbox'; toggle.checked = hooks.settings().directTransfer !== false; - toggle.onchange = () => { hooks.settings().directTransfer = toggle.checked; hooks.persist(); }; label.append(toggle, ' Direct device transfer (P2P)'); + toggle.onchange = () => { hooks.settings().directTransfer = toggle.checked; hooks.persist(); publish(); }; label.append(toggle, ' Direct device transfer (P2P)'); const help = document.createElement('p'); help.textContent = 'Pair through Remote or join a Watch Party, then send your currently playing saved song. The receiver confirms before saving. Keep both devices open. No TURN relay is used.'; container.append(label, help); for (const [key, r] of rooms) for (const peer of r.peers()) { @@ -130,5 +155,5 @@ } } }); } - root.DirectMedia = { configure, attach, handle, offer, enabled }; + root.DirectMedia = { configure, attach, handle, offer, enabled, obtain, hasSource, publish }; }(globalThis)); diff --git a/frontend/direct-protocol.js b/frontend/direct-protocol.js index a60c3b0..3e8620e 100644 --- a/frontend/direct-protocol.js +++ b/frontend/direct-protocol.js @@ -13,6 +13,7 @@ if (typeof value === 'string') { if (value.length > 65536) return null; try { value = JSON.parse(value); } catch { return null; } } if (!value || typeof value !== 'object' || Array.isArray(value) || value.type !== 'direct') return null; const m = { type: 'direct', action: value.action }; + if (value.action === 'request') return id(value.to) && id(value.id) ? { ...m, to: value.to, id: value.id } : null; if (value.action === 'invite') { const f = file(value.file); if (!id(value.to) || !f) return null; return { ...m, to: value.to, file: f }; diff --git a/frontend/p2p-client.js b/frontend/p2p-client.js index 09e9d54..7ab5834 100644 --- a/frontend/p2p-client.js +++ b/frontend/p2p-client.js @@ -216,7 +216,7 @@ ws = sock; let ping = null; sock.onopen = () => { - sock.send(JSON.stringify({ type: 'auth', device: d.deviceId, secret: d.secret })); + sock.send(JSON.stringify({ type: 'auth', device: d.deviceId, secret: d.secret, profile: hooks.getProfile(), profileSecret: hooks.getProfileSecret?.() || '' })); // The server drops sockets idle for 120 s (server.js websocketHandler). ping = setInterval(() => { try { sock.send('{"type":"ping"}'); } catch { /* closing */ } }, 50_000); }; @@ -304,6 +304,7 @@ window.P2PClient = { start, changed, sync, device, authHeaders, config, contribute, unknownVideos, + send: m => { if (!ws || ws.readyState !== 1) return false; ws.send(JSON.stringify(m)); return true; }, onMessage, signal, peer: () => myPeer, isConnected: () => !!(ws && ws.readyState === 1 && myPeer), }; }()); diff --git a/server/direct-relay.js b/server/direct-relay.js index 2fd8f85..e53df7f 100644 --- a/server/direct-relay.js +++ b/server/direct-relay.js @@ -14,6 +14,10 @@ export function createDirectRelay({ now = Date.now } = {}) { const r = rate && t - rate.start < 60000 ? rate : { start: t, count: 0 }; rates.set(from, r); if (++r.count > 240) return false; + if (m.action === 'request') { + if (m.to === from || !peers.has(m.to)) return false; + send(peers.get(m.to), { type: 'direct', action: 'request', from, id: m.id }); return true; + } if (m.action === 'invite') { if (m.to === from || !peers.has(m.to) || transfers.size >= 64) return false; const token = randomBytes(24).toString('base64url'); diff --git a/server/p2p-hub.js b/server/p2p-hub.js index 7929917..60116b6 100644 --- a/server/p2p-hub.js +++ b/server/p2p-hub.js @@ -15,6 +15,7 @@ * reports both: `online` (now) and `lastVerifiedAt` / `stale` (history). * Peers are addressed by an opaque id (peerIdOf), never by device id. * ========================================================================== */ +import { createDirectRelay } from './direct-relay.js'; import { createHash, timingSafeEqual } from 'node:crypto'; const sha = (s) => createHash('sha256').update(String(s)).digest('hex'); @@ -24,7 +25,8 @@ export const peerIdOf = (deviceId) => sha('peer:' + deviceId).slice(0, 12); const MAX_MSG = 64 * 1024; const BUDGET_PER_MIN = 300; -export function createP2pHub({ getDevice, enabled = () => true, now = () => Date.now(), log = console } = {}) { +export function createP2pHub({ getDevice, authorizeProfile = async () => '', enabled = () => true, now = () => Date.now(), log = console } = {}) { + const directRooms = new Map(); const online = new Map(); // deviceId -> ws const byPeer = new Map(); // peer -> deviceId (online only) @@ -46,6 +48,8 @@ export function createP2pHub({ getDevice, enabled = () => true, now = () => Date const secret = String(m.secret || ''); 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; } + ws.data.profile = await authorizeProfile(m.profile, m.profileSecret, deviceId).catch(() => ''); + ws.data.inventory = []; ws.data.authed = true; ws.data.deviceId = deviceId; ws.data.peer = peerIdOf(deviceId); @@ -54,6 +58,14 @@ export function createP2pHub({ getDevice, enabled = () => true, now = () => Date online.set(deviceId, ws); byPeer.set(ws.data.peer, deviceId); send(ws, { type: 'hello', peer: ws.data.peer }); + directory(ws.data.profile); + } + + function directory(profile) { + if (!profile) return; + const sockets = [...online.values()].filter(ws => ws.data.profile === profile); + const peers = sockets.map(ws => ({ id: ws.data.peer, name: 'Device ' + ws.data.peer.slice(-4), files: ws.data.inventory || [] })); + for (const ws of sockets) send(ws, { type: 'direct-peers', peers: peers.filter(p => p.id !== ws.data.peer) }); } async function message(ws, raw) { @@ -66,6 +78,16 @@ export function createP2pHub({ getDevice, enabled = () => true, now = () => Date let m; try { m = JSON.parse(s); } catch { return; } if (!ws.data.authed) { if (m && m.type === 'auth' && !ws.data.authing) { ws.data.authing = true; await auth(ws, m); } return; } + if (m?.type === 'direct-inventory' && ws.data.profile) { + ws.data.inventory = Array.isArray(m.ids) ? [...new Set(m.ids.filter(id => typeof id === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(id)))].slice(0, 1000) : []; + directory(ws.data.profile); return; + } + if (m?.type === 'direct' && ws.data.profile) { + const profile = ws.data.profile; + const peers = new Map([...online.values()].filter(x => x.data.profile === profile).map(x => [x.data.peer, x])); + if (!directRooms.has(profile)) directRooms.set(profile, createDirectRelay()); + directRooms.get(profile).handle(ws.data.peer, m, peers, send); return; + } if (m && m.type === 'signal' && typeof m.to === 'string') { const target = online.get(byPeer.get(m.to)); if (!target || target === ws) { send(ws, { type: 'error', error: 'peer offline', to: m.to }); return; } @@ -78,6 +100,8 @@ export function createP2pHub({ getDevice, enabled = () => true, now = () => Date if (deviceId && online.get(deviceId) === ws) { online.delete(deviceId); byPeer.delete(peer); + directory(ws.data.profile); + if (![...online.values()].some(x => x.data.profile === ws.data.profile)) directRooms.delete(ws.data.profile); } } diff --git a/server/p2p-hub.test.js b/server/p2p-hub.test.js index ceaa6f1..afca7f2 100644 --- a/server/p2p-hub.test.js +++ b/server/p2p-hub.test.js @@ -103,3 +103,19 @@ test('rehydrator asks one online sharing holder, once per 10 min, only when the expect(await rehydrate('dQw4w9WgXcQ')).toBe(false); // server copy is back expect(await rehydrate('unknownVid1')).toBe(false); // nobody holds it }); + +test('direct directory and invitations are restricted to authorized profile sockets', async () => { + const hub = createP2pHub({ getDevice: p2pDb.getDevice, authorizeProfile: async (name, secret) => secret === 'proof' ? name : '' }); + const a = fakeWs({ budget: [] }), b = fakeWs({ budget: [] }), c = fakeWs({ budget: [] }); + for (const [ws, id, secret, profile] of [[a, 'a', SECRET_A, 'shared'], [b, 'b', SECRET_B, 'shared'], [c, 'c', 'x', 'different']]) { + await hub.websocket.message(ws, JSON.stringify({ type: 'auth', device: 'dev_000000000000000' + id, secret, profile, profileSecret: 'proof' })); + } + await hub.websocket.message(a, JSON.stringify({ type: 'direct-inventory', ids: ['localSong'] })); + expect(b.sent.at(-1).peers[0].files).toEqual(['localSong']); + expect(c.sent.at(-1).peers).toEqual([]); + const before = c.sent.length; + await hub.websocket.message(a, JSON.stringify({ type: 'direct', action: 'request', to: peerIdOf('dev_000000000000000c'), id: 'localSong' })); + expect(c.sent.length).toBe(before); + await hub.websocket.message(b, JSON.stringify({ type: 'direct', action: 'request', to: peerIdOf('dev_000000000000000a'), id: 'localSong' })); + expect(a.sent.at(-1).action).toBe('request'); +}); diff --git a/server/server.js b/server/server.js index a27aa65..792d6c6 100644 --- a/server/server.js +++ b/server/server.js @@ -2338,7 +2338,18 @@ async function fileForCid(cid) { return (await f.exists()) ? { path, size: f.size } : null; } const p2p = registerP2pRoutes(app, { cfg: P2P, p2pDb, fileForCid, sha256Range }); -const p2pHub = createP2pHub({ getDevice: p2pDb.getDevice, enabled: () => P2P.enabled }); +const directProfileFails = new Map(); +const p2pHub = createP2pHub({ getDevice: p2pDb.getDevice, enabled: () => P2P.enabled, authorizeProfile: async (name, secret, device) => { + name = typeof name === 'string' ? name.trim().toLowerCase() : ''; + if (!name || name.length > 80) return ''; + const row = await getProfile(name); if (!row) return ''; + if (!row.secretHash) return name; + const key = name + ':' + device, old = directProfileFails.get(key); + if (old && old.count >= 10 && Date.now() - old.at < 900000) return ''; + if (typeof secret === 'string' && secret.length <= 1024 && await Bun.password.verify(secret, row.secretHash).catch(() => false)) { directProfileFails.delete(key); return name; } + directProfileFails.set(key, { count: old && Date.now() - old.at < 900000 ? old.count + 1 : 1, at: Date.now() }); + if (directProfileFails.size > 5000) directProfileFails.clear(); return ''; +} }); // GET /api/p2p/holders?v=|cid= — who holds a copy. Rows are // persistent; `stale` flags a holder not re-verified for P2P_STALE_DAYS.