| 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 |
|
|---|
| 26 | import express from 'express';
|
|---|
| 27 | import fs from 'fs';
|
|---|
| 28 | import path from 'path';
|
|---|
| 29 | import { fileURLToPath } from 'url';
|
|---|
| 30 | import db from '../config/database.js';
|
|---|
| 31 | import { recordPlay } from '../services/StatsService.js';
|
|---|
| 32 | import AP from '../services/ActivityPubService.js';
|
|---|
| 33 |
|
|---|
| 34 | const __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.
|
|---|
| 37 | const AUDIO_DIR = path.resolve(
|
|---|
| 38 | process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio')
|
|---|
| 39 | );
|
|---|
| 40 |
|
|---|
| 41 | const router = express.Router();
|
|---|
| 42 |
|
|---|
| 43 | // MIME map for the formats v9 supported. Defaults to mpeg.
|
|---|
| 44 | const 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.
|
|---|
| 58 | function 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 | */
|
|---|
| 87 | async 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 |
|
|---|
| 102 | router.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>]].
|
|---|
| 184 | router.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 |
|
|---|
| 198 | export default router;
|
|---|