114 lines
5.0 KiB
TypeScript
114 lines
5.0 KiB
TypeScript
import { type NextRequest } from 'next/server';
|
|
import { apiError, apiSuccess } from '@/lib/server/api-response';
|
|
import { proxyFetch } from '@/lib/server/proxy-fetch';
|
|
import { resolveRenderServiceUrl } from '@/lib/server/render-service';
|
|
import { capBodyStream } from '@/lib/server/capped-stream';
|
|
import { createLogger } from '@/lib/logger';
|
|
|
|
const log = createLogger('ExportVideo Render API');
|
|
|
|
// Only forwards the upload to the isolated render service; the render itself
|
|
// happens there, so this route stays lightweight despite large ZIP bodies. The
|
|
// budget must cover *uploading* up to MAX_UPLOAD_BYTES over a slow link (a
|
|
// 300 MB body needs ~40 Mbps to finish in 60s), not the render — so it's sized
|
|
// for the transfer, well above the old 60s.
|
|
export const maxDuration = 300;
|
|
|
|
/** Reject uploads larger than this (compressed ZIP bytes), enforced on real bytes. */
|
|
const MAX_UPLOAD_BYTES = 300 * 1024 * 1024;
|
|
|
|
/** Upload-forwarding budget. Covers a large body over a slow link; the render is async. */
|
|
const SUBMIT_TIMEOUT_MS = 300_000;
|
|
|
|
/**
|
|
* Derive a client identity for the render service's per-identity guard.
|
|
*
|
|
* `x-forwarded-for` / `x-real-ip` are only trustworthy when a trusted reverse
|
|
* proxy sets them; if the app is exposed directly (as the default Compose does),
|
|
* a client can rotate them to defeat the guard. So we only honor them when
|
|
* `TRUST_PROXY_HEADERS=true` is set by the operator (who then must ensure a real
|
|
* proxy overwrites the headers). Otherwise every caller collapses to a single
|
|
* `direct` bucket — a conservative shared limit rather than a spoofable one.
|
|
*/
|
|
function clientIdentity(req: NextRequest): string {
|
|
if (process.env.TRUST_PROXY_HEADERS !== 'true') return 'direct';
|
|
const fwd = req.headers.get('x-forwarded-for');
|
|
if (fwd) return fwd.split(',')[0].trim() || 'anonymous';
|
|
return req.headers.get('x-real-ip')?.trim() || 'anonymous';
|
|
}
|
|
|
|
/**
|
|
* Submit an export ZIP for MP4 rendering. Streams the multipart body straight
|
|
* to the render service (no `formData()` buffering here) and relays its
|
|
* `202 { jobId }`. Returns 501 when the service is not configured so the client
|
|
* can degrade to a local ZIP download.
|
|
*/
|
|
export async function POST(req: NextRequest) {
|
|
const resolved = resolveRenderServiceUrl();
|
|
if ('error' in resolved) {
|
|
return apiError('PROVIDER_DISABLED', 501, 'Render service is not configured');
|
|
}
|
|
|
|
// Fast-path reject an oversized body by its declared length. This is only a
|
|
// courtesy 413 for honest clients — `Content-Length` is client-supplied and
|
|
// omitted on chunked uploads, so the real bound is the byte-counting cap on
|
|
// the stream below (belt-and-suspenders with the service's own archive guards).
|
|
const contentLength = Number(req.headers.get('content-length') ?? '0');
|
|
if (Number.isFinite(contentLength) && contentLength > MAX_UPLOAD_BYTES) {
|
|
return apiError('INVALID_REQUEST', 413, 'Export archive is too large');
|
|
}
|
|
|
|
const contentType = req.headers.get('content-type') ?? '';
|
|
if (!contentType.includes('multipart/form-data') || !req.body) {
|
|
return apiError('INVALID_REQUEST', 400, 'Expected multipart/form-data');
|
|
}
|
|
|
|
// Forward the raw multipart body verbatim, bounded to MAX_UPLOAD_BYTES of
|
|
// actual bytes. We deliberately do NOT parse it: the render service derives
|
|
// identity from the header below and ignores any multipart `userId`, so
|
|
// there's nothing to strip — and re-parsing would defeat the streaming bound.
|
|
const capped = capBodyStream(req.body, MAX_UPLOAD_BYTES);
|
|
|
|
try {
|
|
// Long enough for the upload of a multi-MB ZIP; the render is async.
|
|
const upstream = await proxyFetch(`${resolved.url}/render`, {
|
|
method: 'POST',
|
|
body: capped.stream,
|
|
// duplex is required to send a streaming request body via fetch.
|
|
duplex: 'half',
|
|
headers: {
|
|
'content-type': contentType,
|
|
'x-openmaic-client': clientIdentity(req),
|
|
},
|
|
signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
|
|
} as RequestInit);
|
|
|
|
const data = (await upstream.json().catch(() => ({}))) as Record<string, unknown>;
|
|
if (!upstream.ok) {
|
|
const detail = typeof data.error === 'string' ? data.error : upstream.statusText;
|
|
const status = upstream.status === 429 ? 429 : upstream.status === 413 ? 413 : 502;
|
|
const code =
|
|
upstream.status === 429
|
|
? 'RATE_LIMITED'
|
|
: upstream.status === 413
|
|
? 'INVALID_REQUEST'
|
|
: 'UPSTREAM_ERROR';
|
|
return apiError(code, status, 'Render service rejected the request', detail);
|
|
}
|
|
|
|
return apiSuccess({ jobId: data.jobId, pollIntervalMs: 3000 }, 202);
|
|
} catch (error) {
|
|
// A cap trip aborts the forwarded stream, surfacing here as a fetch error.
|
|
if (capped.exceeded()) {
|
|
return apiError('INVALID_REQUEST', 413, 'Export archive is too large');
|
|
}
|
|
log.error('Failed to submit render job:', error);
|
|
return apiError(
|
|
'UPSTREAM_ERROR',
|
|
502,
|
|
'Failed to reach render service',
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
}
|
|
}
|