/* ============================================================================ * remote.js — phone-as-remote relay for a desktop instance (TV / speakers) * * Both ends are ordinary browser tabs of this web app, so the server relays: * * desktop ("host") ──ws──▶ server ◀──ws── phone ("remote") * * Host: opens /ws/remote?role=host&secret=…&name=…. The secret is generated * and kept by the desktop (localStorage); the room id is derived from it, so * a reloaded TV tab — or a restarted server — gets the same room back. * Pairing: the host is shown a one-time 6-digit code (10 min). The phone POSTs * it to /api/remote/pair and receives { roomId, remoteId, token } where * token = HMAC(secret, remoteId). Nothing about a paired phone is stored on * the server: the token is re-checked against the connected host's secret on * every connect, so a server restart keeps phones paired, and "Unpair all" * on the desktop (a new secret) revokes every phone at once. * Remote: opens /ws/remote?role=remote&room=…&rid=…&token=…&name=…. * * Messages (JSON): * host → remotes {type:'state', state} {type:'queue', queue} {type:'pong', rid, t} * remote → host {type:'cmd', cmd, …args} {type:'ping', t} * server → host {type:'hello', roomId, code, codeExpires, remotes} * {type:'code', code, codeExpires} {type:'remotes', remotes} * {type:'cmd', from, …} {type:'ping', rid, t} * server → remote {type:'hello', hostName, state, queue} {type:'host-offline'} * * REMOTE_SAME_NETWORK=1 makes pairing require the phone and the desktop to * 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'; export const REMOTE_COMMANDS = new Set([ 'toggle', 'play', 'pause', 'next', 'prev', 'seek', 'volume', 'jump', 'remove', 'move', 'add', 'sleep', 'service', ]); const CODE_TTL_MS = 10 * 60_000; const ROOM_IDLE_MS = 60 * 60_000; const MAX_MSG = 256 * 1024; export function roomIdFor(secret) { return createHash('sha256').update('ytp-remote:' + secret).digest('hex').slice(0, 20); } export function remoteToken(secret, remoteId) { return createHmac('sha256', secret).update(remoteId).digest('base64url'); } function sameToken(a, b) { const x = Buffer.from(String(a)), y = Buffer.from(String(b)); return x.length === y.length && timingSafeEqual(x, y); } const cleanName = (v, fallback) => String(v || '').replace(/[\u0000-\u001f\u007f<>]+/g, ' ').trim().slice(0, 40) || fallback; export function createRemoteHub({ requireSameNetwork = false } = {}) { const rooms = new Map(); // roomId → room const codes = new Map(); // code → { roomId, expires } const pairFails = new Map(); // ip → [timestamps] const send = (ws, msg) => { try { ws.send(JSON.stringify(msg)); } catch { /* socket gone */ } }; function remoteList(room) { return [...room.remotes.entries()].map(([rid, r]) => ({ rid, name: r.name, since: r.since })); } function issueCode(room) { if (room.code) codes.delete(room.code); let code; do { code = String(randomInt(0, 1_000_000)).padStart(6, '0'); } while (codes.has(code)); room.code = code; room.codeExpires = Date.now() + CODE_TTL_MS; codes.set(code, { roomId: room.id, expires: room.codeExpires }); return code; } function sweep() { const now = Date.now(); for (const [code, c] of codes) if (c.expires < now) codes.delete(code); for (const [id, room] of rooms) { if (!room.host && !room.remotes.size && now - room.lastSeen > ROOM_IDLE_MS) { if (room.code) codes.delete(room.code); rooms.delete(id); } } } const sweeper = setInterval(sweep, 60_000); sweeper.unref?.(); // ---- HTTP: pairing ---- function pair({ code, name, ip }) { const now = Date.now(); const fails = (pairFails.get(ip) || []).filter((t) => now - t < 10 * 60_000); if (fails.length >= 10) return { status: 429, body: { ok: false, error: 'too many attempts — wait a few minutes' } }; const fail = (status, error) => { fails.push(now); pairFails.set(ip, fails); if (pairFails.size > 5000) pairFails.clear(); return { status, body: { ok: false, error } }; }; const c = codes.get(String(code || '').replace(/\D/g, '')); const room = c && c.expires >= now ? rooms.get(c.roomId) : null; if (!room || !room.host) return fail(404, 'code not found or expired — check the code on the desktop'); if (requireSameNetwork && room.hostIp && ip !== room.hostIp) { return fail(403, 'the phone and the desktop must be on the same network'); } const remoteId = randomBytes(8).toString('hex'); const token = remoteToken(room.secret, remoteId); // One-time code: the desktop immediately shows a fresh one. issueCode(room); send(room.host, { type: 'code', code: room.code, codeExpires: room.codeExpires }); return { status: 200, body: { ok: true, roomId: room.id, remoteId, token, hostName: room.hostName, name: cleanName(name, 'Phone') }, }; } // ---- WebSocket ---- // Returns a Response on rejection, undefined when upgraded. function upgrade(req, server, ip) { const u = new URL(req.url); const q = (k) => u.searchParams.get(k) || ''; const role = q('role'); if (role === 'host') { const secret = q('secret'); if (!/^[A-Za-z0-9_-]{32,128}$/.test(secret)) return new Response('bad secret', { status: 400 }); const ok = server.upgrade(req, { data: { role, secret, name: cleanName(q('name'), 'Desktop'), ip } }); return ok ? undefined : new Response('upgrade failed', { status: 400 }); } if (role === 'remote') { const data = { role, roomId: q('room'), rid: q('rid'), token: q('token'), name: cleanName(q('name'), 'Phone'), ip }; if (!/^[0-9a-f]{20}$/.test(data.roomId) || !/^[0-9a-f]{16}$/.test(data.rid) || !data.token) { return new Response('bad pairing', { status: 400 }); } const ok = server.upgrade(req, { data }); return ok ? undefined : new Response('upgrade failed', { status: 400 }); } return new Response('unknown role', { status: 400 }); } function openHost(ws) { const d = ws.data; const id = roomIdFor(d.secret); let room = rooms.get(id); if (!room) { 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); } if (room.host && room.host !== ws) { try { room.host.close(4000, 'replaced by a newer desktop tab'); } catch { /* already closed */ } } room.host = ws; room.hostName = d.name; room.hostIp = d.ip; room.lastSeen = Date.now(); d.roomId = id; issueCode(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', you: r.ws.data.rid, hostName: room.hostName, state: room.state, queue: room.queue }); } function openRemote(ws) { const d = ws.data; const room = rooms.get(d.roomId); // Without a connected desktop there is no secret to check the token // against — the phone keeps retrying until the desktop is back. if (!room || !room.host) { ws.close(4004, 'desktop offline'); return; } if (!sameToken(d.token, remoteToken(room.secret, d.rid))) { ws.close(4001, 'not paired'); return; } const prev = room.remotes.get(d.rid); 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.lastSeen = Date.now(); 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 }); } function onMessage(ws, raw) { if (typeof raw !== 'string' || raw.length > MAX_MSG) return; let m; try { m = JSON.parse(raw); } catch { return; } if (!m || typeof m !== 'object') return; const d = ws.data; const room = rooms.get(d.roomId); if (!room) return; 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 (room.host !== ws) return; if (m.type === 'state') { room.state = m.state || null; for (const r of room.remotes.values()) send(r.ws, { type: 'state', state: room.state }); } else if (m.type === 'queue') { const q = m.queue && typeof m.queue === 'object' ? m.queue : {}; room.queue = { items: Array.isArray(q.items) ? q.items.slice(0, 200) : [], idx: Number.isInteger(q.idx) ? q.idx : -1 }; for (const r of room.remotes.values()) send(r.ws, { type: 'queue', queue: room.queue }); } else if (m.type === 'pong') { const r = room.remotes.get(m.rid); if (r) send(r.ws, { type: 'pong', t: m.t }); } else if (m.type === 'new-code') { issueCode(room); send(ws, { type: 'code', code: room.code, codeExpires: room.codeExpires }); } else if (m.type === 'unpair-all') { for (const r of room.remotes.values()) { try { r.ws.close(4001, 'unpaired'); } catch { /* gone */ } } room.remotes.clear(); if (room.code) codes.delete(room.code); rooms.delete(room.id); // the desktop reconnects with a new secret } else if (m.type === 'kick') { const r = room.remotes.get(m.rid); if (r) { try { r.ws.close(4001, 'removed by the desktop'); } catch { /* gone */ } } } return; } // remote const me = room.remotes.get(d.rid); if (!me || me.ws !== ws) return; if (!room.host) { send(ws, { type: 'host-offline' }); return; } if (m.type === 'ping') { send(room.host, { type: 'ping', rid: d.rid, t: m.t }); } else if (m.type === 'cmd' && REMOTE_COMMANDS.has(m.cmd)) { const { type, ...args } = m; send(room.host, { type: 'cmd', from: me.name, rid: d.rid, ...args }); } } function onClose(ws) { const d = ws.data; const room = rooms.get(d.roomId); if (!room) return; room.lastSeen = Date.now(); if (d.role === 'host') { if (room.host !== ws) return; room.host = null; if (room.code) { codes.delete(room.code); room.code = null; } for (const r of room.remotes.values()) send(r.ws, { type: 'host-offline' }); } else { const r = room.remotes.get(d.rid); if (r && r.ws === ws) { room.remotes.delete(d.rid); if (room.host) send(room.host, { type: 'remotes', remotes: remoteList(room), left: r.name }); } } } const websocket = { maxPayloadLength: MAX_MSG, idleTimeout: 120, open(ws) { if (ws.data.role === 'host') openHost(ws); else openRemote(ws); }, message(ws, msg) { onMessage(ws, typeof msg === 'string' ? msg : null); }, close(ws) { onClose(ws); }, }; return { pair, upgrade, websocket, _rooms: rooms, stop() { clearInterval(sweeper); } }; }