import { NextRequest, NextResponse } from "next/server"; import JSZip from "jszip"; import { auth } from "@/app/(auth)/auth"; export const runtime = "nodejs"; export const maxDuration = 120; type DownloadFileItem = { url: string; name?: string; }; const FETCH_TIMEOUT_MS = 15000; const MAX_CONCURRENCY = 6; 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()}`; } 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, 100); } 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<{ filename: string; bytes: Buffer } | 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]!; try { const targetOrigin = new URL(file.url).origin; const headers = targetOrigin === requestOrigin && cookie ? ({ cookie, } as HeadersInit) : undefined; const response = await fetchWithTimeout( file.url, { method: "GET", cache: "no-store", headers, }, FETCH_TIMEOUT_MS ); if (!response.ok) continue; const ext = inferExt(file.url, response.headers.get("content-type") || ""); const baseName = sanitizeFileName(file.name || `canvas-image-${index + 1}`, `canvas-image-${index + 1}`); const filename = `${baseName}-${index + 1}.${ext}`; const bytes = Buffer.from(await response.arrayBuffer()); results[index] = { filename, bytes }; } catch (_error) { continue; } } }) ); let successCount = 0; let failedCount = 0; for (const item of results) { if (!item) { failedCount += 1; continue; } folder.file(item.filename, item.bytes); successCount += 1; } if (successCount === 0) { return NextResponse.json( { success: false, error: "图片下载失败,请检查图片链接是否可访问" }, { status: 502 } ); } 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": `attachment; filename=\"${folderName}.zip\"`, "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 }); } }