Não pode escolher mais do que 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 

176 linhas
5.9 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. export const dynamic = 'force-dynamic';
  7. export const maxDuration = 300;
  8. // Allowed extensions and their families (size + handling differ per family).
  9. type Family = 'image' | 'video' | 'pdf';
  10. const EXT_FAMILY: Record<string, Family> = {
  11. png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', webp: 'image',
  12. mp4: 'video', m4v: 'video', webm: 'video', mov: 'video', ogv: 'video', ogg: 'video',
  13. pdf: 'pdf',
  14. };
  15. // Strict per family, permissive within a family (mov ↔ mp4 are both ISO BMFF).
  16. const ALLOWED_MIMES: Record<string, string[]> = {
  17. png: ['image/png'],
  18. jpg: ['image/jpeg'],
  19. jpeg: ['image/jpeg'],
  20. gif: ['image/gif'],
  21. webp: ['image/webp'],
  22. mp4: ['video/mp4', 'video/x-m4v', 'video/quicktime'],
  23. m4v: ['video/mp4', 'video/x-m4v'],
  24. webm: ['video/webm'],
  25. mov: ['video/quicktime', 'video/mp4'],
  26. ogv: ['video/ogg', 'application/ogg'],
  27. ogg: ['video/ogg', 'application/ogg', 'audio/ogg'],
  28. pdf: ['application/pdf'],
  29. };
  30. const MAX_BYTES: Record<Family, number> = {
  31. image: 25 * 1024 * 1024, // 25 MB
  32. pdf: 20 * 1024 * 1024, // 20 MB (pdfjs lato browser non regge bene di più)
  33. video: 1024 * 1024 * 1024, // 1 GB (verrà ricodificato lato server se necessario)
  34. };
  35. const UPLOAD_DIR = path.join(process.cwd(), 'data', 'uploads');
  36. const TMP_DIR = path.join(UPLOAD_DIR, '.tmp');
  37. function extractExt(name: string): string {
  38. const lastDot = name.lastIndexOf('.');
  39. if (lastDot < 0 || lastDot === name.length - 1) return '';
  40. return name.slice(lastDot + 1).toLowerCase();
  41. }
  42. export function normalizeFilename(originalName: string): string {
  43. const ext = extractExt(originalName);
  44. const safeExt = /^[a-z0-9]{1,5}$/.test(ext) ? ext : 'bin';
  45. const base = ext ? originalName.slice(0, originalName.length - ext.length - 1) : originalName;
  46. const slug = base
  47. .normalize('NFKD')
  48. .replace(/\p{Mn}/gu, '')
  49. .toLowerCase()
  50. .replace(/[\s/\\]+/g, '-')
  51. .replace(/[^a-z0-9_-]/g, '')
  52. .replace(/-+/g, '-')
  53. .replace(/^[-_]+|[-_]+$/g, '')
  54. .slice(0, 40) || 'file';
  55. const ts = Date.now().toString(36);
  56. const rand = Math.random().toString(36).slice(2, 8);
  57. return `${ts}-${rand}-${slug}.${safeExt}`;
  58. }
  59. export async function POST(request: Request) {
  60. let tmpPath: string | null = null;
  61. try {
  62. const formData = await request.formData();
  63. const file = formData.get('file') as File | null;
  64. if (!file) {
  65. return NextResponse.json({ error: 'No file received.' }, { status: 400 });
  66. }
  67. const ext = extractExt(file.name);
  68. const family = EXT_FAMILY[ext];
  69. if (!family) {
  70. return NextResponse.json(
  71. { error: `Estensione non permessa: .${ext || '(nessuna)'}` },
  72. { status: 400 }
  73. );
  74. }
  75. if (file.size > MAX_BYTES[family]) {
  76. const mb = (MAX_BYTES[family] / (1024 * 1024)).toFixed(0);
  77. return NextResponse.json(
  78. { error: `File troppo grande (max ${mb} MB per ${family}).` },
  79. { status: 413 }
  80. );
  81. }
  82. const buffer = Buffer.from(await file.arrayBuffer());
  83. // Magic-bytes sniffing: reject if the file content doesn't match its claimed extension.
  84. const detected = await fileTypeFromBuffer(buffer);
  85. if (!detected) {
  86. return NextResponse.json(
  87. { error: 'Tipo del file non riconoscibile dal contenuto.' },
  88. { status: 400 }
  89. );
  90. }
  91. const allowedMimes = ALLOWED_MIMES[ext] ?? [];
  92. if (!allowedMimes.includes(detected.mime)) {
  93. return NextResponse.json(
  94. { error: `Contenuto del file non corrisponde all'estensione (.${ext} dichiarato, rilevato ${detected.mime}).` },
  95. { status: 400 }
  96. );
  97. }
  98. const storedName = normalizeFilename(file.name);
  99. await mkdir(TMP_DIR, { recursive: true });
  100. tmpPath = path.join(TMP_DIR, storedName);
  101. await writeFile(tmpPath, buffer);
  102. // Image / PDF: rename atomically into the uploads dir and we're done.
  103. if (family !== 'video') {
  104. const finalPath = path.join(UPLOAD_DIR, storedName);
  105. await rename(tmpPath, finalPath);
  106. tmpPath = null;
  107. return NextResponse.json(
  108. { url: `/api/files?name=${encodeURIComponent(storedName)}` },
  109. { status: 201 }
  110. );
  111. }
  112. // Video: probe codecs. If already h264+aac/mp3 → fast path (rename).
  113. const codecs = await probeCodecs(tmpPath);
  114. if (!needsTranscode(codecs)) {
  115. const finalPath = path.join(UPLOAD_DIR, storedName);
  116. await rename(tmpPath, finalPath);
  117. tmpPath = null;
  118. return NextResponse.json(
  119. { url: `/api/files?name=${encodeURIComponent(storedName)}` },
  120. { status: 201 }
  121. );
  122. }
  123. // Needs transcoding: bail out early if ffmpeg is unavailable.
  124. if (!(await isFfmpegAvailable())) {
  125. return NextResponse.json(
  126. { error: 'Video richiede ricodifica ma ffmpeg non è disponibile sul server.' },
  127. { status: 503 }
  128. );
  129. }
  130. // The final file is always an .mp4 once transcoded — replace the extension.
  131. const finalMp4Name = storedName.replace(/\.[^.]+$/, '.mp4');
  132. const job = await enqueueTranscode({
  133. inputPath: tmpPath,
  134. outputName: finalMp4Name,
  135. originalUploadName: file.name,
  136. });
  137. // The input is now owned by the transcode worker; don't unlink in our catch.
  138. tmpPath = null;
  139. return NextResponse.json(
  140. {
  141. url: `/api/files?name=${encodeURIComponent(finalMp4Name)}`,
  142. transcoding: { jobId: job.id, status: job.status },
  143. },
  144. { status: 201 }
  145. );
  146. } catch (error) {
  147. console.error('Upload Error:', error);
  148. if (tmpPath) {
  149. try { await unlink(tmpPath); } catch {}
  150. }
  151. return NextResponse.json({ error: 'Failed to upload file.' }, { status: 500 });
  152. }
  153. }