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

main
Last change on this file since f1c50f9 was f2eacca, checked in by roboburr <roboburr@…>, 2 months ago

feat(music): per-track "share on the fediverse" — federate the file as a native AS2 Audio attachment

A per-track opt-in (default off) so an OPEN track's audio file is federated as a real AS2 Audio
attachment and served ungated → it plays inline in EVERY fediverse client, incl. the official
Mastodon apps (which only play native media, not external player cards). Gated tracks (default)
keep the file hidden + web-player-only. This is the spec-canonical way to federate audio; the
gated path stays the deliberate anti-steal choice.

  • src/config/database.js — audio_tracks.fedi_open column (default 0)
  • src/routes/audio.js — /audio/stream serves fedi_open tracks ungated so remote servers can fetch them
  • src/services/ActivityPubService.js (buildNote) — fedi_open tracks → AS2 Audio attachments (the file URL)
  • src/routes/admin-audio.js — POST /:id/fedi-open toggle (god-only) + fedi_open in the track query
  • src/views/pages/admin-audio.ejs — per-track share toggle next to the download toggle
  • src/services/i18n.js — aaud.fedi_on/off labels (nl/en/de)

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

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