Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.
 
 

213 lignes
7.3 KiB

  1. import { NextResponse } from 'next/server';
  2. import { writeFile, mkdir, rename, unlink } from 'fs/promises';
  3. import path from 'path';
  4. import { fromBuffer as fileTypeFromBuffer } from 'file-type';
  5. import { enqueueTranscode, needsTranscode, probeCodecs, isFfmpegAvailable } from '@/lib/transcode';
  6. import { UPLOAD_LIMITS } from '@/lib/config';
  7. import { sanitizeSvg } from '@/lib/svg-sanitize';
  8. export const dynamic = 'force-dynamic';
  9. export const maxDuration = 300;
  10. // Allowed extensions and their families (size + handling differ per family).
  11. type Family = 'image' | 'video' | 'pdf';
  12. const EXT_FAMILY: Record<string, Family> = {
  13. png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', webp: 'image',
  14. mp4: 'video', m4v: 'video', webm: 'video', mov: 'video', ogv: 'video', ogg: 'video',
  15. pdf: 'pdf',
  16. };
  17. // Strict per family, permissive within a family (mov ↔ mp4 are both ISO BMFF).
  18. const ALLOWED_MIMES: Record<string, string[]> = {
  19. png: ['image/png'],
  20. jpg: ['image/jpeg'],
  21. jpeg: ['image/jpeg'],
  22. gif: ['image/gif'],
  23. webp: ['image/webp'],
  24. mp4: ['video/mp4', 'video/x-m4v', 'video/quicktime'],
  25. m4v: ['video/mp4', 'video/x-m4v'],
  26. webm: ['video/webm'],
  27. mov: ['video/quicktime', 'video/mp4'],
  28. ogv: ['video/ogg', 'application/ogg'],
  29. ogg: ['video/ogg', 'application/ogg', 'audio/ogg'],
  30. pdf: ['application/pdf'],
  31. };
  32. const MAX_BYTES: Record<Family, number> = UPLOAD_LIMITS;
  33. const UPLOAD_DIR = path.join(process.cwd(), 'data', 'uploads');
  34. const TMP_DIR = path.join(UPLOAD_DIR, '.tmp');
  35. function extractExt(name: string): string {
  36. const lastDot = name.lastIndexOf('.');
  37. if (lastDot < 0 || lastDot === name.length - 1) return '';
  38. return name.slice(lastDot + 1).toLowerCase();
  39. }
  40. export function normalizeFilename(originalName: string): string {
  41. const ext = extractExt(originalName);
  42. const safeExt = /^[a-z0-9]{1,5}$/.test(ext) ? ext : 'bin';
  43. const base = ext ? originalName.slice(0, originalName.length - ext.length - 1) : originalName;
  44. const slug = base
  45. .normalize('NFKD')
  46. .replace(/\p{Mn}/gu, '')
  47. .toLowerCase()
  48. .replace(/[\s/\\]+/g, '-')
  49. .replace(/[^a-z0-9_-]/g, '')
  50. .replace(/-+/g, '-')
  51. .replace(/^[-_]+|[-_]+$/g, '')
  52. .slice(0, 40) || 'file';
  53. const ts = Date.now().toString(36);
  54. const rand = Math.random().toString(36).slice(2, 8);
  55. return `${ts}-${rand}-${slug}.${safeExt}`;
  56. }
  57. export async function POST(request: Request) {
  58. let tmpPath: string | null = null;
  59. try {
  60. const formData = await request.formData();
  61. const file = formData.get('file') as File | null;
  62. if (!file) {
  63. return NextResponse.json({ error: 'No file received.' }, { status: 400 });
  64. }
  65. const ext = extractExt(file.name);
  66. // SVG: ammesso SOLO per upload del logo (`?context=logo`).
  67. // file-type non rileva l'SVG affidabilmente perché è XML/testo, quindi qui
  68. // saltiamo il magic-bytes check e affidiamo la verifica al sanitizer
  69. // (richiede che il file inizi con `<svg>` o `<?xml ... <svg>`).
  70. const { searchParams } = new URL(request.url);
  71. const isLogoContext = searchParams.get('context') === 'logo';
  72. if (ext === 'svg') {
  73. if (!isLogoContext) {
  74. return NextResponse.json(
  75. { error: 'SVG accettato solo per upload del logo.' },
  76. { status: 400 }
  77. );
  78. }
  79. if (file.size > MAX_BYTES.image) {
  80. const mb = (MAX_BYTES.image / (1024 * 1024)).toFixed(0);
  81. return NextResponse.json(
  82. { error: `File troppo grande (max ${mb} MB per image).` },
  83. { status: 413 }
  84. );
  85. }
  86. const text = Buffer.from(await file.arrayBuffer()).toString('utf-8');
  87. const result = sanitizeSvg(text);
  88. if (!result.ok) {
  89. return NextResponse.json({ error: result.error }, { status: 400 });
  90. }
  91. const storedName = normalizeFilename(file.name);
  92. await mkdir(TMP_DIR, { recursive: true });
  93. tmpPath = path.join(TMP_DIR, storedName);
  94. await writeFile(tmpPath, Buffer.from(result.sanitized, 'utf-8'));
  95. const finalPath = path.join(UPLOAD_DIR, storedName);
  96. await rename(tmpPath, finalPath);
  97. tmpPath = null;
  98. return NextResponse.json(
  99. { url: `/api/files?name=${encodeURIComponent(storedName)}` },
  100. { status: 201 }
  101. );
  102. }
  103. const family = EXT_FAMILY[ext];
  104. if (!family) {
  105. return NextResponse.json(
  106. { error: `Estensione non permessa: .${ext || '(nessuna)'}` },
  107. { status: 400 }
  108. );
  109. }
  110. if (file.size > MAX_BYTES[family]) {
  111. const mb = (MAX_BYTES[family] / (1024 * 1024)).toFixed(0);
  112. return NextResponse.json(
  113. { error: `File troppo grande (max ${mb} MB per ${family}).` },
  114. { status: 413 }
  115. );
  116. }
  117. const buffer = Buffer.from(await file.arrayBuffer());
  118. // Magic-bytes sniffing: reject if the file content doesn't match its claimed extension.
  119. const detected = await fileTypeFromBuffer(buffer);
  120. if (!detected) {
  121. return NextResponse.json(
  122. { error: 'Tipo del file non riconoscibile dal contenuto.' },
  123. { status: 400 }
  124. );
  125. }
  126. const allowedMimes = ALLOWED_MIMES[ext] ?? [];
  127. if (!allowedMimes.includes(detected.mime)) {
  128. return NextResponse.json(
  129. { error: `Contenuto del file non corrisponde all'estensione (.${ext} dichiarato, rilevato ${detected.mime}).` },
  130. { status: 400 }
  131. );
  132. }
  133. const storedName = normalizeFilename(file.name);
  134. await mkdir(TMP_DIR, { recursive: true });
  135. tmpPath = path.join(TMP_DIR, storedName);
  136. await writeFile(tmpPath, buffer);
  137. // Image / PDF: rename atomically into the uploads dir and we're done.
  138. if (family !== 'video') {
  139. const finalPath = path.join(UPLOAD_DIR, storedName);
  140. await rename(tmpPath, finalPath);
  141. tmpPath = null;
  142. return NextResponse.json(
  143. { url: `/api/files?name=${encodeURIComponent(storedName)}` },
  144. { status: 201 }
  145. );
  146. }
  147. // Video: probe codecs. If already h264+aac/mp3 → fast path (rename).
  148. const codecs = await probeCodecs(tmpPath);
  149. if (!needsTranscode(codecs)) {
  150. const finalPath = path.join(UPLOAD_DIR, storedName);
  151. await rename(tmpPath, finalPath);
  152. tmpPath = null;
  153. return NextResponse.json(
  154. { url: `/api/files?name=${encodeURIComponent(storedName)}` },
  155. { status: 201 }
  156. );
  157. }
  158. // Needs transcoding: bail out early if ffmpeg is unavailable.
  159. if (!(await isFfmpegAvailable())) {
  160. return NextResponse.json(
  161. { error: 'Video richiede ricodifica ma ffmpeg non è disponibile sul server.' },
  162. { status: 503 }
  163. );
  164. }
  165. // The final file is always an .mp4 once transcoded — replace the extension.
  166. const finalMp4Name = storedName.replace(/\.[^.]+$/, '.mp4');
  167. const job = await enqueueTranscode({
  168. inputPath: tmpPath,
  169. outputName: finalMp4Name,
  170. originalUploadName: file.name,
  171. });
  172. // The input is now owned by the transcode worker; don't unlink in our catch.
  173. tmpPath = null;
  174. return NextResponse.json(
  175. {
  176. url: `/api/files?name=${encodeURIComponent(finalMp4Name)}`,
  177. transcoding: { jobId: job.id, status: job.status },
  178. },
  179. { status: 201 }
  180. );
  181. } catch (error) {
  182. console.error('Upload Error:', error);
  183. if (tmpPath) {
  184. try { await unlink(tmpPath); } catch {}
  185. }
  186. return NextResponse.json({ error: 'Failed to upload file.' }, { status: 500 });
  187. }
  188. }