Fix uploaded images not loading in standalone Docker mode
Next.js standalone output does not serve files added dynamically to public/uploads/ after build time. Serve uploads via a new API route (/api/uploads/[filename]) that reads from disk at request time. - Add src/app/api/uploads/[filename]/route.ts to stream uploaded files - Update POST /api/upload to return /api/uploads/<file> URLs - Update GET /api/upload to list uploads with /api/uploads/ URLs - Update DELETE /api/upload to accept /api/uploads/ URL prefix - Add /api/uploads to middleware public routes whitelist Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -75,7 +75,7 @@ export async function POST(request: Request) {
|
||||
|
||||
await writeFile(path.join(UPLOADS_DIR, fileName), buffer);
|
||||
|
||||
return NextResponse.json({ url: `/uploads/${fileName}` });
|
||||
return NextResponse.json({ url: `/api/uploads/${fileName}` });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
|
||||
}
|
||||
@@ -103,7 +103,7 @@ export async function GET() {
|
||||
const uploads = await readdir(UPLOADS_DIR);
|
||||
for (const file of uploads) {
|
||||
if (/\.(png|jpe?g|gif|webp|svg)$/i.test(file)) {
|
||||
images.push({ url: `/uploads/${file}`, name: file });
|
||||
images.push({ url: `/api/uploads/${file}`, name: file });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,7 +149,11 @@ export async function DELETE(request: Request) {
|
||||
}
|
||||
|
||||
// Only allow deletion from known image directories
|
||||
if (!url.startsWith('/uploads/') && !url.startsWith('/images/')) {
|
||||
if (
|
||||
!url.startsWith('/uploads/') &&
|
||||
!url.startsWith('/images/') &&
|
||||
!url.startsWith('/api/uploads/')
|
||||
) {
|
||||
return NextResponse.json({ error: 'Invalid image path' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -157,7 +161,7 @@ export async function DELETE(request: Request) {
|
||||
const fileName = path.basename(url);
|
||||
|
||||
// Determine the target directory based on the URL prefix
|
||||
const targetDir = url.startsWith('/uploads/') ? UPLOADS_DIR : IMAGES_DIR;
|
||||
const targetDir = url.startsWith('/images/') ? IMAGES_DIR : UPLOADS_DIR;
|
||||
const filePath = path.join(targetDir, fileName);
|
||||
|
||||
// Verify the resolved path is still within the expected directory
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Uploaded file serving route
|
||||
*
|
||||
* Serves files from the uploads directory. This is needed in Next.js standalone
|
||||
* mode because dynamically-added files in public/uploads/ are not served as
|
||||
* static files (only files present at build time are included in the output).
|
||||
*
|
||||
* GET /api/uploads/[filename] — streams the file with correct Content-Type
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { readFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
const UPLOADS_DIR = path.join(process.cwd(), 'public', 'uploads');
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
};
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: { filename: string } }
|
||||
) {
|
||||
const filename = params.filename;
|
||||
|
||||
// Prevent path traversal — only allow plain filenames
|
||||
if (!filename || filename.includes('/') || filename.includes('..')) {
|
||||
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
|
||||
}
|
||||
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
const mimeType = MIME_TYPES[ext];
|
||||
|
||||
if (!mimeType) {
|
||||
return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 });
|
||||
}
|
||||
|
||||
const filePath = path.join(UPLOADS_DIR, filename);
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await readFile(filePath);
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed to read file' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export default withAuth(
|
||||
pathname.startsWith('/api/setup') ||
|
||||
pathname.startsWith('/api/squares') ||
|
||||
pathname.startsWith('/api/settings') ||
|
||||
pathname.startsWith('/api/uploads') ||
|
||||
pathname.startsWith('/api/users') ||
|
||||
pathname.startsWith('/_next') ||
|
||||
pathname.startsWith('/images')
|
||||
|
||||
Reference in New Issue
Block a user