route.ts 2.0 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
  1. /**
  2. * Media Proxy API
  3. *
  4. * Server-side proxy for fetching remote media URLs (images/videos).
  5. * Required because browser fetch() to remote CDN URLs fails with CORS errors.
  6. * The media orchestrator uses this to download generated media as blobs
  7. * for IndexedDB persistence.
  8. *
  9. * POST /api/proxy-media
  10. * Body: { url: string }
  11. * Response: Binary blob with appropriate Content-Type
  12. */
  13. import { NextRequest, NextResponse } from 'next/server';
  14. import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';
  15. import { apiError } from '@/lib/server/api-response';
  16. import { createLogger } from '@/lib/logger';
  17. const log = createLogger('ProxyMedia');
  18. export const maxDuration = 60;
  19. export async function POST(request: NextRequest) {
  20. let url: string | undefined;
  21. try {
  22. ({ url } = await request.json());
  23. if (!url || typeof url !== 'string') {
  24. return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing or invalid url');
  25. }
  26. // Block local/private network URLs to prevent SSRF
  27. const ssrfError = await validateUrlForSSRF(url);
  28. if (ssrfError) {
  29. return apiError('INVALID_URL', 403, ssrfError);
  30. }
  31. // Disable redirect following to prevent redirect-to-internal attacks
  32. const response = await fetch(url, { redirect: 'manual' });
  33. if (response.status >= 300 && response.status < 400) {
  34. return apiError('REDIRECT_NOT_ALLOWED', 403, 'Redirects are not allowed');
  35. }
  36. if (!response.ok) {
  37. return apiError('UPSTREAM_ERROR', 502, `Upstream returned ${response.status}`);
  38. }
  39. const blob = await response.blob();
  40. const contentType = response.headers.get('content-type') || 'application/octet-stream';
  41. return new NextResponse(blob, {
  42. headers: {
  43. 'Content-Type': contentType,
  44. 'Content-Length': String(blob.size),
  45. 'Cache-Control': 'private, max-age=3600',
  46. },
  47. });
  48. } catch (error) {
  49. log.error(`Proxy media failed [url="${url?.substring(0, 100) ?? 'unknown'}"]:`, error);
  50. return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error));
  51. }
  52. }