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')