Fix original image downloads and SKU message rendering
This commit is contained in:
parent
ae4605d479
commit
db46983725
@ -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 : "原图下载失败,请重试");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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");
|
||||
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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 : "原图下载失败,请重试");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
113
lib/canvas/markdown-image-segments.ts
Normal file
113
lib/canvas/markdown-image-segments.ts
Normal 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;
|
||||
}
|
||||
@ -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
|
||||
|
||||
72
tests/integration/markdown-image-segments.test.ts
Normal file
72
tests/integration/markdown-image-segments.test.ts
Normal 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[](${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(`[](${image})\n`);
|
||||
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 = `[](https://example.com/full.jpg)`;
|
||||
const input = `[文档](https://example.com/doc) ${nested} `;
|
||||
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(".jpg)"), [
|
||||
{ type: "image", alt: "a", src: "https://example.com/a(1).jpg" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not extract examples in inline and fenced code", () => {
|
||||
const input = `\`\`\n\`\`\`md\n[](${image})\n\`\`\`\n~~~md\n\n~~~\n`;
|
||||
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 [
|
||||
`[](javascript:alert(1))`,
|
||||
`[](`,
|
||||
`\\`,
|
||||
"",
|
||||
]) 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(``), [
|
||||
{ type: "image", alt, src: image },
|
||||
]);
|
||||
}
|
||||
});
|
||||
@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user