import { createHash } from "node:crypto"; import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage"; import { fetchImageBytes, onceImageBytes } from "@/lib/storage/image-byte-cache"; import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url"; type DownloadFileItem = { url: string; name?: string; }; export type CanvasDownloadAssetResult = | { ok: true; filename: string; bytes: Buffer; contentType: string; } | { ok: false; url: string; name?: string; error: string; }; const DEFAULT_FETCH_TIMEOUT_MS = 30_000; const DEFAULT_FETCH_RETRY_COUNT = 3; function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } function normalizeDownloadError(error: unknown): string { if (error instanceof Error) return error.message || "download failed"; if (typeof error === "string") return error; return "download failed"; } export function sanitizeCanvasDownloadFileName(value: string, fallback: string): string { const cleaned = String(value || "") .replace(/\.[a-zA-Z0-9]{2,6}$/g, "") .replace(/[\\/:*?"<>|]+/g, "-") .replace(/\s+/g, "-") .replace(/-+/g, "-") .replace(/^-+|-+$/g, "") .trim() .slice(0, 120); return cleaned || fallback; } export function buildAttachmentContentDisposition(filename: string): string { const safeAsciiName = filename .replace(/[^\x20-\x7E]+/g, "") .replace(/"/g, "") .trim() || `canvas-image-${Date.now()}.png`; return `attachment; filename="${safeAsciiName}"; filename*=UTF-8''${encodeURIComponent(filename)}`; } function buildRemoteFetchHeaders(fileUrl: string, requestOrigin: string, cookie: string): HeadersInit { const headers: Record = { Accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,video/*,*/*;q=0.8", "User-Agent": "Mozilla/5.0 (compatible; BuyiCanvasDownloader/1.0; +https://buyi.ai)", Referer: requestOrigin, }; try { const targetOrigin = new URL(fileUrl).origin; if (targetOrigin === requestOrigin && cookie) { headers.cookie = cookie; } } catch (_error) { // URL has already been normalized before this point; keep this defensive. } return headers; } function resolveHttpUrl(value: string, requestOrigin: string): string { const raw = String(value || "").trim(); if (!raw) return ""; try { const parsed = new URL(raw, requestOrigin); if (!/^https?:$/i.test(parsed.protocol)) return ""; return parsed.toString(); } catch { return ""; } } function buildCanvasDownloadSourceCandidates(fileUrl: string, requestOrigin: string) { const candidates: string[] = []; const seen = new Set(); const add = (value: string) => { const resolved = resolveHttpUrl(value, requestOrigin); if (!resolved || seen.has(resolved)) return; seen.add(resolved); candidates.push(resolved); }; const resolvedFileUrl = resolveHttpUrl(fileUrl, requestOrigin); const proxySourceUrl = extractImageProxySourceUrl(resolvedFileUrl || fileUrl); if (proxySourceUrl) { add(inferOriginalImageUrl(proxySourceUrl) || proxySourceUrl); add(proxySourceUrl); } if (resolvedFileUrl) { add(inferOriginalImageUrl(resolvedFileUrl) || resolvedFileUrl); add(resolvedFileUrl); } return candidates; } function getContentTypeMime(contentType: string): string { return String(contentType || "") .split(";")[0] ?.trim() .toLowerCase(); } function hasAsciiAt(buffer: Buffer, offset: number, value: string): boolean { return buffer.subarray(offset, offset + value.length).toString("latin1") === value; } function looksLikeSvg(buffer: Buffer, contentType: string): boolean { if (getContentTypeMime(contentType) === "image/svg+xml") return true; const text = buffer.subarray(0, Math.min(buffer.length, 1024)).toString("utf8").trimStart(); return /^(?:<\?xml[^>]*>\s*)?]/i.test(text); } function looksLikeErrorDocument(buffer: Buffer, contentType: string): boolean { const mime = getContentTypeMime(contentType); if ( mime.startsWith("text/") || mime === "application/json" || mime === "application/xml" || mime === "application/xhtml+xml" ) { return true; } const text = buffer.subarray(0, Math.min(buffer.length, 512)).toString("utf8").trimStart(); return /^(?:]|<\?xml|]|[{[])/i.test(text); } function detectAsset(bytes: Buffer, contentType: string): { kind: "image" | "video" | "audio"; mimeType: string; ext: string } | null { if (bytes.length < 4) return null; if (bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) { return { kind: "image", mimeType: "image/png", ext: "png" }; } if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { return { kind: "image", mimeType: "image/jpeg", ext: "jpg" }; } if (hasAsciiAt(bytes, 0, "RIFF") && hasAsciiAt(bytes, 8, "WEBP")) { return { kind: "image", mimeType: "image/webp", ext: "webp" }; } if (hasAsciiAt(bytes, 0, "GIF87a") || hasAsciiAt(bytes, 0, "GIF89a")) { return { kind: "image", mimeType: "image/gif", ext: "gif" }; } if (hasAsciiAt(bytes, 0, "BM")) { return { kind: "image", mimeType: "image/bmp", ext: "bmp" }; } if ( bytes.subarray(0, 4).equals(Buffer.from([0x49, 0x49, 0x2a, 0x00])) || bytes.subarray(0, 4).equals(Buffer.from([0x4d, 0x4d, 0x00, 0x2a])) ) { return { kind: "image", mimeType: "image/tiff", ext: "tiff" }; } if (looksLikeSvg(bytes, contentType)) { return { kind: "image", mimeType: "image/svg+xml", ext: "svg" }; } if (hasAsciiAt(bytes, 0, "RIFF") && hasAsciiAt(bytes, 8, "WAVE")) return {kind:"audio",mimeType:"audio/wav",ext:"wav"}; if (hasAsciiAt(bytes, 0, "ID3") || (bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0 && (bytes[1] & 0x06) !== 0)) return {kind:"audio",mimeType:"audio/mpeg",ext:"mp3"}; if (hasAsciiAt(bytes, 0, "OggS")) return {kind:"audio",mimeType:"audio/ogg",ext:"ogg"}; if (hasAsciiAt(bytes, 0, "fLaC")) return {kind:"audio",mimeType:"audio/flac",ext:"flac"}; if (getContentTypeMime(contentType).startsWith("audio/")) return {kind:"audio",mimeType:getContentTypeMime(contentType),ext:getContentTypeMime(contentType).includes("mp4") ? "m4a" : getContentTypeMime(contentType).split("/")[1]}; if (hasAsciiAt(bytes, 4, "ftyp")) { const brand = bytes.subarray(8, Math.min(bytes.length, 32)).toString("latin1").toLowerCase(); if (brand.includes("m4a")) return {kind:"audio",mimeType:"audio/mp4",ext:"m4a"}; if (brand.includes("avif")) return { kind: "image", mimeType: "image/avif", ext: "avif" }; if (brand.includes("heic") || brand.includes("heif") || brand.includes("mif1")) { return { kind: "image", mimeType: "image/heic", ext: "heic" }; } return { kind: "video", mimeType: "video/mp4", ext: "mp4" }; } if (bytes.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]))) { return { kind: "video", mimeType: "video/webm", ext: "webm" }; } const mime = getContentTypeMime(contentType); if (mime.startsWith("image/")) { const ext = mime.includes("jpeg") ? "jpg" : mime.split("/")[1] || "png"; return { kind: "image", mimeType: mime, ext }; } if (mime.startsWith("video/")) { const ext = mime.includes("quicktime") ? "mov" : mime.split("/")[1] || "mp4"; return { kind: "video", mimeType: mime, ext }; } return null; } export async function convertImageToPhotoshopPng(bytes: Buffer): Promise { const sharp = await import("sharp"); const image = sharp.default(bytes, { animated: false, failOn: "none", limitInputPixels: false, }); const metadata = await image.metadata(); if (!metadata.width || !metadata.height) { throw new Error("无法解析图片尺寸"); } // RGB/RGBA, 8-bit, single-frame PNG is already compatible; preserve its exact bytes. if (metadata.format === "png" && metadata.space === "srgb" && metadata.depth === "uchar" && !metadata.isPalette && (metadata.pages || 1) === 1 && (!metadata.orientation || metadata.orientation === 1)) return bytes; return image.rotate().toColorspace("srgb").png({ compressionLevel: 3, adaptiveFiltering: true }).toBuffer(); } function buildCanvasAssetFilename(params: { item: DownloadFileItem; index: number; ext: string; includeSequencePrefix: boolean; }) { const baseName = sanitizeCanvasDownloadFileName( params.item.name || `canvas-image-${params.index + 1}`, `canvas-image-${params.index + 1}` ); if (!params.includeSequencePrefix) { return `${baseName}.${params.ext}`; } const sequencePrefix = String(params.index + 1).padStart(3, "0"); return `${sequencePrefix}-${baseName}.${params.ext}`; } export async function normalizeCanvasDownloadAsset(params: { item: DownloadFileItem; index: number; bytes: Buffer; contentType: string; includeSequencePrefix: boolean; }): Promise { if (params.bytes.length === 0) { throw new Error("empty response"); } if (looksLikeErrorDocument(params.bytes, params.contentType) && !looksLikeSvg(params.bytes, params.contentType)) { throw new Error("源地址返回的不是图片文件"); } const detected = detectAsset(params.bytes, params.contentType); if (!detected) { throw new Error("无法识别图片格式"); } if (detected.kind !== "image") { return { ok: true, filename: buildCanvasAssetFilename({ item: params.item, index: params.index, ext: detected.ext, includeSequencePrefix: params.includeSequencePrefix, }), bytes: params.bytes, contentType: detected.mimeType, }; } const digest = createHash("sha256").update(params.bytes).digest("hex"); const { buffer: pngBytes } = await onceImageBytes(`photoshop:${digest}`, async () => ({ buffer: await convertImageToPhotoshopPng(params.bytes), contentType: "image/png", })); return { ok: true, filename: buildCanvasAssetFilename({ item: params.item, index: params.index, ext: "png", includeSequencePrefix: params.includeSequencePrefix, }), bytes: pngBytes, contentType: "image/png", }; } export async function fetchCanvasDownloadAsset(params: { file: DownloadFileItem; index: number; requestOrigin: string; cookie: string; includeSequencePrefix?: boolean; timeoutMs?: number; retryCount?: number; signal?: AbortSignal; }): Promise { if (isRetiredStorageUrl(params.file.url)) { return { ok: false, url: params.file.url, name: params.file.name, error: RETIRED_STORAGE_MESSAGE }; } let lastError = ""; const timeoutMs = params.timeoutMs || DEFAULT_FETCH_TIMEOUT_MS; const retryCount = params.retryCount || DEFAULT_FETCH_RETRY_COUNT; const originalCandidates = buildCanvasDownloadSourceCandidates( params.file.url, params.requestOrigin ); for (let attempt = 1; attempt <= retryCount; attempt += 1) { params.signal?.throwIfAborted(); const candidateUrls = [...originalCandidates]; if (process.env.POSTGRES_URL) { // ZIPs and binary fallback also reuse the durable prepared object after the // local byte cache expires, instead of converting the original JPEG again. const { findImageArchiveDownloadTask } = await import("@/lib/storage/image-archive-db"); const task = await findImageArchiveDownloadTask(candidateUrls).catch(() => null); const asset = (task?.metadata as { downloadAsset?: { status?: string; url?: string; contentType?: string } } | null)?.downloadAsset; if (asset?.status === "ready" && asset.url && asset.contentType === "image/png") { const readyUrl = resolveHttpUrl(asset.url, params.requestOrigin); if (readyUrl && !candidateUrls.includes(readyUrl)) candidateUrls.unshift(readyUrl); } else if (task?.targetUrl && !candidateUrls.includes(task.targetUrl)) { candidateUrls.unshift(task.targetUrl); } } for (const candidateUrl of candidateUrls) { params.signal?.throwIfAborted(); try { const data = await fetchImageBytes(candidateUrl, { timeoutMs, headers: buildRemoteFetchHeaders(candidateUrl, params.requestOrigin, params.cookie), // Authenticated local assets must never share a cache across users. scope: new URL(candidateUrl).origin === params.requestOrigin && params.cookie ? createHash("sha256").update(params.cookie).digest("hex") : "", }); return await normalizeCanvasDownloadAsset({ item: params.file, index: params.index, bytes: data.buffer, contentType: data.contentType, includeSequencePrefix: params.includeSequencePrefix !== false, }); } catch (error) { params.signal?.throwIfAborted(); lastError = normalizeDownloadError(error); } } // Archiving may have completed during the failed fetch. Resolve aliases // again before retrying, rather than spending every attempt on the old URL. if (attempt < retryCount) await sleep(350 * attempt); } return { ok: false, url: params.file.url, name: params.file.name, error: lastError || "图片下载失败", }; }