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