90 lines
3.4 KiB
TypeScript
90 lines
3.4 KiB
TypeScript
/**
|
|
* 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 { NextRequest, NextResponse } from 'next/server';
|
|
import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';
|
|
import { apiError } from '@/lib/server/api-response';
|
|
import { createLogger } from '@/lib/logger';
|
|
|
|
const log = createLogger('ProxyMedia');
|
|
|
|
export const maxDuration = 60;
|
|
|
|
export async function POST(request: NextRequest) {
|
|
let url: string | undefined;
|
|
try {
|
|
({ url } = await request.json());
|
|
|
|
if (!url || typeof url !== 'string') {
|
|
return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing or invalid url');
|
|
}
|
|
|
|
// Initial SSRF validation
|
|
const ssrfError = await validateUrlForSSRF(url);
|
|
if (ssrfError) {
|
|
return apiError('INVALID_URL', 403, ssrfError);
|
|
}
|
|
|
|
const MAX_REDIRECTS = 5;
|
|
let currentUrl = url;
|
|
let response: Response;
|
|
for (let hop = 0; ; hop++) {
|
|
response = await fetch(currentUrl, { redirect: 'manual' });
|
|
if (response.status < 300 || response.status >= 400) break; // not a redirect
|
|
const location = response.headers.get('location');
|
|
if (!location)
|
|
return apiError('UPSTREAM_ERROR', 502, 'Redirect response without Location header');
|
|
if (hop >= MAX_REDIRECTS) return apiError('TOO_MANY_REDIRECTS', 502, 'Too many redirects');
|
|
let nextUrl: string;
|
|
try {
|
|
nextUrl = new URL(location, currentUrl).href; // resolve relative redirects
|
|
} catch {
|
|
return apiError('INVALID_URL', 502, 'Invalid redirect Location');
|
|
}
|
|
// Re-validate each redirect hop to prevent redirect-to-internal SSRF (#398)
|
|
const hopError = await validateUrlForSSRF(nextUrl);
|
|
if (hopError) return apiError('INVALID_URL', 403, hopError);
|
|
currentUrl = nextUrl;
|
|
}
|
|
|
|
if (!response!.ok) {
|
|
// Forward client (4xx) errors as-is so the caller treats them as permanent
|
|
// (no retry); collapse upstream server (5xx) errors to 502.
|
|
const status = response!.status >= 400 && response!.status < 500 ? response!.status : 502;
|
|
return apiError('UPSTREAM_ERROR', status, `Upstream returned ${response!.status}`);
|
|
}
|
|
|
|
const MAX_PROXY_BYTES = 25 * 1024 * 1024; // 25 MiB
|
|
const contentLength = Number(response!.headers.get('content-length') ?? '');
|
|
if (Number.isFinite(contentLength) && contentLength > MAX_PROXY_BYTES) {
|
|
return apiError('UPSTREAM_ERROR', 502, `Upstream asset too large (${contentLength} bytes)`);
|
|
}
|
|
const blob = await response!.blob();
|
|
if (blob.size > MAX_PROXY_BYTES) {
|
|
return apiError('UPSTREAM_ERROR', 502, `Upstream asset too large (${blob.size} bytes)`);
|
|
}
|
|
const contentType = response!.headers.get('content-type') || 'application/octet-stream';
|
|
|
|
return new NextResponse(blob, {
|
|
headers: {
|
|
'Content-Type': contentType,
|
|
'Content-Length': String(blob.size),
|
|
'Cache-Control': 'private, max-age=3600',
|
|
},
|
|
});
|
|
} catch (error) {
|
|
log.error(`Proxy media failed [url="${url?.substring(0, 100) ?? 'unknown'}"]:`, error);
|
|
return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error));
|
|
}
|
|
}
|