import JSZip from "jszip"; import { NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; import { getAllStorageConfigs } from "@/lib/db/queries"; import { countOpenClawArchiveRootFolders, sanitizeOpenClawArchiveEntryPath, sanitizeOpenClawArchiveName, } from "@/lib/openclaw-chat/folder-archive"; import { OPENCLAW_CHAT_FOLDER_MAX_FILES, } from "@/lib/openclaw-chat/media-attachments"; import { createLogger } from "@/lib/observability/logger"; import { toDbS3UploadConfig, uploadFileWithS3Config, } from "@/lib/storage/service"; export const runtime = "nodejs"; export const maxDuration = 600; const logger = createLogger("openclaw-folder-upload"); const LEGACY_FOLDER_UPLOAD_MAX_BYTES = 300 * 1024 * 1024; function isUploadedFile(value: FormDataEntryValue): value is File { return ( typeof value === "object" && value !== null && "arrayBuffer" in value && "name" in value && "size" in value ); } function toArrayBuffer(buffer: Buffer) { return buffer.buffer.slice( buffer.byteOffset, buffer.byteOffset + buffer.byteLength ) as ArrayBuffer; } export async function POST(request: Request) { const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ error: "请先登录" }, { status: 401 }); } try { const formData = await request.formData(); const files = formData .getAll("files") .filter((entry): entry is File => isUploadedFile(entry)); const relativePaths = formData .getAll("relativePaths") .map((entry) => (typeof entry === "string" ? entry : "")); if (files.length === 0) { return NextResponse.json( { error: "所选文件夹中没有可上传文件" }, { status: 400 } ); } if (files.length > OPENCLAW_CHAT_FOLDER_MAX_FILES) { return NextResponse.json( { error: `单次最多打包 ${OPENCLAW_CHAT_FOLDER_MAX_FILES} 个文件` }, { status: 413 } ); } const originalSize = files.reduce((sum, file) => sum + file.size, 0); if (originalSize > LEGACY_FOLDER_UPLOAD_MAX_BYTES) { return NextResponse.json( { error: "请刷新页面后使用支持 2GB 和断点续传的新文件夹上传方式" }, { status: 413 } ); } const archivePaths = files.map((file, index) => sanitizeOpenClawArchiveEntryPath( relativePaths[index] || file.name, file.name, index ) ); const zip = new JSZip(); for (const [index, file] of files.entries()) { zip.file(archivePaths[index], await file.arrayBuffer(), { date: new Date(file.lastModified || Date.now()), }); } const archiveBaseName = sanitizeOpenClawArchiveName( String(formData.get("archiveName") || archivePaths[0]?.split("/")[0] || "") ); const archiveName = `${archiveBaseName.replace(/\.zip$/i, "")}.zip`; const archiveBuffer = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE", compressionOptions: { level: 6 }, platform: "UNIX", }); const storageConfigs = await getAllStorageConfigs(); const qiniuStorage = storageConfigs .filter((config) => config.type === "qiniu-kodo" && config.isEnabled) .sort((left, right) => Number(right.isDefault) - Number(left.isDefault)) .find((config) => toDbS3UploadConfig(config)); const qiniuConfig = toDbS3UploadConfig(qiniuStorage); if (!qiniuConfig) { return NextResponse.json( { error: "系统尚未配置可用的七牛云储存桶" }, { status: 503 } ); } const uploaded = await uploadFileWithS3Config( archiveName, toArrayBuffer(archiveBuffer), qiniuConfig ); if (!/^https?:\/\//i.test(uploaded.url)) { throw new Error("七牛云未返回可访问的公网地址"); } return NextResponse.json({ url: uploaded.url, name: archiveName, contentType: "application/zip", size: archiveBuffer.byteLength, originalSize, fileCount: files.length, folderCount: countOpenClawArchiveRootFolders(archivePaths), }); } catch (error) { logger.error("Failed to package and upload Claw folders", error); return NextResponse.json( { error: error instanceof Error ? error.message : "文件夹上传失败" }, { status: 500 } ); } }