// Shared CodeHub rules: file paths, size limits, content types and binary storage (browser + server safe). export const CODEHUB_LIMITS = { maxFiles: 200, maxFileBytes: 512 * 1024, maxBinaryFileBytes: 2 * 1024 * 1024, maxTotalBytes: 8 * 1024 * 1024, } as const; const TEXT_EXT = [ "html", "htm", "css", "js", "mjs", "cjs", "jsx", "ts", "tsx", "mts", "cts", "json", "md", "txt", "svg", "xml", "csv", "yml", "yaml", "toml", "ini", "conf", "py", "sh", "rb", "go", "php", "sql", "graphql", "vue", "svelte", "astro", "ics", "map", "env.example", "gitignore", ]; const BINARY_MIME: Record = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", avif: "image/avif", bmp: "image/bmp", ico: "image/x-icon", woff: "font/woff", woff2: "font/woff2", ttf: "font/ttf", otf: "font/otf", eot: "application/vnd.ms-fontobject", pdf: "application/pdf", }; export type CodeFiles = Record; const SENSITIVE_SUFFIX = [".pem", ".key", ".p12", ".pfx", ".p8"]; const SENSITIVE_NAMES = [".env", ".npmrc", ".htpasswd", ".htaccess", "id_rsa", ".aws", ".ssh", ".vercel"]; /** Normalises a file path; returns null for unsafe, ignored or secret-bearing paths. */ export function cleanPath(raw: string): string | null { const parts = raw.replace(/\\/g, "/").split("/").filter((p) => p && p !== "."); if (!parts.length || parts.some((p) => p === ".." || p === ".git" || p === "node_modules" || p === "__MACOSX" || p === ".DS_Store")) return null; const path = parts.join("/"); const name = (parts[parts.length - 1] ?? "").toLowerCase(); if (SENSITIVE_NAMES.includes(name)) return null; // .env.example is documentation, .env / .env.production would leak secrets onto a public site. if (name === ".env" || (name.startsWith(".env.") && name !== ".env.example")) return null; if (SENSITIVE_SUFFIX.some((s) => name.endsWith(s))) return null; return path.length > 200 ? null : path; } export function isTextFile(path: string) { const name = path.split("/").pop()!.toLowerCase(); if (!name.includes(".")) return true; return TEXT_EXT.some((ext) => name.endsWith(`.${ext}`)); } export function isBinaryFile(path: string) { const ext = path.split(".").pop()?.toLowerCase() ?? ""; return Object.hasOwn(BINARY_MIME, ext); } /** Anything CodeHub can store: editable code/text plus images, fonts and PDFs. */ export function isSupportedFile(path: string) { return isTextFile(path) || isBinaryFile(path); } export function binaryMime(path: string) { return BINARY_MIME[path.split(".").pop()?.toLowerCase() ?? ""] ?? "application/octet-stream"; } /** Binary files are stored inside the JSON document as data URLs. */ export function toDataUrl(path: string, base64: string) { return `data:${binaryMime(path)};base64,${base64}`; } export function parseDataUrl(value: string): { mime: string; base64: string } | null { if (!value.startsWith("data:")) return null; const comma = value.indexOf(","); if (comma < 0) return null; const header = value.slice(5, comma); if (!header.includes(";base64")) return null; return { mime: header.split(";")[0] || "application/octet-stream", base64: value.slice(comma + 1) }; } export function isBinaryValue(value: string) { return parseDataUrl(value) !== null; } export function isImageValue(value: string) { return parseDataUrl(value)?.mime.startsWith("image/") ?? false; } export function base64Bytes(b64: string) { const pad = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0; return Math.max(0, Math.floor((b64.length * 3) / 4) - pad); } /** Decoded size of a stored file, whether it is text or a data URL. */ export function fileBytes(body: string) { const data = parseDataUrl(body); return data ? base64Bytes(data.base64) : new TextEncoder().encode(body).length; } export function contentType(path: string) { const ext = path.split(".").pop()?.toLowerCase() ?? ""; if (BINARY_MIME[ext]) return BINARY_MIME[ext]; return ({ html: "text/html", htm: "text/html", css: "text/css", js: "application/javascript", mjs: "application/javascript", cjs: "application/javascript", jsx: "application/javascript", json: "application/json", svg: "image/svg+xml", txt: "text/plain", ts: "text/plain", tsx: "text/plain", md: "text/markdown", xml: "application/xml", csv: "text/csv", yml: "text/plain", yaml: "text/plain", map: "application/json", ico: "image/x-icon", } as Record)[ext] ?? "text/plain"; } export function formatBytes(n: number) { return n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(1)} KB` : `${(n / (1024 * 1024)).toFixed(1)} MB`; } /** Checks a file set against CodeHub limits; returns an error message or null. */ export function validateFiles(files: CodeFiles): string | null { const entries = Object.entries(files); if (entries.length > CODEHUB_LIMITS.maxFiles) return `Too many files (max ${CODEHUB_LIMITS.maxFiles}).`; let total = 0; for (const [path, body] of entries) { if (cleanPath(path) !== path) return `Invalid file name: ${path}`; const binary = isBinaryFile(path); const size = fileBytes(body); if (binary && size > CODEHUB_LIMITS.maxBinaryFileBytes) return `${path} is larger than 2 MB.`; if (!binary && size > CODEHUB_LIMITS.maxFileBytes) return `${path} is larger than 512 KB.`; total += size; } return total > CODEHUB_LIMITS.maxTotalBytes ? "Project is larger than 8 MB." : null; } /** Strips a single shared top folder (common in ZIP exports). */ export function stripCommonRoot(files: CodeFiles): CodeFiles { const paths = Object.keys(files); const first = paths[0]?.split("/")[0]; if (!first || !paths.every((p) => p.startsWith(`${first}/`))) return files; return Object.fromEntries(Object.entries(files).map(([p, b]) => [p.slice(first.length + 1), b])); }