237 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { writeDownloadZip, readDownloadZip } from "@/lib/canvas/download-zip-stream";
import { claimZipTask, loadZipTask, saveZipTask, zipTaskPath, assertZipDiskSpace, cleanupZipTasks, assertZipTaskCapacity, type ZipTask } from "@/lib/canvas/download-zip-task-store";
import { parseDownloadByteRange } from "@/lib/canvas/download-byte-range";
import { createReadStream } from "node:fs";
import { mkdir, stat, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { after, NextRequest, NextResponse } from "next/server";
import JSZip from "jszip";
import { auth } from "@/app/(auth)/auth";
import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
import { publishCanvasZip } from "@/lib/storage/canvas-zip-delivery";
export const runtime = "nodejs";
export const maxDuration = 300;
type DownloadFileItem = { url: string; name?: string };
const MAX_DOWNLOAD_FILES = 200;
const TASK_TTL_MS = 60 * 60_000;
const running = (globalThis as typeof globalThis & { __canvasZipRunning?: Set<string> });
const active = running.__canvasZipRunning ??= new Set<string>();
function sanitizeFolderName(value: string): string {
const cleaned = String(value || "")
.replace(/[\\/:*?"<>|]+/g, "-")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "")
.trim();
return cleaned && !/^\.+$/.test(cleaned) ? cleaned.slice(0, 100) : `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<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();
const nextItem: DownloadFileItem = { url: resolvedUrl };
if (name) {
nextItem.name = name;
}
return nextItem;
})
.filter((item): item is DownloadFileItem => item !== null)
.slice(0, MAX_DOWNLOAD_FILES);
}
function serializeTask(task: ZipTask) {
return { id: task.id, folderName: task.folderName, status: task.status, stage: task.stage,
totalCount: task.totalCount, processedCount: task.processedCount, successCount: task.successCount,
failedCount: task.failedCount, progress: task.status === "completed" ? 100 : Math.min(99,
Math.round(task.processedCount / Math.max(1, task.totalCount) * 85 + task.compressPercent * .14)),
error: task.error || "", expiresAt: task.expiresAt,
downloadUrl: task.status === "completed" ? `/api/canvas/download-zip-task?taskId=${task.id}&download=1` : "" };
}
async function processTask(id: string, userId: string, requestOrigin: string, cookie: string) {
if (active.has(id) || active.size >= 2) return;
active.add(id);
let release: (() => Promise<void>) | null = null;
let task: ZipTask | null = null;
try {
release = await claimZipTask(id);
if (!release) return;
task = await loadZipTask(id, userId);
if (!task || task.status === "completed" || task.status === "failed") return;
await assertZipDiskSpace();
task.status = "running"; task.stage = "正在准备原图"; task.updatedAt = Date.now();
await saveZipTask(task);
// Stage each image on disk, not all PNG buffers in the web process heap.
// A restart resumes from these files using the current owner's session.
let cursor = 0;
let writes = Promise.resolve();
const persist = () => { const current = task!; writes = writes.then(() => saveZipTask(current)); return writes; };
const results = await Promise.allSettled(Array.from({ length: Math.min(2, task.files.length) }, async () => {
while (cursor < task!.files.length) {
const index = cursor++; const file = task!.files[index];
const path = join(zipTaskPath(id), `${index}.image`);
if (file.prepared && file.filename && (await stat(path).catch(() => null))?.size) continue;
const result = await fetchCanvasDownloadAsset({ file, index, requestOrigin, cookie,
signal: AbortSignal.timeout(120_000) }).catch(() => ({ ok: false as const, error: "原图读取超时或源地址暂时不可用" }));
if (result.ok) {
await assertZipDiskSpace(result.bytes.length);
await writeFile(path, result.bytes, { mode: 0o600 });
file.prepared = `${index}.image`; file.filename = result.filename; delete file.error;
} else file.error = result.error;
task!.processedCount = task!.files.filter(f => f.prepared || f.error).length;
task!.successCount = task!.files.filter(f => f.prepared).length;
task!.failedCount = task!.files.filter(f => f.error).length;
task!.stage = `正在准备原图 ${task!.processedCount}/${task!.totalCount}`;
task!.updatedAt = Date.now(); await persist();
}
}));
const rejected = results.find(result => result.status === "rejected");
if (rejected?.status === "rejected") throw rejected.reason;
if (!task.successCount) throw new Error("所选原图暂时都无法读取,请稍后重试;画布图片不会被删除");
const zip = new JSZip(); const folder = zip.folder(task.folderName) || zip;
for (const file of task.files) if (file.prepared && file.filename)
folder.file(file.filename, createReadStream(join(zipTaskPath(id), file.prepared)));
if (task.failedCount) folder.file("_download-failures.txt", task.files.filter(f => f.error)
.map(f => `${f.name || "图片"}:${f.error}`).join("\n"));
task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task);
task.zipFile = await writeDownloadZip(zip, task.id, percent => { task!.compressPercent = percent; });
task.contentDisposition = buildContentDisposition(task.folderName);
task.stage = "正在准备下载链接"; await saveZipTask(task);
try {
const cdn = await publishCanvasZip({ id: task.id, ...task.zipFile, disposition: task.contentDisposition });
if (cdn) {
task.cdnObject = { storageConfigId: cdn.storageConfigId, key: cdn.key };
if (cdn.url) task.cdnUrl = cdn.url;
}
} catch { console.warn("[CanvasDownloadZipTask] CDN delivery unavailable; complete local ZIP retained", { taskId: id }); }
task.status = "completed"; task.stage = task.failedCount ? `已打包 ${task.successCount} 张,${task.failedCount} 张未能读取` : "打包完成";
task.compressPercent = 100; task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS;
await saveZipTask(task);
// The complete ZIP is enough for retries; release staged duplicate bytes.
await Promise.all(task.files.filter(f => f.prepared).map(f => rm(join(zipTaskPath(id), f.prepared!), { force: true }).catch(() => undefined)));
console.info("[CanvasDownloadZipTask] Complete", { taskId: id, count: task.successCount, failed: task.failedCount,
bytes: task.zipFile.bytes, delivery: task.cdnUrl ? "cdn" : "local" });
} catch (error) {
if (task) {
task.status = "failed"; task.stage = "打包未完成";
task.error = error instanceof Error ? error.message : "打包失败,请稍后重试";
task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS; await saveZipTask(task).catch(() => undefined);
}
console.error("[CanvasDownloadZipTask] Preparation failed", { taskId: id, message: task?.error });
} finally { await release?.().catch(() => undefined); active.delete(id); }
}
function schedule(task: ZipTask, request: NextRequest) {
const run = () => processTask(task.id, task.userId, getRequestOrigin(request), request.headers.get("cookie") || "");
after(run);
}
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
await cleanupZipTasks();
const body = await request.json();
let id = typeof body.taskId === "string" ? body.taskId : randomUUID();
let task = await loadZipTask(id, session.user.id);
if (!task) {
await assertZipTaskCapacity(session.user.id);
if (await stat(zipTaskPath(id)).catch(() => null)) id = randomUUID();
const files = toDownloadItems(body.files, getRequestOrigin(request));
if (!files.length || !Array.isArray(body.files) || body.files.length > MAX_DOWNLOAD_FILES || files.length !== body.files.length)
return NextResponse.json({ error: "请选择 1 到 200 张可下载图片" }, { status: 400 });
// Exclusive creation prevents an arbitrary taskId from overwriting another owner's manifest.
await mkdir(zipTaskPath(id), { recursive: false, mode: 0o700 }).catch(async error => {
if (error.code !== "ENOENT") throw error;
await mkdir((await import("@/lib/canvas/download-zip-stream")).downloadZipDirectory, { recursive: true, mode: 0o700 });
await mkdir(zipTaskPath(id), { mode: 0o700 });
});
const now = Date.now();
task = { id, userId: session.user.id, folderName: sanitizeFolderName(String(body.folderName || "")), files,
totalCount: files.length, processedCount: 0, successCount: 0, failedCount: 0, compressPercent: 0,
status: "pending", stage: "等待后台准备", createdAt: now, updatedAt: now, expiresAt: now + 6 * TASK_TTL_MS };
await saveZipTask(task);
} else if (task.status === "failed" && body.retry === true) {
task.status = "pending"; task.error = undefined;
await assertZipTaskCapacity(session.user.id);
for (const file of task.files) delete file.error;
task.processedCount = task.successCount = task.files.filter(file => file.prepared).length;
task.failedCount = 0; task.updatedAt = Date.now(); task.expiresAt = Date.now() + 6 * TASK_TTL_MS;
await saveZipTask(task);
}
if (task.status === "pending" || task.status === "running") schedule(task, request);
return NextResponse.json({ success: true, task: serializeTask(task) }, { status: 202, headers: { "Cache-Control": "no-store" } });
} catch (error) {
console.warn("[CanvasDownloadZipTask] Create rejected", { message: (error as Error).message });
return NextResponse.json({ error: "无法创建打包任务,请稍后重试" }, { status: 400 });
}
}
export async function GET(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const id = request.nextUrl.searchParams.get("taskId") || "";
const task = await loadZipTask(id, session.user.id);
if (!task) return NextResponse.json({ error: "打包任务不存在或已过期,请重新打包" }, { status: 404 });
if (request.nextUrl.searchParams.get("download") === "1") {
if (task.status !== "completed" || !task.zipFile) return NextResponse.json({ error: "ZIP 尚未准备好" }, { status: 409 });
if (task.cdnUrl && request.nextUrl.searchParams.get("local") !== "1")
return NextResponse.redirect(task.cdnUrl, 307);
const info = await stat(task.zipFile.path).catch(() => null);
if (!info || info.size !== task.zipFile.bytes) return NextResponse.json({ error: "下载文件已过期,请重新打包" }, { status: 410 });
const etag = `"${task.id}-${info.size}"`;
const headers: Record<string, string> = { "Content-Type": "application/zip", "Content-Disposition": task.contentDisposition!,
"Accept-Ranges": "bytes", "ETag": etag, "Cache-Control": "private, no-store",
"X-Download-Success-Count": String(task.successCount), "X-Download-Failed-Count": String(task.failedCount) };
const ifRange = request.headers.get("if-range");
let range;
try { range = parseDownloadByteRange(!ifRange || ifRange === etag ? request.headers.get("range") : null, info.size); }
catch { return new NextResponse(null, { status: 416, headers: { ...headers, "Content-Range": `bytes */${info.size}` } }); }
headers["Content-Length"] = String(range ? range.end - range.start + 1 : info.size);
if (range) headers["Content-Range"] = `bytes ${range.start}-${range.end}/${info.size}`;
return new NextResponse(readDownloadZip(task.zipFile.path, range || undefined), { status: range ? 206 : 200, headers });
}
if (task.status === "pending" || task.status === "running") schedule(task, request);
return NextResponse.json({ success: true, task: serializeTask(task) }, { headers: { "Cache-Control": "no-store" } });
} catch {
return NextResponse.json({ error: "查询打包任务失败,请稍后重试" }, { status: 500 });
}
}