feat(F-056): completed feature
@@ -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
@@ -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);
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 568 B |
|
After Width: | Height: | Size: 568 B |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 37 KiB |
245
project/frontend/src/app/api/checkout/route.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { cookies } from 'next/headers';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
||||
const SESSION_COOKIE = 'mdv_session';
|
||||
const COUNTRY_FALLBACK = 'ES';
|
||||
|
||||
const addressSchema = {
|
||||
type: 'object',
|
||||
required: ['line1', 'city', 'postalCode', 'country'],
|
||||
properties: {
|
||||
line1: { type: 'string', minLength: 1, maxLength: 200 },
|
||||
line2: { type: 'string', maxLength: 200 },
|
||||
city: { type: 'string', minLength: 1, maxLength: 80 },
|
||||
postalCode: { type: 'string', minLength: 1, maxLength: 20 },
|
||||
country: { type: 'string', minLength: 2, maxLength: 80 },
|
||||
firstName: { type: 'string', maxLength: 80 },
|
||||
lastName: { type: 'string', maxLength: 80 },
|
||||
phone: { type: 'string', maxLength: 40 },
|
||||
email: { type: 'string', maxLength: 160 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const itemSchema = {
|
||||
type: 'object',
|
||||
required: ['productId', 'variantId', 'quantity'],
|
||||
properties: {
|
||||
productId: { type: 'string' },
|
||||
variantId: { type: 'string' },
|
||||
quantity: { type: 'number' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const bodySchema = {
|
||||
type: 'object',
|
||||
required: ['shippingAddress', 'items'],
|
||||
properties: {
|
||||
shippingAddress: addressSchema,
|
||||
billingAddress: addressSchema,
|
||||
items: { type: 'array', items: itemSchema, minItems: 1 },
|
||||
promoCode: { type: 'string', maxLength: 64 },
|
||||
notes: { type: 'string', maxLength: 500 },
|
||||
idempotencyKey: { type: 'string', maxLength: 120 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const UNAUTHORIZED = {
|
||||
error: {
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Debes iniciar sesión para finalizar el pedido.',
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface RawItem {
|
||||
productId?: unknown;
|
||||
variantId?: unknown;
|
||||
quantity?: unknown;
|
||||
}
|
||||
|
||||
interface RawBody {
|
||||
shippingAddress?: { country?: unknown; postalCode?: unknown; [key: string]: unknown };
|
||||
items?: RawItem[];
|
||||
promoCode?: string | null;
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0;
|
||||
}
|
||||
|
||||
function isUuid(value: unknown): value is string {
|
||||
return typeof value === 'string' && /^[0-9a-f-]{36}$/i.test(value);
|
||||
}
|
||||
|
||||
function parseQuantity(value: unknown): number {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number) || !Number.isInteger(number) || number < 1 || number > 99) {
|
||||
throw new Error('Cantidad inválida en el carrito.');
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
function parseItems(raw: RawItem[] | undefined): {
|
||||
productId: string;
|
||||
variantId: string;
|
||||
quantity: number;
|
||||
}[] {
|
||||
if (!Array.isArray(raw) || raw.length === 0) {
|
||||
throw new Error('El carrito está vacío.');
|
||||
}
|
||||
return raw.map((item, index) => {
|
||||
if (!isUuid(item.productId)) throw new Error(`productId inválido (item ${index}).`);
|
||||
if (!isUuid(item.variantId)) throw new Error(`variantId inválido (item ${index}).`);
|
||||
return {
|
||||
productId: item.productId,
|
||||
variantId: item.variantId,
|
||||
quantity: parseQuantity(item.quantity),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function syncCart(
|
||||
cookies: string,
|
||||
items: { productId: string; variantId: string; quantity: number }[],
|
||||
): Promise<{ ok: true } | { ok: false; status: number; message: string }> {
|
||||
let serverItems: { variantId: string; quantity: number }[] = [];
|
||||
try {
|
||||
const current = await fetch(`${API}/cart`, {
|
||||
headers: { Cookie: cookies },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (current.ok) {
|
||||
const body = (await current.json()) as { items?: { variantId?: unknown; quantity?: unknown }[] };
|
||||
serverItems = (body.items ?? [])
|
||||
.filter(
|
||||
(entry): entry is { variantId: string; quantity: number } =>
|
||||
typeof entry.variantId === 'string' && typeof entry.quantity === 'number',
|
||||
)
|
||||
.map((entry) => ({ variantId: entry.variantId, quantity: entry.quantity }));
|
||||
}
|
||||
} catch {
|
||||
// Treat the cart as empty if the backend is unreachable; the checkout call
|
||||
// will surface the real problem.
|
||||
}
|
||||
|
||||
const nextByVariant = new Map(items.map((item) => [item.variantId, item]));
|
||||
const operations: Promise<Response>[] = [];
|
||||
for (const item of serverItems) {
|
||||
if (!nextByVariant.has(item.variantId)) {
|
||||
operations.push(
|
||||
fetch(`${API}/cart/items/${item.variantId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Cookie: cookies },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const item of items) {
|
||||
operations.push(
|
||||
fetch(`${API}/cart/items`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookies },
|
||||
body: JSON.stringify(item),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const results = await Promise.all(operations);
|
||||
for (const response of results) {
|
||||
if (!response.ok && response.status !== 404) {
|
||||
const message = await response.text();
|
||||
return { ok: false, status: response.status, message: message || 'Error al sincronizar el carrito' };
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const cookieJar = await cookies();
|
||||
const sessionCookie = cookieJar.get(SESSION_COOKIE)?.value;
|
||||
if (!sessionCookie) {
|
||||
return NextResponse.json(UNAUTHORIZED, { status: 401 });
|
||||
}
|
||||
|
||||
const cookieHeader = request.headers.get('cookie') ?? `${SESSION_COOKIE}=${sessionCookie}`;
|
||||
|
||||
let body: RawBody;
|
||||
try {
|
||||
body = (await request.json()) as RawBody;
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'INVALID_BODY', message: 'Cuerpo JSON inválido.' } },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const address = body.shippingAddress ?? {};
|
||||
const country = isNonEmptyString(address.country) ? address.country : COUNTRY_FALLBACK;
|
||||
const postalCode = isNonEmptyString(address.postalCode) ? address.postalCode : null;
|
||||
if (!postalCode) {
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'INVALID_ADDRESS', message: 'Falta el código postal.' } },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let items: { productId: string; variantId: string; quantity: number }[];
|
||||
try {
|
||||
items = parseItems(body.items);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'INVALID_CART', message: (error as Error).message } },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const sync = await syncCart(cookieHeader, items);
|
||||
if (!sync.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'CART_SYNC_FAILED', message: sync.message } },
|
||||
{ status: sync.status },
|
||||
);
|
||||
}
|
||||
|
||||
const idempotencyKey = isNonEmptyString(body.idempotencyKey)
|
||||
? body.idempotencyKey
|
||||
: `frontend-${randomUUID()}`;
|
||||
|
||||
let backendRes: Response;
|
||||
try {
|
||||
backendRes = await fetch(`${API}/checkout`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookieHeader },
|
||||
body: JSON.stringify({
|
||||
address: { country, postalCode },
|
||||
promoCode: body.promoCode ?? null,
|
||||
idempotencyKey,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'BACKEND_UNREACHABLE',
|
||||
message: `No se pudo conectar con el servidor: ${(error as Error).message}`,
|
||||
},
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const text = await backendRes.text();
|
||||
let payload: unknown = text;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
// Keep raw text if backend returned non-JSON.
|
||||
}
|
||||
|
||||
if (backendRes.status === 401) {
|
||||
return NextResponse.json(UNAUTHORIZED, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.json(payload, { status: backendRes.status });
|
||||
}
|
||||
@@ -100,7 +100,7 @@ export default async function ProductPage({ params }: Props) {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
{/* Image */}
|
||||
<div>
|
||||
<div className="aspect-square max-h-[500px] bg-gray-50 rounded-2xl border border-gray-100 flex items-center justify-center overflow-hidden">
|
||||
<div className="relative aspect-square max-h-[500px] bg-gray-50 rounded-2xl border border-gray-100 flex items-center justify-center overflow-hidden">
|
||||
{product.images?.[0] ? (
|
||||
<Image
|
||||
src={product.images[0].url}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -75,8 +75,12 @@ export default function CheckoutClient() {
|
||||
notes: form.notes,
|
||||
}),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
setError('Debes iniciar sesión para finalizar el pedido.');
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error?.message || 'Error al procesar el pedido');
|
||||
}
|
||||
const { orderId } = await res.json();
|
||||
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 568 B |
|
After Width: | Height: | Size: 568 B |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 37 KiB |
126
project/storefront/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);
|
||||
}
|
||||