Browse Source

Merge pull request #36 from shrimbly/pr/r2-community-workflows

fix: use presigned URLs for fetching large community workflows
handoff-20260429-1057
Willie 6 months ago
committed by GitHub
parent
commit
af62799b1b
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 96
      src/app/api/community-workflows/[id]/route.ts

96
src/app/api/community-workflows/[id]/route.ts

@ -5,6 +5,9 @@ const COMMUNITY_WORKFLOWS_API_URL =
process.env.COMMUNITY_WORKFLOWS_API_URL || process.env.COMMUNITY_WORKFLOWS_API_URL ||
"https://nodebananapro.com/api/public/community-workflows"; "https://nodebananapro.com/api/public/community-workflows";
// Allowed hostnames for presigned URL fetches (SSRF protection)
const ALLOWED_R2_HOSTS = [".r2.cloudflarestorage.com"];
interface RouteParams { interface RouteParams {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
} }
@ -13,7 +16,8 @@ interface RouteParams {
* GET: Load a specific community workflow by ID from the remote API * GET: Load a specific community workflow by ID from the remote API
* *
* This proxies to the node-banana-pro hosted service which stores * This proxies to the node-banana-pro hosted service which stores
* community workflows in R2 storage. * community workflows in R2 storage. The API returns a presigned URL
* which we use to fetch the actual workflow data directly from R2.
*/ */
export async function GET(request: Request, { params }: RouteParams) { export async function GET(request: Request, { params }: RouteParams) {
const controller = new AbortController(); const controller = new AbortController();
@ -22,22 +26,22 @@ export async function GET(request: Request, { params }: RouteParams) {
try { try {
const { id } = await params; const { id } = await params;
const response = await fetch( // Step 1: Get the presigned URL from the API
const urlResponse = await fetch(
`${COMMUNITY_WORKFLOWS_API_URL}/${encodeURIComponent(id)}`, `${COMMUNITY_WORKFLOWS_API_URL}/${encodeURIComponent(id)}`,
{ {
headers: { headers: {
Accept: "application/json", Accept: "application/json",
}, },
signal: controller.signal, signal: controller.signal,
// Cache for 10 minutes (individual workflows change less frequently) // Short cache since presigned URLs expire
next: { revalidate: 600 }, next: { revalidate: 60 },
} }
); );
clearTimeout(timeoutId); if (!urlResponse.ok) {
clearTimeout(timeoutId);
if (!response.ok) { if (urlResponse.status === 404) {
if (response.status === 404) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
@ -48,22 +52,86 @@ export async function GET(request: Request, { params }: RouteParams) {
} }
console.error( console.error(
"Error fetching community workflow:", "Error fetching community workflow URL:",
response.status, urlResponse.status,
response.statusText urlResponse.statusText
); );
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: "Failed to load workflow", error: "Failed to load workflow",
}, },
{ status: response.status } { status: urlResponse.status }
);
}
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",
},
{ status: 500 }
); );
} }
const data = await response.json(); const workflow = await workflowResponse.json();
return NextResponse.json(data); return NextResponse.json({
success: true,
workflow,
});
} catch (error) { } catch (error) {
clearTimeout(timeoutId); clearTimeout(timeoutId);

Loading…
Cancel
Save