source: Klonkt/src/routes/audio.js@ 92f6976

main
Last change on this file since 92f6976 was 834bcc3, checked in by Robin Genis <roboburr@…>, 3 months ago

i18n: translate Dutch code comments to English across src/

Comments in routes/services/views/config/middleware/assets translated to
English for the public repo. A few dev-facing throw/console message strings
were Englished too. No user-facing UI strings or i18n dictionary values changed
(src/services/i18n.js untouched). Logic unchanged.

Co-Authored-By: Claude <noreply@…>

  • Property mode set to 100644
File size: 6.0 KB
Line 
1/**
2 * Audio streaming routes — byte-range streaming.
3 *
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.
7 *
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.
24 */
25
26import express from 'express';
27import fs from 'fs';
28import path from 'path';
29import { fileURLToPath } from 'url';
30import db from '../config/database.js';
31import { recordPlay } from '../services/StatsService.js';
32
33const __dirname = path.dirname(fileURLToPath(import.meta.url));
34// Audio files live OUTSIDE storage/media — the public /media static handler
35// cannot reach them. Every fetch must go through this gated route.
36const AUDIO_DIR = path.resolve(
37 process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio')
38);
39
40const router = express.Router();
41
42// MIME map for the formats v9 supported. Defaults to mpeg.
43const MIME = {
44 '.mp3': 'audio/mpeg',
45 '.m4a': 'audio/mp4',
46 '.mp4': 'audio/mp4',
47 '.aac': 'audio/aac',
48 '.oga': 'audio/ogg',
49 '.ogg': 'audio/ogg',
50 '.opus': 'audio/ogg',
51 '.flac': 'audio/flac',
52 '.wav': 'audio/wav',
53 '.webm': 'audio/webm',
54};
55
56// Access gate: allow only same-origin browser fetches / media loads.
57function isAllowedAudioRequest(req) {
58 if (req.get('X-Audio-Player') === '1') return true; // our blob fetch
59 const site = req.get('Sec-Fetch-Site'); // set by modern browsers
60 return site === 'same-origin' || site === 'same-site';
61}
62
63router.get('/stream/:filename', (req, res) => {
64 const { filename } = req.params;
65
66 if (!isAllowedAudioRequest(req)) {
67 return res.status(403).send('Direct access not allowed');
68 }
69
70 // Sanity: no path traversal, no slashes
71 if (!filename || filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
72 return res.status(400).send('Bad filename');
73 }
74
75 const filePath = path.join(AUDIO_DIR, filename);
76 // Belt-and-suspenders: confirm the resolved path stays inside AUDIO_DIR
77 if (!filePath.startsWith(AUDIO_DIR + path.sep) && filePath !== AUDIO_DIR) {
78 return res.status(400).send('Bad path');
79 }
80
81 let stat;
82 try {
83 stat = fs.statSync(filePath);
84 } catch (e) {
85 return res.status(404).send('Not found');
86 }
87 if (!stat.isFile()) return res.status(404).send('Not found');
88
89 const ext = path.extname(filename).toLowerCase();
90 const mime = MIME[ext] || 'audio/mpeg';
91 const total = stat.size;
92 const range = req.headers.range;
93
94 // Statistics: count one play on the initial player fetch (not on scrub/
95 // range continuations; replays within 24h come from the browser cache → no
96 // double counting). Best-effort, must never break the stream.
97 if (req.get('X-Audio-Player') === '1' && (!range || /^bytes=0-/.test(range))) {
98 try {
99 const tr = db.prepare(`
100 SELECT t.id FROM audio_tracks t JOIN media m ON t.media_id = m.id
101 WHERE m.storage_path = ? OR m.storage_path LIKE ? LIMIT 1
102 `).get(filename, '%' + filename);
103 if (tr) recordPlay(tr.id);
104 } catch {}
105 }
106
107 // Common headers
108 res.setHeader('Content-Type', mime);
109 res.setHeader('Accept-Ranges', 'bytes');
110 // Allow the browser to cache the file for a day so play/pause/replay
111 // doesn't re-fetch the whole stream every time. `private` keeps it out of
112 // shared proxies/CDNs (only the user's own browser cache), preserving the
113 // signed-URL access model. `immutable` skips the If-Modified-Since
114 // round-trip — the URL is content-addressed (signed token tied to file)
115 // so its content can't change.
116 res.setHeader('Cache-Control', 'private, max-age=86400, immutable');
117 res.setHeader('X-Content-Type-Options', 'nosniff');
118
119 if (!range) {
120 res.setHeader('Content-Length', total);
121 return fs.createReadStream(filePath).pipe(res);
122 }
123
124 // Parse "bytes=START-END"
125 const m = /^bytes=(\d+)-(\d*)$/.exec(range);
126 if (!m) {
127 res.status(416).setHeader('Content-Range', `bytes */${total}`);
128 return res.end();
129 }
130 const start = parseInt(m[1], 10);
131 const end = m[2] ? Math.min(parseInt(m[2], 10), total - 1) : total - 1;
132 if (start >= total || end < start) {
133 res.status(416).setHeader('Content-Range', `bytes */${total}`);
134 return res.end();
135 }
136
137 res.status(206);
138 res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
139 res.setHeader('Content-Length', end - start + 1);
140 fs.createReadStream(filePath, { start, end }).pipe(res);
141});
142
143// Which post contains this track? (for the mini-player → "jump to the post +
144// scroll to the track".) Fetches the newest published post with [[track:<id>]].
145router.get('/track/:id/post', (req, res) => {
146 const id = String(req.params.id || '');
147 if (!/^[A-Za-z0-9_-]+$/.test(id)) return res.status(400).json({ error: 'bad id' });
148 const isHub = res.locals.tenancy === 'hub';
149 const row = db.prepare(`
150 SELECT p.slug, s.slug AS site_slug
151 FROM posts p JOIN sites s ON s.id = p.site_id
152 WHERE p.status = 'published' AND p.content LIKE ?
153 ORDER BY p.published_at DESC LIMIT 1
154 `).get('%[[track:' + id + ']]%');
155 if (!row) return res.status(404).json({ error: 'not found' });
156 const url = isHub ? `/user/${row.site_slug}/${row.slug}` : `/${row.slug}`;
157 res.json({ url });
158});
159
160export default router;
Note: See TracBrowser for help on using the repository browser.