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, getImageDownloadExtension } from "@/lib/image-delivery"; test.describe("Qiniu image delivery", () => { 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(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(""); }); });