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