source: Klonkt/src/services/AudioStreamService.js@ 7bc636b

main
Last change on this file since 7bc636b was 7bc636b, checked in by Robin <robin@…>, 4 months ago

Initial commit — PrutFolio v1 source (pulled from Hetzner /srv/prutfolio)

  • Property mode set to 100644
File size: 3.3 KB
Line 
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
15import crypto from 'crypto';
16import fs from 'fs';
17import path from 'path';
18import { fileURLToPath } from 'url';
19
20const __dirname = path.dirname(fileURLToPath(import.meta.url));
21const SECRET_FILE = path.join(__dirname, '..', '..', 'storage', '.audio-secret');
22
23export const TOKEN_TTL_SECONDS = 600;
24
25let cachedSecret = null;
26
27function 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
58function 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 */
70export 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 */
85export 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 */
106export function rotateSecret() {
107 cachedSecret = null;
108 try { fs.unlinkSync(SECRET_FILE); } catch (e) {}
109 return loadOrGenerateSecret();
110}
111
112export default { signUrl, verifyToken, rotateSecret, TOKEN_TTL_SECONDS };
Note: See TracBrowser for help on using the repository browser.