feat: implement audio token generation and update streaming endpoints
All checks were successful
Build and Push Docker Image / docker (push) Successful in 21s
All checks were successful
Build and Push Docker Image / docker (push) Successful in 21s
This commit is contained in:
11
.vscode/tasks.json
vendored
11
.vscode/tasks.json
vendored
@@ -6,7 +6,16 @@
|
|||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "npm run start",
|
"command": "npm run start",
|
||||||
"isBackground": false,
|
"isBackground": false,
|
||||||
"problemMatcher": ["$eslint-stylish"],
|
"problemMatcher": [
|
||||||
|
"$eslint-stylish"
|
||||||
|
],
|
||||||
|
"group": "build"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "start-dev",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npm run start",
|
||||||
|
"isBackground": false,
|
||||||
"group": "build"
|
"group": "build"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -18,19 +18,20 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
|
"lru-cache": "^11.0.0",
|
||||||
"mime": "^3.0.0",
|
"mime": "^3.0.0",
|
||||||
"music-metadata": "^7.14.0",
|
"music-metadata": "^7.14.0",
|
||||||
|
"socket.io": "^4.7.5",
|
||||||
"undici": "^6.19.8",
|
"undici": "^6.19.8",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1"
|
||||||
"socket.io": "^4.7.5"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.0",
|
|
||||||
"eslint": "^9.11.1",
|
|
||||||
"@eslint/js": "^9.11.1",
|
"@eslint/js": "^9.11.1",
|
||||||
"globals": "^13.24.0",
|
"eslint": "^9.11.1",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-import": "^2.29.1",
|
"eslint-plugin-import": "^2.29.1",
|
||||||
|
"globals": "^13.24.0",
|
||||||
|
"nodemon": "^3.1.0",
|
||||||
"prettier": "^3.3.3"
|
"prettier": "^3.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Server as SocketIOServer } from 'socket.io';
|
import { Server as SocketIOServer } from 'socket.io';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { rooms, createRoom, broadcast, roomSummary, nextPlayer, shuffle } from './game/state.js';
|
import { rooms, createRoom, broadcast, roomSummary, nextPlayer, shuffle } from './game/state.js';
|
||||||
|
import { createAudioToken } from './routes/audio.js';
|
||||||
import { loadDeck } from './game/deck.js';
|
import { loadDeck } from './game/deck.js';
|
||||||
import { startSyncTimer, stopSyncTimer } from './game/sync.js';
|
import { startSyncTimer, stopSyncTimer } from './game/sync.js';
|
||||||
import { scoreTitle, scoreArtist, splitArtists } from './game/answerCheck.js';
|
import { scoreTitle, scoreArtist, splitArtists } from './game/answerCheck.js';
|
||||||
@@ -13,7 +14,16 @@ function drawNextTrack(room) {
|
|||||||
broadcast(room, 'game_ended', { winner: null });
|
broadcast(room, 'game_ended', { winner: null });
|
||||||
return;
|
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.phase = 'guess';
|
||||||
room.state.lastResult = null;
|
room.state.lastResult = null;
|
||||||
room.state.paused = false;
|
room.state.paused = false;
|
||||||
|
|||||||
@@ -1,41 +1,94 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import mime from 'mime';
|
import mime from 'mime';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import LRUCache from 'lru-cache';
|
||||||
import { parseFile as mmParseFile } from 'music-metadata';
|
import { parseFile as mmParseFile } from 'music-metadata';
|
||||||
import { DATA_DIR } from '../config.js';
|
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) {
|
export function registerAudioRoutes(app) {
|
||||||
// Simple in-memory cover cache: name -> { mime, buf }
|
// 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) => {
|
// Cleanup helper
|
||||||
const name = req.params.name;
|
function getTokenInfo(token) {
|
||||||
const filePath = path.join(DATA_DIR, name);
|
const info = tokenCache.get(token);
|
||||||
if (!filePath.startsWith(DATA_DIR)) return res.status(400).end();
|
if (!info) return null;
|
||||||
if (!fs.existsSync(filePath)) return res.status(404).end();
|
return info;
|
||||||
const stat = fs.statSync(filePath);
|
}
|
||||||
const type = mime.getType(filePath) || 'audio/mpeg';
|
|
||||||
|
// 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('Accept-Ranges', 'bytes');
|
||||||
res.setHeader('Content-Type', type);
|
res.setHeader('Content-Type', info.mime || 'audio/mpeg');
|
||||||
res.setHeader('Content-Length', stat.size);
|
res.setHeader('Content-Length', info.size);
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
return res.status(200).end();
|
return res.status(200).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/audio/:name', (req, res) => {
|
// New GET endpoint using opaque token (supports Range requests)
|
||||||
const name = req.params.name;
|
app.get('/audio/t/:token', (req, res) => {
|
||||||
const filePath = path.join(DATA_DIR, name);
|
const token = String(req.params.token || '');
|
||||||
if (!filePath.startsWith(DATA_DIR)) return res.status(400).send('Invalid path');
|
const info = getTokenInfo(token);
|
||||||
if (!fs.existsSync(filePath)) return res.status(404).send('Not found');
|
if (!info) return res.status(404).send('Not found');
|
||||||
|
const { path: filePath, mime: type } = info;
|
||||||
const stat = fs.statSync(filePath);
|
const stat = fs.statSync(filePath);
|
||||||
const range = req.headers.range;
|
const range = req.headers.range;
|
||||||
const type = mime.getType(filePath) || 'audio/mpeg';
|
|
||||||
res.setHeader('Accept-Ranges', 'bytes');
|
res.setHeader('Accept-Ranges', 'bytes');
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
if (range) {
|
if (range) {
|
||||||
const match = /bytes=(\d+)-(\d+)?/.exec(range);
|
const match = /bytes=(\d+)-(\d+)?/.exec(range);
|
||||||
let start = match && match[1] ? parseInt(match[1], 10) : 0;
|
let start = match?.[1] ? parseInt(match[1], 10) : 0;
|
||||||
let end = match && match[2] ? parseInt(match[2], 10) : stat.size - 1;
|
let end = match?.[2] ? parseInt(match[2], 10) : stat.size - 1;
|
||||||
if (Number.isNaN(start)) start = 0;
|
if (Number.isNaN(start)) start = 0;
|
||||||
if (Number.isNaN(end)) end = stat.size - 1;
|
if (Number.isNaN(end)) end = stat.size - 1;
|
||||||
start = Math.min(Math.max(0, start), Math.max(0, stat.size - 1));
|
start = Math.min(Math.max(0, start), Math.max(0, stat.size - 1));
|
||||||
@@ -60,17 +113,76 @@ 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
|
// Serve embedded cover art from audio files, if present
|
||||||
app.get('/cover/:name', async (req, res) => {
|
app.get('/cover/:name', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const name = req.params.name;
|
const name = req.params.name;
|
||||||
const filePath = path.join(DATA_DIR, name);
|
const resolved = resolveSafePath(name);
|
||||||
const resolved = path.resolve(filePath);
|
if (!resolved) return res.status(400).send('Invalid path');
|
||||||
if (!resolved.startsWith(DATA_DIR)) return res.status(400).send('Invalid path');
|
if (!fs.existsSync(resolved)) {
|
||||||
if (!fs.existsSync(resolved)) return res.status(404).send('Not found');
|
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('Content-Type', c.mime || 'image/jpeg');
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
return res.status(200).end(c.buf);
|
return res.status(200).end(c.buf);
|
||||||
@@ -78,7 +190,7 @@ export function registerAudioRoutes(app) {
|
|||||||
|
|
||||||
const meta = await mmParseFile(resolved, { duration: false });
|
const meta = await mmParseFile(resolved, { duration: false });
|
||||||
const pic = meta.common?.picture?.[0];
|
const pic = meta.common?.picture?.[0];
|
||||||
if (!pic || !pic.data || !pic.data.length) {
|
if (!pic?.data?.length) {
|
||||||
return res.status(404).send('No cover');
|
return res.status(404).send('No cover');
|
||||||
}
|
}
|
||||||
const mimeType = pic.format || 'image/jpeg';
|
const mimeType = pic.format || 'image/jpeg';
|
||||||
|
|||||||
Reference in New Issue
Block a user