141 lines
4.4 KiB
TypeScript
141 lines
4.4 KiB
TypeScript
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<string, string> = {
|
|
'.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);
|
|
});
|
|
}
|