Browse Source

fix: path traversal, media type detection, resource leaks, and UX improvements

- Add path traversal guard to workflow-images GET handler (matching POST handler)
- Preserve stored prompt in generateAudioExecutor when connected text is null
- Reset conflicting isVideo/isAudio flags in kie.ts content-type detection
- Fix Infinity aspect ratio causing zero-height canvas in VideoStitchNode
- Check response content-type before trusting isAudioModel in fal.ts
- Move audio feeding inside inner try block in useStitchVideos for proper cleanup
- Add 30s seek timeout to frame extraction in videoProcessingExecutors
- Surface file-open errors via toast in Generate3DNode instead of failing silently

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
e3a68f3e6c
  1. 11
      src/app/api/generate/providers/fal.ts
  2. 4
      src/app/api/generate/providers/kie.ts
  3. 11
      src/app/api/workflow-images/route.ts
  4. 12
      src/components/nodes/Generate3DNode.tsx
  5. 3
      src/components/nodes/VideoStitchNode.tsx
  6. 2
      src/hooks/useStitchVideos.ts
  7. 2
      src/store/execution/generateAudioExecutor.ts
  8. 14
      src/store/execution/videoProcessingExecutors.ts

11
src/app/api/generate/providers/fal.ts

@ -531,9 +531,12 @@ export async function generateWithFalQueue(
};
}
// For audio models, always base64 encode (audio files are small)
if (isAudioModel) {
const audioContentType = mediaResponse.headers.get("content-type") || "audio/mpeg";
// Detect actual media type from response content-type, falling back to model hints
const rawContentType = mediaResponse.headers.get("content-type") || "";
const isAudioResponse = rawContentType.startsWith("audio/") || (!rawContentType.startsWith("video/") && !rawContentType.startsWith("image/") && isAudioModel);
if (isAudioResponse) {
const audioContentType = rawContentType.startsWith("audio/") ? rawContentType : "audio/mpeg";
const audioBuffer = await mediaResponse.arrayBuffer();
const audioBase64 = Buffer.from(audioBuffer).toString("base64");
console.log(`[API:${requestId}] SUCCESS - Returning audio`);
@ -547,7 +550,7 @@ export async function generateWithFalQueue(
};
}
const contentType = mediaResponse.headers.get("content-type") || (isVideoModel ? "video/mp4" : "image/png");
const contentType = rawContentType || (isVideoModel ? "video/mp4" : "image/png");
const isVideo = contentType.startsWith("video/");
const mediaArrayBuffer = await mediaResponse.arrayBuffer();

4
src/app/api/generate/providers/kie.ts

@ -812,11 +812,13 @@ export async function generateWithKie(
const rawContentType = mediaResponse.headers.get("content-type") || "";
const isConcreteMedia = rawContentType.startsWith("audio/") || rawContentType.startsWith("video/") || rawContentType.startsWith("image/");
// Content-type wins; fall back to URL/capability detection only when ambiguous
// Content-type wins over URL-based detection; reset opposite flag to avoid conflicts
if (rawContentType.startsWith("video/")) {
isVideo = true;
isAudio = false;
} else if (rawContentType.startsWith("audio/")) {
isAudio = true;
isVideo = false;
} else if (!isConcreteMedia && !isVideo && !isAudio && isAudioModel) {
isAudio = true;
}

11
src/app/api/workflow-images/route.ts

@ -169,6 +169,15 @@ export async function GET(request: NextRequest) {
}
try {
// Sanitize imageId to prevent path traversal
const safeImageId = path.basename(imageId);
if (safeImageId !== imageId || imageId.includes('..')) {
return NextResponse.json(
{ success: false, error: "Invalid imageId" },
{ status: 400 }
);
}
// Validate workflow directory exists
try {
const stats = await fs.stat(workflowPath);
@ -202,7 +211,7 @@ export async function GET(request: NextRequest) {
// Check each folder and extension combination in order
for (const searchFolder of searchOrder) {
for (const ext of possibleExtensions) {
const filename = `${imageId}.${ext}`;
const filename = `${safeImageId}.${ext}`;
const candidatePath = path.join(searchFolder, filename);
try {
await fs.access(candidatePath);

12
src/components/nodes/Generate3DNode.tsx

@ -334,15 +334,23 @@ export function Generate3DNode({ id, data, selected }: NodeProps<Generate3DNodeT
<button
onClick={async (e) => {
e.stopPropagation();
if (!nodeData.savedFilePath) return;
if (!nodeData.savedFilePath) {
useToast.getState().show("No file path available", "error");
return;
}
try {
await fetch("/api/open-file", {
const res = await fetch("/api/open-file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filePath: nodeData.savedFilePath }),
});
if (!res.ok) {
const detail = await res.text().catch(() => `Status ${res.status}`);
useToast.getState().show("Failed to open file", "error", true, detail);
}
} catch (err) {
console.error("Failed to open file location:", err);
useToast.getState().show("Failed to open file location", "error");
}
}}
className="nodrag nopan text-[10px] text-neutral-400 hover:text-orange-300 truncate max-w-full cursor-pointer transition-colors flex items-center gap-1"

3
src/components/nodes/VideoStitchNode.tsx

@ -169,7 +169,8 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
const canvas = document.createElement("canvas");
const thumbWidth = 160;
const aspectRatio = video.videoWidth / video.videoHeight || 16 / 9;
const rawAspectRatio = video.videoHeight > 0 ? video.videoWidth / video.videoHeight : 0;
const aspectRatio = Number.isFinite(rawAspectRatio) && rawAspectRatio > 0 ? rawAspectRatio : 16 / 9;
canvas.width = thumbWidth;
canvas.height = Math.round(thumbWidth / aspectRatio);
const ctx = canvas.getContext("2d");

2
src/hooks/useStitchVideos.ts

@ -261,6 +261,7 @@ export async function stitchVideosAsync(
await output.start();
outputStarted = true;
try {
// Feed audio early so the muxer can interleave audio packets with video frames.
// Writing audio after all video causes broken interleaving (Discord won't play audio).
if (audioSource && pendingAudioBuffer) {
@ -273,7 +274,6 @@ export async function stitchVideosAsync(
pendingAudioBuffer = null;
}
try {
// Track the highest timestamp we've written to ensure monotonicity
// Start at -frameInterval so first frame can be at timestamp 0
const frameInterval = 1 / MAX_OUTPUT_FPS;

2
src/store/execution/generateAudioExecutor.ts

@ -73,7 +73,7 @@ export async function executeGenerateAudio(
}
updateNodeData(node.id, {
inputPrompt: text,
inputPrompt: text ?? nodeData.inputPrompt,
status: "loading",
error: null,
});

14
src/store/execution/videoProcessingExecutors.ts

@ -372,7 +372,14 @@ export async function executeVideoFrameGrab(ctx: NodeExecutionContext): Promise<
let blobUrl: string | null = null;
try {
const FRAME_EXTRACTION_TIMEOUT = 30_000; // 30 seconds
const outputImage = await new Promise<string>((resolve, reject) => {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
if (timeoutId) clearTimeout(timeoutId);
};
video.onloadedmetadata = () => {
// For "first" frame, seek to 0.001 (not exactly 0 to ensure a decoded frame)
// For "last" frame, seek to duration - small epsilon
@ -380,9 +387,15 @@ export async function executeVideoFrameGrab(ctx: NodeExecutionContext): Promise<
? 0.001
: Math.max(0, video.duration - 0.1);
video.currentTime = seekTime;
timeoutId = setTimeout(() => {
if (blobUrl) URL.revokeObjectURL(blobUrl);
reject(new Error("Frame extraction timed out"));
}, FRAME_EXTRACTION_TIMEOUT);
};
video.onseeked = () => {
cleanup();
try {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
@ -401,6 +414,7 @@ export async function executeVideoFrameGrab(ctx: NodeExecutionContext): Promise<
};
video.onerror = () => {
cleanup();
reject(new Error("Failed to load video for frame extraction"));
};

Loading…
Cancel
Save