source: Klonkt/src/routes/audio.js@ a5caa73

main
Last change on this file since a5caa73 was a5caa73, checked in by Robin <roboburr@…>, 3 weeks ago

Audio-stream: CORP cross-origin, zodat externe spelers echt kunnen afspelen (prutfolio-src-ap6)

Helmets default Cross-Origin-Resource-Policy: same-origin stond ook op
/audio/stream/*, en daarmee weigert de browser de bytes aan een
cross-origin <audio>-element: het bestand komt aan, de speler blijft
stil. Precies de les die /media al geleerd had -- de stream-route was
toen vergeten. En het zijn juist DEZE URLs die we in federatieve
Audio-objecten adverteren om elders (Funkwhale, de hub) afgespeeld te
worden. WIE mag ophalen beslist de fedi_open-poort, niet CORP.

Gemeten op hub.klonkt.com: /audio/stream/* speelde niet (same-origin),
de oude /media/migrated/*.mp3 wel (cross-origin). Na de fix op
dev.klonkt.com geverifieerd: de header staat er en de stream speelt
cross-origin.

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

  • Property mode set to 100644
File size: 8.1 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';
32import AP from '../services/ActivityPubService.js';
33
34const __dirname = path.dirname(fileURLToPath(import.meta.url));
35// Audio files live OUTSIDE storage/media — the public /media static handler
36// cannot reach them. Every fetch must go through this gated route.
37const AUDIO_DIR = path.resolve(
38 process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio')
39);
40
41const router = express.Router();
42
43// MIME map for the formats v9 supported. Defaults to mpeg.
44const MIME = {
45 '.mp3': 'audio/mpeg',
46 '.m4a': 'audio/mp4',
47 '.mp4': 'audio/mp4',
48 '.aac': 'audio/aac',
49 '.oga': 'audio/ogg',
50 '.ogg': 'audio/ogg',
51 '.opus': 'audio/ogg',
52 '.flac': 'audio/flac',
53 '.wav': 'audio/wav',
54 '.webm': 'audio/webm',
55};
56
57// Access gate: same-origin browser fetches / media loads — PLUS fediverse-shared tracks.
58function isAllowedAudioRequest(req, filename) {
59 if (req.get('X-Audio-Player') === '1') return true; // our blob fetch
60 const site = req.get('Sec-Fetch-Site'); // set by modern browsers
61 if (site === 'same-origin' || site === 'same-site') return true;
62 // fedi_open tracks are deliberately served ungated so remote servers (Mastodon, …) can
63 // fetch + play the file inline. The operator opted this specific track in (per-track flag).
64 if (filename) {
65 try {
66 const r = db.prepare(`SELECT 1 FROM audio_tracks t JOIN media m ON t.media_id = m.id
67 WHERE t.fedi_open = 1 AND (m.storage_path = ? OR m.storage_path LIKE ?) LIMIT 1`).get(filename, '%' + filename);
68 if (r) return true;
69 } catch { /* ignore */ }
70 }
71 return false;
72}
73
74/**
75 * FEP-1580: de instantie waar dit account naartoe verhuisd is mag ALLE audio
76 * ophalen, ook wat niet fedi_open is.
77 *
78 * Zonder deze tak ziet de nieuwe Klonkt de tracklijst wel en krijgt hij de
79 * bestanden niet, en dan verhuis je een bibliotheek met alleen titels. Dat is
80 * precies de halve waarheid die deze hele ronde moest opruimen.
81 *
82 * Smal gehouden: een geldige handtekening, van precies de actor in moved_to, en
83 * alleen voor een bestand dat van DIE site is. moved_to komt er alleen te staan
84 * als de doel-actor ons in alsoKnownAs had, dus er heeft iemand met beheer aan
85 * beide kanten ja gezegd.
86 */
87async function isMoveTargetAudio(req, filename) {
88 if (!req.headers['signature'] || !filename) return false;
89 let rij;
90 try {
91 rij = db.prepare(`SELECT s.slug FROM audio_tracks t
92 JOIN media m ON t.media_id = m.id
93 JOIN sites s ON s.id = t.site_id
94 WHERE m.storage_path = ? OR m.storage_path LIKE ? LIMIT 1`)
95 .get(filename, `%${filename}`);
96 } catch { return false; }
97 if (!rij || !rij.slug) return false;
98 const v = await AP.verifyRequest(req).catch(() => null);
99 return !!(v && v.id && AP.isMoveTarget(rij.slug, v.id));
100}
101
102router.get('/stream/:filename', async (req, res) => {
103 const { filename } = req.params;
104
105 if (!isAllowedAudioRequest(req, filename) && !(await isMoveTargetAudio(req, filename))) {
106 return res.status(403).send('Direct access not allowed');
107 }
108
109 // Sanity: no path traversal, no slashes
110 if (!filename || filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
111 return res.status(400).send('Bad filename');
112 }
113
114 const filePath = path.join(AUDIO_DIR, filename);
115 // Belt-and-suspenders: confirm the resolved path stays inside AUDIO_DIR
116 if (!filePath.startsWith(AUDIO_DIR + path.sep) && filePath !== AUDIO_DIR) {
117 return res.status(400).send('Bad path');
118 }
119
120 let stat;
121 try {
122 stat = fs.statSync(filePath);
123 } catch (e) {
124 return res.status(404).send('Not found');
125 }
126 if (!stat.isFile()) return res.status(404).send('Not found');
127
128 const ext = path.extname(filename).toLowerCase();
129 const mime = MIME[ext] || 'audio/mpeg';
130 const total = stat.size;
131 const range = req.headers.range;
132
133 // Statistics: count one play on the initial player fetch (not on scrub/
134 // range continuations; replays within 24h come from the browser cache → no
135 // double counting). Best-effort, must never break the stream.
136 if (req.get('X-Audio-Player') === '1' && (!range || /^bytes=0-/.test(range))) {
137 try {
138 const tr = db.prepare(`
139 SELECT t.id FROM audio_tracks t JOIN media m ON t.media_id = m.id
140 WHERE m.storage_path = ? OR m.storage_path LIKE ? LIMIT 1
141 `).get(filename, '%' + filename);
142 if (tr) recordPlay(tr.id);
143 } catch {}
144 }
145
146 // Common headers
147 res.setHeader('Content-Type', mime);
148 res.setHeader('Accept-Ranges', 'bytes');
149 // Same lesson /media already learned: Helmet's default CORP is same-origin,
150 // and the browser then refuses to hand a cross-origin <audio> the bytes —
151 // the file arrives, the player stays silent. These URLs are precisely what
152 // we advertise in federated Audio objects (Funkwhale, the hub) to be played
153 // elsewhere; WHO may fetch is decided by the gate above, not by CORP.
154 res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
155 // Allow the browser to cache the file for a day so play/pause/replay
156 // doesn't re-fetch the whole stream every time. `private` keeps it out of
157 // shared proxies/CDNs (only the user's own browser cache), preserving the
158 // signed-URL access model. `immutable` skips the If-Modified-Since
159 // round-trip — the URL is content-addressed (signed token tied to file)
160 // so its content can't change.
161 res.setHeader('Cache-Control', 'private, max-age=86400, immutable');
162 res.setHeader('X-Content-Type-Options', 'nosniff');
163
164 if (!range) {
165 res.setHeader('Content-Length', total);
166 return fs.createReadStream(filePath).pipe(res);
167 }
168
169 // Parse "bytes=START-END"
170 const m = /^bytes=(\d+)-(\d*)$/.exec(range);
171 if (!m) {
172 res.status(416).setHeader('Content-Range', `bytes */${total}`);
173 return res.end();
174 }
175 const start = parseInt(m[1], 10);
176 const end = m[2] ? Math.min(parseInt(m[2], 10), total - 1) : total - 1;
177 if (start >= total || end < start) {
178 res.status(416).setHeader('Content-Range', `bytes */${total}`);
179 return res.end();
180 }
181
182 res.status(206);
183 res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
184 res.setHeader('Content-Length', end - start + 1);
185 fs.createReadStream(filePath, { start, end }).pipe(res);
186});
187
188// Which post contains this track? (for the mini-player → "jump to the post +
189// scroll to the track".) Fetches the newest published post with [[track:<id>]].
190router.get('/track/:id/post', (req, res) => {
191 const id = String(req.params.id || '');
192 if (!/^[A-Za-z0-9_-]+$/.test(id)) return res.status(400).json({ error: 'bad id' });
193 const row = db.prepare(`
194 SELECT p.slug, s.slug AS site_slug
195 FROM posts p JOIN sites s ON s.id = p.site_id
196 WHERE p.status = 'published' AND p.content LIKE ?
197 ORDER BY p.published_at DESC LIMIT 1
198 `).get('%[[track:' + id + ']]%');
199 if (!row) return res.status(404).json({ error: 'not found' });
200 const url = `/${row.slug}`;
201 res.json({ url });
202});
203
204export default router;
Note: See TracBrowser for help on using the repository browser.