import { FastifyInstance } from 'fastify'; import { createReadStream } from 'fs'; import { stat } from 'fs/promises'; import path from 'path'; import { DbService } from '../services/db.service.js'; // Music library root on disk. The worker scanner writes absolute file paths into // tracks.path rooted here; the backend container must mount the same path so they // resolve. Path-traversal guard below verifies the resolved file stays inside. const MUSIC_DIR = path.resolve(process.env.MUSIC_DIR || '/mnt/hdd1/media/Music'); const CONTENT_TYPES: Record = { '.mp3': 'audio/mpeg', '.flac': 'audio/flac', '.m4a': 'audio/mp4', '.wav': 'audio/wav', '.ogg': 'audio/ogg', }; function contentTypeFor(filePath: string): string { return CONTENT_TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream'; } // True when resolvedPath is the music root itself or a descendant of it. function isInsideRoot(resolvedPath: string, root: string): boolean { return resolvedPath === root || resolvedPath.startsWith(root + path.sep); } export default async function streamRoutes( fastify: FastifyInstance, options: { dbService: DbService } ) { const { dbService } = options; fastify.get('/tracks/:id/stream', async (request, reply) => { const { id } = request.params as { id: string }; const track = await dbService.getTrackById(id); if (!track) { return reply.code(404).send({ error: 'Track not found' }); } // SECURITY: resolve the path and confirm it stays within MUSIC_DIR. This // rejects relative paths, symlink-style escapes and any path outside root. const resolvedPath = path.resolve(track.path); if (!isInsideRoot(resolvedPath, MUSIC_DIR)) { return reply.code(403).send({ error: 'Forbidden' }); } let fileSize: number; try { const stats = await stat(resolvedPath); if (!stats.isFile()) { return reply.code(404).send({ error: 'File not found' }); } fileSize = stats.size; } catch (err: any) { if (err && err.code === 'ENOENT') { // File missing on disk; the integrity worker would flag this track MISSING. return reply.code(404).send({ error: 'File not found on disk' }); } throw err; } const contentType = contentTypeFor(resolvedPath); const rangeHeader = request.headers.range; // No Range header: stream the whole file with a 200. if (!rangeHeader) { reply .code(200) .header('Content-Type', contentType) .header('Content-Length', fileSize) .header('Accept-Ranges', 'bytes'); const stream = createReadStream(resolvedPath); stream.on('error', (err) => { request.log.error(err); reply.raw.destroy(err); }); return reply.send(stream); } // Parse "bytes=start-end". Either bound may be omitted. const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim()); if (!match || (match[1] === '' && match[2] === '')) { return reply .code(416) .header('Content-Range', `bytes */${fileSize}`) .send({ error: 'Invalid range' }); } let start: number; let end: number; if (match[1] === '') { // suffix range: last N bytes const suffixLength = parseInt(match[2], 10); if (suffixLength <= 0) { return reply .code(416) .header('Content-Range', `bytes */${fileSize}`) .send({ error: 'Unsatisfiable range' }); } start = Math.max(fileSize - suffixLength, 0); end = fileSize - 1; } else { start = parseInt(match[1], 10); end = match[2] === '' ? fileSize - 1 : parseInt(match[2], 10); } if (end > fileSize - 1) end = fileSize - 1; if ( Number.isNaN(start) || Number.isNaN(end) || start > end || start < 0 || start >= fileSize ) { return reply .code(416) .header('Content-Range', `bytes */${fileSize}`) .send({ error: 'Unsatisfiable range' }); } const chunkSize = end - start + 1; reply .code(206) .header('Content-Type', contentType) .header('Content-Range', `bytes ${start}-${end}/${fileSize}`) .header('Accept-Ranges', 'bytes') .header('Content-Length', chunkSize); const stream = createReadStream(resolvedPath, { start, end }); stream.on('error', (err) => { request.log.error(err); reply.raw.destroy(err); }); return reply.send(stream); }); }