fix: harden browser file picker and remove legacy
This commit is contained in:
+3
-3
@@ -25,9 +25,9 @@ Picker поддерживает разрешённые документы, па
|
||||
|
||||
## Legacy
|
||||
|
||||
Каталоги `backend/` и старые ссылки на `uploadViaVM`/VM-буфер относятся к
|
||||
предыдущей архитектуре и сохранены как legacy-остатки. Для текущей picker-only
|
||||
интеграции они не нужны.
|
||||
Старые `uploadViaVM`, VM-буфер, backend sessions и upload API относятся к
|
||||
предыдущей архитектуре и в текущем модуле отсутствуют. Упоминания этих названий
|
||||
в `HISTORY/` являются историческими и не должны использоваться как инструкция.
|
||||
|
||||
Подробная таблица функций, входов, выходов и побочных эффектов находится в
|
||||
[`../docs/CODE-REFERENCE.md`](../docs/CODE-REFERENCE.md).
|
||||
|
||||
@@ -120,6 +120,7 @@ export function initFilePicker(config) {
|
||||
const elements = {
|
||||
fileInputEl: root.querySelector('.fp-file-input'),
|
||||
folderInputEl: root.querySelector('.fp-folder-input'),
|
||||
statusEl: root.querySelector('.fp-status'),
|
||||
tableBodyEl: root.querySelector('.fp-table-body'),
|
||||
countEl: root.querySelector('.fp-count'),
|
||||
};
|
||||
|
||||
@@ -27,6 +27,28 @@ export function addFileWithDedup(state, fileNode) {
|
||||
const accepted = accept(fileNode);
|
||||
// Пустой ZIP/каталог не должен появляться в таблице как пустая строка.
|
||||
if (!accepted || (accepted.kind !== 'file' && !accepted.children.length)) return false;
|
||||
if (accepted.kind !== 'file') {
|
||||
const existing = state.nodes.find((node) => node.kind !== 'file' && node.path === accepted.path);
|
||||
if (existing) {
|
||||
mergeChildren(existing, accepted);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
state.nodes.push(accepted);
|
||||
return true;
|
||||
}
|
||||
|
||||
function mergeChildren(target, incoming) {
|
||||
incoming.children.forEach((child) => {
|
||||
if (child.kind === 'file') {
|
||||
const duplicate = target.children.some((existing) => existing.kind === 'file'
|
||||
&& existing.path === child.path && existing.file.size === child.file.size);
|
||||
if (!duplicate) target.children.push(child);
|
||||
return;
|
||||
}
|
||||
const existing = target.children.find((candidate) => candidate.kind !== 'file'
|
||||
&& candidate.path === child.path);
|
||||
if (existing) mergeChildren(existing, child);
|
||||
else target.children.push(child);
|
||||
});
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { addFiles, onFilesChange } from './on_files_change.js';
|
||||
import { onFolderChange } from './on_folder_change.js';
|
||||
import { findNode, flattenFiles, render } from './render.js';
|
||||
|
||||
/**
|
||||
* Удаляет ключи дедупликации для leaf-файлов внутри удаляемого поддерева.
|
||||
*
|
||||
* @param {{fileKeys: Set<string>}} state Состояние дедупликации.
|
||||
* @param {{kind: string, path: string, file?: File, children?: Array}} node Удаляемый узел.
|
||||
* @returns {void}
|
||||
*/
|
||||
function removeFileMeta(state, node) {
|
||||
if (node.kind === 'file') {
|
||||
state.fileKeys.delete(`${node.path}\u0000${node.file.size}`);
|
||||
return;
|
||||
}
|
||||
node.children.forEach((child) => removeFileMeta(state, child));
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет узел из дерева и синхронно освобождает его dedup-ключи.
|
||||
*
|
||||
* @param {{nodes: Array, fileKeys: Set<string>}} state Состояние таблицы.
|
||||
* @param {string} id Идентификатор узла.
|
||||
* @returns {void} Ничего не делает, если id не найден.
|
||||
*/
|
||||
function removeNode(state, id) {
|
||||
const found = findNode(state.nodes, id);
|
||||
if (!found) return;
|
||||
removeFileMeta(state, found.node);
|
||||
found.nodes.splice(found.nodes.indexOf(found.node), 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт picker и возвращает его публичный API.
|
||||
*
|
||||
* @param {{allowedExt: string[], fileInputEl: HTMLInputElement,
|
||||
* folderInputEl: HTMLInputElement, tableBodyEl: HTMLElement, countEl: HTMLElement}} cfg
|
||||
* Конфигурация и обязательные DOM-элементы интегратора.
|
||||
* @returns {{pickFiles: Function, pickFolder: Function, addFiles: Function,
|
||||
* getFiles: Function, remove: Function, render: Function, clear: Function}}
|
||||
* Управляющий API без прямого доступа к внутреннему state.
|
||||
*
|
||||
* Функция регистрирует DOM-события один раз, хранит дерево и dedup Set внутри
|
||||
* замыкания и сразу рисует пустое состояние. Внешнее приложение получает только
|
||||
* операции выбора, добавления, чтения и удаления.
|
||||
*/
|
||||
export function initUploadTable(cfg) {
|
||||
// Нормализованный набор DOM-ссылок передаётся во все функции рендера.
|
||||
const elements = {
|
||||
fileInputEl: cfg.fileInputEl,
|
||||
folderInputEl: cfg.folderInputEl,
|
||||
tableBodyEl: cfg.tableBodyEl,
|
||||
countEl: cfg.countEl,
|
||||
};
|
||||
// nodes — дерево; fileKeys — ключи path+NUL+size; busy защищает async change handlers.
|
||||
const state = { nodes: [], fileKeys: new Set(), busy: false };
|
||||
elements.fileInputEl.addEventListener('change', onFilesChange(state, cfg, elements));
|
||||
elements.folderInputEl.addEventListener('change', onFolderChange(state, cfg, elements));
|
||||
elements.tableBodyEl.addEventListener('click', (event) => {
|
||||
// Делегирование событий позволяет обслуживать динамически созданные кнопки.
|
||||
const toggle = event.target.closest('[data-toggle]');
|
||||
if (toggle) {
|
||||
const found = findNode(state.nodes, toggle.dataset.toggle);
|
||||
if (found) found.node.expanded = !found.node.expanded;
|
||||
render(state, elements, cfg);
|
||||
return;
|
||||
}
|
||||
const remove = event.target.closest('[data-remove]');
|
||||
if (remove) {
|
||||
removeNode(state, remove.dataset.remove);
|
||||
render(state, elements, cfg);
|
||||
}
|
||||
});
|
||||
|
||||
const api = {
|
||||
// Открывают системные диалоги, не обходя браузерные ограничения File API.
|
||||
pickFiles: () => elements.fileInputEl.click(),
|
||||
pickFolder: () => elements.folderInputEl.click(),
|
||||
// Программное добавление использует тот же фильтр и ZIP-парсер, что и input.
|
||||
addFiles: (files) => addFiles(state, cfg, files, elements),
|
||||
// Возвращает только leaf-файлы; группы и внутренний state наружу не выдаются.
|
||||
getFiles: () => flattenFiles(state.nodes),
|
||||
remove: (id) => {
|
||||
removeNode(state, id);
|
||||
render(state, elements, cfg);
|
||||
},
|
||||
render: () => render(state, elements),
|
||||
clear: () => {
|
||||
// Очистка сбрасывает и дерево, и dedup Set, чтобы повторный выбор был возможен.
|
||||
state.nodes = [];
|
||||
state.fileKeys.clear();
|
||||
render(state, elements);
|
||||
},
|
||||
};
|
||||
api.render();
|
||||
return api;
|
||||
}
|
||||
@@ -34,7 +34,11 @@ export async function addFiles(state, cfg, files, elements) {
|
||||
if (zipTree) addFileWithDedup(state, zipTree);
|
||||
} catch (error) {
|
||||
// Битый или небезопасный ZIP пропускается, чтобы не блокировать picker.
|
||||
if (typeof cfg.onError === 'function') cfg.onError(error, file);
|
||||
if (typeof cfg.onError === 'function') {
|
||||
cfg.onError(error, file);
|
||||
} else if (elements.statusEl) {
|
||||
elements.statusEl.textContent = `Ошибка чтения архива ${file.name}: ${error.message}`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -61,6 +65,7 @@ export function onFilesChange(state, cfg, elements) {
|
||||
await addFiles(state, cfg, elements.fileInputEl.files, elements);
|
||||
} finally {
|
||||
// Даже исключение вне внутреннего ZIP-catch не оставляет picker заблокированным.
|
||||
elements.fileInputEl.value = '';
|
||||
state.busy = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -60,7 +60,11 @@ export function onFolderChange(state, cfg, elements) {
|
||||
}
|
||||
} catch (error) {
|
||||
// Ошибка одного ZIP не должна терять остальные файлы каталога.
|
||||
if (typeof cfg.onError === 'function') cfg.onError(error, file);
|
||||
if (typeof cfg.onError === 'function') {
|
||||
cfg.onError(error, file);
|
||||
} else if (elements.statusEl) {
|
||||
elements.statusEl.textContent = `Ошибка чтения архива ${file.name}: ${error.message}`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
* @param {string} prefix Путь выбранной папки, в которую попал узел.
|
||||
* @returns {object} Тот же узел после изменения путей.
|
||||
*
|
||||
* Для leaf-файлов создаётся новый File с обновлённым именем, чтобы метаданные
|
||||
* browser File и путь, возвращаемый через getFiles(), оставались согласованными.
|
||||
* Для leaf-файлов создаётся новый File с тем же базовым именем; логический путь
|
||||
* хранится отдельно в узле дерева.
|
||||
*/
|
||||
export function rebaseTree(root, prefix) {
|
||||
root.path = `${prefix}/${root.path}`;
|
||||
if (root.file) root.file = new File([root.file], root.path, { lastModified: root.file.lastModified });
|
||||
if (root.file) root.file = new File([root.file], root.file.name, { lastModified: root.file.lastModified });
|
||||
root.children.forEach((child) => rebaseTree(child, prefix));
|
||||
return root;
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { flattenFiles } from './render.js';
|
||||
|
||||
/**
|
||||
* Legacy helper: заменяет HTML статуса у найденного leaf-файла.
|
||||
*
|
||||
* @param {string} path Полный логический путь файла в текущем дереве.
|
||||
* @param {string} html Готовая HTML-строка статуса; вызывающая сторона отвечает
|
||||
* за её безопасность.
|
||||
* @param {{nodes: Array}} state Состояние дерева, из которого ищется файл.
|
||||
* @param {{tableBodyEl: HTMLElement}} elements DOM-элементы старого renderer.
|
||||
* @returns {void} Ничего не возвращает; при отсутствии файла или ячейки ничего
|
||||
* не изменяет.
|
||||
*/
|
||||
export function setStatus(path, html, state, elements) {
|
||||
const file = flattenFiles(state.nodes).find((item) => item.path === path);
|
||||
if (!file) return;
|
||||
const cell = Array.from(elements.tableBodyEl.querySelectorAll('.tree-row.tree-file'))
|
||||
.find((row) => row.dataset.path === file.path)
|
||||
?.querySelector('td:nth-child(3)');
|
||||
if (cell) cell.innerHTML = html;
|
||||
}
|
||||
@@ -15,9 +15,9 @@ function extensionAllowed(name, allowedExt) {
|
||||
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
|
||||
}
|
||||
|
||||
/** Создаёт browser File из байтов ZIP entry и задаёт ему полный логический путь. */
|
||||
/** Создаёт browser File из байтов ZIP entry с базовым именем файла. */
|
||||
function makeFile(data, name) {
|
||||
return new File([data], name);
|
||||
return new File([data], name.split('/').at(-1));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,6 +74,8 @@ async function listEntries(data, zipName, allowedExt, depth, limits, budget) {
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user