import { NextRequest, NextResponse } from "next/server"; import JSZip from "jszip"; import { auth } from "@/app/(auth)/auth"; export const runtime = "nodejs"; export const maxDuration = 300; type DownloadFileItem = { url: string; name?: string; }; const FETCH_TIMEOUT_MS = 30000; const FETCH_RETRY_COUNT = 3; const MAX_CONCURRENCY = 8; const MAX_DOWNLOAD_FILES = 200; const RETRYABLE_STATUS_CODES = new Set([408, 409, 425, 429, 500, 502, 503, 504]); function sanitizeFileName(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(); return cleaned || fallback; } function inferExt(url: string, contentType: string): string { const type = String(contentType || "").toLowerCase(); if (type.includes("jpeg")) return "jpg"; if (type.includes("png")) return "png"; if (type.includes("webp")) return "webp"; if (type.includes("gif")) return "gif"; if (type.includes("bmp")) return "bmp"; if (type.includes("tiff")) return "tiff"; if (type.includes("mp4")) return "mp4"; if (type.includes("webm")) return "webm"; if (type.includes("quicktime")) return "mov"; const cleanUrl = String(url || "").split("?")[0] || ""; const matched = cleanUrl.match(/\.([a-zA-Z0-9]{2,6})$/); if (matched?.[1]) return matched[1].toLowerCase(); return "png"; } function sanitizeFolderName(value: string): string { const cleaned = String(value || "") .replace(/[\\/:*?"<>|]+/g, "-") .replace(/\s+/g, "-") .replace(/-+/g, "-") .replace(/^-+|-+$/g, "") .trim(); return cleaned || `canvas-images-${Date.now()}`; } function buildContentDisposition(folderName: string): string { const encodedName = encodeURIComponent(`${folderName}.zip`); const asciiName = folderName .replace(/[^\x20-\x7E]+/g, "") .replace(/"/g, "") .trim() || "canvas-images"; return `attachment; filename="${asciiName}.zip"; filename*=UTF-8''${encodedName}`; } function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { ...init, signal: controller.signal, }); } finally { clearTimeout(timer); } } function getRequestOrigin(request: NextRequest): string { const forwardedHost = (request.headers.get("x-forwarded-host") || "").split(",")[0]?.trim(); const forwardedProto = (request.headers.get("x-forwarded-proto") || "").split(",")[0]?.trim(); if (forwardedHost && forwardedProto) { return `${forwardedProto}://${forwardedHost}`; } return new URL(request.url).origin; } function toDownloadItems(input: unknown, origin: string): DownloadFileItem[] { if (!Array.isArray(input)) return []; return input .map((item) => { const raw = (item || {}) as Record; const rawUrl = String(raw.url || "").trim(); if (!rawUrl) return null; let resolvedUrl = ""; try { const parsed = new URL(rawUrl, origin); if (!/^https?:$/i.test(parsed.protocol)) return null; resolvedUrl = parsed.toString(); } catch (_error) { return null; } const name = String(raw.name || "").trim(); return { url: resolvedUrl, name, } as DownloadFileItem; }) .filter((item): item is DownloadFileItem => Boolean(item)) .slice(0, MAX_DOWNLOAD_FILES); } function buildRemoteFetchHeaders(fileUrl: string, requestOrigin: string, cookie: string): HeadersInit { const headers: Record = { Accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;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 normalizeDownloadError(error: unknown): string { if (error instanceof Error) return error.message || "download failed"; if (typeof error === "string") return error; return "download failed"; } async function fetchDownloadFile(params: { file: DownloadFileItem; index: number; requestOrigin: string; cookie: string; }): Promise< | { ok: true; filename: string; bytes: Buffer } | { ok: false; url: string; name?: string; error: string } > { let lastError = ""; for (let attempt = 1; attempt <= FETCH_RETRY_COUNT; attempt += 1) { try { const response = await fetchWithTimeout( params.file.url, { method: "GET", cache: "no-store", redirect: "follow", headers: buildRemoteFetchHeaders(params.file.url, params.requestOrigin, params.cookie), }, FETCH_TIMEOUT_MS ); if (!response.ok) { lastError = `HTTP ${response.status}`; if (attempt < FETCH_RETRY_COUNT && RETRYABLE_STATUS_CODES.has(response.status)) { await sleep(350 * attempt); continue; } break; } const bytes = Buffer.from(await response.arrayBuffer()); if (bytes.length === 0) { throw new Error("empty response"); } const ext = inferExt(params.file.url, response.headers.get("content-type") || ""); const baseName = sanitizeFileName( params.file.name || `canvas-image-${params.index + 1}`, `canvas-image-${params.index + 1}` ); const sequencePrefix = String(params.index + 1).padStart(3, "0"); const filename = `${sequencePrefix}-${baseName}.${ext}`; return { ok: true, filename, bytes }; } catch (error) { lastError = normalizeDownloadError(error); if (attempt < FETCH_RETRY_COUNT) { await sleep(350 * attempt); } } } return { ok: false, url: params.file.url, name: params.file.name, error: lastError || "图片下载失败", }; } export async function POST(request: NextRequest) { try { const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); } let body: Record = {}; const contentType = request.headers.get("content-type") || ""; if (contentType.includes("application/json")) { body = (await request.json().catch(() => ({}))) as Record; } else { const formData = await request.formData().catch(() => null); const payload = formData?.get("payload"); if (typeof payload === "string" && payload.trim()) { try { body = (JSON.parse(payload) as Record) || {}; } catch (_error) { body = {}; } } } const requestOrigin = getRequestOrigin(request); const files = toDownloadItems((body as any)?.files, requestOrigin); const folderName = sanitizeFolderName(String((body as any)?.folderName || "")); if (files.length === 0) { return NextResponse.json({ success: false, error: "没有可下载的图片" }, { status: 400 }); } const zip = new JSZip(); const folder = zip.folder(folderName) || zip; const cookie = request.headers.get("cookie") || ""; const results: Array< | { ok: true; filename: string; bytes: Buffer } | { ok: false; url: string; name?: string; error: string } | null > = new Array(files.length).fill(null); let cursor = 0; const workerCount = Math.min(MAX_CONCURRENCY, files.length); await Promise.all( Array.from({ length: workerCount }, async () => { while (true) { const index = cursor; cursor += 1; if (index >= files.length) break; const file = files[index]!; results[index] = await fetchDownloadFile({ file, index, requestOrigin, cookie, }); } }) ); let successCount = 0; let failedCount = 0; const failures: Array<{ index: number; url: string; name?: string; error: string }> = []; for (const [index, item] of results.entries()) { if (!item || !item.ok) { failedCount += 1; failures.push({ index: index + 1, url: item?.ok === false ? item.url : files[index]?.url || "", name: item?.ok === false ? item.name : files[index]?.name, error: item?.ok === false ? item.error : "未知错误", }); continue; } folder.file(item.filename, item.bytes); successCount += 1; } if (failures.length > 0) { folder.file( "_download-failures.txt", [ "以下图片暂时无法下载。可稍后重试,或复制链接检查源图是否仍可访问。", "", ...failures.map((item) => { const label = item.name ? `${item.index}. ${item.name}` : `${item.index}. image`; return `${label}\nURL: ${item.url}\n原因: ${item.error}`; }), ].join("\n\n") ); console.warn("[CanvasDownloadZip] Partial download failures:", failures); } const zipBuffer = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE", compressionOptions: { level: 6 }, }); return new NextResponse(zipBuffer, { status: 200, headers: { "Content-Type": "application/zip", "Content-Disposition": buildContentDisposition(folderName), "Cache-Control": "no-store", "X-Download-Success-Count": String(successCount), "X-Download-Failed-Count": String(failedCount), }, }); } catch (error) { console.error("[CanvasDownloadZip] Failed:", error); return NextResponse.json({ success: false, error: "打包下载失败" }, { status: 500 }); } }