feat(F-056): completed feature
This commit is contained in:
126
project/frontend/src/app/uploads/[...path]/route.ts
Normal file
126
project/frontend/src/app/uploads/[...path]/route.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user