source: Klonkt/src/routes/audio.js@ 6c4ff7e

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

Audio verhuisde niet mee: archiefformaat v2

Robin verhuisde naar soundfabrics.nl en meldde: de berichten zijn er, de
plaatjes zijn er, een deel van de audio staat er maar speelt niet af, en
daarnaast mist er veel. Drie losse oorzaken, alle drie echt.

Gemeten op de twee live instanties:

berichten 36 -> 36
tracks 140 -> 14, waarvan 13 stuk
playlists 11 -> 0

DE EXPORTER LAS storage_path, DE SPELER NIET. routes/audio.js resolvet met
AUDIO_DIR plus bestandsnaam en kijkt niet naar media.storage_path. Op
sound-fabrics.com wees storage_path voor 124 van de 139 audiorijen nog naar
/srv/prutfolio/storage/audio, van voor de dataverhuizing. De site speelde
gewoon door; de exporter vond niets, markeerde availability: missing, en liet
124 nummers stil achter. Vanuit de exporter gezien was het bestand er echt niet,
dus er kwam ook geen klacht. Nu is er een resolveAudioPath die zoekt zoals de
speler zoekt.

DE IMPORTER MAAKTE EEN TRACK ZONDER BESTAND. Bij gewone bijlagen werd
availability: missing netjes gecontroleerd, in de audio-tak eronder niet. Die
zette een media-rij met size 0 en een audio_tracks-rij neer voor bytes die nooit
zijn weggeschreven. Dat is erger dan ontbreken: het ziet eruit alsof de
verhuizing gelukt is, dus je zegt je oude instantie op. Nu geldt er een harde
regel: geen bestand, geen track.

AUDIO PASTE NIET IN HET FORMAAT. Gehoste audio staat buiten MEDIA_ROOT, achter
een eigen gated route, en het archief droeg alleen bestanden onder media/. De
exporter rekende er met path.relative een /media/../audio/x.mp3 van, en
veiligMediaPad weigert dat pad terecht. Audio kon dus principieel niet
aankomen. Daarbovenop ging alleen mee wat met [[track:]] in een bericht stond,
14 van de 140, en playlists helemaal niet.

De ophaalknop leerde audio erbij. Dat vroeg meer dan verwacht: de bron toont
over AP alleen fedi_open-nummers, en de bestanden zitten achter dezelfde poort.
Beide staan nu open voor precies de doel-actor van de Move, en de ingest haalt
de bytes ONDERTEKEND op. De eerste poging gaf drie keer "nummer niet opgehaald",
want haalBijlage deed een kale fetch en kreeg een 403 van onze eigen poort.

Bewezen op twee draaiende instanties, met een track die opzettelijk een
verouderd pad had. Zip: 3/3 included, playlist-volgorde intact, bestanden op
schijf met de goede grootte, /audio/stream geeft 200. Ophaalknop: 5 berichten
plus 3 tracks met echte bytes, ook de gesloten nummers.

Changed files:
src/config/paths.js

  • AUDIO_ROOT en resolveAudioPath(): een audiobestand zoeken zoals de speler het zoekt, met het opgeslagen pad als terugval

src/services/ArchiveExportService.js

  • FORMAT_VERSION 2
  • audioBibliotheek(): de HELE tracklijst plus de playlists, met de bestanden in een eigen audio/-gebied
  • [[track:]] weg uit de media-tak; die kon nooit aankomen
  • audioOf() wijst naar het archiefpad in plaats van naar een bijlage

src/services/ArchiveImportService.js

  • tracksTerug() en playlistsTerug(); de per-post audio-tak is weg
  • bestanden naar AUDIO_ROOT, en geen rij zonder bestand
  • een playlist verwijst nooit naar een nummer dat niet aankwam
  • nieuwe rapportvelden tracks / tracksMissing / playlists

src/services/MigrationService.js

  • de ingest loopt de tracks-collectie af en haalt de bytes ondertekend op
  • haalBijlage kreeg submap en headers

src/services/ActivityPubService.js

  • signedGetHeaders() losgetrokken uit signedGetJson, want een verhuizing moet ook bytes kunnen ophalen

src/services/music/index.js

  • siteOpenTracks({ alles }): de doel-actor ziet ook de gesloten nummers

src/routes/audio.js

  • isMoveTargetAudio(): een ondertekend verzoek van de doel-actor mag alle audio ophalen, ook wat niet fedi_open is

src/routes/activitypub.js

  • magAlles(): een plek voor de vraag "mag deze aanvrager alles zien"

src/routes/admin-migrate.js

  • AUDIO_ROOT en signedGetHeaders doorgegeven aan de ingest

test/archive-export.test.js

  • de audio-testen naar het v2-contract, plus bibliotheek en ontbrekend
  • resolveAudioPath getoetst op VOLGORDE met een nep-fs

New file:
test/archive-audio.test.js

  • de hele rondgang met audio, inclusief het verouderde pad, de playlist- volgorde en de droogloop

remarks: soundfabrics.nl staat nu met 13 lege tracks; die verdwijnen niet
vanzelf bij een nieuwe import. En een archief van voor deze commit is v1 en
bevat de audio niet, dus opnieuw exporteren is nodig. Suite 985 groen;
tegenproef gedaan op de resolver en op de geen-bestand-geen-track-regel.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 7.7 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 // Allow the browser to cache the file for a day so play/pause/replay
150 // doesn't re-fetch the whole stream every time. `private` keeps it out of
151 // shared proxies/CDNs (only the user's own browser cache), preserving the
152 // signed-URL access model. `immutable` skips the If-Modified-Since
153 // round-trip — the URL is content-addressed (signed token tied to file)
154 // so its content can't change.
155 res.setHeader('Cache-Control', 'private, max-age=86400, immutable');
156 res.setHeader('X-Content-Type-Options', 'nosniff');
157
158 if (!range) {
159 res.setHeader('Content-Length', total);
160 return fs.createReadStream(filePath).pipe(res);
161 }
162
163 // Parse "bytes=START-END"
164 const m = /^bytes=(\d+)-(\d*)$/.exec(range);
165 if (!m) {
166 res.status(416).setHeader('Content-Range', `bytes */${total}`);
167 return res.end();
168 }
169 const start = parseInt(m[1], 10);
170 const end = m[2] ? Math.min(parseInt(m[2], 10), total - 1) : total - 1;
171 if (start >= total || end < start) {
172 res.status(416).setHeader('Content-Range', `bytes */${total}`);
173 return res.end();
174 }
175
176 res.status(206);
177 res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
178 res.setHeader('Content-Length', end - start + 1);
179 fs.createReadStream(filePath, { start, end }).pipe(res);
180});
181
182// Which post contains this track? (for the mini-player → "jump to the post +
183// scroll to the track".) Fetches the newest published post with [[track:<id>]].
184router.get('/track/:id/post', (req, res) => {
185 const id = String(req.params.id || '');
186 if (!/^[A-Za-z0-9_-]+$/.test(id)) return res.status(400).json({ error: 'bad id' });
187 const row = db.prepare(`
188 SELECT p.slug, s.slug AS site_slug
189 FROM posts p JOIN sites s ON s.id = p.site_id
190 WHERE p.status = 'published' AND p.content LIKE ?
191 ORDER BY p.published_at DESC LIMIT 1
192 `).get('%[[track:' + id + ']]%');
193 if (!row) return res.status(404).json({ error: 'not found' });
194 const url = `/${row.slug}`;
195 res.json({ url });
196});
197
198export default router;
Note: See TracBrowser for help on using the repository browser.