Changeset 21522ae in Klonkt for src/services/AudioStreamService.js
- 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)
- File:
-
- 1 edited
-
src/services/AudioStreamService.js (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
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 };
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)