From e5737bf58785c224b242599c1ce6342a32d112d1 Mon Sep 17 00:00:00 2001 From: Philip Date: Thu, 12 Mar 2026 09:47:59 -0700 Subject: [PATCH] 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/ 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 --- src/app/api/upload/route.ts | 12 +++-- src/app/api/uploads/[filename]/route.ts | 62 +++++++++++++++++++++++++ src/middleware.ts | 1 + 3 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 src/app/api/uploads/[filename]/route.ts diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index f0c3bed..a2b8bc8 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -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 diff --git a/src/app/api/uploads/[filename]/route.ts b/src/app/api/uploads/[filename]/route.ts new file mode 100644 index 0000000..dde8ac5 --- /dev/null +++ b/src/app/api/uploads/[filename]/route.ts @@ -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 = { + '.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 }); + } +} diff --git a/src/middleware.ts b/src/middleware.ts index d69d808..b410613 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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')