Authorize direct file invitations within paired rooms and parties
This commit is contained in:
39
frontend/direct-protocol.js
Normal file
39
frontend/direct-protocol.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/* Shared bounded control protocol: media bytes belong exclusively on DataChannel. */
|
||||||
|
(function (root) {
|
||||||
|
'use strict';
|
||||||
|
const CHUNK = 65536;
|
||||||
|
const text = (v, n) => typeof v === 'string' && v.length <= n && !/[\u0000-\u001f]/.test(v);
|
||||||
|
const id = (v) => text(v, 128) && /^[A-Za-z0-9_-]+$/.test(v);
|
||||||
|
function file(v) {
|
||||||
|
if (!v || !id(v.id) || !/^[a-f0-9]{64}$/.test(v.cid) || !Number.isSafeInteger(v.size) || v.size <= 0 || v.size > 4 * 1024 ** 3) return null;
|
||||||
|
if (!text(v.title, 300) || !text(v.channel || '', 200)) return null;
|
||||||
|
return { id: v.id, cid: v.cid, size: v.size, title: v.title, channel: v.channel || '', duration: Number.isFinite(v.duration) && v.duration >= 0 ? v.duration : 0 };
|
||||||
|
}
|
||||||
|
function parse(value) {
|
||||||
|
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 === 'invite') {
|
||||||
|
const f = file(value.file); if (!id(value.to) || !f) return null;
|
||||||
|
return { ...m, to: value.to, file: f };
|
||||||
|
}
|
||||||
|
if (!['accept', 'decline', 'signal', 'complete'].includes(value.action) || !id(value.token)) return null;
|
||||||
|
m.token = value.token;
|
||||||
|
if (value.action === 'signal') {
|
||||||
|
const d = value.data;
|
||||||
|
if (!d || typeof d !== 'object') return null;
|
||||||
|
if (['offer', 'answer'].includes(d.kind) && typeof d.sdp === 'string' && d.sdp.length <= 60000) m.data = { kind: d.kind, sdp: d.sdp };
|
||||||
|
else if (d.kind === 'ice' && d.candidate && text(d.candidate.candidate, 2048)) {
|
||||||
|
const c = d.candidate;
|
||||||
|
if (!(c.sdpMid == null || text(c.sdpMid, 64)) || !(c.sdpMLineIndex == null || Number.isInteger(c.sdpMLineIndex) && c.sdpMLineIndex >= 0 && c.sdpMLineIndex < 100)) return null;
|
||||||
|
m.data = { kind: 'ice', candidate: { candidate: c.candidate, sdpMid: c.sdpMid ?? null, sdpMLineIndex: c.sdpMLineIndex ?? null } };
|
||||||
|
} else return null;
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
function offset(v, size) { if (!Number.isSafeInteger(v) || v < 0 || v > size) throw new Error('Invalid resume offset'); return v; }
|
||||||
|
function nextChunk(pos, size) { offset(pos, size); return { start: pos, end: Math.min(size, pos + CHUNK) }; }
|
||||||
|
const api = { CHUNK, file, parse, offset, nextChunk };
|
||||||
|
if (typeof module !== 'undefined') module.exports = api;
|
||||||
|
root.DirectProtocol = api;
|
||||||
|
}(typeof globalThis !== 'undefined' ? globalThis : this));
|
||||||
18
frontend/direct-protocol.test.js
Normal file
18
frontend/direct-protocol.test.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const p = require('./direct-protocol.js');
|
||||||
|
test('direct protocol strips unexpected payload and validates file claims', () => {
|
||||||
|
const m = p.parse({ type: 'direct', action: 'invite', to: 'peer', file: { id: 'video', title: 'Song', cid: 'a'.repeat(64), size: 10, bytes: 'secret' }, bytes: 'not relayed' });
|
||||||
|
assert.equal(m.file.size, 10); assert.equal(m.bytes, undefined); assert.equal(m.file.bytes, undefined);
|
||||||
|
assert.equal(p.parse({ ...m, file: { ...m.file, size: Infinity } }), null);
|
||||||
|
assert.equal(p.parse(new Uint8Array(4)), null);
|
||||||
|
});
|
||||||
|
test('chunks retain exact resume offset and never exceed file end', () => {
|
||||||
|
assert.deepEqual(p.nextChunk(17, 100000), { start: 17, end: 65553 });
|
||||||
|
assert.deepEqual(p.nextChunk(99999, 100000), { start: 99999, end: 100000 });
|
||||||
|
for (const n of [-1, 100001, 0.5, NaN]) assert.throws(() => p.offset(n, 100000));
|
||||||
|
});
|
||||||
|
test('signalling only admits bounded SDP or ICE, never byte frames', () => {
|
||||||
|
assert.equal(p.parse({ type: 'direct', action: 'signal', token: 'abc', data: { kind: 'bytes', data: [1, 2] } }), null);
|
||||||
|
assert.equal(p.parse({ type: 'direct', action: 'signal', token: 'abc', data: { kind: 'offer', sdp: 'x'.repeat(60001) } }), null);
|
||||||
|
});
|
||||||
38
server/direct-relay.js
Normal file
38
server/direct-relay.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import protocol from '../frontend/direct-protocol.js';
|
||||||
|
|
||||||
|
// One instance per authenticated room. It has no media endpoint or binary path.
|
||||||
|
export function createDirectRelay({ now = Date.now } = {}) {
|
||||||
|
const transfers = new Map();
|
||||||
|
const rates = new Map();
|
||||||
|
function handle(from, raw, peers, send) {
|
||||||
|
const m = protocol.parse(raw);
|
||||||
|
if (!m || !peers.has(from)) return false;
|
||||||
|
const t = now();
|
||||||
|
for (const [token, x] of transfers) if (x.expires < t || !peers.has(x.from) || !peers.has(x.to)) transfers.delete(token);
|
||||||
|
const rate = rates.get(from);
|
||||||
|
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 === 'invite') {
|
||||||
|
if (m.to === from || !peers.has(m.to) || transfers.size >= 64) return false;
|
||||||
|
const token = randomBytes(24).toString('base64url');
|
||||||
|
transfers.set(token, { from, to: m.to, file: m.file, accepted: false, expires: t + 60000 });
|
||||||
|
send(peers.get(m.to), { type: 'direct', action: 'invite', from, token, file: m.file });
|
||||||
|
send(peers.get(from), { type: 'direct', action: 'invited', to: m.to, token, file: m.file });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const x = transfers.get(m.token);
|
||||||
|
if (!x || ![x.from, x.to].includes(from)) return false;
|
||||||
|
const target = from === x.from ? x.to : x.from;
|
||||||
|
if (m.action === 'accept') {
|
||||||
|
if (from !== x.to || x.accepted) return false;
|
||||||
|
x.accepted = true;
|
||||||
|
x.expires = t + 30 * 60000;
|
||||||
|
} else if (m.action === 'signal' && !x.accepted) return false;
|
||||||
|
else if (m.action === 'decline' || m.action === 'complete') transfers.delete(m.token);
|
||||||
|
send(peers.get(target), { ...m, from });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return { handle };
|
||||||
|
}
|
||||||
23
server/direct-relay.test.js
Normal file
23
server/direct-relay.test.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { test, expect } from 'bun:test';
|
||||||
|
import { createDirectRelay } from './direct-relay.js';
|
||||||
|
test('room membership, receiver acceptance and one-use invitation bind signalling', () => {
|
||||||
|
let clock = 0; const relay = createDirectRelay({ now: () => clock });
|
||||||
|
const peers = new Map([['host', 'hostSocket'], ['guest', 'guestSocket'], ['other', 'otherSocket']]);
|
||||||
|
const out = []; const send = (ws, m) => out.push({ ws, m });
|
||||||
|
const invite = { type: 'direct', action: 'invite', to: 'guest', file: { id: 'song', title: 'Song', cid: 'a'.repeat(64), size: 123 } };
|
||||||
|
expect(relay.handle('outsider', invite, peers, send)).toBe(false);
|
||||||
|
expect(relay.handle('host', { ...invite, to: 'outsider' }, peers, send)).toBe(false);
|
||||||
|
expect(relay.handle('host', invite, peers, send)).toBe(true);
|
||||||
|
const token = out[0].m.token;
|
||||||
|
const signal = { type: 'direct', action: 'signal', token, data: { kind: 'offer', sdp: 'valid' } };
|
||||||
|
expect(relay.handle('host', signal, peers, send)).toBe(false);
|
||||||
|
expect(relay.handle('other', { type: 'direct', action: 'accept', token }, peers, send)).toBe(false);
|
||||||
|
expect(relay.handle('guest', { type: 'direct', action: 'accept', token }, peers, send)).toBe(true);
|
||||||
|
expect(relay.handle('guest', { type: 'direct', action: 'accept', token }, peers, send)).toBe(false);
|
||||||
|
expect(relay.handle('other', signal, peers, send)).toBe(false);
|
||||||
|
expect(relay.handle('host', signal, peers, send)).toBe(true);
|
||||||
|
relay.handle('guest', { type: 'direct', action: 'complete', token }, peers, send);
|
||||||
|
expect(relay.handle('host', signal, peers, send)).toBe(false);
|
||||||
|
relay.handle('host', invite, peers, send); clock = 61000;
|
||||||
|
expect(relay.handle('guest', { type: 'direct', action: 'accept', token: out.at(-1).m.token }, peers, send)).toBe(false);
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
* link); the host resumes it with the secret it was given.
|
* link); the host resumes it with the secret it was given.
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { createDirectRelay } from './direct-relay.js';
|
||||||
import { createDJQueue, changeDJQueue, queueSnapshot, takeDJVideo } from './party-dj.js';
|
import { createDJQueue, changeDJQueue, queueSnapshot, takeDJVideo } from './party-dj.js';
|
||||||
import { randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
|
import { randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
@@ -89,7 +90,7 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
|
|||||||
if (p && !same(p.secret, d.secret)) { ws.close(4003, 'not the host of this party'); return; }
|
if (p && !same(p.secret, d.secret)) { ws.close(4003, 'not the host of this party'); return; }
|
||||||
if (!p) {
|
if (!p) {
|
||||||
const code = newCode();
|
const code = newCode();
|
||||||
p = { code, secret: randomBytes(18).toString('base64url'), hostPid: d.pid, members: new Map(), state: null, chat: [], dj: createDJQueue(), allowControl: false, hostOnline: true, hostLeftAt: 0 };
|
p = { direct: createDirectRelay(), code, secret: randomBytes(18).toString('base64url'), hostPid: d.pid, members: new Map(), state: null, chat: [], dj: createDJQueue(), allowControl: false, hostOnline: true, hostLeftAt: 0 };
|
||||||
parties.set(code, p);
|
parties.set(code, p);
|
||||||
}
|
}
|
||||||
p.hostPid = d.pid;
|
p.hostPid = d.pid;
|
||||||
@@ -118,7 +119,9 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
|
|||||||
const me = p && p.members.get(d.pid);
|
const me = p && p.members.get(d.pid);
|
||||||
if (!me || me.ws !== ws || !m || typeof m !== 'object') return;
|
if (!me || me.ws !== ws || !m || typeof m !== 'object') return;
|
||||||
const isHost = d.pid === p.hostPid;
|
const isHost = d.pid === p.hostPid;
|
||||||
if (m.type === 'dj') {
|
if (m.type === 'direct') {
|
||||||
|
p.direct.handle(me.pid, m, new Map([...p.members].map(([pid, member]) => [pid, member.ws])), send);
|
||||||
|
} else if (m.type === 'dj') {
|
||||||
if (m.action === 'take' && isHost) { const video = takeDJVideo(p.dj); send(ws, { type: 'dj-play', video }); if (video) broadcast(p, { type: 'dj', ...queueSnapshot(p.dj) }); }
|
if (m.action === 'take' && isHost) { const video = takeDJVideo(p.dj); send(ws, { type: 'dj-play', video }); if (video) broadcast(p, { type: 'dj', ...queueSnapshot(p.dj) }); }
|
||||||
else if (changeDJQueue(p.dj, m, me.pid, isHost)) broadcast(p, { type: 'dj', ...queueSnapshot(p.dj) });
|
else if (changeDJQueue(p.dj, m, me.pid, isHost)) broadcast(p, { type: 'dj', ...queueSnapshot(p.dj) });
|
||||||
} else if (m.type === 'state' && isHost) {
|
} else if (m.type === 'state' && isHost) {
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
* reach the server from the same public IP (i.e. the same home/church Wi-Fi).
|
* reach the server from the same public IP (i.e. the same home/church Wi-Fi).
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { createDirectRelay } from './direct-relay.js';
|
||||||
|
|
||||||
import { createHash, createHmac, randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
|
import { createHash, createHmac, randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
export const REMOTE_COMMANDS = new Set([
|
export const REMOTE_COMMANDS = new Set([
|
||||||
@@ -145,7 +147,7 @@ export function createRemoteHub({ requireSameNetwork = false } = {}) {
|
|||||||
const id = roomIdFor(d.secret);
|
const id = roomIdFor(d.secret);
|
||||||
let room = rooms.get(id);
|
let room = rooms.get(id);
|
||||||
if (!room) {
|
if (!room) {
|
||||||
room = { id, secret: d.secret, host: null, hostName: d.name, hostIp: d.ip, remotes: new Map(), state: null, queue: null, code: null, codeExpires: 0, lastSeen: Date.now() };
|
room = { direct: createDirectRelay(), id, secret: d.secret, host: null, hostName: d.name, hostIp: d.ip, remotes: new Map(), state: null, queue: null, code: null, codeExpires: 0, lastSeen: Date.now() };
|
||||||
rooms.set(id, room);
|
rooms.set(id, room);
|
||||||
}
|
}
|
||||||
if (room.host && room.host !== ws) {
|
if (room.host && room.host !== ws) {
|
||||||
@@ -158,7 +160,7 @@ export function createRemoteHub({ requireSameNetwork = false } = {}) {
|
|||||||
d.roomId = id;
|
d.roomId = id;
|
||||||
issueCode(room);
|
issueCode(room);
|
||||||
send(ws, { type: 'hello', roomId: id, code: room.code, codeExpires: room.codeExpires, remotes: remoteList(room) });
|
send(ws, { type: 'hello', roomId: id, code: room.code, codeExpires: room.codeExpires, remotes: remoteList(room) });
|
||||||
for (const r of room.remotes.values()) send(r.ws, { type: 'hello', hostName: room.hostName, state: room.state, queue: room.queue });
|
for (const r of room.remotes.values()) send(r.ws, { type: 'hello', you: r.ws.data.rid, hostName: room.hostName, state: room.state, queue: room.queue });
|
||||||
}
|
}
|
||||||
|
|
||||||
function openRemote(ws) {
|
function openRemote(ws) {
|
||||||
@@ -172,7 +174,7 @@ export function createRemoteHub({ requireSameNetwork = false } = {}) {
|
|||||||
if (prev && prev.ws !== ws) { try { prev.ws.close(4000, 'replaced'); } catch { /* gone */ } }
|
if (prev && prev.ws !== ws) { try { prev.ws.close(4000, 'replaced'); } catch { /* gone */ } }
|
||||||
room.remotes.set(d.rid, { ws, name: d.name, since: Date.now() });
|
room.remotes.set(d.rid, { ws, name: d.name, since: Date.now() });
|
||||||
room.lastSeen = Date.now();
|
room.lastSeen = Date.now();
|
||||||
send(ws, { type: 'hello', hostName: room.hostName, state: room.state, queue: room.queue });
|
send(ws, { type: 'hello', you: d.rid, hostName: room.hostName, state: room.state, queue: room.queue });
|
||||||
send(room.host, { type: 'remotes', remotes: remoteList(room), joined: d.name });
|
send(room.host, { type: 'remotes', remotes: remoteList(room), joined: d.name });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +187,14 @@ export function createRemoteHub({ requireSameNetwork = false } = {}) {
|
|||||||
const room = rooms.get(d.roomId);
|
const room = rooms.get(d.roomId);
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
room.lastSeen = Date.now();
|
room.lastSeen = Date.now();
|
||||||
|
if (m.type === 'direct') {
|
||||||
|
const me = d.role === 'host' ? (room.host === ws ? 'host' : null) : (room.remotes.get(d.rid)?.ws === ws ? d.rid : null);
|
||||||
|
if (!me) return;
|
||||||
|
const peers = new Map([...room.remotes].map(([rid, r]) => [rid, r.ws]));
|
||||||
|
if (room.host) peers.set('host', room.host);
|
||||||
|
room.direct.handle(me, m, peers, send);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (d.role === 'host') {
|
if (d.role === 'host') {
|
||||||
if (room.host !== ws) return;
|
if (room.host !== ws) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user