diff --git a/package-lock.json b/package-lock.json index 8818b7bd..59a9e162 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,6 @@ "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", "@tailwindcss/postcss": "^4.1.17", - "@types/three": "^0.182.0", "@xyflow/react": "^12.9.3", "ai": "^6.0.49", "autoprefixer": "^10.4.22", @@ -39,6 +38,7 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/three": "^0.182.0", "@vitejs/plugin-react": "^4.7.0", "@vitest/coverage-v8": "^4.0.16", "jsdom": "^27.4.0", diff --git a/package.json b/package.json index 9e21827e..2b9898b1 100644 --- a/package.json +++ b/package.json @@ -40,9 +40,9 @@ "@testing-library/react": "^16.3.1", "@types/jszip": "^3.4.0", "@types/node": "^24.10.1", - "@types/three": "^0.182.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/three": "^0.182.0", "@vitejs/plugin-react": "^4.7.0", "@vitest/coverage-v8": "^4.0.16", "jsdom": "^27.4.0", diff --git a/src/app/api/generate/providers/__tests__/fal-prompt-passthrough.test.ts b/src/app/api/generate/providers/__tests__/fal-prompt-passthrough.test.ts new file mode 100644 index 00000000..c87eaf4f --- /dev/null +++ b/src/app/api/generate/providers/__tests__/fal-prompt-passthrough.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { generateWithFalQueue, clearFalInputMappingCache } from "../fal"; +import type { GenerationInput } from "@/lib/providers/types"; + +/** + * Tests for prompt passthrough when dynamicInputs are present. + * + * Bug: When a generate node has both a prompt AND image input connected, + * the dynamicInputs code path processes images but never reads input.prompt, + * causing "prompt is required" errors from the fal.ai API. + */ + +// Captured request bodies from fetch calls +let capturedQueueBody: Record | null = null; + +function makeInput(overrides: Partial = {}): GenerationInput { + return { + model: { + id: "fal-ai/test-model", + name: "Test Model", + description: null, + provider: "fal", + capabilities: ["text-to-image"], + }, + prompt: "a photo of a cat", + images: [], + parameters: {}, + ...overrides, + }; +} + +/** + * Create a mock fetch that intercepts fal.ai API calls. + * - Schema request: returns OpenAPI spec with prompt + image_url properties + * - Queue submission: captures body, returns request_id + * - Status poll: returns COMPLETED + * - Result fetch: returns images array + * - Media fetch: returns fake image + */ +function createMockFetch() { + return vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url; + + // Schema request (fal.ai model search API with OpenAPI expansion) + if (urlStr.includes("api.fal.ai/v1/models")) { + return new Response( + JSON.stringify({ + models: [ + { + endpoint_id: "fal-ai/test-model", + openapi: { + paths: { + "/": { + post: { + requestBody: { + content: { + "application/json": { + schema: { + properties: { + prompt: { type: "string", description: "Text prompt" }, + image_url: { type: "string", description: "Input image URL" }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }), + { status: 200 } + ); + } + + // Queue submission + if (urlStr.includes("queue.fal.run/fal-ai/test-model") && init?.method === "POST") { + capturedQueueBody = JSON.parse(init.body as string); + return new Response( + JSON.stringify({ + request_id: "test-123", + status_url: "https://queue.fal.run/fal-ai/test-model/requests/test-123/status", + response_url: "https://queue.fal.run/fal-ai/test-model/requests/test-123", + }), + { status: 200 } + ); + } + + // Status poll + if (urlStr.includes("/requests/test-123/status")) { + return new Response( + JSON.stringify({ status: "COMPLETED" }), + { status: 200 } + ); + } + + // Result fetch + if (urlStr.includes("/requests/test-123") && !urlStr.includes("/status")) { + return new Response( + JSON.stringify({ + images: [{ url: "https://cdn.fal.ai/test/image.png" }], + }), + { status: 200 } + ); + } + + // Media fetch (output image) + if (urlStr.includes("cdn.fal.ai")) { + return new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]), { + status: 200, + headers: { "content-type": "image/png" }, + }); + } + + return new Response("Not Found", { status: 404 }); + }); +} + +describe("fal.ai prompt passthrough with dynamicInputs", () => { + let mockFetch: ReturnType; + + beforeEach(() => { + capturedQueueBody = null; + clearFalInputMappingCache(); + mockFetch = createMockFetch(); + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("includes prompt when dynamicInputs has image_url but no prompt", async () => { + const input = makeInput({ + prompt: "a photo of a cat", + dynamicInputs: { + image_url: "https://cdn.example.com/img.png", + }, + }); + + await generateWithFalQueue("test-req", "test-api-key", input); + + expect(capturedQueueBody).not.toBeNull(); + expect(capturedQueueBody!.prompt).toBe("a photo of a cat"); + expect(capturedQueueBody!.image_url).toBe("https://cdn.example.com/img.png"); + }); + + it("does not duplicate prompt when dynamicInputs already contains prompt", async () => { + const input = makeInput({ + prompt: "a cat", + dynamicInputs: { + prompt: "a dog", + image_url: "https://cdn.example.com/img.png", + }, + }); + + await generateWithFalQueue("test-req", "test-api-key", input); + + expect(capturedQueueBody).not.toBeNull(); + // dynamicInputs value wins - not overwritten by input.prompt + expect(capturedQueueBody!.prompt).toBe("a dog"); + }); + + it("works without dynamicInputs (existing behavior)", async () => { + const input = makeInput({ + prompt: "a cat", + dynamicInputs: undefined, + }); + + await generateWithFalQueue("test-req", "test-api-key", input); + + expect(capturedQueueBody).not.toBeNull(); + expect(capturedQueueBody!.prompt).toBe("a cat"); + }); +}); diff --git a/src/app/api/generate/providers/__tests__/replicate-prompt-passthrough.test.ts b/src/app/api/generate/providers/__tests__/replicate-prompt-passthrough.test.ts new file mode 100644 index 00000000..bad9e722 --- /dev/null +++ b/src/app/api/generate/providers/__tests__/replicate-prompt-passthrough.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { generateWithReplicate } from "../replicate"; +import type { GenerationInput } from "@/lib/providers/types"; + +/** + * Tests for prompt passthrough when dynamicInputs are present. + * + * Bug: When a generate node has both a prompt AND image input connected, + * the dynamicInputs code path processes images but never reads input.prompt, + * causing "prompt is required" errors from the Replicate API. + */ + +// Captured request bodies from fetch calls +let capturedPredictionBody: Record | null = null; + +function makeInput(overrides: Partial = {}): GenerationInput { + return { + model: { + id: "owner/test-model", + name: "Test Model", + description: null, + provider: "replicate", + capabilities: ["text-to-image"], + }, + prompt: "a photo of a cat", + images: [], + parameters: {}, + ...overrides, + }; +} + +/** + * Create a mock fetch that intercepts Replicate API calls. + * - Model info: returns schema with prompt + image_input properties + * - Prediction creation: captures body, returns succeeded + * - Media fetch: returns fake image + */ +function createMockFetch() { + return vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url; + + // Model info request + if (urlStr.includes("/models/owner/test-model") && !urlStr.includes("/predictions")) { + return new Response( + JSON.stringify({ + latest_version: { + id: "abc123", + openapi_schema: { + components: { + schemas: { + Input: { + properties: { + prompt: { type: "string", description: "Text prompt" }, + image_input: { type: "string", description: "Input image URL" }, + }, + }, + }, + }, + }, + }, + }), + { status: 200 } + ); + } + + // Prediction creation + if (urlStr.includes("/predictions") && init?.method === "POST") { + const body = JSON.parse(init.body as string); + capturedPredictionBody = body.input; + return new Response( + JSON.stringify({ + id: "pred-123", + status: "succeeded", + output: ["https://replicate.delivery/test/image.png"], + }), + { status: 201 } + ); + } + + // Media fetch (output image) + if (urlStr.includes("replicate.delivery")) { + return new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]), { + status: 200, + headers: { "content-type": "image/png" }, + }); + } + + return new Response("Not Found", { status: 404 }); + }); +} + +describe("Replicate prompt passthrough with dynamicInputs", () => { + let mockFetch: ReturnType; + + beforeEach(() => { + capturedPredictionBody = null; + mockFetch = createMockFetch(); + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("includes prompt when dynamicInputs has image_input but no prompt", async () => { + const input = makeInput({ + prompt: "a photo of a cat", + dynamicInputs: { + image_input: "https://cdn.example.com/img.png", + }, + }); + + await generateWithReplicate("test-req", "test-api-key", input); + + expect(capturedPredictionBody).not.toBeNull(); + expect(capturedPredictionBody!.prompt).toBe("a photo of a cat"); + expect(capturedPredictionBody!.image_input).toBe("https://cdn.example.com/img.png"); + }); + + it("does not duplicate prompt when dynamicInputs already contains prompt", async () => { + const input = makeInput({ + prompt: "a cat", + dynamicInputs: { + prompt: "a dog", + image_input: "https://cdn.example.com/img.png", + }, + }); + + await generateWithReplicate("test-req", "test-api-key", input); + + expect(capturedPredictionBody).not.toBeNull(); + // dynamicInputs value wins - not overwritten by input.prompt + expect(capturedPredictionBody!.prompt).toBe("a dog"); + }); + + it("works without dynamicInputs (existing behavior)", async () => { + const input = makeInput({ + prompt: "a cat", + dynamicInputs: undefined, + }); + + await generateWithReplicate("test-req", "test-api-key", input); + + expect(capturedPredictionBody).not.toBeNull(); + expect(capturedPredictionBody!.prompt).toBe("a cat"); + }); +}); diff --git a/src/app/api/generate/providers/fal.ts b/src/app/api/generate/providers/fal.ts index 95f43852..30a10a2b 100644 --- a/src/app/api/generate/providers/fal.ts +++ b/src/app/api/generate/providers/fal.ts @@ -288,6 +288,13 @@ export async function generateWithFalQueue( } } Object.assign(requestBody, filteredInputs); + + // Ensure prompt is included even when dynamicInputs are present + // (executor sends prompt as top-level field, not in dynamicInputs) + const promptParam = paramMap.prompt || "prompt"; + if (input.prompt && !requestBody[promptParam]) { + requestBody[promptParam] = input.prompt; + } } else { // Fallback: use schema to map generic input names to model-specific parameter names if (input.prompt) { diff --git a/src/app/api/generate/providers/replicate.ts b/src/app/api/generate/providers/replicate.ts index d292c5bd..7daeaae5 100644 --- a/src/app/api/generate/providers/replicate.ts +++ b/src/app/api/generate/providers/replicate.ts @@ -76,7 +76,7 @@ export async function generateWithReplicate( if (hasDynamicInputs) { // Apply coerced parameters first, then dynamic inputs override Object.assign(predictionInput, coerceParameterTypes(input.parameters, parameterTypes)); - const { schemaArrayParams } = getInputMappingFromSchema(schema); + const { paramMap, schemaArrayParams } = getInputMappingFromSchema(schema); // Apply array wrapping based on schema type for (const [key, value] of Object.entries(input.dynamicInputs!)) { @@ -90,6 +90,13 @@ export async function generateWithReplicate( } } } + + // Ensure prompt is included even when dynamicInputs are present + // (executor sends prompt as top-level field, not in dynamicInputs) + const promptParam = paramMap.prompt || "prompt"; + if (input.prompt && !predictionInput[promptParam]) { + predictionInput[promptParam] = input.prompt; + } } else { // Fallback: use schema to map generic input names to model-specific parameter names const { paramMap, arrayParams } = getInputMappingFromSchema(schema); diff --git a/src/app/api/models/[modelId]/__tests__/route.test.ts b/src/app/api/models/[modelId]/__tests__/route.test.ts index 42a3ca09..cb4e4daf 100644 --- a/src/app/api/models/[modelId]/__tests__/route.test.ts +++ b/src/app/api/models/[modelId]/__tests__/route.test.ts @@ -451,6 +451,329 @@ describe("/api/models/[modelId] schema endpoint", () => { }); }); + describe("real Fal Kling v2.6 pro image-to-video schema", () => { + it("should detect both start_image_url and end_image_url as image inputs", async () => { + // Exact schema structure from Fal API for fal-ai/kling-video/v2.6/pro/image-to-video + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + models: [{ + openapi: { + openapi: "3.0.4", + components: { + schemas: { + KlingVideoV26ProImageToVideoInput: { + title: "ImageToVideoV26ProRequest", + type: "object", + properties: { + prompt: { + title: "Prompt", + type: "string", + maxLength: 2500, + }, + duration: { + enum: ["5", "10"], + title: "Duration", + type: "string", + description: "The duration of the generated video in seconds", + default: "5", + }, + generate_audio: { + title: "Generate Audio", + type: "boolean", + description: "Whether to generate native audio for the video.", + default: true, + }, + start_image_url: { + description: "URL of the image to be used for the video", + type: "string", + title: "Start Image Url", + }, + end_image_url: { + title: "End Image Url", + type: "string", + description: "URL of the image to be used for the end of the video", + }, + negative_prompt: { + title: "Negative Prompt", + type: "string", + maxLength: 2500, + default: "blur, distort, and low quality", + }, + }, + required: ["prompt", "start_image_url"], + }, + }, + }, + paths: { + "/fal-ai/kling-video/v2.6/pro/image-to-video": { + post: { + requestBody: { + required: true, + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/KlingVideoV26ProImageToVideoInput", + }, + }, + }, + }, + }, + }, + }, + }, + }], + }), + }); + + const modelId = `fal-ai/kling-video-real-${testCounter}`; + const request = createMockSchemaRequest(modelId, "fal"); + const response = await GET(request, { params: Promise.resolve({ modelId }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + + const imageInputNames = data.inputs + .filter((i: { type: string }) => i.type === "image") + .map((i: { name: string }) => i.name); + const textInputNames = data.inputs + .filter((i: { type: string }) => i.type === "text") + .map((i: { name: string }) => i.name); + const paramNames = data.parameters.map((p: { name: string }) => p.name); + + // Both image URL fields should be detected as image inputs + expect(imageInputNames).toContain("start_image_url"); + expect(imageInputNames).toContain("end_image_url"); + + // Text inputs + expect(textInputNames).toContain("prompt"); + expect(textInputNames).toContain("negative_prompt"); + + // Parameters (not image or text inputs) + expect(paramNames).toContain("duration"); + expect(paramNames).toContain("generate_audio"); + + // Image inputs should NOT appear as parameters + expect(paramNames).not.toContain("start_image_url"); + expect(paramNames).not.toContain("end_image_url"); + }); + }); + + describe("anyOf/oneOf nullable schema patterns", () => { + // Helper to create fal.ai response with components.schemas for $ref resolution + function createFalModelResponseWithComponents( + inputProperties: Record, + required: string[] = [], + components?: Record + ) { + return { + ok: true, + json: () => Promise.resolve({ + models: [{ + openapi: { + paths: { + "/": { + post: { + requestBody: { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/Input", + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Input: { + type: "object", + properties: inputProperties, + required, + }, + ...components, + }, + }, + }, + }], + }), + }; + } + + it("should detect anyOf nullable string as image input (Kling pattern)", async () => { + // Exact pattern from Kling v2.6 image-to-video on Fal + mockFetch.mockResolvedValueOnce( + createFalModelResponseWithComponents({ + prompt: { + type: "string", + description: "Text prompt", + }, + image_url: { + type: "string", + description: "The URL of the image", + }, + end_image_url: { + anyOf: [{ type: "string" }, { type: "null" }], + description: "The URL of the end image", + }, + }, ["prompt", "image_url"]) + ); + + const modelId = `fal-ai/kling-anyof-${testCounter}`; + const request = createMockSchemaRequest(modelId, "fal"); + const response = await GET(request, { params: Promise.resolve({ modelId }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + + const imageInputNames = data.inputs + .filter((i: { type: string }) => i.type === "image") + .map((i: { name: string }) => i.name); + + // Both image_url and end_image_url should be image inputs + expect(imageInputNames).toContain("image_url"); + expect(imageInputNames).toContain("end_image_url"); + }); + + it("should detect anyOf with format: uri as image input", async () => { + mockFetch.mockResolvedValueOnce( + createFalModelResponseWithComponents({ + reference_image: { + anyOf: [ + { type: "string", format: "uri" }, + { type: "null" }, + ], + description: "Reference image URL", + }, + }) + ); + + const modelId = `fal-ai/anyof-uri-${testCounter}`; + const request = createMockSchemaRequest(modelId, "fal"); + const response = await GET(request, { params: Promise.resolve({ modelId }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + + const imageInputNames = data.inputs + .filter((i: { type: string }) => i.type === "image") + .map((i: { name: string }) => i.name); + + expect(imageInputNames).toContain("reference_image"); + }); + + it("should NOT detect anyOf with non-string types as image input", async () => { + mockFetch.mockResolvedValueOnce( + createFalModelResponseWithComponents({ + image_guidance_scale: { + anyOf: [{ type: "number" }, { type: "null" }], + description: "Image guidance scale", + }, + image_count: { + anyOf: [{ type: "integer" }, { type: "null" }], + description: "Number of images", + }, + }) + ); + + const modelId = `fal-ai/anyof-nonstring-${testCounter}`; + const request = createMockSchemaRequest(modelId, "fal"); + const response = await GET(request, { params: Promise.resolve({ modelId }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + + const paramNames = data.parameters.map((p: { name: string }) => p.name); + const inputNames = data.inputs.map((i: { name: string }) => i.name); + + expect(paramNames).toContain("image_guidance_scale"); + expect(paramNames).toContain("image_count"); + expect(inputNames).not.toContain("image_guidance_scale"); + expect(inputNames).not.toContain("image_count"); + }); + + it("should handle oneOf pattern same as anyOf", async () => { + mockFetch.mockResolvedValueOnce( + createFalModelResponseWithComponents({ + start_image: { + oneOf: [{ type: "string" }, { type: "null" }], + description: "The start image URL", + }, + }) + ); + + const modelId = `fal-ai/oneof-${testCounter}`; + const request = createMockSchemaRequest(modelId, "fal"); + const response = await GET(request, { params: Promise.resolve({ modelId }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + + const imageInputNames = data.inputs + .filter((i: { type: string }) => i.type === "image") + .map((i: { name: string }) => i.name); + + expect(imageInputNames).toContain("start_image"); + }); + + it("should resolve anyOf parameter types correctly (not default to string)", async () => { + mockFetch.mockResolvedValueOnce( + createFalModelResponseWithComponents({ + seed: { + anyOf: [{ type: "integer" }, { type: "null" }], + description: "Random seed", + }, + guidance_scale: { + anyOf: [{ type: "number" }, { type: "null" }], + description: "Guidance scale", + default: 7.5, + }, + }) + ); + + const modelId = `fal-ai/anyof-types-${testCounter}`; + const request = createMockSchemaRequest(modelId, "fal"); + const response = await GET(request, { params: Promise.resolve({ modelId }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + + const seedParam = data.parameters.find((p: { name: string }) => p.name === "seed"); + const guidanceParam = data.parameters.find((p: { name: string }) => p.name === "guidance_scale"); + + expect(seedParam?.type).toBe("integer"); + expect(guidanceParam?.type).toBe("number"); + }); + + it("should NOT classify anyOf boolean with image in name as image input", async () => { + mockFetch.mockResolvedValueOnce( + createFalModelResponseWithComponents({ + enable_image_enhancement: { + anyOf: [{ type: "boolean" }, { type: "null" }], + description: "Enable image enhancement", + default: false, + }, + }) + ); + + const modelId = `fal-ai/anyof-bool-${testCounter}`; + const request = createMockSchemaRequest(modelId, "fal"); + const response = await GET(request, { params: Promise.resolve({ modelId }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + + const paramNames = data.parameters.map((p: { name: string }) => p.name); + const inputNames = data.inputs.map((i: { name: string }) => i.name); + + expect(paramNames).toContain("enable_image_enhancement"); + expect(inputNames).not.toContain("enable_image_enhancement"); + }); + }); + describe("error handling", () => { it("should return 400 for invalid provider", async () => { const request = createMockSchemaRequest("test/model", "invalid"); diff --git a/src/app/api/models/[modelId]/route.ts b/src/app/api/models/[modelId]/route.ts index 1300fc5b..3c6ac278 100644 --- a/src/app/api/models/[modelId]/route.ts +++ b/src/app/api/models/[modelId]/route.ts @@ -123,10 +123,11 @@ function toLabel(name: string): string { * Image inputs must be strings (URLs or base64) or arrays of strings. * Integers, booleans, numbers with "image" in the name are NOT image inputs. */ -function isImageInput(name: string, prop: Record): boolean { +function isImageInput(name: string, prop: Record, schemaComponents?: Record): boolean { // First check: must be a string type (images are URLs or base64 strings) // Integers, booleans, numbers are NEVER image inputs regardless of name - const propType = prop.type as string | undefined; + const resolved = resolvePropertyType(prop, schemaComponents); + const propType = resolved.type; if (propType !== "string" && propType !== "array") { return false; } @@ -146,8 +147,8 @@ function isImageInput(name: string, prop: Record): boolean { return false; } - // Check format hints (OpenAPI format field) - strong signal for image URLs - const format = prop.format as string | undefined; + // Check format hints (OpenAPI format field or resolved format) - strong signal for image URLs + const format = (prop.format ?? resolved.format) as string | undefined; if (format === "uri" || format === "data-uri" || format === "binary") { // Only treat as image if name also suggests it's an image if (IMAGE_INPUT_PATTERNS.includes(name) || @@ -215,6 +216,68 @@ function resolveRef( return resolved || null; } +/** + * Resolve the effective type and format from an OpenAPI property. + * + * Handles wrapper patterns used by code generators (e.g. Pydantic → OpenAPI): + * - anyOf / oneOf: picks the first non-null type (nullable pattern) + * - allOf: merges referenced schemas + * - $ref: resolves from schemaComponents + * - Direct type: returns immediately (fast path — no behavior change) + */ +function resolvePropertyType( + prop: Record, + schemaComponents?: Record +): { type?: string; format?: string } { + // Fast path: direct type is defined — existing behaviour, no change + if (prop.type !== undefined) { + return { type: prop.type as string, format: prop.format as string | undefined }; + } + + // anyOf / oneOf — pick the first non-null variant + const variants = (prop.anyOf ?? prop.oneOf) as Array> | undefined; + if (variants && Array.isArray(variants)) { + for (const variant of variants) { + // Resolve $ref inside variant + if (variant.$ref && typeof variant.$ref === "string" && schemaComponents) { + const resolved = resolveRef(variant.$ref as string, schemaComponents); + if (resolved && resolved.type && resolved.type !== "null") { + return { type: resolved.type as string, format: (resolved.format ?? prop.format) as string | undefined }; + } + } + if (variant.type && variant.type !== "null") { + return { type: variant.type as string, format: (variant.format ?? prop.format) as string | undefined }; + } + } + } + + // allOf — merge referenced schemas + const allOf = prop.allOf as Array> | undefined; + if (allOf && Array.isArray(allOf) && schemaComponents) { + for (const item of allOf) { + if (item.$ref && typeof item.$ref === "string") { + const resolved = resolveRef(item.$ref as string, schemaComponents); + if (resolved && resolved.type) { + return { type: resolved.type as string, format: (resolved.format ?? prop.format) as string | undefined }; + } + } + if (item.type) { + return { type: item.type as string, format: (item.format ?? prop.format) as string | undefined }; + } + } + } + + // $ref at top level + if (prop.$ref && typeof prop.$ref === "string" && schemaComponents) { + const resolved = resolveRef(prop.$ref as string, schemaComponents); + if (resolved && resolved.type) { + return { type: resolved.type as string, format: (resolved.format ?? prop.format) as string | undefined }; + } + } + + return {}; +} + /** * Convert OpenAPI schema property to ModelParameter */ @@ -229,55 +292,80 @@ function convertSchemaProperty( return null; } - // Determine type and extract enum from allOf/$ref if present + // Determine type and extract enum from allOf/$ref/anyOf/oneOf if present let type: ModelParameter["type"] = "string"; let enumValues: unknown[] | undefined; let resolvedDefault: unknown; let resolvedDescription: string | undefined; - const schemaType = prop.type as string | undefined; - const allOf = prop.allOf as Array> | undefined; + // Use resolvePropertyType() to handle anyOf/oneOf/allOf/$ref patterns + const resolved = resolvePropertyType(prop, schemaComponents); + const effectiveType = resolved.type; - if (schemaType === "integer") { + if (effectiveType === "integer") { type = "integer"; - } else if (schemaType === "number") { + } else if (effectiveType === "number") { type = "number"; - } else if (schemaType === "boolean") { + } else if (effectiveType === "boolean") { type = "boolean"; - } else if (schemaType === "array") { + } else if (effectiveType === "array") { type = "array"; - } else if (allOf && allOf.length > 0 && schemaComponents) { - // Handle allOf with $ref - resolve references and extract enum/type + } + + // Extract enum/default/description from allOf with $ref + const allOf = prop.allOf as Array> | undefined; + if (allOf && allOf.length > 0 && schemaComponents) { for (const item of allOf) { const itemRef = item.$ref as string | undefined; if (itemRef) { - const resolved = resolveRef(itemRef, schemaComponents); - if (resolved) { - // Extract type from resolved schema - if (resolved.type === "integer") type = "integer"; - else if (resolved.type === "number") type = "number"; - else if (resolved.type === "boolean") type = "boolean"; - - // Extract enum from resolved schema - if (Array.isArray(resolved.enum)) { - enumValues = resolved.enum; + const refResolved = resolveRef(itemRef, schemaComponents); + if (refResolved) { + if (Array.isArray(refResolved.enum)) { + enumValues = refResolved.enum; } - // Extract default from resolved schema - if (resolved.default !== undefined && resolvedDefault === undefined) { - resolvedDefault = resolved.default; + if (refResolved.default !== undefined && resolvedDefault === undefined) { + resolvedDefault = refResolved.default; } - // Extract description from resolved schema - if (resolved.description && !resolvedDescription) { - resolvedDescription = resolved.description as string; + if (refResolved.description && !resolvedDescription) { + resolvedDescription = refResolved.description as string; } } } else if (Array.isArray(item.enum)) { - // Direct enum in allOf item enumValues = item.enum; } } } + // Extract enum/default/description from anyOf/oneOf variants + const variants = (prop.anyOf ?? prop.oneOf) as Array> | undefined; + if (variants && Array.isArray(variants)) { + for (const variant of variants) { + if (variant.type === "null") continue; + // Resolve $ref inside variant + if (variant.$ref && typeof variant.$ref === "string" && schemaComponents) { + const refResolved = resolveRef(variant.$ref as string, schemaComponents); + if (refResolved) { + if (Array.isArray(refResolved.enum) && !enumValues) { + enumValues = refResolved.enum; + } + if (refResolved.default !== undefined && resolvedDefault === undefined) { + resolvedDefault = refResolved.default; + } + if (refResolved.description && !resolvedDescription) { + resolvedDescription = refResolved.description as string; + } + } + } else { + if (Array.isArray(variant.enum) && !enumValues) { + enumValues = variant.enum; + } + if (variant.default !== undefined && resolvedDefault === undefined) { + resolvedDefault = variant.default; + } + } + } + } + const parameter: ModelParameter = { name, type, @@ -439,14 +527,15 @@ function extractParametersFromSchema( for (const [name, prop] of Object.entries(properties)) { // Check if this is a connectable input (image or text) // Pass both name AND prop to check schema type, not just name - if (isImageInput(name, prop)) { + if (isImageInput(name, prop, schemaComponents)) { + const resolvedType = resolvePropertyType(prop, schemaComponents).type; inputs.push({ name, type: "image", required: required.includes(name), label: toLabel(name), description: prop.description as string | undefined, - isArray: prop.type === "array", + isArray: resolvedType === "array", }); continue; } diff --git a/src/app/api/workflow-images/__tests__/route.test.ts b/src/app/api/workflow-images/__tests__/route.test.ts index a5e34b37..2adba681 100644 --- a/src/app/api/workflow-images/__tests__/route.test.ts +++ b/src/app/api/workflow-images/__tests__/route.test.ts @@ -80,5 +80,53 @@ describe("/api/workflow-images route", () => { expect(mockMkdir).toHaveBeenCalledWith("/test/new-workflow", { recursive: true }); expect(mockMkdir).toHaveBeenCalledWith("/test/new-workflow/inputs", { recursive: true }); }); + + it("should reject path traversal attempts", async () => { + const request = createMockPostRequest({ + workflowPath: "/test/../etc/passwd", + imageId: "img_123", + folder: "inputs", + imageData: "data:image/png;base64,aGVsbG8=", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe("Path contains traversal sequences"); + }); + + it("should reject non-absolute paths", async () => { + const request = createMockPostRequest({ + workflowPath: "relative/path", + imageId: "img_123", + folder: "inputs", + imageData: "data:image/png;base64,aGVsbG8=", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe("Path must be absolute"); + }); + + it("should reject dangerous system paths", async () => { + const request = createMockPostRequest({ + workflowPath: "/etc/workflows", + imageId: "img_123", + folder: "inputs", + imageData: "data:image/png;base64,aGVsbG8=", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe("Access to /etc is not allowed"); + }); }); }); diff --git a/src/app/api/workflow-images/route.ts b/src/app/api/workflow-images/route.ts index 81902831..8b3bda19 100644 --- a/src/app/api/workflow-images/route.ts +++ b/src/app/api/workflow-images/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import * as fs from "fs/promises"; import * as path from "path"; import { logger } from "@/utils/logger"; +import { validateWorkflowPath } from "@/utils/pathValidation"; export const maxDuration = 300; // 5 minute timeout for large image operations @@ -62,6 +63,19 @@ export async function POST(request: NextRequest) { ); } + // Validate path to prevent traversal attacks + const pathValidation = validateWorkflowPath(workflowPath); + if (!pathValidation.valid) { + logger.warn('file.error', 'Workflow image save failed: invalid path', { + workflowPath, + error: pathValidation.error, + }); + return NextResponse.json( + { success: false, error: pathValidation.error }, + { status: 400 } + ); + } + // Validate workflow directory exists, or create it if missing try { const stats = await fs.stat(workflowPath); @@ -75,6 +89,23 @@ export async function POST(request: NextRequest) { ); } } catch (dirError) { + const err = dirError as NodeJS.ErrnoException; + const isNotFound = + err?.code === "ENOENT" || + (typeof err?.message === "string" && + (err.message.includes("ENOENT") || err.message.includes("no such file or directory"))); + + if (!isNotFound) { + logger.warn('file.error', 'Workflow image save failed: directory validation error', { + workflowPath, + error: dirError instanceof Error ? dirError.message : 'Unknown error', + }); + return NextResponse.json( + { success: false, error: "Directory validation failed" }, + { status: 400 } + ); + } + try { await fs.mkdir(workflowPath, { recursive: true }); logger.info('file.save', 'Created workflow directory for image save', { @@ -176,6 +207,19 @@ export async function GET(request: NextRequest) { } try { + // Validate path to prevent traversal attacks + const pathValidation = validateWorkflowPath(workflowPath); + if (!pathValidation.valid) { + logger.warn('file.error', 'Workflow image load failed: invalid path', { + workflowPath, + error: pathValidation.error, + }); + return NextResponse.json( + { success: false, error: pathValidation.error }, + { status: 400 } + ); + } + // Sanitize imageId to prevent path traversal const safeImageId = path.basename(imageId); if (safeImageId !== imageId || imageId.includes('..')) { diff --git a/src/app/api/workflow/__tests__/route.test.ts b/src/app/api/workflow/__tests__/route.test.ts index d61d18ea..28427fad 100644 --- a/src/app/api/workflow/__tests__/route.test.ts +++ b/src/app/api/workflow/__tests__/route.test.ts @@ -249,6 +249,51 @@ describe("/api/workflow route", () => { expect(data.success).toBe(false); expect(data.error).toBe("Disk full"); }); + + it("should reject path traversal attempts", async () => { + const request = createMockPostRequest({ + directoryPath: "/test/../etc/passwd", + filename: "workflow", + workflow: { nodes: [], edges: [] }, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe("Path contains traversal sequences"); + }); + + it("should reject non-absolute paths", async () => { + const request = createMockPostRequest({ + directoryPath: "relative/path", + filename: "workflow", + workflow: { nodes: [], edges: [] }, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe("Path must be absolute"); + }); + + it("should reject dangerous system paths", async () => { + const request = createMockPostRequest({ + directoryPath: "/etc/workflows", + filename: "workflow", + workflow: { nodes: [], edges: [] }, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe("Access to /etc is not allowed"); + }); }); describe("GET - Validate directory", () => { @@ -301,5 +346,25 @@ describe("/api/workflow route", () => { expect(data.success).toBe(false); expect(data.error).toBe("Path parameter required"); }); + + it("should reject path traversal attempts in GET", async () => { + const request = createMockGetRequest({ path: "/test/../etc" }); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe("Path contains traversal sequences"); + }); + + it("should reject non-absolute paths in GET", async () => { + const request = createMockGetRequest({ path: "relative/path" }); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe("Path must be absolute"); + }); }); }); diff --git a/src/app/api/workflow/route.ts b/src/app/api/workflow/route.ts index b95b303e..ce2e83c8 100644 --- a/src/app/api/workflow/route.ts +++ b/src/app/api/workflow/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import * as fs from "fs/promises"; import * as path from "path"; import { logger } from "@/utils/logger"; +import { validateWorkflowPath } from "@/utils/pathValidation"; export const maxDuration = 300; // 5 minute timeout for large workflow files @@ -35,6 +36,19 @@ export async function POST(request: NextRequest) { ); } + // Validate path to prevent traversal attacks + const pathValidation = validateWorkflowPath(directoryPath); + if (!pathValidation.valid) { + logger.warn('file.error', 'Workflow save failed: invalid path', { + directoryPath, + error: pathValidation.error, + }); + return NextResponse.json( + { success: false, error: pathValidation.error }, + { status: 400 } + ); + } + // Ensure project directory exists (supports saving into new subfolders) try { const stats = await fs.stat(directoryPath); @@ -74,7 +88,7 @@ export async function POST(request: NextRequest) { }); return NextResponse.json( { success: false, error: "Could not create directory" }, - { status: 400 } + { status: 500 } ); } } @@ -143,6 +157,19 @@ export async function GET(request: NextRequest) { ); } + // Validate path to prevent traversal attacks + const pathValidation = validateWorkflowPath(directoryPath); + if (!pathValidation.valid) { + logger.warn('file.error', 'Directory validation failed: invalid path', { + directoryPath, + error: pathValidation.error, + }); + return NextResponse.json( + { success: false, error: pathValidation.error }, + { status: 400 } + ); + } + try { const stats = await fs.stat(directoryPath); const isDirectory = stats.isDirectory(); diff --git a/src/components/KeyboardShortcutsDialog.tsx b/src/components/KeyboardShortcutsDialog.tsx index 9fea1570..4333219a 100644 --- a/src/components/KeyboardShortcutsDialog.tsx +++ b/src/components/KeyboardShortcutsDialog.tsx @@ -34,6 +34,8 @@ const shortcutGroups: ShortcutGroup[] = [ { keys: ["Shift", "V"], description: "Add Generate Video node" }, { keys: ["Shift", "L"], description: "Add LLM Text node" }, { keys: ["Shift", "A"], description: "Add Annotation node" }, + { keys: ["Shift", "T"], description: "Add Audio node" }, + { keys: ["Shift", "R"], description: "Add Array node" }, ], }, { diff --git a/src/components/__tests__/OutputNode.test.tsx b/src/components/__tests__/OutputNode.test.tsx index ed0cf507..7260f34b 100644 --- a/src/components/__tests__/OutputNode.test.tsx +++ b/src/components/__tests__/OutputNode.test.tsx @@ -39,6 +39,9 @@ describe("OutputNode", () => { mockUseWorkflowStore.mockImplementation((selector) => { const state = { updateNodeData: mockUpdateNodeData, + regenerateNode: vi.fn(), + edges: [], + isRunning: false, currentNodeIds: [], groups: {}, nodes: [], diff --git a/src/components/nodes/ArrayNode.tsx b/src/components/nodes/ArrayNode.tsx index 8f591fa2..a01052b3 100644 --- a/src/components/nodes/ArrayNode.tsx +++ b/src/components/nodes/ArrayNode.tsx @@ -150,8 +150,8 @@ export function ArrayNode({ id, data, selected }: NodeProps) { // Reset selection if it no longer points to a valid parsed item. useEffect(() => { - const selected = nodeData.selectedOutputIndex; - if (selected !== null && (selected < 0 || selected >= previewItems.length)) { + const currentSelection = nodeData.selectedOutputIndex; + if (currentSelection !== null && (currentSelection < 0 || currentSelection >= previewItems.length)) { updateNodeData(id, { selectedOutputIndex: null }); } }, [id, nodeData.selectedOutputIndex, previewItems.length, updateNodeData]); @@ -163,9 +163,11 @@ export function ArrayNode({ id, data, selected }: NodeProps) { const newHeight = Math.max(baseHeight, 270 + previewItems.length * perItemHeight); setNodes((nodes) => - nodes.map((node) => - node.id === id ? { ...node, style: { ...node.style, height: newHeight } } : node - ) + nodes.map((node) => { + if (node.id !== id) return node; + if ((node.style?.height as number) === newHeight) return node; + return { ...node, style: { ...node.style, height: newHeight } }; + }) ); }, [id, previewItems.length, setNodes]); diff --git a/src/components/nodes/OutputNode.tsx b/src/components/nodes/OutputNode.tsx index 463bf13b..83bc1c97 100644 --- a/src/components/nodes/OutputNode.tsx +++ b/src/components/nodes/OutputNode.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useState, useMemo } from "react"; +import { useCallback, useState, useMemo, useEffect, useRef } from "react"; import { Handle, Position, NodeProps, Node } from "@xyflow/react"; import { BaseNode } from "./BaseNode"; import { useCommentNavigation } from "@/hooks/useCommentNavigation"; @@ -14,7 +14,13 @@ export function OutputNode({ id, data, selected }: NodeProps) { const nodeData = data; const commentNavigation = useCommentNavigation(id); const updateNodeData = useWorkflowStore((state) => state.updateNodeData); + const regenerateNode = useWorkflowStore((state) => state.regenerateNode); + const connectedEdgeCount = useWorkflowStore( + (state) => state.edges.filter((edge) => edge.target === id).length + ); + const isRunning = useWorkflowStore((state) => state.isRunning); const [showLightbox, setShowLightbox] = useState(false); + const previousEdgeCountRef = useRef(null); // Determine if content is audio const isAudio = useMemo(() => { @@ -43,6 +49,24 @@ export function OutputNode({ id, data, selected }: NodeProps) { const videoBlobUrl = useVideoBlobUrl(isVideo ? contentSrc ?? null : null); + // Auto-trigger execution when a new connection is made + useEffect(() => { + if (previousEdgeCountRef.current === null) { + // First run — just record the baseline, don't trigger + previousEdgeCountRef.current = connectedEdgeCount; + return; + } + if (connectedEdgeCount > previousEdgeCountRef.current) { + regenerateNode(id); + } + previousEdgeCountRef.current = connectedEdgeCount; + }, [connectedEdgeCount, id, regenerateNode]); + + // Handle Run button click + const handleRun = useCallback(() => { + regenerateNode(id); + }, [id, regenerateNode]); + const handleDownload = useCallback(async () => { if (!contentSrc) return; @@ -91,6 +115,8 @@ export function OutputNode({ id, data, selected }: NodeProps) { comment={nodeData.comment} onCustomTitleChange={(title) => updateNodeData(id, { customTitle: title || undefined })} onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })} + onRun={handleRun} + isExecuting={isRunning} selected={selected} className="min-w-[200px]" commentNavigation={commentNavigation ?? undefined} diff --git a/src/hooks/useVideoBlobUrl.ts b/src/hooks/useVideoBlobUrl.ts index 7ca8de87..c9e45109 100644 --- a/src/hooks/useVideoBlobUrl.ts +++ b/src/hooks/useVideoBlobUrl.ts @@ -17,13 +17,8 @@ import { useEffect, useRef, useState } from "react"; export function useVideoBlobUrl(videoUrl: string | null): string | null { const [blobUrl, setBlobUrl] = useState(null); const prevBlobUrlRef = useRef(null); - const prevInputRef = useRef(null); useEffect(() => { - // Input unchanged — skip - if (videoUrl === prevInputRef.current) return; - prevInputRef.current = videoUrl; - // Revoke previous blob URL if (prevBlobUrlRef.current) { URL.revokeObjectURL(prevBlobUrlRef.current); @@ -47,11 +42,13 @@ export function useVideoBlobUrl(videoUrl: string | null): string | null { setBlobUrl(videoUrl); let cancelled = false; + let createdUrl: string | null = null; fetch(videoUrl) .then((r) => r.blob()) .then((blob) => { if (cancelled) return; const url = URL.createObjectURL(blob); + createdUrl = url; prevBlobUrlRef.current = url; setBlobUrl(url); }) @@ -61,6 +58,10 @@ export function useVideoBlobUrl(videoUrl: string | null): string | null { return () => { cancelled = true; + // If a blob URL was created after we decided to cancel, revoke it + if (createdUrl && createdUrl !== prevBlobUrlRef.current) { + URL.revokeObjectURL(createdUrl); + } }; } diff --git a/src/lib/quickstart/validation.ts b/src/lib/quickstart/validation.ts index 64a5683f..4afce636 100644 --- a/src/lib/quickstart/validation.ts +++ b/src/lib/quickstart/validation.ts @@ -13,9 +13,11 @@ interface ValidationResult { const VALID_NODE_TYPES: NodeType[] = [ "imageInput", + "audioInput", "annotation", "prompt", "array", + "promptConstructor", "nanoBanana", "generateVideo", "generate3d", @@ -23,9 +25,16 @@ const VALID_NODE_TYPES: NodeType[] = [ "llmGenerate", "splitGrid", "output", + "outputGallery", + "imageCompare", + "videoStitch", + "easeCurve", + "videoTrim", + "videoFrameGrab", + "glbViewer", ]; -const VALID_HANDLE_TYPES = ["image", "text", "audio", "reference"]; +const VALID_HANDLE_TYPES = ["image", "text", "audio", "video", "easeCurve", "3d", "reference"]; // Default node dimensions const DEFAULT_DIMENSIONS: Record = { diff --git a/src/store/execution/videoProcessingExecutors.ts b/src/store/execution/videoProcessingExecutors.ts index c6af158d..edb20225 100644 --- a/src/store/execution/videoProcessingExecutors.ts +++ b/src/store/execution/videoProcessingExecutors.ts @@ -55,7 +55,6 @@ export async function executeVideoStitch(ctx: NodeExecutionContext): Promise 0 && inputs.audio[0]) { - console.log('[VideoStitch] Audio input detected, preparing audio...'); const { prepareAudioAsync } = await import("@/hooks/useAudioMixing"); const audioUrl = inputs.audio[0]; const audioResponse = await fetch(audioUrl); @@ -69,14 +68,6 @@ export async function executeVideoStitch(ctx: NodeExecutionContext): Promise { expect(result.text).toBe("three"); }); + it("should wrap out-of-bounds arrayItemIndex via modulo", () => { + const nodes = [ + makeNode("arr", "array", { outputText: "[\"one\",\"two\"]", outputItems: ["one", "two"] }), + makeNode("gen", "nanoBanana"), + ]; + const edges = [{ + id: "arr-gen-stale", + source: "arr", + target: "gen", + sourceHandle: "text", + targetHandle: "text", + data: { arrayItemIndex: 3 }, // index 3 on 2-item array wraps to index 1 + }] as WorkflowEdge[]; + + const result = getConnectedInputsPure("gen", nodes, edges); + expect(result.text).toBe("two"); + }); + + it("should return null for out-of-bounds arrayItemIndex on empty array", () => { + const nodes = [ + makeNode("arr", "array", { outputText: "[]", outputItems: [] }), + makeNode("gen", "nanoBanana"), + ]; + const edges = [{ + id: "arr-gen-empty", + source: "arr", + target: "gen", + sourceHandle: "text", + targetHandle: "text", + data: { arrayItemIndex: 0 }, + }] as WorkflowEdge[]; + + const result = getConnectedInputsPure("gen", nodes, edges); + expect(result.text).toBeNull(); + }); + + it("should wrap large arrayItemIndex correctly", () => { + const nodes = [ + makeNode("arr", "array", { outputText: "[\"a\",\"b\",\"c\"]", outputItems: ["a", "b", "c"] }), + makeNode("gen", "nanoBanana"), + ]; + const edges = [{ + id: "arr-gen-large", + source: "arr", + target: "gen", + sourceHandle: "text", + targetHandle: "text", + data: { arrayItemIndex: 7 }, // 7 % 3 = 1 + }] as WorkflowEdge[]; + + const result = getConnectedInputsPure("gen", nodes, edges); + expect(result.text).toBe("b"); + }); + it("should extract text from promptConstructor outputText", () => { const nodes = [ makeNode("pc", "promptConstructor", { outputText: "constructed", template: "tmpl" }), diff --git a/src/store/utils/connectedInputs.ts b/src/store/utils/connectedInputs.ts index bd7c0917..959a3051 100644 --- a/src/store/utils/connectedInputs.ts +++ b/src/store/utils/connectedInputs.ts @@ -91,7 +91,10 @@ function getSourceOutput( const arrayData = sourceNode.data as ArrayNodeData; const dataIndex = edgeData?.arrayItemIndex; if (typeof dataIndex === "number" && Number.isInteger(dataIndex) && dataIndex >= 0) { - return { type: "text", value: arrayData.outputItems[dataIndex] ?? null }; + const items = arrayData.outputItems; + if (items.length === 0) return { type: "text", value: null }; + const clampedIndex = dataIndex % items.length; + return { type: "text", value: items[clampedIndex] ?? null }; } if (sourceHandle?.startsWith("text-")) { const index = Number(sourceHandle.replace("text-", "")); diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 8df3f97e..9d1c9590 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -1,4 +1,4 @@ -import { create } from "zustand"; +import { create, StateCreator } from "zustand"; import { useShallow } from "zustand/shallow"; import { Connection, @@ -333,6 +333,7 @@ interface WorkflowStore { // Canvas navigation settings actions updateCanvasNavigationSettings: (settings: CanvasNavigationSettings) => void; + } let nodeIdCounter = 0; @@ -369,7 +370,7 @@ async function waitForPendingImageSyncs(timeout: number = 60000): Promise export { generateWorkflowId, saveGenerateImageDefaults, saveNanoBananaDefaults } from "./utils/localStorage"; export { GROUP_COLORS } from "./utils/nodeDefaults"; -export const useWorkflowStore = create((set, get) => ({ +const workflowStoreImpl: StateCreator = (set, get) => ({ nodes: [], edges: [], edgeStyle: "curved" as EdgeStyle, @@ -1167,6 +1168,11 @@ export const useWorkflowStore = create((set, get) => ({ set({ isRunning: false, currentNodeIds: [] }); await logger.endSession(); return; + } else if (node.type === "output") { + await executeOutput(executionCtx); + set({ isRunning: false, currentNodeIds: [] }); + await logger.endSession(); + return; } // After regeneration, execute directly connected downstream consumer nodes @@ -1535,8 +1541,8 @@ export const useWorkflowStore = create((set, get) => ({ const configs = loadSaveConfigs(); const savedConfig = workflow.id ? configs[workflow.id] : null; - // Determine the workflow directory path (passed in, from saved config, or embedded in workflow) - const directoryPath = workflowPath || savedConfig?.directoryPath || workflow.directoryPath; + // Determine the workflow directory path (passed in, from saved config, or embedded in legacy workflow JSON) + const directoryPath = workflowPath || savedConfig?.directoryPath || workflow.directoryPath || null; // Hydrate images if we have a directory path and the workflow has image refs let hydratedWorkflow = workflow; @@ -1731,7 +1737,6 @@ export const useWorkflowStore = create((set, get) => ({ version: 1, id: workflowId, name: workflowName, - directoryPath: saveDirectoryPath, nodes: currentNodes, edges, edgeStyle, @@ -1840,7 +1845,7 @@ export const useWorkflowStore = create((set, get) => ({ return false; } - const { saveDirectoryPath, workflowId: prevId, workflowName: prevName } = get(); + const { saveDirectoryPath, workflowId: prevId, workflowName: prevName, hasUnsavedChanges: prevUnsaved } = get(); if (!saveDirectoryPath) { return false; } @@ -1856,7 +1861,7 @@ export const useWorkflowStore = create((set, get) => ({ const success = await get().saveToFile(); if (!success) { // Rollback to previous identity on failure - set({ workflowId: prevId, workflowName: prevName }); + set({ workflowId: prevId, workflowName: prevName, hasUnsavedChanges: prevUnsaved }); } return success; }, @@ -2111,7 +2116,10 @@ export const useWorkflowStore = create((set, get) => ({ set({ canvasNavigationSettings: settings }); saveCanvasNavigationSettings(settings); }, -})); + +}); + +export const useWorkflowStore = create()(workflowStoreImpl); /** * Stable hook for provider API keys. diff --git a/src/utils/__tests__/arrayParser.test.ts b/src/utils/__tests__/arrayParser.test.ts index a19be8d9..27fac5a6 100644 --- a/src/utils/__tests__/arrayParser.test.ts +++ b/src/utils/__tests__/arrayParser.test.ts @@ -80,6 +80,21 @@ describe("parseTextToArray", () => { } }); + it("rejects deeply nested quantifier patterns that bypass simple regex checks", () => { + const deeplyNestedPatterns = ["((a+))+", "((a*)+)", "(((x+))+)+"]; + for (const pattern of deeplyNestedPatterns) { + const result = parseTextToArray("test", { + splitMode: "regex", + delimiter: "*", + regexPattern: pattern, + trimItems: true, + removeEmpty: true, + }); + expect(result.items).toEqual([]); + expect(result.error).toContain("nested quantifiers"); + } + }); + it("allows safe regex patterns", () => { const safePatterns = ["\\d+", "[,;]+", "\\s+", "(a|b)"]; for (const pattern of safePatterns) { diff --git a/src/utils/arrayParser.ts b/src/utils/arrayParser.ts index 8e16ef8c..66685a7e 100644 --- a/src/utils/arrayParser.ts +++ b/src/utils/arrayParser.ts @@ -18,14 +18,42 @@ const MAX_REGEX_INPUT_LENGTH = 100_000; /** * Detect regex patterns prone to catastrophic backtracking (ReDoS). - * Rejects nested quantifiers like (a+)+, (a*)+, (a+)*, etc. + * Rejects nested quantifiers like (a+)+, (a*)+, ((a+))+, etc. + * Uses character-by-character parsing to track nesting depth. */ function isUnsafePattern(pattern: string): boolean { - // Strip /pattern/flags wrapper to inspect the body const slashFormat = pattern.match(/^\/(.+)\/[a-z]*$/i); const body = slashFormat ? slashFormat[1] : pattern; - // Nested quantifiers: a group containing a quantifier, followed by another quantifier - return /\([^)]*[+*]\)[+*{]/.test(body); + + // Track groups: when we see ')' followed by a quantifier, + // check if anything inside that group also had a quantifier. + let depth = 0; + const quantifierAtDepth: boolean[] = []; + + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if (ch === '\\') { i++; continue; } // skip escaped chars + if (ch === '(') { + depth++; + quantifierAtDepth[depth] = false; + } else if (ch === ')') { + const hadQuantifier = quantifierAtDepth[depth] || false; + depth = Math.max(0, depth - 1); + // Check if this closing paren is followed by a quantifier + const next = body[i + 1]; + if (next === '+' || next === '*' || next === '{') { + if (hadQuantifier) return true; // nested quantifier! + // Mark parent depth as having a quantifier + quantifierAtDepth[depth] = true; + } else if (hadQuantifier) { + // Group contained quantifier but isn't followed by one - propagate to parent + quantifierAtDepth[depth] = true; + } + } else if ((ch === '+' || ch === '*') && depth > 0) { + quantifierAtDepth[depth] = true; + } + } + return false; } function parseRegexPattern(pattern: string): RegExp { diff --git a/src/utils/nodeDimensions.ts b/src/utils/nodeDimensions.ts index c28a355e..89f6b3aa 100644 --- a/src/utils/nodeDimensions.ts +++ b/src/utils/nodeDimensions.ts @@ -16,20 +16,29 @@ export function getImageDimensions( return; } + let resolved = false; const img = new Image(); const cleanup = () => { img.onload = null; img.onerror = null; img.src = ""; }; - img.onload = () => { - const dims = { width: img.naturalWidth, height: img.naturalHeight }; + const safeResolve = (value: { width: number; height: number } | null) => { + if (resolved) return; + resolved = true; cleanup(); - resolve(dims); + resolve(value); + }; + + const timeout = setTimeout(() => safeResolve(null), 10_000); + + img.onload = () => { + clearTimeout(timeout); + safeResolve({ width: img.naturalWidth, height: img.naturalHeight }); }; img.onerror = () => { - cleanup(); - resolve(null); + clearTimeout(timeout); + safeResolve(null); }; img.src = base64DataUrl; }); diff --git a/src/utils/pathValidation.ts b/src/utils/pathValidation.ts new file mode 100644 index 00000000..42155778 --- /dev/null +++ b/src/utils/pathValidation.ts @@ -0,0 +1,59 @@ +import * as path from "path"; + +/** + * Validates a workflow directory path to prevent path traversal attacks. + * Ensures the path is absolute, doesn't contain traversal sequences, + * and doesn't point to dangerous system directories. + */ +export function validateWorkflowPath(inputPath: string): { + valid: boolean; + resolved: string; + error?: string; +} { + // Must be an absolute path + if (!path.isAbsolute(inputPath)) { + return { + valid: false, + resolved: inputPath, + error: "Path must be absolute", + }; + } + + // Resolve the path and ensure it equals the input (catches .. traversal) + const resolved = path.resolve(inputPath); + if (resolved !== inputPath) { + return { + valid: false, + resolved, + error: "Path contains traversal sequences", + }; + } + + // Block known dangerous system directories + const dangerousPrefixes = [ + "/etc", + "/usr", + "/bin", + "/sbin", + "/sys", + "/proc", + "/var/run", + "/System", + "/Library", + ]; + + for (const prefix of dangerousPrefixes) { + if (resolved.startsWith(prefix + "/") || resolved === prefix) { + return { + valid: false, + resolved, + error: `Access to ${prefix} is not allowed`, + }; + } + } + + return { + valid: true, + resolved, + }; +}