Changeset 21522ae in Klonkt for src/routes/audio.js


Ignore:
Timestamp:
05/20/2026 10:14:01 PM (4 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
353c39c
Parents:
46f23fd
git-author:
Robin Genis <roboburr@…> (05/20/2026 10:13:26 PM)
git-committer:
Robin Genis <roboburr@…> (05/20/2026 10:14:01 PM)
Message:

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@…>

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/routes/audio.js

    r46f23fd r21522ae  
    11/**
    2  * Audio streaming routes — v9-style signed URL + byte-range support.
     2 * Audio streaming routes — byte-range streaming.
    33 *
    4  * Files live in storage/media/audio/ and are NOT served by the static
    5  * /media handler — every fetch must go through this verified route.
     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.
    67 *
    7  * GET /audio/stream/:filename?t=<hmac>&exp=<unix>
    8  *   Verifies the token. If valid, streams the file with byte-range support
    9  *   so HTML5 <audio> can seek. Anything invalid returns 403.
     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.
    1024 */
    1125
     
    1428import path from 'path';
    1529import { fileURLToPath } from 'url';
    16 import { verifyToken } from '../services/AudioStreamService.js';
    1730
    1831const __dirname = path.dirname(fileURLToPath(import.meta.url));
    1932// Audio files live OUTSIDE storage/media — the public /media static handler
    20 // cannot reach them. Every fetch must go through this signed route.
     33// cannot reach them. Every fetch must go through this gated route.
    2134const AUDIO_DIR = path.resolve(
    2235  process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio')
     
    3952};
    4053
     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
    4161router.get('/stream/:filename', (req, res) => {
    4262  const { filename } = req.params;
    43   const { t, exp } = req.query;
     63
     64  if (!isAllowedAudioRequest(req)) {
     65    return res.status(403).send('Direct access not allowed');
     66  }
    4467
    4568  // Sanity: no path traversal, no slashes
    4669  if (!filename || filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
    4770    return res.status(400).send('Bad filename');
    48   }
    49 
    50   if (!verifyToken(filename, t, exp)) {
    51     return res.status(403).send('Invalid or expired token');
    5271  }
    5372
Note: See TracChangeset for help on using the changeset viewer.