source: Klonkt/src/routes/audio.js@ 72ec6a4

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

Hub-modus en guardian-lite eruit

Robins besluit (31-7): een instance is een eigenaar. Twee dingen weg.

HUB-MODUS was al dood: getTenancy() gaf sinds 24-6 hardcoded 'solo'
terug, dus elke tenancy === 'hub'-tak was onbereikbaar. Nu ook echt
verwijderd: getTenancy/setTenancy zelf, de /user/:slug-routing in
resolveSite, de hub-takken in admin, zoeken, audio, posts (neighbours
en related over alle sites), download, en de push-prefix. In de views
verdwijnen de hub-tagline, de hub-navigatie, de sites- en
users-tabellen (die kwamen alleen in hub-modus gevuld en verwezen nu
naar locals die niemand meer meegeeft), de eigenaar-toewijzing bij een
site, de /user/-slugprefix, het hub-brandblok en de hub-thuisknop.

GUARDIAN-LITE was de laatste multi-user-rest: /guardian/invite gaf een
link waarmee iemand via /guardian/join een echte user plus een site met
guardian_only=1 aanmaakte. Dat zette andermans wachtwoordhash, sessie
en PRIVATE actor-sleutel in jouw database, waardoor een verhuizing of
export nooit netjes kon (shaer-qw6q). Routes, formulier, kolom en
uitnodigingstabel zijn weg. Het guardian-DASHBOARD blijft: dat is
FEP-633c en werkt voor guardians met een eigen Klonkt. Bestaande
installaties houden kolom en tabel ongebruikt; nieuwe krijgen ze niet.

Changed files:
src/services/SettingsService.js

  • getTenancy/setTenancy verwijderd; kop herschreven

src/middleware/site.js, src/middleware/render.js

  • /user/:slug-routing weg; tenancy en hubTitle uit de locals

src/routes/admin.js, admin-settings.js, audio.js, download.js,
src/routes/posts.js, search.js

  • hub-takken en hub-queries weg; postNeighbors zonder isHub

src/services/ActivityPubService.js

  • pushPrefix is nu gewoon ; getTenancy-import weg

src/routes/guardian.js

  • /invite en /join verwijderd (dashboard blijft), imports opgeschoond

src/config/database.js

  • guardian_only-kolom en ap_guardian_invites-tabel niet meer aangemaakt

src/views/pages/admin.ejs, admin-users.ejs, admin-site-edit.ejs,
src/views/pages/guardian.ejs, partials/topnav.ejs, chrome.ejs, bottom-tab.ejs

  • alle hub-takken en de uitnodigingsknop weg

remarks: 320 regels weg, 63 erbij. Suite 372 groen; alle 85 templates
compileren; en met een wegwerp-kopie van de database daadwerkelijk
gedraaid en ingelogd: /, /admin, /admin/users, /admin/sites,
/admin/settings, /admin/sites/demo/edit, /admin/media en /guardian
geven alle 200 zonder fouten in het log, en /guardian/invite en
/guardian/join geven nu 404.

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

  • Property mode set to 100644
File size: 6.4 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';
32
33const __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.
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
56// Access gate: same-origin browser fetches / media loads — PLUS fediverse-shared tracks.
57function 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
73router.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>]].
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 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
169export default router;
Note: See TracBrowser for help on using the repository browser.