Merge pull request '普通聊天和画册原图使用 CDN 下载并保留原格式' (#31) from codex/original-download-routing-20261104 into main
This commit is contained in:
commit
cbec13b285
@ -8,6 +8,8 @@ import { ArrowLeft, Download, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsR
|
||||
import { Input } from "@/components/ui/input";
|
||||
import Image from "next/image";
|
||||
import { formatBeijingTime } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||
|
||||
type FeaturedImage = {
|
||||
id: string;
|
||||
@ -74,18 +76,11 @@ export default function FeaturedPage() {
|
||||
|
||||
const handleDownload = async (url: string, prompt: string) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
const blobUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = blobUrl;
|
||||
link.download = `featured-${Date.now()}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(blobUrl);
|
||||
await downloadOriginalImage({ url, name: "featured-image" });
|
||||
toast.success("原图已开始下载");
|
||||
} catch (error) {
|
||||
console.error("Download failed:", error);
|
||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ import { requestLongTaskResult } from "@/lib/client/long-task";
|
||||
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
||||
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||
import { formatBeijingTime } from "@/lib/utils";
|
||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||
|
||||
type GalleryImage = {
|
||||
id: string;
|
||||
@ -119,25 +120,11 @@ export default function GalleryPage() {
|
||||
const parsed = new URL(originalUrl, window.location.origin);
|
||||
const source = parsed.pathname === "/api/image-proxy" ? originalUrl : inferOriginalImageUrl(originalUrl);
|
||||
|
||||
const response = await fetch(
|
||||
buildImageProxyUrl(source, { delivery: "original" })
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`download failed: ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const blobUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = blobUrl;
|
||||
link.download = buildGalleryDownloadFilename(originalUrl, alt, blob.type);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000);
|
||||
await downloadOriginalImage({ url: source, name: buildGalleryDownloadFilename(originalUrl, alt, "") });
|
||||
toast.success("图片已开始下载");
|
||||
} catch (error) {
|
||||
console.error("Download failed:", error);
|
||||
toast.error(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试");
|
||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import crypto from "node:crypto";
|
||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { formatDownloadFailure, getDownloadFailure } from "@/lib/canvas/download-error";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
const logger = createLogger("image.download");
|
||||
|
||||
const DEFAULT_QUALITY = 82;
|
||||
const MAX_WIDTH = 4096;
|
||||
@ -267,8 +270,17 @@ function redirectToDirectImage(target: URL): NextResponse {
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const requestId = crypto.randomUUID();
|
||||
const downloadFailure = (error: unknown) => {
|
||||
const failure = getDownloadFailure(error);
|
||||
logger.warn("original_download_failed", error, { requestId, code: failure.code, status: failure.status });
|
||||
return NextResponse.json({ error: formatDownloadFailure(failure, requestId), failure, requestId }, {
|
||||
status: failure.status, headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
};
|
||||
const rawUrl = (searchParams.get("url") || "").trim();
|
||||
if (isRetiredStorageUrl(rawUrl)) {
|
||||
if (searchParams.get("original") === "1") return downloadFailure(RETIRED_STORAGE_MESSAGE);
|
||||
return NextResponse.json({ error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
|
||||
}
|
||||
if (!rawUrl) {
|
||||
@ -306,6 +318,18 @@ export async function GET(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
if (original && searchParams.get("download") === "resolve") {
|
||||
try {
|
||||
const { buildImageProxyUrl } = await import("@/lib/image-delivery");
|
||||
buildImageProxyUrl(target.toString(), { delivery: "original" });
|
||||
const { resolveOriginalCdnDownload } = await import("@/lib/storage/original-image-download");
|
||||
const direct = await resolveOriginalCdnDownload(target.toString(), searchParams.get("name") || "", request.signal);
|
||||
return NextResponse.json(direct || { delivery: "proxy" }, { headers: { "Cache-Control": "no-store" } });
|
||||
} catch (error) {
|
||||
return downloadFailure(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!forceProxy && !original) {
|
||||
const directImageTarget = resolveDirectImageTarget(
|
||||
request,
|
||||
@ -346,10 +370,11 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const fetchSignal = AbortSignal.any([
|
||||
request.signal,
|
||||
AbortSignal.timeout(width && width <= 256 ? SMALL_PREVIEW_FETCH_TIMEOUT_MS : FETCH_TIMEOUT_MS),
|
||||
AbortSignal.timeout(original ? 60_000 : width && width <= 256 ? SMALL_PREVIEW_FETCH_TIMEOUT_MS : FETCH_TIMEOUT_MS),
|
||||
]);
|
||||
const upstreamFailure = (error: unknown) => {
|
||||
if (request.signal.aborted) return new NextResponse(null, { status: 499 });
|
||||
if (original) return downloadFailure(error);
|
||||
const timedOut = (error instanceof Error && error.name === "TimeoutError") || fetchSignal.reason?.name === "TimeoutError";
|
||||
return NextResponse.json(
|
||||
{ error: timedOut ? "读取源图片超时,请重试" : "源图片传输中断,请重试" },
|
||||
@ -369,6 +394,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (!upstreamResponse.ok) {
|
||||
if (original) return downloadFailure(`HTTP ${upstreamResponse.status}`);
|
||||
return NextResponse.json(
|
||||
{ error: `Upstream responded with ${upstreamResponse.status}` },
|
||||
{ status: 502 }
|
||||
@ -377,6 +403,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream";
|
||||
if (!contentType.toLowerCase().startsWith("image/")) {
|
||||
if (original) return downloadFailure("Source is not an image");
|
||||
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
@ -575,31 +576,11 @@ export function AgentGenerationWorkspace({
|
||||
toast.error("图片地址无效");
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = buildDownloadFilename(raw);
|
||||
try {
|
||||
const response = await fetch(raw);
|
||||
if (!response.ok) {
|
||||
throw new Error(`download failed: ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const blobUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = blobUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(blobUrl);
|
||||
} catch {
|
||||
const link = document.createElement("a");
|
||||
link.href = raw;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
toast.message("已打开原图,可右键另存为");
|
||||
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
|
||||
toast.success("原图已开始下载");
|
||||
} catch (error) {
|
||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
|
||||
@ -6793,31 +6794,11 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
toast.error("图片地址无效");
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = buildDownloadFilename(raw);
|
||||
try {
|
||||
const response = await fetch(raw);
|
||||
if (!response.ok) {
|
||||
throw new Error(`download failed: ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const blobUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = blobUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(blobUrl);
|
||||
} catch {
|
||||
const link = document.createElement("a");
|
||||
link.href = raw;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
toast.message("已打开原图,可右键另存为");
|
||||
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
|
||||
toast.success("原图已开始下载");
|
||||
} catch (error) {
|
||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||
}
|
||||
}, []);
|
||||
const uploadBase64ResultToStorage = useCallback(async (base64Url: string) => {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
@ -725,30 +726,11 @@ export function AgentGenerationWorkspaceNew({
|
||||
toast.error("图片地址无效");
|
||||
return;
|
||||
}
|
||||
const filename = buildDownloadFilename(raw);
|
||||
try {
|
||||
const response = await fetch(raw);
|
||||
if (!response.ok) {
|
||||
throw new Error(`download failed: ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const blobUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = blobUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(blobUrl);
|
||||
} catch {
|
||||
const link = document.createElement("a");
|
||||
link.href = raw;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
toast.message("已打开原图,可右键另存为");
|
||||
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
|
||||
toast.success("原图已开始下载");
|
||||
} catch (error) {
|
||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@ -5,7 +5,8 @@ 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, getImageDownloadExtension } from "@/lib/image-delivery";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||
|
||||
const ImageAnnotator = dynamic(
|
||||
() => import("./image-annotator").then((module) => module.ImageAnnotator),
|
||||
@ -23,25 +24,6 @@ const AngleEditDialog = dynamic(
|
||||
}
|
||||
);
|
||||
|
||||
function triggerBrowserDownload(
|
||||
url: string,
|
||||
filename?: string,
|
||||
options?: { openInNewTab?: boolean }
|
||||
) {
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
if (filename) {
|
||||
link.download = filename;
|
||||
}
|
||||
if (options?.openInNewTab) {
|
||||
link.target = "_blank";
|
||||
}
|
||||
link.rel = "noopener noreferrer";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
export function ImagePreview({
|
||||
src,
|
||||
alt,
|
||||
@ -238,24 +220,12 @@ export function ImagePreview({
|
||||
toast.error("下载失败,请重试");
|
||||
return;
|
||||
}
|
||||
const filenameBase = `generated-image-${Date.now()}`;
|
||||
try {
|
||||
const response = await fetch(
|
||||
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("下载成功");
|
||||
await downloadOriginalImage({ url: safeOriginalUrl });
|
||||
toast.success("原图已开始下载");
|
||||
} catch (error) {
|
||||
console.error("下载失败:", error);
|
||||
toast.error(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试");
|
||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ import { Button } from "./ui/button";
|
||||
import { X, Download, Target, Paintbrush } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ImageAnnotator } from "./image-annotator";
|
||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||
|
||||
export const PreviewAttachment = ({
|
||||
attachment,
|
||||
@ -73,18 +74,11 @@ export const PreviewAttachment = ({
|
||||
// 下载图片函数
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
const downloadUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadUrl;
|
||||
link.download = name ?? `image-${Date.now()}.jpg`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(downloadUrl);
|
||||
await downloadOriginalImage({ url, name: name ?? "image" });
|
||||
toast.success("原图已开始下载");
|
||||
} catch (error) {
|
||||
console.error("下载失败:", error);
|
||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
@ -2073,18 +2074,9 @@ export function SceneFusionView({
|
||||
|
||||
const downloadImage = async (url: string, filename: string) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = blobUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
|
||||
} catch {
|
||||
window.open(url, "_blank");
|
||||
await downloadOriginalImage({ url, name: filename });
|
||||
} catch (error) {
|
||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@ export function getDownloadFailure(error: unknown): DownloadFailure {
|
||||
if (/sharp|pngload|jpegload|unsupported image|无法解析图片|convert|转换/i.test(text)) return failure("IMAGE_CONVERSION_FAILED", "原图无法转换为下载所需的 PNG 文件。", "请保留原图并联系管理员提供下载编号,或重新上传 PNG/JPEG 图片。", 422);
|
||||
if (/upload|存储桶|保存下载|STORAGE_WRITE_FAILED/i.test(text)) return failure("DOWNLOAD_STORAGE_FAILED", "下载文件保存失败,已归档的原图仍然保留。", "请稍后重试;持续失败时联系管理员检查存储,并提供下载编号。", 503, true);
|
||||
if (/未登录/.test(text)) return failure("LOGIN_REQUIRED", "登录状态已失效。", "请重新登录后下载,当前图片不会因此删除。", 401);
|
||||
if (/无法确认原图/.test(text)) return failure("ORIGINAL_IMAGE_UNCONFIRMED", "图片链接包含处理参数,无法确认原图。", "请选择未裁剪、未缩放的原图链接,或重新上传原图。", 400);
|
||||
if (/图片地址无效/.test(text)) return failure("INVALID_IMAGE_URL", "图片地址无效。", "请刷新画布后重新选择图片;仍然失败时请重新上传原图。", 400);
|
||||
return failure("DOWNLOAD_FAILED", "下载准备未能完成。", "请联系管理员并提供下载编号,以便核对原图、转换和存储的具体错误;无需重新生成。", 500, true);
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
||||
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
|
||||
|
||||
@ -176,6 +176,63 @@ function triggerBlobDownload(blob: Blob, filename: string) {
|
||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
|
||||
}
|
||||
|
||||
function triggerCdnDownload(result: { downloadUrl: string; filename: string; bytes?: number }, signal?: AbortSignal) {
|
||||
if (typeof result.downloadUrl !== "string" || new URL(result.downloadUrl).protocol !== "https:") {
|
||||
throw new Error("下载链接无效");
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const frame = document.createElement("iframe");
|
||||
frame.hidden = true;
|
||||
frame.src = result.downloadUrl;
|
||||
document.body.appendChild(frame);
|
||||
window.setTimeout(() => frame.remove(), 10 * 60_000);
|
||||
return { filename: result.filename, bytes: result.bytes || 0 };
|
||||
}
|
||||
|
||||
/** Keep the source format and bytes for chat, gallery and reference downloads. */
|
||||
export async function downloadOriginalImage({ url, name, signal, onProgress }: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
|
||||
const source = String(url || "").trim();
|
||||
if (!source) throw new Error("图片地址无效");
|
||||
const proxy = buildImageProxyUrl(source, { delivery: "original" });
|
||||
const filenameBase = sanitizeDownloadName(name || inferDownloadNameFromUrl(source), "image");
|
||||
const target = new URL(proxy, window.location.origin);
|
||||
onProgress?.({ phase: "preparing", message: "正在准备原图..." });
|
||||
const checkedFetch = async (input: string) => {
|
||||
let response: Response;
|
||||
try { response = await fetch(input, { signal }); }
|
||||
catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
const failure = getDownloadFailure(error);
|
||||
throw new CanvasImageDownloadError(failure.message, { code: "request_failed", failure });
|
||||
}
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null) as DownloadErrorPayload | null;
|
||||
throw new CanvasImageDownloadError(payload?.error || "原图下载失败", { code: "request_failed",
|
||||
status: response.status, failure: payload?.failure || getDownloadFailure(`HTTP ${response.status}`), requestId: payload?.requestId });
|
||||
}
|
||||
return response;
|
||||
};
|
||||
if (target.origin === window.location.origin && target.pathname === "/api/image-proxy") {
|
||||
target.searchParams.set("download", "resolve");
|
||||
target.searchParams.set("name", filenameBase);
|
||||
const metadata = await checkedFetch(target.pathname + target.search);
|
||||
const delivery = await metadata.json();
|
||||
if (delivery.delivery === "cdn") {
|
||||
const result = triggerCdnDownload(delivery, signal);
|
||||
onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" });
|
||||
return result;
|
||||
}
|
||||
if (delivery.delivery !== "proxy") throw new Error("下载链接无效");
|
||||
}
|
||||
const response = await checkedFetch(proxy);
|
||||
const blob = await readResponseBlobWithProgress(response, onProgress);
|
||||
if (!blob.type.toLowerCase().startsWith("image/")) throw new Error("Source is not an image");
|
||||
const extension = getImageDownloadExtension(blob.type);
|
||||
const filename = extension ? `${filenameBase}.${extension}` : filenameBase;
|
||||
triggerBlobDownload(blob, filename);
|
||||
return { filename, bytes: blob.size };
|
||||
}
|
||||
|
||||
function toDownloadCandidates(url: string, candidates: string[] = []) {
|
||||
const raw = String(url || "").trim();
|
||||
const originalUrl = String(inferOriginalImageUrl(raw) || raw).trim();
|
||||
@ -347,16 +404,10 @@ export async function downloadCanvasImageAsPng({
|
||||
|
||||
if (response.headers.get("content-type")?.includes("application/json")) {
|
||||
const result = await response.json();
|
||||
if (result.delivery !== "cdn" || typeof result.downloadUrl !== "string"
|
||||
|| new URL(result.downloadUrl).protocol !== "https:") throw new Error("下载链接无效");
|
||||
if (signal?.aborted) throw signal.reason || new DOMException("已取消下载", "AbortError");
|
||||
const frame = document.createElement("iframe");
|
||||
frame.hidden = true;
|
||||
frame.src = result.downloadUrl;
|
||||
document.body.appendChild(frame);
|
||||
window.setTimeout(() => frame.remove(), 10 * 60_000);
|
||||
if (result.delivery !== "cdn") throw new Error("下载链接无效");
|
||||
const downloaded = triggerCdnDownload(result, signal);
|
||||
onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" });
|
||||
return { filename: result.filename, bytes: result.bytes || 0 };
|
||||
return downloaded;
|
||||
}
|
||||
|
||||
const filename =
|
||||
|
||||
@ -20,6 +20,8 @@ export function getImageDownloadExtension(contentType: string) {
|
||||
"image/avif": "avif",
|
||||
"image/gif": "gif",
|
||||
"image/svg+xml": "svg",
|
||||
"image/bmp": "bmp",
|
||||
"image/tiff": "tiff",
|
||||
};
|
||||
return extensions[mime] || "";
|
||||
}
|
||||
@ -183,8 +185,8 @@ export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {})
|
||||
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.
|
||||
// The same-origin endpoint can resolve a native CDN download without relying
|
||||
// on cross-origin fetch, while retaining a byte-preserving proxy fallback.
|
||||
if (options.delivery === "original") {
|
||||
const params = new URLSearchParams({ url: source, original: "1" });
|
||||
return `${IMAGE_PROXY_PATH}?${params.toString()}`;
|
||||
|
||||
45
lib/storage/original-image-download.ts
Normal file
45
lib/storage/original-image-download.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import "server-only";
|
||||
import { getDefaultStorageConfig } from "@/lib/db/queries";
|
||||
import { findImageArchiveDownloadTask } from "./image-archive-db";
|
||||
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||
import { getImageDownloadExtension } from "@/lib/image-delivery";
|
||||
import { sanitizeCanvasDownloadFileName } from "@/lib/canvas/photoshop-safe-download";
|
||||
|
||||
/** Resolve the archived original without waiting for a separate PNG conversion. */
|
||||
export async function resolveOriginalCdnDownload(source: string, name: string, signal: AbortSignal) {
|
||||
const config = await getDefaultStorageConfig();
|
||||
if (config?.type !== "qiniu-kodo" || !config.isEnabled || !config.publicUrl) return null;
|
||||
const base = new URL(config.publicUrl);
|
||||
const origin = process.env.CANVAS_QINIU_ORIGIN_URL;
|
||||
let target = new URL(source);
|
||||
if (target.origin !== base.origin && (!origin || target.origin !== new URL(origin).origin)) {
|
||||
const task = await findImageArchiveDownloadTask([...new Set([source, inferOriginalImageUrl(source)])]);
|
||||
target = new URL(task?.targetUrl || source);
|
||||
}
|
||||
if (origin && target.origin === new URL(origin).origin && !target.searchParams.has("token")) {
|
||||
target.host = base.host;
|
||||
target.protocol = base.protocol;
|
||||
}
|
||||
if (base.protocol !== "https:" || target.origin !== base.origin ||
|
||||
!target.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/")) return null;
|
||||
// A Kodo download token signs the complete URL; adding attname would invalidate it.
|
||||
if (target.searchParams.has("token")) return null;
|
||||
|
||||
// Only metadata crosses the application server. The browser receives the file
|
||||
// from the CDN, including when cross-origin fetch is disabled by storage CORS.
|
||||
const response = await fetch(target, {
|
||||
method: "HEAD", cache: "no-store",
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (!contentType.toLowerCase().startsWith("image/")) throw new Error("Source is not an image");
|
||||
const extension = getImageDownloadExtension(contentType);
|
||||
if (!extension) return null;
|
||||
const basename = sanitizeCanvasDownloadFileName(name || target.pathname.split("/").pop() || "image", "image")
|
||||
.replace(/\.(?:png|jpe?g|webp|avif|gif|svg|bmp|tiff?)$/i, "");
|
||||
const filename = `${basename}.${extension}`;
|
||||
target.searchParams.set("attname", filename);
|
||||
return { delivery: "cdn" as const, downloadUrl: target.toString(), filename,
|
||||
bytes: Number(response.headers.get("content-length")) || 0 };
|
||||
}
|
||||
@ -3,6 +3,7 @@ import { defineConfig } from "@playwright/test";
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
testMatch: [
|
||||
/routes\/original-image-download\.test\.ts/,
|
||||
/routes\/canvas-download-speed\.test\.ts/,
|
||||
/routes\/canvas-download-recovery\.test\.ts/,
|
||||
/routes\/canvas-download-rate-limit\.test\.ts/,
|
||||
|
||||
@ -154,7 +154,7 @@ test.describe("Qiniu image delivery", () => {
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
114
tests/routes/original-image-download.test.ts
Normal file
114
tests/routes/original-image-download.test.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||
|
||||
const config = { type: "qiniu-kodo", isEnabled: true, publicUrl: "https://cdn.example" };
|
||||
|
||||
test("original downloads use the archived JPEG rather than the prepared PNG and transfer only HEAD metadata", async () => {
|
||||
const calls: Array<{ url: string; method: string }> = [];
|
||||
const loader = serverLoader({
|
||||
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
|
||||
"./image-archive-db": { findImageArchiveDownloadTask: async () => ({ targetUrl: "https://cdn.example/original.jpg", metadata: {
|
||||
downloadAsset: { status: "ready", url: "https://cdn.example/converted.png", contentType: "image/png" },
|
||||
} }) },
|
||||
fetch: async (url: URL, options: { method: string }) => {
|
||||
calls.push({ url: url.toString(), method: options.method });
|
||||
return { ok: true, headers: new Headers({ "content-type": "image/jpeg", "content-length": "12345" }),
|
||||
arrayBuffer() { throw new Error("Image bytes must bypass the application"); } };
|
||||
},
|
||||
});
|
||||
const result = await loader("lib/storage/original-image-download.ts").resolveOriginalCdnDownload("https://upstream.example/temporary.png", "产品.png", new AbortController().signal);
|
||||
expect(calls).toEqual([{ url: "https://cdn.example/original.jpg", method: "HEAD" }]);
|
||||
expect(result).toMatchObject({ delivery: "cdn", filename: "产品.jpg", bytes: 12345 });
|
||||
expect(new URL(result.downloadUrl).pathname).toBe("/original.jpg");
|
||||
});
|
||||
|
||||
test("signed original URLs retain the byte-preserving proxy without changing their signature", async () => {
|
||||
const module = serverLoader({
|
||||
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
|
||||
"./image-archive-db": { findImageArchiveDownloadTask: async () => null },
|
||||
fetch() { throw new Error("Do not add filename parameters to a signed URL"); },
|
||||
})("lib/storage/original-image-download.ts");
|
||||
const result = await module.resolveOriginalCdnDownload("https://cdn.example/source.jpg?token=signature%2Fvalue&e=123", "image.jpg", new AbortController().signal);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("existing CDN objects bypass the archive scan and retain the MIME-derived format", async () => {
|
||||
const module = serverLoader({
|
||||
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
|
||||
"./image-archive-db": { findImageArchiveDownloadTask() { throw new Error("An existing CDN object needs no archive scan"); } },
|
||||
fetch: async () => ({ ok: true, headers: new Headers({ "content-type": "image/webp" }) }),
|
||||
})("lib/storage/original-image-download.ts");
|
||||
const result = await module.resolveOriginalCdnDownload("https://cdn.example/source.png", "source.png", new AbortController().signal);
|
||||
expect(result.filename).toBe("source.webp");
|
||||
expect(new URL(result.downloadUrl).pathname).toBe("/source.png");
|
||||
});
|
||||
|
||||
test("temporary third-party URLs are never checked with HEAD when no archive is ready", async () => {
|
||||
const module = serverLoader({
|
||||
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
|
||||
"./image-archive-db": { findImageArchiveDownloadTask: async () => null },
|
||||
fetch() { throw new Error("The upstream file service does not support HEAD"); },
|
||||
})("lib/storage/original-image-download.ts");
|
||||
expect(await module.resolveOriginalCdnDownload("https://upstream.example/image.png", "image", new AbortController().signal)).toBeNull();
|
||||
});
|
||||
|
||||
test("original download metadata reports missing sources with cause, advice and a feedback ID", async () => {
|
||||
const route = serverLoader({
|
||||
"next/server": { NextResponse: Response },
|
||||
"@/lib/storage/original-image-download": { resolveOriginalCdnDownload: async () => { throw new Error("HTTP 404"); } },
|
||||
})("app/api/image-proxy/route.ts");
|
||||
const request = { nextUrl: new URL("https://app.example/api/image-proxy?url=https%3A%2F%2Fcdn.example%2Fmissing.jpg&original=1&download=resolve"), signal: new AbortController().signal, headers: new Headers() };
|
||||
const response = await route.GET(request);
|
||||
const data = await response.json();
|
||||
expect(response.status).toBe(404);
|
||||
expect(data.failure).toMatchObject({ code: "SOURCE_NOT_FOUND", retryable: false });
|
||||
expect(data.failure.recommendation).toBeTruthy();
|
||||
expect(data.error).toContain(data.requestId);
|
||||
});
|
||||
|
||||
function browserMocks() {
|
||||
const frames: any[] = []; const links: any[] = [];
|
||||
return { frames, links,
|
||||
window: { location: { origin: "https://app.example" }, setTimeout() {} },
|
||||
document: { createElement: (tag: string) => ({ tag, click() { links.push(this); }, remove() {} }),
|
||||
body: { appendChild: (el: any) => { if (el.tag === "iframe") frames.push(el); }, removeChild() {} } },
|
||||
};
|
||||
}
|
||||
|
||||
test("chat original downloads use a native CDN download without fetching the image through CORS", async () => {
|
||||
const dom = browserMocks(); const calls: string[] = [];
|
||||
const client = serverLoader({ ...dom, fetch: async (url: string) => {
|
||||
calls.push(url);
|
||||
return Response.json({ delivery: "cdn", downloadUrl: "https://cdn.example/image.jpg?attname=original.jpg", filename: "original.jpg", bytes: 5000000 });
|
||||
} })("lib/client/canvas-image-download.ts");
|
||||
const result = await client.downloadOriginalImage({ url: "https://cdn.example/image.jpg" });
|
||||
expect(result).toEqual({ filename: "original.jpg", bytes: 5000000 });
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(new URL(calls[0], "https://app.example").searchParams.get("download")).toBe("resolve");
|
||||
expect(dom.frames[0].src).toBe("https://cdn.example/image.jpg?attname=original.jpg");
|
||||
expect(dom.links).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("original fallback preserves JPEG bytes and the MIME-derived filename", async () => {
|
||||
const dom = browserMocks(); const bytes = Buffer.from([255, 216, 255, 224, 0, 16, 74, 70, 73, 70, 255, 217]); let calls = 0;
|
||||
const client = serverLoader({ ...dom, fetch: async () => ++calls === 1 ? Response.json({ delivery: "proxy" }) : new Response(bytes, { headers: { "content-type": "image/jpeg" } }) })("lib/client/canvas-image-download.ts");
|
||||
expect(await client.downloadOriginalImage({ url: "https://external.example/image.png", name: "original.png" })).toEqual({ filename: "original.jpg", bytes: bytes.length });
|
||||
expect(calls).toBe(2);
|
||||
const actual = await fetch(dom.links[0].href).then(r=>r.arrayBuffer());
|
||||
expect(Buffer.from(actual).equals(bytes)).toBe(true);
|
||||
URL.revokeObjectURL(dom.links[0].href);
|
||||
});
|
||||
|
||||
test("original downloads reject processed references before network access", async () => {
|
||||
const client = serverLoader({ ...browserMocks(), fetch() { throw new Error("Must not fetch a processed image as an original"); } })("lib/client/canvas-image-download.ts");
|
||||
await expect(client.downloadOriginalImage({ url: "https://cdn.example/image.jpg?imageView2/2/w/320&token=signed" })).rejects.toThrow("无法确认原图");
|
||||
});
|
||||
|
||||
test("chat downloads display the server cause and advice without losing its feedback ID", async () => {
|
||||
const failure = { code: "SOURCE_FORBIDDEN", message: "图片源站拒绝访问原图。", recommendation: "请联系管理员检查访问权限。", status: 403, retryable: false };
|
||||
const client = serverLoader({ ...browserMocks(), fetch: async () => Response.json({ failure, requestId: "original-feedback" }, { status: 403 }) })("lib/client/canvas-image-download.ts");
|
||||
let error: unknown;
|
||||
try { await client.downloadOriginalImage({ url: "https://cdn.example/image.jpg" }); } catch(e) { error=e; }
|
||||
expect(client.getCanvasImageDownloadErrorMessage(error)).toContain("下载编号:original-feedback");
|
||||
expect(client.getCanvasImageDownloadErrorMessage(error)).toContain(failure.recommendation);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user