diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index ebd9c374..4967a13a 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -2019,8 +2019,15 @@ async function generateWithWaveSpeed( console.log(`[API:${requestId}] WaveSpeed submit response:`, JSON.stringify(submitResult).substring(0, 500)); const taskId = submitResult.data?.id || submitResult.id; - // Use the polling URL provided by the API if available - const providedPollUrl = submitResult.data?.urls?.get; + // Use the polling URL provided by the API if available, with SSRF validation + let providedPollUrl: string | undefined = submitResult.data?.urls?.get; + if (providedPollUrl) { + const pollUrlCheck = validateMediaUrl(providedPollUrl); + if (!pollUrlCheck.valid || !providedPollUrl.startsWith('https://api.wavespeed.ai')) { + console.warn(`[API:${requestId}] WaveSpeed provided invalid poll URL: ${providedPollUrl} — falling back to constructed URL`); + providedPollUrl = undefined; + } + } if (!taskId) { console.error(`[API:${requestId}] No task ID in WaveSpeed submit response`); diff --git a/src/lib/providers/wavespeed.ts b/src/lib/providers/wavespeed.ts index c330be45..b4a8265f 100644 --- a/src/lib/providers/wavespeed.ts +++ b/src/lib/providers/wavespeed.ts @@ -285,6 +285,11 @@ const wavespeedProvider: ProviderInterface = { }; } + // Validate taskId to prevent path traversal in poll URL + if (/[^a-zA-Z0-9\-_]/.test(taskId)) { + return { success: false, error: `WaveSpeed: Invalid task ID format` }; + } + // Poll for completion const maxWaitTime = 5 * 60 * 1000; // 5 minutes const pollInterval = 1000; // 1 second diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 34aeb9f7..f7acc5b0 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -414,10 +414,20 @@ function chunk(array: T[], size: number): T[][] { } // Clear all imageRefs from nodes (used when saving to a different directory) +/** Revoke a blob URL if the value is one, to free the underlying memory. */ +function revokeBlobUrl(url: string | null | undefined): void { + if (url && url.startsWith('blob:')) { + try { URL.revokeObjectURL(url); } catch { /* ignore */ } + } +} + function clearNodeImageRefs(nodes: WorkflowNode[]): WorkflowNode[] { return nodes.map(node => { const data = { ...node.data } as Record; + // Revoke blob URLs for video outputs before clearing + revokeBlobUrl(data.outputVideo as string | undefined); + // Clear all ref fields regardless of node type delete data.imageRef; delete data.sourceImageRef; @@ -1113,24 +1123,10 @@ export const useWorkflowStore = create((set, get) => ({ nodeId: node.id, nodeType: node.type, }); - set({ pausedAtNodeId: node.id, isRunning: false, currentNodeIds: [], _abortController: null }); + set({ pausedAtNodeId: node.id }); useToast.getState().show("Workflow paused - click Run to continue", "warning"); - // Save logs to server (on pause) - const session = logger.getCurrentSession(); - if (session) { - session.endTime = new Date().toISOString(); - fetch('/api/logs', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ session }), - }).catch((err) => { - console.error('Failed to save log session:', err); - }); - } - - await logger.endSession(); - // Signal to stop the entire workflow + // Signal to stop the entire workflow — outer loop handles isRunning/session cleanup abortController.abort(); return; } @@ -1272,9 +1268,7 @@ export const useWorkflowStore = create((set, get) => ({ status: "error", error: "Missing text input", }); - set({ isRunning: false, currentNodeIds: [] }); - await logger.endSession(); - return; + throw new Error("Missing text input"); } updateNodeData(node.id, { @@ -1373,9 +1367,7 @@ export const useWorkflowStore = create((set, get) => ({ status: "error", error: errorMessage, }); - set({ isRunning: false, currentNodeIds: [] }); - await logger.endSession(); - return; + throw new Error(errorMessage); } const result = await response.json(); @@ -1451,9 +1443,7 @@ export const useWorkflowStore = create((set, get) => ({ status: "error", error: result.error || "Generation failed", }); - set({ isRunning: false, currentNodeIds: [] }); - await logger.endSession(); - return; + throw new Error(result.error || "Generation failed"); } } catch (error) { let errorMessage = "Generation failed"; @@ -1479,9 +1469,8 @@ export const useWorkflowStore = create((set, get) => ({ status: "error", error: errorMessage, }); - set({ isRunning: false, currentNodeIds: [] }); - await logger.endSession(); - return; + if (error instanceof DOMException && error.name === 'AbortError') throw error; + throw new Error(errorMessage); } break; } @@ -1499,9 +1488,7 @@ export const useWorkflowStore = create((set, get) => ({ status: "error", error: "Missing required inputs", }); - set({ isRunning: false, currentNodeIds: [] }); - await logger.endSession(); - return; + throw new Error("Missing required inputs"); } // Get fresh node data from store (not stale data from sorted array) @@ -1516,9 +1503,7 @@ export const useWorkflowStore = create((set, get) => ({ status: "error", error: "No model selected", }); - set({ isRunning: false, currentNodeIds: [] }); - await logger.endSession(); - return; + throw new Error("No model selected"); } updateNodeData(node.id, { @@ -1608,9 +1593,7 @@ export const useWorkflowStore = create((set, get) => ({ status: "error", error: errorMessage, }); - set({ isRunning: false, currentNodeIds: [] }); - await logger.endSession(); - return; + throw new Error(errorMessage); } const result = await response.json(); @@ -1693,9 +1676,7 @@ export const useWorkflowStore = create((set, get) => ({ status: "error", error: result.error || "Video generation failed", }); - set({ isRunning: false, currentNodeIds: [] }); - await logger.endSession(); - return; + throw new Error(result.error || "Video generation failed"); } } catch (error) { let errorMessage = "Video generation failed"; @@ -1719,9 +1700,8 @@ export const useWorkflowStore = create((set, get) => ({ status: "error", error: errorMessage, }); - set({ isRunning: false, currentNodeIds: [] }); - await logger.endSession(); - return; + if (error instanceof DOMException && error.name === 'AbortError') throw error; + throw new Error(errorMessage); } break; } @@ -1741,9 +1721,7 @@ export const useWorkflowStore = create((set, get) => ({ status: "error", error: "Missing text input - connect a prompt node or set internal prompt", }); - set({ isRunning: false, currentNodeIds: [] }); - await logger.endSession(); - return; + throw new Error("Missing text input"); } updateNodeData(node.id, { @@ -1815,9 +1793,7 @@ export const useWorkflowStore = create((set, get) => ({ status: "error", error: errorMessage, }); - set({ isRunning: false, currentNodeIds: [] }); - await logger.endSession(); - return; + throw new Error(errorMessage); } const result = await response.json(); @@ -1837,9 +1813,7 @@ export const useWorkflowStore = create((set, get) => ({ status: "error", error: result.error || "LLM generation failed", }); - set({ isRunning: false, currentNodeIds: [] }); - await logger.endSession(); - return; + throw new Error(result.error || "LLM generation failed"); } } catch (error) { logger.error('node.error', 'llmGenerate node execution failed', { @@ -1849,9 +1823,8 @@ export const useWorkflowStore = create((set, get) => ({ status: "error", error: error instanceof Error ? error.message : "LLM generation failed", }); - set({ isRunning: false, currentNodeIds: [] }); - await logger.endSession(); - return; + if (error instanceof DOMException && error.name === 'AbortError') throw error; + throw error instanceof Error ? error : new Error("LLM generation failed"); } break; } @@ -1865,8 +1838,7 @@ export const useWorkflowStore = create((set, get) => ({ status: "error", error: "No input image connected", }); - set({ isRunning: false, currentNodeIds: [] }); - return; + throw new Error("No input image connected"); } const nodeData = node.data as SplitGridNodeData; @@ -1876,8 +1848,7 @@ export const useWorkflowStore = create((set, get) => ({ status: "error", error: "Node not configured - open settings first", }); - set({ isRunning: false, currentNodeIds: [] }); - return; + throw new Error("Node not configured - open settings first"); } updateNodeData(node.id, { @@ -1925,9 +1896,8 @@ export const useWorkflowStore = create((set, get) => ({ status: "error", error: error instanceof Error ? error.message : "Failed to split image", }); - set({ isRunning: false, currentNodeIds: [] }); - await logger.endSession(); - return; + if (error instanceof DOMException && error.name === 'AbortError') throw error; + throw error instanceof Error ? error : new Error("Failed to split image"); } break; } @@ -2090,6 +2060,9 @@ export const useWorkflowStore = create((set, get) => ({ } ); + // Revoke old blob URL before replacing + revokeBlobUrl((node.data as Record).outputVideo as string | undefined); + // Store output - blob URL for large files, data URL for small let outputVideo: string; if (outputBlob.size > 20 * 1024 * 1024) { @@ -2213,6 +2186,9 @@ export const useWorkflowStore = create((set, get) => ({ throw new Error("Speed curve processing returned no output"); } + // Revoke old blob URL before replacing + revokeBlobUrl((node.data as Record).outputVideo as string | undefined); + // Store output - blob URL for large files, data URL for small let outputVideo: string; if (outputBlob.size > 20 * 1024 * 1024) { @@ -3007,6 +2983,10 @@ export const useWorkflowStore = create((set, get) => ({ } ); + // Revoke old blob URL before replacing + const oldStitchData = get().nodes.find(n => n.id === nodeId)?.data as Record | undefined; + revokeBlobUrl(oldStitchData?.outputVideo as string | undefined); + let outputVideo: string; if (outputBlob.size > 20 * 1024 * 1024) { outputVideo = URL.createObjectURL(outputBlob); @@ -3124,6 +3104,10 @@ export const useWorkflowStore = create((set, get) => ({ throw new Error("Speed curve processing returned no output"); } + // Revoke old blob URL before replacing + const oldEaseData = get().nodes.find(n => n.id === nodeId)?.data as Record | undefined; + revokeBlobUrl(oldEaseData?.outputVideo as string | undefined); + let outputVideo: string; if (outputBlob.size > 20 * 1024 * 1024) { outputVideo = URL.createObjectURL(outputBlob);