| 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 |
|
|---|
| 33 | const __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.
|
|---|
| 36 | const AUDIO_DIR = path.resolve(
|
|---|
| 37 | process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio')
|
|---|
| 38 | );
|
|---|
| 39 |
|
|---|
| 40 | const router = express.Router();
|
|---|
| 41 |
|
|---|
| 42 | // MIME map for the formats v9 supported. Defaults to mpeg.
|
|---|
| 43 | const 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: same-origin browser fetches / media loads — PLUS fediverse-shared tracks.
|
|---|
| 57 | function isAllowedAudioRequest(req, filename) {
|
|---|
| 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 | 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;
|
|---|
| 71 | }
|
|---|
| 72 |
|
|---|
| 73 | router.get('/stream/:filename', (req, res) => {
|
|---|
| 74 | const { filename } = req.params;
|
|---|
| 75 |
|
|---|
| 76 | if (!isAllowedAudioRequest(req, filename)) {
|
|---|
| 77 | return res.status(403).send('Direct access not allowed');
|
|---|
| 78 | }
|
|---|
| 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 |
|
|---|
| 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.
|
|---|
| 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 |
|
|---|
| 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 |
|
|---|
| 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>]].
|
|---|
| 155 | router.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 row = db.prepare(`
|
|---|
| 159 | SELECT p.slug, s.slug AS site_slug
|
|---|
| 160 | FROM posts p JOIN sites s ON s.id = p.site_id
|
|---|
| 161 | WHERE p.status = 'published' AND p.content LIKE ?
|
|---|
| 162 | ORDER BY p.published_at DESC LIMIT 1
|
|---|
| 163 | `).get('%[[track:' + id + ']]%');
|
|---|
| 164 | if (!row) return res.status(404).json({ error: 'not found' });
|
|---|
| 165 | const url = `/${row.slug}`;
|
|---|
| 166 | res.json({ url });
|
|---|
| 167 | });
|
|---|
| 168 |
|
|---|
| 169 | export default router;
|
|---|