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'; 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 = { 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 = { 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 = { image: 25 * 1024 * 1024, // 25 MB pdf: 20 * 1024 * 1024, // 20 MB (pdfjs lato browser non regge bene di più) video: 1024 * 1024 * 1024, // 1 GB (verrà ricodificato lato server se necessario) }; 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); 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 }); } }