From 10a992c0489ac1d3bd048a97ea3acc46697c2c37 Mon Sep 17 00:00:00 2001 From: Elmar Kresse Date: Thu, 4 Sep 2025 22:10:27 +0200 Subject: [PATCH] feat: implement audio token generation and update streaming endpoints --- .vscode/tasks.json | 33 ++++--- package.json | 11 +-- src/server/game.js | 12 ++- src/server/routes/audio.js | 172 ++++++++++++++++++++++++++++++------- 4 files changed, 180 insertions(+), 48 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 8b2baf8..4d967e0 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,13 +1,22 @@ { - "version": "2.0.0", - "tasks": [ - { - "label": "start-dev", - "type": "shell", - "command": "npm run start", - "isBackground": false, - "problemMatcher": ["$eslint-stylish"], - "group": "build" - } - ] -} + "version": "2.0.0", + "tasks": [ + { + "label": "start-dev", + "type": "shell", + "command": "npm run start", + "isBackground": false, + "problemMatcher": [ + "$eslint-stylish" + ], + "group": "build" + }, + { + "label": "start-dev", + "type": "shell", + "command": "npm run start", + "isBackground": false, + "group": "build" + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json index b32fae1..3b2cf15 100644 --- a/package.json +++ b/package.json @@ -18,19 +18,20 @@ }, "dependencies": { "express": "^4.19.2", + "lru-cache": "^11.0.0", "mime": "^3.0.0", "music-metadata": "^7.14.0", + "socket.io": "^4.7.5", "undici": "^6.19.8", - "uuid": "^9.0.1", - "socket.io": "^4.7.5" + "uuid": "^9.0.1" }, "devDependencies": { - "nodemon": "^3.1.0", - "eslint": "^9.11.1", "@eslint/js": "^9.11.1", - "globals": "^13.24.0", + "eslint": "^9.11.1", "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.29.1", + "globals": "^13.24.0", + "nodemon": "^3.1.0", "prettier": "^3.3.3" } } diff --git a/src/server/game.js b/src/server/game.js index 3c27e32..7924a3e 100644 --- a/src/server/game.js +++ b/src/server/game.js @@ -1,6 +1,7 @@ import { Server as SocketIOServer } from 'socket.io'; import { v4 as uuidv4 } from 'uuid'; import { rooms, createRoom, broadcast, roomSummary, nextPlayer, shuffle } from './game/state.js'; +import { createAudioToken } from './routes/audio.js'; import { loadDeck } from './game/deck.js'; import { startSyncTimer, stopSyncTimer } from './game/sync.js'; import { scoreTitle, scoreArtist, splitArtists } from './game/answerCheck.js'; @@ -13,7 +14,16 @@ function drawNextTrack(room) { broadcast(room, 'game_ended', { winner: null }); return; } - room.state.currentTrack = { ...track, url: `/audio/${encodeURIComponent(track.file)}` }; + // Generate an opaque, short-lived token for streaming the audio without exposing the file name + let tokenUrl = null; + try { + const token = createAudioToken(track.file); + tokenUrl = `/audio/t/${token}`; + } catch { + // Fallback to name-based URL if token generation fails (should be rare) + tokenUrl = `/audio/${encodeURIComponent(track.file)}`; + } + room.state.currentTrack = { ...track, url: tokenUrl }; room.state.phase = 'guess'; room.state.lastResult = null; room.state.paused = false; diff --git a/src/server/routes/audio.js b/src/server/routes/audio.js index 5f20649..199d4c2 100644 --- a/src/server/routes/audio.js +++ b/src/server/routes/audio.js @@ -1,41 +1,94 @@ import fs from 'fs'; import path from 'path'; import mime from 'mime'; +import crypto from 'crypto'; +import LRUCache from 'lru-cache'; import { parseFile as mmParseFile } from 'music-metadata'; import { DATA_DIR } from '../config.js'; +// Token cache: bounded LRU with TTL per entry +const TOKEN_TTL_MS_DEFAULT = 10 * 60 * 1000; // 10 minutes +const tokenCache = new LRUCache({ + max: 2048, + ttl: TOKEN_TTL_MS_DEFAULT, + ttlAutopurge: true, + allowStale: false, + updateAgeOnGet: false, + updateAgeOnHas: false, +}); + +function genToken() { + return crypto.randomBytes(16).toString('hex'); +} + +function resolveSafePath(name) { + const filePath = path.join(DATA_DIR, name); + const resolved = path.resolve(filePath); + if (!resolved.startsWith(DATA_DIR)) return null; + return resolved; +} + +export function createAudioToken(name, ttlMs = TOKEN_TTL_MS_DEFAULT) { + const resolved = resolveSafePath(name); + if (!resolved) throw new Error('Invalid path'); + if (!fs.existsSync(resolved)) throw new Error('Not found'); + const stat = fs.statSync(resolved); + const type = mime.getType(resolved) || 'audio/mpeg'; + const token = genToken(); + tokenCache.set( + token, + { path: resolved, mime: type, size: stat.size }, + { ttl: Math.max(1000, ttlMs) } + ); + return token; +} + export function registerAudioRoutes(app) { // Simple in-memory cover cache: name -> { mime, buf } - const coverCache = new Map(); + const COVER_CACHE_MAX_ITEMS = 256; + const COVER_CACHE_MAX_BYTES = 50 * 1024 * 1024; // 50 MB + const coverCache = new LRUCache({ + max: COVER_CACHE_MAX_ITEMS, + maxSize: COVER_CACHE_MAX_BYTES, + sizeCalculation: (v) => (v?.buf?.length ? v.buf.length : 0), + ttl: 5 * 60 * 1000, + ttlAutopurge: true, + }); + const ENABLE_NAME_ENDPOINT = process.env.AUDIO_DEBUG_NAMES === '1'; - app.head('/audio/:name', (req, res) => { - const name = req.params.name; - const filePath = path.join(DATA_DIR, name); - if (!filePath.startsWith(DATA_DIR)) return res.status(400).end(); - if (!fs.existsSync(filePath)) return res.status(404).end(); - const stat = fs.statSync(filePath); - const type = mime.getType(filePath) || 'audio/mpeg'; + // Cleanup helper + function getTokenInfo(token) { + const info = tokenCache.get(token); + if (!info) return null; + return info; + } + + // New HEAD endpoint using opaque token + app.head('/audio/t/:token', (req, res) => { + const token = String(req.params.token || ''); + const info = getTokenInfo(token); + if (!info) return res.status(404).end(); res.setHeader('Accept-Ranges', 'bytes'); - res.setHeader('Content-Type', type); - res.setHeader('Content-Length', stat.size); + res.setHeader('Content-Type', info.mime || 'audio/mpeg'); + res.setHeader('Content-Length', info.size); res.setHeader('Cache-Control', 'no-store'); return res.status(200).end(); }); - app.get('/audio/:name', (req, res) => { - const name = req.params.name; - const filePath = path.join(DATA_DIR, name); - if (!filePath.startsWith(DATA_DIR)) return res.status(400).send('Invalid path'); - if (!fs.existsSync(filePath)) return res.status(404).send('Not found'); + // New GET endpoint using opaque token (supports Range requests) + app.get('/audio/t/:token', (req, res) => { + const token = String(req.params.token || ''); + const info = getTokenInfo(token); + if (!info) return res.status(404).send('Not found'); + const { path: filePath, mime: type } = info; const stat = fs.statSync(filePath); const range = req.headers.range; - const type = mime.getType(filePath) || 'audio/mpeg'; res.setHeader('Accept-Ranges', 'bytes'); res.setHeader('Cache-Control', 'no-store'); if (range) { - const match = /bytes=(\d+)-(\d+)?/.exec(range); - let start = match && match[1] ? parseInt(match[1], 10) : 0; - let end = match && match[2] ? parseInt(match[2], 10) : stat.size - 1; + const match = /bytes=(\d+)-(\d+)?/.exec(range); + let start = match?.[1] ? parseInt(match[1], 10) : 0; + let end = match?.[2] ? parseInt(match[2], 10) : stat.size - 1; if (Number.isNaN(start)) start = 0; if (Number.isNaN(end)) end = stat.size - 1; start = Math.min(Math.max(0, start), Math.max(0, stat.size - 1)); @@ -60,30 +113,89 @@ export function registerAudioRoutes(app) { } }); + if (ENABLE_NAME_ENDPOINT) { + app.head('/audio/:name', (req, res) => { + const name = req.params.name; + const filePath = resolveSafePath(name); + if (!filePath) return res.status(400).end(); + if (!fs.existsSync(filePath)) return res.status(404).end(); + const stat = fs.statSync(filePath); + const type = mime.getType(filePath) || 'audio/mpeg'; + res.setHeader('Accept-Ranges', 'bytes'); + res.setHeader('Content-Type', type); + res.setHeader('Content-Length', stat.size); + res.setHeader('Cache-Control', 'no-store'); + return res.status(200).end(); + }); + + app.get('/audio/:name', (req, res) => { + const name = req.params.name; + const filePath = resolveSafePath(name); + if (!filePath) return res.status(400).send('Invalid path'); + if (!fs.existsSync(filePath)) return res.status(404).send('Not found'); + const stat = fs.statSync(filePath); + const range = req.headers.range; + const type = mime.getType(filePath) || 'audio/mpeg'; + res.setHeader('Accept-Ranges', 'bytes'); + res.setHeader('Cache-Control', 'no-store'); + if (range) { + const match = /bytes=(\d+)-(\d+)?/.exec(range); + let start = match?.[1] ? parseInt(match[1], 10) : 0; + let end = match?.[2] ? parseInt(match[2], 10) : stat.size - 1; + if (Number.isNaN(start)) start = 0; + if (Number.isNaN(end)) end = stat.size - 1; + start = Math.min(Math.max(0, start), Math.max(0, stat.size - 1)); + end = Math.min(Math.max(start, end), Math.max(0, stat.size - 1)); + if (start > end || start >= stat.size) { + res.setHeader('Content-Range', `bytes */${stat.size}`); + return res.status(416).end(); + } + const chunkSize = end - start + 1; + res.writeHead(206, { + 'Content-Range': `bytes ${start}-${end}/${stat.size}`, + 'Content-Length': chunkSize, + 'Content-Type': type, + }); + fs.createReadStream(filePath, { start, end }).pipe(res); + } else { + res.writeHead(200, { + 'Content-Length': stat.size, + 'Content-Type': type, + }); + fs.createReadStream(filePath).pipe(res); + } + }); + } else { + // Explicitly block the name-based endpoint to avoid leaking song names + app.all('/audio/:name', (req, res) => res.status(404).send('Not found')); + } + // Serve embedded cover art from audio files, if present app.get('/cover/:name', async (req, res) => { try { const name = req.params.name; - const filePath = path.join(DATA_DIR, name); - const resolved = path.resolve(filePath); - if (!resolved.startsWith(DATA_DIR)) return res.status(400).send('Invalid path'); - if (!fs.existsSync(resolved)) return res.status(404).send('Not found'); + const resolved = resolveSafePath(name); + if (!resolved) return res.status(400).send('Invalid path'); + if (!fs.existsSync(resolved)) { + res.status(404).send('Not found'); + return; + } - if (coverCache.has(resolved)) { - const c = coverCache.get(resolved); + const c = coverCache.get(resolved); + if (c) { res.setHeader('Content-Type', c.mime || 'image/jpeg'); res.setHeader('Cache-Control', 'no-store'); return res.status(200).end(c.buf); } const meta = await mmParseFile(resolved, { duration: false }); - const pic = meta.common?.picture?.[0]; - if (!pic || !pic.data || !pic.data.length) { + const pic = meta.common?.picture?.[0]; + if (!pic?.data?.length) { return res.status(404).send('No cover'); } - const mimeType = pic.format || 'image/jpeg'; - const buf = Buffer.from(pic.data); - coverCache.set(resolved, { mime: mimeType, buf }); + const mimeType = pic.format || 'image/jpeg'; + const buf = Buffer.from(pic.data); + coverCache.set(resolved, { mime: mimeType, buf }); res.setHeader('Content-Type', mimeType); res.setHeader('Cache-Control', 'no-store'); return res.status(200).end(buf);