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