| [7bc636b] | 1 | /**
|
|---|
| 2 | * AudioStreamService — Signed audio streaming, v9-style.
|
|---|
| 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.
|
|---|
| 13 | */
|
|---|
| 14 |
|
|---|
| 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;
|
|---|
| 56 | }
|
|---|
| 57 |
|
|---|
| 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 };
|
|---|