Merge pull request '修复原图下载及 SKU 结果展示' (#5) from codex/sku-original-image-display into main
This commit is contained in:
commit
e6f07ed2ce
@ -9,7 +9,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { requestLongTaskResult } from "@/lib/client/long-task";
|
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 { inferOriginalImageUrl } from "@/lib/image-url";
|
||||||
import { formatBeijingTime } from "@/lib/utils";
|
import { formatBeijingTime } from "@/lib/utils";
|
||||||
|
|
||||||
@ -32,7 +32,7 @@ type Pagination = {
|
|||||||
totalPages: number;
|
totalPages: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildGalleryDownloadFilename(url: string, alt: string) {
|
function buildGalleryDownloadFilename(url: string, alt: string, contentType: string) {
|
||||||
const rawUrl = String(url || "").trim();
|
const rawUrl = String(url || "").trim();
|
||||||
const rawAlt = String(alt || "").trim();
|
const rawAlt = String(alt || "").trim();
|
||||||
const safeBase =
|
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() {
|
export default function GalleryPage() {
|
||||||
@ -103,7 +103,7 @@ export default function GalleryPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = async (url: string, alt: string) => {
|
const handleDownload = async (url: string, alt: string) => {
|
||||||
const originalUrl = inferOriginalImageUrl(String(url || "").trim());
|
const originalUrl = String(url || "").trim();
|
||||||
if (!originalUrl) {
|
if (!originalUrl) {
|
||||||
toast.error("图片地址无效");
|
toast.error("图片地址无效");
|
||||||
return;
|
return;
|
||||||
@ -112,8 +112,14 @@ export default function GalleryPage() {
|
|||||||
try {
|
try {
|
||||||
toast.info("正在准备下载...");
|
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(
|
const response = await fetch(
|
||||||
buildImageProxyUrl(originalUrl, { delivery: "proxy" })
|
buildImageProxyUrl(source, { delivery: "original" })
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`download failed: ${response.status}`);
|
throw new Error(`download failed: ${response.status}`);
|
||||||
@ -122,7 +128,7 @@ export default function GalleryPage() {
|
|||||||
const blobUrl = window.URL.createObjectURL(blob);
|
const blobUrl = window.URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = blobUrl;
|
link.href = blobUrl;
|
||||||
link.download = buildGalleryDownloadFilename(originalUrl, alt);
|
link.download = buildGalleryDownloadFilename(originalUrl, alt, blob.type);
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
@ -130,14 +136,7 @@ export default function GalleryPage() {
|
|||||||
toast.success("图片已开始下载");
|
toast.success("图片已开始下载");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Download failed:", error);
|
console.error("Download failed:", error);
|
||||||
const fallbackLink = document.createElement("a");
|
toast.error(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试");
|
||||||
fallbackLink.href = originalUrl;
|
|
||||||
fallbackLink.target = "_blank";
|
|
||||||
fallbackLink.rel = "noopener noreferrer";
|
|
||||||
document.body.appendChild(fallbackLink);
|
|
||||||
fallbackLink.click();
|
|
||||||
document.body.removeChild(fallbackLink);
|
|
||||||
toast.error("下载失败,已为你打开原图,可直接另存为");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -127,13 +127,15 @@ function buildImageResponse(
|
|||||||
etag: string;
|
etag: string;
|
||||||
vary?: string;
|
vary?: string;
|
||||||
cacheStatus?: "HIT" | "MISS";
|
cacheStatus?: "HIT" | "MISS";
|
||||||
|
cacheControl?: string;
|
||||||
|
attachment?: boolean;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": params.contentType,
|
"Content-Type": params.contentType,
|
||||||
"Content-Length": String(body.length),
|
"Content-Length": String(body.length),
|
||||||
ETag: params.etag,
|
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) {
|
if (params.vary) {
|
||||||
headers.Vary = params.vary;
|
headers.Vary = params.vary;
|
||||||
@ -141,6 +143,11 @@ function buildImageResponse(
|
|||||||
if (params.cacheStatus) {
|
if (params.cacheStatus) {
|
||||||
headers["X-Image-Proxy-Cache"] = 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, {
|
return new NextResponse(body, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers,
|
headers,
|
||||||
@ -285,8 +292,16 @@ export async function GET(request: NextRequest) {
|
|||||||
const directQuality = requestedQuality ? clamp(requestedQuality, MIN_QUALITY, MAX_QUALITY) : null;
|
const directQuality = requestedQuality ? clamp(requestedQuality, MIN_QUALITY, MAX_QUALITY) : null;
|
||||||
const requestedFormat = searchParams.get("f");
|
const requestedFormat = searchParams.get("f");
|
||||||
const forceProxy = searchParams.get("proxy") === "1";
|
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(
|
const directImageTarget = resolveDirectImageTarget(
|
||||||
request,
|
request,
|
||||||
target,
|
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 etag = buildEtag(cacheKey);
|
||||||
const ifNoneMatch = request.headers.get("if-none-match");
|
const ifNoneMatch = request.headers.get("if-none-match");
|
||||||
if (ifNoneMatch && ifNoneMatch === etag) {
|
if (!original && ifNoneMatch && ifNoneMatch === etag) {
|
||||||
return new NextResponse(null, {
|
return new NextResponse(null, {
|
||||||
status: 304,
|
status: 304,
|
||||||
headers: {
|
headers: {
|
||||||
@ -312,7 +329,7 @@ export async function GET(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const cached = getCachedImageResponse(cacheKey);
|
const cached = original ? null : getCachedImageResponse(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return buildImageResponse(cached.body, {
|
return buildImageResponse(cached.body, {
|
||||||
contentType: cached.contentType,
|
contentType: cached.contentType,
|
||||||
@ -327,7 +344,7 @@ export async function GET(request: NextRequest) {
|
|||||||
upstreamResponse = await fetch(target.toString(), {
|
upstreamResponse = await fetch(target.toString(), {
|
||||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||||
headers: {
|
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) {
|
} catch (error) {
|
||||||
@ -348,6 +365,14 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
|
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 isSvg = contentType.toLowerCase().includes("image/svg");
|
||||||
const isGif = contentType.toLowerCase().includes("image/gif");
|
const isGif = contentType.toLowerCase().includes("image/gif");
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
} from "@/components/elements/openclaw-chart-card";
|
} from "@/components/elements/openclaw-chart-card";
|
||||||
import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download";
|
import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download";
|
||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
|
import { splitMarkdownImages } from "@/lib/canvas/markdown-image-segments";
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
@ -362,43 +363,6 @@ function normalizeMarkdownContent(value: string) {
|
|||||||
.trim();
|
.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) {
|
function isLikelyImageUrl(value?: string | null) {
|
||||||
return IMAGE_URL_REGEX.test(String(value || "").trim());
|
return IMAGE_URL_REGEX.test(String(value || "").trim());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import dynamic from "next/dynamic";
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors, Expand, Maximize2, Move3D, Droplet } from "lucide-react";
|
import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors, Expand, Maximize2, Move3D, Droplet } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
||||||
|
|
||||||
const ImageAnnotator = dynamic(
|
const ImageAnnotator = dynamic(
|
||||||
() => import("./image-annotator").then((module) => module.ImageAnnotator),
|
() => import("./image-annotator").then((module) => module.ImageAnnotator),
|
||||||
@ -237,34 +237,24 @@ export function ImagePreview({
|
|||||||
toast.error("下载失败,请重试");
|
toast.error("下载失败,请重试");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const filename = `generated-image-${Date.now()}.jpg`;
|
const filenameBase = `generated-image-${Date.now()}`;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
buildImageProxyUrl(safeOriginalUrl, { delivery: "proxy" })
|
buildImageProxyUrl(safeOriginalUrl, { delivery: "original" })
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`下载失败(${response.status})`);
|
throw new Error(`下载失败(${response.status})`);
|
||||||
}
|
}
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
|
const extension = getImageDownloadExtension(blob.type);
|
||||||
|
const filename = extension ? `${filenameBase}.${extension}` : filenameBase;
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
triggerBrowserDownload(url, filename);
|
triggerBrowserDownload(url, filename);
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
toast.success("下载成功");
|
toast.success("下载成功");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("下载失败:", error);
|
console.error("下载失败:", error);
|
||||||
try {
|
toast.error(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试");
|
||||||
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("下载失败,请重试");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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;
|
width?: number;
|
||||||
quality?: number;
|
quality?: number;
|
||||||
format?: ImageProxyFormat;
|
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 {
|
function clamp(value: number, min: number, max: number): number {
|
||||||
return Math.max(min, Math.min(max, value));
|
return Math.max(min, Math.min(max, value));
|
||||||
}
|
}
|
||||||
@ -141,11 +156,40 @@ function resolveAliyunOssDisplayUrl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {}): string {
|
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)) {
|
if (isBypassUrl(source)) {
|
||||||
return 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(
|
const trustedDirectStorageUrl = resolveTrustedDirectStorageDisplayUrl(
|
||||||
source,
|
source,
|
||||||
options
|
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 { 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.describe("Qiniu image delivery", () => {
|
||||||
test("uses Qiniu image processing for direct canvas previews", () => {
|
test("uses Qiniu image processing for direct canvas previews", () => {
|
||||||
@ -38,4 +41,119 @@ test.describe("Qiniu image delivery", () => {
|
|||||||
});
|
});
|
||||||
expect(result).toContain("/api/image-proxy?");
|
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