feat(F-095): completed feature
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user