| 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 |
|
|---|
| 12 | import express from 'express';
|
|---|
| 13 | import fs from 'fs';
|
|---|
| 14 | import path from 'path';
|
|---|
| 15 | import { fileURLToPath } from 'url';
|
|---|
| 16 | import { verifyToken } from '../services/AudioStreamService.js';
|
|---|
| 17 |
|
|---|
| 18 | const __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.
|
|---|
| 21 | const AUDIO_DIR = path.resolve(
|
|---|
| 22 | process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio')
|
|---|
| 23 | );
|
|---|
| 24 |
|
|---|
| 25 | const router = express.Router();
|
|---|
| 26 |
|
|---|
| 27 | // MIME map for the formats v9 supported. Defaults to mpeg.
|
|---|
| 28 | const 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 |
|
|---|
| 41 | router.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 |
|
|---|
| 109 | export default router;
|
|---|