diff --git a/Dockerfile b/Dockerfile index d6dec83dc8..48c00b2ccd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,8 +8,8 @@ WORKDIR /app # that already have a fix published in trixie. CVEs without an upstream fix yet # (local-only TOCTOU, etc.) remain until the distro patches them and the image # is rebuilt; none are reachable from the proxy's request surface at runtime. -RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=shared \ - --mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=shared \ +RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \ apt-get update \ && apt-get upgrade -y \ && apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \ @@ -29,8 +29,8 @@ FROM base AS builder # Build tools for native module compilation # apt-get update needed here because base's rm -rf clears the shared cache -RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=shared \ - --mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=shared \ +RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \ apt-get update \ && apt-get install -y --no-install-recommends python3 make g++ \ && rm -rf /var/lib/apt/lists/* diff --git a/open-sse/executors/veoaifree-web.ts b/open-sse/executors/veoaifree-web.ts index 5d62bf0771..269da61f31 100644 --- a/open-sse/executors/veoaifree-web.ts +++ b/open-sse/executors/veoaifree-web.ts @@ -6,8 +6,15 @@ * * No auth required. Rate limited to 6 requests/hour per IP. */ +import dns from "node:dns"; +import { isIP } from "node:net"; import { BaseExecutor, type ExecuteInput } from "./base.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; +import { + OutboundUrlGuardError, + isPrivateHost, + parseAndValidatePublicUrl, +} from "@/shared/network/outboundUrlGuard"; const BASE_URL = "https://veoaifree.com"; const AJAX_URL = `${BASE_URL}/wp-admin/admin-ajax.php`; @@ -17,8 +24,37 @@ const USER_AGENT = const POLL_INTERVAL_MS = 20_000; const MAX_POLLS = 30; // 10 minutes max const FETCH_TIMEOUT_MS = 30_000; +const ARTIFACT_MAX_BYTES = 100 * 1024 * 1024; +const ARTIFACT_MAX_REDIRECTS = 3; +const ARTIFACT_READY_RETRY_INTERVAL_MS = 5_000; +const ARTIFACT_READY_MAX_ATTEMPTS = 12; +const RETRYABLE_ARTIFACT_STATUSES = new Set([202, 404, 409, 425]); -// ─── Helpers ──────────────────────────────────────────────────────────────── +type ToolIntent = "video" | "image" | "tts" | "enhance"; + +type VeoSessionContext = { + cookieHeader: string; + userAgent: string; + origin: string; + referer: string; + signal?: AbortSignal; +}; + +class VeoArtifactError extends Error { + code: string; + status: number; + retryable: boolean; + + constructor(code: string, message: string, status = 502, options?: { retryable?: boolean }) { + super(message); + this.name = "VeoArtifactError"; + this.code = code; + this.status = status; + this.retryable = options?.retryable === true; + } +} + +// ————————————————————————— Helpers ————————————————————————— function throwIfAborted(signal?: AbortSignal): void { if (signal?.aborted) { @@ -63,11 +99,11 @@ async function fetchWithTimeout( } } -function waitForPoll(signal?: AbortSignal): Promise { +function waitForDuration(ms: number, signal?: AbortSignal): Promise { throwIfAborted(signal); let abort: (() => void) | undefined; return new Promise((resolve, reject) => { - const timeout = setTimeout(resolve, POLL_INTERVAL_MS); + const timeout = setTimeout(resolve, ms); abort = () => { clearTimeout(timeout); reject(signal?.reason instanceof Error ? signal.reason : new Error("Request aborted")); @@ -78,35 +114,8 @@ function waitForPoll(signal?: AbortSignal): Promise { }); } -async function fetchNonce(signal?: AbortSignal): Promise { - const res = await fetchWithTimeout(BASE_URL, { headers: { "User-Agent": USER_AGENT } }, signal); - const html = await res.text(); - const match = html.match(/nonce":"([a-f0-9]+)"/); - if (!match) throw new Error("Failed to extract CSRF nonce from veoaifree.com"); - return match[1]; -} - -async function postAjax( - nonce: string, - params: Record, - signal?: AbortSignal -): Promise { - const body = new URLSearchParams({ action: "veo_video_generator", nonce, ...params }); - const res = await fetchWithTimeout( - AJAX_URL, - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": USER_AGENT, - Origin: BASE_URL, - Referer: `${BASE_URL}/`, - }, - body: body.toString(), - }, - signal - ); - return res.text(); +function waitForPoll(signal?: AbortSignal): Promise { + return waitForDuration(POLL_INTERVAL_MS, signal); } function jsonResp(data: unknown, status = 200): Response { @@ -116,13 +125,129 @@ function jsonResp(data: unknown, status = 200): Response { }); } -function errResp(message: string, status = 502): Response { - return jsonResp({ error: { message } }, status); +function errResp(message: string, status = 502, code = "upstream_error"): Response { + return jsonResp( + { + error: { + message: sanitizeErrorMessage(message), + type: "upstream_error", + code, + }, + }, + status + ); } -// ─── Intent Detection ─────────────────────────────────────────────────────── +function getSetCookieHeaders(headers: Headers): string[] { + const value = headers as Headers & { getSetCookie?: () => string[] }; + if (typeof value.getSetCookie === "function") { + return value.getSetCookie(); + } + const raw = headers.get("set-cookie"); + return raw ? [raw] : []; +} -type ToolIntent = "video" | "image" | "tts" | "enhance"; +function parseSetCookiePair(setCookie: string): { name: string; value: string } | null { + const pair = setCookie.split(";")[0]?.trim(); + if (!pair) return null; + const eq = pair.indexOf("="); + if (eq <= 0) return null; + return { name: pair.slice(0, eq).trim(), value: pair.slice(eq + 1) }; +} + +function mergeCookieHeaderWithSetCookie(cookieHeader: string, setCookieHeaders: string[]): string { + const cookieMap = new Map(); + + for (const part of cookieHeader.split(";")) { + const trimmed = part.trim(); + if (!trimmed) continue; + const eq = trimmed.indexOf("="); + if (eq <= 0) continue; + cookieMap.set(trimmed.slice(0, eq).trim(), trimmed.slice(eq + 1)); + } + + for (const setCookie of setCookieHeaders) { + const parsed = parseSetCookiePair(setCookie); + if (!parsed || !parsed.value) continue; + cookieMap.set(parsed.name, parsed.value); + } + + return [...cookieMap.entries()].map(([name, value]) => `${name}=${value}`).join("; "); +} + +function updateSessionCookies(context: VeoSessionContext, response: Response): void { + const setCookieHeaders = getSetCookieHeaders(response.headers); + if (setCookieHeaders.length === 0) return; + context.cookieHeader = mergeCookieHeaderWithSetCookie(context.cookieHeader, setCookieHeaders); +} + +function buildRequestHeaders( + context: VeoSessionContext, + options?: { + contentType?: string; + referer?: string; + includeOrigin?: boolean; + includeCookie?: boolean; + } +): Record { + const headers: Record = { + "User-Agent": context.userAgent, + Referer: options?.referer || context.referer, + }; + if (options?.contentType) headers["Content-Type"] = options.contentType; + if (options?.includeOrigin) headers.Origin = context.origin; + if (options?.includeCookie !== false && context.cookieHeader) { + headers.Cookie = context.cookieHeader; + } + return headers; +} + +async function fetchSession( + context: VeoSessionContext, + url: string, + init: RequestInit = {} +): Promise { + const response = await fetchWithTimeout(url, init, context.signal); + updateSessionCookies(context, response); + return response; +} + +async function fetchNonce(context: VeoSessionContext): Promise { + const res = await fetchSession(context, BASE_URL, { + headers: buildRequestHeaders(context, { includeCookie: false }), + }); + const html = await res.text(); + const match = html.match(/nonce":"([a-f0-9]+)"/); + if (!match) throw new Error("Failed to extract CSRF nonce from veoaifree.com"); + return match[1]; +} + +async function postAjax( + context: VeoSessionContext, + nonce: string, + params: Record +): Promise { + const body = new URLSearchParams({ action: "veo_video_generator", nonce, ...params }); + const res = await fetchSession(context, AJAX_URL, { + method: "POST", + headers: buildRequestHeaders(context, { + contentType: "application/x-www-form-urlencoded", + includeOrigin: true, + }), + body: body.toString(), + }); + return res.text(); +} + +function buildSessionContext(signal?: AbortSignal): VeoSessionContext { + return { + cookieHeader: "", + userAgent: USER_AGENT, + origin: BASE_URL, + referer: `${BASE_URL}/`, + signal, + }; +} export function detectIntent(model?: string, prompt?: string): ToolIntent { const m = (model || "").toLowerCase(); @@ -130,93 +255,30 @@ export function detectIntent(model?: string, prompt?: string): ToolIntent { if (m.includes("image") || m.includes("banana") || m.includes("imagen")) return "image"; if (m.includes("enhance") || m.includes("prompt")) return "enhance"; if (m.includes("video") || m.includes("veo") || m.includes("seedance")) return "video"; - // Auto-detect from prompt const p = (prompt || "").toLowerCase(); - if (p.startsWith("generate image") || p.startsWith("create image") || p.startsWith("draw ")) + if (p.startsWith("generate image") || p.startsWith("create image") || p.startsWith("draw ")) { return "image"; + } if (p.startsWith("enhance") || p.startsWith("improve prompt")) return "enhance"; - return "video"; // default -} - -// ─── Tool Handlers ────────────────────────────────────────────────────────── - -async function handleVideo( - nonce: string, - prompt: string, - aspectRatio: string, - signal?: AbortSignal -): Promise { - // Generate - const genResult = await postAjax( - nonce, - { - prompt, - totalVariations: "1", - aspectRatio, - actionType: "full-video-generate", - }, - signal - ); - const sceneData = genResult.trim(); - if (!sceneData || sceneData === "0" || sceneData.toLowerCase().includes("error")) { - return errResp("Video generation failed"); - } - - // Poll - for (let i = 0; i < MAX_POLLS; i++) { - await waitForPoll(signal); - throwIfAborted(signal); - try { - const pollResult = await postAjax( - nonce, - { - sceneData, - actionType: "final-video-results", - }, - signal - ); - const trimmed = pollResult.trim(); - if (trimmed && trimmed !== "0" && !trimmed.toLowerCase().includes("error")) { - const urls = trimmed - .split(/[,\n]/) - .map((u) => u.trim()) - .filter((u) => u.startsWith("http")); - if (urls.length > 0) { - return jsonResp({ - object: "video.generation", - data: urls.map((url) => ({ url, type: "video" })), - status: "completed", - }); - } - } - } catch { - /* continue polling */ - } - } - return errResp("Video generation timed out after 10 minutes", 504); + return "video"; } async function handleImage( + context: VeoSessionContext, nonce: string, prompt: string, - aspectRatio: string, - signal?: AbortSignal + aspectRatio: string ): Promise { - const result = await postAjax( - nonce, - { - promptIMG: prompt, - totalVariationsIMG: "1", - aspectRatioIMG: aspectRatio, - actionType: "banan-image-generator", - }, - signal - ); + const result = await postAjax(context, nonce, { + promptIMG: prompt, + totalVariationsIMG: "1", + aspectRatioIMG: aspectRatio, + actionType: "banan-image-generator", + }); const trimmed = result.trim(); if (!trimmed || trimmed === "0" || trimmed.toLowerCase().includes("error")) { return errResp("Image generation failed"); } - // Response is comma-separated base64 PNGs or URLs const parts = trimmed .split(",") .map((s) => s.trim()) @@ -233,7 +295,6 @@ async function handleTTS( lang?: string, signal?: AbortSignal ): Promise { - // Parse prompt for text and optional voice instructions const text = prompt; const selectedVoice = voice || "en-US-AvaNeural"; const selectedLang = lang || "en-US"; @@ -269,7 +330,6 @@ async function handleTTS( contentType.includes("octet-stream") || contentType.includes("wav") ) { - // Return audio directly return new Response(res.body, { headers: { "Content-Type": contentType.includes("wav") ? "audio/wav" : "audio/mpeg", @@ -278,7 +338,6 @@ async function handleTTS( }); } - // JSON response with base64 audio_data const data = await res.text(); try { const json = JSON.parse(data); @@ -295,18 +354,14 @@ async function handleTTS( } async function handleEnhance( + context: VeoSessionContext, nonce: string, - prompt: string, - signal?: AbortSignal + prompt: string ): Promise { - const result = await postAjax( - nonce, - { - prompt, - actionType: "main-prompt-generation", - }, - signal - ); + const result = await postAjax(context, nonce, { + prompt, + actionType: "main-prompt-generation", + }); const trimmed = result.trim(); if (!trimmed || trimmed === "0") { return errResp("Prompt enhancement failed"); @@ -314,7 +369,335 @@ async function handleEnhance( return jsonResp({ object: "prompt.enhancement", enhanced: trimmed, status: "completed" }); } -// ─── Executor ─────────────────────────────────────────────────────────────── +function validateArtifactUrl(input: string): URL { + let url: URL; + try { + url = parseAndValidatePublicUrl(input); + } catch (error) { + if (error instanceof OutboundUrlGuardError) { + throw new VeoArtifactError( + error.code === "OUTBOUND_URL_INVALID" + ? "VIDEO_ARTIFACT_URL_INVALID" + : "VIDEO_ARTIFACT_URL_BLOCKED", + error.code === "OUTBOUND_URL_INVALID" + ? "Video artifact URL is invalid" + : "Video artifact URL is blocked", + 502 + ); + } + throw new VeoArtifactError("VIDEO_ARTIFACT_URL_INVALID", "Video artifact URL is invalid", 502); + } + + if (url.protocol !== "https:") { + throw new VeoArtifactError( + "VIDEO_ARTIFACT_URL_INVALID", + "Video artifact URL must use HTTPS", + 502 + ); + } + + return url; +} + +async function assertHostnameResolvesPublic(hostname: string): Promise { + const bare = + hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; + if (isIP(bare)) { + if (isPrivateHost(bare)) { + throw new VeoArtifactError( + "VIDEO_ARTIFACT_URL_BLOCKED", + "Video artifact host resolves to a blocked address", + 502 + ); + } + return; + } + + let resolved: Array<{ address: string }>; + try { + resolved = await dns.promises.lookup(bare, { all: true }); + } catch { + throw new VeoArtifactError( + "VIDEO_ARTIFACT_DOWNLOAD_FAILED", + "Video artifact host could not be resolved", + 502 + ); + } + + if (!resolved.length) { + throw new VeoArtifactError( + "VIDEO_ARTIFACT_DOWNLOAD_FAILED", + "Video artifact host could not be resolved", + 502 + ); + } + + for (const { address } of resolved) { + if (isPrivateHost(address)) { + throw new VeoArtifactError( + "VIDEO_ARTIFACT_URL_BLOCKED", + "Video artifact host resolves to a blocked address", + 502 + ); + } + } +} + +async function readCappedBuffer(response: Response, maxBytes: number): Promise { + const contentLengthHeader = response.headers.get("content-length"); + const contentLength = contentLengthHeader ? Number.parseInt(contentLengthHeader, 10) : null; + if (contentLength !== null && Number.isFinite(contentLength) && contentLength > maxBytes) { + throw new VeoArtifactError( + "VIDEO_ARTIFACT_TOO_LARGE", + "Video artifact exceeds the maximum supported size", + 502 + ); + } + + if (!response.body) { + const buffer = Buffer.from(await response.arrayBuffer()); + if (buffer.byteLength > maxBytes) { + throw new VeoArtifactError( + "VIDEO_ARTIFACT_TOO_LARGE", + "Video artifact exceeds the maximum supported size", + 502 + ); + } + return buffer; + } + + const reader = response.body.getReader(); + const chunks: Buffer[] = []; + let totalBytes = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = Buffer.from(value); + totalBytes += chunk.byteLength; + if (totalBytes > maxBytes) { + await reader.cancel(); + throw new VeoArtifactError( + "VIDEO_ARTIFACT_TOO_LARGE", + "Video artifact exceeds the maximum supported size", + 502 + ); + } + chunks.push(chunk); + } + } finally { + reader.releaseLock(); + } + + return Buffer.concat(chunks, totalBytes); +} + +function validateMp4Buffer(buffer: Buffer): void { + if (buffer.length <= 0) { + throw new VeoArtifactError("VIDEO_ARTIFACT_SIGNATURE_INVALID", "Video artifact is empty", 502); + } + + if (buffer.length < 12 || buffer.toString("ascii", 4, 8) !== "ftyp") { + throw new VeoArtifactError( + "VIDEO_ARTIFACT_SIGNATURE_INVALID", + "Video artifact is not a valid MP4 file", + 502 + ); + } +} + +async function downloadArtifactOnce( + context: VeoSessionContext, + artifactUrl: string +): Promise { + let currentUrl = artifactUrl; + + for (let redirectCount = 0; redirectCount <= ARTIFACT_MAX_REDIRECTS; redirectCount++) { + const parsedUrl = validateArtifactUrl(currentUrl); + await assertHostnameResolvesPublic(parsedUrl.hostname); + + const response = await fetchSession(context, parsedUrl.toString(), { + method: "GET", + redirect: "manual", + headers: buildRequestHeaders(context), + }); + + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get("location"); + if (!location) { + throw new VeoArtifactError( + "VIDEO_ARTIFACT_DOWNLOAD_FAILED", + "Video artifact redirect is missing a destination", + 502 + ); + } + if (redirectCount >= ARTIFACT_MAX_REDIRECTS) { + throw new VeoArtifactError( + "VIDEO_ARTIFACT_DOWNLOAD_FAILED", + "Video artifact exceeded the redirect limit", + 502 + ); + } + try { + currentUrl = new URL(location, parsedUrl.toString()).toString(); + } catch { + throw new VeoArtifactError( + "VIDEO_ARTIFACT_URL_INVALID", + "Video artifact redirect destination is invalid", + 502 + ); + } + continue; + } + + if (RETRYABLE_ARTIFACT_STATUSES.has(response.status)) { + throw new VeoArtifactError( + response.status === 202 ? "VIDEO_ARTIFACT_NOT_READY" : "VIDEO_ARTIFACT_UNAVAILABLE", + "Video artifact is not available yet", + 502, + { retryable: true } + ); + } + + if (!response.ok) { + throw new VeoArtifactError( + "VIDEO_ARTIFACT_DOWNLOAD_FAILED", + `Video artifact download failed (${response.status})`, + 502 + ); + } + + const contentType = (response.headers.get("content-type") || "") + .split(";")[0] + .trim() + .toLowerCase(); + if (contentType !== "video/mp4" && contentType !== "application/octet-stream") { + throw new VeoArtifactError( + "VIDEO_ARTIFACT_CONTENT_TYPE_INVALID", + "Video artifact returned an unsupported content type", + 502 + ); + } + + const buffer = await readCappedBuffer(response, ARTIFACT_MAX_BYTES); + validateMp4Buffer(buffer); + return buffer; + } + + throw new VeoArtifactError( + "VIDEO_ARTIFACT_DOWNLOAD_FAILED", + "Video artifact exceeded the redirect limit", + 502 + ); +} + +async function downloadArtifactWithAvailabilityWindow( + context: VeoSessionContext, + artifactUrl: string +): Promise { + let lastError: VeoArtifactError | null = null; + + for (let attempt = 0; attempt < ARTIFACT_READY_MAX_ATTEMPTS; attempt++) { + try { + return await downloadArtifactOnce(context, artifactUrl); + } catch (error) { + if (!(error instanceof VeoArtifactError)) throw error; + if (!error.retryable) throw error; + lastError = error; + if (attempt === ARTIFACT_READY_MAX_ATTEMPTS - 1) break; + await waitForDuration(ARTIFACT_READY_RETRY_INTERVAL_MS, context.signal); + } + } + + if (lastError?.code === "VIDEO_ARTIFACT_NOT_READY") { + throw new VeoArtifactError( + "VIDEO_ARTIFACT_UNAVAILABLE", + "Video artifact did not become available in time", + 502 + ); + } + + throw new VeoArtifactError( + "VIDEO_ARTIFACT_UNAVAILABLE", + "Video artifact did not become available in time", + 502 + ); +} + +function extractCandidateUrls(value: string): string[] { + return value + .split(/[,\n]/) + .map((part) => part.trim()) + .filter((part) => /^https?:\/\//i.test(part)); +} + +async function handleVideo( + context: VeoSessionContext, + nonce: string, + prompt: string, + aspectRatio: string +): Promise { + const genResult = await postAjax(context, nonce, { + prompt, + totalVariations: "1", + aspectRatio, + actionType: "full-video-generate", + }); + const sceneData = genResult.trim(); + if (!sceneData || sceneData === "0" || sceneData.toLowerCase().includes("error")) { + return errResp("Video generation failed"); + } + + for (let i = 0; i < MAX_POLLS; i++) { + await waitForPoll(context.signal); + throwIfAborted(context.signal); + try { + const pollResult = await postAjax(context, nonce, { + sceneData, + actionType: "final-video-results", + }); + const trimmed = pollResult.trim(); + if (!trimmed || trimmed === "0" || trimmed.toLowerCase().includes("error")) { + continue; + } + + const urls = extractCandidateUrls(trimmed); + if (urls.length === 0) { + continue; + } + + let artifactBuffer: Buffer | null = null; + try { + artifactBuffer = await downloadArtifactWithAvailabilityWindow(context, urls[0]); + const b64 = artifactBuffer.toString("base64"); + return jsonResp({ + created: Math.floor(Date.now() / 1000), + data: [{ b64_json: b64, format: "mp4" }], + }); + } catch (error) { + if (artifactBuffer) artifactBuffer.fill(0); + if (error instanceof VeoArtifactError) { + return errResp(error.message, error.status, error.code); + } + throw error; + } finally { + if (artifactBuffer) artifactBuffer.fill(0); + } + } catch (error) { + if (error instanceof VeoArtifactError) { + return errResp(error.message, error.status, error.code); + } + if (error instanceof Error && error.message === "Request aborted") { + throw error; + } + } + } + + return errResp("Video generation timed out after 10 minutes", 504, "VIDEO_ARTIFACT_NOT_READY"); +} + +// ————————————————————————— Executor ————————————————————————— export class VeoAIFreeWebExecutor extends BaseExecutor { constructor() { @@ -330,7 +713,6 @@ export class VeoAIFreeWebExecutor extends BaseExecutor { const body = input.body as Record | undefined; const model = input.model || (body?.model as string) || "veo-3.1"; - // Extract prompt const messages = (body?.messages as Array>) || []; const userMsg = messages.filter((m) => m.role === "user").pop(); const systemMsg = messages.filter((m) => m.role === "system").pop(); @@ -339,17 +721,15 @@ export class VeoAIFreeWebExecutor extends BaseExecutor { if (!prompt.trim()) { return { - response: errResp("No prompt provided", 400), + response: errResp("No prompt provided", 400, "invalid_request"), url: AJAX_URL, headers: {}, transformedBody: null, }; } - // Detect intent const intent = detectIntent(model, prompt); - // TTS doesn't need nonce if (intent === "tts") { const voiceMatch = systemText.match(/voice:\s*(\S+)/); const langMatch = systemText.match(/lang:\s*(\S+)/); @@ -357,39 +737,34 @@ export class VeoAIFreeWebExecutor extends BaseExecutor { return { response: resp, url: TTS_URL, headers: {}, transformedBody: { intent, model } }; } - // Get nonce for AJAX endpoints + const context = buildSessionContext(input.signal || undefined); + let nonce: string; try { - nonce = await fetchNonce(input.signal); + nonce = await fetchNonce(context); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to get nonce"; return { - response: errResp(sanitizeErrorMessage(msg)), + response: errResp(sanitizeErrorMessage(msg), 502, "upstream_error"), url: BASE_URL, headers: {}, transformedBody: null, }; } - // Extract aspect ratio from system prompt or default const arMatch = systemText.match(/aspect[_-]?ratio:\s*(\S+)/i); const aspectRatio = arMatch?.[1] || "VIDEO_ASPECT_RATIO_LANDSCAPE"; let resp: Response; switch (intent) { case "image": - resp = await handleImage( - nonce, - prompt, - aspectRatio.replace("VIDEO_", "IMAGE_"), - input.signal - ); + resp = await handleImage(context, nonce, prompt, aspectRatio.replace("VIDEO_", "IMAGE_")); break; case "enhance": - resp = await handleEnhance(nonce, prompt, input.signal); + resp = await handleEnhance(context, nonce, prompt); break; default: - resp = await handleVideo(nonce, prompt, aspectRatio, input.signal); + resp = await handleVideo(context, nonce, prompt, aspectRatio); } return { response: resp, url: AJAX_URL, headers: {}, transformedBody: { intent, model } }; diff --git a/open-sse/handlers/videoGeneration.ts b/open-sse/handlers/videoGeneration.ts index 29b0902269..4ba841c688 100644 --- a/open-sse/handlers/videoGeneration.ts +++ b/open-sse/handlers/videoGeneration.ts @@ -253,9 +253,34 @@ async function handleVeoAiFreeVideoGeneration({ model, provider, body, credentia }; } + const payload = await upstreamResponse.json().catch(() => null); + const item = Array.isArray(payload?.data) ? payload.data[0] : null; + if ( + !payload || + !Array.isArray(payload.data) || + payload.data.length !== 1 || + !item || + typeof item.b64_json !== "string" || + item.b64_json.trim().length === 0 || + item.format !== "mp4" || + typeof item.url === "string" + ) { + return { + success: false, + status: 502, + error: { + error: { + message: "Veo AI Free did not return a valid MP4 artifact", + type: "upstream_error", + code: "VIDEO_ARTIFACT_UNAVAILABLE", + }, + }, + }; + } + return { success: true, - data: await upstreamResponse.json(), + data: payload, }; } diff --git a/tests/unit/veoaifree-video-route.test.ts b/tests/unit/veoaifree-video-route.test.ts new file mode 100644 index 0000000000..e8dd11f68b --- /dev/null +++ b/tests/unit/veoaifree-video-route.test.ts @@ -0,0 +1,169 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-veo-route-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "veo-route-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const videoRoute = await import("../../src/app/api/v1/videos/generations/route.ts"); + +const originalFetch = globalThis.fetch; +const originalSetTimeout = globalThis.setTimeout; + +function createResponse(body: BodyInit | null, init?: ResponseInit & { setCookies?: string[] }) { + const response = new Response(body, init); + if (init?.setCookies) { + Object.defineProperty(response.headers, "getSetCookie", { + value: () => init.setCookies, + configurable: true, + }); + } + return response; +} + +function createTestMp4Buffer() { + return Buffer.from([ + 0x00, 0x00, 0x00, 0x14, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, 0x00, 0x00, 0x00, 0x00, + ]); +} + +function immediateButSafeTimeout( + callback: (...args: unknown[]) => void, + ms?: number, + ...args: unknown[] +) { + if (ms === 20_000 || ms === 5_000) { + callback(...args); + return 0 as unknown as ReturnType; + } + return originalSetTimeout(callback as TimerHandler, ms, ...args); +} + +test.afterEach(() => { + globalThis.fetch = originalFetch; + globalThis.setTimeout = originalSetTimeout; +}); + +test.after(() => { + globalThis.fetch = originalFetch; + globalThis.setTimeout = originalSetTimeout; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("video route returns 200 with normalized b64_json for Veo AI Free", async () => { + globalThis.setTimeout = immediateButSafeTimeout as typeof setTimeout; + const mp4 = createTestMp4Buffer(); + + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const stringUrl = String(url); + + if (stringUrl === "https://veoaifree.com") { + return createResponse('{"nonce":"abc123ff"}', { + status: 200, + headers: { "content-type": "text/html" }, + setCookies: ["session_id=bootstrap; Path=/; Secure"], + }); + } + + if (stringUrl === "https://veoaifree.com/wp-admin/admin-ajax.php") { + const params = new URLSearchParams(String(init?.body || "")); + if (params.get("actionType") === "full-video-generate") { + return createResponse("scene-xyz", { + status: 200, + headers: { "content-type": "text/plain" }, + setCookies: ["session_id=scene; Path=/; Secure", "artifact_token=ready; Path=/; Secure"], + }); + } + return createResponse("https://93.184.216.34/video.mp4", { + status: 200, + headers: { "content-type": "text/plain" }, + }); + } + + if (stringUrl === "https://93.184.216.34/video.mp4") { + return createResponse(mp4, { + status: 200, + headers: { "content-type": "video/mp4", "content-length": String(mp4.length) }, + }); + } + + throw new Error(`Unexpected URL: ${stringUrl}`); + }) as typeof fetch; + + const response = await videoRoute.POST( + new Request("http://localhost/api/v1/videos/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "veoaifree-web/veo", + prompt: "synthetic product shot", + }), + }) + ); + + const payload = (await response.json()) as { + data: Array<{ b64_json?: string; format?: string; url?: string }>; + }; + assert.equal(response.status, 200); + assert.equal(payload.data.length, 1); + assert.equal(typeof payload.data[0].b64_json, "string"); + assert.equal(payload.data[0].format, "mp4"); + assert.equal("url" in payload.data[0], false); +}); + +test("video route returns non-2xx instead of false success when Veo artifact stays unavailable", async () => { + globalThis.setTimeout = immediateButSafeTimeout as typeof setTimeout; + + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const stringUrl = String(url); + + if (stringUrl === "https://veoaifree.com") { + return createResponse('{"nonce":"abc123ff"}', { + status: 200, + headers: { "content-type": "text/html" }, + }); + } + + if (stringUrl === "https://veoaifree.com/wp-admin/admin-ajax.php") { + const params = new URLSearchParams(String(init?.body || "")); + return createResponse( + params.get("actionType") === "full-video-generate" + ? "scene-xyz" + : "https://93.184.216.34/missing.mp4", + { + status: 200, + headers: { "content-type": "text/plain" }, + } + ); + } + + if (stringUrl === "https://93.184.216.34/missing.mp4") { + return createResponse("missing", { + status: 404, + headers: { "content-type": "text/plain" }, + }); + } + + throw new Error(`Unexpected URL: ${stringUrl}`); + }) as typeof fetch; + + const response = await videoRoute.POST( + new Request("http://localhost/api/v1/videos/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "veoaifree-web/veo", + prompt: "synthetic product shot", + }), + }) + ); + + const payload = (await response.json()) as { error: { code?: string } }; + assert.equal(response.status, 502); + assert.equal(payload.error.code, "VIDEO_ARTIFACT_UNAVAILABLE"); +}); diff --git a/tests/unit/veoaifree-web-executor.test.ts b/tests/unit/veoaifree-web-executor.test.ts index 38ff07dde6..e91e54f860 100644 --- a/tests/unit/veoaifree-web-executor.test.ts +++ b/tests/unit/veoaifree-web-executor.test.ts @@ -1,7 +1,44 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { detectIntent } = await import("../../open-sse/executors/veoaifree-web.ts"); +const originalFetch = globalThis.fetch; +const originalSetTimeout = globalThis.setTimeout; +const { detectIntent, VeoAIFreeWebExecutor } = + await import("../../open-sse/executors/veoaifree-web.ts"); + +test.afterEach(() => { + globalThis.fetch = originalFetch; + globalThis.setTimeout = originalSetTimeout; +}); + +function createResponse(body: BodyInit | null, init?: ResponseInit & { setCookies?: string[] }) { + const response = new Response(body, init); + if (init?.setCookies) { + Object.defineProperty(response.headers, "getSetCookie", { + value: () => init.setCookies, + configurable: true, + }); + } + return response; +} + +function createTestMp4Buffer() { + return Buffer.from([ + 0x00, 0x00, 0x00, 0x14, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, 0x00, 0x00, 0x00, 0x00, + ]); +} + +function immediateButSafeTimeout( + callback: (...args: unknown[]) => void, + ms?: number, + ...args: unknown[] +) { + if (ms === 20_000 || ms === 5_000) { + callback(...args); + return 0 as unknown as ReturnType; + } + return originalSetTimeout(callback as TimerHandler, ms, ...args); +} // ─── detectIntent: model-based ────────────────────────────────────────────── @@ -68,8 +105,201 @@ test("detectIntent model takes precedence over prompt", () => { // ─── Integration: executor class exists ───────────────────────────────────── test("VeoAIFreeWebExecutor class can be imported", async () => { - const { VeoAIFreeWebExecutor } = await import("../../open-sse/executors/veoaifree-web.ts"); const executor = new VeoAIFreeWebExecutor(); assert.ok(executor); assert.equal(typeof executor.execute, "function"); }); + +test("VeoAIFreeWebExecutor preserves cookies and returns normalized base64 mp4", async () => { + globalThis.setTimeout = immediateButSafeTimeout as typeof setTimeout; + const executor = new VeoAIFreeWebExecutor(); + const seenCookies: string[] = []; + const seenReferers: string[] = []; + const mp4 = createTestMp4Buffer(); + + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const stringUrl = String(url); + const headers = new Headers(init?.headers || {}); + if (headers.get("Cookie")) seenCookies.push(headers.get("Cookie") || ""); + if (headers.get("Referer")) seenReferers.push(headers.get("Referer") || ""); + + if (stringUrl === "https://veoaifree.com") { + return createResponse('{"nonce":"abc123ff"}', { + status: 200, + headers: { "content-type": "text/html" }, + setCookies: ["session_id=bootstrap; Path=/; Secure"], + }); + } + + if (stringUrl === "https://veoaifree.com/wp-admin/admin-ajax.php") { + const params = new URLSearchParams(String(init?.body || "")); + const actionType = params.get("actionType"); + if (actionType === "full-video-generate") { + assert.match(headers.get("Cookie") || "", /session_id=bootstrap/); + return createResponse("scene-xyz", { + status: 200, + headers: { "content-type": "text/plain" }, + setCookies: ["session_id=scene; Path=/; Secure", "artifact_token=ready; Path=/; Secure"], + }); + } + if (actionType === "final-video-results") { + assert.match(headers.get("Cookie") || "", /session_id=scene/); + assert.match(headers.get("Cookie") || "", /artifact_token=ready/); + return createResponse("https://93.184.216.34/video.mp4", { + status: 200, + headers: { "content-type": "text/plain" }, + }); + } + } + + if (stringUrl === "https://93.184.216.34/video.mp4") { + assert.match(headers.get("Cookie") || "", /session_id=scene/); + assert.match(headers.get("Cookie") || "", /artifact_token=ready/); + return createResponse(mp4, { + status: 200, + headers: { "content-type": "video/mp4", "content-length": String(mp4.length) }, + }); + } + + throw new Error(`Unexpected URL: ${stringUrl}`); + }) as typeof fetch; + + const result = await executor.execute({ + model: "veo", + body: { + messages: [ + { role: "system", content: "aspect_ratio: VIDEO_ASPECT_RATIO_LANDSCAPE" }, + { role: "user", content: "synthetic product shot" }, + ], + }, + stream: false, + credentials: { connectionId: "noauth" }, + signal: null, + log: null, + }); + + assert.ok(seenCookies.some((value) => value.includes("session_id=scene"))); + assert.ok(seenCookies.some((value) => value.includes("artifact_token=ready"))); + assert.ok(seenReferers.every((value) => value.startsWith("https://veoaifree.com/"))); + + const payload = await result.response.json(); + assert.equal(result.response.status, 200); + assert.equal(Array.isArray(payload.data), true); + assert.equal(payload.data.length, 1); + assert.equal(typeof payload.data[0].b64_json, "string"); + assert.equal(payload.data[0].format, "mp4"); + assert.equal("url" in payload.data[0], false); + assert.equal(Buffer.from(payload.data[0].b64_json, "base64").equals(mp4), true); +}); + +test("VeoAIFreeWebExecutor retries same artifact URL within availability window and succeeds", async () => { + globalThis.setTimeout = immediateButSafeTimeout as typeof setTimeout; + const executor = new VeoAIFreeWebExecutor(); + const mp4 = createTestMp4Buffer(); + let artifactAttempts = 0; + + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const stringUrl = String(url); + + if (stringUrl === "https://veoaifree.com") { + return createResponse('{"nonce":"abc123ff"}', { + status: 200, + headers: { "content-type": "text/html" }, + }); + } + + if (stringUrl === "https://veoaifree.com/wp-admin/admin-ajax.php") { + const params = new URLSearchParams(String(init?.body || "")); + return createResponse( + params.get("actionType") === "full-video-generate" + ? "scene-xyz" + : "https://93.184.216.34/video-late.mp4", + { + status: 200, + headers: { "content-type": "text/plain" }, + } + ); + } + + if (stringUrl === "https://93.184.216.34/video-late.mp4") { + artifactAttempts += 1; + if (artifactAttempts === 1) { + return createResponse("not ready", { + status: 404, + headers: { "content-type": "text/plain" }, + }); + } + return createResponse(mp4, { + status: 200, + headers: { "content-type": "video/mp4", "content-length": String(mp4.length) }, + }); + } + + throw new Error(`Unexpected URL: ${stringUrl}`); + }) as typeof fetch; + + const result = await executor.execute({ + model: "veo", + body: { messages: [{ role: "user", content: "synthetic product shot" }] }, + stream: false, + credentials: { connectionId: "noauth" }, + signal: null, + log: null, + }); + + const payload = await result.response.json(); + assert.equal(result.response.status, 200); + assert.equal(artifactAttempts, 2); + assert.equal(payload.data[0].format, "mp4"); +}); + +test("VeoAIFreeWebExecutor returns deterministic error when artifact URL stays 404", async () => { + globalThis.setTimeout = immediateButSafeTimeout as typeof setTimeout; + const executor = new VeoAIFreeWebExecutor(); + let artifactAttempts = 0; + + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const stringUrl = String(url); + + if (stringUrl === "https://veoaifree.com") { + return createResponse('{"nonce":"abc123ff"}', { + status: 200, + headers: { "content-type": "text/html" }, + }); + } + + if (stringUrl === "https://veoaifree.com/wp-admin/admin-ajax.php") { + const params = new URLSearchParams(String(init?.body || "")); + return createResponse( + params.get("actionType") === "full-video-generate" + ? "scene-xyz" + : "https://93.184.216.34/missing.mp4", + { + status: 200, + headers: { "content-type": "text/plain" }, + } + ); + } + + if (stringUrl === "https://93.184.216.34/missing.mp4") { + artifactAttempts += 1; + return createResponse("missing", { status: 404, headers: { "content-type": "text/plain" } }); + } + + throw new Error(`Unexpected URL: ${stringUrl}`); + }) as typeof fetch; + + const result = await executor.execute({ + model: "veo", + body: { messages: [{ role: "user", content: "synthetic product shot" }] }, + stream: false, + credentials: { connectionId: "noauth" }, + signal: null, + log: null, + }); + + const payload = await result.response.json(); + assert.equal(result.response.status, 502); + assert.equal(artifactAttempts, 12); + assert.equal(payload.error.code, "VIDEO_ARTIFACT_UNAVAILABLE"); +});