Browse Source
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
7 changed files with 390 additions and 79 deletions
@ -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 }); |
||||
|
} |
||||
@ -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 |
||||
|
); |
||||
|
} |
||||
@ -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…
Reference in new issue