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/services/AudioStreamService.js

    r46f23fd r21522ae  
    11/**
    2  * AudioStreamService — Signed audio streaming, v9-style.
     2 * AudioStreamService — builds URLs for the audio streaming route.
    33 *
    4  * The src of <audio> is /audio/stream/:filename?t=HMAC&exp=TIMESTAMP.
    5  * HMAC = SHA256(filename|exp|AUDIO_SECRET).
    6  *
    7  * Defeats hotlinking, scrapers, casual URL sharing — not state actors.
    8  * Token TTL: 10 minutes (long enough for a track, short enough that a
    9  * shared link expires before anyone can use it).
    10  *
    11  * AUDIO_SECRET comes from env. If missing on first boot, generate one
    12  * and persist to storage/.audio-secret so it survives restarts.
     4 * ┌─ ANTI-THEFT MODEL (Spotify-flavoured, step 1 — 2026-05-20) ─────────────┐
     5 * │ audioUrl() returns a plain /audio/stream/<filename> path. There is NO   │
     6 * │ signed/expiring token in the URL — that earlier design baked a single   │
     7 * │ 10-min deadline into a whole queue at render time, so later tracks'     │
     8 * │ tokens expired mid-session and the player looped "next" forever.        │
     9 * │                                                                          │
     10 * │ Protection now lives in two NON-expiring layers, so it can't cause that │
     11 * │ failure again:                                                           │
     12 * │   1. Client (audio-player.js) fetch()es the bytes and plays from a      │
     13 * │      blob: object URL — no shareable link, no "save audio as".          │
     14 * │   2. Server (routes/audio.js) gates /audio/stream to same-origin        │
     15 * │      browser fetches — blocks address-bar paste, hotlinks, curl/yt-dlp. │
     16 * │                                                                          │
     17 * │ FUTURE STEPS (deliberate, tested one at a time):                         │
     18 * │   - step 2: MSE chunked/progressive streaming (true Spotify feel)        │
     19 * │   - step 3: per-session short-lived token in a header, minted JIT        │
     20 * │   - step 4: light byte obfuscation (XOR/key) on the wire                 │
     21 * └──────────────────────────────────────────────────────────────────────┘
    1322 */
    1423
    15 import crypto from 'crypto';
    16 import fs from 'fs';
    17 import path from 'path';
    18 import { fileURLToPath } from 'url';
    19 
    20 const __dirname = path.dirname(fileURLToPath(import.meta.url));
    21 const SECRET_FILE = path.join(__dirname, '..', '..', 'storage', '.audio-secret');
    22 
    23 export const TOKEN_TTL_SECONDS = 600;
    24 
    25 let cachedSecret = null;
    26 
    27 function loadOrGenerateSecret() {
    28   if (cachedSecret) return cachedSecret;
    29 
    30   // 1. Env wins
    31   if (process.env.AUDIO_SECRET && process.env.AUDIO_SECRET.length >= 32) {
    32     cachedSecret = process.env.AUDIO_SECRET;
    33     return cachedSecret;
    34   }
    35 
    36   // 2. Persisted file
    37   try {
    38     const fromDisk = fs.readFileSync(SECRET_FILE, 'utf-8').trim();
    39     if (fromDisk.length >= 32) {
    40       cachedSecret = fromDisk;
    41       return cachedSecret;
    42     }
    43   } catch (e) { /* file missing — generate */ }
    44 
    45   // 3. Generate + persist
    46   const generated = crypto.randomBytes(32).toString('hex');
    47   try {
    48     fs.mkdirSync(path.dirname(SECRET_FILE), { recursive: true });
    49     fs.writeFileSync(SECRET_FILE, generated, { mode: 0o600 });
    50     console.log('AudioStreamService: generated new audio secret at', SECRET_FILE);
    51   } catch (e) {
    52     console.error('AudioStreamService: could not persist audio secret:', e.message);
    53   }
    54   cachedSecret = generated;
    55   return cachedSecret;
     24/**
     25 * Build the public stream URL for an audio filename.
     26 * Returns null for a falsy filename so callers can guard playability.
     27 */
     28export function audioUrl(filename) {
     29  if (!filename) return null;
     30  return `/audio/stream/${encodeURIComponent(filename)}`;
    5631}
    5732
    58 function makeHmac(filename, exp) {
    59   const secret = loadOrGenerateSecret();
    60   return crypto
    61     .createHmac('sha256', secret)
    62     .update(`${filename}|${exp}`)
    63     .digest('hex');
    64 }
    65 
    66 /**
    67  * Sign a filename → returns { url, exp, t } so callers can build the URL.
    68  * The full URL is /audio/stream/<filename>?t=<t>&exp=<exp>.
    69  */
    70 export function signUrl(filename, ttlSeconds = TOKEN_TTL_SECONDS) {
    71   const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
    72   const t = makeHmac(filename, exp);
    73   const safe = encodeURIComponent(filename);
    74   return {
    75     url: `/audio/stream/${safe}?t=${t}&exp=${exp}`,
    76     exp,
    77     t,
    78   };
    79 }
    80 
    81 /**
    82  * Verify a token for a filename. Returns true iff exp is in the future
    83  * AND the HMAC matches.
    84  */
    85 export function verifyToken(filename, t, exp) {
    86   if (!filename || !t || !exp) return false;
    87   const expNum = Number(exp);
    88   if (!Number.isFinite(expNum)) return false;
    89   if (expNum < Math.floor(Date.now() / 1000)) return false;
    90 
    91   const expected = makeHmac(filename, expNum);
    92   // timingSafeEqual requires equal-length buffers
    93   try {
    94     const a = Buffer.from(t, 'hex');
    95     const b = Buffer.from(expected, 'hex');
    96     if (a.length !== b.length) return false;
    97     return crypto.timingSafeEqual(a, b);
    98   } catch (e) {
    99     return false;
    100   }
    101 }
    102 
    103 /**
    104  * Force-rotate the secret. Invalidates all outstanding tokens.
    105  */
    106 export function rotateSecret() {
    107   cachedSecret = null;
    108   try { fs.unlinkSync(SECRET_FILE); } catch (e) {}
    109   return loadOrGenerateSecret();
    110 }
    111 
    112 export default { signUrl, verifyToken, rotateSecret, TOKEN_TTL_SECONDS };
     33export default { audioUrl };
Note: See TracChangeset for help on using the changeset viewer.