Changeset 21522ae in Klonkt for src/routes


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

Location:
src/routes
Files:
4 edited

Legend:

Unmodified
Added
Removed
  • src/routes/admin-audio.js

    r46f23fd r21522ae  
    2020import { requireGod } from '../middleware/auth.js';
    2121import { transcodeToMp3 } from '../services/AudioTranscoder.js';
    22 import { signUrl } from '../services/AudioStreamService.js';
     22import { audioUrl } from '../services/AudioStreamService.js';
    2323
    2424const __dirname = path.dirname(fileURLToPath(import.meta.url));
     
    8080  `).all(site.id);
    8181
    82   // Sign each track's stream URL so admins can preview audio inline.
    83   // Short TTL (default 10 min from AudioStreamService) means the URL on
    84   // the page expires if it sits open too long; a refresh re-signs.
     82  // Build each track's stream URL so admins can preview audio inline.
    8583  const tracks = rows.map(t => ({
    8684    ...t,
    87     stream_url: t.filename ? signUrl(t.filename).url : null,
     85    stream_url: t.filename ? audioUrl(t.filename) : null,
    8886  }));
    8987
     
    335333  `).get(req.params.id, site.id);
    336334  if (!t) return res.status(404).json({ error: 'Track niet gevonden' });
    337   // Sign the stream URL so the modal can render an inline preview player.
    338   const stream_url = t.filename ? signUrl(t.filename).url : null;
     335  // Stream URL so the modal can render an inline preview player.
     336  const stream_url = t.filename ? audioUrl(t.filename) : null;
    339337  res.json({ ok: true, track: { ...t, stream_url } });
    340338});
  • src/routes/admin-playlists.js

    r46f23fd r21522ae  
    112112  if (!site) return res.status(404).json({ error: 'Site required' });
    113113
    114   // Editor needs the raw track-id list (not signed URLs) — pass no signUrl.
     114  // Editor needs the raw track-id list (not stream URLs) — pass no urlFor.
    115115  const playlist = PlaylistService.get(site.id, req.params.id, null);
    116116  if (!playlist) return res.status(404).json({ error: 'Playlist niet gevonden' });
  • 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
  • src/routes/posts.js

    r46f23fd r21522ae  
    1313import AudioEmbedService from '../services/AudioEmbedService.js';
    1414import PlaylistService from '../services/PlaylistService.js';
    15 import { signUrl } from '../services/AudioStreamService.js';
     15import { audioUrl } from '../services/AudioStreamService.js';
    1616
    1717const __dirname = path.dirname(fileURLToPath(import.meta.url));
     
    419419          artist: r.artist,
    420420          cover: r.cover_url,
    421           url: signUrl(r.filename).url,
     421          url: audioUrl(r.filename),
    422422        };
    423423      });
     
    439439        if (!byAlbum.has(r.album)) byAlbum.set(r.album, []);
    440440        byAlbum.get(r.album).push({
    441           url: signUrl(r.filename).url,
     441          url: audioUrl(r.filename),
    442442          title: r.title || 'Untitled',
    443443          artist: r.artist || '',
     
    464464      const isAdmin = req.session?.user?.role === 'god';
    465465      html = AudioEmbedService.embedPlaylistShortcodes(html, (id) => {
    466         return PlaylistService.get(site.id, id, signUrl);
     466        return PlaylistService.get(site.id, id, audioUrl);
    467467      }, { isAdmin });
    468468    }
Note: See TracChangeset for help on using the changeset viewer.