選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 

240 行
8.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. 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. // Formatta bytes in MB con 1 decimale per messaggi all'utente (es. "23.4 MB").
  41. function fmtMB(bytes: number): string {
  42. return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
  43. }
  44. // Lista leggibile delle estensioni accettate per famiglia, per i messaggi di errore.
  45. const EXT_BY_FAMILY: Record<Family, string[]> = {
  46. image: ['PNG', 'JPG', 'JPEG', 'GIF', 'WEBP'],
  47. video: ['MP4', 'M4V', 'WEBM', 'MOV', 'OGV', 'OGG'],
  48. pdf: ['PDF'],
  49. };
  50. export function normalizeFilename(originalName: string): string {
  51. const ext = extractExt(originalName);
  52. const safeExt = /^[a-z0-9]{1,5}$/.test(ext) ? ext : 'bin';
  53. const base = ext ? originalName.slice(0, originalName.length - ext.length - 1) : originalName;
  54. const slug = base
  55. .normalize('NFKD')
  56. .replace(/\p{Mn}/gu, '')
  57. .toLowerCase()
  58. .replace(/[\s/\\]+/g, '-')
  59. .replace(/[^a-z0-9_-]/g, '')
  60. .replace(/-+/g, '-')
  61. .replace(/^[-_]+|[-_]+$/g, '')
  62. .slice(0, 40) || 'file';
  63. const ts = Date.now().toString(36);
  64. const rand = Math.random().toString(36).slice(2, 8);
  65. return `${ts}-${rand}-${slug}.${safeExt}`;
  66. }
  67. export async function POST(request: Request) {
  68. let tmpPath: string | null = null;
  69. try {
  70. const formData = await request.formData();
  71. const file = formData.get('file') as File | null;
  72. if (!file) {
  73. return NextResponse.json(
  74. { error: 'No file was received. Please choose a file before uploading.' },
  75. { status: 400 },
  76. );
  77. }
  78. const ext = extractExt(file.name);
  79. // SVG: ammesso SOLO per upload del logo (`?context=logo`).
  80. // file-type non rileva l'SVG affidabilmente perché è XML/testo, quindi qui
  81. // saltiamo il magic-bytes check e affidiamo la verifica al sanitizer
  82. // (richiede che il file inizi con `<svg>` o `<?xml ... <svg>`).
  83. const { searchParams } = new URL(request.url);
  84. const isLogoContext = searchParams.get('context') === 'logo';
  85. if (ext === 'svg') {
  86. if (!isLogoContext) {
  87. return NextResponse.json(
  88. { error: 'SVG files can only be used for the portal logo. For other images use PNG, JPG, GIF or WEBP.' },
  89. { status: 400 },
  90. );
  91. }
  92. if (file.size > MAX_BYTES.image) {
  93. return NextResponse.json(
  94. { error: `SVG is too large: ${fmtMB(file.size)} (limit: ${fmtMB(MAX_BYTES.image)}). Optimize the SVG (e.g. SVGO) or remove embedded raster images.` },
  95. { status: 413 },
  96. );
  97. }
  98. const text = Buffer.from(await file.arrayBuffer()).toString('utf-8');
  99. const result = sanitizeSvg(text);
  100. if (!result.ok) {
  101. return NextResponse.json({ error: result.error }, { status: 400 });
  102. }
  103. const storedName = normalizeFilename(file.name);
  104. await mkdir(TMP_DIR, { recursive: true });
  105. tmpPath = path.join(TMP_DIR, storedName);
  106. await writeFile(tmpPath, Buffer.from(result.sanitized, 'utf-8'));
  107. const finalPath = path.join(UPLOAD_DIR, storedName);
  108. await rename(tmpPath, finalPath);
  109. tmpPath = null;
  110. return NextResponse.json(
  111. { url: `/api/files?name=${encodeURIComponent(storedName)}` },
  112. { status: 201 }
  113. );
  114. }
  115. const family = EXT_FAMILY[ext];
  116. if (!family) {
  117. const allFormats = [
  118. ...EXT_BY_FAMILY.image,
  119. ...EXT_BY_FAMILY.video,
  120. ...EXT_BY_FAMILY.pdf,
  121. 'SVG (logo only)',
  122. ].join(', ');
  123. return NextResponse.json(
  124. { error: `File extension ".${ext || '(none)'}" is not allowed. Accepted formats: ${allFormats}.` },
  125. { status: 400 },
  126. );
  127. }
  128. if (file.size > MAX_BYTES[family]) {
  129. const hint = family === 'pdf'
  130. ? ' Compress the PDF (Ghostscript /ebook preset, Adobe "Reduce File Size", or an online PDF compressor) or split it into smaller documents.'
  131. : family === 'video'
  132. ? ' Re-encode the video at a lower bitrate or resolution before uploading.'
  133. : ' Resize or compress the image before uploading (e.g. lower resolution or convert to WEBP).';
  134. return NextResponse.json(
  135. { error: `File is too large: ${fmtMB(file.size)} (limit for ${family.toUpperCase()}: ${fmtMB(MAX_BYTES[family])}).${hint}` },
  136. { status: 413 },
  137. );
  138. }
  139. const buffer = Buffer.from(await file.arrayBuffer());
  140. // Magic-bytes sniffing: reject if the file content doesn't match its claimed extension.
  141. const detected = await fileTypeFromBuffer(buffer);
  142. if (!detected) {
  143. return NextResponse.json(
  144. { 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(' / ')}.` },
  145. { status: 400 },
  146. );
  147. }
  148. const allowedMimes = ALLOWED_MIMES[ext] ?? [];
  149. if (!allowedMimes.includes(detected.mime)) {
  150. return NextResponse.json(
  151. { 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.` },
  152. { status: 400 },
  153. );
  154. }
  155. const storedName = normalizeFilename(file.name);
  156. await mkdir(TMP_DIR, { recursive: true });
  157. tmpPath = path.join(TMP_DIR, storedName);
  158. await writeFile(tmpPath, buffer);
  159. // Image / PDF: rename atomically into the uploads dir and we're done.
  160. if (family !== 'video') {
  161. const finalPath = path.join(UPLOAD_DIR, storedName);
  162. await rename(tmpPath, finalPath);
  163. tmpPath = null;
  164. return NextResponse.json(
  165. { url: `/api/files?name=${encodeURIComponent(storedName)}` },
  166. { status: 201 }
  167. );
  168. }
  169. // Video: probe codecs. If already h264+aac/mp3 → fast path (rename).
  170. const codecs = await probeCodecs(tmpPath);
  171. if (!needsTranscode(codecs)) {
  172. const finalPath = path.join(UPLOAD_DIR, storedName);
  173. await rename(tmpPath, finalPath);
  174. tmpPath = null;
  175. return NextResponse.json(
  176. { url: `/api/files?name=${encodeURIComponent(storedName)}` },
  177. { status: 201 }
  178. );
  179. }
  180. // Needs transcoding: bail out early if ffmpeg is unavailable.
  181. if (!(await isFfmpegAvailable())) {
  182. return NextResponse.json(
  183. { 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.' },
  184. { status: 503 },
  185. );
  186. }
  187. // The final file is always an .mp4 once transcoded — replace the extension.
  188. const finalMp4Name = storedName.replace(/\.[^.]+$/, '.mp4');
  189. const job = await enqueueTranscode({
  190. inputPath: tmpPath,
  191. outputName: finalMp4Name,
  192. originalUploadName: file.name,
  193. });
  194. // The input is now owned by the transcode worker; don't unlink in our catch.
  195. tmpPath = null;
  196. return NextResponse.json(
  197. {
  198. url: `/api/files?name=${encodeURIComponent(finalMp4Name)}`,
  199. transcoding: { jobId: job.id, status: job.status },
  200. },
  201. { status: 201 }
  202. );
  203. } catch (error) {
  204. console.error('Upload Error:', error);
  205. if (tmpPath) {
  206. try { await unlink(tmpPath); } catch {}
  207. }
  208. return NextResponse.json(
  209. { error: 'Unexpected error while saving the file. The upload was aborted; nothing was changed. Check the server logs for details and try again.' },
  210. { status: 500 },
  211. );
  212. }
  213. }