feat(F-048): completed feature

This commit is contained in:
chattie
2026-08-19 07:17:14 +02:00
parent 8ee1938af9
commit 835ab66eda
187 changed files with 12361 additions and 1065 deletions

View File

@@ -3,9 +3,10 @@ import { NextRequest, NextResponse } from 'next/server';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
/**
* Strip the Secure flag from the backend's Set-Cookie so the browser
* (which connects over HTTP) actually stores the session cookie.
* Also drop SameSite=Lax to avoid browser restrictions.
* FIX-14: admin panel auth now goes through the backoffice auth endpoint,
* which sets a separate `backoffice_session` cookie (independent of the
* storefront `mdv_session`). This keeps the physical separation between
* backoffice users and storefront customers.
*/
function makeLocalhostCompatible(cookie: string): string {
return cookie
@@ -19,7 +20,7 @@ export async function POST(req: NextRequest) {
const body = await req.json();
const { email, password } = body;
const backendRes = await fetch(`${API}/auth/login`, {
const backendRes = await fetch(`${API}/backoffice/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
@@ -43,4 +44,4 @@ export async function POST(req: NextRequest) {
{ status: 500 },
);
}
}
}

View File

@@ -1,19 +1,21 @@
import { NextRequest, NextResponse } from 'next/server';
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
export async function POST(req: NextRequest) {
/** FIX-14: logout hits the backoffice endpoint and clears backoffice_session. */
export async function POST() {
try {
const cookies = req.headers.get('cookie') ?? '';
await fetch(`${API}/auth/logout`, {
const { cookies } = await import('next/headers');
const token = (await cookies()).get('backoffice_session')?.value;
await fetch(`${API}/backoffice/auth/logout`, {
method: 'POST',
headers: { Cookie: cookies },
headers: token ? { Cookie: `backoffice_session=${token}` } : {},
});
} catch {
// Best-effort
/* ignore */
}
const response = NextResponse.json({ ok: true });
response.cookies.delete('mdv_session');
return response;
}
const res = NextResponse.json({ ok: true }, { status: 200 });
res.cookies.set('backoffice_session', '', { path: '/', maxAge: 0 });
return res;
}

View File

@@ -2,10 +2,11 @@ import { NextRequest, NextResponse } from 'next/server';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
/** FIX-14: admin panel auth now resolves through the backoffice endpoint. */
export async function GET(req: NextRequest) {
const cookies = req.headers.get('cookie') ?? '';
try {
const backendRes = await fetch(`${API}/auth/me`, {
const backendRes = await fetch(`${API}/backoffice/auth/me`, {
headers: { Cookie: cookies },
});
if (!backendRes.ok) return NextResponse.json({ user: null });
@@ -13,4 +14,4 @@ export async function GET(req: NextRequest) {
} catch {
return NextResponse.json({ user: null });
}
}
}

View File

@@ -1,44 +1,109 @@
import { randomUUID } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { NextRequest, NextResponse } from 'next/server';
import { writeFile, mkdir } from 'fs/promises';
import path from 'path';
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif'];
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
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>> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/avif': 'avif',
'image/gif': 'gif',
};
export async function POST(request: NextRequest) {
if (!(await isAuthenticatedBackofficeRequest(request))) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
try {
const formData = await request.formData();
const file = formData.get('file') as File | null;
const file = formData.get('file');
if (!file) {
if (!(file instanceof File)) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
if (!ALLOWED_TYPES.includes(file.type)) {
const extension = EXTENSION_BY_TYPE[file.type];
if (!extension) {
return NextResponse.json(
{ error: `Tipo no permitido. Usa: ${ALLOWED_TYPES.join(', ')}` },
{ error: `Tipo no permitido. Usa: ${Object.keys(EXTENSION_BY_TYPE).join(', ')}` },
{ status: 400 },
);
}
if (file.size > MAX_SIZE) {
return NextResponse.json({ error: 'El archivo excede 10MB' }, { status: 400 });
if (file.size <= 0 || file.size > MAX_SIZE) {
return NextResponse.json({ error: 'El archivo debe ocupar entre 1 byte y 10MB' }, { status: 400 });
}
// Unique filename
const ext = file.name.split('.').pop() ?? 'jpg';
const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
if (!hasExpectedImageSignature(buffer, file.type)) {
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 });
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(filePath, buffer);
await writeFile(filePath, buffer, { flag: 'wx' });
const url = `/uploads/${filename}`;
return NextResponse.json({ url, filename, size: file.size });
return NextResponse.json({
url: `/uploads/${filename}`,
filename,
size: file.size,
});
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json({ error: 'Error al subir el archivo' }, { status: 500 });
}
}
async function isAuthenticatedBackofficeRequest(request: NextRequest): Promise<boolean> {
const cookies = request.headers.get('cookie');
if (!cookies) return false;
try {
const response = await fetch(`${API}/backoffice/auth/me`, {
headers: { Cookie: cookies },
cache: 'no-store',
});
if (!response.ok) return false;
const body = (await response.json()) as { id?: unknown; role?: unknown; user?: null };
return (
typeof body.id === 'string' &&
(body.role === 'admin' || body.role === 'editor') &&
body.user !== null
);
} catch {
return false;
}
}
function hasExpectedImageSignature(buffer: Buffer, type: string): boolean {
if (type === 'image/jpeg') {
return buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
}
if (type === 'image/png') {
return buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'));
}
if (type === 'image/gif') {
const signature = buffer.subarray(0, 6).toString('ascii');
return signature === 'GIF87a' || signature === 'GIF89a';
}
if (type === 'image/webp') {
return (
buffer.length >= 12 &&
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
);
}
if (type === 'image/avif') {
if (buffer.length < 12 || buffer.subarray(4, 8).toString('ascii') !== 'ftyp') return false;
const brands = buffer.subarray(8, Math.min(buffer.length, 32)).toString('ascii');
return brands.includes('avif') || brands.includes('avis');
}
return false;
}