source: Klonkt/src/routes/audio.js@ bf61be7

main
Last change on this file since bf61be7 was 21522ae, checked in by Robin Genis <roboburr@…>, 4 months ago

audio: Spotify-style blob playback + same-origin gate (fix playback loop)

Root cause of the "next-loops-but-never-plays after 4-5 songs" bug: every
track URL was HMAC-signed once at page-render time with a 10-min TTL. A whole
queue shared that single deadline, so tracks further down expired mid-session
-> /audio/stream returned 403 -> audio 'error' -> auto-skip -> next track also
expired -> infinite loop. The 3-strike guard never fired because the eager
'play' event reset the counter before each 403 landed.

Removed the expiring-token system entirely and replaced it with two
non-expiring layers:

  • Client fetch()es track bytes and plays from a blob: object URL (no shareable URL, no "save audio as"); blobs revoked to avoid leaks; loadSeq guards fast prev/next; pre-seed is metadata-only (no auto-download).
  • Server gates /audio/stream to same-origin browser fetches (X-Audio-Player header or Sec-Fetch-Site): blocks address-bar paste, hotlinks, curl.

Also: reset error counter on real 'playing' event (not eager 'play') so the
3-strike auto-skip-stop actually works; fix admin play-state detection to
compare logical currentTrack().url instead of the now-blob: audio.src; bump
audio-player.js cache-buster v5.

Co-Authored-By: Claude Opus 4.7 <noreply@…>

  • Property mode set to 100644
File size: 4.5 KB
RevLine 
[7bc636b]1/**
[21522ae]2 * Audio streaming routes — byte-range streaming.
[7bc636b]3 *
[21522ae]4 * Files live in storage/audio/ and are NOT served by the static /media
5 * handler — every fetch goes through this route, which adds byte-range
6 * support so HTML5 <audio> can seek.
[7bc636b]7 *
[21522ae]8 * GET /audio/stream/:filename
9 * Streams the file with byte-range support.
10 *
11 * ANTI-THEFT (Spotify-flavoured, step 1 — 2026-05-20):
12 * The player never exposes this URL to the user — it fetch()es the bytes
13 * and plays from a blob: object URL (no shareable link, no "save audio as").
14 * This route additionally refuses anything that isn't a same-origin browser
15 * fetch, so the raw URL can't be pasted into the address bar, hotlinked from
16 * another site, or pulled with curl/yt-dlp.
17 *
18 * A request is allowed when EITHER:
19 * - it carries the X-Audio-Player header (our fetch sets it), OR
20 * - Sec-Fetch-Site is same-origin/same-site (covers the admin <audio>
21 * preview, which can't set custom headers).
22 * Address-bar paste sends Sec-Fetch-Site: none; hotlinks send cross-site;
23 * curl/yt-dlp send neither signal → all rejected.
[7bc636b]24 */
25
26import express from 'express';
27import fs from 'fs';
28import path from 'path';
29import { fileURLToPath } from 'url';
30
31const __dirname = path.dirname(fileURLToPath(import.meta.url));
32// Audio files live OUTSIDE storage/media — the public /media static handler
[21522ae]33// cannot reach them. Every fetch must go through this gated route.
[7bc636b]34const AUDIO_DIR = path.resolve(
35 process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio')
36);
37
38const router = express.Router();
39
40// MIME map for the formats v9 supported. Defaults to mpeg.
41const MIME = {
42 '.mp3': 'audio/mpeg',
43 '.m4a': 'audio/mp4',
44 '.mp4': 'audio/mp4',
45 '.aac': 'audio/aac',
46 '.oga': 'audio/ogg',
47 '.ogg': 'audio/ogg',
48 '.opus': 'audio/ogg',
49 '.flac': 'audio/flac',
50 '.wav': 'audio/wav',
51 '.webm': 'audio/webm',
52};
53
[21522ae]54// Access gate: allow only same-origin browser fetches / media loads.
55function isAllowedAudioRequest(req) {
56 if (req.get('X-Audio-Player') === '1') return true; // our blob fetch
57 const site = req.get('Sec-Fetch-Site'); // set by modern browsers
58 return site === 'same-origin' || site === 'same-site';
59}
60
[7bc636b]61router.get('/stream/:filename', (req, res) => {
62 const { filename } = req.params;
[21522ae]63
64 if (!isAllowedAudioRequest(req)) {
65 return res.status(403).send('Direct access not allowed');
66 }
[7bc636b]67
68 // Sanity: no path traversal, no slashes
69 if (!filename || filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
70 return res.status(400).send('Bad filename');
71 }
72
73 const filePath = path.join(AUDIO_DIR, filename);
74 // Belt-and-suspenders: confirm the resolved path stays inside AUDIO_DIR
75 if (!filePath.startsWith(AUDIO_DIR + path.sep) && filePath !== AUDIO_DIR) {
76 return res.status(400).send('Bad path');
77 }
78
79 let stat;
80 try {
81 stat = fs.statSync(filePath);
82 } catch (e) {
83 return res.status(404).send('Not found');
84 }
85 if (!stat.isFile()) return res.status(404).send('Not found');
86
87 const ext = path.extname(filename).toLowerCase();
88 const mime = MIME[ext] || 'audio/mpeg';
89 const total = stat.size;
90 const range = req.headers.range;
91
92 // Common headers
93 res.setHeader('Content-Type', mime);
94 res.setHeader('Accept-Ranges', 'bytes');
95 // Allow the browser to cache the file for a day so play/pause/replay
96 // doesn't re-fetch the whole stream every time. `private` keeps it out of
97 // shared proxies/CDNs (only the user's own browser cache), preserving the
98 // signed-URL access model. `immutable` skips the If-Modified-Since
99 // round-trip — the URL is content-addressed (signed token tied to file)
100 // so its content can't change.
101 res.setHeader('Cache-Control', 'private, max-age=86400, immutable');
102 res.setHeader('X-Content-Type-Options', 'nosniff');
103
104 if (!range) {
105 res.setHeader('Content-Length', total);
106 return fs.createReadStream(filePath).pipe(res);
107 }
108
109 // Parse "bytes=START-END"
110 const m = /^bytes=(\d+)-(\d*)$/.exec(range);
111 if (!m) {
112 res.status(416).setHeader('Content-Range', `bytes */${total}`);
113 return res.end();
114 }
115 const start = parseInt(m[1], 10);
116 const end = m[2] ? Math.min(parseInt(m[2], 10), total - 1) : total - 1;
117 if (start >= total || end < start) {
118 res.status(416).setHeader('Content-Range', `bytes */${total}`);
119 return res.end();
120 }
121
122 res.status(206);
123 res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
124 res.setHeader('Content-Length', end - start + 1);
125 fs.createReadStream(filePath, { start, end }).pipe(res);
126});
127
128export default router;
Note: See TracBrowser for help on using the repository browser.