diff --git a/app/(chat)/featured/page.tsx b/app/(chat)/featured/page.tsx index 44dd59dd2..27eff5b84 100644 --- a/app/(chat)/featured/page.tsx +++ b/app/(chat)/featured/page.tsx @@ -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)); } }; diff --git a/app/(chat)/gallery/page.tsx b/app/(chat)/gallery/page.tsx index 2816cd436..58796f8ba 100644 --- a/app/(chat)/gallery/page.tsx +++ b/app/(chat)/gallery/page.tsx @@ -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)); } }; diff --git a/app/api/image-proxy/route.ts b/app/api/image-proxy/route.ts index b4204b700..9abf3db3e 100644 --- a/app/api/image-proxy/route.ts +++ b/app/api/image-proxy/route.ts @@ -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 }); } diff --git a/components/agents/AgentGenerationWorkspace.tsx b/components/agents/AgentGenerationWorkspace.tsx index c1544aa57..a365c4b4e 100644 --- a/components/agents/AgentGenerationWorkspace.tsx +++ b/components/agents/AgentGenerationWorkspace.tsx @@ -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)); } }, []); diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index 152997579..b4fddada6 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -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) => { diff --git a/components/agents/AgentGenerationWorkspaceNew.tsx b/components/agents/AgentGenerationWorkspaceNew.tsx index f26fbe96d..c34379ac5 100644 --- a/components/agents/AgentGenerationWorkspaceNew.tsx +++ b/components/agents/AgentGenerationWorkspaceNew.tsx @@ -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)); } }, []); diff --git a/components/image-preview.tsx b/components/image-preview.tsx index efd19650c..a20b78ebc 100644 --- a/components/image-preview.tsx +++ b/components/image-preview.tsx @@ -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)); } }; diff --git a/components/preview-attachment.tsx b/components/preview-attachment.tsx index c2f4dd36b..323621b7e 100644 --- a/components/preview-attachment.tsx +++ b/components/preview-attachment.tsx @@ -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)); } }; diff --git a/components/scene-fusion-view.tsx b/components/scene-fusion-view.tsx index fd79a9426..e96fcb817 100644 --- a/components/scene-fusion-view.tsx +++ b/components/scene-fusion-view.tsx @@ -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)); } }; diff --git a/lib/canvas/download-error.ts b/lib/canvas/download-error.ts index e7298b460..bf66b9fbe 100644 --- a/lib/canvas/download-error.ts +++ b/lib/canvas/download-error.ts @@ -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); } diff --git a/lib/client/canvas-image-download.ts b/lib/client/canvas-image-download.ts index 22b8309fa..2252bef8d 100644 --- a/lib/client/canvas-image-download.ts +++ b/lib/client/canvas-image-download.ts @@ -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 { + 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 = diff --git a/lib/image-delivery.ts b/lib/image-delivery.ts index a57de9114..299ee7cc0 100644 --- a/lib/image-delivery.ts +++ b/lib/image-delivery.ts @@ -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()}`; diff --git a/lib/storage/original-image-download.ts b/lib/storage/original-image-download.ts new file mode 100644 index 000000000..564d7e386 --- /dev/null +++ b/lib/storage/original-image-download.ts @@ -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 }; +} diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts index 139a12601..2a028a8de 100644 --- a/playwright.helpers.config.ts +++ b/playwright.helpers.config.ts @@ -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/, diff --git a/tests/routes/image-delivery-qiniu.test.ts b/tests/routes/image-delivery-qiniu.test.ts index 87fb15bb7..658cbdfdc 100644 --- a/tests/routes/image-delivery-qiniu.test.ts +++ b/tests/routes/image-delivery-qiniu.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; } diff --git a/tests/routes/original-image-download.test.ts b/tests/routes/original-image-download.test.ts new file mode 100644 index 000000000..07f009ca3 --- /dev/null +++ b/tests/routes/original-image-download.test.ts @@ -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); +});