feat(F-095): completed feature

This commit is contained in:
chattie
2026-08-20 22:05:54 +02:00
parent 418bdca7f4
commit 4b60db026b
11 changed files with 272 additions and 77 deletions

View File

@@ -1,4 +1,6 @@
import { randomUUID } from 'node:crypto';
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
import { copyFile, mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { NextRequest, NextResponse } from 'next/server';
@@ -81,6 +83,16 @@ export async function POST(request: NextRequest) {
}
try {
if (request.headers.get('content-type')?.includes('application/json')) {
const body = (await request.json()) as { url?: unknown };
if (typeof body.url !== 'string' || !body.url.trim()) {
return NextResponse.json({ error: 'URL de imagen obligatoria' }, { status: 400 });
}
const remote = await downloadRemoteImage(body.url.trim());
const saved = await saveUpload(remote.buffer, remote.extension);
return NextResponse.json(saved);
}
const formData = await request.formData();
const file = formData.get('file');
@@ -105,26 +117,82 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'El contenido no coincide con un formato de imagen válido' }, { status: 400 });
}
const filename = `${randomUUID()}.${extension}`;
const uploadDir = path.join(process.cwd(), 'public', 'uploads');
const filePath = path.join(uploadDir, filename);
await mkdir(uploadDir, { recursive: true });
await writeFile(filePath, buffer, { flag: 'wx' });
await mirrorToPeers(filePath);
await generateThumbnails(buffer, filename);
return NextResponse.json({
url: `/uploads/${filename}`,
filename,
size: file.size,
});
const saved = await saveUpload(buffer, extension);
return NextResponse.json({ ...saved, size: file.size });
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json({ error: 'Error al subir el archivo' }, { status: 500 });
}
}
async function saveUpload(buffer: Buffer, extension: string): Promise<{ url: string; filename: string }> {
const filename = `${randomUUID()}.${extension}`;
const uploadDir = path.join(process.cwd(), 'public', 'uploads');
const filePath = path.join(uploadDir, filename);
await mkdir(uploadDir, { recursive: true });
await writeFile(filePath, buffer, { flag: 'wx' });
await mirrorToPeers(filePath);
await generateThumbnails(buffer, filename);
return { url: `/uploads/${filename}`, filename };
}
async function downloadRemoteImage(rawUrl: string): Promise<{ buffer: Buffer; extension: string }> {
let currentUrl = rawUrl;
for (let attempt = 0; attempt < 4; attempt += 1) {
await assertPublicRemoteUrl(currentUrl);
const response = await fetch(currentUrl, {
redirect: 'manual',
headers: { Accept: 'image/*', 'User-Agent': 'MercadoDeVida image importer' },
signal: AbortSignal.timeout(15_000),
});
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (!location) throw new Error('La imagen remota redirige sin destino');
currentUrl = new URL(location, currentUrl).toString();
continue;
}
if (!response.ok) throw new Error(`La imagen remota respondió ${response.status}`);
const contentType = (response.headers.get('content-type') ?? '').split(';')[0].toLowerCase();
const extension = EXTENSION_BY_TYPE[contentType];
if (!extension) throw new Error('La URL no devuelve una imagen compatible');
const declaredSize = Number(response.headers.get('content-length') ?? 0);
if (declaredSize > MAX_SIZE) throw new Error('La imagen supera el límite de 10MB');
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length === 0 || buffer.length > MAX_SIZE) throw new Error('La imagen supera el límite de 10MB');
if (!hasExpectedImageSignature(buffer, contentType)) throw new Error('El contenido remoto no es una imagen válida');
return { buffer, extension };
}
throw new Error('Demasiadas redirecciones en la URL de imagen');
}
async function assertPublicRemoteUrl(rawUrl: string): Promise<void> {
let parsed: URL;
try { parsed = new URL(rawUrl); } catch { throw new Error('URL de imagen no válida'); }
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
throw new Error('Solo se permiten URLs HTTP o HTTPS públicas');
}
const hostname = parsed.hostname.toLowerCase();
if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname.endsWith('.local')) {
throw new Error('No se permiten hosts locales');
}
const addresses = await lookup(hostname, { all: true });
if (addresses.some(({ address }) => isPrivateAddress(address))) {
throw new Error('No se permiten direcciones privadas o internas');
}
}
function isPrivateAddress(address: string): boolean {
if (isIP(address) === 4) {
const octets = address.split('.').map(Number);
return octets[0] === 10 || octets[0] === 127 || octets[0] === 0 ||
(octets[0] === 169 && octets[1] === 254) ||
(octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) ||
(octets[0] === 192 && octets[1] === 168);
}
const normalized = address.toLowerCase();
return normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe80:');
}
async function isAuthenticatedBackofficeRequest(request: NextRequest): Promise<boolean> {
const cookies = request.headers.get('cookie');
if (!cookies) return false;

View File

@@ -34,17 +34,31 @@ export function ImagesSection({ productId }: ImagesSectionProps) {
if (!url.trim()) return;
setSavingUrl(true);
try {
const p = await productsApi.update(productId, {});
// Attach via images array — for now use the attach endpoint
await fetch(`/api/products/${productId}/images`, {
const uploadResponse = await fetch('/api/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json', credentials: 'include' },
body: JSON.stringify({ url: url.trim(), altText: '', role: 'gallery' }),
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ url: url.trim() }),
});
const uploadData = await uploadResponse.json().catch(() => ({})) as { url?: string; error?: string };
if (!uploadResponse.ok || !uploadData.url) {
throw new Error(uploadData.error ?? 'No se pudo descargar la imagen');
}
const attachResponse = await fetch(`/api/products/${productId}/images`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ url: uploadData.url, altText: '', role: 'gallery' }),
});
if (!attachResponse.ok) {
const attachData = await attachResponse.json().catch(() => ({})) as { message?: string; error?: string };
throw new Error(attachData.message ?? attachData.error ?? 'No se pudo adjuntar la imagen');
}
setUrlInput('');
load();
} catch {
setError('Error al añadir imagen');
} catch (cause) {
setError(cause instanceof Error ? cause.message : 'Error al añadir imagen');
} finally {
setSavingUrl(false);
}

File diff suppressed because one or more lines are too long