生成记录同时包含模型内部 ID 和上游模型 ID,名称显示和筛选统一关联模型目录。图片代理处理源响应体超时与传输中断,返回明确的 504/502;浏览器取消请求同步停止上游下载。小尺寸预览允许 45 秒读取窗口,前端保留 4 路并发和可见区域加载,失败提示展示接口原因。原图下载保持字节不变。 验证:模型 ID 筛选的生产只读查询返回正确记录,浏览器夹具验证内部 ID 名称和峰值并发 4;15 项相关测试覆盖取消、响应体超时、原图字节与 ICC 保留。TypeScript 与改动文件 lint 通过。
190 lines
9.9 KiB
TypeScript
190 lines
9.9 KiB
TypeScript
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("");
|
|
});
|
|
});
|