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:
Philip
2026-03-12 09:47:59 -07:00
parent 49b4f017bd
commit e5737bf587
3 changed files with 71 additions and 4 deletions
+8 -4
View File
@@ -75,7 +75,7 @@ export async function POST(request: Request) {
await writeFile(path.join(UPLOADS_DIR, fileName), buffer); await writeFile(path.join(UPLOADS_DIR, fileName), buffer);
return NextResponse.json({ url: `/uploads/${fileName}` }); return NextResponse.json({ url: `/api/uploads/${fileName}` });
} catch { } catch {
return NextResponse.json({ error: 'Upload failed' }, { status: 500 }); return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
} }
@@ -103,7 +103,7 @@ export async function GET() {
const uploads = await readdir(UPLOADS_DIR); const uploads = await readdir(UPLOADS_DIR);
for (const file of uploads) { for (const file of uploads) {
if (/\.(png|jpe?g|gif|webp|svg)$/i.test(file)) { 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 // 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 }); return NextResponse.json({ error: 'Invalid image path' }, { status: 400 });
} }
@@ -157,7 +161,7 @@ export async function DELETE(request: Request) {
const fileName = path.basename(url); const fileName = path.basename(url);
// Determine the target directory based on the URL prefix // 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); const filePath = path.join(targetDir, fileName);
// Verify the resolved path is still within the expected directory // Verify the resolved path is still within the expected directory
+62
View File
@@ -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 });
}
}
+1
View File
@@ -38,6 +38,7 @@ export default withAuth(
pathname.startsWith('/api/setup') || pathname.startsWith('/api/setup') ||
pathname.startsWith('/api/squares') || pathname.startsWith('/api/squares') ||
pathname.startsWith('/api/settings') || pathname.startsWith('/api/settings') ||
pathname.startsWith('/api/uploads') ||
pathname.startsWith('/api/users') || pathname.startsWith('/api/users') ||
pathname.startsWith('/_next') || pathname.startsWith('/_next') ||
pathname.startsWith('/images') pathname.startsWith('/images')