Files
upload-platform/HISTORY/2026-09-05-file-picker.md
T

206 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 2026-09-05 — browser file-picker и иерархическое дерево
> **Статус документа:** исторический журнал изменений. Устаревшие промежуточные
> состояния помечены `LEGACY`; исправленные дефекты помечены `RESOLVED`.
## Текущее состояние
- **Актуальная версия:** `0.1.9`.
- **Актуальная архитектура:** browser-only file-picker без VM upload, backend
sessions и upload API.
- **Актуальный commit:** `1e30548 Fix async picker race`, отправлен в
`origin/master`.
## Исходная задача
- Требовался браузерный выбор документов в `upload-platform`.
- После уточнения требований сервис должен работать только как file-picker.
- Загрузка файлов на VM, pull-фаза, backend sessions и upload API не требовались.
- Интерфейс должен поддерживать выбор отдельных файлов, папок и ZIP.
- Выбранные данные должны отображаться в виде сворачиваемого иерархического дерева.
- Должна быть возможность удалить отдельный файл или целую группу.
- Последующий сервисный слой должен получать leaf-файлы и их полные пути.
- Разрешены только расширения `.pdf`, `.doc`, `.docx`, `.txt`, `.md`.
- Ограничения по размеру и количеству файлов не добавлялись.
- Должны поддерживаться вложенные структуры: `folder -> ZIP -> subfolder -> ZIP -> file`.
## Реализовано на этапе версии 0.1.7 — LEGACY
> **LEGACY:** раздел фиксирует состояние на момент версии `0.1.7`, а не текущий
> состав кода. Для актуального состояния см. раздел «Текущее состояние» и
> последующие исправления ниже.
- Удалён VM upload layer: PUT на внешний VM endpoint, pull, backend sessions и upload API.
- Удалены старые backend/API части upload-platform по явному запросу:
- `upload/backend/`;
- `site/routes/`;
- `upload/frontend/upload/`.
- Flask-приложение оставлено только для отдачи страницы и frontend-модулей:
- `/`;
- `/health`;
- `/upload-frontend/<path:filename>`.
- Реализован интерфейс выбора файлов, выбора папки и очистки.
- Добавлена конфигурация разрешённых расширений.
- Реализована рекурсивная модель дерева с типами `file`, `folder`, `zip`.
- Для ZIP реализованы чтение через локальный `fflate`, сохранение полных путей, раскрытие вложенных ZIP и фильтрация расширений.
- Для выбранной папки реализованы чтение `webkitRelativePath`, построение вложенных каталогов, раскрытие ZIP внутри папки и удаление пустых групп.
- Добавлена дедупликация по `path + size`.
- Реализовано рекурсивное удаление дублей и пустых групп.
- В таблице реализованы рендер дерева, сворачивание/разворачивание групп, удаление leaf-файлов и удаление групп вместе со всем поддеревом.
- Счётчики количества файлов и общего размера пересчитываются после изменений.
- Состояние таблицы хранит исходные browser `File` objects и метаданные для последующего сервисного слоя.
- Версия приложения повышена до `0.1.7`.
## Исправления по ходу работы
- Первоначальная реализация отправляла файлы на VM; она заменена на picker-only архитектуру.
- Убраны устаревшие фрагменты VM upload и API.
- Исправлены дублированные фрагменты кода, обнаруженные синтаксической проверкой после первоначального патча.
- Исправлено построение полных путей для файлов в ZIP и вложенных ZIP.
- Исправлено построение путей корневых папок и обычных файлов при выборе директории.
- Исправлено удаление пустых групп после дедупликации.
- Исправлен показ запрещённых файлов при обработке ZIP: ZIP без разрешённых leaf-файлов больше не отображается как группа.
- Исправлен fallback для ошибочно раскрывающихся ZIP: нераскрытый ZIP не показывается пользователю как содержимое.
- Добавлен HTML-фильтр `accept` для системного диалога выбора: показываются только разрешённые документы и `.zip`, а не `Все файлы (*.*)`.
## Проверки
- Синтаксис JavaScript проверен через Node.js для frontend-модулей.
- Синтетический тест вложенного ZIP подтвердил пути `archive.zip/root.pdf` и `archive.zip/docs/inner.zip/deep.txt`, а `.png` был исключён.
- Повторная загрузка одинакового дерева не добавляет дубликаты.
- Удаление корневого дерева удаляет все leaf-файлы.
- Flask smoke test подтвердил страницу, `/health` и frontend-маршруты.
- `git diff --check` прошёл.
- Реальный браузерный тест подтвердил:
- отображение обычного `plain.md`;
- ZIP с вложенным ZIP;
- фильтрацию `.png` и `.jpg`;
- три сохранённых leaf-файла и общий размер `16 B`;
- сворачивание `archive.zip` через `aria-expanded=false`;
- удаление группы `archive.zip` вместе со всем поддеревом;
- удаление leaf-файла до состояния `0 файлов · 0 B`;
- выбор папки и фильтрацию запрещённых `main.py` и `image.png`.
- Несколько автоматических кликов через browser tool не сработали из-за нестабильности Playwright locator; точные accessibility-селекторы и DOM-события подтвердили те же операции без дефектов приложения.
- Дополнительный браузерный тест подтвердил, что ZIP только с `image.jpg` оставляет таблицу пустой: `0 файлов · 0 B`.
- Проверено наличие `accept=".pdf,.doc,.docx,.txt,.md,.zip"` у обоих file input.
## Код-ревью и исправления — 2026-09-05
Проведено ревью frontend-модулей file-picker после версии `0.1.6`.
### Найдено
- **Критично:** удаление файла или группы не удаляло ключ из `state.fileMeta`. После удаления повторный выбор того же файла с тем же путём и размером ошибочно считался дубликатом.
- **Средний приоритет:** для leaf-файлов `padding-left` задавался одновременно на `<td>` и `<span>`, поэтому отступ листьев удваивался.
- **Средний приоритет:** `set_status.js` обращался к удалённому `state.files` и падал с `TypeError` при вызове. Кроме того, старый поиск по имени был бы неоднозначен для одинаковых имён в разных каталогах.
- **Низкий приоритет:** `addFiles` и `onFilesChange` импортировались двумя отдельными строками из одного модуля.
### Исправлено
- В `init_upload_table.js` добавлено рекурсивное удаление ключей из `state.fileMeta` перед удалением leaf или группы. Исправлены и UI-обработчик, и публичный `api.remove`.
- В `render.js` убран второй источник отступа для leaf-файлов.
- В `render.js` для файлов добавлен `data-path` с экранированным полным путём.
- В `set_status.js` переход выполнен на актуальный `state.nodes` через `flattenFiles`; строка статуса ищется по полному `data-path`.
- Объединён дублирующий импорт из `on_files_change.js`.
- `state.fileMeta` заменён на `Set state.fileKeys`, поскольку значения идентификаторов узлов не использовались.
- Логика удаления узла объединена в одну функцию и используется обработчиком таблицы и публичным `api.remove`.
- Из `findNode` удалён неиспользуемый параметр `parent`.
### Проверено после ревью
- Все frontend `.js` файлы проходят `node --input-type=module --check`.
- `site/app.py` проходит `python3 -m py_compile`.
- `git diff --check` проходит.
- Браузерная проверка удаления leaf-файла показала `0 файлов · 0 B`; после удаления ключ дедупликации очищается.
- Повторный выбор того же файла после удаления снова разрешён.
- Изменения не затрагивают фильтрацию расширений, ZIP-рекурсию и публичный API picker.
## Жёсткие тесты по плану — 2026-09-05 — HISTORICAL SNAPSHOT
> **HISTORICAL SNAPSHOT:** результаты ниже относятся к тестовому проходу до
> исправления traversal. Актуальная повторная проверка приведена в разделе
> «Повторная проверка» и в последующих записях.
### PASS
- Расширения без учёта регистра: `UPPER.PDF`, `Doc.DOCX`, `note.TxT` и `file.name.md` приняты.
- Некорректные расширения и имена без разрешённого суффикса: `archive.tar.gz`, `README` и `file.` отклонены.
- Пустой ZIP не создаёт группу и оставляет состояние `0 файлов · 0 B`.
- ZIP только с запрещёнными `.jpg` и `.py` не создаёт группу.
- ZIP с разрешёнными файлами и вложенным ZIP оставляет только разрешённые leaf-файлы.
- ZIP с вложенностью глубже 20 отклоняется без зависания страницы.
- Дедупликация: одинаковый путь и размер схлопываются; другой путь сохраняется.
- XSS-имена с `<script>`, `<img>`, кавычками и `&` экранируются: в DOM нет `script` и `img` элементов, исходный текст отображается безопасно.
- Выбор папки с одинаковыми именами в разных каталогах сохраняет оба файла.
- Рабочее дерево Git после тестов осталось чистым.
### FAIL — RESOLVED / LEGACY RESULT
> **RESOLVED:** перечисленные FAIL описывают состояние до исправления traversal
> и не являются текущими дефектами. Раздел сохранён как исторический результат
> тестирования.
- ZIP entry `folder/../../escape.txt` попадает в дерево с тем же traversal-путём.
- ZIP entry `/absolute.md` попадает в дерево с двойным слэшем `archive.zip//absolute.md`.
- Пути с `..` создают видимые группы `..`.
- Путь `<пустая часть>/absolute.md` создаёт группу с пустым отображаемым именем.
### Вывод — LEGACY
> **LEGACY:** это вывод на момент обнаружения дефекта; после исправления
> traversal он больше не описывает текущее состояние.
Фильтрация расширений, XSS-экранирование, дедупликация и обработка пустых/глубоких ZIP работают. Обнаружен отдельный дефект нормализации ZIP-путей: перед построением дерева нужно отклонять абсолютные пути, `..`, пустые сегменты и backslash traversal. В текущем тестовом проходе код не менялся; FAIL оставлен документированным для следующего исправления.
## Git — HISTORICAL REFERENCES
> **HISTORICAL REFERENCES:** ссылки ниже перечисляют коммиты этапов разработки,
> а не являются текущим состоянием ветки. Текущий commit указан в начале файла.
## Исправление traversal — 2026-09-05
### Причина
`list_zip_files.js` использовал сырой `entryName` из ZIP: он напрямую попадал в `path` и разбивался через `split('/')`. Поэтому `..`, ведущий `/`, пустые сегменты и Windows backslash создавали видимые небезопасные группы и пути.
### Исправление
- Добавлена проверка сегментов ZIP entry до построения дерева.
- Отклоняются пустые entry, абсолютные пути, backslash, `.`, `..` и пустые сегменты.
- Пути и имена leaf-файлов строятся только из проверенных сегментов.
- Вложенный ZIP с опасным путём пропускается без прерывания обработки внешнего ZIP.
- Версия на момент исправления повышена до `0.1.8`; **LEGACY:** текущая версия
уже `0.1.9` после исправления async race.
### Повторная проверка
ZIP с `folder/../../escape.txt`, `/absolute.md`, `windows\\..\\bad.txt`, `folder/./dot.txt` и безопасным `safe/normal.txt` оставил только `paths.zip/safe/normal.txt`. Группы `..`, пустая группа и абсолютный путь больше не отображаются. Результат: `1 файлов · 2 B`.
## Исправление async race — 2026-09-05 — АКТУАЛЬНО
### Причина
`onFilesChange` запускал асинхронный `addFiles` без ожидания и без установки `state.busy` до первого `await`. При быстрых повторных событиях выбора ZIP несколько обработчиков могли одновременно менять дерево и вызывать `render`.
### Исправление
- `onFilesChange` теперь сразу устанавливает `state.busy`, ожидает `addFiles` и сбрасывает флаг в `finally`.
- `onFolderChange` использует тот же порядок: ранний выход при занятом состоянии, блокировка до асинхронного разбора ZIP и гарантированный сброс в `finally`.
- Ошибка разбора ZIP больше не оставляет picker заблокированным.
### Проверки
- Все frontend `.js` файлы проходят `node --input-type=module --check`.
- `site/app.py` проходит `python3 -m py_compile`.
- `git diff --check` проходит.
- Flask smoke test загрузил страницу и оба изменённых ES-модуля без HTTP-ошибок.
- Узкий Node-тест не применён: в проекте отсутствует `package.json` с `type: module`, поэтому Node трактует browser ES-модули как CommonJS; синтаксис проверен отдельным module-check, а загрузка проверена браузером.
- `c0b8788 Implement reusable upload platform` — первоначальный reusable upload platform.
- `85605fe Remove VM upload layer; keep file picker only` — удаление VM upload и переход к picker-only.
- `a965f18 Add hierarchical file picker` — иерархическое дерево, вложенные ZIP, удаление групп и файлов.
- До добавления этой записи `master` был синхронизирован с `origin/master`.
- Текущая запись добавлялась отдельными коммитами и отправлялась в
`origin/master`; **LEGACY:** эта формулировка описывает историю публикации,
а не текущую операцию деплоя.