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

@@ -14,6 +14,9 @@ const PEER_UPLOAD_DIRS = [
path.join(process.cwd(), '..', '..', 'storefront', 'public', 'uploads'),
];
/** Thumbnail widths pre-generated for lists (40px) and previews (200px). */
const THUMBNAIL_WIDTHS = [40, 200];
async function mirrorToPeers(filePath: string): Promise<void> {
await Promise.allSettled(
PEER_UPLOAD_DIRS.map(async (dir) => {
@@ -23,6 +26,34 @@ async function mirrorToPeers(filePath: string): Promise<void> {
);
}
/**
* Generate the 40px/200px thumbnails for every upload directory right after
* the file lands on disk, so list and preview URLs (`/uploads/40/<file>` and
* `/uploads/200/<file>`) resolve without waiting for the batch script.
* Failures are non-fatal: the dynamic `/uploads/[...path]` handler regenerates
* missing thumbnails on demand.
*/
async function generateThumbnails(buffer: Buffer, filename: string): Promise<void> {
let sharp: (input: Buffer) => import('sharp').Sharp;
try {
({ default: sharp } = await import('sharp'));
} catch {
return;
}
const targets = [path.join(process.cwd(), 'public', 'uploads'), ...PEER_UPLOAD_DIRS];
await Promise.allSettled(
targets.flatMap((dir) =>
THUMBNAIL_WIDTHS.map(async (width) => {
const thumbDir = path.join(dir, String(width));
await mkdir(thumbDir, { recursive: true });
const resized = await sharp(buffer).resize({ width, withoutEnlargement: true }).toBuffer();
await writeFile(path.join(thumbDir, filename), resized);
}),
),
);
}
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
const MAX_SIZE = 10 * 1024 * 1024;
const EXTENSION_BY_TYPE: Readonly<Record<string, string>> = {
@@ -70,6 +101,7 @@ export async function POST(request: NextRequest) {
await mkdir(uploadDir, { recursive: true });
await writeFile(filePath, buffer, { flag: 'wx' });
await mirrorToPeers(filePath);
await generateThumbnails(buffer, filename);
return NextResponse.json({
url: `/uploads/${filename}`,

View File

@@ -0,0 +1,125 @@
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',
};
/** Admin owns the canonical uploads directory. */
const CANONICAL_UPLOADS = path.join(process.cwd(), 'public', 'uploads');
const UPLOAD_ROOTS = [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);
}

File diff suppressed because one or more lines are too long