feat(F-056): completed feature

This commit is contained in:
chattie
2026-08-19 15:10:09 +02:00
parent cc84f24658
commit a967e49851
29 changed files with 946 additions and 43 deletions

View File

@@ -0,0 +1,126 @@
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);
}