import { NextRequest, NextResponse } from 'next/server'; import { stat } from 'fs/promises'; import { createReadStream } from 'fs'; import path from 'path'; export async function GET( request: NextRequest, { params }: { params: Promise<{ filename: string }> } ) { try { const { filename } = await params; // Security: Prevent path traversal attacks // Only allow alphanumeric, hyphens, underscores, and dots const safeFilenamePattern = /^[a-zA-Z0-9_\-\.]+\.mp3$/; if (!safeFilenamePattern.test(filename)) { return new NextResponse('Invalid filename', { status: 400 }); } // Additional check: ensure no path separators if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) { return new NextResponse('Invalid filename', { status: 400 }); } const filePath = path.join(process.cwd(), 'public/uploads', filename); // Security: Verify the resolved path is still within uploads directory const uploadsDir = path.join(process.cwd(), 'public/uploads'); const resolvedPath = path.resolve(filePath); if (!resolvedPath.startsWith(uploadsDir)) { return new NextResponse('Forbidden', { status: 403 }); } const stats = await stat(filePath); const fileSize = stats.size; const range = request.headers.get('range'); if (range) { const parts = range.replace(/bytes=/, "").split("-"); const start = parseInt(parts[0], 10); const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; const chunksize = (end - start) + 1; const stream = createReadStream(filePath, { start, end }); // Convert Node stream to Web stream const readable = new ReadableStream({ start(controller) { let isClosed = false; stream.on('data', (chunk: any) => { if (isClosed) return; try { controller.enqueue(chunk); } catch (e) { isClosed = true; stream.destroy(); } }); stream.on('end', () => { if (isClosed) return; isClosed = true; controller.close(); }); stream.on('error', (err: any) => { if (isClosed) return; isClosed = true; controller.error(err); }); }, cancel() { stream.destroy(); } }); return new NextResponse(readable, { status: 206, headers: { 'Content-Range': `bytes ${start}-${end}/${fileSize}`, 'Accept-Ranges': 'bytes', 'Content-Length': chunksize.toString(), 'Content-Type': 'audio/mpeg', 'Cache-Control': 'public, max-age=3600, must-revalidate', }, }); } else { const stream = createReadStream(filePath); // Convert Node stream to Web stream const readable = new ReadableStream({ start(controller) { let isClosed = false; stream.on('data', (chunk: any) => { if (isClosed) return; try { controller.enqueue(chunk); } catch (e) { isClosed = true; stream.destroy(); } }); stream.on('end', () => { if (isClosed) return; isClosed = true; controller.close(); }); stream.on('error', (err: any) => { if (isClosed) return; isClosed = true; controller.error(err); }); }, cancel() { stream.destroy(); } }); return new NextResponse(readable, { status: 200, headers: { 'Content-Length': fileSize.toString(), 'Content-Type': 'audio/mpeg', 'Accept-Ranges': 'bytes', 'Cache-Control': 'public, max-age=3600, must-revalidate', }, }); } } catch (error) { console.error('Error serving audio file:', error); return new NextResponse('Internal Server Error', { status: 500 }); } }