127 lines
4.4 KiB
TypeScript
127 lines
4.4 KiB
TypeScript
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/<file>` and `/uploads/200/<file>` always resolve even
|
|
* when the pre-generation script has not been executed yet.
|
|
*/
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
const THUMB_SIZES: Readonly<Record<string, number>> = { '40': 40, '200': 200 };
|
|
const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
|
const CONTENT_TYPE_BY_EXTENSION: Readonly<Record<string, string>> = {
|
|
'.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<Buffer | null> {
|
|
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<Buffer | null> {
|
|
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<Response> {
|
|
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);
|
|
}
|