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 // Allow alphanumeric, hyphens, underscores, and dots for image filenames // Support common image formats: jpg, jpeg, png, gif, webp const safeFilenamePattern = /^[a-zA-Z0-9_\-\.]+\.(jpg|jpeg|png|gif|webp)$/i; 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/covers', filename); // Security: Verify the resolved path is still within covers directory const coversDir = path.join(process.cwd(), 'public/uploads/covers'); const resolvedPath = path.resolve(filePath); if (!resolvedPath.startsWith(coversDir)) { return new NextResponse('Forbidden', { status: 403 }); } const stats = await stat(filePath); const fileSize = stats.size; // Determine content type based on file extension const ext = filename.toLowerCase().split('.').pop(); const contentTypeMap: Record = { 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp', }; const contentType = contentTypeMap[ext || ''] || 'image/jpeg'; 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': contentType, 'Cache-Control': 'public, max-age=3600, must-revalidate', }, }); } catch (error) { console.error('Error serving cover image:', error); return new NextResponse('Internal Server Error', { status: 500 }); } }