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