172 lines
5.4 KiB
TypeScript
172 lines
5.4 KiB
TypeScript
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;
|
|
};
|
|
|
|
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";
|
|
|
|
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 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<string, unknown>;
|
|
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 });
|
|
}
|
|
|
|
const body = await request.json().catch(() => ({}));
|
|
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;
|
|
let successCount = 0;
|
|
let failedCount = 0;
|
|
const usedNames = new Set<string>();
|
|
const cookie = request.headers.get("cookie") || "";
|
|
|
|
for (let index = 0; index < files.length; index += 1) {
|
|
const file = files[index]!;
|
|
try {
|
|
const targetOrigin = new URL(file.url).origin;
|
|
const headers =
|
|
targetOrigin === requestOrigin && cookie
|
|
? ({
|
|
cookie,
|
|
} as HeadersInit)
|
|
: undefined;
|
|
const response = await fetch(file.url, {
|
|
method: "GET",
|
|
cache: "no-store",
|
|
headers,
|
|
});
|
|
if (!response.ok) {
|
|
failedCount += 1;
|
|
continue;
|
|
}
|
|
|
|
const ext = inferExt(file.url, response.headers.get("content-type") || "");
|
|
const baseName = sanitizeFileName(file.name || `canvas-image-${index + 1}`, `canvas-image-${index + 1}`);
|
|
let candidate = `${baseName}.${ext}`;
|
|
let suffix = 2;
|
|
while (usedNames.has(candidate)) {
|
|
candidate = `${baseName}-${suffix}.${ext}`;
|
|
suffix += 1;
|
|
}
|
|
usedNames.add(candidate);
|
|
|
|
const bytes = Buffer.from(await response.arrayBuffer());
|
|
folder.file(candidate, bytes);
|
|
successCount += 1;
|
|
} catch (_error) {
|
|
failedCount += 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 });
|
|
}
|
|
}
|