From db469837254c260abaa7e67805a6e36b058575e4 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 2 Nov 2026 17:46:31 +0800 Subject: [PATCH] Fix original image downloads and SKU message rendering --- app/(chat)/gallery/page.tsx | 27 ++-- app/api/image-proxy/route.ts | 37 +++++- components/elements/response.tsx | 38 +----- components/image-preview.tsx | 22 +--- lib/canvas/markdown-image-segments.ts | 113 +++++++++++++++++ lib/image-delivery.ts | 48 ++++++- .../markdown-image-segments.test.ts | 72 +++++++++++ tests/routes/image-delivery-qiniu.test.ts | 120 +++++++++++++++++- 8 files changed, 401 insertions(+), 76 deletions(-) create mode 100644 lib/canvas/markdown-image-segments.ts create mode 100644 tests/integration/markdown-image-segments.test.ts diff --git a/app/(chat)/gallery/page.tsx b/app/(chat)/gallery/page.tsx index bb41768eb..fbba95141 100644 --- a/app/(chat)/gallery/page.tsx +++ b/app/(chat)/gallery/page.tsx @@ -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 : "原图下载失败,请重试"); } }; diff --git a/app/api/image-proxy/route.ts b/app/api/image-proxy/route.ts index 698bfb1ef..ff8d75bd7 100644 --- a/app/api/image-proxy/route.ts +++ b/app/api/image-proxy/route.ts @@ -127,13 +127,15 @@ function buildImageResponse( etag: string; vary?: string; cacheStatus?: "HIT" | "MISS"; + cacheControl?: string; + attachment?: boolean; } ) { const headers: Record = { "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"); diff --git a/components/elements/response.tsx b/components/elements/response.tsx index c0fe127c5..a1daf8b2e 100644 --- a/components/elements/response.tsx +++ b/components/elements/response.tsx @@ -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()); } diff --git a/components/image-preview.tsx b/components/image-preview.tsx index e905b2234..597e0ccb9 100644 --- a/components/image-preview.tsx +++ b/components/image-preview.tsx @@ -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 : "原图下载失败,请重试"); } }; diff --git a/lib/canvas/markdown-image-segments.ts b/lib/canvas/markdown-image-segments.ts new file mode 100644 index 000000000..3369422b6 --- /dev/null +++ b/lib/canvas/markdown-image-segments.ts @@ -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; +} diff --git a/lib/image-delivery.ts b/lib/image-delivery.ts index f3692057f..a57de9114 100644 --- a/lib/image-delivery.ts +++ b/lib/image-delivery.ts @@ -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 = { + "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 diff --git a/tests/integration/markdown-image-segments.test.ts b/tests/integration/markdown-image-segments.test.ts new file mode 100644 index 000000000..3e897423e --- /dev/null +++ b/tests/integration/markdown-image-segments.test.ts @@ -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 }, + ]); + } +}); diff --git a/tests/routes/image-delivery-qiniu.test.ts b/tests/routes/image-delivery-qiniu.test.ts index e4c8891fd..c9b92c53e 100644 --- a/tests/routes/image-delivery-qiniu.test.ts +++ b/tests/routes/image-delivery-qiniu.test.ts @@ -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(""); + }); });