Files
openmaic/OpenMAIC/app/api/proxy-media/route.ts
2026-08-16 14:58:47 +08:00

173 lines
5.9 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 { 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');
}
}