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 } }