diff --git a/src/app/api/community-workflows/[id]/route.ts b/src/app/api/community-workflows/[id]/route.ts index dce25974..677057d4 100644 --- a/src/app/api/community-workflows/[id]/route.ts +++ b/src/app/api/community-workflows/[id]/route.ts @@ -3,62 +3,38 @@ import { NextResponse } from "next/server"; const COMMUNITY_WORKFLOWS_API_URL = "https://nodebananapro.com/api/public/community-workflows"; -// Allowed hostnames for presigned URL fetches (SSRF protection) -const ALLOWED_R2_HOSTS = [".r2.cloudflarestorage.com"]; - interface RouteParams { params: Promise<{ id: string }>; } /** - * GET: Load a specific community workflow by ID from the remote API + * GET: Get a presigned download URL for a community workflow * - * This proxies to the node-banana-pro hosted service which stores - * community workflows in R2 storage. The API returns a presigned URL - * which we use to fetch the actual workflow data directly from R2. + * Returns { success: true, downloadUrl: "..." } so the client can + * download the workflow directly from R2 (avoids proxying 80-275MB files + * through a serverless function). */ export async function GET(request: Request, { params }: RouteParams) { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 90000); // 90 second timeout - try { const { id } = await params; - // Step 1: Get the presigned URL from the API const urlResponse = await fetch( `${COMMUNITY_WORKFLOWS_API_URL}/${encodeURIComponent(id)}`, { - headers: { - Accept: "application/json", - }, - signal: controller.signal, - // Short cache since presigned URLs expire + headers: { Accept: "application/json" }, next: { revalidate: 60 }, } ); if (!urlResponse.ok) { - clearTimeout(timeoutId); if (urlResponse.status === 404) { return NextResponse.json( - { - success: false, - error: `Workflow not found: ${id}`, - }, + { success: false, error: `Workflow not found: ${id}` }, { status: 404 } ); } - - console.error( - "Error fetching community workflow URL:", - urlResponse.status, - urlResponse.statusText - ); return NextResponse.json( - { - success: false, - error: "Failed to load workflow", - }, + { success: false, error: "Failed to load workflow" }, { status: urlResponse.status } ); } @@ -66,90 +42,20 @@ export async function GET(request: Request, { params }: RouteParams) { const urlData = await urlResponse.json(); if (!urlData.success || !urlData.downloadUrl) { - clearTimeout(timeoutId); - return NextResponse.json( - { - success: false, - error: urlData.error || "Failed to get download URL", - }, - { status: 500 } - ); - } - - // Step 2: Validate presigned URL to prevent SSRF - let downloadUrl: URL; - try { - downloadUrl = new URL(urlData.downloadUrl); - } catch { - clearTimeout(timeoutId); - console.error("Invalid download URL received:", urlData.downloadUrl); - return NextResponse.json( - { success: false, error: "Invalid download URL" }, - { status: 500 } - ); - } - - const isAllowedHost = - downloadUrl.protocol === "https:" && - ALLOWED_R2_HOSTS.some((host) => downloadUrl.hostname.endsWith(host)); - - if (!isAllowedHost) { - clearTimeout(timeoutId); - console.error("Untrusted download URL hostname:", downloadUrl.hostname); return NextResponse.json( - { success: false, error: "Untrusted download URL" }, - { status: 403 } - ); - } - - // Step 3: Fetch the actual workflow from the presigned R2 URL - const workflowResponse = await fetch(downloadUrl.href, { - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!workflowResponse.ok) { - console.error( - "Error fetching workflow from R2:", - workflowResponse.status, - workflowResponse.statusText - ); - return NextResponse.json( - { - success: false, - error: "Failed to download workflow", - }, + { success: false, error: urlData.error || "Failed to get download URL" }, { status: 500 } ); } - const workflow = await workflowResponse.json(); - return NextResponse.json({ success: true, - workflow, + downloadUrl: urlData.downloadUrl, }); } catch (error) { - clearTimeout(timeoutId); - - if (error instanceof Error && error.name === "AbortError") { - console.error("Community workflow fetch timed out"); - return NextResponse.json( - { - success: false, - error: "Request timed out", - }, - { status: 504 } - ); - } - - console.error("Error loading community workflow:", error); + console.error("Error getting community workflow URL:", error); return NextResponse.json( - { - success: false, - error: "Failed to load workflow", - }, + { success: false, error: "Failed to load workflow" }, { status: 500 } ); } diff --git a/src/components/quickstart/QuickstartTemplatesView.tsx b/src/components/quickstart/QuickstartTemplatesView.tsx index 9e68fafe..db0bde29 100644 --- a/src/components/quickstart/QuickstartTemplatesView.tsx +++ b/src/components/quickstart/QuickstartTemplatesView.tsx @@ -84,16 +84,22 @@ export function QuickstartTemplatesView({ setError(null); try { + // Step 1: Get presigned download URL from API const response = await fetch(`/api/community-workflows/${workflowId}`); const result = await response.json(); - if (!result.success) { - throw new Error(result.error || "Failed to load workflow"); + if (!result.success || !result.downloadUrl) { + throw new Error(result.error || "Failed to get download URL"); } - if (result.workflow) { - onWorkflowSelected(result.workflow); + // Step 2: Download workflow directly from R2 + const workflowResponse = await fetch(result.downloadUrl); + if (!workflowResponse.ok) { + throw new Error("Failed to download workflow"); } + + const workflow = await workflowResponse.json(); + onWorkflowSelected(workflow); } catch (err) { console.error("Error loading community workflow:", err); setError(err instanceof Error ? err.message : "Failed to load workflow"); diff --git a/src/components/quickstart/TemplateExplorerView.tsx b/src/components/quickstart/TemplateExplorerView.tsx index 8d0ec812..b6915216 100644 --- a/src/components/quickstart/TemplateExplorerView.tsx +++ b/src/components/quickstart/TemplateExplorerView.tsx @@ -252,16 +252,22 @@ export function TemplateExplorerView({ setError(null); try { + // Step 1: Get presigned download URL from API const response = await fetch(`/api/community-workflows/${workflowId}`); const result = await response.json(); - if (!result.success) { - throw new Error(result.error || "Failed to load workflow"); + if (!result.success || !result.downloadUrl) { + throw new Error(result.error || "Failed to get download URL"); } - if (result.workflow) { - onWorkflowSelected(result.workflow); + // Step 2: Download workflow directly from R2 + const workflowResponse = await fetch(result.downloadUrl); + if (!workflowResponse.ok) { + throw new Error("Failed to download workflow"); } + + const workflow = await workflowResponse.json(); + onWorkflowSelected(workflow); } catch (err) { console.error("Error loading community workflow:", err); setError(err instanceof Error ? err.message : "Failed to load workflow");