Your Name edc24cb956
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: refine PSD templates and result exports
2026-05-14 20:29:27 +08:00

319 lines
10 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 = 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<Response> {
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<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, MAX_DOWNLOAD_FILES);
}
function buildRemoteFetchHeaders(fileUrl: string, requestOrigin: string, cookie: string): HeadersInit {
const headers: Record<string, string> = {
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<string, unknown> = {};
const contentType = request.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
} 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<string, unknown>) || {};
} 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 });
}
}