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 ||
"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 }>;
}
@ -13,7 +16,8 @@ interface RouteParams {
* GET: Load a specific community workflow by ID from the remote API
*
* 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) {
const controller = new AbortController();
@ -22,22 +26,22 @@ export async function GET(request: Request, { params }: RouteParams) {
try {
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)}`,
{
headers: {
Accept: "application/json",
},
signal: controller.signal,
// Cache for 10 minutes (individual workflows change less frequently)
next: { revalidate: 600 },
// Short cache since presigned URLs expire
next: { revalidate: 60 },
}
);
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 404) {
if (!urlResponse.ok) {
clearTimeout(timeoutId);
if (urlResponse.status === 404) {
return NextResponse.json(
{
success: false,
@ -48,22 +52,86 @@ export async function GET(request: Request, { params }: RouteParams) {
}
console.error(
"Error fetching community workflow:",
response.status,
response.statusText
"Error fetching community workflow URL:",
urlResponse.status,
urlResponse.statusText
);
return NextResponse.json(
{
success: false,
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) {
clearTimeout(timeoutId);

Loading…
Cancel
Save