app.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. import { createServer } from 'node:http';
  2. import { EventEmitter } from 'node:events';
  3. import path from 'node:path';
  4. import { fileURLToPath } from 'node:url';
  5. import express from 'express';
  6. import { Server } from 'socket.io';
  7. import validator from 'validator';
  8. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  9. // ***************************************************************************
  10. // Config
  11. // ***************************************************************************
  12. // Everything comes from the environment. Use `.env` locally (see .env.example);
  13. // `npm run dev` loads it. In production pass real env vars via the service
  14. // manager. This replaces the old config.js / config-dev.js pair, which was
  15. // gitignored and therefore never actually present.
  16. const config = {
  17. port: Number(process.env.PORT ?? 3000),
  18. // '' when served at the domain root, '/radio' when mounted at a subpath.
  19. basePath: normalizeBasePath(process.env.BASE_PATH ?? ''),
  20. mainRoom: process.env.MAIN_ROOM ?? 'Lobby',
  21. // Unset => single process, no Redis. Set => Redis adapter, multi-process safe.
  22. redisUrl: process.env.REDIS_URL ?? '',
  23. // Number of reverse proxies in front of us, or a preset like 'loopback'.
  24. trustProxy: process.env.TRUST_PROXY ?? '',
  25. debug: process.env.DEBUG === 'true',
  26. // Icecast's own JSON status. The old code polled a hand-customized
  27. // status2.xsl over JSONP; Icecast upgrades overwrite those XSL files (it
  28. // 404s today, and has since 2021 per the README), so use the stock endpoint
  29. // and proxy it from here instead.
  30. streamStatusUrl:
  31. process.env.STREAM_STATUS_URL ?? 'https://stream.davidawindham.com/status-json.xsl',
  32. streamMount: process.env.STREAM_MOUNT ?? '/stream',
  33. // the-ham.org video (nginx-rtmp -> HLS). /api/live proxies its stat.xml
  34. // (which has no CORS, unlike the .m3u8/.ts) to discover the live stream and
  35. // report online/offline, so the client never hardcodes a stream key.
  36. hamStatUrl: process.env.HAM_STAT_URL ?? 'https://the-ham.org/stat.xml',
  37. hamHlsBase: process.env.HAM_HLS_BASE ?? 'https://the-ham.org/stream/hls/',
  38. hamApp: process.env.HAM_APP ?? 'live',
  39. // Proxied via /api/lastfm so the key stops shipping in the client bundle.
  40. // Unset => the sidebar lists just stay empty.
  41. lastfmKey: process.env.LASTFM_API_KEY ?? '',
  42. lastfmUser: process.env.LASTFM_USER ?? 'windhamdavid',
  43. // Local dev only. See the /embed proxy below.
  44. dawOrigin: process.env.DAW_ORIGIN ?? '',
  45. };
  46. function normalizeBasePath(value) {
  47. const trimmed = value.trim().replace(/\/+$/, '');
  48. if (!trimmed) return '';
  49. return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
  50. }
  51. const MAX_MESSAGE_LENGTH = 500;
  52. const MAX_ROOM_LENGTH = 25;
  53. const logger = new EventEmitter();
  54. logger.on('newEvent', (event, data) => {
  55. console.log('%s: %s', event, JSON.stringify(data));
  56. });
  57. // ***************************************************************************
  58. // App
  59. // ***************************************************************************
  60. const app = express();
  61. const server = createServer(app);
  62. if (config.trustProxy) {
  63. // Needed for correct client IPs / protocol when nginx fronts us at /radio.
  64. const asNumber = Number(config.trustProxy);
  65. app.set('trust proxy', Number.isNaN(asNumber) ? config.trustProxy : asNumber);
  66. }
  67. app.use(express.json());
  68. app.use(express.urlencoded({ extended: false }));
  69. const router = express.Router();
  70. // Built assets. `npm run build` produces app/ from src/.
  71. router.use(express.static(path.join(__dirname, 'app')));
  72. router.get('/health', (req, res) => {
  73. res.json({ ok: true, rooms: publicRooms().length, sockets: io.engine.clientsCount });
  74. });
  75. // Now-playing / listener counts, proxied from Icecast.
  76. //
  77. // Proxying rather than calling Icecast from the browser buys three things: the
  78. // page can be served over TLS without mixed-content breakage, JSONP goes away,
  79. // and the stream host stops being baked into client JS. Every client polls this
  80. // on a timer, so a short cache keeps one Icecast hit per interval regardless of
  81. // how many listeners are connected.
  82. router.get('/api/status', async (req, res) => {
  83. try {
  84. res.json(await getStreamStatus());
  85. } catch {
  86. res.status(502).json({ online: false, error: 'stream status unavailable' });
  87. }
  88. });
  89. // the-ham.org video status, proxied from nginx-rtmp's stat.xml.
  90. //
  91. // stat.xml has no CORS header (the .m3u8/.ts do), so the browser can't read it
  92. // directly. This proxy discovers whatever is publishing under the `live` app
  93. // and hands back a ready-to-play HLS URL, so the client never hardcodes a
  94. // stream key and gets a clean offline state when nothing is broadcasting --
  95. // which, per the nginx logs, is most of the time.
  96. router.get('/api/live', async (req, res) => {
  97. try {
  98. res.json(await getHamStatus());
  99. } catch {
  100. res.status(502).json({ online: false, error: 'live status unavailable' });
  101. }
  102. });
  103. // Last.fm sidebar data, proxied so the API key stays on the server.
  104. //
  105. // The key used to be hardcoded in src/js/radio.js (4x) and shipped in the
  106. // bundle. It's read-only public data, so the exposure was mild, but a
  107. // credential in client JS is a credential you can't rotate quietly.
  108. //
  109. // Strictly allowlisted -- method, period and limit are all constrained, so
  110. // this can't be turned into an open relay for arbitrary Last.fm calls.
  111. const LASTFM_METHODS = new Set([
  112. 'user.gettopartists',
  113. 'user.gettoptracks',
  114. 'user.gettopalbums',
  115. 'user.getrecenttracks',
  116. ]);
  117. const LASTFM_PERIODS = new Set(['overall', '7day', '1month', '3month', '6month', '12month']);
  118. const LASTFM_CACHE_MS = 60000;
  119. const lastfmCache = new Map();
  120. router.get('/api/lastfm', async (req, res) => {
  121. const method = String(req.query.method ?? '');
  122. if (!LASTFM_METHODS.has(method)) {
  123. res.status(400).json({ error: 'unsupported method' });
  124. return;
  125. }
  126. if (!config.lastfmKey) {
  127. res.status(503).json({ error: 'lastfm not configured' });
  128. return;
  129. }
  130. const period = LASTFM_PERIODS.has(String(req.query.period)) ? String(req.query.period) : '12month';
  131. const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 200);
  132. const cacheKey = `${method}:${period}:${limit}`;
  133. const hit = lastfmCache.get(cacheKey);
  134. if (hit && Date.now() - hit.at < LASTFM_CACHE_MS) {
  135. res.json(hit.value);
  136. return;
  137. }
  138. const url = new URL('https://ws.audioscrobbler.com/2.0/');
  139. url.searchParams.set('method', method);
  140. url.searchParams.set('user', config.lastfmUser);
  141. url.searchParams.set('api_key', config.lastfmKey);
  142. url.searchParams.set('format', 'json');
  143. url.searchParams.set('limit', String(limit));
  144. // getrecenttracks has no notion of a period.
  145. if (method !== 'user.getrecenttracks') url.searchParams.set('period', period);
  146. try {
  147. const upstream = await fetch(url, { signal: AbortSignal.timeout(8000) });
  148. if (!upstream.ok) throw new Error(`lastfm responded ${upstream.status}`);
  149. const value = await upstream.json();
  150. lastfmCache.set(cacheKey, { at: Date.now(), value });
  151. res.json(value);
  152. } catch {
  153. res.status(502).json({ error: 'lastfm unavailable' });
  154. }
  155. });
  156. // Broadcast a message to every active room.
  157. router.post('/api/broadcast', requireAuthentication, (req, res) => {
  158. const text = typeof req.body?.msg === 'string' ? req.body.msg.trim() : '';
  159. if (!text) {
  160. res.status(400).send('No message provided');
  161. return;
  162. }
  163. sendBroadcast(validator.escape(text.slice(0, MAX_MESSAGE_LENGTH)));
  164. res.status(201).send('Message sent to all rooms');
  165. });
  166. // Local dev only: serve the shared site chrome (/embed/chrome.js + its fonts)
  167. // by proxying to the main site.
  168. //
  169. // index.html loads /embed/chrome.js root-relative. In production that path is
  170. // served by Apache from the WordPress docroot, alongside /radio -- nginx only
  171. // routes /radio/ here, so this never runs there. Locally there's no Apache in
  172. // front, so without this the chrome would 404 and the page would render bare.
  173. // Unset DAW_ORIGIN => not mounted at all.
  174. if (config.dawOrigin) {
  175. app.use('/embed', async (req, res) => {
  176. try {
  177. const upstream = await fetch(new URL(`/embed${req.url}`, config.dawOrigin), {
  178. signal: AbortSignal.timeout(5000),
  179. });
  180. if (!upstream.ok) {
  181. res.sendStatus(upstream.status);
  182. return;
  183. }
  184. const type = upstream.headers.get('content-type');
  185. if (type) res.type(type);
  186. res.send(Buffer.from(await upstream.arrayBuffer()));
  187. } catch {
  188. res.sendStatus(502);
  189. }
  190. });
  191. logger.emit('newEvent', 'embedProxyEnabled', { origin: config.dawOrigin });
  192. }
  193. // Canonicalize /radio -> /radio/ before the router sees it. Without the
  194. // trailing slash the browser resolves the page's relative asset URLs against
  195. // the parent directory, so every script and stylesheet 404s.
  196. //
  197. // The exact `req.path ===` test matters: Express's default non-strict routing
  198. // treats '/radio' and '/radio/' as the same route, so a plain
  199. // app.get(basePath) would also match the slashed URL and redirect it to
  200. // itself, forever.
  201. if (config.basePath) {
  202. app.use((req, res, next) => {
  203. if (req.path === config.basePath) res.redirect(301, `${config.basePath}/`);
  204. else next();
  205. });
  206. }
  207. app.use(config.basePath || '/', router);
  208. // ***************************************************************************
  209. // Helpers
  210. // ***************************************************************************
  211. // Placeholder, as it has always been. Kept so the broadcast route has an
  212. // obvious place to grow one rather than pretending the gap isn't there.
  213. function requireAuthentication(req, res, next) {
  214. next();
  215. }
  216. // socket.io keeps a private room per socket, keyed by socket id. `sids` lets us
  217. // tell those apart from rooms people actually joined.
  218. function publicRooms() {
  219. const { rooms, sids } = io.of('/').adapter;
  220. const named = [];
  221. for (const key of rooms.keys()) {
  222. if (!sids.has(key)) named.push(key);
  223. }
  224. return named;
  225. }
  226. // Rooms this socket joined, minus its own private room.
  227. function joinedRooms(socket) {
  228. return [...socket.rooms].filter((room) => room !== socket.id);
  229. }
  230. // Room names become DOM ids and jQuery selectors on the client, so keep them to
  231. // a charset that can't break out of either.
  232. function cleanRoomName(value) {
  233. if (typeof value !== 'string') return null;
  234. const cleaned = value.replace(/[^A-Za-z0-9_-]/g, '').slice(0, MAX_ROOM_LENGTH);
  235. return cleaned || null;
  236. }
  237. function cleanNickname(value) {
  238. if (typeof value !== 'string') return null;
  239. const cleaned = value.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 25);
  240. return cleaned.length >= 3 ? cleaned : null;
  241. }
  242. // Icecast reports `source` as an object for one mount, an array for several,
  243. // and omits it entirely when nothing is broadcasting -- which is the off-air
  244. // case the player draws.
  245. function normalizeIcecastStatus(payload, mount) {
  246. const stats = payload?.icestats ?? {};
  247. const sources = stats.source == null ? [] : [].concat(stats.source);
  248. const source =
  249. sources.find((s) => typeof s?.listenurl === 'string' && s.listenurl.endsWith(mount)) ??
  250. sources[0];
  251. if (!source) return { online: false };
  252. // `bitrate` is kbps; `audio_bitrate` is bps. Only one is usually present,
  253. // depending on how the source client connected.
  254. const bitrate =
  255. source.bitrate ?? (source.audio_bitrate ? Math.round(source.audio_bitrate / 1000) : null);
  256. return {
  257. online: true,
  258. title: source.title ?? source.yp_currently_playing ?? null,
  259. listeners: source.listeners ?? 0,
  260. peakListeners: source.listener_peak ?? 0,
  261. bitrate,
  262. };
  263. }
  264. const STATUS_CACHE_MS = 5000;
  265. let statusCache = { at: 0, value: null };
  266. async function getStreamStatus() {
  267. const now = Date.now();
  268. if (statusCache.value && now - statusCache.at < STATUS_CACHE_MS) return statusCache.value;
  269. const upstream = await fetch(config.streamStatusUrl, { signal: AbortSignal.timeout(5000) });
  270. if (!upstream.ok) throw new Error(`icecast responded ${upstream.status}`);
  271. const value = normalizeIcecastStatus(await upstream.json(), config.streamMount);
  272. statusCache = { at: now, value };
  273. return value;
  274. }
  275. // Parse nginx-rtmp stat.xml for a live publisher under `app`.
  276. //
  277. // The XML is small and machine-generated, so targeted regex is enough (Node has
  278. // no built-in XML parser and this doesn't warrant a dependency). While nothing
  279. // is publishing there's no <stream> node at all under the app's <live>; while
  280. // publishing there's <stream><name>…</name>…<publishing/> plus a <meta><video>.
  281. // The <publishing/> marker is the definitive "a source is connected" signal.
  282. function parseHamStat(xml, app) {
  283. // Narrow to the target application's block.
  284. const appRe = new RegExp(
  285. `<application>\\s*<name>\\s*${app}\\s*</name>([\\s\\S]*?)</application>`,
  286. 'i',
  287. );
  288. const appBlock = appRe.exec(xml)?.[1];
  289. if (!appBlock) return { online: false };
  290. // Only streams with an active publisher count as live.
  291. for (const m of appBlock.matchAll(/<stream>([\s\S]*?)<\/stream>/g)) {
  292. const s = m[1];
  293. if (!/<publishing\/>/.test(s)) continue;
  294. const name = /<name>([\s\S]*?)<\/name>/.exec(s)?.[1]?.trim();
  295. if (!name) continue;
  296. const width = Number(/<width>(\d+)<\/width>/.exec(s)?.[1]) || null;
  297. const height = Number(/<height>(\d+)<\/height>/.exec(s)?.[1]) || null;
  298. return {
  299. online: true,
  300. name,
  301. // .m3u8/.ts carry CORS, so the client plays this URL directly.
  302. hlsUrl: new URL(`${encodeURIComponent(name)}.m3u8`, config.hamHlsBase).href,
  303. width,
  304. height,
  305. };
  306. }
  307. return { online: false };
  308. }
  309. const HAM_CACHE_MS = 8000;
  310. let hamCache = { at: 0, value: null };
  311. async function getHamStatus() {
  312. const now = Date.now();
  313. if (hamCache.value && now - hamCache.at < HAM_CACHE_MS) return hamCache.value;
  314. const upstream = await fetch(config.hamStatUrl, { signal: AbortSignal.timeout(5000) });
  315. if (!upstream.ok) throw new Error(`the-ham stat responded ${upstream.status}`);
  316. const value = parseHamStat(await upstream.text(), config.hamApp);
  317. hamCache = { at: now, value };
  318. return value;
  319. }
  320. function sendBroadcast(text) {
  321. for (const room of publicRooms()) {
  322. io.to(room).emit('newMessage', {
  323. room,
  324. username: 'Radio-Robbot',
  325. msg: text,
  326. date: new Date(),
  327. });
  328. }
  329. logger.emit('newEvent', 'newBroadcastMessage', { msg: text });
  330. }
  331. // ***************************************************************************
  332. // Socket.io
  333. // ***************************************************************************
  334. const io = new Server(server, {
  335. // Must line up with the client and with the proxy's websocket location.
  336. path: `${config.basePath}/socket.io`,
  337. });
  338. io.on('connection', (socket) => {
  339. // Per-socket state lives here instead of Redis. It survives exactly as long
  340. // as the connection does, which is all it ever needed to do, and it works
  341. // unchanged whether or not the Redis adapter is enabled.
  342. socket.data.username = 'anonymous';
  343. socket.data.connectedAt = new Date();
  344. socket.emit('connected', 'Welcome to the chat server');
  345. logger.emit('newEvent', 'userConnected', { socket: socket.id });
  346. socket.join(config.mainRoom);
  347. logger.emit('newEvent', 'userJoinsRoom', { socket: socket.id, room: config.mainRoom });
  348. io.to(config.mainRoom).emit('userJoinsRoom', {
  349. room: config.mainRoom,
  350. username: socket.data.username,
  351. msg: '----- Joined -----',
  352. id: socket.id,
  353. });
  354. // No 'subscribe' / 'unsubscribe' / 'getRooms' handlers: there is one room and
  355. // everyone is in it. Dropping them server-side rather than only hiding the UI
  356. // is the point -- otherwise anyone could still emit 'subscribe' by hand and
  357. // wander off into a private room. Every socket joins config.mainRoom on
  358. // connect and never leaves, so the `socket.rooms.has(room)` check in
  359. // 'newMessage' below can only ever pass for that one room.
  360. socket.on('getUsersInRoom', async (data) => {
  361. const room = cleanRoomName(data?.room);
  362. if (!room) return;
  363. // Works across processes when the Redis adapter is on.
  364. const sockets = await io.in(room).fetchSockets();
  365. socket.emit('usersInRoom', {
  366. users: sockets.map((member) => ({
  367. room,
  368. username: member.data.username,
  369. id: member.id,
  370. })),
  371. });
  372. });
  373. socket.on('setNickname', (data) => {
  374. const nickname = cleanNickname(data?.username);
  375. if (!nickname) return;
  376. const oldUsername = socket.data.username;
  377. socket.data.username = nickname;
  378. logger.emit('newEvent', 'userSetsNickname', {
  379. socket: socket.id,
  380. oldUsername,
  381. newUsername: nickname,
  382. });
  383. for (const room of joinedRooms(socket)) {
  384. io.to(room).emit('userNicknameUpdated', {
  385. room,
  386. oldUsername,
  387. newUsername: nickname,
  388. id: socket.id,
  389. });
  390. }
  391. });
  392. socket.on('newMessage', (data) => {
  393. const room = cleanRoomName(data?.room);
  394. const text = typeof data?.msg === 'string' ? data.msg.trim() : '';
  395. if (!room || !text) return;
  396. // Only relay to rooms this socket actually joined.
  397. if (!socket.rooms.has(room)) return;
  398. const message = {
  399. room,
  400. username: socket.data.username,
  401. msg: text.slice(0, MAX_MESSAGE_LENGTH),
  402. date: new Date(),
  403. };
  404. io.to(room).emit('newMessage', message);
  405. logger.emit('newEvent', 'newMessage', message);
  406. });
  407. // 'disconnecting' fires while socket.rooms is still populated; by the time
  408. // 'disconnect' runs it has been cleared, so departure notices must go out
  409. // from here.
  410. socket.on('disconnecting', () => {
  411. logger.emit('newEvent', 'userDisconnected', {
  412. socket: socket.id,
  413. username: socket.data.username,
  414. });
  415. for (const room of joinedRooms(socket)) {
  416. io.to(room).emit('userLeavesRoom', {
  417. room,
  418. username: socket.data.username,
  419. msg: '----- Left the room -----',
  420. id: socket.id,
  421. });
  422. }
  423. });
  424. });
  425. // ***************************************************************************
  426. // Startup
  427. // ***************************************************************************
  428. async function connectRedisAdapter(url) {
  429. const { createClient } = await import('redis');
  430. const { createAdapter } = await import('@socket.io/redis-adapter');
  431. const pubClient = createClient({ url });
  432. const subClient = pubClient.duplicate();
  433. pubClient.on('error', (err) => logger.emit('newEvent', 'redisError', { msg: err.message }));
  434. subClient.on('error', (err) => logger.emit('newEvent', 'redisError', { msg: err.message }));
  435. await Promise.all([pubClient.connect(), subClient.connect()]);
  436. io.adapter(createAdapter(pubClient, subClient));
  437. return [pubClient, subClient];
  438. }
  439. let redisClients = [];
  440. if (config.redisUrl) {
  441. redisClients = await connectRedisAdapter(config.redisUrl);
  442. logger.emit('newEvent', 'redisAdapterEnabled', {});
  443. } else {
  444. logger.emit('newEvent', 'redisAdapterDisabled', { reason: 'REDIS_URL not set; single process' });
  445. }
  446. if (config.debug) {
  447. setInterval(() => sendBroadcast('Testing rooms'), 60000);
  448. }
  449. server.listen(config.port, () => {
  450. logger.emit('newEvent', 'serverStarted', {
  451. port: config.port,
  452. basePath: config.basePath || '/',
  453. mainRoom: config.mainRoom,
  454. });
  455. });
  456. async function shutdown(signal) {
  457. logger.emit('newEvent', 'shuttingDown', { signal });
  458. await io.close();
  459. await Promise.all(redisClients.map((client) => client.quit().catch(() => {})));
  460. server.close(() => process.exit(0));
  461. }
  462. process.on('SIGTERM', () => shutdown('SIGTERM'));
  463. process.on('SIGINT', () => shutdown('SIGINT'));