import AdmZip from "adm-zip"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; export class ZipValidationError extends Error { constructor(message: string) { super(message); this.name = "ZipValidationError"; } } export function validateZipBuffer( buffer: Buffer, maxBytes: number, ): { normalizedBuffer: Buffer } { if (buffer.length > maxBytes) { throw new ZipValidationError( `Zip file exceeds ${Math.floor(maxBytes / (1024 * 1024))}MB limit`, ); } let zip: AdmZip; try { zip = new AdmZip(buffer); } catch { throw new ZipValidationError("Invalid zip archive"); } const entries = zip.getEntries().filter((entry) => !entry.isDirectory); if (entries.length === 0) { throw new ZipValidationError("Zip archive is empty"); } const indexEntry = entries.find((entry) => { const normalized = entry.entryName.replace(/\\/g, "/"); if (normalized === "index.html") return true; const parts = normalized.split("/"); return parts.length === 2 && parts[1] === "index.html"; }); if (!indexEntry) { throw new ZipValidationError( "Zip must contain index.html at root or in a single top-level folder", ); } const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "web2app-")); try { zip.extractAllTo(tmpDir, true); const rootIndex = path.join(tmpDir, "index.html"); if (!fs.existsSync(rootIndex)) { const subdirs = fs .readdirSync(tmpDir, { withFileTypes: true }) .filter((d) => d.isDirectory()); if (subdirs.length !== 1) { throw new ZipValidationError( "index.html must be at zip root or inside exactly one folder", ); } const nestedRoot = path.join(tmpDir, subdirs[0].name); flattenDirectory(nestedRoot, tmpDir); } const normalizedZip = new AdmZip(); addDirectoryToZip(normalizedZip, tmpDir, ""); return { normalizedBuffer: normalizedZip.toBuffer() }; } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } } function flattenDirectory(sourceDir: string, targetDir: string) { for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) { const from = path.join(sourceDir, entry.name); const to = path.join(targetDir, entry.name); if (entry.isDirectory()) { fs.cpSync(from, to, { recursive: true }); } else { fs.copyFileSync(from, to); } } } function addDirectoryToZip(zip: AdmZip, dir: string, prefix: string) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const fullPath = path.join(dir, entry.name); const entryName = prefix ? `${prefix}/${entry.name}` : entry.name; if (entry.isDirectory()) { addDirectoryToZip(zip, fullPath, entryName); } else { zip.addFile(entryName, fs.readFileSync(fullPath)); } } } const JAVA_RESERVED_SEGMENTS = new Set([ "abstract", "assert", "boolean", "break", "byte", "case", "catch", "char", "class", "const", "continue", "default", "do", "double", "else", "enum", "extends", "final", "finally", "float", "for", "goto", "if", "implements", "import", "instanceof", "int", "interface", "long", "native", "new", "package", "private", "protected", "public", "return", "short", "static", "strictfp", "super", "switch", "synchronized", "this", "throw", "throws", "transient", "try", "void", "volatile", "while", ]); function sanitizeSegment(segment: string): string { let value = segment.toLowerCase().replace(/[^a-z0-9_]/g, ""); if (!value) return "app"; if (/^\d/.test(value)) value = `app${value}`; if (JAVA_RESERVED_SEGMENTS.has(value)) value = `${value}app`; return value.slice(0, 48); } export function validateEnglishAppName(input: string): string { const value = input.trim(); if (!value) { throw new ZipValidationError("应用英文名不能为空"); } if (!/^[a-zA-Z][a-zA-Z0-9 _.-]*$/.test(value)) { throw new ZipValidationError( "应用英文名需以字母开头,仅支持英文字母、数字、空格、下划线和连字符", ); } return value; } export function validateChineseAppName(input: string): string { const value = input.trim(); if (!value) { throw new ZipValidationError("应用中文名不能为空"); } return value; } export function slugifyIdentifier(input: string): string { return input .toLowerCase() .replace(/[^a-z0-9]+/g, ".") .replace(/^\.+|\.+$/g, "") .split(".") .map(sanitizeSegment) .filter(Boolean) .join(".") .slice(0, 120); } export function normalizeAppIdentifier(identifier: string): string { const parts = identifier .trim() .split(".") .map(sanitizeSegment) .filter(Boolean); if (parts.length < 2) { throw new ZipValidationError( "Bundle ID 至少需要两段,例如 com.example.myapp", ); } return parts.join("."); }