/** * Media Proxy API * * Server-side proxy for fetching remote media URLs (images/videos). * Required because browser fetch() to remote CDN URLs fails with CORS errors. * The media orchestrator uses this to download generated media as blobs * for IndexedDB persistence. * * POST /api/proxy-media * Body: { url: string } * Response: Binary blob with appropriate Content-Type */ import { NextResponse } from 'next/server'; import { apiError } from '@/lib/server/api-response'; import { createLogger } from '@/lib/logger'; import { capBodyStream } from '@/lib/server/capped-stream'; import { fetchPinnedPublicUrl, PUBLIC_MEDIA_MAX_RESPONSE_BYTES, PublicUrlFetchError, } from '@/lib/server/public-url-fetch'; const log = createLogger('ProxyMedia'); const PROXY_REQUEST_MAX_BYTES = 8 * 1024; const MAX_REDIRECTS = 5; const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); export const maxDuration = 60; export const runtime = 'nodejs'; function exceedsDeclaredRequestLimit(request: Request): boolean { const declaredLength = request.headers.get('content-length')?.trim(); return Boolean( declaredLength && /^\d+$/.test(declaredLength) && Number(declaredLength) > PROXY_REQUEST_MAX_BYTES, ); } async function readProxyUrl(request: Request): Promise<{ url: string } | { response: Response }> { if (!request.headers.get('content-type')?.toLowerCase().startsWith('application/json')) { return { response: apiError('INVALID_REQUEST', 415, 'Media proxy request requires application/json'), }; } if (exceedsDeclaredRequestLimit(request)) { return { response: apiError('INVALID_REQUEST', 413, 'Media proxy request is too large') }; } if (!request.body) { return { response: apiError('INVALID_REQUEST', 400, 'Invalid JSON body') }; } const cappedBody = capBodyStream(request.body, PROXY_REQUEST_MAX_BYTES); let rawBody: string; try { rawBody = await new Response(cappedBody.stream).text(); } catch { return cappedBody.exceeded() ? { response: apiError('INVALID_REQUEST', 413, 'Media proxy request is too large') } : { response: apiError('INVALID_REQUEST', 400, 'Invalid JSON body') }; } let body: unknown; try { body = JSON.parse(rawBody); } catch { return { response: apiError('INVALID_REQUEST', 400, 'Invalid JSON body') }; } const url = body && typeof body === 'object' && !Array.isArray(body) ? (body as { url?: unknown }).url : undefined; if (typeof url !== 'string' || !url) { return { response: apiError('MISSING_REQUIRED_FIELD', 400, 'Missing or invalid url') }; } return { url }; } function safeFetchErrorResponse(error: unknown): Response { if (error instanceof PublicUrlFetchError) { if ( error.code === 'INVALID_URL' || error.code === 'BLOCKED_TARGET' || error.code === 'DNS_FAILURE' ) { return apiError('INVALID_URL', 403, 'URL target is not allowed'); } return apiError('UPSTREAM_ERROR', 502, 'Unable to fetch upstream media'); } return apiError('UPSTREAM_ERROR', 502, 'Unable to fetch upstream media'); } export async function POST(request: Request) { try { const bodyResult = await readProxyUrl(request); if ('response' in bodyResult) { return bodyResult.response; } let currentUrl = bodyResult.url; const requestSignal = AbortSignal.timeout(55_000); for (let redirectsFollowed = 0; ; ) { let pinnedResponse; try { pinnedResponse = await fetchPinnedPublicUrl(currentUrl, { signal: requestSignal, maxResponseBytes: PUBLIC_MEDIA_MAX_RESPONSE_BYTES, }); } catch (error) { return safeFetchErrorResponse(error); } const { response } = pinnedResponse; if (REDIRECT_STATUSES.has(response.status)) { const location = response.headers.get('location'); await pinnedResponse.dispose(); if (!location) { return apiError('UPSTREAM_ERROR', 502, 'Invalid upstream redirect'); } if (redirectsFollowed >= MAX_REDIRECTS) { return apiError('TOO_MANY_REDIRECTS', 502, 'Too many redirects'); } try { currentUrl = new URL(location, currentUrl).href; } catch { return apiError('UPSTREAM_ERROR', 502, 'Invalid upstream redirect'); } redirectsFollowed += 1; continue; } try { if (!response.ok) { // Forward client (4xx) errors so the caller treats them as permanent; // collapse upstream server errors without exposing upstream details. const status = response.status >= 400 && response.status < 500 ? response.status : 502; return apiError('UPSTREAM_ERROR', status, `Upstream returned ${response.status}`); } const contentLength = Number(response.headers.get('content-length') ?? ''); if (Number.isFinite(contentLength) && contentLength > PUBLIC_MEDIA_MAX_RESPONSE_BYTES) { return apiError('UPSTREAM_ERROR', 502, 'Upstream asset is too large'); } let bodyBuffer: ArrayBuffer; try { bodyBuffer = await response.arrayBuffer(); } catch { return apiError('UPSTREAM_ERROR', 502, 'Unable to read upstream media'); } if (bodyBuffer.byteLength > PUBLIC_MEDIA_MAX_RESPONSE_BYTES) { return apiError('UPSTREAM_ERROR', 502, 'Upstream asset is too large'); } const contentType = response.headers.get('content-type') || 'application/octet-stream'; return new NextResponse(bodyBuffer, { headers: { 'Content-Type': contentType, 'Content-Length': String(bodyBuffer.byteLength), 'Cache-Control': 'private, max-age=3600', }, }); } finally { await pinnedResponse.dispose(); } } } catch (error) { log.error('Proxy media failed:', error); return apiError('INTERNAL_ERROR', 500, 'Media proxy request failed'); } }