// Построить дерево ZIP с файлами только разрешённых расширений. import { unzipSync } from 'fflate'; import { rebaseTree } from '../table/rebase_tree.js'; const DEFAULT_LIMITS = { maxEntries: 1000, maxTotalBytes: 100 * 1024 * 1024, maxEntryBytes: 50 * 1024 * 1024, maxDepth: 20, }; /** Возвращает true, если имя заканчивается одним из разрешённых расширений. */ function extensionAllowed(name, allowedExt) { const lowerName = name.toLowerCase(); return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase())); } /** Создаёт browser File из байтов ZIP entry с базовым именем файла. */ function makeFile(data, name) { return new File([data], name.split('/').at(-1)); } /** * Валидирует путь ZIP entry до его использования в дереве. * * @param {string} entryName Сырой путь из центрального каталога ZIP. * @returns {string[]|null} Безопасные сегменты пути или null для опасной записи. * * Отклоняются абсолютные пути, backslash, пустые сегменты, `.` и `..`. * Это предотвращает traversal и появление ложных групп в UI. */ function safeEntryParts(entryName) { if (!entryName || entryName.startsWith('/') || entryName.includes('\\')) return null; const parts = entryName.split('/'); if (parts.some((part) => !part || part === '.' || part === '..')) return null; return parts; } /** Создаёт единый узел file, folder или zip с уникальным id и начальным состоянием раскрытия. */ function node(kind, name, path, children = [], file = null) { return { id: crypto.randomUUID(), kind, name, path, children, file, expanded: kind === 'file' }; } /** Вставляет leaf или вложенное дерево по сегментам пути, создавая folder-узлы. */ function addPath(root, parts, fileNode) { let current = root; parts.forEach((part, index) => { const last = index === parts.length - 1; let child = current.children.find((item) => item.name === part); if (!child) { child = last ? fileNode : node('folder', part, `${current.path}/${part}`); current.children.push(child); } current = child; }); } /** * Рекурсивно читает байтовый массив ZIP и строит дерево разрешённых файлов. * * @param {Uint8Array} data Распаковываемые байты ZIP. * @param {string} zipName Имя текущего архива для корневого узла и пути. * @param {string[]} allowedExt Разрешённые расширения документов. * @param {number} depth Текущая глубина вложенных ZIP. * @returns {Promise} Дерево или null, если разрешённых leaf нет. * @throws {Error} При превышении глубины или повреждённом ZIP. */ async function listEntries(data, zipName, allowedExt, depth, limits, budget) { // Ограничение глубины защищает браузер от бесконечной/чрезмерной рекурсии. if (depth > limits.maxDepth) throw new Error('Слишком глубокая вложенность ZIP'); const entries = unzipSync(data, { filter: (entry) => { budget.entries += 1; if (budget.entries > limits.maxEntries) throw new Error('Слишком много ZIP entries'); const lowerName = entry.name.toLowerCase(); if (!extensionAllowed(lowerName, allowedExt) && !lowerName.endsWith('.zip')) return false; if (entry.originalSize > limits.maxEntryBytes) return false; if (budget.totalBytes + entry.originalSize > limits.maxTotalBytes) { throw new Error('Превышен суммарный размер распакованных ZIP entries'); } budget.totalBytes += entry.originalSize; return true; }, }); const root = node('zip', zipName, zipName); for (const [entryName, entryData] of Object.entries(entries)) { // Каталоги ZIP не являются файлами и будут созданы addPath при необходимости. if (entryName.endsWith('/')) continue; const parts = safeEntryParts(entryName); if (!parts) continue; const normalizedName = parts.join('/'); if (normalizedName.toLowerCase().endsWith('.zip')) { // Вложенный ZIP раскрывается только если внутри есть разрешённые leaf-файлы. const nested = await listEntries(entryData, entryName, allowedExt, depth + 1, limits, budget); if (nested) { rebaseTree(nested, zipName); nested.name = parts.at(-1); addPath(root, parts, nested); } } else if (extensionAllowed(normalizedName, allowedExt)) { // Запрещённые документы отбрасываются до создания browser File. const path = `${zipName}/${normalizedName}`; const file = makeFile(entryData, path); addPath(root, parts, node('file', parts.at(-1), path, [], file)); } } return root.children.length ? root : null; } /** * Публично читает выбранный ZIP-файл и возвращает его фильтрованное дерево. * * @param {File} file Browser File с ZIP-содержимым. * @param {string[]} allowedExt Разрешённые расширения. * @param {object} customLimits Ограничения распаковки. * @returns {Promise} Корневой zip-узел или null для пустого результата. */ export async function listZipFiles(file, allowedExt, customLimits = {}) { const data = new Uint8Array(await file.arrayBuffer()); const limits = { ...DEFAULT_LIMITS, ...customLimits }; return listEntries(data, file.name, allowedExt, 0, limits, { entries: 0, totalBytes: 0 }); }