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 = { 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 = 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(); } // Formatta bytes in MB con 1 decimale per messaggi all'utente (es. "23.4 MB"). function fmtMB(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } // Lista leggibile delle estensioni accettate per famiglia, per i messaggi di errore. const EXT_BY_FAMILY: Record = { image: ['PNG', 'JPG', 'JPEG', 'GIF', 'WEBP'], video: ['MP4', 'M4V', 'WEBM', 'MOV', 'OGV', 'OGG'], pdf: ['PDF'], }; 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 was received. Please choose a file before uploading.' }, { 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 `` o ``). const { searchParams } = new URL(request.url); const isLogoContext = searchParams.get('context') === 'logo'; if (ext === 'svg') { if (!isLogoContext) { return NextResponse.json( { error: 'SVG files can only be used for the portal logo. For other images use PNG, JPG, GIF or WEBP.' }, { status: 400 }, ); } if (file.size > MAX_BYTES.image) { return NextResponse.json( { error: `SVG is too large: ${fmtMB(file.size)} (limit: ${fmtMB(MAX_BYTES.image)}). Optimize the SVG (e.g. SVGO) or remove embedded raster images.` }, { 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) { const allFormats = [ ...EXT_BY_FAMILY.image, ...EXT_BY_FAMILY.video, ...EXT_BY_FAMILY.pdf, 'SVG (logo only)', ].join(', '); return NextResponse.json( { error: `File extension ".${ext || '(none)'}" is not allowed. Accepted formats: ${allFormats}.` }, { status: 400 }, ); } if (file.size > MAX_BYTES[family]) { const hint = family === 'pdf' ? ' Compress the PDF (Ghostscript /ebook preset, Adobe "Reduce File Size", or an online PDF compressor) or split it into smaller documents.' : family === 'video' ? ' Re-encode the video at a lower bitrate or resolution before uploading.' : ' Resize or compress the image before uploading (e.g. lower resolution or convert to WEBP).'; return NextResponse.json( { error: `File is too large: ${fmtMB(file.size)} (limit for ${family.toUpperCase()}: ${fmtMB(MAX_BYTES[family])}).${hint}` }, { 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: `Could not identify the file content. The file may be corrupted, empty, or saved in an unsupported variant. Try re-exporting it as ${EXT_BY_FAMILY[family].join(' / ')}.` }, { status: 400 }, ); } const allowedMimes = ALLOWED_MIMES[ext] ?? []; if (!allowedMimes.includes(detected.mime)) { return NextResponse.json( { error: `File content does not match its extension: the file looks like ${detected.mime} but the extension is ".${ext}". Rename the file to the correct extension or re-export it in the declared format.` }, { 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: 'This video uses a codec that browsers cannot play and needs to be transcoded, but ffmpeg is not installed on the server. Upload the video already encoded as H.264 + AAC in an .mp4 container, or ask the administrator to install ffmpeg.' }, { 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: 'Unexpected error while saving the file. The upload was aborted; nothing was changed. Check the server logs for details and try again.' }, { status: 500 }, ); } }