You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

98 lines
3.1 KiB

  1. import { NextResponse } from 'next/server';
  2. import fs from 'fs';
  3. import path from 'path';
  4. export const dynamic = 'force-dynamic';
  5. const MIME: Record<string, string> = {
  6. '.png': 'image/png',
  7. '.jpg': 'image/jpeg',
  8. '.jpeg': 'image/jpeg',
  9. '.gif': 'image/gif',
  10. '.webp': 'image/webp',
  11. '.svg': 'image/svg+xml',
  12. '.mp4': 'video/mp4',
  13. '.webm': 'video/webm',
  14. '.mov': 'video/quicktime',
  15. '.m4v': 'video/x-m4v',
  16. '.ogv': 'video/ogg',
  17. '.ogg': 'video/ogg',
  18. '.pdf': 'application/pdf',
  19. };
  20. export async function GET(request: Request) {
  21. const { searchParams } = new URL(request.url);
  22. const name = searchParams.get('name');
  23. if (!name) return new NextResponse('File name required', { status: 400 });
  24. // Strip any directory traversal attempt; we only ever serve from data/uploads/
  25. const safeName = path.basename(name);
  26. const filePath = path.join(process.cwd(), 'data', 'uploads', safeName);
  27. let stat: fs.Stats;
  28. try {
  29. stat = fs.statSync(filePath);
  30. } catch {
  31. return new NextResponse('File not found', { status: 404 });
  32. }
  33. const ext = path.extname(safeName).toLowerCase();
  34. const mimeType = MIME[ext] || 'application/octet-stream';
  35. const disposition = `inline; filename="${safeName.replace(/"/g, '')}"`;
  36. const fileSize = stat.size;
  37. // Defense-in-depth per SVG: blocca qualunque script eventualmente sfuggito al
  38. // sanitizer al momento dell'upload, e impedisce caricamenti di risorse esterne.
  39. const extraHeaders: Record<string, string> = ext === '.svg'
  40. ? { 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:" }
  41. : {};
  42. // Handle Range requests (essential for video seeking)
  43. const range = request.headers.get('range');
  44. if (range) {
  45. const match = /bytes=(\d*)-(\d*)/.exec(range);
  46. if (match) {
  47. const start = match[1] ? parseInt(match[1], 10) : 0;
  48. const end = match[2] ? parseInt(match[2], 10) : fileSize - 1;
  49. const chunkSize = end - start + 1;
  50. const stream = fs.createReadStream(filePath, { start, end });
  51. // Convert Node stream to Web ReadableStream
  52. const webStream = new ReadableStream({
  53. start(controller) {
  54. stream.on('data', chunk => controller.enqueue(new Uint8Array(chunk as Buffer)));
  55. stream.on('end', () => controller.close());
  56. stream.on('error', err => controller.error(err));
  57. },
  58. cancel() {
  59. stream.destroy();
  60. },
  61. });
  62. return new NextResponse(webStream, {
  63. status: 206,
  64. headers: {
  65. 'Content-Type': mimeType,
  66. 'Content-Length': chunkSize.toString(),
  67. 'Content-Range': `bytes ${start}-${end}/${fileSize}`,
  68. 'Accept-Ranges': 'bytes',
  69. 'Cache-Control': 'public, max-age=86400',
  70. 'Content-Disposition': disposition,
  71. },
  72. });
  73. }
  74. }
  75. // Full file response (for images, or videos without Range header)
  76. const buffer = fs.readFileSync(filePath);
  77. return new NextResponse(buffer, {
  78. headers: {
  79. 'Content-Type': mimeType,
  80. 'Content-Length': fileSize.toString(),
  81. 'Accept-Ranges': 'bytes',
  82. 'Cache-Control': 'public, max-age=86400',
  83. ...extraHeaders,
  84. },
  85. });
  86. }