Changeset 21522ae in Klonkt
- Timestamp:
- 05/20/2026 10:14:01 PM (4 months ago)
- 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)
- Location:
- src
- Files:
-
- 10 edited
-
assets/js/audio-player.js (modified) (10 diffs)
-
routes/admin-audio.js (modified) (3 diffs)
-
routes/admin-playlists.js (modified) (1 diff)
-
routes/audio.js (modified) (3 diffs)
-
routes/posts.js (modified) (4 diffs)
-
services/AudioStreamService.js (modified) (1 diff)
-
services/PlaylistService.js (modified) (2 diffs)
-
views/pages/admin-audio.ejs (modified) (2 diffs)
-
views/partials/track-editor.ejs (modified) (1 diff)
-
views/shell.ejs (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/assets/js/audio-player.js
r46f23fd r21522ae 158 158 let isPlaying = false; 159 159 let albumName = ''; 160 // Blob playback state. We fetch each track's bytes and play from a blob: 161 // object URL — no plain media URL is ever exposed to the page. currentObjectUrl 162 // is revoked when we move on, so we don't leak one Blob per track in memory. 163 let currentObjectUrl = null; 164 // Monotonic load token: a fast prev/next can fire several loads before an 165 // earlier fetch resolves. Only the latest load may set audio.src. 166 let loadSeq = 0; 160 167 161 168 // Hide initially … … 177 184 } 178 185 179 function loadTrack(index) { 186 // Fetch the track bytes and hand back a blob: object URL. The X-Audio-Player 187 // header + same-origin credentials get us past the stream route's access gate. 188 async function fetchAsObjectUrl(url) { 189 const r = await fetch(url, { 190 credentials: 'same-origin', 191 headers: { 'X-Audio-Player': '1' }, 192 }); 193 if (!r.ok) throw new Error('HTTP ' + r.status); 194 const blob = await r.blob(); 195 return URL.createObjectURL(blob); 196 } 197 198 // metaOnly: show the track in the UI but DON'T download its bytes yet. 199 // Used by the site pre-seed so opening a page doesn't auto-download audio; 200 // the blob is fetched lazily on the first play(). 201 function loadTrack(index, autoplay, metaOnly) { 180 202 if (!queue[index]) { 181 203 console.warn('[pcms-audio] loadTrack: no track at index', index); … … 188 210 return; 189 211 } 190 console.log('[pcms-audio] loading', t.title, t.url); 191 // Schone overgang: pause + reset voorkomt state-corruption van het 192 // audio-element na meerdere src-changes (bug die continuous playback 193 // brak na 3-4 tracks). audio.load() forceert reset van internal state. 194 try { audio.pause(); } catch (e) {} 195 audio.src = t.url; 196 try { audio.load(); } catch (e) {} 212 console.log('[pcms-audio] loading', t.title, t.url, metaOnly ? '(meta only)' : ''); 213 // Metadata + chrome update synchronously so the UI reacts instantly while 214 // the bytes download. 197 215 titleEl.textContent = t.title || 'Untitled'; 198 216 artistEl.textContent = t.artist || ''; … … 205 223 document.body.classList.add('has-audio-player'); 206 224 renderQueue(); 225 226 if (metaOnly) return; 227 228 const mySeq = ++loadSeq; 229 root.classList.add('audio-loading'); 230 fetchAsObjectUrl(t.url).then((objUrl) => { 231 if (mySeq !== loadSeq) { URL.revokeObjectURL(objUrl); return; } // superseded 232 root.classList.remove('audio-loading'); 233 // Free the previous track's blob — otherwise every track leaks a copy. 234 if (currentObjectUrl) { try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {} } 235 currentObjectUrl = objUrl; 236 // Schone overgang: pause + load forceert reset van internal state na 237 // meerdere src-changes (voorkomt state-corruption van het audio-element). 238 try { audio.pause(); } catch (e) {} 239 audio.src = objUrl; 240 try { audio.load(); } catch (e) {} 241 if (autoplay) play(); 242 }).catch((err) => { 243 if (mySeq !== loadSeq) return; // superseded — ignore stale failure 244 root.classList.remove('audio-loading'); 245 console.error('[pcms-audio] track fetch failed', err); 246 // Treat a failed download like a playback error: bump the counter and 247 // auto-skip, but stop after 3 in a row so we never loop forever. 248 consecutiveErrors++; 249 if (consecutiveErrors < 3 && queue.length > 1) setTimeout(next, 400); 250 }); 207 251 } 208 252 … … 211 255 albumName = (opts && opts.albumName) || ''; 212 256 if (!queue.length) return; 213 loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0); 214 play(); 257 loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0, true); 215 258 } 216 259 217 260 function play() { 218 if (!audio.src) return; 261 if (!audio.src) { 262 // Nothing fetched yet (pre-seed showed metadata only, or a load is still 263 // in flight). Kick off the blob load for the current track and autoplay. 264 if (queue[currentIndex]) loadTrack(currentIndex, true); 265 return; 266 } 219 267 const p = audio.play(); 220 268 if (p && typeof p.catch === 'function') { … … 236 284 function next() { 237 285 if (!queue.length) return; 238 loadTrack((currentIndex + 1) % queue.length); 239 play(); 286 loadTrack((currentIndex + 1) % queue.length, true); 240 287 } 241 288 function prev() { 242 289 if (!queue.length) return; 243 loadTrack(currentIndex === 0 ? queue.length - 1 : currentIndex - 1); 244 play(); 290 loadTrack(currentIndex === 0 ? queue.length - 1 : currentIndex - 1, true); 245 291 } 246 292 function close() { … … 251 297 queue = []; 252 298 albumName = ''; 299 loadSeq++; // cancel any in-flight load 300 if (currentObjectUrl) { try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {} } 301 currentObjectUrl = null; 302 try { audio.removeAttribute('src'); audio.load(); } catch (e) {} 253 303 } 254 304 … … 268 318 const idx = parseInt(li.dataset.idx, 10); 269 319 if (!isNaN(idx) && idx !== currentIndex) { 270 loadTrack(idx); 271 play(); 320 loadTrack(idx, true); 272 321 } 273 322 }); … … 288 337 audio.addEventListener('play', () => { 289 338 isPlaying = true; 290 consecutiveErrors = 0; // reset bij succesvolle play291 339 root.classList.add('is-playing'); 292 340 root.classList.remove('audio-needs-tap'); // verstop tap-hint 293 341 }); 342 // Reset de error-teller pas bij ECHTE playback-start (`playing`), niet bij 343 // het eager `play`-event. `play` vuurt vóór een eventuele netwerk-/decode- 344 // fout, dus resetten daar zou de 3-strikes-stop nooit laten triggeren bij 345 // een kapotte track → infinite "next"-loop. `playing` vuurt alleen als er 346 // daadwerkelijk audio speelt. 347 audio.addEventListener('playing', () => { consecutiveErrors = 0; }); 294 348 audio.addEventListener('pause', () => { isPlaying = false; root.classList.remove('is-playing'); }); 295 349 audio.addEventListener('ended', next); … … 539 593 cover: t.cover_url || t.cover || '', 540 594 })); 541 if (queue.length) loadTrack(0 );595 if (queue.length) loadTrack(0, false, true); // metadata only — fetch on first play 542 596 } 543 597 })(); -
src/routes/admin-audio.js
r46f23fd r21522ae 20 20 import { requireGod } from '../middleware/auth.js'; 21 21 import { transcodeToMp3 } from '../services/AudioTranscoder.js'; 22 import { signUrl } from '../services/AudioStreamService.js';22 import { audioUrl } from '../services/AudioStreamService.js'; 23 23 24 24 const __dirname = path.dirname(fileURLToPath(import.meta.url)); … … 80 80 `).all(site.id); 81 81 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. 85 83 const tracks = rows.map(t => ({ 86 84 ...t, 87 stream_url: t.filename ? signUrl(t.filename).url: null,85 stream_url: t.filename ? audioUrl(t.filename) : null, 88 86 })); 89 87 … … 335 333 `).get(req.params.id, site.id); 336 334 if (!t) return res.status(404).json({ error: 'Track niet gevonden' }); 337 // S ign 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; 339 337 res.json({ ok: true, track: { ...t, stream_url } }); 340 338 }); -
src/routes/admin-playlists.js
r46f23fd r21522ae 112 112 if (!site) return res.status(404).json({ error: 'Site required' }); 113 113 114 // Editor needs the raw track-id list (not s igned URLs) — pass no signUrl.114 // Editor needs the raw track-id list (not stream URLs) — pass no urlFor. 115 115 const playlist = PlaylistService.get(site.id, req.params.id, null); 116 116 if (!playlist) return res.status(404).json({ error: 'Playlist niet gevonden' }); -
src/routes/audio.js
r46f23fd r21522ae 1 1 /** 2 * Audio streaming routes — v9-style signed URL + byte-range support.2 * Audio streaming routes — byte-range streaming. 3 3 * 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. 6 7 * 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. 10 24 */ 11 25 … … 14 28 import path from 'path'; 15 29 import { fileURLToPath } from 'url'; 16 import { verifyToken } from '../services/AudioStreamService.js';17 30 18 31 const __dirname = path.dirname(fileURLToPath(import.meta.url)); 19 32 // 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. 21 34 const AUDIO_DIR = path.resolve( 22 35 process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio') … … 39 52 }; 40 53 54 // Access gate: allow only same-origin browser fetches / media loads. 55 function 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 41 61 router.get('/stream/:filename', (req, res) => { 42 62 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 } 44 67 45 68 // Sanity: no path traversal, no slashes 46 69 if (!filename || filename.includes('/') || filename.includes('\\') || filename.includes('..')) { 47 70 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');52 71 } 53 72 -
src/routes/posts.js
r46f23fd r21522ae 13 13 import AudioEmbedService from '../services/AudioEmbedService.js'; 14 14 import PlaylistService from '../services/PlaylistService.js'; 15 import { signUrl } from '../services/AudioStreamService.js';15 import { audioUrl } from '../services/AudioStreamService.js'; 16 16 17 17 const __dirname = path.dirname(fileURLToPath(import.meta.url)); … … 419 419 artist: r.artist, 420 420 cover: r.cover_url, 421 url: signUrl(r.filename).url,421 url: audioUrl(r.filename), 422 422 }; 423 423 }); … … 439 439 if (!byAlbum.has(r.album)) byAlbum.set(r.album, []); 440 440 byAlbum.get(r.album).push({ 441 url: signUrl(r.filename).url,441 url: audioUrl(r.filename), 442 442 title: r.title || 'Untitled', 443 443 artist: r.artist || '', … … 464 464 const isAdmin = req.session?.user?.role === 'god'; 465 465 html = AudioEmbedService.embedPlaylistShortcodes(html, (id) => { 466 return PlaylistService.get(site.id, id, signUrl);466 return PlaylistService.get(site.id, id, audioUrl); 467 467 }, { isAdmin }); 468 468 } -
src/services/AudioStreamService.js
r46f23fd r21522ae 1 1 /** 2 * AudioStreamService — Signed audio streaming, v9-style.2 * AudioStreamService — builds URLs for the audio streaming route. 3 3 * 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 * └──────────────────────────────────────────────────────────────────────┘ 13 22 */ 14 23 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 */ 28 export function audioUrl(filename) { 29 if (!filename) return null; 30 return `/audio/stream/${encodeURIComponent(filename)}`; 56 31 } 57 32 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 }; 33 export default { audioUrl }; -
src/services/PlaylistService.js
r46f23fd r21522ae 77 77 * Returns null if the playlist doesn't exist. 78 78 * 79 * ` signUrl` is an optional callback that takes a media filename and returns80 * a (possibly signed) URL. If not provided, tracks come back with no `url`81 * and the caller has to resolve them. The render pipeline in posts.js82 * always passes signUrl.83 */ 84 static get(siteId, id, signUrl) {79 * `urlFor` is an optional callback that takes a media filename and returns 80 * its stream URL. If not provided, tracks come back with no `url` and the 81 * caller has to resolve them. The render pipeline in posts.js always passes 82 * urlFor. 83 */ 84 static get(siteId, id, urlFor) { 85 85 id = this.normalizeId(id); 86 86 if (!id) return null; … … 117 117 cover: t.cover_url || p.cover_url || '', 118 118 duration: t.duration || 0, 119 url: signUrl ? signUrl(t.filename).url: null,119 url: urlFor ? urlFor(t.filename) : null, 120 120 })), 121 121 }; -
src/views/pages/admin-audio.ejs
r46f23fd r21522ae 583 583 function resyncAll() { 584 584 const audio = document.getElementById('audio-element'); 585 const player = window.pcmsAudioPlayer; 585 586 const playing = audio && !audio.paused && !audio.ended; 586 const currentSrc = audio ? audio.src : ''; 587 // audio.src is now a blob: URL (Spotify-style playback), so compare 588 // against the player's logical track URL, not the element src. 589 const cur = player && player.currentTrack(); 590 const curUrl = cur ? cur.url : ''; 587 591 buttons.forEach(b => { 588 const isThisOne = playing && cur rentSrc.endsWith(b.dataset.streamUrl);592 const isThisOne = playing && curUrl === b.dataset.streamUrl; 589 593 setIcon(b, isThisOne); 590 594 }); … … 617 621 }; 618 622 619 // If this exact track is already playing, toggle pause/play instead620 // of restarting from zero. 621 const audio = document.getElementById('audio-element');622 if ( audio && audio.src.endsWith(url)) {623 if ( audio.paused) player.play();624 else player.pause();623 // If this exact track is already current, toggle pause/play instead 624 // of restarting from zero. Compare logical URLs (audio.src is a blob:). 625 const cur = player.currentTrack(); 626 if (cur && cur.url === url) { 627 if (player.isPlaying()) player.pause(); 628 else player.play(); 625 629 return; 626 630 } -
src/views/partials/track-editor.ejs
r46f23fd r21522ae 445 445 }; 446 446 const isOurTrack = () => { 447 const audio = document.getElementById('audio-element'); 448 return audio && track.stream_url && audio.src.endsWith(track.stream_url); 447 // audio.src is a blob: URL (Spotify-style playback) — compare against 448 // the player's logical current-track URL instead. 449 const player = window.pcmsAudioPlayer; 450 const cur = player && player.currentTrack(); 451 return !!(cur && track.stream_url && cur.url === track.stream_url); 449 452 }; 450 453 const resync = () => { -
src/views/shell.ejs
r46f23fd r21522ae 277 277 ?v=N — cache-buster: bump bij elke audio-player.js wijziging zodat 278 278 Cloudflare (max-age=1y) niet de oude versie blijft serveren. --> 279 <script src="/assets/js/audio-player.js?v= 3"></script>279 <script src="/assets/js/audio-player.js?v=5"></script> 280 280 <% if (site && site.enable_audio_player && audioTracks && audioTracks.length > 0) { %> 281 281 <script>window.PCMS_SITE_TRACKS = <%- JSON.stringify(audioTracks) %>;</script>
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)