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.
 
 
 

85 lines
2.5 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. '.svg': 'image/svg+xml',
  11. '.webp': 'image/webp',
  12. '.mp4': 'video/mp4',
  13. '.webm': 'video/webm',
  14. '.mov': 'video/quicktime',
  15. '.m4v': 'video/x-m4v',
  16. '.ogv': 'video/ogg',
  17. };
  18. export async function GET(request: Request) {
  19. const { searchParams } = new URL(request.url);
  20. const name = searchParams.get('name');
  21. if (!name) return new NextResponse('File name required', { status: 400 });
  22. const filePath = path.join(process.cwd(), 'data', 'uploads', name);
  23. let stat: fs.Stats;
  24. try {
  25. stat = fs.statSync(filePath);
  26. } catch {
  27. return new NextResponse('File not found', { status: 404 });
  28. }
  29. const ext = path.extname(name).toLowerCase();
  30. const mimeType = MIME[ext] || 'application/octet-stream';
  31. const fileSize = stat.size;
  32. // Handle Range requests (essential for video seeking)
  33. const range = request.headers.get('range');
  34. if (range) {
  35. const match = /bytes=(\d*)-(\d*)/.exec(range);
  36. if (match) {
  37. const start = match[1] ? parseInt(match[1], 10) : 0;
  38. const end = match[2] ? parseInt(match[2], 10) : fileSize - 1;
  39. const chunkSize = end - start + 1;
  40. const stream = fs.createReadStream(filePath, { start, end });
  41. // Convert Node stream to Web ReadableStream
  42. const webStream = new ReadableStream({
  43. start(controller) {
  44. stream.on('data', chunk => controller.enqueue(new Uint8Array(chunk as Buffer)));
  45. stream.on('end', () => controller.close());
  46. stream.on('error', err => controller.error(err));
  47. },
  48. cancel() {
  49. stream.destroy();
  50. },
  51. });
  52. return new NextResponse(webStream, {
  53. status: 206,
  54. headers: {
  55. 'Content-Type': mimeType,
  56. 'Content-Length': chunkSize.toString(),
  57. 'Content-Range': `bytes ${start}-${end}/${fileSize}`,
  58. 'Accept-Ranges': 'bytes',
  59. 'Cache-Control': 'public, max-age=86400',
  60. },
  61. });
  62. }
  63. }
  64. // Full file response (for images, or videos without Range header)
  65. const buffer = fs.readFileSync(filePath);
  66. return new NextResponse(buffer, {
  67. headers: {
  68. 'Content-Type': mimeType,
  69. 'Content-Length': fileSize.toString(),
  70. 'Accept-Ranges': 'bytes',
  71. 'Cache-Control': 'public, max-age=86400',
  72. },
  73. });
  74. }