Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

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));
}
}