Merge pull request '修复原图下载及 SKU 结果展示' (#5) from codex/sku-original-image-display into main

This commit is contained in:
WuYongJie 2026-11-02 17:47:03 +08:00
commit e6f07ed2ce
8 changed files with 401 additions and 76 deletions

View File

@ -9,7 +9,7 @@ import { Input } from "@/components/ui/input";
import Image from "next/image";
import { toast } from "sonner";
import { requestLongTaskResult } from "@/lib/client/long-task";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
import { inferOriginalImageUrl } from "@/lib/image-url";
import { formatBeijingTime } from "@/lib/utils";
@ -32,7 +32,7 @@ type Pagination = {
totalPages: number;
};
function buildGalleryDownloadFilename(url: string, alt: string) {
function buildGalleryDownloadFilename(url: string, alt: string, contentType: string) {
const rawUrl = String(url || "").trim();
const rawAlt = String(alt || "").trim();
const safeBase =
@ -58,7 +58,7 @@ function buildGalleryDownloadFilename(url: string, alt: string) {
}
}
return `${safeBase}-${Date.now()}.${extension}`;
return `${safeBase}-${Date.now()}.${getImageDownloadExtension(contentType) || extension}`;
}
export default function GalleryPage() {
@ -103,7 +103,7 @@ export default function GalleryPage() {
};
const handleDownload = async (url: string, alt: string) => {
const originalUrl = inferOriginalImageUrl(String(url || "").trim());
const originalUrl = String(url || "").trim();
if (!originalUrl) {
toast.error("图片地址无效");
return;
@ -112,8 +112,14 @@ export default function GalleryPage() {
try {
toast.info("正在准备下载...");
// Validate the supplied URL before any legacy thumbnail inference can
// remove signed storage transformation parameters.
buildImageProxyUrl(originalUrl, { delivery: "original" });
const parsed = new URL(originalUrl, window.location.origin);
const source = parsed.pathname === "/api/image-proxy" ? originalUrl : inferOriginalImageUrl(originalUrl);
const response = await fetch(
buildImageProxyUrl(originalUrl, { delivery: "proxy" })
buildImageProxyUrl(source, { delivery: "original" })
);
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
@ -122,7 +128,7 @@ export default function GalleryPage() {
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = buildGalleryDownloadFilename(originalUrl, alt);
link.download = buildGalleryDownloadFilename(originalUrl, alt, blob.type);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
@ -130,14 +136,7 @@ export default function GalleryPage() {
toast.success("图片已开始下载");
} catch (error) {
console.error("Download failed:", error);
const fallbackLink = document.createElement("a");
fallbackLink.href = originalUrl;
fallbackLink.target = "_blank";
fallbackLink.rel = "noopener noreferrer";
document.body.appendChild(fallbackLink);
fallbackLink.click();
document.body.removeChild(fallbackLink);
toast.error("下载失败,已为你打开原图,可直接另存为");
toast.error(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试");
}
};

View File

@ -127,13 +127,15 @@ function buildImageResponse(
etag: string;
vary?: string;
cacheStatus?: "HIT" | "MISS";
cacheControl?: string;
attachment?: boolean;
}
) {
const headers: Record<string, string> = {
"Content-Type": params.contentType,
"Content-Length": String(body.length),
ETag: params.etag,
"Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
"Cache-Control": params.cacheControl || "public, max-age=31536000, immutable, stale-while-revalidate=86400",
};
if (params.vary) {
headers.Vary = params.vary;
@ -141,6 +143,11 @@ function buildImageResponse(
if (params.cacheStatus) {
headers["X-Image-Proxy-Cache"] = params.cacheStatus;
}
if (params.attachment) {
headers["Content-Disposition"] = "attachment";
headers["X-Content-Type-Options"] = "nosniff";
headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
}
return new NextResponse(body, {
status: 200,
headers,
@ -285,8 +292,16 @@ export async function GET(request: NextRequest) {
const directQuality = requestedQuality ? clamp(requestedQuality, MIN_QUALITY, MAX_QUALITY) : null;
const requestedFormat = searchParams.get("f");
const forceProxy = searchParams.get("proxy") === "1";
const original = searchParams.get("original") === "1";
if (!forceProxy) {
if (original && (requestedWidth || requestedQuality || requestedFormat)) {
return NextResponse.json(
{ error: "Original images cannot be transformed" },
{ status: 400 }
);
}
if (!forceProxy && !original) {
const directImageTarget = resolveDirectImageTarget(
request,
target,
@ -299,10 +314,12 @@ export async function GET(request: NextRequest) {
}
}
const cacheKey = `${target.toString()}|${width || "auto"}|${quality}|${requestedFormat || "auto"}`;
const cacheKey = original
? `${target.toString()}|original-v1`
: `${target.toString()}|${width || "auto"}|${quality}|${requestedFormat || "auto"}`;
const etag = buildEtag(cacheKey);
const ifNoneMatch = request.headers.get("if-none-match");
if (ifNoneMatch && ifNoneMatch === etag) {
if (!original && ifNoneMatch && ifNoneMatch === etag) {
return new NextResponse(null, {
status: 304,
headers: {
@ -312,7 +329,7 @@ export async function GET(request: NextRequest) {
});
}
const cached = getCachedImageResponse(cacheKey);
const cached = original ? null : getCachedImageResponse(cacheKey);
if (cached) {
return buildImageResponse(cached.body, {
contentType: cached.contentType,
@ -327,7 +344,7 @@ export async function GET(request: NextRequest) {
upstreamResponse = await fetch(target.toString(), {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: {
Accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
Accept: original ? "*/*" : "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
},
});
} catch (error) {
@ -348,6 +365,14 @@ export async function GET(request: NextRequest) {
}
const originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
if (original) {
return buildImageResponse(originalBuffer, {
contentType,
etag: `"${crypto.createHash("sha256").update(originalBuffer).digest("hex")}"`,
cacheControl: "private, no-store",
attachment: true,
});
}
const isSvg = contentType.toLowerCase().includes("image/svg");
const isGif = contentType.toLowerCase().includes("image/gif");

View File

@ -16,6 +16,7 @@ import {
} from "@/components/elements/openclaw-chart-card";
import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { splitMarkdownImages } from "@/lib/canvas/markdown-image-segments";
import {
Collapsible,
CollapsibleContent,
@ -362,43 +363,6 @@ function normalizeMarkdownContent(value: string) {
.trim();
}
function splitMarkdownImages(content: string) {
const segments: Array<
| { type: "text"; value: string }
| { type: "image"; alt?: string; src: string }
> = [];
const imageRegex = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = imageRegex.exec(content)) !== null) {
if (match.index > lastIndex) {
segments.push({
type: "text",
value: content.slice(lastIndex, match.index),
});
}
segments.push({
type: "image",
alt: match[1],
src: match[2],
});
lastIndex = imageRegex.lastIndex;
}
if (lastIndex < content.length) {
segments.push({
type: "text",
value: content.slice(lastIndex),
});
}
return segments;
}
function isLikelyImageUrl(value?: string | null) {
return IMAGE_URL_REGEX.test(String(value || "").trim());
}

View File

@ -4,7 +4,7 @@ import dynamic from "next/dynamic";
import React, { useState, useEffect } from "react";
import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors, Expand, Maximize2, Move3D, Droplet } from "lucide-react";
import { toast } from "sonner";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
const ImageAnnotator = dynamic(
() => import("./image-annotator").then((module) => module.ImageAnnotator),
@ -237,34 +237,24 @@ export function ImagePreview({
toast.error("下载失败,请重试");
return;
}
const filename = `generated-image-${Date.now()}.jpg`;
const filenameBase = `generated-image-${Date.now()}`;
try {
const response = await fetch(
buildImageProxyUrl(safeOriginalUrl, { delivery: "proxy" })
buildImageProxyUrl(safeOriginalUrl, { delivery: "original" })
);
if (!response.ok) {
throw new Error(`下载失败(${response.status})`);
}
const blob = await response.blob();
const extension = getImageDownloadExtension(blob.type);
const filename = extension ? `${filenameBase}.${extension}` : filenameBase;
const url = window.URL.createObjectURL(blob);
triggerBrowserDownload(url, filename);
window.URL.revokeObjectURL(url);
toast.success("下载成功");
} catch (error) {
console.error("下载失败:", error);
try {
triggerBrowserDownload(safeOriginalUrl, filename);
toast.message("代理下载失败,已切换为原图直链下载");
} catch (fallbackError) {
console.error("原图直链下载失败:", fallbackError);
try {
triggerBrowserDownload(safeOriginalUrl, undefined, { openInNewTab: true });
toast.message("已打开原图,可直接另存为");
} catch (openError) {
console.error("打开原图失败:", openError);
toast.error("下载失败,请重试");
}
}
toast.error(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试");
}
};

View File

@ -0,0 +1,113 @@
export type MarkdownImageSegment =
| { type: "text"; value: string }
| { type: "image"; alt?: string; src: string };
// Markdown destinations may include balanced parentheses and backslash escapes.
function readDestination(content: string, open: number) {
if (content[open] !== "(") return null;
let depth = 1;
for (let index = open + 1; index < content.length; index += 1) {
const character = content[index];
if (character === "\\") {
index += 1;
continue;
}
if (character === "(") depth += 1;
if (character === ")" && --depth === 0) {
const raw = content.slice(open + 1, index);
if (!/^https?:\/\//i.test(raw) || /\s/.test(raw)) return null;
return { url: raw.replace(/\\([()\\])/g, "$1"), end: index + 1 };
}
}
return null;
}
function readImage(content: string, start: number) {
if (!content.startsWith("![", start)) return null;
let depth = 1;
for (let index = start + 2; index < content.length; index += 1) {
const character = content[index];
if (character === "\\") {
index += 1;
continue;
}
if (character === "[") depth += 1;
if (character !== "]" || --depth !== 0) continue;
const destination = readDestination(content, index + 1);
if (!destination) return null;
return {
alt: content.slice(start + 2, index).replace(/\\([\[\]\\])/g, "$1"),
src: destination.url,
end: destination.end,
};
}
return null;
}
export function splitMarkdownImages(content: string): MarkdownImageSegment[] {
const segments: MarkdownImageSegment[] = [];
let textStart = 0;
let cursor = 0;
while (cursor < content.length) {
// Leave Markdown examples in code spans and fences untouched.
if (content[cursor] === "`") {
let endOfRun = cursor + 1;
while (content[endOfRun] === "`") endOfRun += 1;
const marker = content.slice(cursor, endOfRun);
const close = content.indexOf(marker, endOfRun);
cursor = close < 0 ? content.length : close + marker.length;
continue;
}
const lineStart = cursor === 0 || content[cursor - 1] === "\n";
if (lineStart) {
const fence = content.slice(cursor).match(/^ {0,3}(~{3,})[^\n]*(?:\n|$)/);
if (fence) {
const marker = fence[1];
const closing = new RegExp(`^ {0,3}~{${marker.length},}\\s*$`);
let line = cursor + fence[0].length;
while (line < content.length) {
const next = content.indexOf("\n", line);
const end = next < 0 ? content.length : next;
if (closing.test(content.slice(line, end))) {
line = next < 0 ? end : end + 1;
break;
}
line = next < 0 ? end : end + 1;
}
cursor = line;
continue;
}
}
if (content[cursor] === "\\") {
cursor += 2;
continue;
}
const wrapped = content.startsWith("[![", cursor);
const image = readImage(content, cursor + (wrapped ? 1 : 0));
if (!image) {
cursor += 1;
continue;
}
let end = image.end;
if (wrapped) {
const outer = content[end] === "]" ? readDestination(content, end + 1) : null;
// Preserve incomplete, unsafe and different-target links as Markdown so
// Streamdown applies its normal link sanitization and click behavior.
if (!outer || outer.url !== image.src) {
cursor = outer ? outer.end : end;
continue;
}
end = outer.end;
}
if (cursor > textStart) {
segments.push({ type: "text", value: content.slice(textStart, cursor) });
}
segments.push({ type: "image", alt: image.alt, src: image.src });
cursor = end;
textStart = end;
}
if (textStart < content.length) {
segments.push({ type: "text", value: content.slice(textStart) });
}
return segments;
}

View File

@ -6,9 +6,24 @@ export type ImageProxyOptions = {
width?: number;
quality?: number;
format?: ImageProxyFormat;
delivery?: "display" | "proxy";
delivery?: "display" | "proxy" | "original";
};
/** Preserve the actual response format when naming an original download. */
export function getImageDownloadExtension(contentType: string) {
const mime = contentType.split(";")[0]?.trim().toLowerCase();
const extensions: Record<string, string> = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/avif": "avif",
"image/gif": "gif",
"image/svg+xml": "svg",
};
return extensions[mime] || "";
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
@ -141,11 +156,40 @@ function resolveAliyunOssDisplayUrl(
}
export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {}): string {
const source = String(url || "").trim();
let source = String(url || "").trim();
// Some messages retain our preview URL instead of its source URL. Rebuild
// that local proxy request without the preview's resize/format parameters.
if (options.delivery === "original") {
const base = typeof window === "undefined" ? "https://image-proxy.local" : window.location.origin;
try {
const parsed = new URL(source, base);
if (parsed.origin === base && parsed.pathname === IMAGE_PROXY_PATH) {
const nestedSource = parsed.searchParams.get("url") || "";
if (isAbsoluteHttpUrl(nestedSource)) source = nestedSource;
}
} catch {
// Leave malformed and non-URL sources to the existing handling below.
}
try {
const parsed = new URL(source);
if ([...parsed.searchParams.keys()].some((key) => /^(?:imageview2(?:\/|$)|x-oss-process$)/i.test(key))) {
throw new Error("图片链接包含处理参数,无法确认原图");
}
} catch (error) {
if (error instanceof Error && error.message === "图片链接包含处理参数,无法确认原图") throw error;
}
}
if (isBypassUrl(source)) {
return source;
}
// An original download must go through the same-origin byte-preserving
// proxy even for trusted storage hosts: a direct fetch may fail CORS.
if (options.delivery === "original") {
const params = new URLSearchParams({ url: source, original: "1" });
return `${IMAGE_PROXY_PATH}?${params.toString()}`;
}
const trustedDirectStorageUrl = resolveTrustedDirectStorageDisplayUrl(
source,
options

View File

@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import test from "node:test";
import { splitMarkdownImages } from "../../lib/canvas/markdown-image-segments";
const image = "https://example.com/folder/test.jpg";
test("SKU output consumes the entire redundant linked-image wrapper", () => {
const text = `生成图\n[![第2行](${image})](${image})\n下载完成`;
assert.deepEqual(splitMarkdownImages(text), [
{ type: "text", value: "生成图\n" },
{ type: "image", alt: "第2行", src: image },
{ type: "text", value: "\n下载完成" },
]);
});
test("several linked and ordinary images do not leave broken wrapper text", () => {
const parts = splitMarkdownImages(`[![a](${image})](${image})\n![b](https://example.com/b.png)`);
assert.deepEqual(parts, [
{ type: "image", alt: "a", src: image },
{ type: "text", value: "\n" },
{ type: "image", alt: "b", src: "https://example.com/b.png" },
]);
});
test("different outer links and ordinary links are preserved verbatim", () => {
const nested = `[![thumbnail](${image})](https://example.com/full.jpg)`;
const input = `[文档](https://example.com/doc) ${nested} ![plain](https://example.com/next.jpg)`;
assert.deepEqual(splitMarkdownImages(input), [
{ type: "text", value: `[文档](https://example.com/doc) ${nested} ` },
{ type: "image", alt: "plain", src: "https://example.com/next.jpg" },
]);
});
test("supports balanced and escaped parentheses and nested/escaped alt text", () => {
const url = "https://example.com/a_(1)/x.jpg";
assert.deepEqual(splitMarkdownImages(`[![a [b] \\] c](${url})](${url})`), [
{ type: "image", alt: "a [b] ] c", src: url },
]);
assert.deepEqual(splitMarkdownImages("![a](https://example.com/a\\(1\\).jpg)"), [
{ type: "image", alt: "a", src: "https://example.com/a(1).jpg" },
]);
});
test("does not extract examples in inline and fenced code", () => {
const input = `\`![code](${image})\`\n\`\`\`md\n[![a](${image})](${image})\n\`\`\`\n~~~md\n![c](${image})\n~~~\n![actual](${image})`;
const parts = splitMarkdownImages(input);
assert.equal(parts.filter((part) => part.type === "image").length, 1);
assert.equal(parts[0].type, "text");
assert.ok(parts[0].type === "text" && parts[0].value.includes("![code]"));
assert.deepEqual(parts[1], { type: "image", alt: "actual", src: image });
});
test("preserves unsafe, incomplete, and escaped markup for the existing renderer", () => {
for (const content of [
`[![x](${image})](javascript:alert(1))`,
`[![x](${image})](`,
`\\![x](${image})`,
"![x](data:image/png;base64,AAAA)",
]) assert.deepEqual(splitMarkdownImages(content), [{ type: "text", value: content }]);
});
test("retains existing thumbnail metadata in alt text", () => {
for (const alt of [
"thumb|||https://example.com/original.jpg|||",
"thumb$$https://example.com/original.jpg$$",
"thumb|https://example.com/original.jpg|",
]) {
assert.deepEqual(splitMarkdownImages(`![${alt}](${image})`), [
{ type: "image", alt, src: image },
]);
}
});

View File

@ -1,5 +1,8 @@
import { expect, test } from "@playwright/test";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { NextRequest } from "next/server";
import sharp from "sharp";
import { GET } from "@/app/api/image-proxy/route";
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
test.describe("Qiniu image delivery", () => {
test("uses Qiniu image processing for direct canvas previews", () => {
@ -38,4 +41,119 @@ test.describe("Qiniu image delivery", () => {
});
expect(result).toContain("/api/image-proxy?");
});
test("original downloads use an explicit same-origin proxy even for trusted signed CDN URLs", () => {
const source = "https://yybb.top/uploads/result.png?token=example&expires=123";
const result = new URL(buildImageProxyUrl(source, { delivery: "original" }), "https://app.example");
expect(result.pathname).toBe("/api/image-proxy");
expect(result.searchParams.get("url")).toBe(source);
expect(result.searchParams.get("original")).toBe("1");
expect(result.searchParams.has("w")).toBe(false);
expect(buildImageProxyUrl("data:image/png;base64,AA==", { delivery: "original" })).toBe("data:image/png;base64,AA==");
expect(buildImageProxyUrl("blob:example", { delivery: "original" })).toBe("blob:example");
expect(buildImageProxyUrl("/api/canvas/file/1", { delivery: "original" })).toBe("/api/canvas/file/1");
const nested = `/api/image-proxy?url=${encodeURIComponent(source)}&w=320&f=webp`;
expect(new URL(buildImageProxyUrl(nested, { delivery: "original" }), "https://app.example").searchParams.get("url")).toBe(source);
expect(buildImageProxyUrl(nested, { delivery: "original" })).not.toContain("w=320");
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
Object.defineProperty(globalThis, "window", { configurable: true, value: { location: { origin: "https://ai.yydd.ai" } } });
try {
const sameOrigin = `https://ai.yydd.ai${nested}`;
expect(new URL(buildImageProxyUrl(sameOrigin, { delivery: "original" }), "https://ai.yydd.ai").searchParams.get("url")).toBe(source);
const external = `https://other.example${nested}`;
expect(new URL(buildImageProxyUrl(external, { delivery: "original" }), "https://ai.yydd.ai").searchParams.get("url")).toBe(external);
} finally {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
}
});
test("original proxy preserves JPEG bytes and ICC and does not reuse transformed or stale responses", async () => {
const source = "https://public.example/original.jpg?signature=abc";
const jpeg = await sharp({ create: { width: 12, height: 12, channels: 3, background: "#345678" } })
.withIccProfile("srgb").jpeg().toBuffer();
const newer = await sharp({ create: { width: 12, height: 12, channels: 3, background: "#123456" } })
.withIccProfile("srgb").jpeg().toBuffer();
expect((await sharp(jpeg).metadata()).icc).toBeTruthy();
const previousFetch = globalThis.fetch;
let body = jpeg;
globalThis.fetch = async () => new Response(body, { headers: { "Content-Type": "image/jpeg" } });
try {
const transformedUrl = new URL("http://localhost/api/image-proxy");
transformedUrl.searchParams.set("url", source);
transformedUrl.searchParams.set("proxy", "1");
const transformed = await GET(new NextRequest(transformedUrl));
expect(Buffer.from(await transformed.arrayBuffer()).equals(jpeg)).toBe(false);
const rawUrl = new URL("http://localhost/api/image-proxy");
rawUrl.searchParams.set("url", source);
rawUrl.searchParams.set("original", "1");
const first = await GET(new NextRequest(rawUrl));
const downloaded = Buffer.from(await first.arrayBuffer());
expect(downloaded.equals(jpeg)).toBe(true);
expect((await sharp(downloaded).metadata()).icc).toEqual((await sharp(jpeg).metadata()).icc);
expect(first.headers.get("content-type")).toBe("image/jpeg");
expect(first.headers.get("cache-control")).toBe("private, no-store");
expect(first.headers.get("content-disposition")).toBe("attachment");
expect(first.headers.get("x-content-type-options")).toBe("nosniff");
body = newer;
const next = await GET(new NextRequest(rawUrl, { headers: { "If-None-Match": first.headers.get("etag") || "" } }));
expect(next.status).toBe(200);
expect(Buffer.from(await next.arrayBuffer()).equals(newer)).toBe(true);
} finally {
globalThis.fetch = previousFetch;
}
});
test("rejects processed storage URLs without removing signatures", () => {
const qiniu = "https://yybb.top/a.jpg?imageView2/2/w/320&token=signed";
const aliyun = "https://bucket.oss-cn.aliyuncs.com/a.jpg?x-oss-process=image%2Fresize%2Cw_320&signature=signed";
for (const source of [qiniu, aliyun, `/api/image-proxy?url=${encodeURIComponent(qiniu)}&w=300`]) {
expect(() => buildImageProxyUrl(source, { delivery: "original" })).toThrow("无法确认原图");
}
const signed = "https://yybb.top/a.jpg?token=example%2Fvalue&expires=123";
expect(new URL(buildImageProxyUrl(signed, { delivery: "original" }), "https://app.example").searchParams.get("url")).toBe(signed);
});
test("original proxy rejects private hosts, conflicting transforms and non-image content", async () => {
const previousFetch = globalThis.fetch;
let fetchCount = 0;
globalThis.fetch = async () => { fetchCount++; return new Response("not an image", { headers: { "Content-Type": "text/html" } }); };
try {
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=http%3A%2F%2F192.168.1.1%2Fsecret&original=1"))).status).toBe(403);
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fa.jpg&original=1&w=200"))).status).toBe(400);
expect(fetchCount).toBe(0);
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fa.jpg&original=1"))).status).toBe(400);
} finally {
globalThis.fetch = previousFetch;
}
});
test("original proxy returns PNG and WebP data without format conversion", async () => {
const originalFetch = globalThis.fetch;
try {
for (const format of ["png", "webp"] as const) {
const image = sharp({ create: { width: 4, height: 4, channels: 4, background: "#11447780" } });
const data = await (format === "png" ? image.png() : image.webp()).toBuffer();
globalThis.fetch = async () => new Response(data, { headers: { "Content-Type": `image/${format}` } });
const url = new URL("http://localhost/api/image-proxy");
url.searchParams.set("url", `https://public.example/original.${format}`);
url.searchParams.set("original", "1");
const response = await GET(new NextRequest(url));
expect(response.headers.get("content-type")).toBe(`image/${format}`);
expect(Buffer.from(await response.arrayBuffer()).equals(data)).toBe(true);
}
} finally {
globalThis.fetch = originalFetch;
}
});
test("download filename extension follows the original image MIME", () => {
expect(getImageDownloadExtension("image/png")).toBe("png");
expect(getImageDownloadExtension("image/jpeg; charset=binary")).toBe("jpg");
expect(getImageDownloadExtension("image/webp")).toBe("webp");
expect(getImageDownloadExtension("image/avif")).toBe("avif");
expect(getImageDownloadExtension("application/octet-stream")).toBe("");
});
});