Files
hoerdle/app/api/covers/[filename]/route.ts

96 lines
3.3 KiB
TypeScript

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<string, string> = {
'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 });
}
}