source: Klonkt/src/routes/audio.js@ 7bc636b

main
Last change on this file since 7bc636b was 7bc636b, checked in by Robin <robin@…>, 4 months ago

Initial commit — PrutFolio v1 source (pulled from Hetzner /srv/prutfolio)

  • Property mode set to 100644
File size: 3.6 KB
Line 
1/**
2 * Audio streaming routes — v9-style signed URL + byte-range support.
3 *
4 * Files live in storage/media/audio/ and are NOT served by the static
5 * /media handler — every fetch must go through this verified route.
6 *
7 * GET /audio/stream/:filename?t=<hmac>&exp=<unix>
8 * Verifies the token. If valid, streams the file with byte-range support
9 * so HTML5 <audio> can seek. Anything invalid returns 403.
10 */
11
12import express from 'express';
13import fs from 'fs';
14import path from 'path';
15import { fileURLToPath } from 'url';
16import { verifyToken } from '../services/AudioStreamService.js';
17
18const __dirname = path.dirname(fileURLToPath(import.meta.url));
19// Audio files live OUTSIDE storage/media — the public /media static handler
20// cannot reach them. Every fetch must go through this signed route.
21const AUDIO_DIR = path.resolve(
22 process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio')
23);
24
25const router = express.Router();
26
27// MIME map for the formats v9 supported. Defaults to mpeg.
28const MIME = {
29 '.mp3': 'audio/mpeg',
30 '.m4a': 'audio/mp4',
31 '.mp4': 'audio/mp4',
32 '.aac': 'audio/aac',
33 '.oga': 'audio/ogg',
34 '.ogg': 'audio/ogg',
35 '.opus': 'audio/ogg',
36 '.flac': 'audio/flac',
37 '.wav': 'audio/wav',
38 '.webm': 'audio/webm',
39};
40
41router.get('/stream/:filename', (req, res) => {
42 const { filename } = req.params;
43 const { t, exp } = req.query;
44
45 // Sanity: no path traversal, no slashes
46 if (!filename || filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
47 return res.status(400).send('Bad filename');
48 }
49
50 if (!verifyToken(filename, t, exp)) {
51 return res.status(403).send('Invalid or expired token');
52 }
53
54 const filePath = path.join(AUDIO_DIR, filename);
55 // Belt-and-suspenders: confirm the resolved path stays inside AUDIO_DIR
56 if (!filePath.startsWith(AUDIO_DIR + path.sep) && filePath !== AUDIO_DIR) {
57 return res.status(400).send('Bad path');
58 }
59
60 let stat;
61 try {
62 stat = fs.statSync(filePath);
63 } catch (e) {
64 return res.status(404).send('Not found');
65 }
66 if (!stat.isFile()) return res.status(404).send('Not found');
67
68 const ext = path.extname(filename).toLowerCase();
69 const mime = MIME[ext] || 'audio/mpeg';
70 const total = stat.size;
71 const range = req.headers.range;
72
73 // Common headers
74 res.setHeader('Content-Type', mime);
75 res.setHeader('Accept-Ranges', 'bytes');
76 // Allow the browser to cache the file for a day so play/pause/replay
77 // doesn't re-fetch the whole stream every time. `private` keeps it out of
78 // shared proxies/CDNs (only the user's own browser cache), preserving the
79 // signed-URL access model. `immutable` skips the If-Modified-Since
80 // round-trip — the URL is content-addressed (signed token tied to file)
81 // so its content can't change.
82 res.setHeader('Cache-Control', 'private, max-age=86400, immutable');
83 res.setHeader('X-Content-Type-Options', 'nosniff');
84
85 if (!range) {
86 res.setHeader('Content-Length', total);
87 return fs.createReadStream(filePath).pipe(res);
88 }
89
90 // Parse "bytes=START-END"
91 const m = /^bytes=(\d+)-(\d*)$/.exec(range);
92 if (!m) {
93 res.status(416).setHeader('Content-Range', `bytes */${total}`);
94 return res.end();
95 }
96 const start = parseInt(m[1], 10);
97 const end = m[2] ? Math.min(parseInt(m[2], 10), total - 1) : total - 1;
98 if (start >= total || end < start) {
99 res.status(416).setHeader('Content-Range', `bytes */${total}`);
100 return res.end();
101 }
102
103 res.status(206);
104 res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
105 res.setHeader('Content-Length', end - start + 1);
106 fs.createReadStream(filePath, { start, end }).pipe(res);
107});
108
109export default router;
Note: See TracBrowser for help on using the repository browser.