import { NextRequest, NextResponse } from "next/server"; import JSZip from "jszip"; import { auth } from "@/app/(auth)/auth"; import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download"; export const runtime = "nodejs"; export const maxDuration = 300; type DownloadFileItem = { url: string; name?: string; }; const MAX_CONCURRENCY = 8; const MAX_DOWNLOAD_FILES = 200; 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 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); } 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; contentType: string } | { 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 fetchCanvasDownloadAsset({ 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 }); } }