Document picker code and architecture

This commit is contained in:
“Naeel”
2026-09-05 13:24:00 +03:00
parent 13ea0efcd7
commit e4b4dca53a
18 changed files with 437 additions and 2 deletions
+34
View File
@@ -1,15 +1,26 @@
// Построить дерево ZIP с файлами только разрешённых расширений.
import { rebaseTree } from '../table/rebase_tree.js';
/** Возвращает 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);
}
/**
* Валидирует путь 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('/');
@@ -17,10 +28,12 @@ function safeEntryParts(entryName) {
return parts;
}
/** Создаёт единый узел file, folder или zip с уникальным id и раскрытым состоянием. */
function node(kind, name, path, children = [], file = null) {
return { id: crypto.randomUUID(), kind, name, path, children, file, expanded: true };
}
/** Вставляет leaf или вложенное дерево по сегментам пути, создавая folder-узлы. */
function addPath(root, parts, fileNode) {
let current = root;
parts.forEach((part, index) => {
@@ -36,17 +49,30 @@ function addPath(root, parts, fileNode) {
});
}
/**
* Рекурсивно читает байтовый массив ZIP и строит дерево разрешённых файлов.
*
* @param {Uint8Array} data Распаковываемые байты ZIP.
* @param {string} zipName Имя текущего архива для корневого узла и пути.
* @param {string[]} allowedExt Разрешённые расширения документов.
* @param {number} depth Текущая глубина вложенных ZIP.
* @returns {Promise<object|null>} Дерево или null, если разрешённых leaf нет.
* @throws {Error} При превышении глубины или повреждённом ZIP.
*/
async function listEntries(data, zipName, allowedExt, depth) {
// Ограничение глубины защищает браузер от бесконечной/чрезмерной рекурсии.
if (depth > 20) throw new Error('Слишком глубокая вложенность ZIP');
const entries = fflate.unzipSync(data);
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);
if (nested) {
rebaseTree(nested, zipName);
@@ -54,6 +80,7 @@ async function listEntries(data, zipName, allowedExt, depth) {
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));
@@ -62,6 +89,13 @@ async function listEntries(data, zipName, allowedExt, depth) {
return root.children.length ? root : null;
}
/**
* Публично читает выбранный ZIP-файл и возвращает его фильтрованное дерево.
*
* @param {File} file Browser File с ZIP-содержимым.
* @param {string[]} allowedExt Разрешённые расширения.
* @returns {Promise<object|null>} Корневой zip-узел или null для пустого результата.
*/
export async function listZipFiles(file, allowedExt) {
const data = new Uint8Array(await file.arrayBuffer());
return listEntries(data, file.name, allowedExt, 0);