import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; /** * Dynamic uploads server. * * Next.js caches the `public/` directory listing when the server boots, so * files uploaded *after* `next start` return 404 (and `next/image` answers * 502 because its upstream fetch fails). This route handler reads uploads * from disk on every request, so a freshly uploaded image is available with * no rebuild and no restart. * * It also generates the 40px/200px thumbnails on demand and caches them on * disk, so `/uploads/40/` and `/uploads/200/` always resolve even * when the pre-generation script has not been executed yet. */ export const dynamic = 'force-dynamic'; const THUMB_SIZES: Readonly> = { '40': 40, '200': 200 }; const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/; const CONTENT_TYPE_BY_EXTENSION: Readonly> = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.avif': 'image/avif', '.gif': 'image/gif', }; const LOCAL_UPLOADS = path.join(process.cwd(), 'public', 'uploads'); /** Admin owns the canonical uploads directory; peers mirror it. */ const CANONICAL_UPLOADS = path.join(process.cwd(), '..', 'apps', 'admin', 'public', 'uploads'); const UPLOAD_ROOTS = [LOCAL_UPLOADS, CANONICAL_UPLOADS]; const IMMUTABLE = 'public, max-age=31536000, immutable'; async function readIfFile(filePath: string): Promise { try { const stats = await stat(filePath); if (!stats.isFile() || stats.size === 0) return null; return await readFile(filePath); } catch { return null; } } async function findOriginal(filename: string): Promise<{ root: string; buffer: Buffer } | null> { for (const root of UPLOAD_ROOTS) { const buffer = await readIfFile(path.join(root, filename)); if (buffer) return { root, buffer }; } return null; } async function buildThumbnail(source: Buffer, width: number): Promise { try { const { default: sharp } = await import('sharp'); return await sharp(source).resize({ width, withoutEnlargement: true }).toBuffer(); } catch { return null; } } function imageResponse(body: Buffer, filename: string): Response { const contentType = CONTENT_TYPE_BY_EXTENSION[path.extname(filename).toLowerCase()] ?? 'application/octet-stream'; return new Response(new Uint8Array(body), { status: 200, headers: { 'Content-Type': contentType, 'Content-Length': String(body.byteLength), 'Cache-Control': IMMUTABLE, }, }); } export async function GET( _request: Request, context: { params: Promise<{ path?: string[] }> }, ): Promise { const { path: segments = [] } = await context.params; if (segments.length === 0 || segments.length > 2) return new Response('Not found', { status: 404 }); if (!segments.every((segment) => SAFE_SEGMENT.test(segment) && segment !== '..')) { return new Response('Not found', { status: 404 }); } const sizeSegment = segments.length === 2 ? segments[0] : null; const filename = segments[segments.length - 1]; if (sizeSegment !== null && !(sizeSegment in THUMB_SIZES)) { return new Response('Not found', { status: 404 }); } if (!(path.extname(filename).toLowerCase() in CONTENT_TYPE_BY_EXTENSION)) { return new Response('Not found', { status: 404 }); } // Original image requested. if (sizeSegment === null) { const original = await findOriginal(filename); if (!original) return new Response('Not found', { status: 404 }); return imageResponse(original.buffer, filename); } // Cached thumbnail already on disk? for (const root of UPLOAD_ROOTS) { const cached = await readIfFile(path.join(root, sizeSegment, filename)); if (cached) return imageResponse(cached, filename); } // Generate the thumbnail on demand and cache it next to the original. const original = await findOriginal(filename); if (!original) return new Response('Not found', { status: 404 }); const thumbnail = await buildThumbnail(original.buffer, THUMB_SIZES[sizeSegment]); if (!thumbnail) return imageResponse(original.buffer, filename); try { const cacheDir = path.join(original.root, sizeSegment); await mkdir(cacheDir, { recursive: true }); await writeFile(path.join(cacheDir, filename), thumbnail); } catch { // Serving the thumbnail matters more than caching it. } return imageResponse(thumbnail, filename); }