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

@@ -4264,6 +4264,41 @@
"close": true
},
"completed_at": "2026-08-20T20:01:54Z"
},
{
"id": "F-095",
"type": "fix",
"title": "Download remote image URL before attaching product image",
"problem": "Adding an external image URL from the admin product editor sends the URL directly to the attach endpoint, and an unnecessary empty product PATCH causes HTTP 400. External images should be downloaded to local uploads before being attached so they remain available and are mirrored to the other apps.",
"goal": "Make Add URL download a validated remote image through the admin upload route, store it locally, then attach the local uploads URL to the product. Remove the erroneous empty product update.",
"scope_in": [
"admin image editor upload route and product image attachment"
],
"scope_out": [
"No arbitrary non-image downloads",
"no private network access",
"no external URL stored as the product image"
],
"priority": "high",
"risk": "low",
"description": "Problem: Adding an external image URL from the admin product editor sends the URL directly to the attach endpoint, and an unnecessary empty product PATCH causes HTTP 400. External images should be downloaded to local uploads before being attached so they remain available and are mirrored to the other apps.. Goal: Make Add URL download a validated remote image through the admin upload route, store it locally, then attach the local uploads URL to the product. Remove the erroneous empty product update.. Scope IN: admin image editor upload route and product image attachment. Scope OUT: No arbitrary non-image downloads, no private network access, no external URL stored as the product image. Type: fix. Priority: high. Risk: low.",
"acceptance": [
"- Valid remote image URL is downloaded and stored under local uploads",
"- Local uploads URL is attached to the product after download",
"- Invalid/non-image/oversized URLs return a clear error",
"- Private/local network URLs are rejected",
"- Existing file upload flow remains working",
"- Admin typecheck/lint and verify.sh pass"
],
"status": "done",
"created_at": "2026-08-20",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"completed_at": "2026-08-20T20:05:54Z"
}
]
}

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

View File

@@ -0,0 +1,14 @@
# F-095 — Implementer evidence
## Changes
- Removed the erroneous empty `productsApi.update(productId, {})` call from URL image attachment.
- The admin image editor now sends `{ url }` to `/api/upload`, then attaches the returned local `/uploads/...` URL to the product.
- `/api/upload` now supports authenticated JSON URL imports: validates public HTTP(S) hosts, follows limited redirects, accepts only supported image MIME/signatures, enforces the 10MB limit, stores locally, mirrors peers, and generates thumbnails.
- Existing multipart file upload behavior uses the shared local save helper.
## Validation
- Admin `npx tsc --noEmit` → exit 0
- Admin ESLint on upload route and ImagesSection → 0 errors
- Admin production build → exit 0

View File

@@ -0,0 +1,15 @@
{
"feature_id": "F-095",
"agent": "leader",
"verdict": "APPROVED",
"summary": "F-095 downloads validated remote images to local uploads before attaching them and removes the 400-causing empty PATCH.",
"evidence": [
"reviewer.json verdict=APPROVED",
"security.json verdict=APPROVED",
"qa.json verdict=APPROVED",
"Admin build exit 0",
"Root tests: 133 passed, 56 skipped",
"scripts/verify.sh exit 0"
],
"timestamp": "2026-08-20T20:05:50Z"
}

View File

@@ -0,0 +1,14 @@
{
"feature_id": "F-095",
"agent": "qa",
"verdict": "APPROVED",
"summary": "Remote image import changes pass admin build, root tests, typechecks, and harness verification.",
"evidence": [
"Admin npx tsc --noEmit exit 0",
"Admin npm run build exit 0",
"Root tests: 133 passed, 56 skipped",
"Admin ESLint upload route and ImagesSection exit 0",
"scripts/verify.sh exit 0"
],
"timestamp": "2026-08-20T20:05:40Z"
}

View File

@@ -0,0 +1,13 @@
{
"feature_id": "F-095",
"agent": "reviewer",
"verdict": "APPROVED",
"summary": "URL image import now downloads into the managed upload pipeline before product attachment and removes the 400-causing empty product PATCH.",
"evidence": [
"ImagesSection uploads URL JSON then attaches only the returned local path",
"Remote route validates MIME, signature, size, redirects, and public host",
"Existing multipart upload shares local persistence and thumbnail generation",
"Admin production build passes"
],
"timestamp": "2026-08-20T20:05:00Z"
}

View File

@@ -0,0 +1,14 @@
{
"feature_id": "F-095",
"agent": "security",
"verdict": "APPROVED",
"summary": "Remote imports remain authenticated and constrained to public HTTP(S) image resources with redirect, size, MIME, and binary signature checks.",
"evidence": [
"Upload route requires authenticated backoffice session before remote fetch",
"localhost, local hostnames, private IPv4, and private IPv6 destinations are rejected",
"Only image MIME types supported by the existing upload pipeline are accepted",
"10MB limit, timeout, limited redirects, and image magic-byte validation are enforced",
"Remote URL is never stored directly as a product image"
],
"timestamp": "2026-08-20T20:05:20Z"
}

View File

@@ -1,14 +1,22 @@
# Feature actual
## Feature activa: F-094 (in_progress) — Create and explain product variants from Publish tab
## Feature activa: F-095 (in_progress) — Download remote image URL before attaching product image
Backlog: 162 features (152 done, 9 pending, 1 in_progress).
Backlog: 163 features (153 done, 9 pending, 1 in_progress).
Últimas features cerradas: **F-080**, **F-081**, **F-082**, **F-083**, **F-084**, **F-085**, **F-086**, **F-087**.
## Incidencia actual (2026-08-20)
Precios e Inventario indican que las variantes se crean desde Publicar, pero Publicar no ofrece creación ni explicación. F-094 añade la gestión de variantes allí.
Añadir una imagen por URL devuelve 400 porque el flujo hace un PATCH vacío del producto y adjunta directamente la URL remota. F-095 descargará la imagen validada a uploads locales antes de adjuntarla.
## Última incidencia resuelta (2026-08-20)
F-094 cerrada con todos los gates aprobados. Publicar permite crear variantes y explica SKU/EAN, precios y stock.
## Incidencia anterior (2026-08-20)
Precios e Inventario indicaban que las variantes se creaban desde Publicar, pero Publicar no ofrecía creación ni explicación.
## Última incidencia resuelta (2026-08-20)

View File

@@ -1,62 +1,13 @@
{
"feature_id": "F-094",
"feature_id": "F-095",
"stage": "close",
"agent": "leader",
"action": "Validate F-094 gates and close variant workflow",
"action": "Validate F-095 gates and close remote image importer",
"state": "running",
"next_agent": "leader",
"waiting_for": "verify.sh green",
"updated_at": "2026-08-20T20:01:42Z",
"updated_at": "2026-08-20T20:05:44Z",
"timeline": [
{
"ts": "2026-08-20T19:48:12Z",
"agent": "leader",
"stage": "intake",
"state": "running",
"message": "Triage expiration date placement: move from SEO tab to General tab"
},
{
"ts": "2026-08-20T19:48:26Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Move existing expirationDate control from SEO section into General section"
},
{
"ts": "2026-08-20T19:51:05Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "running",
"message": "Review expiration date placement and unchanged save contract"
},
{
"ts": "2026-08-20T19:51:17Z",
"agent": "security",
"stage": "security_gate",
"state": "running",
"message": "Check expiration date move for unchanged validation and payload"
},
{
"ts": "2026-08-20T19:51:27Z",
"agent": "qa",
"stage": "qa_gate",
"state": "running",
"message": "Run F-092 admin checks, deployment smoke test, and verify"
},
{
"ts": "2026-08-20T19:51:36Z",
"agent": "leader",
"stage": "close",
"state": "running",
"message": "Validate F-092 gates and close expiration date placement"
},
{
"ts": "2026-08-20T19:51:56Z",
"agent": "leader",
"stage": "close",
"state": "done",
"message": "F-092 cerrado: caducidad en General y backend/admin redeployados"
},
{
"ts": "2026-08-20T19:55:40Z",
"agent": "leader",
@@ -147,6 +98,55 @@
"stage": "close",
"state": "running",
"message": "Validate F-094 gates and close variant workflow"
},
{
"ts": "2026-08-20T20:02:00Z",
"agent": "leader",
"stage": "close",
"state": "done",
"message": "F-094 cerrado: creación y explicación de variantes en Publicar"
},
{
"ts": "2026-08-20T20:02:19Z",
"agent": "leader",
"stage": "intake",
"state": "running",
"message": "Triage image URL 400 and implement secure remote download to uploads"
},
{
"ts": "2026-08-20T20:03:18Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Implement remote image URL download and local attachment flow"
},
{
"ts": "2026-08-20T20:04:52Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "running",
"message": "Review remote image download, validation, storage, and attachment flow"
},
{
"ts": "2026-08-20T20:05:06Z",
"agent": "security",
"stage": "security_gate",
"state": "running",
"message": "Check SSRF, remote content validation, size limits, and upload auth"
},
{
"ts": "2026-08-20T20:05:21Z",
"agent": "qa",
"stage": "qa_gate",
"state": "running",
"message": "Run image import typecheck, tests, build, and verify"
},
{
"ts": "2026-08-20T20:05:44Z",
"agent": "leader",
"stage": "close",
"state": "running",
"message": "Validate F-095 gates and close remote image importer"
}
]
}