import { expect, test } from "@playwright/test"; import { NextRequest } from "next/server"; import sharp from "sharp"; import { GET } from "@/app/api/image-proxy/route"; import { buildImageProxyUrl, getFullscreenPreviewSize, getImageDownloadExtension } from "@/lib/image-delivery"; import { serverLoader } from "../helpers/canvas-download-server-loader"; import { NextResponse } from "next/server"; test.describe("Qiniu image delivery", () => { test("model result buckets use our thumbnail endpoint without forbidden OSS transforms", () => { const source = "https://dashscope-a717.oss-accelerate.aliyuncs.com/output.png"; const preview = new URL(buildImageProxyUrl(source, { width: 960, format: "webp" }), "https://app.example"); expect(preview.pathname).toBe("/api/image-proxy"); expect(preview.searchParams.get("url")).toBe(source); expect(preview.searchParams.has("x-oss-process")).toBe(false); expect(buildImageProxyUrl("https://other-bucket.oss-cn-shanghai.aliyuncs.com/image.png", { width: 960 })).toContain("x-oss-process="); }); test("a completed archive serves old model-result references from Qiniu immediately", async () => { const source = "https://dashscope-a717.oss-accelerate.aliyuncs.com/archived.png"; const route = serverLoader({ "next/server": { NextResponse }, "@/lib/storage/image-archive-db": { getArchivedImageUrlMappings: async () => ({ [source]: "https://yybb.top/archived.png" }) }, fetch: () => { throw new Error("Must use the completed archive"); }, })("app/api/image-proxy/route.ts"); const request = new URL("https://app.example/api/image-proxy"); request.searchParams.set("url", source); request.searchParams.set("w", "960"); request.searchParams.set("f", "webp"); const response = await route.GET(new NextRequest(request, { headers: { "sec-fetch-dest": "image" } })); expect(response.status).toBe(307); expect(response.headers.get("location")).toBe("https://yybb.top/archived.png?imageView2/2/w/960/format/webp/q/82"); }); test("unarchived model previews share one original fetch with archiving and return resized WebP", async () => { const source = `https://dashscope-a717.oss-accelerate.aliyuncs.com/${crypto.randomUUID()}.png`; const original = await sharp({ create: { width: 1024, height: 1024, channels: 3, background: "#345678" } }).png().toBuffer(); let requests = 0; const load = serverLoader({ "next/server": { NextResponse }, "@/lib/storage/image-archive-db": { getArchivedImageUrlMappings: async () => ({}) }, fetch: async (url: string) => { expect(url).toBe(source); requests++; await new Promise(resolve => setTimeout(resolve, 30)); return new Response(original, { headers: { "content-type": "image/png" } }); }, }); const route = load("app/api/image-proxy/route.ts"); const archiveRead = load("lib/storage/image-byte-cache.ts").fetchImageBytes(source); const responses = await Promise.all([100, 200].map(width => { const request = new URL("https://app.example/api/image-proxy"); request.searchParams.set("url", source); request.searchParams.set("w", String(width)); request.searchParams.set("f", "webp"); return route.GET(new NextRequest(request, { headers: { "sec-fetch-dest": "image" } })); })); expect((await archiveRead).buffer.equals(original)).toBe(true); expect(requests).toBe(1); for (let i = 0; i < responses.length; i++) { expect(responses[i].status).toBe(200); expect(responses[i].headers.get("content-type")).toBe("image/webp"); expect((await sharp(Buffer.from(await responses[i].arrayBuffer())).metadata()).width).toBe([100, 200][i]); } }); test("sizes a fullscreen square to its visible height and reuses the chat preview on standard density screens", () => { const size = getFullscreenPreviewSize({ imageWidth: 2048, imageHeight: 2048, viewportWidth: 1657, viewportHeight: 884 }); expect(size.displayWidth).toBeCloseTo(839.8); expect(size.requestWidth).toBe(960); expect(buildImageProxyUrl("https://yybb.top/a.png", { width: size.requestWidth, quality: 86, format: "webp" })) .toBe(buildImageProxyUrl("https://yybb.top/a.png", { width: 960, quality: 86, format: "webp" })); }); test("preserves screen density and image aspect ratio without requesting oversized fixed previews", () => { expect(getFullscreenPreviewSize({ imageWidth: 2048, imageHeight: 2048, viewportWidth: 1657, viewportHeight: 884, pixelRatio: 2 }).requestWidth).toBe(2048); const portrait = getFullscreenPreviewSize({ imageWidth: 1000, imageHeight: 2000, viewportWidth: 1440, viewportHeight: 900, pixelRatio: 2 }); expect(portrait.displayWidth).toBeCloseTo(427.5); expect(portrait.requestWidth).toBe(960); expect(getFullscreenPreviewSize({ imageWidth: 4000, imageHeight: 1000, viewportWidth: 5000, viewportHeight: 2000, pixelRatio: 2 }).requestWidth).toBe(4096); }); test("reports a timeout while reading image bytes as a retryable response", async () => { const previousFetch = globalThis.fetch; globalThis.fetch = async () => new Response(new ReadableStream({ start(controller) { controller.error(new DOMException("source stalled", "TimeoutError")); }, }), { headers: { "Content-Type": "image/png" } }); try { const response = await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fslow.png&proxy=1&w=192")); expect(response.status).toBe(504); expect(await response.json()).toMatchObject({ error: "读取源图片超时,请重试" }); } finally { globalThis.fetch = previousFetch; } }); test("stops the upstream image body when the preview request is cancelled", async () => { const previousFetch = globalThis.fetch; const controller = new AbortController(); let upstreamSignal: AbortSignal | undefined; globalThis.fetch = async (_url, init) => { upstreamSignal = init?.signal as AbortSignal; return new Response(new ReadableStream({ start(stream) { upstreamSignal!.addEventListener("abort", () => stream.error(upstreamSignal!.reason)); queueMicrotask(() => controller.abort()); } }), { headers: { "Content-Type": "image/png" } }); }; try { const response = await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fcancel.png&proxy=1&w=192", { signal: controller.signal })); expect(upstreamSignal?.aborted).toBe(true); expect(response.status).toBe(499); } finally { globalThis.fetch = previousFetch; } }); test("uses Qiniu image processing for direct canvas previews", () => { expect( buildImageProxyUrl("https://yybb.top/uploads/result.png", { width: 1200, quality: 80, format: "webp", }) ).toBe( "https://yybb.top/uploads/result.png?imageView2/2/w/1200/format/webp/q/80" ); }); test("optimizes legacy first-party upload URLs without changing their identity", () => { expect( buildImageProxyUrl("https://bbyy.ai/uploads/result.png", { width: 960, quality: 88, format: "webp", }) ).toBe( "https://bbyy.ai/uploads/result.png?imageView2/2/w/960/format/webp/q/88" ); }); test("does not overwrite an existing storage query", () => { const url = "https://yybb.top/uploads/result.png?imageView2/2/w/400"; expect(buildImageProxyUrl(url, { width: 1200, format: "webp" })).toBe(url); }); test("keeps unrelated first-party URLs out of the Qiniu path", () => { const result = buildImageProxyUrl("https://bbyy.ai/logo.png", { delivery: "proxy", width: 320, }); 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(422); } 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(""); }); });