Add phone remote control: pair a phone with a 6-digit code or QR to drive a desktop instance's playback, queue, volume, sleep timer and service mode
This commit is contained in:
@@ -6,11 +6,12 @@
|
||||
"scripts": {
|
||||
"start": "bun server.js",
|
||||
"dev": "bun --hot server.js",
|
||||
"test": "bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js"
|
||||
"test": "bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./remote.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.7.10",
|
||||
"@hono/node-server": "^1.14.0",
|
||||
"@libsql/client": "^0.14.0"
|
||||
"@libsql/client": "^0.14.0",
|
||||
"hono": "^4.7.10",
|
||||
"qrcode": "^1.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
256
server/remote.js
Normal file
256
server/remote.js
Normal file
@@ -0,0 +1,256 @@
|
||||
/* ============================================================================
|
||||
* 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 { 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 = { 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', 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', 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 (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 : Buffer.from(msg).toString('utf8')); },
|
||||
close(ws) { onClose(ws); },
|
||||
};
|
||||
|
||||
return { pair, upgrade, websocket, _rooms: rooms, stop() { clearInterval(sweeper); } };
|
||||
}
|
||||
153
server/remote.test.js
Normal file
153
server/remote.test.js
Normal file
@@ -0,0 +1,153 @@
|
||||
// Phone-remote relay: real Bun server + real WebSocket clients.
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { createRemoteHub, roomIdFor } from './remote.js';
|
||||
|
||||
const SECRET = 'a'.repeat(43);
|
||||
let hub, server, base, ipFor;
|
||||
|
||||
function start(opts) {
|
||||
hub = createRemoteHub(opts);
|
||||
ipFor = () => '203.0.113.1';
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req, srv) {
|
||||
const u = new URL(req.url);
|
||||
if (u.pathname === '/ws/remote') return hub.upgrade(req, srv, ipFor(req));
|
||||
if (u.pathname === '/pair') {
|
||||
const body = await req.json();
|
||||
const r = hub.pair({ ...body, ip: ipFor(req) });
|
||||
return Response.json(r.body, { status: r.status });
|
||||
}
|
||||
return new Response('nope', { status: 404 });
|
||||
},
|
||||
websocket: hub.websocket,
|
||||
});
|
||||
base = `localhost:${server.port}`;
|
||||
}
|
||||
|
||||
// A WebSocket client that queues messages and lets a test await the next
|
||||
// one of a given type.
|
||||
function client(query) {
|
||||
const ws = new WebSocket(`ws://${base}/ws/remote?${new URLSearchParams(query)}`);
|
||||
const inbox = [];
|
||||
const waiters = [];
|
||||
ws.onmessage = (e) => {
|
||||
const m = JSON.parse(e.data);
|
||||
const i = waiters.findIndex((w) => w.type === m.type);
|
||||
if (i >= 0) waiters.splice(i, 1)[0].resolve(m);
|
||||
else inbox.push(m);
|
||||
};
|
||||
const closed = new Promise((r) => { ws.onclose = (e) => r(e.code); });
|
||||
return {
|
||||
ws,
|
||||
closed,
|
||||
send: (m) => ws.send(JSON.stringify(m)),
|
||||
next(type, ms = 2000) {
|
||||
const i = inbox.findIndex((m) => m.type === type);
|
||||
if (i >= 0) return Promise.resolve(inbox.splice(i, 1)[0]);
|
||||
return new Promise((resolve, reject) => {
|
||||
waiters.push({ type, resolve });
|
||||
setTimeout(() => reject(new Error(`timeout waiting for ${type}`)), ms);
|
||||
});
|
||||
},
|
||||
opened: new Promise((r) => { ws.onopen = r; }),
|
||||
};
|
||||
}
|
||||
|
||||
const pair = async (code, name = 'Josh iPhone') => {
|
||||
const res = await fetch(`http://${base}/pair`, { method: 'POST', body: JSON.stringify({ code, name }) });
|
||||
return { status: res.status, body: await res.json() };
|
||||
};
|
||||
|
||||
describe('remote relay', () => {
|
||||
beforeAll(() => start());
|
||||
afterAll(() => { server.stop(true); hub.stop(); });
|
||||
|
||||
let host, remote, creds;
|
||||
|
||||
test('host gets a room and a 6-digit code', async () => {
|
||||
host = client({ role: 'host', secret: SECRET, name: 'Living Room TV' });
|
||||
const hello = await host.next('hello');
|
||||
expect(hello.roomId).toBe(roomIdFor(SECRET));
|
||||
expect(hello.code).toMatch(/^\d{6}$/);
|
||||
host.code = hello.code;
|
||||
});
|
||||
|
||||
test('wrong code is refused; right code pairs once and rotates', async () => {
|
||||
expect((await pair('000000')).status === 404 || host.code === '000000').toBe(true);
|
||||
const ok = await pair(host.code);
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body.hostName).toBe('Living Room TV');
|
||||
creds = ok.body;
|
||||
const fresh = await host.next('code');
|
||||
expect(fresh.code).not.toBe(host.code);
|
||||
expect((await pair(host.code)).status).toBe(404); // one-time
|
||||
host.code = fresh.code;
|
||||
});
|
||||
|
||||
test('remote connects, host sees it, state flows down, commands flow up', async () => {
|
||||
host.send({ type: 'state', state: { title: 'Living Hope', cur: 84, paused: false } });
|
||||
host.send({ type: 'queue', queue: { items: [{ id: 'x', title: 'House of the Lord' }], idx: -1 } });
|
||||
remote = client({ role: 'remote', room: creds.roomId, rid: creds.remoteId, token: creds.token, name: 'Josh iPhone' });
|
||||
const hello = await remote.next('hello');
|
||||
expect(hello.state.title).toBe('Living Hope');
|
||||
expect(hello.queue.items[0].title).toBe('House of the Lord');
|
||||
const list = await host.next('remotes');
|
||||
expect(list.remotes.map((r) => r.name)).toEqual(['Josh iPhone']);
|
||||
|
||||
remote.send({ type: 'cmd', cmd: 'seek', t: 120 });
|
||||
remote.send({ type: 'cmd', cmd: 'rm -rf' }); // not a command → dropped
|
||||
remote.send({ type: 'cmd', cmd: 'next' });
|
||||
const c1 = await host.next('cmd');
|
||||
expect(c1).toMatchObject({ cmd: 'seek', t: 120, from: 'Josh iPhone' });
|
||||
expect((await host.next('cmd')).cmd).toBe('next');
|
||||
|
||||
host.send({ type: 'state', state: { title: 'Living Hope', cur: 121, paused: true } });
|
||||
expect((await remote.next('state')).state.cur).toBe(121);
|
||||
});
|
||||
|
||||
test('ping goes through the host and back', async () => {
|
||||
remote.send({ type: 'ping', t: 42 });
|
||||
const p = await host.next('ping');
|
||||
expect(p.t).toBe(42);
|
||||
host.send({ type: 'pong', rid: p.rid, t: p.t });
|
||||
expect((await remote.next('pong')).t).toBe(42);
|
||||
});
|
||||
|
||||
test('a forged token is rejected', async () => {
|
||||
const bad = client({ role: 'remote', room: creds.roomId, rid: creds.remoteId, token: 'forged', name: 'x' });
|
||||
expect(await bad.closed).toBe(4001);
|
||||
});
|
||||
|
||||
test('desktop offline: remotes are told, reconnects wait; desktop back → phone still paired', async () => {
|
||||
host.ws.close();
|
||||
await remote.next('host-offline');
|
||||
const early = client({ role: 'remote', room: creds.roomId, rid: creds.remoteId, token: creds.token, name: 'x' });
|
||||
expect(await early.closed).toBe(4004);
|
||||
host = client({ role: 'host', secret: SECRET, name: 'Living Room TV' });
|
||||
await host.next('hello');
|
||||
expect((await remote.next('hello')).hostName).toBe('Living Room TV');
|
||||
});
|
||||
|
||||
test('unpair-all disconnects every phone', async () => {
|
||||
host.send({ type: 'unpair-all' });
|
||||
expect(await remote.closed).toBe(4001);
|
||||
host.ws.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('same-network pairing', () => {
|
||||
beforeAll(() => start({ requireSameNetwork: true }));
|
||||
afterAll(() => { server.stop(true); hub.stop(); });
|
||||
|
||||
test('a phone on another network cannot pair', async () => {
|
||||
const host = client({ role: 'host', secret: 'b'.repeat(40), name: 'TV' });
|
||||
const { code } = await host.next('hello');
|
||||
ipFor = () => '198.51.100.7';
|
||||
const r = await pair(code);
|
||||
expect(r.status).toBe(403);
|
||||
ipFor = () => '203.0.113.1';
|
||||
expect((await pair(code)).status).toBe(200);
|
||||
host.ws.close();
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,8 @@ import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getP
|
||||
import { createMediaCache, HIGH, LOW } from './media-cache.js';
|
||||
import * as notesDb from './db.js';
|
||||
import { registerNoteRoutes } from './notes.js';
|
||||
import { createRemoteHub } from './remote.js';
|
||||
import QRCode from 'qrcode';
|
||||
import { dirname, join as pathJoin } from 'node:path';
|
||||
|
||||
// A media proxy must not die because one client's stream hit an edge case
|
||||
@@ -1557,6 +1559,37 @@ app.get('/api/user/data', async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Phone remote for a desktop instance — see remote.js
|
||||
// ============================================================================
|
||||
const remote = createRemoteHub({ requireSameNetwork: process.env.REMOTE_SAME_NETWORK === '1' });
|
||||
|
||||
// The public IP a request came from. Behind Traefik that is the first
|
||||
// X-Forwarded-For hop; locally, the socket address.
|
||||
function clientIpOf(req, server) {
|
||||
const xff = (req.headers.get('x-forwarded-for') || '').split(',')[0].trim();
|
||||
if (xff) return xff;
|
||||
try { return server?.requestIP?.(req)?.address || ''; } catch { return ''; }
|
||||
}
|
||||
|
||||
app.post('/api/remote/pair', async (c) => {
|
||||
let body = {};
|
||||
try { body = await c.req.json(); } catch { /* validated below */ }
|
||||
const r = remote.pair({ code: body.code, name: body.name, ip: clientIpOf(c.req.raw, c.env) });
|
||||
return c.json(r.body, r.status);
|
||||
});
|
||||
|
||||
// QR for the pairing link, drawn server-side so the client needs no library.
|
||||
// Only ever encodes this origin's own /?pair=<6 digits> URL.
|
||||
app.get('/api/remote/qr/:code', async (c) => {
|
||||
const code = c.req.param('code');
|
||||
if (!/^\d{6}$/.test(code)) return c.text('bad code', 400);
|
||||
const proto = c.req.header('x-forwarded-proto') || new URL(c.req.url).protocol.replace(':', '');
|
||||
const host = c.req.header('x-forwarded-host') || c.req.header('host') || new URL(c.req.url).host;
|
||||
const svg = await QRCode.toString(`${proto}://${host}/?pair=${code}`, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' });
|
||||
return c.body(svg, 200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-store' });
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Shared lyrics + chapters, API tokens, admin page — see notes.js
|
||||
// ============================================================================
|
||||
@@ -1645,7 +1678,12 @@ async function main() {
|
||||
// Bun.serve is the native Bun HTTP server
|
||||
Bun.serve({
|
||||
port: PORT,
|
||||
fetch: app.fetch,
|
||||
// /ws/remote is upgraded here, before Hono — see remote.js.
|
||||
fetch(req, server) {
|
||||
if (new URL(req.url).pathname === '/ws/remote') return remote.upgrade(req, server, clientIpOf(req, server));
|
||||
return app.fetch(req, server);
|
||||
},
|
||||
websocket: remote.websocket,
|
||||
// Default is 10s, which killed /api/download proxy streams whenever the
|
||||
// connection went idle mid-transfer. 240s then killed every save whose
|
||||
// server-side yt-dlp phase (no bytes sent yet) ran longer than 4 min —
|
||||
|
||||
Reference in New Issue
Block a user