|
- import { NextResponse } from 'next/server';
- import { writeFile, mkdir, rename, unlink } from 'fs/promises';
- import path from 'path';
- import { fromBuffer as fileTypeFromBuffer } from 'file-type';
- import { enqueueTranscode, needsTranscode, probeCodecs, isFfmpegAvailable } from '@/lib/transcode';
- import { UPLOAD_LIMITS } from '@/lib/config';
- import { sanitizeSvg } from '@/lib/svg-sanitize';
-
- export const dynamic = 'force-dynamic';
- export const maxDuration = 300;
-
- // Allowed extensions and their families (size + handling differ per family).
- type Family = 'image' | 'video' | 'pdf';
- const EXT_FAMILY: Record<string, Family> = {
- png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', webp: 'image',
- mp4: 'video', m4v: 'video', webm: 'video', mov: 'video', ogv: 'video', ogg: 'video',
- pdf: 'pdf',
- };
-
- // Strict per family, permissive within a family (mov ↔ mp4 are both ISO BMFF).
- const ALLOWED_MIMES: Record<string, string[]> = {
- png: ['image/png'],
- jpg: ['image/jpeg'],
- jpeg: ['image/jpeg'],
- gif: ['image/gif'],
- webp: ['image/webp'],
- mp4: ['video/mp4', 'video/x-m4v', 'video/quicktime'],
- m4v: ['video/mp4', 'video/x-m4v'],
- webm: ['video/webm'],
- mov: ['video/quicktime', 'video/mp4'],
- ogv: ['video/ogg', 'application/ogg'],
- ogg: ['video/ogg', 'application/ogg', 'audio/ogg'],
- pdf: ['application/pdf'],
- };
-
- const MAX_BYTES: Record<Family, number> = UPLOAD_LIMITS;
-
- const UPLOAD_DIR = path.join(process.cwd(), 'data', 'uploads');
- const TMP_DIR = path.join(UPLOAD_DIR, '.tmp');
-
- function extractExt(name: string): string {
- const lastDot = name.lastIndexOf('.');
- if (lastDot < 0 || lastDot === name.length - 1) return '';
- return name.slice(lastDot + 1).toLowerCase();
- }
-
- export function normalizeFilename(originalName: string): string {
- const ext = extractExt(originalName);
- const safeExt = /^[a-z0-9]{1,5}$/.test(ext) ? ext : 'bin';
- const base = ext ? originalName.slice(0, originalName.length - ext.length - 1) : originalName;
-
- const slug = base
- .normalize('NFKD')
- .replace(/\p{Mn}/gu, '')
- .toLowerCase()
- .replace(/[\s/\\]+/g, '-')
- .replace(/[^a-z0-9_-]/g, '')
- .replace(/-+/g, '-')
- .replace(/^[-_]+|[-_]+$/g, '')
- .slice(0, 40) || 'file';
-
- const ts = Date.now().toString(36);
- const rand = Math.random().toString(36).slice(2, 8);
- return `${ts}-${rand}-${slug}.${safeExt}`;
- }
-
- export async function POST(request: Request) {
- let tmpPath: string | null = null;
- try {
- const formData = await request.formData();
- const file = formData.get('file') as File | null;
-
- if (!file) {
- return NextResponse.json({ error: 'No file received.' }, { status: 400 });
- }
-
- const ext = extractExt(file.name);
-
- // SVG: ammesso SOLO per upload del logo (`?context=logo`).
- // file-type non rileva l'SVG affidabilmente perché è XML/testo, quindi qui
- // saltiamo il magic-bytes check e affidiamo la verifica al sanitizer
- // (richiede che il file inizi con `<svg>` o `<?xml ... <svg>`).
- const { searchParams } = new URL(request.url);
- const isLogoContext = searchParams.get('context') === 'logo';
- if (ext === 'svg') {
- if (!isLogoContext) {
- return NextResponse.json(
- { error: 'SVG accettato solo per upload del logo.' },
- { status: 400 }
- );
- }
- if (file.size > MAX_BYTES.image) {
- const mb = (MAX_BYTES.image / (1024 * 1024)).toFixed(0);
- return NextResponse.json(
- { error: `File troppo grande (max ${mb} MB per image).` },
- { status: 413 }
- );
- }
- const text = Buffer.from(await file.arrayBuffer()).toString('utf-8');
- const result = sanitizeSvg(text);
- if (!result.ok) {
- return NextResponse.json({ error: result.error }, { status: 400 });
- }
- const storedName = normalizeFilename(file.name);
- await mkdir(TMP_DIR, { recursive: true });
- tmpPath = path.join(TMP_DIR, storedName);
- await writeFile(tmpPath, Buffer.from(result.sanitized, 'utf-8'));
- const finalPath = path.join(UPLOAD_DIR, storedName);
- await rename(tmpPath, finalPath);
- tmpPath = null;
- return NextResponse.json(
- { url: `/api/files?name=${encodeURIComponent(storedName)}` },
- { status: 201 }
- );
- }
-
- const family = EXT_FAMILY[ext];
- if (!family) {
- return NextResponse.json(
- { error: `Estensione non permessa: .${ext || '(nessuna)'}` },
- { status: 400 }
- );
- }
-
- if (file.size > MAX_BYTES[family]) {
- const mb = (MAX_BYTES[family] / (1024 * 1024)).toFixed(0);
- return NextResponse.json(
- { error: `File troppo grande (max ${mb} MB per ${family}).` },
- { status: 413 }
- );
- }
-
- const buffer = Buffer.from(await file.arrayBuffer());
-
- // Magic-bytes sniffing: reject if the file content doesn't match its claimed extension.
- const detected = await fileTypeFromBuffer(buffer);
- if (!detected) {
- return NextResponse.json(
- { error: 'Tipo del file non riconoscibile dal contenuto.' },
- { status: 400 }
- );
- }
- const allowedMimes = ALLOWED_MIMES[ext] ?? [];
- if (!allowedMimes.includes(detected.mime)) {
- return NextResponse.json(
- { error: `Contenuto del file non corrisponde all'estensione (.${ext} dichiarato, rilevato ${detected.mime}).` },
- { status: 400 }
- );
- }
-
- const storedName = normalizeFilename(file.name);
-
- await mkdir(TMP_DIR, { recursive: true });
- tmpPath = path.join(TMP_DIR, storedName);
- await writeFile(tmpPath, buffer);
-
- // Image / PDF: rename atomically into the uploads dir and we're done.
- if (family !== 'video') {
- const finalPath = path.join(UPLOAD_DIR, storedName);
- await rename(tmpPath, finalPath);
- tmpPath = null;
- return NextResponse.json(
- { url: `/api/files?name=${encodeURIComponent(storedName)}` },
- { status: 201 }
- );
- }
-
- // Video: probe codecs. If already h264+aac/mp3 → fast path (rename).
- const codecs = await probeCodecs(tmpPath);
- if (!needsTranscode(codecs)) {
- const finalPath = path.join(UPLOAD_DIR, storedName);
- await rename(tmpPath, finalPath);
- tmpPath = null;
- return NextResponse.json(
- { url: `/api/files?name=${encodeURIComponent(storedName)}` },
- { status: 201 }
- );
- }
-
- // Needs transcoding: bail out early if ffmpeg is unavailable.
- if (!(await isFfmpegAvailable())) {
- return NextResponse.json(
- { error: 'Video richiede ricodifica ma ffmpeg non è disponibile sul server.' },
- { status: 503 }
- );
- }
-
- // The final file is always an .mp4 once transcoded — replace the extension.
- const finalMp4Name = storedName.replace(/\.[^.]+$/, '.mp4');
- const job = await enqueueTranscode({
- inputPath: tmpPath,
- outputName: finalMp4Name,
- originalUploadName: file.name,
- });
- // The input is now owned by the transcode worker; don't unlink in our catch.
- tmpPath = null;
-
- return NextResponse.json(
- {
- url: `/api/files?name=${encodeURIComponent(finalMp4Name)}`,
- transcoding: { jobId: job.id, status: job.status },
- },
- { status: 201 }
- );
- } catch (error) {
- console.error('Upload Error:', error);
- if (tmpPath) {
- try { await unlink(tmpPath); } catch {}
- }
- return NextResponse.json({ error: 'Failed to upload file.' }, { status: 500 });
- }
- }
|