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

18 KiB
Raw Blame History

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: эта формулировка описывает историю публикации, а не текущую операцию деплоя.