Browse Source

Prevent large reference images from truncating generation requests

Multi-image NewAPIWG generations were sending base64 reference images
inside the /api/generate JSON body. Two ordinary canvas references can
push that payload near the dev/server body boundary, leaving the route
to parse an incomplete JSON string and surface a raw syntax error.

Move large NewAPIWG references through the existing in-memory image
serving path: the browser uploads large data URLs as multipart image
files, sends short /api/images/{id} URLs in the generation request, and
the route resolves those temporary URLs back to server-local image data
before calling providers. The route also turns malformed/truncated JSON
into a readable validation error.

Constraint: Keep provider payload semantics unchanged after /api/generate receives the request
Constraint: Do not rely on request origin matching because Next dev can report 0.0.0.0 while the browser uses localhost
Rejected: Increase body size limits only | still sends multi-MB JSON and does not protect hosted/proxied environments
Rejected: Compress reference images client-side | changes model input fidelity and still leaves large JSON possible
Confidence: high
Scope-risk: moderate
Directive: Do not forward /api/images/{id} temporary URLs to external providers; resolve them server-side first
Tested: npm run test:run -- src/store/execution/__tests__/nanoBananaExecutor.test.ts src/app/api/generate/__tests__/route.test.ts
Tested: npm run build
Not-tested: Live NewAPIWG generation with the user's exact two images
TEST-s
jiajia 2 months ago
parent
commit
be48d9d5d5
  1. 18
      src/app/api/generate/__tests__/route.test.ts
  2. 37
      src/app/api/generate/route.ts
  3. 50
      src/app/api/images/upload/route.ts
  4. 36
      src/lib/images/temporaryUrls.ts
  5. 180
      src/store/execution/__tests__/nanoBananaExecutor.test.ts
  6. 53
      src/store/execution/nanoBananaExecutor.ts
  7. 95
      src/utils/temporaryImageUpload.ts

18
src/app/api/generate/__tests__/route.test.ts

@ -57,6 +57,7 @@ function createMockPostRequest(
Authorization: "Bearer login-token",
...(headers || {}),
}),
nextUrl: new URL("http://localhost:3000/api/generate"),
} as unknown as NextRequest;
}
@ -109,6 +110,23 @@ describe("/api/generate route", () => {
process.env = originalEnv;
});
it("returns a readable error when the JSON request body was truncated", async () => {
const request = {
json: vi.fn().mockRejectedValue(
new SyntaxError("Unterminated string in JSON at position 10481152")
),
headers: new Headers({ Authorization: "Bearer login-token" }),
nextUrl: new URL("http://localhost:3000/api/generate"),
} as unknown as NextRequest;
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toContain("生成请求过大或不完整");
expect(data.error).not.toContain("Unterminated string");
});
describe("getConnectionParams", () => {
it("should prefer provider API key headers over env vars", () => {
process.env.GEMINI_API_KEY = "env-gemini-key";

37
src/app/api/generate/route.ts

@ -22,6 +22,7 @@ import { generateWithNewApiWG } from "./providers/newapiwg";
import { getRequestToken, requireConfiguredValue, requireLogin } from "../_auth";
import { ApiError, apiErrorResponse } from "../_errors";
import { resolveUserGatewayApiKey } from "../_gatewayApiKey";
import { resolveTemporaryImageUrlsInArray } from "@/lib/images/temporaryUrls";
// Re-export for backward compatibility (test file imports from route)
export const clearFalInputMappingCache = _clearFalInputMappingCache;
@ -170,12 +171,29 @@ function mergeImageGenerationParameters(
return Object.keys(merged).length > 0 ? merged : undefined;
}
async function readGenerateRequest(request: NextRequest): Promise<MultiProviderGenerateRequest> {
try {
return await request.json();
} catch (error) {
const message = error instanceof Error ? error.message : "";
if (
error instanceof SyntaxError ||
/json|unterminated string|unexpected end/i.test(message)
) {
throw ApiError.badRequest(
"生成请求过大或不完整:参考图数据可能在发送时被截断。请重试,或压缩参考图后再生成。"
);
}
throw error;
}
}
export async function POST(request: NextRequest) {
const requestId = Math.random().toString(36).substring(7);
console.log(`\n[API:${requestId}] ========== NEW GENERATE REQUEST ==========`);
try {
const body: MultiProviderGenerateRequest = await request.json();
const body = await readGenerateRequest(request);
const {
images,
prompt,
@ -189,6 +207,7 @@ export async function POST(request: NextRequest) {
dynamicInputs,
mediaType,
} = body;
const requestImages = resolveTemporaryImageUrlsInArray(images);
// Prompt is required unless:
// - Provided via dynamicInputs
@ -199,7 +218,7 @@ export async function POST(request: NextRequest) {
? dynamicInputs.prompt
: Array.isArray(dynamicInputs.prompt) && dynamicInputs.prompt.length > 0
));
const hasImages = (images && images.length > 0);
const hasImages = requestImages.length > 0;
const hasMediaInputs = dynamicInputs && Object.keys(dynamicInputs).some(key =>
key.includes('frame') || key.includes('image') || key.includes('video') || key.includes('audio')
);
@ -232,7 +251,7 @@ export async function POST(request: NextRequest) {
description: null,
},
prompt: prompt || "",
images: images ? [...images] : [],
images: requestImages,
parameters: mergeImageGenerationParameters(parameters, aspectRatio, resolution),
dynamicInputs,
};
@ -267,7 +286,7 @@ export async function POST(request: NextRequest) {
);
// Keep Data URIs as-is since localhost URLs won't work (provider can't reach them)
const processedImages: string[] = images ? [...images] : [];
const processedImages = requestImages;
// Process dynamicInputs: filter empty values, keep Data URIs
let processedDynamicInputs: Record<string, string | string[]> | undefined = undefined;
@ -337,7 +356,7 @@ export async function POST(request: NextRequest) {
}
// Pass images as-is; generateWithFalQueue uploads base64 to CDN internally
const processedImages: string[] = images ? [...images] : [];
const processedImages = requestImages;
// Process dynamicInputs: filter empty values
let processedDynamicInputs: Record<string, string | string[]> | undefined = undefined;
@ -407,7 +426,7 @@ export async function POST(request: NextRequest) {
);
// Process images - Kie requires URLs, we'll upload base64 images in generateWithKie
const processedImages: string[] = images ? [...images] : [];
const processedImages = requestImages;
// Process dynamicInputs: filter empty values
let processedDynamicInputs: Record<string, string | string[]> | undefined = undefined;
@ -475,7 +494,7 @@ export async function POST(request: NextRequest) {
);
// Keep Data URIs as-is since localhost URLs won't work
const processedImages: string[] = images ? [...images] : [];
const processedImages = requestImages;
// Process dynamicInputs: filter empty values
let processedDynamicInputs: Record<string, string | string[]> | undefined = undefined;
@ -576,7 +595,7 @@ export async function POST(request: NextRequest) {
geminiApiKey,
selectedModel.modelId,
resolvedPrompt || "",
images || [],
requestImages,
veoParams,
);
@ -602,7 +621,7 @@ export async function POST(request: NextRequest) {
requestId,
geminiApiKey,
resolvedPrompt,
images || [],
requestImages,
geminiModel,
aspectRatio,
resolution,

50
src/app/api/images/upload/route.ts

@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from "next/server";
import { deleteImages, storeImage } from "@/lib/images";
function extensionFromMime(mimeType: string): string {
if (mimeType === "image/jpeg") return "jpg";
if (mimeType === "image/webp") return "webp";
if (mimeType === "image/gif") return "gif";
return "png";
}
export async function POST(request: NextRequest): Promise<NextResponse> {
const formData = await request.formData();
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json(
{ success: false, error: "Missing image file" },
{ status: 400 }
);
}
if (!file.type.startsWith("image/")) {
return NextResponse.json(
{ success: false, error: "Only image uploads are supported" },
{ status: 400 }
);
}
const buffer = Buffer.from(await file.arrayBuffer());
const dataUrl = `data:${file.type};base64,${buffer.toString("base64")}`;
const id = storeImage(dataUrl);
const url = `${request.nextUrl.origin}/api/images/${id}`;
return NextResponse.json({
success: true,
id,
url,
filename: file.name || `image.${extensionFromMime(file.type)}`,
});
}
export async function DELETE(request: NextRequest): Promise<NextResponse> {
const body = await request.json().catch(() => ({}));
const ids = Array.isArray(body.ids)
? body.ids.filter((id: unknown): id is string => typeof id === "string" && id.length > 0)
: [];
deleteImages(ids);
return NextResponse.json({ success: true });
}

36
src/lib/images/temporaryUrls.ts

@ -0,0 +1,36 @@
import { getImage } from "./store";
function parseTemporaryImageId(url: string): string | null {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return null;
}
// Do not fetch arbitrary URLs here. This only maps the random local image id
// from /api/images/{id} back to the in-memory store.
if (!parsed.pathname.startsWith("/api/images/")) {
return null;
}
const id = decodeURIComponent(parsed.pathname.slice("/api/images/".length));
return id || null;
}
export function resolveTemporaryImageUrlToDataUrl(url: string): string | null {
const id = parseTemporaryImageId(url);
if (!id) return null;
const image = getImage(id);
if (!image) return null;
return `data:${image.mimeType};base64,${Buffer.from(image.data).toString("base64")}`;
}
export function resolveTemporaryImageUrlsInArray(
images: string[] | undefined
): string[] {
return (images || []).map((image) =>
resolveTemporaryImageUrlToDataUrl(image) ?? image
);
}

180
src/store/execution/__tests__/nanoBananaExecutor.test.ts

@ -100,6 +100,28 @@ beforeEach(() => {
mockWriteBrowserGenerationFile.mockResolvedValue({ filePath: "browserfs://root/project/generations/img.png" });
});
async function withProviderMode<T>(mode: "multi" | "popi", run: () => Promise<T>): Promise<T> {
const originalNextPublicMode = process.env.NEXT_PUBLIC_PROVIDER_MODE;
const originalProviderMode = process.env.PROVIDER_MODE;
process.env.NEXT_PUBLIC_PROVIDER_MODE = mode;
process.env.PROVIDER_MODE = mode;
try {
return await run();
} finally {
if (originalNextPublicMode === undefined) {
delete process.env.NEXT_PUBLIC_PROVIDER_MODE;
} else {
process.env.NEXT_PUBLIC_PROVIDER_MODE = originalNextPublicMode;
}
if (originalProviderMode === undefined) {
delete process.env.PROVIDER_MODE;
} else {
process.env.PROVIDER_MODE = originalProviderMode;
}
}
}
describe("executeNanoBanana", () => {
it("should throw when no text input is provided", async () => {
const node = makeNode();
@ -619,16 +641,18 @@ describe("executeNanoBanana", () => {
});
it("should track cost for gemini provider", async () => {
const node = makeNode();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, image: "data:image/png;base64,result" }),
});
await withProviderMode("multi", async () => {
const node = makeNode();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, image: "data:image/png;base64,result" }),
});
const ctx = makeCtx(node);
await executeNanoBanana(ctx);
const ctx = makeCtx(node);
await executeNanoBanana(ctx);
expect(ctx.addIncurredCost).toHaveBeenCalledWith(0.05);
expect(ctx.addIncurredCost).toHaveBeenCalledWith(0.05);
});
});
it("should track cost for fal provider", async () => {
@ -728,6 +752,60 @@ describe("executeNanoBanana", () => {
expect(body.images).toEqual(["data:image/png;base64,img1"]);
});
it("uploads large NewApiWG reference images before sending the generate request", async () => {
const largeImage = `data:image/png;base64,${"a".repeat(400_000)}`;
const node = makeNode({
selectedModel: {
provider: "newapiwg",
modelId: "apiyi_nano_banana_2",
displayName: "apiyi_nano_banana_2",
capabilities: ["text-to-image", "image-to-image"],
},
});
const ctx = makeCtx(node, {
getConnectedInputs: vi.fn().mockReturnValue({
images: [largeImage],
videos: [],
audio: [],
text: "with large image",
dynamicInputs: {},
easeCurve: null,
}),
});
mockFetch.mockImplementation(async (url: string) => {
if (url === "/api/images/upload") {
return {
ok: true,
json: () => Promise.resolve({
success: true,
id: "tmp-image-1",
url: "http://localhost:3000/api/images/tmp-image-1",
}),
};
}
if (url === "/api/generate") {
return {
ok: true,
json: () => Promise.resolve({ success: true, image: "data:image/png;base64,result" }),
};
}
throw new Error(`Unexpected fetch URL: ${url}`);
});
await executeNanoBanana(ctx);
const generateCall = mockFetch.mock.calls.find(([url]) => url === "/api/generate");
const cleanupCall = mockFetch.mock.calls.find(
([url, init]) => url === "/api/images/upload" && init?.method === "DELETE"
);
const body = JSON.parse(generateCall?.[1].body);
expect(body.images).toEqual(["http://localhost:3000/api/images/tmp-image-1"]);
expect(body.images[0]).not.toContain("base64");
expect(cleanupCall?.[1].body).toBe(JSON.stringify({ ids: ["tmp-image-1"] }));
});
it("should fall back to stored inputs in regenerate mode", async () => {
const node = makeNode({
inputImages: ["stored-img.png"],
@ -821,51 +899,53 @@ describe("executeNanoBanana", () => {
});
it("falls back on primary failure and stamps metadata", async () => {
const node = makeNode({
fallbackModel: {
provider: "replicate",
modelId: "flux-dev",
displayName: "Flux Dev",
},
});
// Primary fails, fallback succeeds
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: false, error: "Primary boom" }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, image: "data:image/png;base64,fallback" }),
await withProviderMode("multi", async () => {
const node = makeNode({
fallbackModel: {
provider: "replicate",
modelId: "flux-dev",
displayName: "Flux Dev",
},
});
const ctx = makeCtx(node);
await executeNanoBanana(ctx);
expect(mockFetch).toHaveBeenCalledTimes(2);
// Second fetch should carry the fallback model
const secondBody = JSON.parse(mockFetch.mock.calls[1][1].body);
expect(secondBody.selectedModel.modelId).toBe("flux-dev");
// Metadata stamp should be present in the final updateNodeData call
const calls = (ctx.updateNodeData as ReturnType<typeof vi.fn>).mock.calls;
const stampCall = calls.find(
(c: unknown[]) => (c[1] as Record<string, unknown>).__usedFallback === true
);
expect(stampCall).toBeDefined();
expect((stampCall![1] as Record<string, unknown>).__fallbackModelUsed).toBe("Flux Dev");
expect((stampCall![1] as Record<string, unknown>).__primaryError).toBe("Primary boom");
// Primary fails, fallback succeeds
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: false, error: "Primary boom" }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, image: "data:image/png;base64,fallback" }),
});
const completeCall = calls.find(
(c: unknown[]) => (c[1] as Record<string, unknown>).status === "complete"
);
expect(completeCall?.[1]).toMatchObject({
lastUsedModel: {
provider: "replicate",
modelId: "flux-dev",
},
const ctx = makeCtx(node);
await executeNanoBanana(ctx);
expect(mockFetch).toHaveBeenCalledTimes(2);
// Second fetch should carry the fallback model
const secondBody = JSON.parse(mockFetch.mock.calls[1][1].body);
expect(secondBody.selectedModel.modelId).toBe("flux-dev");
// Metadata stamp should be present in the final updateNodeData call
const calls = (ctx.updateNodeData as ReturnType<typeof vi.fn>).mock.calls;
const stampCall = calls.find(
(c: unknown[]) => (c[1] as Record<string, unknown>).__usedFallback === true
);
expect(stampCall).toBeDefined();
expect((stampCall![1] as Record<string, unknown>).__fallbackModelUsed).toBe("Flux Dev");
expect((stampCall![1] as Record<string, unknown>).__primaryError).toBe("Primary boom");
const completeCall = calls.find(
(c: unknown[]) => (c[1] as Record<string, unknown>).status === "complete"
);
expect(completeCall?.[1]).toMatchObject({
lastUsedModel: {
provider: "replicate",
modelId: "flux-dev",
},
});
});
});
});

53
src/store/execution/nanoBananaExecutor.ts

@ -27,6 +27,10 @@ import {
writeBrowserGenerationFile,
} from "@/utils/browserFileSystem";
import { isHttpMediaUrl } from "@/utils/mediaResolver";
import {
cleanupTemporaryImages,
uploadTemporaryImagesForGeneration,
} from "@/utils/temporaryImageUpload";
export interface NanoBananaOptions {
/** When true, falls back to stored inputImages/inputPrompt if no connections provide them. */
@ -136,28 +140,35 @@ export async function executeNanoBanana(
const sanitizedDynamicInputs = { ...dynamicInputs };
delete sanitizedDynamicInputs.prompt;
const requestPayload = {
images,
prompt: finalPrompt,
aspectRatio: (parametersOverride?.aspectRatio as string) ?? nodeData.aspectRatio,
resolution: (parametersOverride?.resolution as string) ?? nodeData.resolution,
model: nodeData.model,
useGoogleSearch: (parametersOverride?.useGoogleSearch as boolean) ?? nodeData.useGoogleSearch,
useImageSearch: (parametersOverride?.useImageSearch as boolean) ?? nodeData.useImageSearch,
selectedModel: modelToUse,
parameters: parametersOverride ?? nodeData.parameters,
dynamicInputs: sanitizedDynamicInputs,
};
// Final guard: assert that prompt is a string before sending to API
if (typeof requestPayload.prompt !== 'string') {
const errorMsg = `Internal error: prompt is ${typeof requestPayload.prompt}, expected string`;
console.error('[nanoBanana]', errorMsg);
updateNodeData(node.id, { status: 'error', error: errorMsg });
throw new Error(errorMsg);
}
let temporaryImageIds: string[] = [];
try {
const preparedImages = provider === "newapiwg"
? await uploadTemporaryImagesForGeneration(images)
: { images, cleanupIds: [] };
temporaryImageIds = preparedImages.cleanupIds;
const requestPayload = {
images: preparedImages.images,
prompt: finalPrompt,
aspectRatio: (parametersOverride?.aspectRatio as string) ?? nodeData.aspectRatio,
resolution: (parametersOverride?.resolution as string) ?? nodeData.resolution,
model: nodeData.model,
useGoogleSearch: (parametersOverride?.useGoogleSearch as boolean) ?? nodeData.useGoogleSearch,
useImageSearch: (parametersOverride?.useImageSearch as boolean) ?? nodeData.useImageSearch,
selectedModel: modelToUse,
parameters: parametersOverride ?? nodeData.parameters,
dynamicInputs: sanitizedDynamicInputs,
};
// Final guard: assert that prompt is a string before sending to API
if (typeof requestPayload.prompt !== 'string') {
const errorMsg = `Internal error: prompt is ${typeof requestPayload.prompt}, expected string`;
console.error('[nanoBanana]', errorMsg);
updateNodeData(node.id, { status: 'error', error: errorMsg });
throw new Error(errorMsg);
}
const requestedImageCount = nodeData.imageCount === 2 || nodeData.imageCount === 4
? nodeData.imageCount
: 1;
@ -418,6 +429,8 @@ export async function executeNanoBanana(
error: errorMessage,
});
throw new Error(errorMessage);
} finally {
await cleanupTemporaryImages(temporaryImageIds);
}
};

95
src/utils/temporaryImageUpload.ts

@ -0,0 +1,95 @@
const TEMP_IMAGE_UPLOAD_THRESHOLD_BYTES = 256 * 1024;
type UploadedTemporaryImages = {
images: string[];
cleanupIds: string[];
};
function dataUrlMimeType(dataUrl: string): string | null {
return dataUrl.match(/^data:([^;]+);base64,/)?.[1] ?? null;
}
function approximateDataUrlBytes(dataUrl: string): number {
const base64 = dataUrl.match(/^data:[^;]+;base64,(.+)$/)?.[1];
if (!base64) return 0;
const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
return Math.max(0, Math.floor((base64.length * 3) / 4) - padding);
}
function dataUrlToBlob(dataUrl: string): Blob {
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
if (!match) throw new Error("Invalid image data URL");
const [, mimeType, base64] = match;
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return new Blob([bytes], { type: mimeType });
}
function shouldUploadTemporaryImage(image: string): boolean {
return image.startsWith("data:image/") &&
approximateDataUrlBytes(image) > TEMP_IMAGE_UPLOAD_THRESHOLD_BYTES;
}
function extensionFromMime(mimeType: string | null): string {
if (mimeType === "image/jpeg") return "jpg";
if (mimeType === "image/webp") return "webp";
if (mimeType === "image/gif") return "gif";
return "png";
}
async function uploadTemporaryImage(image: string): Promise<{ url: string; id: string }> {
const mimeType = dataUrlMimeType(image);
const blob = dataUrlToBlob(image);
const formData = new FormData();
formData.append("file", blob, `reference.${extensionFromMime(mimeType)}`);
const response = await fetch("/api/images/upload", {
method: "POST",
body: formData,
});
const result = await response.json().catch(() => null) as
| { success?: boolean; url?: string; id?: string; error?: string }
| null;
if (!response.ok || !result?.success || !result.id) {
throw new Error(result?.error || "Failed to prepare reference image");
}
const sameOriginUrl = typeof window !== "undefined"
? `${window.location.origin}/api/images/${encodeURIComponent(result.id)}`
: result.url;
if (!sameOriginUrl) throw new Error("Failed to prepare reference image");
return { url: sameOriginUrl, id: result.id };
}
export async function uploadTemporaryImagesForGeneration(
images: string[]
): Promise<UploadedTemporaryImages> {
const cleanupIds: string[] = [];
const preparedImages = await Promise.all(
images.map(async (image) => {
if (!shouldUploadTemporaryImage(image)) return image;
const uploaded = await uploadTemporaryImage(image);
cleanupIds.push(uploaded.id);
return uploaded.url;
})
);
return { images: preparedImages, cleanupIds };
}
export async function cleanupTemporaryImages(ids: string[]): Promise<void> {
if (ids.length === 0) return;
await fetch("/api/images/upload", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
}).catch(() => undefined);
}
Loading…
Cancel
Save