import AdmZip from "adm-zip"; import { randomUUID } from "node:crypto"; import { promises as fs } from "node:fs"; import path from "node:path"; import { PrototypeSummary } from "@/lib/prototype"; interface PrototypeRecord { id: string; name: string; description: string; entryFile: string; createdAt: string; } const DATA_ROOT = path.join(process.cwd(), "data", "prototypes"); const INDEX_FILE = path.join(DATA_ROOT, "index.json"); const MAX_UPLOAD_BYTES = 200 * 1024 * 1024; const MAX_FILES = 20_000; const MAX_UNCOMPRESSED_BYTES = 600 * 1024 * 1024; let writeQueue: Promise = Promise.resolve(); function queueWrite(work: () => Promise): Promise { const next = writeQueue.then(work, work); writeQueue = next.then( () => undefined, () => undefined, ); return next; } async function ensureDataRoot() { await fs.mkdir(DATA_ROOT, { recursive: true }); } async function readIndex(): Promise { await ensureDataRoot(); try { const raw = await fs.readFile(INDEX_FILE, "utf-8"); const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) { return []; } return parsed.filter((item): item is PrototypeRecord => { return ( typeof item?.id === "string" && typeof item?.name === "string" && typeof item?.description === "string" && typeof item?.entryFile === "string" && typeof item?.createdAt === "string" ); }); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return []; } throw error; } } async function writeIndex(records: PrototypeRecord[]) { await ensureDataRoot(); const tempFile = `${INDEX_FILE}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`; await fs.writeFile(tempFile, JSON.stringify(records, null, 2), "utf-8"); await fs.rename(tempFile, INDEX_FILE); } function toSummary(record: PrototypeRecord): PrototypeSummary { return { ...record, viewUrl: `/api/prototypes/${encodeURIComponent(record.id)}/${encodePathSegments(record.entryFile)}`, }; } function generateId() { return `proto-${Date.now()}-${randomUUID().slice(0, 8)}`; } function isPathInside(rootDir: string, candidatePath: string) { const root = path.resolve(rootDir); const candidate = path.resolve(candidatePath); return candidate === root || candidate.startsWith(`${root}${path.sep}`); } function sanitizeZipEntry(entryName: string) { const normalized = entryName.replace(/\\/g, "/"); const cleaned = path.posix.normalize(normalized); if (!cleaned || cleaned === ".") { return null; } if ( cleaned.startsWith("../") || cleaned === ".." || cleaned.startsWith("/") || /^[A-Za-z]:/.test(cleaned) ) { throw new Error(`ZIP 中包含非法路径:${entryName}`); } return cleaned; } function detectMimeType(filePath: string) { const ext = path.extname(filePath).toLowerCase(); switch (ext) { case ".html": case ".htm": return "text/html; charset=utf-8"; case ".css": return "text/css; charset=utf-8"; case ".js": return "application/javascript; charset=utf-8"; case ".json": return "application/json; charset=utf-8"; case ".svg": return "image/svg+xml"; case ".png": return "image/png"; case ".jpg": case ".jpeg": return "image/jpeg"; case ".gif": return "image/gif"; case ".webp": return "image/webp"; case ".ico": return "image/x-icon"; case ".woff": return "font/woff"; case ".woff2": return "font/woff2"; case ".ttf": return "font/ttf"; default: return "application/octet-stream"; } } function encodePathSegments(relativePath: string) { return relativePath .split("/") .map((segment) => encodeURIComponent(segment)) .join("/"); } export async function listPrototypes(): Promise { const records = await readIndex(); return records .slice() .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) .map(toSummary); } export async function findPrototypeById(id: string): Promise { const records = await readIndex(); const found = records.find((item) => item.id === id); return found ? toSummary(found) : null; } export async function createPrototypeFromZip(input: { name: string; description?: string; zipFileName: string; zipBuffer: Buffer; }): Promise { return queueWrite(async () => { const name = input.name.trim(); if (!name) { throw new Error("原型名称不能为空"); } if (!input.zipFileName.toLowerCase().endsWith(".zip")) { throw new Error("仅支持上传 ZIP 文件"); } if (input.zipBuffer.byteLength > MAX_UPLOAD_BYTES) { throw new Error("上传文件过大,请控制在 200MB 以内"); } const zip = new AdmZip(input.zipBuffer); const entries = zip.getEntries(); if (!entries.length) { throw new Error("ZIP 文件为空"); } if (entries.length > MAX_FILES) { throw new Error(`ZIP 文件数量过多(>${MAX_FILES})`); } await ensureDataRoot(); const id = generateId(); const tempDir = path.join(DATA_ROOT, `.tmp-${id}`); const finalDir = path.join(DATA_ROOT, id); const extractedFiles: string[] = []; let totalUncompressed = 0; await fs.mkdir(tempDir, { recursive: true }); try { for (const entry of entries) { const safePath = sanitizeZipEntry(entry.entryName); if (!safePath) { continue; } const target = path.join(tempDir, ...safePath.split("/")); if (!isPathInside(tempDir, target)) { throw new Error(`ZIP 中包含非法路径:${entry.entryName}`); } if (entry.isDirectory) { await fs.mkdir(target, { recursive: true }); continue; } totalUncompressed += entry.header.size; if (totalUncompressed > MAX_UNCOMPRESSED_BYTES) { throw new Error("解压后文件总体积过大"); } await fs.mkdir(path.dirname(target), { recursive: true }); await fs.writeFile(target, entry.getData()); extractedFiles.push(safePath); } const startCandidates = extractedFiles .filter((item) => item.toLowerCase().endsWith("/start.html") || item.toLowerCase() === "start.html") .sort((a, b) => a.length - b.length); if (!startCandidates.length) { throw new Error("ZIP 中未找到 start.html"); } const entryFile = startCandidates[0]; await fs.rm(finalDir, { recursive: true, force: true }); await fs.rename(tempDir, finalDir); const records = await readIndex(); const record: PrototypeRecord = { id, name, description: input.description?.trim() ?? "", createdAt: new Date().toISOString(), entryFile, }; records.push(record); await writeIndex(records); return toSummary(record); } catch (error) { await fs.rm(tempDir, { recursive: true, force: true }); throw error; } }); } export async function deletePrototype(id: string): Promise { return queueWrite(async () => { const records = await readIndex(); const target = records.find((item) => item.id === id); if (!target) { return false; } const remained = records.filter((item) => item.id !== id); await writeIndex(remained); const targetDir = path.join(DATA_ROOT, id); if (isPathInside(DATA_ROOT, targetDir)) { await fs.rm(targetDir, { recursive: true, force: true }); } return true; }); } export async function readPrototypeFile(id: string, relativePath?: string) { const records = await readIndex(); const target = records.find((item) => item.id === id); if (!target) { return null; } const requested = relativePath?.trim() ? relativePath : target.entryFile; const normalized = sanitizeZipEntry(requested); if (!normalized) { return null; } const filePath = path.join(DATA_ROOT, id, ...normalized.split("/")); if (!isPathInside(path.join(DATA_ROOT, id), filePath)) { return null; } try { const stat = await fs.stat(filePath); if (!stat.isFile()) { return null; } const content = await fs.readFile(filePath); return { content, mimeType: detectMimeType(filePath), size: stat.size, lastModified: stat.mtime.toUTCString(), }; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return null; } throw error; } }