39 Commits
Author SHA1 Message Date
Repinoid 1750a8497f chore: sync local VM mirror state 2026-09-12 19:48:57 +03:00
“Naeel” 09016b22e8 Document hardening test results 2026-09-06 13:46:50 +03:00
“Naeel” 523b52a26c Test partial upload cancellation 2026-09-06 13:46:06 +03:00
“Naeel” 67a4caa43c Add backend hardening tests 2026-09-06 13:44:57 +03:00
“Naeel” 05a035471d Document hardening test plan 2026-09-06 13:44:08 +03:00
“Naeel” bfe487164f Document hard 0.2.2 release test 2026-09-06 13:38:35 +03:00
“Naeel” df0576aebd Document full 0.2.2 release test 2026-09-06 13:22:41 +03:00
“Naeel” f7687d3f12 Bump version to 0.2.2 2026-09-06 12:38:21 +03:00
“Naeel” 2b202795b8 Rebuild picker bundles with collapsed groups 2026-09-06 12:37:56 +03:00
“Naeel” 5e2c90f8db Record collapsed groups verification 2026-09-06 12:37:51 +03:00
“Naeel” 8316e19725 Collapse folders and zips by default 2026-09-06 12:37:06 +03:00
“Naeel” 860cdd4214 Document collapsed layer 1 groups 2026-09-06 12:36:28 +03:00
“Naeel” fca6f6aafb Document commit and history policy 2026-09-06 12:30:23 +03:00
“Naeel” 87de22306f Add upload wait timer and progress explanation 2026-09-06 12:23:58 +03:00
“Naeel” 8768fd96ec docs: record final Layer 2 checkpoint 2026-09-06 12:03:21 +03:00
“Naeel” 112c84f11a feat: implement Layer 2 per-file transit, RAM session, mock buffer, and tests (v0.2.0) 2026-09-06 12:01:56 +03:00
“Naeel” 0ea7ba058c docs: add LAYER2-RESUME.md and layer 2 architecture history record 2026-09-06 11:08:44 +03:00
“Naeel” 6586b5d893 docs: finalize current picker architecture 2026-09-05 20:51:45 +03:00
“Naeel” 017bd8c354 fix: harden browser file picker and remove legacy 2026-09-05 20:50:36 +03:00
“Naeel” f6b2f6d1b3 docs: sync readmes with picker API 2026-09-05 15:12:20 +03:00
“Naeel” a0bfd9f0e3 docs: record real file picker test 2026-09-05 15:11:15 +03:00
“Naeel” 3d227f1daa feat: make file picker embeddable 2026-09-05 14:42:50 +03:00
“Naeel” aec8ad4fbe Add architecture review prompts and responses (Sonnet, GPT SOL) 2026-09-05 14:26:12 +03:00
“Naeel” 3bd8ee2b23 Bump version to 0.1.10 2026-09-05 13:28:36 +03:00
“Naeel” e4b4dca53a Document picker code and architecture 2026-09-05 13:24:00 +03:00
“Naeel” 13ea0efcd7 Resolve Opus review code findings 2026-09-05 13:11:25 +03:00
“Naeel” edf1de5aa2 Split Opus review into separate history file 2026-09-05 13:07:38 +03:00
“Naeel” 5a0f82b75e Document Opus review findings 2026-09-05 13:05:31 +03:00
“Naeel” 23c1c86968 Mark obsolete implementation plan as legacy 2026-09-05 12:59:56 +03:00
“Naeel” 8df4d22cfb Update picker documentation 2026-09-05 12:58:47 +03:00
“Naeel” 5d44c836e7 Mark legacy history entries 2026-09-05 12:55:42 +03:00
“Naeel” 1e305485be Fix async picker race 2026-09-05 12:31:17 +03:00
“Naeel” eda5263d8b Reject unsafe ZIP paths 2026-09-05 12:20:26 +03:00
“Naeel” 317526b246 Document strict file picker tests 2026-09-05 10:08:11 +03:00
“Naeel” 8f775e34a9 Fix review findings in file picker 2026-09-05 10:00:14 +03:00
“Naeel” 6fea5bce49 Fix file picker extension filtering 2026-09-05 09:55:44 +03:00
“Naeel” a965f181d9 Add hierarchical file picker 2026-09-05 09:29:03 +03:00
“Naeel” 85605fe8c6 Remove VM upload layer; keep file picker only 2026-09-05 09:23:13 +03:00
“Naeel” c0b87880ae Implement reusable upload platform 2026-09-05 08:51:32 +03:00
74 changed files with 5067 additions and 68 deletions
+6
View File
@@ -7,6 +7,9 @@ __pycache__/
build/
develop-eggs/
dist/
!dist/
!dist/file-picker.esm.js
!dist/file-picker.iife.js
downloads/
eggs/
.eggs/
@@ -46,6 +49,9 @@ htmlcov/
.DS_Store
Thumbs.db
# Local frontend tooling
node_modules/
# Logs / temp
*.log
/tmp/
+23
View File
@@ -0,0 +1,23 @@
# Документация кода picker-only
## Что сделано
- Добавлены подробные комментарии и JSDoc в собственные JS-модули picker-а.
- Добавлены пояснения к Flask entrypoint, HTML-шаблону, CSS и пакетному
`upload/__init__.py`.
- Добавлен `docs/CODE-REFERENCE.md` со схемой архитектуры, моделью узлов,
состоянием, публичным API, функциями модулей и описанием legacy-кода.
- Ссылки на справочник добавлены в корневой и модульный README.
- Legacy `set_status.js` документирован отдельно; его поведение не менялось.
## Границы
- Поведение picker-а намеренно не изменялось.
- `site/static/vendor/fflate.min.js` не редактировался.
- Старые backend/VM-файлы и `.pyc` не удалялись.
## Проверка
- `node --input-type=module --check` для собственных JS-файлов: PASS.
- `python3 -m py_compile site/app.py upload/__init__.py`: PASS.
- `git diff --check`: PASS.
+205
View File
@@ -0,0 +1,205 @@
# 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:** эта формулировка описывает историю публикации,
а не текущую операцию деплоя.
@@ -0,0 +1,84 @@
# Ревью Gemini 3.8 Flash — 2026-09-05
## Контекст
Ревью ограничивалось проектом `upload-platform`. Проверены frontend-модули
универсального browser-only picker-а, Flask-обёртка, конфигурация и структура
проекта. Ниже зафиксированы findings ревью; исправления по этим пунктам в рамках
данной записи не выполнялись.
## Критические ошибки и логические баги (High)
1. **Не сбрасывается `fileInputEl.value` при выборе файлов.**
**Файл:** `upload/frontend/table/on_files_change.js`.
После удаления файла повторный выбор того же файла может не вызвать событие
`change`, если значение input не сброшено.
2. **Распаковка лишних файлов и преждевременный OOM в ZIP-фильтре.**
**Файл:** `upload/frontend/zip/list_zip_files.js`.
Callback `filter` проверяет размер и бюджет, но до декомпрессии не отбрасывает
записи с неразрешёнными расширениями. Бинарные файлы могут быть распакованы в
память, хотя затем будут отброшены.
3. **Синхронная блокировка UI при распаковке архивов.**
**Файл:** `upload/frontend/zip/list_zip_files.js`.
`unzipSync` выполняется в главном потоке браузера и может замораживать UI на
больших архивах. Предложенное направление: async unzip с чанками или Web Worker.
## Архитектурные недочёты и надёжность (Medium)
4. **Нарушается семантика `File.name` для файлов из папок и ZIP.**
**Файлы:** `upload/frontend/table/rebase_tree.js`,
`upload/frontend/zip/list_zip_files.js`.
При создании нового `File` полный логический путь может попасть в `file.name`,
хотя путь должен храниться отдельно в метаданных узла.
5. **Не выполняется слияние папок с одинаковыми путями.**
**Файл:** `upload/frontend/table/add_file_with_dedup.js`.
Повторное добавление корневого узла с тем же путём может создать два отдельных
узла вместо объединения их дочерних элементов.
6. **Потенциально небезопасная вставка HTML в статус.**
**Файл:** `upload/frontend/table/set_status.js`.
Прямое присваивание `innerHTML` требует гарантировать экранирование всех
передаваемых значений либо заменить его на безопасную работу с текстом.
7. **Молчаливый пропуск ошибок ZIP без `cfg.onError`.**
**Файлы:** `upload/frontend/table/on_files_change.js`,
`upload/frontend/table/on_folder_change.js`.
При отсутствии callback интегратор может не получить индикацию ошибки
повреждённого архива или превышения лимита.
8. **Падение `app.py` при отсутствии или повреждении `config.json`.**
**Файл:** `site/app.py`.
Чтение конфигурации выполняется на уровне импорта без обработки исключений;
при ошибке Flask-приложение не запускается.
## Мёртвый код и гигиена (Low)
9. **Дубликат `init_upload_table.js`.**
Файл выглядит неиспользуемым и дублирует логику текущего picker API.
Предложенное направление: удалить либо явно пометить как deprecated после
проверки всех импортов.
10. **Пустые служебные каталоги backend.**
Каталоги `session` и `upload_refs` содержат только старые `__pycache__`.
Требуется отдельное решение о сохранении или очистке структуры.
11. **Недостаточная обработка ошибок сборки в `build.mjs`.**
**Файл:** `build.mjs`.
Предложено добавить явную обработку ошибки сборки с `process.exit(1)` и
определить поведение каталога `dist/` перед сборкой.
## Итог
Gemini 3.8 Flash создал 4 задачи: три high/medium направления по ZIP и
событиям выбора, а также дополнительный набор архитектурных и гигиенических
проверок. Эта запись фиксирует результаты ревью как backlog. Код проекта и
рабочая реализация picker-а в рамках документирования не изменялись.
## Проверка записи
- Файл ревью создан в `HISTORY/`.
- Исходники проекта не изменялись.
- Findings сохранены в исходной классификации High, Medium и Low.
@@ -0,0 +1,98 @@
# Продолжение вопросов и ответов по ревью Gemini 3.8 Flash — 2026-09-05
## Контекст
Зафиксирован второй блок ответов Gemini на дополнительные вопросы по ревью
`upload-platform`. Ответы уточняют границы ответственности picker-а, приоритеты
и декомпозицию findings. Код приложения в рамках документирования не изменялся.
## Уточнения
### №4: `File.name` и логический путь
Рекомендация Gemini: сохранять в `file.name` только базовое имя файла, а полный
логический путь передавать через `node.path` и контракт `getFiles()`.
Основание: стандартный browser `File` использует `name` как имя файла без
каталогов. Полный путь в `file.name` может некорректно обрабатываться при
`FormData.append('files', file)` и сторонними upload-библиотеками. Передача пути
третьим аргументом `FormData.append(name, file, filename)` устраняет проблему,
но перекладывает внутреннюю особенность picker-а на интегратора.
### №8: fallback для `config.json`
Пункт признан защитной мерой demo-сервера, а не production-риском библиотеки.
В целевом сценарии распространяется JavaScript bundle из `dist/`, а `site/app.py`
служит demo-обёрткой и не участвует в интеграции picker-а с host-системой. Поэтому
пункт не входит в обязательный pre-release топ-3.
### №3: порог `unzipSync`
Оценочный порог заметного фриза заявлен как 15–25 MiB сжатых данных или сотни
мелких XML/DOCX entries с суммарной распаковкой свыше 50 MiB. Для архивов до
10 MiB задержка обычно несущественна. Для целевого профиля офисных документов и
текущих лимитов переход на Web Worker признан преждевременной оптимизацией.
Приоритетнее сначала фильтровать расширения до декомпрессии и сохранять жёсткие
лимиты. Worker остаётся backlog для архивов порядка 100 MiB и более.
### №7: поведение без `onError`
Рекомендованный default — выводить ошибку в общий статус picker-а, например в
`elements.statusEl` или `.fp-status`. Молчаливый `continue` создаёт плохой UX:
пользователь не понимает, почему содержимое архива не появилось. Создание
неотправляемого сломанного корневого узла в таблице также признано нежелательным.
### №10: состояние backend-каталогов
По заявленному результату проверки файловой системы в `upload/backend/` остаются
только каталоги `session/` и `upload_refs/`, внутри которых нет исходников; есть
лишь пустые каталоги `__pycache__` от удалённых модулей. Вердикт Gemini: чинить
нечего, это остатки, которые можно удалить отдельным разрешённым изменением.
## Декомпозиция 11 findings на 4 задачи
### Задача 1: pre-release hotfixes UI и ZIP
- №1: сброс `fileInputEl.value` после обработки выбора;
- №2: фильтрация расширения внутри ZIP `filter` до декомпрессии;
- №5: слияние корневых папок с одинаковым путём.
Результат: корректный повторный выбор, снижение риска OOM и отсутствие дубликатов
корневых папок.
### Задача 2: контракт данных и обработка ошибок
- №4: базовое имя в `file.name`, путь в метаданных;
- №7: fallback-вывод ошибок ZIP в общий статус;
- №8: безопасная загрузка `config.json` с fallback-конфигурацией.
Результат: предсказуемый контракт browser `File` и понятная обратная связь.
### Задача 3: очистка репозитория
- №6: удаление или отдельное решение по legacy `set_status.js`;
- №9: удаление или отдельное решение по legacy `init_upload_table.js`;
- №10: удаление пустого `backend/` с остатками `__pycache__`;
- №11: определение поведения `dist/` перед сборкой.
Результат: в репозитории остаются актуальные исходники и ясная структура сборки.
### Задача 4: производительность больших архивов
- №3: перевод тяжёлой ZIP-распаковки в async-поток или Web Worker.
Задача отнесена в backlog и актуальна при появлении сценариев с очень большими
архивами.
## Итоговый приоритет
До следующего release в первую очередь предлагаются пункты **№2, №1 и №5**.
Пункты №4, №7, №8 и очистка legacy-кода относятся ко второй очереди. №3
остаётся производительным backlog до подтверждения реальных сценариев больших
архивов.
## Статус документирования
- Ответ Gemini сохранён в `HISTORY/`.
- Исходный код picker-а не изменялся.
- Удаление `__pycache__` и любых каталогов не выполнялось.
+78
View File
@@ -0,0 +1,78 @@
# Вопросы и ответы по ревью Gemini 3.8 Flash — 2026-09-05
## Контекст
После критического разбора ревью Gemini 3.8 Flash были заданы уточняющие
вопросы по версии исходников, дублированию API, семантике `File.name`, legacy
XSS, `build.mjs`, ZIP-фильтрации и приоритетам исправлений.
## Зафиксированные ответы
### Версия кода и номера строк
Ревью выполнялось по исходникам `upload-platform`; `dist/` отдельно не
исследовался. Номера строк в первой сводке были смещены из-за объединения
отчётов субагента. Фактические участки находятся в текущих исходниках
`on_files_change.js` и `list_zip_files.js`.
### `init_upload_table.js`
Буквального совпадения интерфейса с `initFilePicker` нет: один entry point
принимает готовые DOM-узлы, другой создаёт DOM внутри `mount`. Однако внутренняя
логика жизненного цикла таблицы параллельна: состояние `nodes/fileKeys/busy`,
удаление узлов, делегирование кликов и похожий API. Файл не входит в граф сборки
`build.mjs`, поэтому это legacy-альтернатива, создающая риск рассинхронизации.
### `File.name` и логический путь
`flattenFiles()` возвращает `name`, `path` и `file` раздельно, но при `rebaseTree`
вложенный browser `File` получает полный путь в `file.name`. При прямом
`FormData.append('files', file)` сервер получает filename с разделителями пути.
Это может конфликтовать с серверными `basename`/`secure_filename` и нарушает
обычный контракт browser `File`, где `name` является базовым именем. При явной
передаче третьего аргумента `FormData.append('files', file, path)` проблема не
возникает.
### XSS в `set_status.js`
Замечание относится только к legacy-файлу. `set_status.js` не входит в активный
граф сборки и не используется боевым рендерером. В активном `render.js` значения
статуса экранируются через `esc()`. Реальная уязвимость в текущем активном
рендеринге не подтверждена; пункт относится к гигиене мёртвого кода.
### `build.mjs`
Для top-level `await` необработанная ошибка сборки сама приводит Node.js к
завершению с ненулевым кодом. Явные `try/catch` и `process.exit(1)` технически
избыточны. Реальное возможное улучшение этого пункта — определить поведение
каталога `dist/` перед сборкой, например его предварительную очистку.
### Проверка фильтра `fflate`
Проверка исходника `fflate` подтвердила, что `filter` вызывается после чтения
метаданных ZIP entry, но до копирования или декомпрессии данных. Если фильтр
возвращает `false`, `inflateSync` для этой записи не вызывается и буфер для
распакованных данных не создаётся. Поэтому добавление проверки расширения в
`filter` является корректным способом не распаковывать тяжёлые неразрешённые
entries.
### Приоритет до прода
Согласован обязательный приоритет:
1. **№2:** отбрасывать неразрешённые расширения внутри ZIP `filter` до
декомпрессии, чтобы снизить риск OOM и лишнего расхода бюджета.
2. **№1:** сбрасывать `elements.fileInputEl.value`, чтобы повторный выбор того
же файла после удаления снова генерировал `change`.
3. **№5:** объединять корневые папки с одинаковыми путями, чтобы избежать
дублирующихся деревьев при последовательном выборе.
Пункты `File.name`, `onError`, fallback-конфигурация, legacy-код и очистка
служебных каталогов отнесены ко второй очереди.
## Итог
Уточнение подтвердило, что главным техническим finding является ZIP-фильтрация
до декомпрессии. Пункты про `set_status.js` и явный `process.exit(1)` не являются
активными production-дефектами. Исправления кода в рамках этой записи не
выполнялись.
+44
View File
@@ -0,0 +1,44 @@
# Ревью Opus 4.8 — 2026-09-05
## Контекст
Ревью ограничивалось проектом `upload-platform`. Проверены структура проекта,
frontend-модули, Flask-обёртка, конфигурация и HISTORY. Исходники backend
VM-upload отсутствуют; в рабочем дереве обнаруживались только артефакты
`__pycache__` от удалённых модулей.
## Замечания и текущий статус
1. **Документация описывала отменённую VM-архитектуру.**
**RESOLVED:** `README.md`, `upload/README.md` и `PLAN.md` приведены к
picker-only архитектуре. Старые VM/backend разделы в `PLAN.md` и README
помечены как `LEGACY`.
2. **Документация содержала неиспользуемые параметры конфигурации и лимиты.**
**RESOLVED:** актуальная документация указывает только `allowedExt` и
прямо фиксирует отсутствие ограничений размера и количества файлов.
3. **В рабочем дереве присутствовали `.pyc` в `__pycache__`.**
**OPEN:** это локальные игнорируемые артефакты, не попадающие в Git. Удаление
не выполнялось, поскольку удаление файлов требует отдельного решения.
4. **`set_status.js` и `api.setBusy` выглядят неиспользуемыми.**
**PARTIALLY RESOLVED:** публичный `api.setBusy` удалён как неиспользуемый.
`set_status.js` сохранён как legacy-файл до отдельного решения об удалении.
5. **`rebaseTree` дублируется в ZIP- и folder-обработчиках.**
**RESOLVED:** функция вынесена в общий модуль
`upload/frontend/table/rebase_tree.js`.
## Вывод
Критичных дефектов в picker-only коде ревью не выявило. Основное замечание по
рассинхронизации документации устранено. После дополнительной проверки и
рефакторинга остаются две технические задачи: решение по локальным
`__pycache__` и отдельное решение по удалению либо сохранению legacy-файла
`set_status.js`.
### Проверка после исправлений
- Все frontend `.js` файлы проходят `node --input-type=module --check`.
- `site/app.py` проходит `python3 -m py_compile`.
- ZIP-функциональность в браузере сохранена: разрешённые leaf-файлы приняты,
`.exe` отфильтрован.
- Общий `rebaseTree` загружается, дублированных реализаций нет.
- `api.setBusy` отсутствует в публичном API.
+20
View File
@@ -0,0 +1,20 @@
# 2026-09-05 — синхронизация README с универсальным picker API
## Что исправлено
- Корневой `README.md` теперь описывает актуальные `package.json`, `build.mjs`
и `dist/`.
- Добавлены команды `npm install` и `npm run build` в локальный запуск.
- Инструкция интеграции переведена на готовые ESM/IIFE bundle.
- Зафиксировано, что `fflate` уже встроен в bundle и отдельный vendor script
для интеграции не требуется.
- В `upload/README.md` описаны `onChange(files)` и `onError(error, file)`.
- `docs/CODE-REFERENCE.md` синхронизирован с bundle-архитектурой и актуальными
сигнатурами `render`.
## Проверка
- Все целевые файлы и ссылки на `docs/CODE-REFERENCE.md`, `HISTORY/` и оба
bundle существуют.
- `git diff --check` прошёл.
- Исходный код picker-а не изменялся.
+65
View File
@@ -0,0 +1,65 @@
# 2026-09-05 — тестирование реальных файлов из `/mnt/y/T`
## Контекст
Универсальный browser file-picker версии `0.1.11` проверен на реальных
документах из каталога `/mnt/y/T`, а не только на синтетических `File` objects.
Для доступа браузера к Linux-файлам использовался временный HTTP-сервер с
CORS на порту `8766`. В репозитории и в каталоге тестовых файлов изменения не
вносились.
## Набор файлов
В `/mnt/y/T` на уровне проверки обнаружены:
- `186` PDF;
- `81` DOCX;
- `42` DOC;
- архивов `zip`, `7z` и `rar` не обнаружено.
Для массового browser-теста использован каталог `/mnt/y/T/many`:
- найдено файлов разрешённых типов: `300`;
- загружено в browser `File`: `300`;
- принято picker-ом: `300`;
- уникальных имён: `300`;
- суммарный размер: `6 453 081 B`.
## Отдельные реальные файлы
Проверены документы с пробелами, кириллицей и скобками в именах:
| Файл | Размер |
|---|---:|
| `Assumption of Responsibility for Hosting.pdf` | `19 755 B` |
| `1 РИМО.docx` | `18 301 B` |
| `GU2_ot_23.01.2047 (1).doc` | `29 184 B` |
| `Evaporator Drain Access.pdf` | `29 767 B` |
Суммарный размер этих четырёх файлов: `97 007 B`.
## Результаты
- Все четыре отдельных файла отображаются со статусом `готов`.
- Имена с кириллицей, пробелами, `$` и скобками не искажаются.
- Размеры browser `File` совпали с размерами файлов на диске.
- PDF, DOC и DOCX принимаются при конфигурации `allowedExt: ['.pdf', '.doc', '.docx']`.
- Массовый набор из `300` документов принят без потерь и без дубликатов.
- Счётчик picker-а показывает `300 файлов · 6453081 B`.
- Файлы не отправляются в upload backend: обработка остаётся browser-only.
## Ограничения теста
- В каталоге `/mnt/y/T` не было реальных архивов, поэтому ZIP проверялся
отдельными синтетическими архивами в предыдущем browser-тесте.
- Прямой системный chooser remote browser не смог принять Linux-пути
`/mnt/y/T`; поэтому реальные байты были получены браузером через временный
CORS-сервер и переданы в тот же публичный API `picker.addFiles()`.
- Временные Flask и HTTP-серверы завершались по заданному `timeout`; это
нормальное завершение тестового окружения, а не ошибка picker-а.
## Итог
Тест реальных документов подтверждает корректную работу фильтра расширений,
чтение browser `File`, сохранение имён и размеров, массовую загрузку и
отображение документов на русском, английском и смешанном наборе имён.
+35
View File
@@ -0,0 +1,35 @@
# План исправлений по ревью Gemini — 2026-09-05
## Обязательный релизный набор
1. Сбрасывать `fileInputEl.value` после обработки выбора, чтобы повторный выбор
того же файла снова генерировал `change`.
2. Фильтровать ZIP entries по разрешённому расширению до декомпрессии, сохраняя
отдельный проход для вложенных `.zip`.
3. Объединять корневые папки с одинаковым путём вместо создания дублей.
4. Сохранять базовое имя в `File.name`, а логический путь — в `node.path` и
результате `flattenFiles()`.
5. Показывать ошибку ZIP в статусе по умолчанию, если `onError` не передан.
6. Использовать fallback-конфигурацию при отсутствии или повреждении
`config.json` в demo-сервере.
## Статус выполнения
- Выполнено: обязательный релизный набор пунктов 1–6.
- Выполнено: удалены legacy `set_status.js`, `init_upload_table.js` и пустой
`upload/backend/` с остатками `__pycache__`.
- Выполнено: живая документация очищена от ссылок на удалённую legacy-архитектуру.
- Выполнено: `build.mjs` очищает `dist/` перед сборкой; версия повышена до `0.1.13`.
## Отложенные задачи
- Перевести тяжёлую синхронную ZIP-распаковку в Web Worker после подтверждения
реальными измерениями необходимости.
## Проверка
- Пересобрать ESM/IIFE bundles.
- Проверить синтаксис изменённых JavaScript и Python-файлов.
- Обновить версию с `0.1.11` до `0.1.12` в `package.json` и `site/app.py`.
- Выполнить browser smoke-тесты для повторного выбора, ZIP-фильтрации,
слияния папок и fallback ошибки.
@@ -0,0 +1,17 @@
# 2026-09-06: правило документирования и частых коммитов
## Подтверждённое правило работы
Пользователь явно потребовал:
- документировать все обнаруженные факты, решения и результаты сразу;
- делать отдельный коммит после каждой правки или логического изменения;
- не накапливать несколько независимых изменений до одного общего коммита.
## Результат текущего шага
- UX-правка таймера и пояснения загрузки зафиксирована коммитом `87de223`.
- Коммит `87de223` успешно отправлен в `origin/master`.
- На момент документирования локальная ветка `master` синхронизирована с `origin/master`.
Это правило применяется к дальнейшей работе в проекте upload-platform.
@@ -0,0 +1,14 @@
# 2026-09-06: план дополнительных hardening-тестов
После полного прогона `0.2.2` выявлены тестовые пробелы, которые не покрывались штатными 10 Python и 10 Node тестами:
- границы `MAX_FILE_BYTES` (ровно лимит и лимит + 1 байт);
- превышение фактического размера файла после pull при заниженном заявленном размере;
- граница `MAX_SESSION_BYTES`;
- retry pull после временного HTTP-сбоя;
- удаление сессии по TTL;
- удаление файла из буфера при пропуске по лимиту;
- отмена frontend после частично завершённого пофайлового транзита;
- повторный выбор/двойной запуск picker без параллельной обработки.
Первым шагом добавляются backend hardening-тесты для лимитов, retry, TTL и очистки буфера. После каждого логического изменения выполняется отдельный коммит и проверка.
@@ -0,0 +1,39 @@
# 2026-09-06: результат дополнительных hardening-тестов
## Добавленные тесты
Backend:
- точное достижение `MAX_SESSION_BYTES` и отказ при переполнении;
- отказ при фактическом размере файла больше `MAX_FILE_BYTES`, даже если заявленный размер допустим;
- обязательное удаление oversize-файла из буфера;
- retry pull после временного HTTP `503`;
- удаление файла после успешного pull;
- автоматическое удаление RAM-сессии по короткому TTL.
Frontend:
- отмена после первого успешно доставленного файла;
- подтверждение, что второй `PUT` не начинается;
- подтверждение частичного результата и `aborted: true`;
- проверка callback статуса доставленного первого файла.
## Результаты
- hardening backend: `4/4` PASS;
- полный Node suite: `11/11` PASS;
- полный Python suite: `14/14` PASS;
- syntax sweep: `22` Python-файла и `13` JavaScript-файлов PASS;
- frontend build: PASS;
- runtime `/health`: `ok`;
- runtime `/`: HTTP 200, `10035` байт;
- `git diff --check`: PASS.
## Исправленные ошибки тестового harness
- Для backend pull использован корректный `httpx.WSGITransport`.
- Конфигурационный ключ лимита приведён к API-формату `maxFileBytes`.
- Timing partial-cancel теста синхронизирован с первым `PUT`.
- Assertion статуса приведён к фактическому callback-тексту `✓ Доставлен`.
Production-код не изменялся, поэтому версия остаётся `0.2.2`.
@@ -0,0 +1,37 @@
# 2026-09-06: группы слоя 1 по умолчанию свёрнуты
## Запрос
При большом количестве файлов в папках и ZIP раскрытая таблица становится неудобной: все файлы сразу занимают много строк, и пользователь вынужден вручную сворачивать группы.
Требование: папки и ZIP должны быть свёрнуты по умолчанию, с возможностью раскрывать каждую группу отдельно.
## Найденная причина
Рендер слоя 1 уже поддерживал `expanded === false`: дочерние строки не выводятся, а кнопка группы показывает стрелку раскрытия. Проблема находилась в создании узлов:
- корень ZIP и вложенные папки создавались с `expanded: true`;
- корни выбранных папок и промежуточные папки также создавались с `expanded: true`.
## План исправления
- сделать `expanded: false` только для групп `folder` и `zip`;
- оставить leaf-файлы обычными строками;
- сохранить независимое ручное раскрытие групп;
- добавить регрессионный тест рендера свернутой группы.
## Результат
- узлы `folder` и `zip` теперь создаются с `expanded: false`;
- leaf-файлы доступны после раскрытия родительской группы;
- ручное раскрытие каждой группы сохранено;
- добавлен регрессионный тест рендера свернутой папки;
- пересобраны ESM и IIFE-бандлы в `dist/`.
## Проверка
- `npm run build` выполнен успешно;
- `npm test`: `10/10` тестов пройдены;
- `python3 -m pytest tests/ -q`: `10/10` тестов пройдены;
- `python3 -m py_compile site/app.py` выполнен успешно;
- `git diff --check` без замечаний.
@@ -0,0 +1,87 @@
# 2026-09-06: Архитектурные решения Слоя 2 и границы независимых API слоёв
## 1. Контекст и цели
Зафиксированы ключевые проектные и архитектурные решения по разработке Слоя 2 в репозитории `upload-platform` перед началом реализации:
1. Полная автономность и независимость каждого слоя как отдельного API.
2. Легкость интеграции обоих слоёв (Слой 1 + Слой 2) в целевые сервисы (`drhider`, `contractor`) без переделки исходного кода ядра.
3. Составлено подробное ТЗ и резюме в файле `LAYER2-RESUME.md`.
---
## 2. Архитектурные границы слоёв и API-контракты
### Слой 1: File Picker (v0.1.13 — готов и протестирован)
- **Зона ответственности**: выбор отдельных файлов, выбор папок (`webkitdirectory`), клиентская рекурсивная распаковка ZIP-архивов с помощью `fflate`, дедупликация, фильтрация по расширениям, проверка лимитов, отрисовка древовидного UI.
- **API-контракт**: метод `picker.getFiles()` возвращает плоский массив стандартных объектов `File`. Слой полностью изолирован от транспорта и бэкенда.
### Слой 2: Транзитная доставка через ВМ-буфер в RAM бэкенда (текущая задача)
- **Причина существования**: Ingress-контроллер k8s-кластера (`pythonk8s`) обрывает входящие запросы с телом более 64 КБ. Прямой `POST` больших файлов в кластер невозможен; исходящий трафик (egress) из кластера не ограничен.
- **Принципиальные решения**:
1. **Пофайловый транзит (Per-File Transit)** вместо накопления всей пачки в буфере:
- Файл $k$ отправляется браузером через `PUT` в буфер на ВМ.
- Браузер сразу отправляет `POST /api/upload_refs` бэкенду во Flask для одного файла $k$.
- Бэкенд забирает файл исходящим потоковым `GET` прямо в RAM сессии (`upload/backend/session/state.py`).
- Бэкенд немедленно отправляет `DELETE` на ВМ.
- Файл на ВМ удалён, буфер чист. Браузер переходит к файлу $k+1$.
- В любой момент времени в буфере на ВМ находится максимум один файл.
2. **RAM-only (хранение строго в памяти)**:
- Никакой записи на диск ни на бэкенде, ни на ВМ в постоянном режиме.
3. **Zero Hardcode**:
- Полная параметризация через конфигурацию (`vmUploadUrl` на фронтенде, `vmUploadPrefix` на бэкенде для обязательной SSRF-валидации).
4. **Автономный mock-буфер**:
- In-memory mock WebDAV (`PUT`, `GET`, `DELETE`) внутри тестового стенда `site/app.py` для автономного прогона pytest и Playwright без внешней сети.
- **API-контракт фронтенда**: функция `uploadFilesViaVm(files, options)` с колбэками прогресса по файлу/пачке, завершения файла и поддержкой отмены (`AbortController`).
- **API-контракт бэкенда**: Blueprint `upload_refs` и менеджер сессий `session` (создание сессии, потоковое вытягивание, хранение в RAM, TTL, мягкая отмена).
### Слой 3: Бизнес-логика потребителей (не входит в текущую задачу)
- Сервисы `drhider` (обфускация, LLM) и `contractor` (парсинг, diff договоров) забирают файлы из оперативной памяти сессии через `session.get_files(session_id)`.
- В `upload-platform` Слой 3 эмулируется тестовым callback/эндпоинтом, фиксирующим успешный приём файла из сессии для дальнейшей обработки.
---
## 3. Файловая структура Слоя 2 в `upload-platform`
```
upload-platform/
├── upload/
│ ├── frontend/
│ │ ├── index.js # Слой 1 (initFilePicker)
│ │ ├── table/ # UI Слоя 1
│ │ ├── zip/ # fflate
│ │ └── upload/ # Слой 2 (Фронтенд):
│ │ ├── put_to_vm.js # XHR PUT одного файла на буфер с прогрессом и abort
│ │ └── upload_via_vm.js # Пофайловый транзит (PUT -> upload_refs -> repeat)
│ └── backend/ # Слой 2 (Бэкенд):
│ ├── __init__.py
│ ├── upload_refs/
│ │ ├── __init__.py
│ │ ├── blueprint.py # Flask Blueprint (POST /api/upload_refs)
│ │ ├── pull_file.py # Исходящий потоковый GET с ретраями (httpx)
│ │ ├── safe_name.py # Санитизация имен файлов
│ │ └── config.py # Конфигурация по умолчанию
│ └── session/
│ ├── __init__.py
│ ├── state.py # In-memory хранилище сессий
│ ├── create_session.py
│ ├── add_file.py
│ ├── get_files.py
│ ├── store_result.py
│ ├── store_csv.py
│ ├── ttl.py
│ ├── cancel.py
│ └── cleanup.py
├── site/
│ ├── app.py # Flask demo + local RAM mock buffer
│ └── templates/index.html
├── tests/ # pytest + browser smoke
├── LAYER2-RESUME.md # Техническое задание и сводка
├── build.mjs
├── package.json
└── requirements.txt
```
---
## 4. Зафиксированные факты инфраструктуры
1. **ВМ `5.172.178.213`**: Nginx WebDAV настроен на сброс на диск (`/var/www/drhider-upload/`, `/var/www/contracts-upload/`) с cron-очисткой каждые 5 мин (`find ... -mmin +30 -delete`). Для целевого прода требуется перевод в RAM/in-memory буфер.
2. **Кластер k8s**: неймспейсы `20a75175-a58c-49cb-b8fa-e86367b1a8dc` (`drhider`), `b4523aba-b5e6-40f1-be56-bb4d2509357c` (`contractor`), `0e108526-5bb2-4757-9499-3f0bac4f0e83` (`uploader-dev`).
@@ -0,0 +1,52 @@
# 2026-09-06: Финальная фиксация состояния Слоя 2
## Репозиторий
- Проект: `upload-platform`.
- Основная ветка: `master`.
- Рабочее дерево на момент фиксации чистое.
- Основной коммит реализации: `112c84f`.
- Изменения отправлены в `origin/master`.
- Контрольное состояние сохранено в ветке `checkpoint-layer2-complete`, опубликованной в origin.
## Реализованное состояние
В `upload-platform` реализованы и изолированы два API-слоя:
1. **Слой 1: File Picker**
- Выбор файлов и папок.
- Клиентская распаковка ZIP и вложенных архивов.
- Дедупликация, фильтрация и лимиты.
- Контракт: `picker.getFiles()` возвращает плоский список объектов `{ path, name, size, file }`.
2. **Слой 2: Per-file Transit**
- Для каждого файла выполняется последовательность `PUT` в буфер ВМ -> `POST /api/upload_refs` -> исходящий потоковый `GET` в RAM сессии -> `DELETE` из буфера ВМ.
- Следующий файл начинает передаваться только после завершения текущего.
- В буфере ВМ одновременно находится не более одного файла.
- Файлы сессии хранятся только в оперативной памяти.
- URL буфера задаются конфигурацией через `vmUploadUrl` и `vmUploadPrefix`; боевые домены не зашиваются в интеграционный код.
- Входящие URL проходят SSRF-проверку, имена файлов санитизируются.
- Для будущего Слоя 3 предусмотрен callback `onFileReceived(sid, name, content)`.
## Автономный тестовый стенд
`site/app.py` содержит RAM-only mock WebDAV с операциями `PUT`, `GET`, `DELETE` и маршрутом статуса. Это позволяет тестировать полный транзит без внешней ВМ:
`browser -> mock buffer -> upload_refs -> RAM session -> Layer 3 callback`.
## Проверка
- `npm run build`: успешно.
- `npm test`: 9 тестов пройдены.
- `pytest tests/ -v`: 10 тестов пройдены.
- Итого: 19 тестов, все пройдены.
- Приложение `site/app.py` импортируется и регистрирует 11 маршрутов.
## Версия и документация
- Версия платформы: `0.2.0`.
- Обновлены `README.md` и `upload/README.md`.
- Техническое задание: `LAYER2-RESUME.md`.
- Подробный отчёт реализации: `HISTORY/2026-09-06-layer2-implementation-and-tests.md`.
Слой 3 в текущую реализацию не входит; он подключается потребляющим сервисом через API сессии и callback-контракт.
@@ -0,0 +1,69 @@
# 2026-09-06: Реализация и верификация Слоя 2 (пофайловый транзит через RAM)
## 1. Контекст задачи
В рамках репозитория `upload-platform` выполнена реализация Слоя 2 (транзитная доставка файлов в сессию бэкенда через внешний буфер) поверх завершённого Слоя 1 (File Picker v0.1.13) с поднятием версии платформы до `0.2.0`.
---
## 2. Ключевые архитектурные решения и реализация
### 1. Пофайловый транзит (Per-File Transit)
Вместо накопления всей пачки файлов на ВМ реализован потоковый пофайловый цикл:
1. Браузер берёт файл $k$ и отправляет его методом `PUT` в буфер на ВМ (`putToVm` через XHR со стримингом прогресса и поддержкой `AbortSignal`).
2. Браузер сразу делает `POST /api/upload_refs` только для этого одного файла $k$.
3. Бэкенд забирает файл исходящим потоковым `GET` (`httpx.stream`) прямо в оперативную память сессии (`upload/backend/session`).
4. Бэкенд немедленно отправляет `DELETE` на буфер ВМ.
5. Файл на ВМ удалён, буфер чист. Браузер переходит к файлу $k+1$.
- **Результат**: в буфере на ВМ в любой момент времени находится максимум один файл; объем потребляемой буфером памяти минимален.
### 2. Хранение строго в оперативной памяти (RAM-only)
- На бэкенде: файлы сессии хранятся в RAM-структуре `_sessions` (`upload/backend/session/state.py`). Никаких временных файлов на диске.
- Защита памяти: настраиваемые лимиты `MAX_FILE_BYTES` (по умолчанию 50 МБ) и `MAX_SESSION_BYTES` (по умолчанию 500 МБ).
- Автоматическая очистка: TTL-таймер (по умолчанию 30 мин), функции `touch`, `pause_ttl`, `resume_ttl`, `cleanup`.
### 3. Защита и параметры (Zero Hardcode & Security)
- **SSRF-защита**: `create_upload_refs_blueprint` валидирует входящие URL по префиксу `vmUploadPrefix` (поддерживаются абсолютные URL для прода и относительные для локального мока). Запросы к сторонним хостам отсекаются.
- **Path Traversal защита**: модуль `safe_name` нормализует пути, запрещает `..` и сохраняет безопасные относительные подпапки.
- **Мягкая отмена**: поддержка `AbortSignal` на фронтенде и `threading.Event` на бэкенде (`request_cancel`, `get_cancel_event`).
- **Слой 3 (эмуляция/интеграция)**: в `create_upload_refs_blueprint` добавлен колбэк `onFileReceived(sid, name, content)`, вызываемый при успешной доставке файла в RAM.
### 4. Автономный mock-буфер и тестовый стенд
- В `site/app.py` встроен in-memory mock WebDAV (`PUT`, `GET`, `DELETE` по пути `/mock-buffer/<key>`), хранящий данные в RAM.
- Позволяет запускать тесты и локальный demo-стенд на 100% автономно без доступа к внешней сети или боевой ВМ.
- В шаблоне `site/templates/index.html` добавлена панель запуска Слоя 2, отображение прогресса пофайловой передачи и просмотр файлов, сохранённых в RAM сессии.
---
## 3. Автоматическое тестирование
Создан полный набор автоматических тестов (19 тестов, 100% PASS):
1. **Бэкенд тесты (`pytest tests/ -v`, 10 тестов, 0.99s)**:
- `test_safe_name_simple` — проверка корректных путей и слэшей.
- `test_safe_name_traversal` — отсечение атак `..`, абсолютных путей, пустых строк.
- `test_session_lifecycle` — полный жизненный цикл сессии (создание, добавление, чтение, TTL, отмена, cleanup).
- `test_session_limits` — проверка ограничения суммарного размера сессии в RAM.
- `test_upload_refs_pull_and_delete` — проверка pull по исходящему GET, удаления из буфера, вызова колбэка Слоя 3.
- `test_upload_refs_ssrf_protection` — блокировка попыток pull с недоверенных хостов (SSRF).
- `test_health` — liveness-проверка платформы.
- `test_index_page` — проверка отдачи UI стенда.
- `test_mock_buffer_crud` — операции PUT, GET, DELETE и статус mock-буфера.
- `test_full_transit_flow_mock` — сквозной тест пофайлового транзита от mock-буфера до сессии Flask.
2. **Фронтенд тесты (`npm test`, `node:test`, 9 тестов, 329ms)**:
- `putToVm: успешная отправка` — проверка корректности HTTP PUT с сырым бинарным телом.
- `putToVm: ошибка HTTP статуса` — обработка ответов 4xx/5xx.
- `putToVm: сетевая ошибка` — обработка XHR onerror.
- `putToVm: таймаут` — обработка XHR ontimeout.
- `putToVm: прерывание через AbortSignal` — мгновенный abort текущего XHR.
- `uploadViaVM: пофайловый транзит` — проверка, что $N$ файлов вызывают $N$ парных запросов PUT + upload_refs последовательно, передавая `session_id`.
- `uploadViaVM: поддержка формата FilePicker.getFiles()` — совместимость со Слой 1.
- `uploadViaVM: обработка ошибки PUT` — остановка конвейера и возврат ошибки.
- `uploadViaVM: прерывание через signal` — отмена всего пофайлового цикла.
---
## 4. Версионирование и сборка
- Версия поднята с `0.1.13` до `0.2.0` в `package.json` и `site/app.py`.
- Собраны актуальные дистрибутивные бандлы `dist/file-picker.esm.js` (40.1 KB) и `dist/file-picker.iife.js` (43.4 KB).
- Документация в `README.md` и `upload/README.md` полностью обновлена с пошаговой инструкцией интеграции в `drhider` и `contractor`.
@@ -0,0 +1,26 @@
# 2026-09-06: UX-индикация ожидания первого файла
## Наблюдение
При начале загрузки пользователь мог увидеть длительное отсутствие визуального прогресса на первом файле. Причина: первый PUT ещё не успел вызвать событие `XHR upload.onprogress`, а цепочка дополнительно выполняет первичное установление соединений и передачу файла в буфер ВМ.
Это не является зависанием, но без пояснения пользователь может нажать «Отмена».
## Изменение
В demo `site/templates/index.html` добавлены:
- отдельный блок текущей фазы загрузки;
- пояснение, что выполняются соединение с буфером ВМ, PUT, перенос в RAM и очистка буфера;
- таймер `Прошло: MM:SS`, запускающийся до первого сетевого ответа;
- остановка таймера при завершении, ошибке или отмене;
- сообщение о том, что уже переданные до отмены файлы остаются в RAM сессии.
В `PLAN.md` добавлен TODO следующей UX-правки: отдельные этапы `PUT`, `GET`, `DELETE`, прогресс пачки, обратная связь при ретраях и проверка мобильного сценария.
## Проверка
- `node build.mjs` выполнен успешно.
- `node --test tests/test_upload_layer2.test.mjs`: 9 тестов пройдены.
- `python3 -m pytest tests/ -v`: 10 тестов пройдены.
- Версия приложения повышена до `0.2.1` в `package.json` и `site/app.py`.
@@ -0,0 +1,37 @@
# 2026-09-06: полный тест релиза 0.2.2
## Релиз
Проверен коммит `f7687d3` с версией `0.2.2`. Локальная ветка на момент проверки совпадала с `origin/master`.
## Результаты
- `npm run build` — успешно;
- `npm test` — `10/10` Node-тестов пройдены;
- `python3 -m pytest tests/ -v` — `10/10` Python-тестов пройдены;
- `python3 -m py_compile site/app.py` — успешно;
- `node --version` — `v18.19.1`;
- `python` — `3.12.3`;
- `git diff --check` — без замечаний;
- запуск `python3 site/app.py` — успешный, Flask слушает `0.0.0.0:5000`;
- живой `GET /health` — HTTP 200, тело `ok`;
- живой `GET /` — HTTP 200, получено `10035` байт HTML.
## Состав Node-тестов
Проверены:
- рендер папок по умолчанию в свёрнутом состоянии;
- успешный PUT в буфер ВМ;
- HTTP-ошибка PUT;
- сетевая ошибка;
- таймаут;
- отмена через `AbortSignal`;
- пофайловый транзит Layer 2;
- формат файлов `FilePicker.getFiles()`;
- ошибка PUT в `uploadViaVM`;
- отмена загрузки через signal.
## Вывод
Релиз `0.2.2` проходит полный автоматический и локальный runtime-прогон. После документирования запись должна быть отдельным коммитом и отправлена в `origin/master`.
@@ -0,0 +1,55 @@
# 2026-09-06: жёсткий полный тест релиза 0.2.2
## Объём проверки
Проверен опубликованный релиз `0.2.2`, commit `df0576a` на `origin/master`.
- frontend build;
- весь Node test suite;
- весь Python test suite с `-vv --tb=long`;
- синтаксис всех исходных Python и JavaScript файлов;
- runtime запуск Flask;
- HTTP endpoints и раздача собранных бандлов;
- отрицательные API-сценарии безопасности и целостности транзита.
## Результаты
- `npm run build` — PASS;
- `npm test` — `10/10` PASS;
- `python3 -m pytest tests/ -vv --tb=long` — `10/10` PASS;
- `py_compile` — `22` Python-файла PASS;
- `node --check` — `13` JavaScript-файлов PASS;
- `git diff --check` — PASS;
- `/health` — HTTP 200, тело `ok`;
- `/` — HTTP 200;
- `/file-picker/file-picker.esm.js` — HTTP 200;
- `/file-picker/file-picker.iife.js` — HTTP 200;
- `/upload-frontend/index.js` — HTTP 200;
- Flask слушает `0.0.0.0:5000`.
## Отрицательные сценарии
Отдельный smoke-набор подтвердил:
- пустой `upload_refs` возвращает HTTP 400;
- malformed JSON возвращает HTTP 400;
- traversal-имя не попадает в сессию и не удаляет файл из буфера;
- SSRF URL вне разрешённого prefix пропускается без pull;
- валидный файл создаёт новую RAM-сессию;
- файл переносится в RAM;
- callback Layer 3 вызывается;
- файл удаляется из mock buffer после успешного pull;
- повторное чтение удалённого файла возвращает 404.
## Исправления harness во время проверки
Два первых запуска расширенного smoke-набора остановились в тестовом harness, не в приложении:
1. Для in-process Flask pull не был задан `httpx.WSGITransport`, поэтому backend получил `Connection refused`.
2. Следующий запуск передал несуществующий `session`; API корректно вернул сценарий `Session not found` вместо создания сессии. Новая сессия создаётся при отсутствии поля `session`.
После исправления harness smoke-набор завершился `strict_negative_smoke=PASS`.
## Итог
Релиз `0.2.2` прошёл полный автоматический, синтаксический, runtime и отрицательный security-тест. Код не изменялся, поэтому версию не повышали.
+145
View File
@@ -0,0 +1,145 @@
# РЕЗЮМЕ И ТЕХНИЧЕСКОЕ ЗАДАНИЕ: СЛОЙ 2 (ЗАГРУЗКА ЧЕРЕЗ ВМ-БУФЕР)
Документ составлен 2026-09-06 для старта нового рабочего контекста в репозитории [upload-platform](upload-platform).
---
## 1. ЦЕЛЬ И АРХИТЕКТУРНЫЕ ГРАНИЦЫ
В репозитории [upload-platform](upload-platform) разрабатывается и изолированно тестируется переиспользуемый встраиваемый модуль, состоящий из двух независимых слоёв:
1. **Слой 1 (Выбор файлов / File Picker)** — **ГОТОВ И ПРОТЕСТИРОВАН (v0.1.13)**:
- Фронтенд-модуль на Vanilla JS + `fflate`.
- Поддерживает выбор отдельных файлов, выбор папок (`webkitdirectory`), распаковку ZIP и вложенных архивов.
- Дедупликация, фильтрация по расширениям, древовидный UI, лимиты (`maxEntries`, `maxEntryBytes`, `maxTotalBytes`, `maxDepth`).
- Возвращает чистый плоский массив выбранных объектов `File` через метод `picker.getFiles()`. Не зависит от способа передачи данных.
2. **Слой 2 (Транзитная загрузка файлов через ВМ-буфер)** — **ТЕКУЩАЯ ЗАДАЧА**:
- Задача слоя: доставить файлы из браузера в память сессии бэкенд-приложения (Flask).
- **Почему нужен буфер**: шлюз ingress managed-кластера Kubernetes (`pythonk8s`) обрывает входящие HTTP-запросы с телом более 64 КБ. Прямой `POST /upload` больших файлов в кластер невозможен. При этом исходящий трафик (egress) из кластера не ограничен.
- **Решение**: Браузер загружает файлы во внешний буфер на ВМ, а бэкенд вытягивает их исходящими `GET`-запросами (pull) в оперативную память своей сессии.
- **Потребители модуля**: сервис анонимизации `drhider` и сервис сверки договоров (`contractor`). Оба сервиса встраивают Слой 1 + Слой 2 как подпапку `upload/` без изменения исходного кода модулей (только собственный конфиг) и поверх накладывают свою бизнес-логику (Слой 3: LLM, обфускация, парсинг, diff).
---
## 2. ФАКТИЧЕСКОЕ СОСТОЯНИЕ ИНФРАСТРУКТУРЫ (ПРОВЕРЕНО НА ВМ И В K8S)
Проверено 2026-09-06 прямыми командами на ВМ и в кластере:
1. **ВМ `5.172.178.213` (contracts.kube5s.ru)**:
- Доступ: `ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213`.
- В `nginx` настроены WebDAV-буферы:
- `/drhider-upload/` $\rightarrow$ `alias /var/www/drhider-upload/;`, CORS: `https://drhider.pythonk8s.dev.nubes.ru`.
- `/contracts-upload/` $\rightarrow$ `alias /var/www/contracts-upload/;`, CORS: `https://contractor.pythonk8s.dev.nubes.ru`.
- **Проблема текущей реализации на ВМ**:
- Nginx WebDAV пишет файлы на диск (корневой раздел).
- Cron каждые 5 минут удаляет файлы старше 30 минут: `find /var/www/drhider-upload -type f -mmin +30 -delete`.
- По требованию безопасности файлы не должны оседать на диске — обработка и буферизация должны быть строго в оперативной памяти (RAM).
2. **Kubernetes-кластер (`iot-naeel`)**:
- Под `drhider`: namespace `20a75175-a58c-49cb-b8fa-e86367b1a8dc`, хост `drhider.pythonk8s.dev.nubes.ru`.
- Под `contractor`: namespace `b4523aba-b5e6-40f1-be56-bb4d2509357c`, хост `contractor.pythonk8s.dev.nubes.ru`.
- Под `uploader-dev` (полигон): namespace `0e108526-5bb2-4757-9499-3f0bac4f0e83`, хост `uploader-dev.pythonk8s.dev.nubes.ru` (на нём крутится `upload-platform` 0.1.13).
---
## 3. ПРИНЦИПИАЛЬНЫЕ АРХИТЕКТУРНЫЕ ИЗМЕНЕНИЯ СЛОЯ 2
### Изменение 1: Пофайловый транзит (Streaming / Per-File Transit) вместо батча
- **Как было раньше**: Браузер в цикле заливал все файлы пачки в буфер на ВМ, и только в самом конце отправлял во Flask один общий запрос `/api/upload_refs` со списком всех ссылок. Буфер был вынужден одновременно хранить все файлы пачки (до сотен мегабайт).
- **Как должно быть**:
1. Браузер берет **один файл** $\rightarrow$ делает `PUT` в буфер на ВМ.
2. Браузер сразу делает `POST /api/upload_refs` для **этого одного файла**.
3. Flask вытягивает его исходящим потоковым `GET` прямо в память сессии (`site/session.py`) и сразу отправляет `DELETE` на ВМ.
4. Файл на ВМ удален. Браузер переходит к следующему файлу.
- **Результат**: В буфере на ВМ в любой момент времени находится максимум один файл. Общий объем памяти буфера минимален.
### Изменение 2: Хранение строго в памяти (RAM)
- Никаких временных файлов на диске.
- На бэкенде Flask файлы хранятся в RAM-структуре сессии (`upload/backend/session/state.py`).
- На ВМ для буфера не должен использоваться сброс на диск (на стороне Nginx требуется проксирование в память или легковесный in-memory микросервис).
### Изменение 3: Полная параметризация (Zero Hardcode)
- Прямой IP из браузера поверх HTTPS невозможен (Mixed Content, отсутствие доверенного TLS-сертификата).
- В коде не должно быть зашитых хостов (`contracts.kube5s.ru` и т.д.).
- Все адреса передаются через конфигурацию:
- Фронтенд: `vmUploadUrl` (например, `https://contracts.kube5s.ru/drhider-upload/` или локальный URL).
- Бэкенд: `vmUploadPrefix` (для обязательной SSRF-валидации входящих URL перед pull).
### Изменение 4: Автономный mock-буфер для тестов в `upload-platform`
- Тесты в [upload-platform](upload-platform) не должны зависеть от внешней сети или чужих боевых ВМ.
- В тестовый стенд и dev-сервер [site/app.py](upload-platform/site/app.py) встраивается локальный легковесный in-memory mock WebDAV (обработка `PUT`, `GET`, `DELETE`), что позволяет гонять Playwright- и Python-тесты полностью оффлайн.
---
## 4. СТРУКТУРА МОДУЛЯ В `upload-platform`
```
upload-platform/
├── upload/
│ ├── frontend/
│ │ ├── index.js # Точка входа Слоя 1 (initFilePicker)
│ │ ├── table/ # Логика и UI Слоя 1
│ │ ├── zip/ # Распаковка архивов (fflate)
│ │ └── upload/ # СЛОЙ 2 (ФРОНТЕНД):
│ │ ├── put_to_vm.js # XHR PUT одного файла на буфер с прогрессом и abort
│ │ └── upload_via_vm.js # Пофайловый транзит (PUT -> upload_refs -> repeat)
│ └── backend/ # СЛОЙ 2 (БЭКЕНД):
│ ├── __init__.py
│ ├── upload_refs/
│ │ ├── __init__.py
│ │ ├── blueprint.py # Flask Blueprint (POST /api/upload_refs)
│ │ ├── pull_file.py # Исходящий потоковый GET с ретраями (httpx)
│ │ ├── safe_name.py # Санитизация имен (защита от path traversal)
│ │ └── config.py # Дефолтные параметры (retries, delay)
│ └── session/
│ ├── __init__.py
│ ├── state.py # In-memory хранилище сессий (_sessions, lock, TTL)
│ ├── create_session.py # Создание сессии (UUID)
│ ├── add_file.py # Добавление файла с проверкой лимита памяти
│ ├── get_files.py # Получение списка файлов
│ ├── store_result.py # Сохранение ZIP-результата
│ ├── store_csv.py # Сохранение CSV-результата
│ ├── ttl.py # touch, pause_ttl, resume_ttl
│ ├── cancel.py # Мягкая отмена сессии (Event)
│ └── cleanup.py # Очистка сессии
├── site/
│ ├── app.py # Flask demo (Слой 1 + Слой 2 + local mock buffer)
│ └── templates/index.html # Demo UI со сквозным сценарием выбора и загрузки
├── tests/ # Тесты Слоя 2 (pytest + browser smoke)
├── build.mjs # Сборка бандлов (ESM / IIFE)
├── package.json # Версионирование платформы
└── requirements.txt # Зависимости Python (Flask, httpx, pytest)
```
---
## 5. ПОШАГОВЫЙ ПЛАН РЕАЛИЗАЦИИ В НОВОМ ЧАТЕ
1. **Шаг 1. Перенос и адаптация бэкенда Слоя 2**:
- Перенести `backend/session/` и `backend/upload_refs/` из [upload/backend](upload/backend) в [upload-platform/upload/backend](upload-platform/upload/backend).
- В `upload-platform/requirements.txt` зафиксировать `httpx>=0.27.0`.
- Проверить чистоту импортов и типизацию.
2. **Шаг 2. Перенос и адаптация фронтенда Слоя 2 на пофайловый транзит**:
- Создать [upload-platform/upload/frontend/upload/put_to_vm.js](upload-platform/upload/frontend/upload/put_to_vm.js) (XHR PUT с прогрессом и возможностью `abort`).
- Создать [upload-platform/upload/frontend/upload/upload_via_vm.js](upload-platform/upload/frontend/upload/upload_via_vm.js) с реализацией **пофайловой передачи**:
- цикл по файлам;
- загрузка файла $k$ на буфер;
- немедленный вызов `/api/upload_refs` для файла $k$;
- обновление прогресса в UI;
- переход к файлу $k+1$.
3. **Шаг 3. Локальный mock-буфер и интеграция в `site/app.py`**:
- Встроить в [upload-platform/site/app.py](upload-platform/site/app.py) тестовый Blueprint mock-буфера (`PUT`, `GET`, `DELETE` в оперативной памяти).
- Подключить `create_upload_refs_blueprint` к приложению.
- Обновить [upload-platform/site/templates/index.html](upload-platform/site/templates/index.html), добавив кнопку «Загрузить» и отображение прогресса пофайловой передачи.
4. **Шаг 4. Написание автоматических тестов**:
- Python unit-тесты (`pytest`): `safe_name`, `session` (TTL, лимиты, add/get), `upload_refs` (SSRF-блокировка, pull с mock-буфера, удаление после pull).
- Playwright browser-тесты: сквозной сценарий от выбора файлов в `initFilePicker` до их успешного появления в сессии Flask.
5. **Шаг 5. Сборка, bump версии и документация**:
- Обновить `build.mjs` и `package.json` (bump версии).
- Написать подробный [upload-platform/README.md](upload-platform/README.md) с инструкцией для агентов, как встроить оба слоя в сторонний сервис за 2 минуты.
- Фиксация коммитом и пуш.
+22 -11
View File
@@ -1,5 +1,27 @@
# Upload Platform — подробный план реализации
> **LEGACY — документ устарел и не является планом текущей реализации.**
> Этот файл описывает отменённую архитектуру с VM upload, backend sessions,
> Flask API, лимитами загрузки и слоем `/api/process`. Текущий проект работает
> как browser-only file-picker: выбор файлов, папок и ZIP выполняется в браузере,
> а backend не принимает и не хранит выбранные файлы.
>
> Актуальное описание: [README.md](README.md) и
> [upload/README.md](upload/README.md). История перехода на picker-only и
> исправлений находится в [HISTORY/2026-09-05-file-picker.md](HISTORY/2026-09-05-file-picker.md).
## TODO следующей правки: UX загрузки
- Показывать таймер и понятное пояснение уже до первого сетевого ответа.
- Отдельно отображать этапы `PUT в буфер ВМ`, `GET в RAM`, `DELETE из буфера`.
- Показывать прогресс пачки: текущий файл, общее количество, уже доставленные файлы.
- Не оставлять пользователя без обратной связи при DNS/TLS/CORS-задержке и повторных попытках.
- Перед отменой явно сообщать, сколько файлов уже доставлено в RAM и что будет сохранено.
- Проверить UX на мобильном экране и при медленном соединении.
>
> Весь текст ниже сохранён только как исторический план и не должен использоваться
> для новых изменений без отдельного пересмотра требований.
> Целевой исполнитель: Flash / GPT-5.6 Luna. План самодостаточен: читай и кодируй по нему.
> Источник-референс (паттерн уже отлажен): drhider v0.0.75 (`site/templates/index.html`,
> `site/routes/api_bp.py`, `site/session.py`, `upload/`). Здесь — **standalone** проект:
@@ -64,22 +86,11 @@ upload-platform/
│ │ │ ├── esc.js
│ │ │ ├── add_file_with_dedup.js
│ │ │ ├── render.js
│ │ │ ├── set_status.js
│ │ │ ├── on_files_change.js
│ │ │ ├── on_folder_change.js
│ │ │ └── init_upload_table.js
│ │ └── upload/ # слой 2 (фронт)
│ │ ├── put_to_vm.js
│ │ └── upload_via_vm.js
│ └── backend/
│ ├── upload_refs/ # слой 2 (бэк)
│ │ ├── __init__.py
│ │ ├── config.py
│ │ ├── safe_name.py
│ │ ├── pull_file.py
│ │ └── blueprint.py
│ └── session/
│ ├── __init__.py
│ ├── state.py
│ ├── create_session.py
│ ├── add_file.py
+174 -43
View File
@@ -1,65 +1,196 @@
# Upload Platform
Переиспользуемый сервис загрузки файлов через ВМ-буфер. Выносит два слоя —
**выбор файлов** (фронт) и **закачку через ВМ** (фронт + бэк) — в отдельный
проект, который копируется в любой другой проект.
Модульная платформа загрузки и транзита документов (версия `0.2.0`). Состоит из двух независимых автономных слоёв с чистыми API:
Паттерн: браузер → `PUT` на ВМ-буфер → Flask `POST /api/upload_refs` (egress pull) → сессия.
1. **Слой 1 (File Picker)**: Клиентский выбор файлов, папок и вложенных ZIP-архивов с дедупликацией, фильтрацией по расширениям, проверкой лимитов и визуализацией в виде дерева.
2. **Слой 2 (Transit Upload via VM Buffer)**: Пофайловый потоковый транзит файлов из браузера в оперативную память (RAM) сессии бэкенда через внешний WebDAV-буфер на ВМ. Решает проблему ограничения Ingress Kubernetes на входящий payload (64 КБ) за счёт исходящего pull из кластера.
## Слои
---
| Слой | Ответственность |
|---|---|
| 0. Конфиг | `vmUploadUrl`, `allowedExt`, лимиты, ретраи |
| 1. Выбор файлов | таблица, дедуп, раскрытие ZIP, рекурсивный обход папки |
| 2. Закачка через ВМ | PUT на ВМ → pull в сессию |
| 3. Логика приложения | здесь — стаб-заглушка (в каждом проекте своя) |
## Архитектура слоёв и API
## Структура
```
[ Браузер ]
│
├─► Слой 1: initFilePicker() ──► picker.getFiles() -> File[]
│
└─► Слой 2: uploadViaVM(files, { vmUploadUrl, backendUploadUrl })
│
├─ 1. PUT файл k ──► [ ВМ-буфер (RAM) ]
│ ▲
└─ 2. POST /api/upload_refs│
│ │
▼ │
[ Flask Backend ] ────┘ 3. Потоковый GET (pull) в RAM
│
├─► 4. DELETE файл k с ВМ
└─► 5. session.add_file(sid, name, bytes)
│
▼
[ Слой 3: drhider / contractor ]
```
### Слой 1: File Picker API
- **Вход**: DOM-контейнер (`mount`), разрешённые расширения (`allowedExt`), лимиты (`limits`).
- **Выход**: `picker.getFiles()` возвращает плоский массив объектов `{ path, name, size, file }`.
- **Автономность**: Не зависит от транспорта и бэкенда.
### Слой 2: Transit Upload API
- **Фронтенд**: функция `uploadViaVM(files, options)`:
- `vmUploadUrl`: базовый URL буфера на ВМ (Zero Hardcode).
- `backendUploadUrl`: эндпоинт приёма ссылок (`/api/upload_refs`).
- `signal`: `AbortSignal` для отмены загрузки.
- `onProgress`: колбэк прогресса загрузки текущего файла.
- `onFileStatus`: колбэк статуса строки файла.
- `onFileComplete`: колбэк завершения переноса файла в сессию бэкенда.
- **Бэкенд**: Flask Blueprint `create_upload_refs_blueprint(cfg)`:
- Принимает ссылку на один файл (пофайловый транзит).
- Проверяет URL на доверенный префикс (`vmUploadPrefix`, защита от SSRF).
- Санитизирует имя файла (`safe_name`, защита от path traversal).
- Вытягивает байты потоком исходящим `GET` (ретраи, таймауты) прямо в RAM.
- Немедленно удаляет файл из буфера на ВМ (`DELETE`).
- Сохраняет файл в RAM сессии (`upload/backend/session`).
- При необходимости вызывает `onFileReceived(sid, name, content)` для передачи в Слой 3.
---
## Структура репозитория
```
upload-platform/
├── requirements.txt
├── config.json # слой 0 (рабочий конфиг этого демо)
├── upload/ # ← переиспользуемый модуль (копируется в любой проект)
│ ├── README.md # инструкция интеграции
│ ├── config.example.json
│ ├── frontend/ # слои 1+2 (vanilla JS, ES-модули)
│ │ ├── zip/
│ │ ├── table/
│ │ └── upload/
│ └── backend/ # слой 2 (Python)
├── upload/ # Переиспользуемый встраиваемый модуль
│ ├── frontend/
│ │ ├── index.js # Точка входа фронтенда (initFilePicker, uploadViaVM, putToVm)
│ │ ├── table/ # UI и дерево файлов (Слой 1)
│ │ ├── zip/ # Распаковка архивов fflate (Слой 1)
│ │ └── upload/ # Транспорт Слоя 2:
│ │ ├── put_to_vm.js # XHR PUT одного файла на буфер с прогрессом и abort
│ │ └── upload_via_vm.js # Пофайловый транзит (PUT -> upload_refs -> repeat)
│ └── backend/ # Серверная часть Слоя 2:
│ ├── upload_refs/
│ │ ├── blueprint.py # Flask Blueprint (POST /api/upload_refs)
│ │ ├── pull_file.py # Исходящий потоковый GET с ретраями (httpx)
│ │ ├── safe_name.py # Защита от path traversal
│ │ └── config.py # Дефолтные параметры
│ └── session/
└── site/ # демо-обёртка (не переиспользуется)
├── app.py
├── routes/
├── templates/
└── static/
│ ├── state.py # In-memory хранилище сессий (_sessions, TTL, lock)
│ ├── create_session.py # Создание сессии (UUID)
│ ├── add_file.py # Добавление файла в RAM с проверкой лимита
│ ├── get_files.py # Чтение файлов сессии
│ ├── store_result.py # Хранение результирующего ZIP
│ ├── store_csv.py # Хранение CSV
│ ├── ttl.py # touch, pause_ttl, resume_ttl
│ ├── cancel.py # Мягкая отмена сессии (threading.Event)
│ └── cleanup.py # Удаление сессии
├── site/ # Тестовый стенд и demo-сервер
│ ├── app.py # Flask demo + встроенный RAM mock WebDAV буфер
│ ├── templates/index.html # Demo UI со сквозным сценарием выбора и загрузки
│ └── static/style.css
├── dist/ # Готовые бандлы (собираются через build.mjs)
│ ├── file-picker.esm.js
│ └── file-picker.iife.js
├── tests/ # Автоматические тесты (100% offline)
│ ├── test_safe_name.py # Тесты санитизации путей
│ ├── test_session.py # Тесты RAM-хранилища сессий и лимитов
│ ├── test_upload_refs.py # Тесты Blueprint, SSRF-защиты и pull
│ ├── test_app_integration.py # Сквозной интеграционный тест site/app.py
│ └── test_upload_layer2.test.mjs # Юнит-тесты фронтенда Слоя 2 (node:test)
├── build.mjs # esbuild сборщик
├── package.json # Версия 0.2.0, npm-скрипты
└── requirements.txt # Python зависимости (Flask, httpx, pytest)
```
## Локальный запуск
---
## Быстрый старт и запуск
### 1. Установка зависимостей
```bash
pip install -r requirements.txt
python site/app.py
# → http://127.0.0.1:5000/
npm install
```
## Деплой на Nubes
### 2. Сборка фронтенд-бандлов
```bash
npm run build
```
- Точка входа: `site/app.py` (платформа запускает `python site/app.py`).
- `app.run(host="0.0.0.0", port=5000, debug=False)`.
- Маршрут `/health` → `200` (иначе liveness-проба платформы убивает под).
- Без `site/__init__.py` и factory pattern.
### 3. Запуск локального demo-сервера
```bash
python site/app.py
# Сервер доступен по адресу http://127.0.0.1:5000/
```
В стенде встроен локальный **in-memory mock-буфер** (`/mock-buffer/`), поэтому demo полностью функционально локально без подключения к боевой ВМ.
## Как интегрировать слои в другой проект
### 4. Запуск всех тестов
```bash
# Бэкенд тесты (pytest)
pytest tests/ -v
1. Скопировать папку `upload/` в свой проект.
2. Задать свой конфиг (по образцу `upload/config.example.json`).
3. Фронт: подключить ES-модули из `upload/frontend/` через `<script type="module">`,
вызвать `initUploadTable(cfg)` и `uploadViaVM(...)`.
4. Бэк: зарегистрировать `create_upload_refs_blueprint(cfg)`, импортировать `upload.backend.session`.
5. Слой 3 (обработка файлов) — свой; сюда приходит список файлов сессии после закачки.
# Фронтенд тесты (Node.js test runner)
npm test
```
---
## Инструкция: интеграция в сторонний сервис за 2 минуты
Чтобы встроить оба слоя в целевой сервис (`drhider`, `contractor` и др.):
### 1. Скопировать модуль
Скопировать директорию `upload/` в корень целевого проекта:
```bash
cp -r upload-platform/upload/ /path/to/service/upload/
```
### 2. Подключить бэкенд во Flask
В файле создания Flask-приложения (например, `app.py`):
```python
from upload.backend.upload_refs import create_upload_refs_blueprint
from upload.backend.session import get_files
upload_bp = create_upload_refs_blueprint({
"apiPrefix": "/api",
"vmUploadPrefix": "https://contracts.kube5s.ru/drhider-upload/", # Префикс вашего буфера
"maxFileBytes": 50 * 1024 * 1024,
"maxSessionBytes": 500 * 1024 * 1024,
})
app.register_blueprint(upload_bp)
```
### 3. Подключить фронтенд
В шаблоне страницы:
```html
<div id="file-picker"></div>
<button id="upload-btn">Загрузить</button>
<script src="/dist/file-picker.iife.js"></script>
<script>
const picker = FilePicker.initFilePicker({
mount: '#file-picker',
allowedExt: ['.pdf', '.docx', '.txt'],
});
document.getElementById('upload-btn').addEventListener('click', async () => {
const files = picker.getFiles();
const result = await FilePicker.uploadViaVM(files, {
vmUploadUrl: 'https://contracts.kube5s.ru/drhider-upload/',
backendUploadUrl: '/api/upload_refs',
onFileStatus: (idx, status) => console.log(`File ${idx}: ${status}`),
});
if (result.ok) {
console.log('Успешно загружено в сессию:', result.session);
// Запуск Слоя 3 (обработка файлов сессии)
}
});
</script>
```
### 4. Получение файлов в Слое 3
Бизнес-логика получает файлы из оперативной памяти:
```python
files = get_files(session_id)
# files: [("document.pdf", b"...bytes..."), ...]
```
Никаких временных файлов на диске. Полная безопасность и изоляция.
Подробнее — `PLAN.md`.
+25
View File
@@ -0,0 +1,25 @@
import { build } from 'esbuild';
import { rm } from 'node:fs/promises';
await rm('dist', { recursive: true, force: true });
const shared = {
entryPoints: ['upload/frontend/index.js'],
bundle: true,
minify: false,
sourcemap: false,
logLevel: 'info',
};
await build({
...shared,
format: 'esm',
outfile: 'dist/file-picker.esm.js',
});
await build({
...shared,
format: 'iife',
globalName: 'FilePicker',
outfile: 'dist/file-picker.iife.js',
});
+5
View File
@@ -0,0 +1,5 @@
{
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"],
"vmUploadUrl": "/mock-buffer/",
"vmUploadPrefix": "/mock-buffer/"
}
+160
View File
@@ -0,0 +1,160 @@
# Справочник кода Upload Platform
Документ описывает актуальную picker-only реализацию. VM upload, backend sessions
и API загрузки относятся к отменённой архитектуре и в текущем коде отсутствуют.
## Архитектура
```text
site/app.py
-> site/templates/index.html
-> dist/file-picker.iife.js
-> upload/frontend/index.js
-> table/on_files_change.js
-> table/on_folder_change.js
-> table/add_file_with_dedup.js
-> table/render.js
-> zip/list_zip_files.js
```
Flask отдаёт страницу и готовый bundle. Все выбранные файлы остаются в памяти браузера;
сервер не принимает содержимое файлов.
## Модель данных
Каждый узел дерева имеет поля:
| Поле | Тип | Назначение |
|---|---|---|
| `id` | `string` | Уникальный идентификатор для DOM-кнопок и поиска. |
| `kind` | `file`, `folder`, `zip` | Тип узла. `file` является leaf. |
| `name` | `string` | Имя отображения в дереве. |
| `path` | `string` | Полный логический путь внутри выбора/архива. |
| `file` | `File \| null` | Исходный или созданный из ZIP browser File для leaf. |
| `children` | `Array` | Дочерние узлы группы. |
| `expanded` | `boolean` | Видимость дочерних строк в таблице. |
Внутреннее состояние picker-а:
| Поле | Назначение |
|---|---|
| `nodes` | Корневые узлы дерева. |
| `fileKeys` | Set ключей `path + NUL + size` для дедупликации. |
| `busy` | Защита от параллельной обработки async change-событий. |
## `upload/frontend/table`
### `add_file_with_dedup.js`
| Функция | Вход | Выход | Побочные эффекты |
|---|---|---|---|
| `addFileWithDedup(state, fileNode)` | state и корневой file/folder/zip-узел | `boolean` | Фильтрует дубли и пустые группы, добавляет узел и ключи в state. |
Дедупликация выполняется рекурсивно. Один и тот же путь с другим размером не
считается дублем.
### `esc.js`
| Функция | Вход | Выход |
|---|---|---|
| `esc(value)` | Любое значение | HTML-экранированная строка |
Используется перед `innerHTML`, `data-path`, `aria-label` и текстом имени.
### `fs.js`
| Функция | Вход | Выход |
|---|---|---|
| `fs(bytes)` | Размер в байтах | Строка в `B`, `KB` или `MB` |
### `rebase_tree.js`
| Функция | Вход | Выход | Побочные эффекты |
|---|---|---|---|
| `rebaseTree(root, prefix)` | Дерево и префикс папки | То же дерево | Рекурсивно меняет `path`; для leaf сохраняет базовое имя `File.name`. |
Модуль является единственной реализацией rebasing для ZIP внутри выбранной папки
и для nested ZIP.
### `on_files_change.js`
| Функция | Вход | Выход | Назначение |
|---|---|---|---|
| `addFiles(state, cfg, files, elements)` | state, `allowedExt`, FileList/Array, DOM | `Promise<void>` | Фильтрует обычные файлы, раскрывает ZIP, вызывает render. |
| `onFilesChange(state, cfg, elements)` | state, config, DOM | async callback | Блокирует повторные события через `busy` и вызывает `addFiles`. |
Ошибка отдельного ZIP пропускается и передаётся в `cfg.onError(error, file)`, если
callback задан. `finally` обязательно освобождает `busy`.
### `on_folder_change.js`
| Функция | Вход | Выход | Назначение |
|---|---|---|---|
| `onFolderChange(state, cfg, elements)` | state, `allowedExt`, folder input и DOM | async callback | Строит дерево по `webkitRelativePath`, раскрывает ZIP и рендерит. |
Корень пути становится folder-узлом. Вложенные папки создаются по сегментам
пути, затем корень проходит общую дедупликацию.
### `render.js`
| Функция | Вход | Выход | Назначение |
|---|---|---|---|
| `renderNode(node, depth, cfg)` | Узел, глубина и render config | HTML | Рекурсивно создаёт строки дерева. Внутренняя функция. |
| `render(state, elements, cfg)` | state, DOM и render config | `void` | Перерисовывает таблицу и счётчик. |
| `findNode(nodes, id)` | Массив узлов и id | `{node, nodes}` или `null` | Ищет узел и его родительский массив. |
| `flattenFiles(nodes, result)` | Дерево и аккумулятор | Массив leaf-файлов | Преобразует дерево для API приложения. |
`render` считает все leaf-файлы, включая свернутые группы. `renderNode` показывает
дочерние строки только при `expanded === true`.
## `upload/frontend/zip`
### `list_zip_files.js`
| Функция | Вход | Выход | Назначение |
|---|---|---|---|
| `extensionAllowed(name, allowedExt)` | Имя и расширения | boolean | Проверяет расширение. Внутренняя. |
| `makeFile(data, name)` | Байты и путь | `File` | Создаёт browser File с базовым именем. Внутренняя. |
| `safeEntryParts(entryName)` | Сырой ZIP-путь | сегменты или null | Отбрасывает traversal и опасные пути. Внутренняя. |
| `node(kind, name, path, children, file)` | Метаданные узла | node | Создаёт узел. Внутренняя. |
| `addPath(root, parts, fileNode)` | Дерево, сегменты, узел | `void` | Создаёт папки и вставляет узел. Внутренняя. |
| `listEntries(data, zipName, allowedExt, depth)` | ZIP bytes и контекст | `Promise<node|null>` | Рекурсивно обрабатывает ZIP. Внутренняя. |
| `listZipFiles(file, allowedExt, limits)` | browser File, расширения и limits | `Promise<node|null>` | Публичная точка входа ZIP-парсера. |
Безопасность: отклоняются абсолютные пути, backslash, пустые сегменты, `.` и
`..`; применяются лимиты `maxEntries`, `maxTotalBytes`, `maxEntryBytes` и
`maxDepth` с безопасными значениями по умолчанию. ZIP без разрешённых leaf-файлов
возвращает `null` и не создаёт пустую группу.
## `upload/frontend/index.js`
`initFilePicker({ mount, allowedExt, labels, layout, limits, onChange, onError })`
строит весь DOM внутри HTMLElement или CSS-селектора `mount`, подключает inputs,
таблицу и обработчики. Возвращаемый API: `pickFiles`, `pickFolder`, `addFiles`,
`getFiles`, `remove`, `clear`, `render`, `destroy`. `getFiles` и `onChange`
возвращают копии массива leaf-файлов. `destroy` снимает слушатели и очищает mount.
IIFE-бандл экспортирует этот API как `FilePicker.initFilePicker`; ESM-бандл
экспортирует функцию напрямую. `fflate` включён в оба bundle, поэтому глобальный
vendor script не требуется.
## Flask и HTML
### `site/app.py`
| Маршрут/объект | Вход | Выход |
|---|---|---|
| `CONFIG` | `config.json` | JSON-конфигурация picker |
| `VERSION` | константа | Версия страницы |
| `index()` / `/` | HTTP GET | HTML с version и config |
| `health()` / `/health` | HTTP GET | `ok`, HTTP 200 |
| `upload_frontend(filename)` | HTTP GET и относительный путь | Совместимый маршрут для исходных frontend-модулей |
| `file_picker_dist(filename)` | HTTP GET и имя bundle | Bundle из `dist` |
### `site/templates/index.html`
Шаблон создаёт mount, подключает IIFE bundle с query-параметром версии для
сброса browser cache и вызывает `FilePicker.initFilePicker`.
Vendor `site/static/vendor/fflate.min.js` не документируется построчно: это
внешняя библиотека, используемая как готовый runtime dependency.
+206
View File
@@ -0,0 +1,206 @@
# План: picker → универсальный встраиваемый компонент — LEGACY
> Переход завершён. Этот исторический план не является инструкцией или
> спецификацией текущего кода. Актуальные документы: `README.md`,
> `upload/README.md` и `docs/CODE-REFERENCE.md`.
Документ для разработчика (GPT Luna). Все факты верифицированы по текущему коду
(`upload-platform` на коммите `aec8ad4`, ветка `master`). Кодить строго по этому плану,
не вносить изменений за рамками перечисленных пунктов.
## Цель
Превратить слой выбора файлов в переиспользуемый компонент/АПИ, который встраивается
в любое приложение (включая managed Flask `drhider`) одной строкой. На входе задаются:
типы файлов, порядок/скрытие колонок, все надписи, тема. Контент файлов на сервер не уходит.
## Верифицированное текущее состояние
- Язык: vanilla JS, ES-модули. Flask — только статический сервер.
- Структура `upload/frontend/`:
- `table/init_upload_table.js` — `initUploadTable(cfg)` ТРЕБУЕТ готовые DOM-элементы
`fileInputEl`, `folderInputEl`, `tableBodyEl`, `countEl`. Возвращает
`{pickFiles, pickFolder, addFiles, getFiles, remove, render, clear}`.
- `table/on_files_change.js` — `addFiles`, `onFilesChange` (фильтр расширений, ZIP).
- `table/on_folder_change.js` — `onFolderChange` (webkitdirectory).
- `table/add_file_with_dedup.js` — `addFileWithDedup` (дедуп `path + NUL + size`).
- `table/render.js` — `renderNode`, `render`, `findNode`, `flattenFiles`.
ЗАШИТЫ строки: `'готов'`, `'Удалить ${name}'`, `'Нет выбранных файлов'`,
`'${n} файлов · ${bytes}'`.
- `table/rebase_tree.js`, `table/esc.js` (HTML-экранирование), `table/fs.js` (байты).
- `table/set_status.js` — LEGACY, не используется, НЕ трогать.
- `zip/list_zip_files.js` — `listZipFiles`; на строке 65 вызывает ГЛОБАЛЬНУЮ
`fflate.unzipSync(data)`. Содержит `safeEntryParts` (path traversal), лимит глубины 20.
- `site/app.py` — маршруты `/`, `/health`, `/upload-frontend/<path:filename>`
(`send_from_directory(ROOT/"upload"/"frontend", filename)`). VERSION `0.1.10`.
- `site/templates/index.html` — demo: глобально грузит `vendor/fflate.min.js`,
импортирует модуль по АБСОЛЮТНОМУ пути `/upload-frontend/table/init_upload_table.js`,
содержит зашитые `<thead>` «Путь/Размер/Статус» и кнопки.
- `site/static/vendor/fflate.min.js` — локальная глобальная `fflate`.
- `node_modules/fflate` есть, но корневого `package.json` НЕТ.
- `requirements.txt` — Flask>=3.0, gunicorn, requests.
## Жёсткие связи (что убираем)
1. Глобальная зависимость `fflate` (реальная — `list_zip_files.js:65`).
2. Абсолютный путь импорта `/upload-frontend/...` (в `index.html` + маршрут в `app.py`).
3. Зашитые надписи/колонки в `render.js` и `<thead>`.
4. Принудительный DOM-контракт `initUploadTable`.
5. Нет сборки/единой точки входа.
## Целевая архитектура
Единая точка входа `initFilePicker(config)`:
- сама строит DOM внутри `config.mount` (не требует готовых элементов);
- переиспользует существующие модули (дедуп, ZIP, рендер, обход папок);
- возвращает API с `destroy()`.
Один бандл (ESM + IIFE) с вшитым `fflate` и инжектом CSS. Без Shadow DOM, без системы тем,
без npm-пайплайна (dist коммитится в git).
## Схема конфига `initFilePicker(config)`
```js
initFilePicker({
mount, // HTMLElement | CSS-селектор (обязательно)
allowedExt: ['.pdf', '.txt'],// обязательно; сопоставление регистронезависимое
labels: {
pickFiles: 'Выбрать файлы',
pickFolder: 'Выбрать папку',
clear: 'Очистить',
empty: 'Нет выбранных файлов',
statusReady: 'готов',
remove: 'Удалить', // подставляется в aria-label и title
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
count: (n, bytes) => `${n} файлов · ${formatBytes(bytes)}`, // необязательно
},
layout: {
columns: ['path', 'size', 'status'], // порядок + скрытие (пустой массив = все)
controls: ['files', 'folder', 'clear'],
theme: null, // строка-класс для кастомизации (не система тем)
},
limits: { // защита ZIP (обязательные значения по умолчанию)
maxEntries: 1000,
maxTotalBytes: 100 * 1024 * 1024, // суммарный распакованный размер
maxEntryBytes: 50 * 1024 * 1024, // размер одного entry
maxDepth: 20,
},
onChange(files) {}, // вызывается после каждого изменения дерева
onError(err) {}, // необязательно
});
// Возвращает:
// { pickFiles, pickFolder, addFiles, getFiles, remove, clear, render, destroy }
// getFiles() возвращает копию массива (не внутренний state).
// destroy() снимает слушатели, чистит DOM и state.
```
## Пошаговые изменения по файлам
### 1. `upload/frontend/zip/list_zip_files.js`
- Заменить глобальный `fflate.unzipSync(data)` на импорт: `import { unzipSync } from 'fflate'`.
- Убрать вызов глобальной `fflate`; использовать `unzipSync(data, { filter })`.
`filter` получает entry-метаданные `{ name, size, originalSize, compression }`
и возвращает `false` для пропуска ДО распаковки. Через `filter` реализовать:
- счётчик обработанных entries > `limits.maxEntries` → выбросить ошибку/остановиться;
- `originalSize > limits.maxEntryBytes` → пропустить entry;
- накопительный распакованный размер > `limits.maxTotalBytes` → выбросить ошибку
(защита от zip-бомбы).
- `listZipFiles(file, allowedExt, limits)` — принять `limits`, прокинуть в `listEntries`.
- Лимит `depth > limits.maxDepth` вместо зашитого `20`.
- Сохранить `safeEntryParts` без изменений.
### 2. `upload/frontend/table/render.js`
- `renderNode(node, depth, cfg)` и `render(state, elements, cfg)` принимают `cfg`
(labels + layout). Заменить зашитые строки на `cfg.labels.*`:
- `'готов'` → `cfg.labels.statusReady`;
- `'Удалить ${name}'` → `${cfg.labels.remove} ${name}`;
- `'Нет выбранных файлов'` → `cfg.labels.empty`;
- счётчик → `cfg.labels.count(files.length, totalBytes)` или дефолт.
- Колонки: рендерить `<td>` только для колонок из `layout.columns` в заданном порядке;
при скрытой колонке не выводить её ячейку и соответствующий `<th>`.
- `flattenFiles` оставить как есть (он уже возвращает новые объекты), но в
`getFiles()` дополнительно отдавать `[...flattenFiles(state.nodes)]` (копия массива).
### 3. `upload/frontend/table/on_files_change.js` и `on_folder_change.js`
- Прокинуть `limits` в `listZipFiles(file, cfg.allowedExt, cfg.limits)`.
- Остальное (фильтр, busy, ZIP-catch) без изменений.
### 4. НОВЫЙ `upload/frontend/index.js` (единая точка входа)
- Экспортировать `initFilePicker(config)`.
- Внутри: построить разметку (кнопки, скрытые `<input type=file>`, таблица, счётчик)
внутри `config.mount`, с подписями из `config.labels` и набором кнопок из
`config.layout.controls`.
- Создать state `{ nodes, fileKeys: new Set(), busy }`.
- Переиспользовать существующие функции: навесить `onFilesChange`/`onFolderChange`
на созданные inputs, делегирование кликов (toggle/remove) как в `init_upload_table.js`.
- Вызывать `config.onChange` после каждой перерисовки с копией `getFiles()`.
- Реализовать `destroy()`: снять все слушатели, очистить `mount.innerHTML`, обнулить state.
- Оставить `initUploadTable` рабочим (обратная совместимость demo), но demo перевести на `initFilePicker`.
- Инжектировать CSS: `import cssText from './style.css'` (через esbuild loader `text`),
при первом вызове вставить `<style data-file-picker>` в `<head>` один раз.
### 5. НОВЫЙ `upload/frontend/style.css`
- Перенести релевантные стили из `site/static/style.css`, ВСЕ селекторы обернуть
под корневой класс `.file-picker` (префикс-изоляция, без Shadow DOM).
- Классы: `.file-picker`, `.fp-toolbar`, `.fp-table`, `.fp-tree-*`, `.fp-remove-btn`,
`.fp-status`, `.fp-count`. Поддержать `config.layout.theme` как доп. класс на контейнере.
### 6. Сборка
- Добавить `package.json` (devDependency `esbuild`).
- `build`-скрипт собирает из `upload/frontend/index.js`:
- `dist/file-picker.esm.js` (format=esm, fflate inline);
- `dist/file-picker.iife.js` (format=iife, глобал `FilePicker`, fflate inline);
- CSS инлайнится через loader `text`.
- `dist/` коммитится в git.
### 7. Demo `site/`
- `site/templates/index.html`: убрать глобальный `<script fflate>` и абсолютный import;
подключить собранный бандл (`dist/file-picker.iife.js` или ESM) и вызвать
`initFilePicker({ mount, allowedExt: {{ config|tojson }}.allowedExt, ... })`.
- `site/app.py`: убрать или оставить маршрут `/upload-frontend/...` (рекомендуется
оставить для совместимости, но demo использует бандл); добавить раздачу `dist/`
при необходимости. VERSION поднять (см. ниже).
### 8. Документация
- Обновить `README.md`, `upload/README.md`, `docs/CODE-REFERENCE.md` под новый API
`initFilePicker` и бандл. Пометить `initUploadTable`/`set_status.js` как legacy.
## Требования безопасности (обязательно сохранить)
- Экранирование всех имён/путей через `esc` перед `innerHTML` (уже есть — не сломать).
- Path traversal в ZIP (`safeEntryParts`) — без изменений.
- Новые лимиты ZIP из п.1 — обязательны (zip-бомба).
- `getFiles()`/`onChange` отдают копию, а не внутренний state.
- Не полагаться на абсолютные URL; пути бандла/ассетов задаются интегратором.
## Критерии приёмки
1. `initFilePicker({ mount, allowedExt, labels, layout, onChange })` встраивается
в пустую страницу одной строкой и работает (файл/папка/ZIP, дедуп, удаление, очистка).
2. Глобальной `fflate` больше нет (только внутри бандла).
3. Нет ни одного абсолютного импорта `/upload-frontend/...`.
4. Надписи и порядок/скрытие колонок меняются через `labels`/`layout` без правки кода.
5. `destroy()` полностью чистит DOM и слушатели; повторный `initFilePicker` в тот же
`mount` работает.
6. ZIP-бомба/превышение лимитов не вешает браузер (срабатывают `limits`).
7. `getFiles()` возвращает копию; мутация результата не ломает state.
8. Demo в `site/` работает через бандл; `/health` → 200.
## Что НЕ делать
- Не вводить Shadow DOM, не делать систему тем/плагинов, не публиковать в npm/CDN.
- Не трогать `set_status.js`, `site/static/vendor/fflate.min.js` (после перехода на
бандл тег можно удалить из index.html, но файл vendor можно оставить).
- Не менять формат возвращаемых leaf-объектов (`{ path, name, size, file }`).
## Порядок коммитов
Логические шаги (после каждого — `node --check`/сборка + `git diff --check`):
1. ZIP-лимиты + импорт fflate (п.1, п.3).
2. Параметризация render/labels/layout (п.2).
3. Новая точка входа `initFilePicker` + CSS + destroy (п.4, п.5).
4. Сборка esbuild (п.6).
5. Demo на бандл (п.7).
6. Документация (п.8) + bump VERSION в `site/app.py`.
@@ -0,0 +1,25 @@
# Ответ GPT SOL по архитектуре
## 1. Архитектура
Архитектура верна. Лишнее сейчас: `layout.theme` как система тем и Custom Element. Достаточно CSS-переменных/класса темы.
## 2. Более простой вариант
`initFilePicker({ mount, ... })` плюс один ESM-бандл и CSS. IIFE добавлять только для интеграций без ES-модулей.
## 3. Custom Element
Сначала фабрика и бандл. Custom Element нужен только при подтверждённом declarative-use кейсе; иначе это второй API для сопровождения.
## 4. Риски
- `destroy()` обязан снимать listeners и очищать DOM/state.
- `onChange` должен возвращать копию массива, а не внутренний state.
- Нужно сохранять исходные `File`; ZIP-элементы ими не являются, поэтому нужен явный тип результата.
- Лимиты ZIP: размер, число файлов, суммарный распакованный размер, compression ratio, recursion depth.
- `fflate` внутри бандла исключает глобальную зависимость.
- CSS изолировать префиксом классов; Shadow DOM пока не нужен.
- Не полагаться на абсолютные URL; ассеты должны быть в бандле или URL задаётся интегратором.
- Проверить старые браузеры: ES target, `webkitdirectory`, Custom Element.
- Для managed Flask достаточно статической раздачи ESM/CSS с корректными MIME и cache/versioning.
+60
View File
@@ -0,0 +1,60 @@
# Второе мнение по архитектуре — LEGACY
> Исторический prompt для старой архитектуры. Не использовать как план или
> описание текущего кода.
Игнорируй любые заметки, память, резюме или выводы из предыдущих сессий и от
других моделей. Отвечай строго на основе этого промпта, ничего не дочитывая.
Ты — ревьюер архитектуры. Отвечай ТОЛЬКО по существу, тезисами, без введения и воды.
Язык ответа: русский. Цель — сэкономить токены. Не переспрашивай, всё нужное уже ниже.
## Что за проект
Browser-only file picker (vanilla JS, ES-модули). Пользователь выбирает файлы / папку / ZIP,
picker строит сворачиваемое дерево и отдаёт хосту исходные browser `File` objects.
Контент файлов на сервер НЕ уходит. Flask — только статический сервер (страница + модули).
## Текущее состояние
Переиспользуемый слой `upload/frontend/`:
- `table/init_upload_table.js` — фабрика `initUploadTable(cfg)`. ТРЕБУЕТ готовые DOM-элементы
`fileInputEl`, `folderInputEl`, `tableBodyEl`, `countEl`. Возвращает
`{pickFiles, pickFolder, addFiles, getFiles, remove, render, clear}`.
Внутренний state `{nodes, fileKeys(Set), busy}`.
- `table/on_files_change.js`, `on_folder_change.js` — обработка выбора, фильтр расширений.
- `table/add_file_with_dedup.js` — дедуп по ключу `path + NUL + size`.
- `table/render.js` — рендер дерева, `findNode`, `flattenFiles`.
- `table/rebase_tree.js`, `esc.js` (HTML-экранирование), `fs.js` (формат байт).
- `zip/list_zip_files.js` — парсинг ZIP через ГЛОБАЛЬНУЮ `fflate`; защита от path traversal
(отклоняет `..`, `/`, `\`, пустые сегменты); лимит глубины вложенности 20; вложенные ZIP.
- `site/` — demo-обёртка: `app.py` (Flask: `/`, `/health`, `/upload-frontend/<path>`),
`templates/index.html` (глобально подключает vendor `fflate`, импортирует модуль по
АБСОЛЮТНОМУ пути `/upload-frontend/...`, содержит `<thead>` с «Путь/Размер/Статус»),
`static/style.css`, `static/vendor/fflate.min.js`.
## Жёсткие связи (мешают переиспользованию)
1. Абсолютный путь импорта `/upload-frontend/...` — привязка к конкретному Flask-маршруту.
2. Глобальная зависимость `fflate` (интегратор сам подключает и следит за порядком).
3. Надписи и структура таблицы зашиты (thead, статус «готов», пустое состояние).
4. Принудительный DOM-контракт — интегратор строит разметку сам, «вставить без изменений» нельзя.
5. Нет сборки/дистрибутива — только исходники ES-модулей.
## Цель
Универсальный встраиваемый компонент/АПИ, который вставляется в любое приложение (включая
managed Flask — drhider) одной строкой. На входе задаются: типы файлов (`allowedExt`),
порядок/скрытие колонок, все надписи, тема. Без излишней сложности.
## Моё предложение
1. Единая точка входа `initFilePicker(config)` — сама строит DOM (не требует готовых элементов).
Config: `mount`, `allowedExt`, `labels{...все надписи...}`, `layout{columns, controls, theme}`,
`onChange`. Возвращает API + `destroy`.
2. Сборка esbuild/rollup в ОДИН бандл с вшитым `fflate`, форматы ESM + IIFE.
3. Опционально тонкий Custom Element `<file-picker allowed-ext="...">` поверх фабрики (~30 строк).
4. Без iframe/postMessage, без системы тем/плагинов, без npm-пайплайна (dist коммитится в git).
## Вопрос
1. Архитектура верна? Есть ли ошибка или переусложнение?
2. Есть ли вариант проще, но столь же универсальный?
3. Custom Element делать сразу или хватит фабрики + бандла?
4. Критичные риски / что упущено (безопасность, совместимость, managed Flask)?
Ответь по пунктам 1–4, кратко, только по делу.
@@ -0,0 +1,45 @@
# Ответ Sonnet по архитектуре
## Важная поправка к вводным
Промпт устарел относительно реального кода.
- `zip/list_zip_files.js` не использует глобальную `fflate` — реально вызывает `parseZip()` из `parse_zip.js`, собственный ZIP-парсер без внешних зависимостей.
- Защита от path traversal и лимит вложенности 20 реальны, но находятся в `list_zip_files.js` (`safeEntryParts`, `depth`), а не в парсинге через `fflate`.
- `index.html:37` всё ещё грузит `static/vendor/fflate.min.js` с комментарием «ZIP-парсер использует global fflate» — это мёртвый код и ложный комментарий, `fflate` фактически не импортируется.
- В `add_file_with_dedup.js` есть логика `maxFileBytes`/`maxSessionBytes`, но `config.json` содержит только `allowedExt`; лимиты сейчас нигде не задаются, код неактивен.
## 1. Архитектура
Направление верное: `initFilePicker(config)` с построением DOM внутри, конфигом для `allowedExt/labels/layout`, `onChange` и `destroy` соответствует цели «одна строка встраивания».
Переусложнение: бандл esbuild/rollup с вшитым `fflate` решает несуществующую проблему. Внешней зависимости `fflate` в JS-коде нет, есть только мёртвый script-тег. Нужен клинап, а не сборка ради вшивания отсутствующей зависимости.
Реальные жёсткие связи:
- абсолютный путь импорта `/upload-frontend/...`;
- захардкоженные подписи в `<thead>`.
## 2. Более простой вариант
Оставить ES-модули без сборщика. Добавить в конфиг `basePath` или import map вместо хардкода `/upload-frontend/...`, чтобы интегратор (`drhider`) мог использовать произвольный route.
Подписи и колонки вынести в `labels/layout`.
Отдельно удалить мёртвый `fflate` script-тег и неактивные `maxFileBytes/maxSessionBytes`, если они не используются picker-only конфигом.
## 3. Custom Element
Бандл не нужен. Custom Element пока не делать: для единственного известного интегратора (`drhider`) достаточно фабричной функции.
Custom Element имеет смысл добавить при появлении второго реального потребителя, которому нужен декларативный `<file-picker>`.
## 4. Риски
- Главный риск — план основан на неверной предпосылке о глобальной `fflate`-зависимости.
- Нужно проверить защиту от ZIP-бомб: `parse_zip.js` разбирает central directory и делает `inflateRaw` без очевидной проверки итогового размера entry.
- Совместимость с managed Flask (`drhider`) реально блокируется абсолютным route `/upload-frontend/...`; это главная связь для исправления.
## Краткий вывод
Начать с `basePath`/import map, конфигурируемых `labels/layout` и удаления мёртвого `fflate`/неактивных лимитов. Не добавлять пока сборщик и Custom Element.
+480
View File
@@ -0,0 +1,480 @@
{
"name": "upload-platform-file-picker",
"version": "0.1.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "upload-platform-file-picker",
"version": "0.1.10",
"dependencies": {
"fflate": "^0.8.3"
},
"devDependencies": {
"esbuild": "^0.25.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "upload-platform-file-picker",
"private": true,
"version": "0.2.2",
"type": "module",
"scripts": {
"build": "node build.mjs",
"test": "node --test tests/*.test.mjs"
},
"dependencies": {
"fflate": "^0.8.3"
},
"devDependencies": {
"esbuild": "^0.25.0"
}
}
+3 -1
View File
@@ -1,3 +1,5 @@
Flask>=3.0
gunicorn>=21.2
requests>=2.31
requests>=2.31
httpx>=0.27.0
pytest>=8.0.0
+136 -6
View File
@@ -1,20 +1,150 @@
from flask import Flask, render_template
import json
import logging
from pathlib import Path
import sys
import threading
from flask import Blueprint, Flask, Response, jsonify, render_template, request, send_from_directory
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from upload.backend.upload_refs import create_upload_refs_blueprint
from upload.backend.session import get_files, file_count, cleanup
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
log = logging.getLogger("upload-platform")
# Конфигурация стенда: разрешённые расширения, буфер и лимиты
DEFAULT_CONFIG = {
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"],
"vmUploadUrl": "/mock-buffer/",
"vmUploadPrefix": "/mock-buffer/",
}
try:
with (ROOT / "config.json").open(encoding="utf-8") as config_file:
CONFIG = {**DEFAULT_CONFIG, **json.load(config_file)}
except (OSError, json.JSONDecodeError):
CONFIG = DEFAULT_CONFIG
VERSION = "0.2.2"
app = Flask(
__name__,
template_folder=str(ROOT / "site" / "templates"),
static_folder=str(ROOT / "site" / "static"),
)
# -----------------------------------------------------------------------------
# In-memory Mock Buffer (эмуляция WebDAV буфера на ВМ для локальных тестов)
# -----------------------------------------------------------------------------
_mock_storage = {}
_mock_storage_lock = threading.Lock()
mock_buffer_bp = Blueprint("mock_buffer", __name__)
VERSION = "0.1.1"
app = Flask(__name__, template_folder="templates", static_folder="static")
@mock_buffer_bp.route("/mock-buffer/<path:key>", methods=["PUT"])
def mock_put(key):
"""Принимает файл от браузера в RAM mock-буфера."""
data = request.get_data()
with _mock_storage_lock:
_mock_storage[key] = data
log.info("MockBuffer: stored key=%s (%d bytes)", key, len(data))
return "", 201
@mock_buffer_bp.route("/mock-buffer/<path:key>", methods=["GET"])
def mock_get(key):
"""Отдаёт файл по GET-запросу бэкенда (исходящий pull)."""
with _mock_storage_lock:
data = _mock_storage.get(key)
if data is None:
log.warning("MockBuffer: key not found key=%s", key)
return "Not found", 404
return Response(data, mimetype="application/octet-stream")
@mock_buffer_bp.route("/mock-buffer/<path:key>", methods=["DELETE"])
def mock_delete(key):
"""Удаляет файл из RAM mock-буфера после успешного pull."""
with _mock_storage_lock:
existed = _mock_storage.pop(key, None) is not None
log.info("MockBuffer: deleted key=%s (existed=%s)", key, existed)
return "", 204
@mock_buffer_bp.route("/mock-buffer/status", methods=["GET"])
def mock_status():
"""Диагностика mock-буфера: количество и размер находящихся в нём файлов."""
with _mock_storage_lock:
items = [{"key": k, "size": len(v)} for k, v in _mock_storage.items()]
return jsonify({"count": len(items), "items": items})
# -----------------------------------------------------------------------------
# Регистрация Слоя 2 (upload_refs) с эмуляцией передачи в Слой 3
# -----------------------------------------------------------------------------
def on_file_received_for_layer3(sid: str, name: str, content: bytes):
"""Эмуляция отдачи файла в Слой 3 бизнес-логики (обфускация/парсинг)."""
log.info("[Layer 3 Emulation] Ready to process file in session %s: name=%s, size=%d bytes",
sid, name, len(content))
upload_refs_bp = create_upload_refs_blueprint({
"apiPrefix": "/api",
"vmUploadPrefix": CONFIG.get("vmUploadPrefix", "/mock-buffer/"),
"maxFileBytes": CONFIG.get("maxFileBytes", 50 * 1024 * 1024),
"maxSessionBytes": CONFIG.get("maxSessionBytes", 500 * 1024 * 1024),
"ttlSeconds": CONFIG.get("ttlSeconds", 30 * 60),
"pullRetries": 2,
"pullRetryDelay": 0.3,
"onFileReceived": on_file_received_for_layer3,
})
app.register_blueprint(mock_buffer_bp)
app.register_blueprint(upload_refs_bp)
# -----------------------------------------------------------------------------
# Роуты тестового стенда
# -----------------------------------------------------------------------------
@app.route("/")
def index():
return render_template("index.html", version=VERSION)
"""Возвращает demo-страницу со Слой 1 (picker) и Слой 2 (transit upload)."""
return render_template("index.html", version=VERSION, config=CONFIG)
@app.route("/health")
def health():
"""Liveness-проверка платформы."""
return "ok", 200
@app.route("/api/session/<sid>/files", methods=["GET"])
def session_files(sid):
"""Проверка содержимого сессии в RAM бэкенда (для тестов и UI)."""
files = get_files(sid)
if files is None:
return jsonify({"ok": False, "error": "Session not found"}), 404
return jsonify({
"ok": True,
"session": sid,
"files": [{"name": name, "size": len(content)} for name, content in files]
})
@app.get("/upload-frontend/<path:filename>")
def upload_frontend(filename):
"""Отдаёт исходные модули фронтенда из каталога upload/frontend."""
return send_from_directory(ROOT / "upload" / "frontend", filename)
@app.get("/file-picker/<path:filename>")
def file_picker_dist(filename):
"""Отдаёт собранный бандл из каталога dist."""
return send_from_directory(ROOT / "dist", filename)
if __name__ == "__main__":
app.run(debug=False, host="0.0.0.0", port=5000)
app.run(debug=False, host="0.0.0.0", port=5000)
+52 -2
View File
@@ -1,8 +1,58 @@
/* Базовые цвета и типографика всего demo-picker. */
:root {
color-scheme: light;
font-family: sans-serif;
font-family: Georgia, 'Times New Roman', serif;
color: #17211b;
background: #e9eee8;
}
/* Фон страницы намеренно отделён от карточной логики: picker не требует backend UI. */
body {
margin: 2rem;
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at 10% 0%, #f8fbf4, transparent 38%), #e9eee8;
}
/* Ограничивает ширину рабочей области и сохраняет адаптивные боковые поля. */
.page { max-width: 980px; margin: 0 auto; padding: 64px 24px; }
/* Служебный идентификатор версии приложения. */
.eyebrow { color: #56745f; font: 700 12px/1.2 sans-serif; letter-spacing: 2px; }
/* Заголовок и пояснение вводят пользователя в сценарий выбора документов. */
h1 { max-width: 620px; margin: 12px 0; font-size: clamp(38px, 7vw, 72px); line-height: .95; font-weight: 400; }
.lede { max-width: 560px; color: #5d6a61; font: 16px/1.6 sans-serif; }
/* Группа действий: выбор файлов, папки и очистка состояния. */
.toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin: 36px 0 16px; }
button { border: 0; border-radius: 4px; padding: 12px 17px; color: #f7faf4; background: #1f5a3b; font: 700 13px sans-serif; cursor: pointer; }
button.secondary { color: #1f5a3b; background: #cbdcc9; }
button.quiet { color: #5d6a61; background: transparent; }
button:disabled { opacity: .45; cursor: not-allowed; }
/* Резервная область сообщения интегратора сохраняет высоту и не двигает таблицу. */
.status { min-height: 22px; color: #56745f; font: 13px sans-serif; }
.upload-progress-explanation { display: grid; gap: 4px; margin-top: 14px; color: #46604e; font: 13px/1.45 sans-serif; }
.upload-progress-explanation strong { color: #1b4d3e; }
.upload-progress-explanation span:last-child { color: #6b796f; font-variant-numeric: tabular-nums; }
/* Горизонтальный scroll на узких экранах не ломает таблицу дерева. */
.table-wrap { overflow-x: auto; border-top: 1px solid #b9c7ba; }
table { width: 100%; border-collapse: collapse; font: 14px/1.4 sans-serif; }
th, td { padding: 14px 10px; border-bottom: 1px solid #cbd5cc; text-align: left; }
th { color: #56745f; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
td:nth-child(2) { width: 140px; color: #68766c; }
.tree-name { display: inline-flex; align-items: center; gap: 8px; min-width: 240px; padding-top: 0; padding-bottom: 0; border: 0; background: transparent; color: #17211b; font: inherit; text-align: left; }
.tree-group { padding-left: 0; cursor: pointer; }
.tree-folder { color: #1f5a3b; }
.tree-zip { color: #8a5a20; }
.tree-chevron { display: inline-block; width: 12px; color: #78917d; font-size: 10px; }
/* Удаление работает для leaf и групп и обрабатывается делегированием в initUploadTable. */
.remove-btn { padding: 2px 7px; color: #8c5148; background: transparent; font: 20px/1 sans-serif; }
.remove-btn:hover { color: #b32f22; }
.tree-row td:first-child { white-space: nowrap; }
.count { color: #5d6a61; font: 13px sans-serif; }
/* Legacy-стили результата сохранены для совместимости, текущий picker их не использует. */
.result { margin-top: 44px; padding-top: 18px; border-top: 2px solid #1f5a3b; }
.result h2 { font-size: 26px; font-weight: 400; }
.result li { margin: 8px 0; font: 14px sans-serif; }
@media (max-width: 600px) {
/* На мобильном экране кнопки получают равномерную ширину. */
.page { padding: 36px 16px; }
.toolbar button { flex: 1 1 42%; }
}
+1
View File
@@ -0,0 +1 @@
!function(f){typeof module!='undefined'&&typeof exports=='object'?module.exports=f():typeof define!='undefined'&&define.amd?define(f):(typeof self!='undefined'?self:this).fflate=f()}(function(){var _e={};"use strict";_e.deflate=zt,_e.deflateSync=kt,_e.inflate=At,_e.inflateSync=Tt,_e.gzip=It,_e.compress=It,_e.gzipSync=Ut,_e.compressSync=Ut,_e.gunzip=Zt,_e.gunzipSync=qt,_e.zlib=Lt,_e.zlibSync=Bt,_e.unzlib=Nt,_e.unzlibSync=Pt,_e.gzip=It,_e.compress=It,_e.decompress=Jt,_e.decompressSync=Kt,_e.strToU8=nn,_e.strFromU8=rn,_e.zip=dn,_e.zipSync=gn,_e.unzip=zn,_e.unzipSync=kn;var t=(typeof module!='undefined'&&typeof exports=='object'?function(_f){"use strict";var e,r,t,n=";var __w=require('worker_threads');__w.parentPort.on('message',function(m){onmessage({data:m})}),postMessage=function(m,t){__w.parentPort.postMessage(m,t)},close=process.exit;self=global";try{e=require("worker_threads"),r=e.Worker,t=e.isMarkedAsUntransferable}catch(e){}exports.default=r?function(e,o,a,s,u){var i=!1,l=new r(e+n,{eval:!0}).on("error",function(e){return u(e,null)}).on("message",function(e){return u(null,e)}).on("exit",function(e){e&&!i&&u(Error("exited with code "+e),null)});return t&&(s=s.filter(function(e){return!t(e)})),l.postMessage(a,s),l.terminate=function(){return i=!0,r.prototype.terminate.call(l)},l}:function(e,r,t,n,o){setImmediate(function(){return o(Error("async operations unsupported - update to Node 12+ (or Node 10-11 with the --experimental-worker CLI flag)"),null)});var a=function(){};return{terminate:a,postMessage:a}};return _f}:function(_f){"use strict";var e={};_f.default=function(r,t,s,a,n){var o=new Worker(e[t]||(e[t]=URL.createObjectURL(new Blob([r+';addEventListener("error",function(e){e=e.error;postMessage({$e$:[e.message,e.code,e.stack]})})'],{type:"text/javascript"}))));return o.onmessage=function(e){var r=e.data,t=r.$e$;if(t){var s=Error(t[0]);s.code=t[1],s.stack=t[2],n(s,null)}else n(null,r)},o.postMessage(s,a),o};return _f})({}),n=Uint8Array,r=Uint16Array,i=Int32Array,e=new n([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),o=new n([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),s=new n([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),a=function(t,n){for(var e=new r(31),o=0;o<31;++o)e[o]=n+=1<<t[o-1];var s=new i(e[30]);for(o=1;o<30;++o)for(var a=e[o];a<e[o+1];++a)s[a]=a-e[o]<<5|o;return{b:e,r:s}},u=a(e,2),h=u.b,f=u.r;h[28]=258,f[258]=28;for(var c=a(o,0),l=c.b,p=c.r,v=new r(32768),d=0;d<32768;++d){var g=(43690&d)>>1|(21845&d)<<1;v[d]=((65280&(g=(61680&(g=(52428&g)>>2|(13107&g)<<2))>>4|(3855&g)<<4))>>8|(255&g)<<8)>>1}var y=function(t,n,i){for(var e=t.length,o=0,s=new r(n);o<e;++o)t[o]&&++s[t[o]-1];var a,u=new r(n);for(o=1;o<n;++o)u[o]=u[o-1]+s[o-1]<<1;if(i){a=new r(1<<n);var h=15-n;for(o=0;o<e;++o)if(t[o])for(var f=o<<4|t[o],c=n-t[o],l=u[t[o]-1]++<<c,p=l|(1<<c)-1;l<=p;++l)a[v[l]>>h]=f}else for(a=new r(e),o=0;o<e;++o)t[o]&&(a[o]=v[u[t[o]-1]++]>>15-t[o]);return a},m=new n(288);for(d=0;d<144;++d)m[d]=8;for(d=144;d<256;++d)m[d]=9;for(d=256;d<280;++d)m[d]=7;for(d=280;d<288;++d)m[d]=8;var b=new n(32);for(d=0;d<32;++d)b[d]=5;var w=y(m,9,0),x=y(m,9,1),z=y(b,5,0),k=y(b,5,1),M=function(t){for(var n=t[0],r=1;r<t.length;++r)t[r]>n&&(n=t[r]);return n},S=function(t,n,r){var i=n/8|0;return(t[i]|t[i+1]<<8)>>(7&n)&r},A=function(t,n){var r=n/8|0;return(t[r]|t[r+1]<<8|t[r+2]<<16)>>(7&n)},T=function(t){return(t+7)/8|0},D=function(t,r,i){return(null==r||r<0)&&(r=0),(null==i||i>t.length)&&(i=t.length),new n(t.subarray(r,i))};_e.FlateErrorCode={UnexpectedEOF:0,InvalidBlockType:1,InvalidLengthLiteral:2,InvalidDistance:3,StreamFinished:4,NoStreamHandler:5,InvalidHeader:6,NoCallback:7,InvalidUTF8:8,ExtraFieldTooLong:9,InvalidDate:10,FilenameTooLong:11,StreamFinishing:12,InvalidZipData:13,UnknownCompressionMethod:14};var C=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],I=function(t,n,r){var i=Error(n||C[t]);if(i.code=t,Error.captureStackTrace&&Error.captureStackTrace(i,I),!r)throw i;return i},U=function(t,r,i,a){var u=t.length,f=a?a.length:0;if(!u||r.f&&!r.l)return i||new n(0);var c=!i,p=c||2!=r.i,v=r.i;c&&(i=new n(3*u));var d=function(t){var r=i.length;if(t>r){var e=new n(Math.max(2*r,t));e.set(i),i=e}},g=r.f||0,m=r.p||0,b=r.b||0,w=r.l,z=r.d,C=r.m,U=r.n,F=8*u;do{if(!w){g=S(t,m,1);var E=S(t,m+1,3);if(m+=3,!E){var Z=t[(Y=T(m)+4)-4]|t[Y-3]<<8,q=Y+Z;if(q>u){v&&I(0);break}p&&d(b+Z),i.set(t.subarray(Y,q),b),r.b=b+=Z,r.p=m=8*q,r.f=g;continue}if(1==E)w=x,z=k,C=9,U=5;else if(2==E){var O=S(t,m,31)+257,G=S(t,m+10,15)+4,L=O+S(t,m+5,31)+1;m+=14;for(var B=new n(L),H=new n(19),j=0;j<G;++j)H[s[j]]=S(t,m+3*j,7);m+=3*G;var N=M(H),P=(1<<N)-1,V=y(H,N,1);for(j=0;j<L;){var Y,J=V[S(t,m,P)];if(m+=15&J,(Y=J>>4)<16)B[j++]=Y;else{var K=0,Q=0;for(16==Y?(Q=3+S(t,m,3),m+=2,K=B[j-1]):17==Y?(Q=3+S(t,m,7),m+=3):18==Y&&(Q=11+S(t,m,127),m+=7);Q--;)B[j++]=K}}var R=B.subarray(0,O),W=B.subarray(O);C=M(R),U=M(W),w=y(R,C,1),z=y(W,U,1)}else I(1);if(m>F){v&&I(0);break}}p&&d(b+131072);for(var X=(1<<C)-1,$=(1<<U)-1,_=m;;_=m){var tt=(K=w[A(t,m)&X])>>4;if((m+=15&K)>F){v&&I(0);break}if(K||I(2),tt<256)i[b++]=tt;else{if(256==tt){_=m,w=null;break}var nt=tt-254;tt>264&&(nt=S(t,m,(1<<(et=e[j=tt-257]))-1)+h[j],m+=et);var rt=z[A(t,m)&$],it=rt>>4;if(rt||I(3),m+=15&rt,W=l[it],it>3){var et=o[it];W+=A(t,m)&(1<<et)-1,m+=et}if(m>F){v&&I(0);break}p&&d(b+131072);var ot=b+nt;if(b<W){var st=f-W,at=Math.min(W,ot);for(st+b<0&&I(3);b<at;++b)i[b]=a[st+b]}for(;b<ot;++b)i[b]=i[b-W]}}r.l=w,r.p=_,r.b=b,r.f=g,w&&(g=1,r.m=C,r.d=z,r.n=U)}while(!g);return b!=i.length&&c?D(i,0,b):i.subarray(0,b)},F=function(t,n,r){var i=n/8|0;t[i]|=r<<=7&n,t[i+1]|=r>>8},E=function(t,n,r){var i=n/8|0;t[i]|=r<<=7&n,t[i+1]|=r>>8,t[i+2]|=r>>16},Z=function(t,i){for(var e=[],o=0;o<t.length;++o)t[o]&&e.push({s:o,f:t[o]});var s=e.length,a=e.slice();if(!s)return{t:j,l:0};if(1==s){var u=new n(e[0].s+1);return u[e[0].s]=1,{t:u,l:1}}e.sort(function(t,n){return t.f-n.f}),e.push({s:-1,f:25001});var h=e[0],f=e[1],c=0,l=1,p=2;for(e[0]={s:-1,f:h.f+f.f,l:h,r:f};l!=s-1;)h=e[e[c].f<e[p].f?c++:p++],f=e[c!=l&&e[c].f<e[p].f?c++:p++],e[l++]={s:-1,f:h.f+f.f,l:h,r:f};var v=a[0].s;for(o=1;o<s;++o)a[o].s>v&&(v=a[o].s);var d=new r(v+1),g=q(e[l-1],d,0);if(g>i){o=0;var y=0,m=g-i,b=1<<m;for(a.sort(function(t,n){return d[n.s]-d[t.s]||t.f-n.f});o<s;++o){var w=a[o].s;if(!(d[w]>i))break;y+=b-(1<<g-d[w]),d[w]=i}for(y>>=m;y>0;){var x=a[o].s;d[x]<i?y-=1<<i-d[x]++-1:++o}for(;o>=0&&y;--o){var z=a[o].s;d[z]==i&&(--d[z],++y)}g=i}return{t:new n(d),l:g}},q=function(t,n,r){return-1==t.s?Math.max(q(t.l,n,r+1),q(t.r,n,r+1)):n[t.s]=r},O=function(t){for(var n=t.length;n&&!t[--n];);for(var i=new r(++n),e=0,o=t[0],s=1,a=function(t){i[e++]=t},u=1;u<=n;++u)if(t[u]==o&&u!=n)++s;else{if(!o&&s>2){for(;s>138;s-=138)a(32754);s>2&&(a(s>10?s-11<<5|28690:s-3<<5|12305),s=0)}else if(s>3){for(a(o),--s;s>6;s-=6)a(8304);s>2&&(a(s-3<<5|8208),s=0)}for(;s--;)a(o);s=1,o=t[u]}return{c:i.subarray(0,e),n:n}},G=function(t,n){for(var r=0,i=0;i<n.length;++i)r+=t[i]*n[i];return r},L=function(t,n,r){var i=r.length,e=T(n+2);t[e]=255&i,t[e+1]=i>>8,t[e+2]=255^t[e],t[e+3]=255^t[e+1];for(var o=0;o<i;++o)t[e+o+4]=r[o];return 8*(e+4+i)},B=function(t,n,i,a,u,h,f,c,l,p,v){F(n,v++,i),++u[256];for(var d=Z(u,15),g=d.t,x=d.l,k=Z(h,15),M=k.t,S=k.l,A=O(g),T=A.c,D=A.n,C=O(M),I=C.c,U=C.n,q=new r(19),B=0;B<T.length;++B)++q[31&T[B]];for(B=0;B<I.length;++B)++q[31&I[B]];for(var H=Z(q,7),j=H.t,N=H.l,P=19;P>4&&!j[s[P-1]];--P);var V,Y,J,K,Q=p+5<<3,R=G(u,m)+G(h,b)+f,W=G(u,g)+G(h,M)+f+14+3*P+G(q,j)+2*q[16]+3*q[17]+7*q[18];if(l>=0&&Q<=R&&Q<=W)return L(n,v,t.subarray(l,l+p));if(F(n,v,1+(W<R)),v+=2,W<R){V=y(g,x,0),Y=g,J=y(M,S,0),K=M;var X=y(j,N,0);for(F(n,v,D-257),F(n,v+5,U-1),F(n,v+10,P-4),v+=14,B=0;B<P;++B)F(n,v+3*B,j[s[B]]);v+=3*P;for(var $=[T,I],_=0;_<2;++_){var tt=$[_];for(B=0;B<tt.length;++B)F(n,v,X[rt=31&tt[B]]),v+=j[rt],rt>15&&(F(n,v,tt[B]>>5&127),v+=tt[B]>>12)}}else V=w,Y=m,J=z,K=b;for(B=0;B<c;++B){var nt=a[B];if(nt>255){var rt;E(n,v,V[257+(rt=nt>>18&31)]),v+=Y[rt+257],rt>7&&(F(n,v,nt>>23&31),v+=e[rt]);var it=31&nt;E(n,v,J[it]),v+=K[it],it>3&&(E(n,v,nt>>5&8191),v+=o[it])}else E(n,v,V[nt]),v+=Y[nt]}return E(n,v,V[256]),v+Y[256]},H=new i([65540,131080,131088,131104,262176,1048704,1048832,2114560,2117632]),j=new n(0),N=function(t,s,a,u,h,c){var l=c.z||t.length,v=new n(u+l+5*(1+Math.ceil(l/7e3))+h),d=v.subarray(u,v.length-h),g=c.l,y=7&(c.r||0);if(s){y&&(d[0]=c.r>>3);for(var m=H[s-1],b=m>>13,w=8191&m,x=(1<<a)-1,z=c.p||new r(32768),k=c.h||new r(x+1),M=Math.ceil(a/3),S=2*M,A=function(n){return(t[n]^t[n+1]<<M^t[n+2]<<S)&x},C=new i(25e3),I=new r(288),U=new r(32),F=0,E=0,Z=c.i||0,q=0,O=c.w||0,G=0;Z+2<l;++Z){var j=A(Z),N=32767&Z,P=k[j];if(z[N]=P,k[j]=N,O<=Z){var V=l-Z;if((F>7e3||q>24576)&&(V>423||!g)){y=B(t,d,0,C,I,U,E,q,G,Z-G,y),q=F=E=0,G=Z;for(var Y=0;Y<286;++Y)I[Y]=0;for(Y=0;Y<30;++Y)U[Y]=0}var J=2,K=0,Q=w,R=N-P&32767;if(V>2&&j==A(Z-R))for(var W=Math.min(b,V)-1,X=Math.min(32767,Z),$=Math.min(258,V);R<=X&&--Q&&N!=P;){if(t[Z+J]==t[Z+J-R]){for(var _=0;_<$&&t[Z+_]==t[Z+_-R];++_);if(_>J){if(J=_,K=R,_>W)break;var tt=Math.min(R,_-2),nt=0;for(Y=0;Y<tt;++Y){var rt=Z-R+Y&32767,it=rt-z[rt]&32767;it>nt&&(nt=it,P=rt)}}}R+=(N=P)-(P=z[N])&32767}if(K){C[q++]=268435456|f[J]<<18|p[K];var et=31&f[J],ot=31&p[K];E+=e[et]+o[ot],++I[257+et],++U[ot],O=Z+J,++F}else C[q++]=t[Z],++I[t[Z]]}}for(Z=Math.max(Z,O);Z<l;++Z)C[q++]=t[Z],++I[t[Z]];y=B(t,d,g,C,I,U,E,q,G,Z-G,y),g||(c.r=7&y|d[y/8|0]<<3,y-=7,c.h=k,c.p=z,c.i=Z,c.w=O)}else{for(Z=c.w||0;Z<l+g;Z+=65535){var st=Z+65535;st>=l&&(d[y/8|0]=g,st=l),y=L(d,y+1,t.subarray(Z,st))}c.i=l}return D(v,0,u+T(y)+h)},P=function(){for(var t=new Int32Array(256),n=0;n<256;++n){for(var r=n,i=9;--i;)r=(1&r&&-306674912)^r>>>1;t[n]=r}return t}(),V=function(){var t=-1;return{p:function(n){for(var r=t,i=0;i<n.length;++i)r=P[255&r^n[i]]^r>>>8;t=r},d:function(){return~t}}},Y=function(){var t=1,n=0;return{p:function(r){for(var i=t,e=n,o=0|r.length,s=0;s!=o;){for(var a=Math.min(s+2655,o);s<a;++s)e+=i+=r[s];i=(65535&i)+15*(i>>16),e=(65535&e)+15*(e>>16)}t=i,n=e},d:function(){return(255&(t%=65521))<<24|(65280&t)<<8|(255&(n%=65521))<<8|n>>8}}},J=function(t,r,i,e,o){if(!o&&(o={l:1},r.dictionary)){var s=r.dictionary.subarray(-32768),a=new n(s.length+t.length);a.set(s),a.set(t,s.length),t=a,o.w=s.length}return N(t,null==r.level?6:r.level,null==r.mem?o.l?Math.ceil(1.5*Math.max(8,Math.min(13,Math.log(t.length)))):20:12+r.mem,i,e,o)},K=function(t,n){var r={};for(var i in t)r[i]=t[i];for(var i in n)r[i]=n[i];return r},Q=function(t,n,r){for(var i=t(),e=""+t,o=e.slice(e.indexOf("[")+1,e.lastIndexOf("]")).replace(/\s+/g,"").split(","),s=0;s<i.length;++s){var a=i[s],u=o[s];if("function"==typeof a){n+=";"+u+"=";var h=""+a;if(a.prototype)if(-1!=h.indexOf("[native code]")){var f=h.indexOf(" ",8)+1;n+=h.slice(f,h.indexOf("(",f))}else for(var c in n+=h,a.prototype)n+=";"+u+".prototype."+c+"="+a.prototype[c];else n+=h}else r[u]=a}return n},R=[],W=function(t){var n=[];for(var r in t)t[r].buffer&&n.push((t[r]=new t[r].constructor(t[r])).buffer);return n},X=function(n,r,i,e){if(!R[i]){for(var o="",s={},a=n.length-1,u=0;u<a;++u)o=Q(n[u],o,s);R[i]={c:Q(n[a],o,s),e:s}}var h=K({},R[i].e);return(0,t.default)(R[i].c+";onmessage=function(e){for(var k in e.data)self[k]=e.data[k];onmessage="+r+"}",i,h,W(h),e)},$=function(){return[n,r,i,e,o,s,h,l,x,k,v,C,y,M,S,A,T,D,I,U,Tt,et,ot]},_=function(){return[n,r,i,e,o,s,f,p,w,m,z,b,v,H,j,y,F,E,Z,q,O,G,L,B,T,D,N,J,kt,et]},tt=function(){return[pt,gt,lt,V,P]},nt=function(){return[vt,dt]},rt=function(){return[yt,lt,Y]},it=function(){return[mt]},et=function(t){return postMessage(t,[t.buffer])},ot=function(t){return t&&{out:t.size&&new n(t.size),dictionary:t.dictionary}},st=function(t,n,r,i,e,o){var s=X(r,i,e,function(t,n){s.terminate(),o(t,n)});return s.postMessage([t,n],n.consume?[t.buffer]:[]),function(){s.terminate()}},at=function(t){return t.ondata=function(t,n){return postMessage([t,n],[t.buffer])},function(n){n.data[0]?(t.push(n.data[0],n.data[1]),postMessage([n.data[0].length])):t.flush(n.data[1])}},ut=function(t,n,r,i,e,o,s){var a,u=X(t,i,e,function(t,r){t?(u.terminate(),n.ondata.call(n,t)):Array.isArray(r)?1==r.length?(n.queuedSize-=r[0],n.ondrain&&n.ondrain(r[0])):(r[1]&&u.terminate(),n.ondata.call(n,t,r[0],r[1])):s(r)});u.postMessage(r),n.queuedSize=0,n.push=function(t,r){n.ondata||I(5),a&&n.ondata(I(4,0,1),null,!!r),n.queuedSize+=t.length,u.postMessage([t,a=r],t.buffer instanceof ArrayBuffer?[t.buffer]:[])},n.terminate=function(){u.terminate()},o&&(n.flush=function(t){u.postMessage([0,t])})},ht=function(t,n){return t[n]|t[n+1]<<8},ft=function(t,n){return(t[n]|t[n+1]<<8|t[n+2]<<16|t[n+3]<<24)>>>0},ct=function(t,n){return ft(t,n)+4294967296*ft(t,n+4)},lt=function(t,n,r){for(;r;++n)t[n]=r,r>>>=8},pt=function(t,n){var r=n.filename;if(t[0]=31,t[1]=139,t[2]=8,t[8]=n.level<2?4:9==n.level?2:0,t[9]=3,0!=n.mtime&&lt(t,4,Math.floor(new Date(n.mtime||Date.now())/1e3)),r){t[3]=8;for(var i=0;i<=r.length;++i)t[i+10]=r.charCodeAt(i)}},vt=function(t){31==t[0]&&139==t[1]&&8==t[2]||I(6,"invalid gzip data");var n=t[3],r=10;4&n&&(r+=2+(t[10]|t[11]<<8));for(var i=(n>>3&1)+(n>>4&1);i>0;i-=!t[r++]);return r+(2&n)},dt=function(t){var n=t.length;return(t[n-4]|t[n-3]<<8|t[n-2]<<16|t[n-1]<<24)>>>0},gt=function(t){return 10+(t.filename?t.filename.length+1:0)},yt=function(t,n){var r=n.level,i=0==r?0:r<6?1:9==r?3:2;if(t[0]=120,t[1]=i<<6|(n.dictionary&&32),t[1]|=31-(t[0]<<8|t[1])%31,n.dictionary){var e=Y();e.p(n.dictionary),lt(t,2,e.d())}},mt=function(t,n){return(8!=(15&t[0])||t[0]>>4>7||(t[0]<<8|t[1])%31)&&I(6,"invalid zlib data"),(t[1]>>5&1)==+!n&&I(6,"invalid zlib data: "+(32&t[1]?"need":"unexpected")+" dictionary"),2+(t[1]>>3&4)};function bt(t,n){return"function"==typeof t&&(n=t,t={}),this.ondata=n,t}var wt=function(){function t(t,r){if("function"==typeof t&&(r=t,t={}),this.ondata=r,this.o=t||{},this.s={l:0,i:32768,w:32768,z:32768},this.b=new n(98304),this.o.dictionary){var i=this.o.dictionary.subarray(-32768);this.b.set(i,32768-i.length),this.s.i=32768-i.length}}return t.prototype.p=function(t,n){this.ondata(J(t,this.o,0,0,this.s),n)},t.prototype.push=function(t,r){this.ondata||I(5),this.s.l&&I(4);var i=t.length+this.s.z;if(i>this.b.length){if(i>2*this.b.length-32768){var e=new n(-32768&i);e.set(this.b.subarray(0,this.s.z)),this.b=e}var o=this.b.length-this.s.z;this.b.set(t.subarray(0,o),this.s.z),this.s.z=this.b.length,this.p(this.b,!1),this.b.set(this.b.subarray(-32768)),this.b.set(t.subarray(o),32768),this.s.z=t.length-o+32768,this.s.i=32766,this.s.w=32768}else this.b.set(t,this.s.z),this.s.z+=t.length;this.s.l=1&r,(this.s.z>this.s.w+8191||r)&&(this.p(this.b,r||!1),this.s.w=this.s.i,this.s.i-=2),r&&(this.s=this.o={},this.b=j)},t.prototype.flush=function(t){if(this.ondata||I(5),this.s.l&&I(4),this.p(this.b,!1),this.s.w=this.s.i,this.s.i-=2,t){var r=new n(6);r[0]=this.s.r>>3;var i=L(r,this.s.r,j);this.s.r=0,this.ondata(r.subarray(0,i>>3),!1)}},t}();_e.Deflate=wt;var xt=function(){return function(t,n){ut([_,function(){return[at,wt]}],this,bt.call(this,t,n),function(t){var n=new wt(t.data);onmessage=at(n)},6,1)}}();function zt(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),st(t,n,[_],function(t){return et(kt(t.data[0],t.data[1]))},0,r)}function kt(t,n){return J(t,n||{},0,0)}_e.AsyncDeflate=xt;var Mt=function(){function t(t,r){"function"==typeof t&&(r=t,t={}),this.ondata=r;var i=t&&t.dictionary&&t.dictionary.subarray(-32768);this.s={i:0,b:i?i.length:0},this.o=new n(32768),this.p=new n(0),i&&this.o.set(i)}return t.prototype.e=function(t){if(this.ondata||I(5),this.d&&I(4),this.p.length){if(t.length){var r=new n(this.p.length+t.length);r.set(this.p),r.set(t,this.p.length),this.p=r}}else this.p=t},t.prototype.c=function(t){this.s.i=+(this.d=t||!1);var n=this.s.b,r=U(this.p,this.s,this.o);this.ondata(D(r,n,this.s.b),this.d),this.o=D(r,this.s.b-32768),this.s.b=this.o.length,this.p=D(this.p,this.s.p/8|0),this.s.p&=7},t.prototype.push=function(t,n){this.e(t),this.c(n)},t}();_e.Inflate=Mt;var St=function(){return function(t,n){ut([$,function(){return[at,Mt]}],this,bt.call(this,t,n),function(t){var n=new Mt(t.data);onmessage=at(n)},7,0)}}();function At(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),st(t,n,[$],function(t){return et(Tt(t.data[0],ot(t.data[1])))},1,r)}function Tt(t,n){return U(t,{i:2},n&&n.out,n&&n.dictionary)}_e.AsyncInflate=St;var Dt=function(){function t(t,n){this.c=V(),this.l=0,this.v=1,wt.call(this,t,n)}return t.prototype.push=function(t,n){this.c.p(t),this.l+=t.length,wt.prototype.push.call(this,t,n)},t.prototype.p=function(t,n){var r=J(t,this.o,this.v&&gt(this.o),n&&8,this.s);this.v&&(pt(r,this.o),this.v=0),n&&(lt(r,r.length-8,this.c.d()),lt(r,r.length-4,this.l)),this.ondata(r,n)},t.prototype.flush=function(t){wt.prototype.flush.call(this,t)},t}();_e.Gzip=Dt,_e.Compress=Dt;var Ct=function(){return function(t,n){ut([_,tt,function(){return[at,wt,Dt]}],this,bt.call(this,t,n),function(t){var n=new Dt(t.data);onmessage=at(n)},8,1)}}();function It(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),st(t,n,[_,tt,function(){return[Ut]}],function(t){return et(Ut(t.data[0],t.data[1]))},2,r)}function Ut(t,n){n||(n={});var r=V(),i=t.length;r.p(t);var e=J(t,n,gt(n),8),o=e.length;return pt(e,n),lt(e,o-8,r.d()),lt(e,o-4,i),e}_e.AsyncGzip=Ct,_e.AsyncCompress=Ct;var Ft=function(){function t(t,n){this.v=1,this.r=0,Mt.call(this,t,n)}return t.prototype.push=function(t,r){if(Mt.prototype.e.call(this,t),this.r+=t.length,this.v){var i=this.p.subarray(this.v-1),e=i.length>3?vt(i):4;if(e>i.length){if(!r)return}else this.v>1&&this.onmember&&this.onmember(this.r-i.length);this.p=i.subarray(e),this.v=0}Mt.prototype.c.call(this,0),this.s.f&&!this.s.l?(this.v=T(this.s.p)+9,this.s={i:0},this.o=new n(0),this.push(new n(0),r)):r&&Mt.prototype.c.call(this,r)},t}();_e.Gunzip=Ft;var Et=function(){return function(t,n){var r=this;ut([$,nt,function(){return[at,Mt,Ft]}],this,bt.call(this,t,n),function(t){var n=new Ft(t.data);n.onmember=function(t){return postMessage(t)},onmessage=at(n)},9,0,function(t){return r.onmember&&r.onmember(t)})}}();function Zt(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),st(t,n,[$,nt,function(){return[qt]}],function(t){return et(qt(t.data[0],t.data[1]))},3,r)}function qt(t,r){var i=vt(t);return i+8>t.length&&I(6,"invalid gzip data"),U(t.subarray(i,-8),{i:2},r&&r.out||new n(dt(t)),r&&r.dictionary)}_e.AsyncGunzip=Et;var Ot=function(){function t(t,n){this.c=Y(),this.v=1,wt.call(this,t,n)}return t.prototype.push=function(t,n){this.c.p(t),wt.prototype.push.call(this,t,n)},t.prototype.p=function(t,n){var r=J(t,this.o,this.v&&(this.o.dictionary?6:2),n&&4,this.s);this.v&&(yt(r,this.o),this.v=0),n&&lt(r,r.length-4,this.c.d()),this.ondata(r,n)},t.prototype.flush=function(t){wt.prototype.flush.call(this,t)},t}();_e.Zlib=Ot;var Gt=function(){return function(t,n){ut([_,rt,function(){return[at,wt,Ot]}],this,bt.call(this,t,n),function(t){var n=new Ot(t.data);onmessage=at(n)},10,1)}}();function Lt(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),st(t,n,[_,rt,function(){return[Bt]}],function(t){return et(Bt(t.data[0],t.data[1]))},4,r)}function Bt(t,n){n||(n={});var r=Y();r.p(t);var i=J(t,n,n.dictionary?6:2,4);return yt(i,n),lt(i,i.length-4,r.d()),i}_e.AsyncZlib=Gt;var Ht=function(){function t(t,n){Mt.call(this,t,n),this.v=t&&t.dictionary?2:1}return t.prototype.push=function(t,n){if(Mt.prototype.e.call(this,t),this.v){if(this.p.length<6&&!n)return;this.p=this.p.subarray(mt(this.p,this.v-1)),this.v=0}n&&(this.p.length<4&&I(6,"invalid zlib data"),this.p=this.p.subarray(0,-4)),Mt.prototype.c.call(this,n)},t}();_e.Unzlib=Ht;var jt=function(){return function(t,n){ut([$,it,function(){return[at,Mt,Ht]}],this,bt.call(this,t,n),function(t){var n=new Ht(t.data);onmessage=at(n)},11,0)}}();function Nt(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),st(t,n,[$,it,function(){return[Pt]}],function(t){return et(Pt(t.data[0],ot(t.data[1])))},5,r)}function Pt(t,n){return U(t.subarray(mt(t,n&&n.dictionary),-4),{i:2},n&&n.out,n&&n.dictionary)}_e.AsyncUnzlib=jt;var Vt=function(){function t(t,n){this.o=bt.call(this,t,n)||{},this.G=Ft,this.I=Mt,this.Z=Ht}return t.prototype.i=function(){var t=this;this.s.ondata=function(n,r){t.ondata(n,r)}},t.prototype.push=function(t,r){if(this.ondata||I(5),this.s)this.s.push(t,r);else{if(this.p&&this.p.length){var i=new n(this.p.length+t.length);i.set(this.p),i.set(t,this.p.length)}else this.p=t;this.p.length>2&&(this.s=31==this.p[0]&&139==this.p[1]&&8==this.p[2]?new this.G(this.o):8!=(15&this.p[0])||this.p[0]>>4>7||(this.p[0]<<8|this.p[1])%31?new this.I(this.o):new this.Z(this.o),this.i(),this.s.push(this.p,r),this.p=null)}},t}();_e.Decompress=Vt;var Yt=function(){function t(t,n){Vt.call(this,t,n),this.queuedSize=0,this.G=Et,this.I=St,this.Z=jt}return t.prototype.i=function(){var t=this;this.s.ondata=function(n,r,i){t.ondata(n,r,i)},this.s.ondrain=function(n){t.queuedSize-=n,t.ondrain&&t.ondrain(n)}},t.prototype.push=function(t,n){this.queuedSize+=t.length,Vt.prototype.push.call(this,t,n)},t}();function Jt(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),31==t[0]&&139==t[1]&&8==t[2]?Zt(t,n,r):8!=(15&t[0])||t[0]>>4>7||(t[0]<<8|t[1])%31?At(t,n,r):Nt(t,n,r)}function Kt(t,n){return 31==t[0]&&139==t[1]&&8==t[2]?qt(t,n):8!=(15&t[0])||t[0]>>4>7||(t[0]<<8|t[1])%31?Tt(t,n):Pt(t,n)}_e.AsyncDecompress=Yt;var Qt=function(t,r,i,e){for(var o in t){var s=t[o],a=r+o,u=e;Array.isArray(s)&&(u=K(e,s[1]),s=s[0]),ArrayBuffer.isView(s)?i[a]=[s,u]:(i[a+="/"]=[new n(0),u],Qt(s,a,i,e))}},Rt="undefined"!=typeof TextEncoder&&new TextEncoder,Wt="undefined"!=typeof TextDecoder&&new TextDecoder,Xt=0;try{Wt.decode(j,{stream:!0}),Xt=1}catch(t){}var $t=function(t){for(var n="",r=0;;){var i=t[r++],e=(i>127)+(i>223)+(i>239);if(r+e>t.length)return{s:n,r:D(t,r-1)};e?3==e?(i=((15&i)<<18|(63&t[r++])<<12|(63&t[r++])<<6|63&t[r++])-65536,n+=String.fromCharCode(55296|i>>10,56320|1023&i)):n+=String.fromCharCode(1&e?(31&i)<<6|63&t[r++]:(15&i)<<12|(63&t[r++])<<6|63&t[r++]):n+=String.fromCharCode(i)}},_t=function(){function t(t){this.ondata=t,Xt?this.t=new TextDecoder:this.p=j}return t.prototype.push=function(t,r){if(this.ondata||I(5),r=!!r,this.t)return this.ondata(this.t.decode(t,{stream:!0}),r),void(r&&(this.t.decode().length&&I(8),this.t=null));this.p||I(4);var i=new n(this.p.length+t.length);i.set(this.p),i.set(t,this.p.length);var e=$t(i),o=e.s,s=e.r;r?(s.length&&I(8),this.p=null):this.p=s,this.ondata(o,r)},t}();_e.DecodeUTF8=_t;var tn=function(){function t(t){this.ondata=t}return t.prototype.push=function(t,n){this.ondata||I(5),this.d&&I(4),this.ondata(nn(t),this.d=n||!1)},t}();function nn(t,r){if(r){for(var i=new n(t.length),e=0;e<t.length;++e)i[e]=t.charCodeAt(e);return i}if(Rt)return Rt.encode(t);var o=t.length,s=new n(t.length+(t.length>>1)),a=0,u=function(t){s[a++]=t};for(e=0;e<o;++e){if(a+5>s.length){var h=new n(a+8+(o-e<<1));h.set(s),s=h}var f=t.charCodeAt(e);f<128||r?u(f):f<2048?(u(192|f>>6),u(128|63&f)):f>55295&&f<57344?(u(240|(f=65536+(1047552&f)|1023&t.charCodeAt(++e))>>18),u(128|f>>12&63),u(128|f>>6&63),u(128|63&f)):(u(224|f>>12),u(128|f>>6&63),u(128|63&f))}return D(s,0,a)}function rn(t,n){if(n){for(var r="",i=0;i<t.length;i+=16384)r+=String.fromCharCode.apply(null,t.subarray(i,i+16384));return r}if(Wt)return Wt.decode(t);var e=$t(t),o=e.s;return(r=e.r).length&&I(8),o}_e.EncodeUTF8=tn;var en=function(t){return 1==t?3:t<6?2:9==t?1:0},on=function(t,n){return n+30+ht(t,n+26)+ht(t,n+28)},sn=function(t,n,r){var i=ht(t,n+28),e=ht(t,n+30),o=rn(t.subarray(n+46,n+46+i),!(2048&ht(t,n+8))),s=n+46+i,a=an(t,s,e,r,ft(t,n+20),ft(t,n+24),ft(t,n+42)),u=a[0],h=a[1],f=a[2];return[ht(t,n+10),u,h,o,s+e+ht(t,n+32),f]},an=function(t,n,r,i,e,o,s){var a=4294967295==e,u=4294967295==o,h=4294967295==s,f=n+r;if(i&&a+u+h){for(;n+4<f;n+=4+ht(t,n+2))if(1==ht(t,n))return[a?ct(t,n+4+8*u):e,u?ct(t,n+4):o,h?ct(t,n+4+8*(u+a)):s,1];i<2&&I(13)}return[e,o,s,0]},un=function(t){var n=0;if(t)for(var r in t){var i=t[r].length;i>65535&&I(9),n+=i+4}return n},hn=function(t,n,r,i,e,o,s,a){var u=i.length,h=r.extra,f=a&&a.length,c=un(h);lt(t,n,null!=s?33639248:67324752),n+=4,null!=s&&(t[n++]=20,t[n++]=r.os),t[n]=20,n+=2,t[n++]=r.flag<<1|(o<0&&8),t[n++]=e&&8,t[n++]=255&r.compression,t[n++]=r.compression>>8;var l=new Date(null==r.mtime?Date.now():r.mtime),p=l.getFullYear()-1980;if((p<0||p>119)&&I(10),lt(t,n,p<<25|l.getMonth()+1<<21|l.getDate()<<16|l.getHours()<<11|l.getMinutes()<<5|l.getSeconds()>>1),n+=4,-1!=o&&(lt(t,n,r.crc),lt(t,n+4,o<0?-o-2:o),lt(t,n+8,r.size)),lt(t,n+12,u),lt(t,n+14,c),n+=16,null!=s&&(lt(t,n,f),lt(t,n+6,r.attrs),lt(t,n+10,s),n+=14),t.set(i,n),n+=u,c)for(var v in h){var d=h[v],g=d.length;lt(t,n,+v),lt(t,n+2,g),t.set(d,n+4),n+=4+g}return f&&(t.set(a,n),n+=f),n},fn=function(t,n,r,i,e){lt(t,n,101010256),lt(t,n+8,r),lt(t,n+10,r),lt(t,n+12,i),lt(t,n+16,e)},cn=function(){function t(t){this.filename=t,this.c=V(),this.size=0,this.compression=0}return t.prototype.process=function(t,n){this.ondata(null,t,n)},t.prototype.push=function(t,n){this.ondata||I(5),this.c.p(t),this.size+=t.length,n&&(this.crc=this.c.d()),this.process(t,n||!1)},t}();_e.ZipPassThrough=cn;var ln=function(){function t(t,n){var r=this;n||(n={}),cn.call(this,t),this.d=new wt(n,function(t,n){r.ondata(null,t,n)}),this.compression=8,this.flag=en(n.level)}return t.prototype.process=function(t,n){try{this.d.push(t,n)}catch(t){this.ondata(t,null,n)}},t.prototype.push=function(t,n){cn.prototype.push.call(this,t,n)},t}();_e.ZipDeflate=ln;var pn=function(){function t(t,n){var r=this;n||(n={}),cn.call(this,t),this.d=new xt(n,function(t,n,i){r.ondata(t,n,i)}),this.compression=8,this.flag=en(n.level),this.terminate=this.d.terminate}return t.prototype.process=function(t,n){this.d.push(t,n)},t.prototype.push=function(t,n){cn.prototype.push.call(this,t,n)},t}();_e.AsyncZipDeflate=pn;var vn=function(){function t(t){this.ondata=t,this.u=[],this.d=1}return t.prototype.add=function(t){var r=this;if(this.ondata||I(5),2&this.d)this.ondata(I(4+8*(1&this.d),0,1),null,!1);else{var i=nn(t.filename),e=i.length,o=t.comment,s=o&&nn(o),a=e!=t.filename.length||s&&o.length!=s.length,u=e+un(t.extra)+30;e>65535&&this.ondata(I(11,0,1),null,!1);var h=new n(u);hn(h,0,t,i,a,-1);var f=[h],c=function(){for(var t=0,n=f;t<n.length;t++)r.ondata(null,n[t],!1);f=[]},l=this.d;this.d=0;var p=this.u.length,v=K(t,{f:i,u:a,o:s,t:function(){t.terminate&&t.terminate()},r:function(){if(c(),l){var t=r.u[p+1];t?t.r():r.d=1}l=1}}),d=0;t.ondata=function(i,e,o){if(i)r.ondata(i,e,o),r.terminate();else if(d+=e.length,f.push(e),o){var s=new n(16);lt(s,0,134695760),lt(s,4,t.crc),lt(s,8,d),lt(s,12,t.size),f.push(s),v.c=d,v.b=u+d+16,v.crc=t.crc,v.size=t.size,l&&v.r(),l=1}else l&&c()},this.u.push(v)}},t.prototype.end=function(){var t=this;2&this.d?this.ondata(I(4+8*(1&this.d),0,1),null,!0):(this.d?this.e():this.u.push({r:function(){1&t.d&&(t.u.splice(-1,1),t.e())},t:function(){}}),this.d=3)},t.prototype.e=function(){for(var t=0,r=0,i=0,e=0,o=this.u;e<o.length;e++)i+=46+(h=o[e]).f.length+un(h.extra)+(h.o?h.o.length:0);for(var s=new n(i+22),a=0,u=this.u;a<u.length;a++){var h;hn(s,t,h=u[a],h.f,h.u,-h.c-2,r,h.o),t+=46+h.f.length+un(h.extra)+(h.o?h.o.length:0),r+=h.b}fn(s,t,this.u.length,i,r),this.ondata(null,s,!0),this.d=2},t.prototype.terminate=function(){for(var t=0,n=this.u;t<n.length;t++)n[t].t();this.d=2},t}();function dn(t,r,i){i||(i=r,r={}),"function"!=typeof i&&I(7);var e={};Qt(t,"",e,r);var o=Object.keys(e),s=o.length,a=0,u=0,h=s,f=Array(s),c=[],l=function(){for(var t=0;t<c.length;++t)c[t]()},p=function(t,n){xn(function(){i(t,n)})};xn(function(){p=i});var v=function(){var t=new n(u+22),r=a,i=u-a;u=0;for(var e=0;e<h;++e){var o=f[e];try{var s=o.c.length;hn(t,u,o,o.f,o.u,s);var c=30+o.f.length+un(o.extra),l=u+c;t.set(o.c,l),hn(t,a,o,o.f,o.u,s,u,o.m),a+=16+c+(o.m?o.m.length:0),u=l+s}catch(t){return p(t,null)}}fn(t,a,f.length,i,r),p(null,t)};s||v();for(var d=function(t){var n=o[t],r=e[n],i=r[0],h=r[1],d=V(),g=i.length;d.p(i);var y=nn(n),m=y.length,b=h.comment,w=b&&nn(b),x=w&&w.length,z=un(h.extra),k=0==h.level?0:8,M=function(r,i){if(r)l(),p(r,null);else{var e=i.length;f[t]=K(h,{size:g,crc:d.d(),c:i,f:y,m:w,u:m!=n.length||w&&b.length!=x,compression:k}),a+=30+m+z+e,u+=76+2*(m+z)+(x||0)+e,--s||v()}};if(m>65535&&M(I(11,0,1),null),k)if(g<16e4)try{M(null,kt(i,h))}catch(t){M(t,null)}else c.push(zt(i,h,M));else M(null,i)},g=0;g<h;++g)d(g);return l}function gn(t,r){r||(r={});var i={},e=[];Qt(t,"",i,r);var o=0,s=0;for(var a in i){var u=i[a],h=u[0],f=u[1],c=0==f.level?0:8,l=(M=nn(a)).length,p=f.comment,v=p&&nn(p),d=v&&v.length,g=un(f.extra);l>65535&&I(11);var y=c?kt(h,f):h,m=y.length,b=V();b.p(h),e.push(K(f,{size:h.length,crc:b.d(),c:y,f:M,m:v,u:l!=a.length||v&&p.length!=d,o:o,compression:c})),o+=30+l+g+m,s+=76+2*(l+g)+(d||0)+m}for(var w=new n(s+22),x=o,z=s-o,k=0;k<e.length;++k){var M;hn(w,(M=e[k]).o,M,M.f,M.u,M.c.length);var S=30+M.f.length+un(M.extra);w.set(M.c,M.o+S),hn(w,o,M,M.f,M.u,M.c.length,M.o,M.m),o+=16+S+(M.m?M.m.length:0)}return fn(w,o,e.length,z,x),w}_e.Zip=vn;var yn=function(){function t(){}return t.prototype.push=function(t,n){this.ondata(null,t,n)},t.compression=0,t}();_e.UnzipPassThrough=yn;var mn=function(){function t(){var t=this;this.i=new Mt(function(n,r){t.ondata(null,n,r)})}return t.prototype.push=function(t,n){try{this.i.push(t,n)}catch(t){this.ondata(t,null,n)}},t.compression=8,t}();_e.UnzipInflate=mn;var bn=function(){function t(t,n){var r=this;n<32e4?this.i=new Mt(function(t,n){r.ondata(null,t,n)}):(this.i=new St(function(t,n,i){r.ondata(t,n,i)}),this.terminate=this.i.terminate)}return t.prototype.push=function(t,n){this.i.terminate&&(t=D(t,0)),this.i.push(t,n)},t.compression=8,t}();_e.AsyncUnzipInflate=bn;var wn=function(){function t(t){this.onfile=t,this.k=[],this.o={0:yn},this.p=j}return t.prototype.push=function(t,r){var i=this;if(this.onfile||I(5),this.p||I(4),this.c>0){var e=Math.min(this.c,t.length),o=t.subarray(0,e);if(this.c-=e,this.d?this.d.push(o,!this.c):this.k[0].push(o),(t=t.subarray(e)).length)return this.push(t,r)}else{var s=0,a=0,u=void 0,h=void 0;this.p.length?t.length?((h=new n(this.p.length+t.length)).set(this.p),h.set(t,this.p.length)):h=this.p:h=t;for(var f=h.length,c=this.c,l=c&&this.d,p=function(){var t=ft(h,a);if(67324752==t){s=1,u=a,v.d=null,v.c=0;var n=ht(h,a+6),r=ht(h,a+8),e=2048&n,o=8&n,l=ht(h,a+26),p=ht(h,a+28);if(f>a+30+l+p){var d=[];v.k.unshift(d),s=2;var g,y=ft(h,a+18),m=ft(h,a+22),b=rn(h.subarray(a+30,a+=30+l),!e),w=an(h,a,p,2,y,m,0),x=w[0],z=w[1];o&&(x=-1-w[3]),a+=p,v.c=x;var k={name:b,compression:r,start:function(){if(k.ondata||I(5),x){var t=i.o[r];t||k.ondata(I(14,"unknown compression type "+r,1),null,!1),(g=x<0?new t(b):new t(b,x,z)).ondata=function(t,n,r){k.ondata(t,n,r)};for(var n=0,e=d;n<e.length;n++)g.push(e[n],!1);i.k[0]==d&&i.c?i.d=g:g.push(j,!0)}else k.ondata(null,j,!0)},terminate:function(){g&&g.terminate&&g.terminate()}};x>=0&&(k.size=x,k.originalSize=z),v.onfile(k)}return"break"}if(c){if(134695760==t)return u=a+=12+(-2==c&&8),s=3,v.c=0,"break";if(33639248==t)return u=a-=4,s=3,v.c=0,"break"}},v=this;a<f-4&&"break"!==p();++a);if(this.p=j,c<0){var d=h.subarray(0,s?u-12-(-2==c&&8)-(134695760==ft(h,u-16)&&4):a);l?l.push(d,!!s):this.k[+(2==s)].push(d)}if(2&s)return this.push(h.subarray(a),r);this.p=h.subarray(a)}r&&(this.c&&I(13),this.p=null)},t.prototype.register=function(t){this.o[t.compression]=t},t}();_e.Unzip=wn;var xn="function"==typeof queueMicrotask?queueMicrotask:"function"==typeof setTimeout?setTimeout:function(t){t()};function zn(t,r,i){i||(i=r,r={}),"function"!=typeof i&&I(7);var e=[],o=function(){for(var t=0;t<e.length;++t)e[t]()},s={},a=function(t,n){xn(function(){i(t,n)})};xn(function(){a=i});for(var u=t.length-22;101010256!=ft(t,u);--u)if(!u||t.length-u>65558)return a(I(13,0,1),null),o;var h=ht(t,u+8);if(h){var f=h,c=ft(t,u+16),l=117853008==ft(t,u-20);if(l){var p=ft(t,u-12);(l=101075792==ft(t,p))&&(f=h=ft(t,p+32),c=ft(t,p+48))}for(var v=r&&r.filter,d=function(r){var i=sn(t,c,l),u=i[0],f=i[1],p=i[2],d=i[3],g=i[4],y=on(t,i[5]);c=g;var m=function(t,n){t?(o(),a(t,null)):(n&&(s[d]=n),--h||a(null,s))};if(!v||v({name:d,size:f,originalSize:p,compression:u}))if(u)if(8==u){var b=t.subarray(y,y+f);if(p<524288||f>.8*p)try{m(null,Tt(b,{out:new n(p)}))}catch(t){m(t,null)}else e.push(At(b,{size:p},m))}else m(I(14,"unknown compression type "+u,1),null);else m(null,D(t,y,y+f));else m(null,null)},g=0;g<f;++g)d()}else a(null,{});return o}function kn(t,r){for(var i={},e=t.length-22;101010256!=ft(t,e);--e)(!e||t.length-e>65558)&&I(13);var o=ht(t,e+8);if(!o)return{};var s=ft(t,e+16),a=117853008==ft(t,e-20);if(a){var u=ft(t,e-12);(a=101075792==ft(t,u))&&(o=ft(t,u+32),s=ft(t,u+48))}for(var h=r&&r.filter,f=0;f<o;++f){var c=sn(t,s,a),l=c[0],p=c[1],v=c[2],d=c[3],g=c[4],y=on(t,c[5]);s=g,h&&!h({name:d,size:p,originalSize:v,compression:l})||(l?8==l?i[d]=Tt(t.subarray(y,y+p),{out:new n(v)}):I(14,"unknown compression type "+l):i[d]=D(t,y,y+p))}return i}return _e});
+174 -5
View File
@@ -3,13 +3,182 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Upload Platform</title>
<title>Upload Platform {{ version }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<main>
<h1>Upload Platform</h1>
<p>Version: {{ version }}</p>
<!-- Основной контейнер стенда: Слой 1 (выбор) + Слой 2 (пофайловый транзит через RAM) -->
<main class="page">
<header>
<p class="eyebrow">FILE INTAKE &amp; TRANSIT / {{ version }}</p>
<h1>Загрузка документов</h1>
<p class="lede">Выберите отдельные файлы или целую папку. Архивы будут распакованы на клиенте, после чего пофайлово доставлены в память сессии через буфер.</p>
</header>
<section class="toolbar" aria-label="Выбор файлов">
<div id="file-picker"></div>
</section>
<!-- Секция действий Слоя 2: пофайловый транзит в RAM сессии бэкенда -->
<section class="upload-section" style="margin-top: 24px; padding: 20px; background: #f2f6f1; border-radius: 6px; border: 1px solid #c8d6c7;">
<div style="display: flex; gap: 12px; align-items: center; flex-wrap: wrap;">
<button id="upload-btn" type="button" disabled style="background: #1b4d3e;">
⚡ Загрузить в сессию (Слой 2 транзит)
</button>
<button id="cancel-btn" type="button" class="quiet" style="display: none; color: #8c5148;">
✕ Отмена
</button>
<span id="upload-status" style="font: 14px sans-serif; color: #2d4a36;"></span>
</div>
<div id="upload-progress-explanation" class="upload-progress-explanation" hidden>
<strong id="upload-phase">Подготовка загрузки</strong>
<span id="upload-phase-help">Первое соединение может занять время: устанавливаются соединения с буфером ВМ и сервисом.</span>
<span id="upload-elapsed">Прошло: 00:00</span>
</div>
<div id="session-info" style="margin-top: 14px; font: 13px sans-serif; display: none;">
<strong>Сессия в RAM:</strong> <code id="session-id"></code>
<div id="session-files" style="margin-top: 6px; color: #46604e;"></div>
</div>
</section>
</main>
<script src="{{ url_for('file_picker_dist', filename='file-picker.iife.js', v=version) }}"></script>
<script>
const uploadBtn = document.getElementById('upload-btn');
const cancelBtn = document.getElementById('cancel-btn');
const uploadStatus = document.getElementById('upload-status');
const sessionInfo = document.getElementById('session-info');
const sessionIdEl = document.getElementById('session-id');
const sessionFilesEl = document.getElementById('session-files');
const progressExplanation = document.getElementById('upload-progress-explanation');
const uploadPhase = document.getElementById('upload-phase');
const uploadPhaseHelp = document.getElementById('upload-phase-help');
const uploadElapsed = document.getElementById('upload-elapsed');
let abortCtrl = null;
let selectedFiles = [];
let uploadTimer = null;
let uploadStartedAt = 0;
const formatElapsed = (seconds) => {
const minutes = Math.floor(seconds / 60).toString().padStart(2, '0');
const remainder = (seconds % 60).toString().padStart(2, '0');
return `${minutes}:${remainder}`;
};
const stopUploadTimer = () => {
if (uploadTimer) window.clearInterval(uploadTimer);
uploadTimer = null;
};
const startUploadTimer = () => {
stopUploadTimer();
uploadStartedAt = Date.now();
progressExplanation.hidden = false;
uploadTimer = window.setInterval(() => {
uploadElapsed.textContent = `Прошло: ${formatElapsed(Math.floor((Date.now() - uploadStartedAt) / 1000))}`;
}, 1000);
};
const showUploadPhase = (phase, explanation) => {
progressExplanation.hidden = false;
uploadPhase.textContent = phase;
uploadPhaseHelp.textContent = explanation;
};
const picker = FilePicker.initFilePicker({
mount: '#file-picker',
allowedExt: {{ config.allowedExt|tojson }},
onChange: (files) => {
selectedFiles = files;
uploadBtn.disabled = files.length === 0;
uploadStatus.textContent = files.length ? `Готово к отправке: ${files.length} файл(ов)` : '';
}
});
uploadBtn.addEventListener('click', async () => {
const files = picker.getFiles();
if (!files.length) return;
uploadBtn.disabled = true;
cancelBtn.style.display = 'inline-block';
sessionInfo.style.display = 'none';
abortCtrl = new AbortController();
startUploadTimer();
showUploadPhase(
'Первый файл: устанавливаем соединение',
'Это не зависание. Сначала файл отправляется в буфер ВМ, затем сервис забирает его в RAM и очищает буфер.'
);
const vmUploadUrl = new URL({{ config.vmUploadUrl|tojson }}, window.location.origin).href;
let res;
try {
res = await FilePicker.uploadViaVM(files, {
vmUploadUrl: vmUploadUrl,
backendUploadUrl: '/api/upload_refs',
signal: abortCtrl.signal,
onStatus: (msg) => {
uploadStatus.textContent = msg;
if (msg.startsWith('Загрузка в буфер')) {
showUploadPhase('Отправка файла в буфер ВМ', 'Идёт PUT текущего файла. После этого сервис сразу перенесёт его в RAM.');
} else if (msg.startsWith('Приём сервисом')) {
showUploadPhase('Перенос файла в RAM', 'Сервис забирает текущий файл из буфера ВМ и удаляет его после успешного приёма.');
}
},
onFileStatus: (k, statusText) => {
const row = document.querySelectorAll('.tree-row.tree-file')[k];
if (row) {
const statusCell = row.querySelectorAll('td')[2];
if (statusCell) statusCell.innerHTML = statusText;
}
},
onFileComplete: (info) => {
console.log('File delivered to RAM:', info);
}
});
} catch (error) {
stopUploadTimer();
cancelBtn.style.display = 'none';
uploadBtn.disabled = selectedFiles.length === 0;
showUploadPhase('Загрузка остановлена', 'Произошла непредвиденная ошибка. Повторите загрузку.');
uploadStatus.innerHTML = '<span style="color:#b32f22; font-weight: bold;">✗ Ошибка загрузки</span>';
console.error('Upload failed unexpectedly', error);
return;
}
stopUploadTimer();
cancelBtn.style.display = 'none';
uploadBtn.disabled = selectedFiles.length === 0;
if (res.ok) {
showUploadPhase('Загрузка завершена', 'Все файлы доставлены в RAM сессии.');
uploadStatus.innerHTML = '<span style="color:#1f5a3b; font-weight: bold;">✓ Все ' + res.count + ' файл(ов) успешно доставлены в RAM бэкенда!</span>';
sessionIdEl.textContent = res.session;
sessionInfo.style.display = 'block';
try {
const sResp = await fetch(`/api/session/${res.session}/files`);
const sData = await sResp.json();
if (sData.ok) {
sessionFilesEl.innerHTML = 'Сохранено в сессии RAM: ' +
sData.files.map(f => `<code>${f.name}</code> (${f.size} B)`).join(', ');
}
} catch (e) {
console.error('Failed to load session files', e);
}
} else if (res.aborted) {
showUploadPhase('Загрузка отменена', 'Переданные до отмены файлы уже находятся в RAM сессии.');
uploadStatus.innerHTML = '<span style="color:#8c5148;">Загрузка отменена пользователем</span>';
} else {
showUploadPhase('Загрузка остановлена', 'Проверьте сообщение об ошибке. Уже доставленные файлы остаются в RAM сессии.');
uploadStatus.innerHTML = '<span style="color:#b32f22; font-weight: bold;">✗ Ошибка: ' + res.error + '</span>';
}
});
cancelBtn.addEventListener('click', () => {
if (abortCtrl) abortCtrl.abort();
});
</script>
</body>
</html>
</html>
+115
View File
@@ -0,0 +1,115 @@
import importlib.util
import json
from pathlib import Path
import pytest
app_path = Path(__file__).resolve().parent.parent / "site" / "app.py"
spec = importlib.util.spec_from_file_location("site_app", app_path)
site_app = importlib.util.module_from_spec(spec)
spec.loader.exec_module(site_app)
app = site_app.app
_mock_storage = site_app._mock_storage
_mock_storage_lock = site_app._mock_storage_lock
from upload.backend.session import cleanup, get_files
import httpx
@pytest.fixture
def client():
app.config["TESTING"] = True
app.config["UPLOAD_HTTPX_TRANSPORT"] = httpx.WSGITransport(app=app)
with app.test_client() as client:
yield client
def test_health(client):
res = client.get("/health")
assert res.status_code == 200
assert res.data == b"ok"
def test_index_page(client):
res = client.get("/")
assert res.status_code == 200
assert b"upload-btn" in res.data
assert b"file-picker" in res.data
def test_mock_buffer_crud(client):
key = "test_item_1"
# PUT
res = client.put(f"/mock-buffer/{key}", data=b"binary_payload_123")
assert res.status_code == 201
# GET
res = client.get(f"/mock-buffer/{key}")
assert res.status_code == 200
assert res.data == b"binary_payload_123"
# Status
res = client.get("/mock-buffer/status")
assert res.status_code == 200
data = res.get_json()
assert data["count"] >= 1
assert any(item["key"] == key for item in data["items"])
# DELETE
res = client.delete(f"/mock-buffer/{key}")
assert res.status_code == 204
# GET after delete -> 404
res = client.get(f"/mock-buffer/{key}")
assert res.status_code == 404
def test_full_transit_flow_mock(client):
"""Тестирует пофайловый транзит через mock-буфер и приём в сессию RAM."""
with _mock_storage_lock:
_mock_storage.clear()
# Шаг 1: Браузер кладёт файл в mock-буфер
key = "token_uuid_0"
file_bytes = b"%PDF-1.4 test document content for transit"
res = client.put(f"/mock-buffer/{key}", data=file_bytes)
assert res.status_code == 201
# Проверяем, что файл в буфере
with _mock_storage_lock:
assert key in _mock_storage
# Шаг 2: Браузер вызывает /api/upload_refs для этого файла
# Используем относительный URL пути /mock-buffer/key
res = client.post("/api/upload_refs", json={
"files": [
{"name": "contract.pdf", "size": len(file_bytes), "url": f"/mock-buffer/{key}"}
]
})
assert res.status_code == 200
data = res.get_json()
assert data["ok"] is True
sid = data["session"]
assert data["count"] == 1
assert data["added"] == 1
# Шаг 3: Проверяем, что файл переместился в RAM сессии
files = get_files(sid)
assert len(files) == 1
assert files[0] == ("contract.pdf", file_bytes)
# Шаг 4: Проверяем, что файл удалился из mock-буфера (RAM буфера очищен)
with _mock_storage_lock:
assert key not in _mock_storage
# Шаг 5: Проверяем эндпоинт проверки сессии
res = client.get(f"/api/session/{sid}/files")
assert res.status_code == 200
s_data = res.get_json()
assert s_data["ok"] is True
assert s_data["session"] == sid
assert len(s_data["files"]) == 1
assert s_data["files"][0]["name"] == "contract.pdf"
assert s_data["files"][0]["size"] == len(file_bytes)
cleanup(sid)
+116
View File
@@ -0,0 +1,116 @@
import threading
from time import monotonic
import httpx
from flask import Flask
from upload.backend.session import add_file, cleanup, configure, create_session, get_files
from upload.backend.session import state as session_state
from upload.backend.upload_refs.blueprint import create_upload_refs_blueprint
def build_app(transport, **config):
app = Flask(__name__)
app.config['TESTING'] = True
app.config['UPLOAD_HTTPX_TRANSPORT'] = transport
app.register_blueprint(create_upload_refs_blueprint({
'vmUploadPrefix': 'https://vm.test/buffer/',
'httpxTransport': transport,
'pullRetries': 3,
'pullRetryDelay': 0,
**config,
}))
return app
def test_session_limit_accepts_exact_boundary_and_rejects_overflow():
sid = create_session()
previous = session_state.MAX_SESSION_BYTES
configure(max_session_bytes=10)
try:
assert add_file(sid, 'exact.bin', b'x' * 10) is True
assert add_file(sid, 'overflow.bin', b'x') is False
assert len(get_files(sid)) == 1
finally:
configure(max_session_bytes=previous)
cleanup(sid)
def test_upload_refs_rejects_actual_oversize_and_deletes_buffer():
storage = {'oversize': b'123456789'}
deleted = []
def handler(request):
if request.method == 'GET':
return httpx.Response(200, content=storage['oversize'])
if request.method == 'DELETE':
deleted.append(request.url.path)
storage.clear()
return httpx.Response(204)
return httpx.Response(405)
transport = httpx.MockTransport(handler)
app = build_app(transport, maxFileBytes=8)
sid = create_session()
try:
with app.test_client() as client:
response = client.post('/api/upload_refs', json={
'session': sid,
'files': [{
'name': 'oversize.txt',
'size': 8,
'url': 'https://vm.test/buffer/oversize',
}],
})
assert response.status_code == 200
assert response.get_json()['added'] == 0
assert get_files(sid) == []
assert deleted == ['/buffer/oversize']
assert storage == {}
finally:
cleanup(sid)
def test_upload_refs_retries_transient_pull_and_deletes_after_success():
attempts = []
deleted = []
def handler(request):
if request.method == 'GET':
attempts.append(request.url.path)
if len(attempts) == 1:
return httpx.Response(503)
return httpx.Response(200, content=b'retry-content')
if request.method == 'DELETE':
deleted.append(request.url.path)
return httpx.Response(204)
return httpx.Response(405)
transport = httpx.MockTransport(handler)
app = build_app(transport)
with app.test_client() as client:
response = client.post('/api/upload_refs', json={'files': [{
'name': 'retry.txt',
'size': 13,
'url': 'https://vm.test/buffer/retry',
}]})
assert response.status_code == 200
data = response.get_json()
assert data['added'] == 1
assert len(attempts) == 2
assert deleted == ['/buffer/retry']
cleanup(data['session'])
def test_session_ttl_removes_session():
previous = session_state.TTL_SECONDS
configure(ttl_seconds=0.05)
sid = create_session()
try:
deadline = monotonic() + 1
while monotonic() < deadline and get_files(sid) is not None:
threading.Event().wait(0.01)
assert get_files(sid) is None
finally:
configure(ttl_seconds=previous)
cleanup(sid)
+48
View File
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { render } from '../upload/frontend/table/render.js';
function elements() {
return { tableBodyEl: { innerHTML: '' }, countEl: { textContent: '' } };
}
function fileNode() {
return {
id: 'file-1',
kind: 'file',
name: 'document.txt',
path: 'folder/document.txt',
file: { size: 7 },
children: [],
expanded: true,
};
}
test('render: папка по умолчанию скрывает дочерние файлы', () => {
const child = fileNode();
const state = {
nodes: [{
id: 'folder-1',
kind: 'folder',
name: 'folder',
path: 'folder',
children: [child],
expanded: false,
}],
};
const output = elements();
render(state, output);
assert.match(output.tableBodyEl.innerHTML, /data-toggle="folder-1"/);
assert.match(output.tableBodyEl.innerHTML, /aria-expanded="false"/);
assert.doesNotMatch(output.tableBodyEl.innerHTML, /data-path="folder\/document\.txt"/);
assert.equal(output.countEl.textContent, '1 файлов · 7 B');
state.nodes[0].expanded = true;
render(state, output);
assert.match(output.tableBodyEl.innerHTML, /data-path="folder\/document\.txt"/);
assert.match(output.tableBodyEl.innerHTML, /aria-expanded="true"/);
});
+17
View File
@@ -0,0 +1,17 @@
import pytest
from upload.backend.upload_refs.safe_name import safe_name
def test_safe_name_simple():
assert safe_name("test.txt") == "test.txt"
assert safe_name("folder/subfolder/file.pdf") == "folder/subfolder/file.pdf"
assert safe_name("folder\\subfolder\\file.pdf") == "folder/subfolder/file.pdf"
def test_safe_name_traversal():
assert safe_name("../etc/passwd") == ""
assert safe_name("folder/../../etc/passwd") == ""
assert safe_name("/root/file.txt") == "root/file.txt"
assert safe_name("..") == ""
assert safe_name("") == ""
assert safe_name(None) == ""
+61
View File
@@ -0,0 +1,61 @@
import pytest
from upload.backend.session import (
create_session, add_file, get_files, file_count,
store_result, get_result, store_csv, get_csv,
touch, pause_ttl, resume_ttl, request_cancel, get_cancel_event,
cleanup, configure, MAX_FILE_BYTES, MAX_SESSION_BYTES
)
from upload.backend.session.state import _sessions
def test_session_lifecycle():
sid = create_session()
assert sid in _sessions
assert file_count(sid) == 0
assert get_files(sid) == []
# Add file
ok = add_file(sid, "doc.txt", b"hello world")
assert ok is True
assert file_count(sid) == 1
files = get_files(sid)
assert len(files) == 1
assert files[0] == ("doc.txt", b"hello world")
# Store result and csv
assert store_result(sid, b"PK...zip") is True
assert get_result(sid) == b"PK...zip"
assert store_csv(sid, "col1,col2\nval1,val2") is True
assert get_csv(sid) == "col1,col2\nval1,val2"
# Cancel event
ev = get_cancel_event(sid)
assert ev is not None
assert not ev.is_set()
assert request_cancel(sid) is True
assert ev.is_set()
# TTL methods do not raise
touch(sid)
pause_ttl(sid)
resume_ttl(sid)
# Cleanup
cleanup(sid)
assert sid not in _sessions
assert get_files(sid) is None
assert file_count(sid) == 0
def test_session_limits():
sid = create_session()
configure(max_session_bytes=100)
try:
# Add 60 bytes - ok
assert add_file(sid, "f1.bin", b"x" * 60) is True
# Add 50 bytes - should fail (60 + 50 > 100)
assert add_file(sid, "f2.bin", b"x" * 50) is False
assert file_count(sid) == 1
finally:
configure(max_session_bytes=500 * 1024 * 1024)
cleanup(sid)
+262
View File
@@ -0,0 +1,262 @@
// Юнит-тесты слоя 2 (фронт): putToVm + uploadViaVM с пофайловым транзитом.
// Запуск: node --test tests/test_upload_layer2.test.mjs
import test from 'node:test';
import assert from 'node:assert/strict';
// Полифилл File/Blob при необходимости
if (typeof globalThis.File === 'undefined') {
globalThis.File = class File extends Blob {
constructor(parts, name, opts) {
super(parts, opts);
this.name = name;
this.lastModified = (opts && opts.lastModified) || Date.now();
}
};
}
import { putToVm } from '../upload/frontend/upload/put_to_vm.js';
import { uploadViaVM } from '../upload/frontend/upload/upload_via_vm.js';
const tick = () => new Promise(r => setTimeout(r, 0));
// Мок XMLHttpRequest
let lastXHR = null;
class FakeXHR {
constructor() {
this.upload = {};
this.status = 0;
this.timeout = 0;
this.onload = null;
this.onerror = null;
this.ontimeout = null;
this.onabort = null;
this.method = null;
this.url = null;
this.body = null;
this.aborted = false;
lastXHR = this;
}
open(method, url) { this.method = method; this.url = url; }
send(body) { this.body = body; }
abort() {
this.aborted = true;
if (this.onabort) this.onabort();
}
}
globalThis.XMLHttpRequest = FakeXHR;
// Мок fetch
let fetchCalls = [];
function installFetch(handler) {
fetchCalls = [];
globalThis.fetch = async (url, opts) => {
fetchCalls.push({ url, opts });
return handler(url, opts);
};
}
test('putToVm: успешная отправка', async () => {
const f = new File(['test-content'], 'doc.pdf');
const p = putToVm(f, 'https://vm-buffer/token_0');
lastXHR.status = 200;
lastXHR.onload();
await p;
assert.equal(lastXHR.method, 'PUT');
assert.equal(lastXHR.url, 'https://vm-buffer/token_0');
assert.equal(lastXHR.body, f);
});
test('putToVm: ошибка HTTP статуса', async () => {
const p = putToVm(new File(['abc'], 'doc.pdf'), 'https://vm-buffer/token_0');
lastXHR.status = 502;
lastXHR.statusText = 'Bad Gateway';
lastXHR.onload();
await assert.rejects(p, /HTTP 502/);
});
test('putToVm: сетевая ошибка', async () => {
const p = putToVm(new File(['abc'], 'doc.pdf'), 'https://vm-buffer/token_0');
lastXHR.onerror();
await assert.rejects(p, /Сетевая ошибка/);
});
test('putToVm: таймаут', async () => {
const p = putToVm(new File(['abc'], 'doc.pdf'), 'https://vm-buffer/token_0');
lastXHR.ontimeout();
await assert.rejects(p, /Таймаут/);
});
test('putToVm: прерывание через AbortSignal', async () => {
const ac = new AbortController();
const p = putToVm(new File(['abc'], 'doc.pdf'), 'https://vm-buffer/token_0', { signal: ac.signal });
ac.abort();
await assert.rejects(p, (err) => err.name === 'AbortError');
assert.equal(lastXHR.aborted, true);
});
test('uploadViaVM: пофайловый транзит (N файлов -> N PUT + N upload_refs)', async () => {
let callCount = 0;
installFetch(async (url, opts) => {
callCount++;
const body = JSON.parse(opts.body);
return {
ok: true,
json: async () => ({
ok: true,
session: body.session || 'created-sid-1',
count: callCount,
}),
};
});
const files = [
new File(['hello'], 'first.txt'),
new File(['world-data'], 'second.pdf'),
];
const statuses = [];
const completed = [];
const p = uploadViaVM(files, {
vmUploadUrl: 'https://vm-buffer/upload/',
backendUploadUrl: '/api/upload_refs',
onFileStatus: (idx, status) => statuses.push({ idx, status }),
onFileComplete: (info) => completed.push(info),
});
// Файл 1: завершаем PUT
await tick();
assert.equal(lastXHR.method, 'PUT');
assert.ok(lastXHR.url.startsWith('https://vm-buffer/upload/'));
lastXHR.status = 201;
lastXHR.onload();
// Файл 2: завершаем PUT
await tick();
assert.equal(lastXHR.method, 'PUT');
assert.ok(lastXHR.url.startsWith('https://vm-buffer/upload/'));
lastXHR.status = 201;
lastXHR.onload();
const res = await p;
assert.equal(res.ok, true);
assert.equal(res.session, 'created-sid-1');
assert.equal(res.count, 2);
// Проверяем, что было РОВНО 2 вызова fetch (/api/upload_refs), по одному на каждый файл
assert.equal(fetchCalls.length, 2);
const call1Body = JSON.parse(fetchCalls[0].opts.body);
assert.equal(call1Body.files.length, 1);
assert.equal(call1Body.files[0].name, 'first.txt');
const call2Body = JSON.parse(fetchCalls[1].opts.body);
assert.equal(call2Body.files.length, 1);
assert.equal(call2Body.files[0].name, 'second.pdf');
// Сессия, созданная на 1 шаге, проброшена во 2 шаг
assert.equal(call2Body.session, 'created-sid-1');
assert.equal(completed.length, 2);
assert.equal(completed[0].name, 'first.txt');
assert.equal(completed[1].name, 'second.pdf');
});
test('uploadViaVM: поддержка формата FilePicker.getFiles() ({name, size, file, path})', async () => {
installFetch(async (url, opts) => {
return {
ok: true,
json: async () => ({ ok: true, session: 'sid-p', count: 1 }),
};
});
const pickerFiles = [
{
name: 'archive_doc.txt',
size: 42,
file: new File(['x'.repeat(42)], 'archive_doc.txt'),
path: 'folder/archive_doc.txt',
},
];
const p = uploadViaVM(pickerFiles, {
vmUploadUrl: 'https://vm-buffer/upload/',
});
await tick();
lastXHR.status = 200;
lastXHR.onload();
const res = await p;
assert.equal(res.ok, true);
assert.equal(res.count, 1);
const body = JSON.parse(fetchCalls[0].opts.body);
assert.equal(body.files[0].name, 'archive_doc.txt');
assert.equal(body.files[0].size, 42);
});
test('uploadViaVM: обработка ошибки PUT', async () => {
installFetch(async () => ({ ok: true, json: async () => ({ ok: true }) }));
const files = [new File(['bad'], 'err.txt')];
const p = uploadViaVM(files, {
vmUploadUrl: 'https://vm-buffer/upload/',
});
await tick();
lastXHR.onerror();
const res = await p;
assert.equal(res.ok, false);
assert.ok(res.error.includes('Ошибка отправки файла'));
assert.equal(fetchCalls.length, 0); // POST не вызывался
});
test('uploadViaVM: прерывание через signal', async () => {
installFetch(async () => ({ ok: true, json: async () => ({ ok: true }) }));
const ac = new AbortController();
const files = [new File(['1'], 'f1.txt'), new File(['2'], 'f2.txt')];
const p = uploadViaVM(files, {
vmUploadUrl: 'https://vm-buffer/upload/',
signal: ac.signal,
});
await tick();
ac.abort();
const res = await p;
assert.equal(res.ok, false);
assert.equal(res.aborted, true);
});
test('uploadViaVM: отмена после первого файла не начинает второй PUT', async () => {
installFetch(async (url, opts) => ({
ok: true,
json: async () => ({ ok: true, session: 'partial-sid', count: 1 }),
}));
const ac = new AbortController();
const statuses = [];
const files = [new File(['first'], 'first.txt'), new File(['second'], 'second.txt')];
const p = uploadViaVM(files, {
vmUploadUrl: 'https://vm-buffer/upload/',
signal: ac.signal,
onFileStatus: (idx, status) => statuses.push({ idx, status }),
});
await tick();
assert.equal(lastXHR.url.endsWith('_0'), true);
lastXHR.status = 201;
lastXHR.onload();
ac.abort();
const res = await p;
assert.equal(res.ok, false);
assert.equal(res.aborted, true);
assert.equal(fetchCalls.length, 1);
assert.equal(lastXHR.url.endsWith('_0'), true);
assert.ok(statuses.some(({ idx, status }) => idx === 0 && status.includes('Доставлен')));
});
+120
View File
@@ -0,0 +1,120 @@
import threading
from http.server import HTTPServer, BaseHTTPRequestHandler
import pytest
from flask import Flask
from upload.backend.upload_refs.blueprint import create_upload_refs_blueprint
from upload.backend.session import get_files, cleanup
class MockBufferHandler(BaseHTTPRequestHandler):
storage = {}
def do_PUT(self):
length = int(self.headers.get("Content-Length", 0))
data = self.rfile.read(length)
MockBufferHandler.storage[self.path] = data
self.send_response(201)
self.end_headers()
def do_GET(self):
data = MockBufferHandler.storage.get(self.path)
if data is None:
self.send_response(404)
self.end_headers()
return
self.send_response(200)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_DELETE(self):
MockBufferHandler.storage.pop(self.path, None)
self.send_response(204)
self.end_headers()
def log_message(self, format, *args):
pass # suppress console logs in tests
@pytest.fixture(scope="module")
def mock_server():
server = HTTPServer(("127.0.0.1", 0), MockBufferHandler)
port = server.server_port
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
yield f"http://127.0.0.1:{port}/buffer/"
server.shutdown()
def test_upload_refs_pull_and_delete(mock_server):
# Put a file into mock buffer
file_path = "/buffer/token123_0"
content = b"Content of test document for layer 2 transit"
MockBufferHandler.storage[file_path] = content
received_events = []
def on_received(sid, name, data):
received_events.append((sid, name, data))
app = Flask(__name__)
bp = create_upload_refs_blueprint({
"vmUploadPrefix": mock_server,
"pullRetries": 1,
"pullRetryDelay": 0.1,
"onFileReceived": on_received,
})
app.register_blueprint(bp)
client = app.test_client()
file_url = mock_server + "token123_0"
resp = client.post("/api/upload_refs", json={
"files": [
{"name": "test_doc.pdf", "size": len(content), "url": file_url}
]
})
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
sid = data["session"]
assert data["count"] == 1
assert data["added"] == 1
# Verify file is in session memory
files = get_files(sid)
assert len(files) == 1
assert files[0] == ("test_doc.pdf", content)
# Verify callback for Layer 3 was triggered
assert len(received_events) == 1
assert received_events[0] == (sid, "test_doc.pdf", content)
# Verify file was DELETED from mock buffer (RAM clean!)
assert file_path not in MockBufferHandler.storage
cleanup(sid)
def test_upload_refs_ssrf_protection(mock_server):
app = Flask(__name__)
bp = create_upload_refs_blueprint({
"vmUploadPrefix": mock_server,
})
app.register_blueprint(bp)
client = app.test_client()
# Try to pass an evil URL outside vmUploadPrefix
evil_url = "http://169.254.169.254/latest/meta-data/"
resp = client.post("/api/upload_refs", json={
"files": [
{"name": "evil.txt", "size": 100, "url": evil_url}
]
})
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
sid = data["session"]
assert data["added"] == 0
assert data["count"] == 0
cleanup(sid)
+31
View File
@@ -0,0 +1,31 @@
# Upload module (v0.2.0)
Переиспользуемый модуль загрузки, состоящий из двух независимых слоёв:
1. **Слой 1 (`upload/frontend/`)** — File Picker:
- IIFE: `FilePicker.initFilePicker(config)`;
- ESM: `import { initFilePicker } from './file-picker.esm.js'` (или из `upload/frontend/index.js`).
- Получение выбранных файлов: `picker.getFiles()` возвращает плоский массив `{ path, name, size, file }`.
- Поддерживает выбор файлов, папок, клиентскую распаковку ZIP (fflate) и проверку лимитов.
2. **Слой 2 (`upload/frontend/upload/` + `upload/backend/`)** — Пофайловый транзит в RAM сессии:
- Фронтенд: `uploadViaVM(files, options)` или `FilePicker.uploadViaVM(files, options)`:
- Пофайловый стриминг: для каждого файла выполняется `PUT` в буфер на ВМ, немедленный `POST /api/upload_refs` в бэкенд, и после подтверждения приёма буфер на ВМ очищается.
- Поддержка `AbortSignal` для отмены загрузки на любом этапе.
- Бэкенд: `create_upload_refs_blueprint(cfg)`:
- Приём ссылок через исходящий потоковый `GET` в память RAM.
- SSRF-валидация префикса буфера (`vmUploadPrefix`).
- Защита от path traversal (`safe_name`).
- Хранилище сессий в оперативной памяти (`upload/backend/session`).
- Никакой записи на диск.
## Интеграция в сторонний проект
1. Скопировать каталог `upload/` в проект.
2. Во Flask подключить Blueprint:
```python
from upload.backend.upload_refs import create_upload_refs_blueprint
app.register_blueprint(create_upload_refs_blueprint({"vmUploadPrefix": "..."}))
```
3. На фронтенде подключить `file-picker.iife.js` или импортировать ESM.
4. Вызвать `initFilePicker`, а при отправке вызвать `uploadViaVM(picker.getFiles(), options)`.
+5
View File
@@ -0,0 +1,5 @@
"""Переиспользуемые слои загрузки файлов.
Пакет не выполняет импортов и не запускает побочных действий при загрузке.
Рабочая picker-only логика находится в подпакетах ``frontend`` и ``zip``.
"""
+1
View File
@@ -0,0 +1 @@
"""Backend переиспользуемого модуля загрузки: upload_refs + session."""
+38
View File
@@ -0,0 +1,38 @@
"""In-memory хранилище сессий с TTL и лимитами.
Каждая операция — в отдельном файле, общее состояние — в state.py.
Импорт как единый пакет:
from upload.backend.session import create_session, add_file, get_files
"""
from .create_session import create_session
from .add_file import add_file
from .get_files import get_files, file_count
from .store_result import store_result, get_result
from .store_csv import store_csv, get_csv
from .ttl import touch, pause_ttl, resume_ttl
from .cancel import request_cancel, get_cancel_event
from .cleanup import cleanup
from .state import TTL_SECONDS, MAX_FILE_BYTES, MAX_SESSION_BYTES, configure
__all__ = [
"create_session",
"add_file",
"get_files",
"file_count",
"store_result",
"get_result",
"store_csv",
"get_csv",
"touch",
"pause_ttl",
"resume_ttl",
"request_cancel",
"get_cancel_event",
"cleanup",
"configure",
"TTL_SECONDS",
"MAX_FILE_BYTES",
"MAX_SESSION_BYTES",
]
+25
View File
@@ -0,0 +1,25 @@
"""add_file — добавить файл в сессию (с проверкой суммарного лимита)."""
from . import state
def add_file(sid: str, filename: str, content: bytes) -> bool:
"""Добавить файл в сессию.
Args:
sid: Идентификатор сессии
filename: Имя файла
content: Бинарное содержимое
Returns:
True если добавлено; False если сессии нет или превышен лимит сессии.
"""
with state._lock:
s = state._sessions.get(sid)
if not s:
return False
total = sum(len(c) for _, c in s["files"])
if total + len(content) > state.MAX_SESSION_BYTES:
return False # превышен суммарный лимит сессии
s["files"].append((filename, content))
return True
+27
View File
@@ -0,0 +1,27 @@
"""request_cancel / get_cancel_event — мягкое прерывание обработки сессии."""
import threading
from typing import Optional
from .state import _sessions, _lock
def request_cancel(sid: str) -> bool:
"""Запросить мягкое прерывание обработки сессии.
Returns:
True если сессия существует и отмена запрошена, False если нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["cancel"].set()
return True
def get_cancel_event(sid: str) -> Optional[threading.Event]:
"""Получить событие отмены сессии (или None, если сессии нет)."""
with _lock:
s = _sessions.get(sid)
return s["cancel"] if s else None
+11
View File
@@ -0,0 +1,11 @@
"""cleanup — удалить сессию."""
from .state import _sessions, _lock
def cleanup(sid: str):
"""Удалить сессию и остановить её TTL-таймер."""
with _lock:
s = _sessions.pop(sid, None)
if s and s.get("timer"):
s["timer"].cancel()
+23
View File
@@ -0,0 +1,23 @@
"""create_session — создать новую сессию."""
import threading
import uuid
from .state import _sessions, _lock, _start_timer
def create_session() -> str:
"""Создать новую сессию.
Returns:
Уникальный идентификатор сессии (UUID).
"""
sid = uuid.uuid4().hex
with _lock:
_sessions[sid] = {
"files": [],
"result": None,
"cancel": threading.Event(),
"timer": _start_timer(sid),
}
return sid
+23
View File
@@ -0,0 +1,23 @@
"""get_files / file_count — чтение файлов сессии."""
from typing import List, Optional, Tuple
from .state import _sessions, _lock
def get_files(sid: str) -> Optional[List[Tuple[str, bytes]]]:
"""Получить все файлы сессии.
Returns:
[(filename, content), ...] или None если сессия не найдена.
"""
with _lock:
s = _sessions.get(sid)
return list(s["files"]) if s else None
def file_count(sid: str) -> int:
"""Количество файлов в сессии."""
with _lock:
s = _sessions.get(sid)
return len(s["files"]) if s else 0
+45
View File
@@ -0,0 +1,45 @@
"""Общее состояние сессий: хранилище, блокировка, константы, TTL-таймер.
Единая точка хранения состояния — все операции импортируют её.
Дробить state.py на файл-на-переменную не нужно: это данные, а не функции.
"""
import threading
# TTL сессии: 30 минут
TTL_SECONDS = 30 * 60
# Максимальный объём одного файла и суммарный объём файлов в сессии (защита памяти)
MAX_FILE_BYTES = 50 * 1024 * 1024 # 50 MB на один файл
MAX_SESSION_BYTES = 500 * 1024 * 1024 # 500 MB суммарно на сессию
_sessions: dict = {}
_lock = threading.Lock()
def _start_timer(sid: str) -> threading.Timer:
"""Запустить таймер автоочистки сессии через TTL."""
def _clean():
with _lock:
_sessions.pop(sid, None)
timer = threading.Timer(TTL_SECONDS, _clean)
timer.daemon = True
timer.start()
return timer
def configure(max_file_bytes: int = None, max_session_bytes: int = None,
ttl_seconds: int = None):
"""Переопределить лимиты/TTL из конфига приложения (глобально).
None — оставить текущее значение.
"""
global MAX_FILE_BYTES, MAX_SESSION_BYTES, TTL_SECONDS
if max_file_bytes is not None:
MAX_FILE_BYTES = max_file_bytes
if max_session_bytes is not None:
MAX_SESSION_BYTES = max_session_bytes
if ttl_seconds is not None:
TTL_SECONDS = ttl_seconds
+30
View File
@@ -0,0 +1,30 @@
"""store_csv / get_csv — сохранение и чтение CSV с таблицей замен."""
from typing import Optional
from .state import _sessions, _lock
def store_csv(sid: str, csv_str: str) -> bool:
"""Сохранить CSV с таблицей замен.
Returns:
True если сохранено, False если сессии нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["csv"] = csv_str
return True
def get_csv(sid: str) -> Optional[str]:
"""Получить CSV с таблицей замен.
Returns:
Строка CSV или None если нет.
"""
with _lock:
s = _sessions.get(sid)
return s.get("csv") if s else None
+30
View File
@@ -0,0 +1,30 @@
"""store_result / get_result — сохранение и чтение результата обработки."""
from typing import Optional
from .state import _sessions, _lock
def store_result(sid: str, zip_data: bytes) -> bool:
"""Сохранить результат обработки (ZIP-архив).
Returns:
True если сохранено, False если сессии нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["result"] = zip_data
return True
def get_result(sid: str) -> Optional[bytes]:
"""Получить результат обработки.
Returns:
ZIP-архив или None если сессия не найдена/результат не готов.
"""
with _lock:
s = _sessions.get(sid)
return s["result"] if s else None
+34
View File
@@ -0,0 +1,34 @@
"""touch / pause_ttl / resume_ttl — управление TTL-таймером сессии."""
from .state import _sessions, _lock, _start_timer
def touch(sid: str):
"""Продлить жизнь сессии: перезапустить TTL-таймер (если сессия существует)."""
with _lock:
s = _sessions.get(sid)
if not s:
return
if s.get("timer"):
s["timer"].cancel()
s["timer"] = _start_timer(sid)
def pause_ttl(sid: str):
"""Приостановить TTL сессии (во время обработки): сессия живёт, пока идёт воркер."""
with _lock:
s = _sessions.get(sid)
if s and s.get("timer"):
s["timer"].cancel()
s["timer"] = None
def resume_ttl(sid: str):
"""Возобновить TTL сессии (после завершения обработки): результат доступен ещё TTL."""
with _lock:
s = _sessions.get(sid)
if not s:
return
if s.get("timer"):
s["timer"].cancel()
s["timer"] = _start_timer(sid)
+7
View File
@@ -0,0 +1,7 @@
"""upload_refs — Flask Blueprint для приёма ссылок и pull с ВМ-буфера."""
from .blueprint import create_upload_refs_blueprint
from .safe_name import safe_name
from .pull_file import pull_file
__all__ = ["create_upload_refs_blueprint", "safe_name", "pull_file"]
+145
View File
@@ -0,0 +1,145 @@
"""Переиспользуемый Blueprint слоя 2: POST /upload_refs (pull с ВМ-буфера в сессию).
Поведение:
- safe_name (защита от path traversal)
- SSRF-валидация url.startswith(vm_prefix)
- лимит на один файл -> delete + skip
- pull с ретраями через httpx stream
- лимит сессии -> skip; отсутствие сессии -> 404
- delete url с ВМ (best-effort)
- поддержка пофайлового транзита и пачек
- опциональный callback для интеграции/эмуляции Слоя 3 (on_file_received)
"""
import httpx
import logging
from urllib.parse import urlsplit
from flask import Blueprint, request, jsonify, current_app
from ..session import (create_session, add_file, get_files, file_count,
MAX_FILE_BYTES, configure)
from .config import PULL_RETRIES, PULL_RETRY_DELAY, VM_UPLOAD_PREFIX
from .safe_name import safe_name
from .pull_file import pull_file
log = logging.getLogger("upload.upload_refs")
def create_upload_refs_blueprint(cfg: dict = None) -> Blueprint:
"""Создать Blueprint с эндпоинтом upload_refs.
cfg (все ключи опциональны, есть дефолты):
apiPrefix (str) — префикс Blueprint, по умолчанию "/api"
vmUploadPrefix (str) — доверенный префикс ВМ-буфера (SSRF-валидация)
maxFileBytes (int) — лимит на один файл
maxSessionBytes (int) — суммарный лимит сессии (применяется к сессиям)
ttlSeconds (int) — TTL сессии
pullRetries (int) — ретраи pull
pullRetryDelay (int) — пауза между ретраями (сек)
pullTimeout (int) — таймаут одного GET pull
onFileReceived (func) — опциональный callback (sid, name, content) для Слоя 3
"""
cfg = cfg or {}
prefix = cfg.get("apiPrefix", "/api")
vm_prefix = cfg.get("vmUploadPrefix", VM_UPLOAD_PREFIX)
max_file_bytes = cfg.get("maxFileBytes", MAX_FILE_BYTES)
pull_retries = cfg.get("pullRetries", PULL_RETRIES)
pull_delay = cfg.get("pullRetryDelay", PULL_RETRY_DELAY)
pull_timeout = cfg.get("pullTimeout", 120)
on_file_received = cfg.get("onFileReceived")
# Применить лимиты сессии/TTL из конфига (глобально для всех сессий)
configure(
max_file_bytes=cfg.get("maxFileBytes"),
max_session_bytes=cfg.get("maxSessionBytes"),
ttl_seconds=cfg.get("ttlSeconds"),
)
is_path_only_prefix = vm_prefix.startswith("/")
bp = Blueprint("upload_refs", __name__, url_prefix=prefix)
@bp.route("/upload_refs", methods=["POST"])
def upload_refs():
"""Принять ссылки на файлы (загружены на ВМ-буфер), забрать по egress.
Вход: JSON {"session": "...", "files": [{"name": str, "size": int, "url": str}]}.
Каждый файл тянется исходящим GET с ВМ (egress не ограничен шлюзом),
читается по частям (stream), кладётся в сессию. После успешного pull файл
удаляется с ВМ (DELETE).
"""
data = request.get_json(silent=True) or {}
sid = data.get("session") or create_session()
refs = data.get("files") or []
if not refs:
log.warning("upload_refs: no files, sid=%s", sid)
return jsonify({"ok": False, "error": "No files"}), 400
added = 0
transport = cfg.get("httpxTransport") or current_app.config.get("UPLOAD_HTTPX_TRANSPORT")
try:
with httpx.Client(transport=transport, timeout=pull_timeout, follow_redirects=True) as client:
for ref in refs:
name = safe_name(ref.get("name") or "")
url = ref.get("url") or ""
if not name or not url:
continue
# SSRF-защита: тянуть можно ТОЛЬКО с доверенного ВМ-буфера
if is_path_only_prefix:
if not urlsplit(url).path.startswith(vm_prefix):
log.warning("upload_refs: unsafe URL path, skip sid=%s url=%r", sid, url)
continue
if not url.startswith(("http://", "https://")):
url = request.host_url.rstrip("/") + ("/" if not url.startswith("/") else "") + url
else:
if not url.startswith(vm_prefix):
log.warning("upload_refs: unsafe URL, skip sid=%s url=%r", sid, url)
continue
# Лимит на один файл: сверх лимита — пропускаем
if (ref.get("size") or 0) > max_file_bytes:
log.warning("upload_refs: file exceeds %dMB, skip sid=%s file=%r size=%s",
max_file_bytes // (1024 * 1024), sid, name, ref.get("size"))
try:
client.delete(url)
except Exception:
pass
continue
# Pull с ретраями
content = pull_file(client, url, pull_retries, pull_delay, sid=sid, name=name)
log.info("upload_refs: pulled sid=%s file=%r size=%d", sid, name, len(content))
if len(content) > max_file_bytes:
log.warning("upload_refs: pulled file exceeds %dMB, skip sid=%s file=%r size=%d",
max_file_bytes // (1024 * 1024), sid, name, len(content))
try:
client.delete(url)
except Exception:
pass
continue
if not add_file(sid, name, content):
if get_files(sid) is None:
log.warning("upload_refs: session not found, sid=%s file=%r", sid, name)
return jsonify({"ok": False, "error": "Session not found"}), 404
log.warning("upload_refs: session limit exceeded, skip sid=%s file=%r", sid, name)
try:
client.delete(url)
except Exception:
pass
continue
# Успешно добавлен в сессию — удаляем с ВМ-буфера
try:
client.delete(url)
except Exception:
pass
# Если передан callback для Слоя 3 (эмуляция или реальный процессинг)
if callable(on_file_received):
try:
on_file_received(sid, name, content)
except Exception as cb_err:
log.warning("upload_refs: on_file_received callback error: %r", cb_err)
added += 1
except Exception as e:
log.error("upload_refs: pull error sid=%s: %r", sid, e)
return jsonify({"ok": False, "error": "Pull failed: %s" % e}), 502
log.info("upload_refs: done sid=%s added=%d total=%d", sid, added, file_count(sid))
return jsonify({"ok": True, "session": sid, "count": file_count(sid), "added": added})
return bp
+11
View File
@@ -0,0 +1,11 @@
"""Параметры слоя 2 (бэк) по умолчанию.
Переопределяются из конфига приложения через create_upload_refs_blueprint(cfg).
"""
# Ретраи pull из ВМ-буфера: защита от разовых DNS/сетевых сбоев (gaierror -5 и т.п.)
PULL_RETRIES = 3
PULL_RETRY_DELAY = 2 # секунды между попытками
# Доверенный префикс ВМ-буфера по умолчанию — валидация URL при pull (защита от SSRF)
VM_UPLOAD_PREFIX = "https://contracts.kube5s.ru/drhider-upload/"
+39
View File
@@ -0,0 +1,39 @@
"""pull_file — вытащить файл с ВМ-буфера исходящим GET с ретраями."""
import logging
import time
from .config import PULL_RETRIES, PULL_RETRY_DELAY
log = logging.getLogger("upload.upload_refs.pull")
def pull_file(client, url: str, retries: int = PULL_RETRIES,
delay: float = PULL_RETRY_DELAY, sid: str = None, name: str = None) -> bytes:
"""GET url с ретраями; читает по частям (stream).
Args:
client: httpx.Client
url: URL файла на ВМ-буфере.
retries: число попыток.
delay: пауза между попытками (сек).
sid/name: для логирования (опционально).
Returns:
Содержимое файла (bytes).
Raises:
Последнюю ошибку попытки, если все ретраи не удались.
"""
last_err = None
for attempt in range(retries):
try:
with client.stream("GET", url) as resp:
resp.raise_for_status()
return b"".join(resp.iter_bytes())
except Exception as e:
last_err = e
log.warning("pull: attempt %d/%d failed sid=%s file=%r: %r",
attempt + 1, retries, sid, name, e)
time.sleep(delay)
raise last_err if last_err else RuntimeError("pull failed")
+16
View File
@@ -0,0 +1,16 @@
"""safe_name — санитизация имени файла (защита от path traversal)."""
def safe_name(name: str) -> str:
"""Санитизировать имя файла: защита от path traversal, сохраняя подпапки.
Запрещает '..' и абсолютные пути; нормализует слэши. Возвращает "" если
имя пустое или небезопасное.
"""
if not name:
return ""
name = name.replace("\\", "/")
parts = [p for p in name.split("/") if p and p != "."]
if not parts or any(p == ".." for p in parts):
return ""
return "/".join(parts)
+3
View File
@@ -0,0 +1,3 @@
{
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"]
}
+193
View File
@@ -0,0 +1,193 @@
import { addFiles, onFilesChange } from './table/on_files_change.js';
import { onFolderChange } from './table/on_folder_change.js';
import { findNode, flattenFiles, render } from './table/render.js';
import { esc } from './table/esc.js';
const DEFAULTS = {
allowedExt: [],
labels: {
pickFiles: 'Выбрать файлы',
pickFolder: 'Выбрать папку',
clear: 'Очистить',
empty: 'Нет выбранных файлов',
statusReady: 'готов',
remove: 'Удалить',
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
count: (count, bytes) => `${count} файлов · ${bytes} B`,
},
layout: { columns: ['path', 'size', 'status'], controls: ['files', 'folder', 'clear'] },
limits: {
maxEntries: 1000,
maxTotalBytes: 100 * 1024 * 1024,
maxEntryBytes: 50 * 1024 * 1024,
maxDepth: 20,
},
};
function resolveMount(mount) {
const element = typeof mount === 'string' ? document.querySelector(mount) : mount;
if (!element) throw new Error('File picker mount element не найден');
return element;
}
function normalizeConfig(config) {
return {
...config,
allowedExt: config.allowedExt || DEFAULTS.allowedExt,
labels: {
...DEFAULTS.labels,
...(config.labels || {}),
columns: { ...DEFAULTS.labels.columns, ...(config.labels?.columns || {}) },
},
layout: { ...DEFAULTS.layout, ...(config.layout || {}) },
limits: { ...DEFAULTS.limits, ...(config.limits || {}) },
};
}
function injectStyles() {
if (document.querySelector('style[data-file-picker]')) return;
const style = document.createElement('style');
style.dataset.filePicker = '';
style.textContent = `
.file-picker { color: #17211b; font-family: Georgia, 'Times New Roman', serif; }
.file-picker .fp-toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; }
.file-picker .fp-toolbar button { border: 0; border-radius: 4px; padding: 12px 17px; color: #f7faf4; background: #1f5a3b; font: 700 13px sans-serif; cursor: pointer; }
.file-picker .fp-toolbar button.secondary { color: #1f5a3b; background: #cbdcc9; }
.file-picker .fp-toolbar button.quiet { color: #5d6a61; background: transparent; }
.file-picker .fp-table-wrap { overflow-x: auto; border-top: 1px solid #b9c7ba; }
.file-picker .fp-table { width: 100%; border-collapse: collapse; font: 14px/1.4 sans-serif; }
.file-picker .fp-table th, .file-picker .fp-table td { padding: 14px 10px; border-bottom: 1px solid #cbd5cc; text-align: left; }
.file-picker .fp-table th { color: #56745f; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
.file-picker .fp-status, .file-picker .fp-count { color: #5d6a61; font: 13px sans-serif; }
.file-picker .fp-count { min-height: 22px; }
.file-picker .tree-name { display: inline-flex; align-items: center; gap: 8px; min-width: 240px; padding-top: 0; padding-bottom: 0; border: 0; background: transparent; color: #17211b; font: inherit; text-align: left; }
.file-picker .tree-group { padding-left: 0; cursor: pointer; }
.file-picker .tree-chevron { display: inline-block; width: 12px; color: #78917d; font-size: 10px; }
.file-picker .remove-btn { padding: 2px 7px; color: #8c5148; background: transparent; font: 20px/1 sans-serif; }
.file-picker .tree-row td:first-child { white-space: nowrap; }
@media (max-width: 600px) { .file-picker .fp-toolbar button { flex: 1 1 42%; } }
`;
document.head.append(style);
}
function buildMarkup(root, cfg) {
const controls = cfg.layout.controls || [];
const control = (name, id, label, className = '') => controls.includes(name)
? `<button id="${id}" type="button" class="${className}">${esc(label)}</button>` : '';
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(cfg.labels.columns);
const headings = columns.map((column) => `<th>${esc(cfg.labels.columns[column] || column)}</th>`).join('');
root.innerHTML = `
<div class="file-picker${cfg.layout.theme ? ` ${esc(cfg.layout.theme)}` : ''}">
<div class="fp-toolbar" aria-label="${esc(cfg.labels.pickFiles)}">
<input class="fp-file-input" type="file" accept="${esc([...cfg.allowedExt, '.zip'].join(','))}" multiple hidden>
<input class="fp-folder-input" type="file" accept="${esc([...cfg.allowedExt, '.zip'].join(','))}" webkitdirectory directory multiple hidden>
${control('files', 'fp-files-btn', cfg.labels.pickFiles)}
${control('folder', 'fp-folder-btn', cfg.labels.pickFolder, 'secondary')}
${control('clear', 'fp-clear-btn', cfg.labels.clear, 'quiet')}
</div>
<p class="fp-status" role="status"></p>
<div class="fp-table-wrap"><table class="fp-table"><thead><tr>${headings}<th></th></tr></thead><tbody class="fp-table-body"></tbody></table></div>
<p class="fp-count"></p>
</div>`;
}
function notify(cfg, state) {
if (typeof cfg.onChange === 'function') cfg.onChange([...flattenFiles(state.nodes)]);
}
function removeMetadata(state, node) {
if (node.kind === 'file') {
state.fileKeys.delete(`${node.path}\u0000${node.file.size}`);
return;
}
node.children.forEach((child) => removeMetadata(state, child));
}
function removeNode(state, id) {
const found = findNode(state.nodes, id);
if (!found) return false;
removeMetadata(state, found.node);
found.nodes.splice(found.nodes.indexOf(found.node), 1);
return true;
}
export function initFilePicker(config) {
const cfg = normalizeConfig(config);
const mount = resolveMount(cfg.mount);
injectStyles();
buildMarkup(mount, cfg);
const root = mount.firstElementChild;
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'),
};
const state = { nodes: [], fileKeys: new Set(), busy: false };
const listeners = [];
const listen = (target, event, handler) => {
target.addEventListener(event, handler);
listeners.push(() => target.removeEventListener(event, handler));
};
const redraw = () => render(state, elements, cfg);
const filesChange = onFilesChange(state, cfg, elements);
const folderChange = onFolderChange(state, cfg, elements);
listen(elements.fileInputEl, 'change', async () => { await filesChange(); notify(cfg, state); });
listen(elements.folderInputEl, 'change', async () => { await folderChange(); notify(cfg, state); });
listen(elements.tableBodyEl, '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;
redraw();
return;
}
const remove = event.target.closest('[data-remove]');
if (remove) {
if (removeNode(state, remove.dataset.remove)) {
redraw();
notify(cfg, state);
}
}
});
if (root.querySelector('#fp-files-btn')) listen(root.querySelector('#fp-files-btn'), 'click', () => elements.fileInputEl.click());
if (root.querySelector('#fp-folder-btn')) listen(root.querySelector('#fp-folder-btn'), 'click', () => elements.folderInputEl.click());
if (root.querySelector('#fp-clear-btn')) listen(root.querySelector('#fp-clear-btn'), 'click', () => {
state.nodes = [];
state.fileKeys.clear();
redraw();
notify(cfg, state);
});
redraw();
return {
pickFiles: () => elements.fileInputEl.click(),
pickFolder: () => elements.folderInputEl.click(),
addFiles: async (files) => { await addFiles(state, cfg, files, elements); notify(cfg, state); },
getFiles: () => [...flattenFiles(state.nodes)],
remove: (id) => {
if (removeNode(state, id)) {
redraw();
notify(cfg, state);
}
},
clear: () => {
state.nodes = [];
state.fileKeys.clear();
redraw();
notify(cfg, state);
},
render: redraw,
destroy: () => {
listeners.forEach((removeListener) => removeListener());
state.nodes = [];
state.fileKeys.clear();
mount.replaceChildren();
},
};
}
export { putToVm } from './upload/put_to_vm.js';
export { uploadViaVM } from './upload/upload_via_vm.js';
export { DEFAULTS };
@@ -0,0 +1,54 @@
/**
* Добавляет узел файла или группы в состояние picker с рекурсивной дедупликацией.
*
* @param {{nodes: Array, fileKeys: Set<string>}} state Внутреннее состояние таблицы.
* @param {{kind: string, path: string, file?: File, children?: Array}} fileNode
* Корневой узел файла, папки или ZIP-архива.
* @returns {boolean} True, если в состояние добавлен хотя бы один узел.
*
* Ключ дедупликации состоит из полного пути и размера файла. Это позволяет
* повторно выбрать файл после удаления и одновременно не скрывает файл с тем
* же путём, но другим размером.
*/
export function addFileWithDedup(state, fileNode) {
// Удаляет дубли и пустые группы снизу вверх, сохраняя исходные File objects.
const accept = (node) => {
if (node.kind === 'file') {
// NUL-разделитель исключает неоднозначность при склейке пути и размера.
const key = `${node.path}\u0000${node.file.size}`;
if (state.fileKeys.has(key)) return null;
state.fileKeys.add(key);
return node;
}
// Группа нужна только пока после фильтрации в ней остался хотя бы один leaf.
node.children = node.children.map(accept).filter(Boolean);
return node.children.length ? node : null;
};
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);
});
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Экранирует текст перед вставкой в HTML-шаблон, собираемый через innerHTML.
*
* @param {*} value Имя файла, путь или сообщение статуса.
* @returns {string} Строка с заменёнными HTML-значимыми символами.
*
* Экранируются и кавычки, потому что значение может попасть не только в текст
* кнопки, но и в HTML-атрибут вроде data-path или aria-label.
*/
export function esc(value) {
return String(value).replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[character]));
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Преобразует размер файла из байтов в короткую строку для таблицы.
*
* @param {number} bytes Размер в байтах.
* @returns {string} Значение в B, KB или MB с одним знаком после запятой.
*/
export function fs(bytes) {
return bytes < 1024 ? `${bytes} B`
: bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB`
: `${(bytes / 1048576).toFixed(1)} MB`;
}
+72
View File
@@ -0,0 +1,72 @@
import { listZipFiles } from '../zip/list_zip_files.js';
import { addFileWithDedup } from './add_file_with_dedup.js';
import { render } from './render.js';
/**
* Обрабатывает список файлов из обычного file input.
*
* @param {{fileKeys: Set<string>, nodes: Array, busy: boolean}} state Состояние picker.
* @param {{allowedExt: string[]}} cfg Конфигурация разрешённых расширений.
* @param {FileList|File[]} files Выбранные browser File objects.
* @param {{tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-элементы вывода.
* @returns {Promise<void>} Завершается после разбора всех ZIP и перерисовки таблицы.
*
* Обычные документы добавляются сразу. ZIP читаются асинхронно в браузере;
* ошибка одного архива не отменяет обработку остальных выбранных файлов.
*/
export async function addFiles(state, cfg, files, elements) {
for (const file of Array.from(files)) {
if (!file.name.toLowerCase().endsWith('.zip')) {
// Фильтр повторяет accept в HTML, потому что accept не является защитой API.
if (!cfg.allowedExt.some((extension) => file.name.toLowerCase().endsWith(extension.toLowerCase()))) {
continue;
}
// Обычный файл получает имя как путь: у file input нет относительного пути.
addFileWithDedup(state, {
id: crypto.randomUUID(), kind: 'file', name: file.name, path: file.name,
file, children: [], expanded: true,
});
continue;
}
try {
// listZipFiles возвращает дерево только с разрешёнными leaf-файлами.
const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits);
if (zipTree) addFileWithDedup(state, zipTree);
} catch (error) {
// Битый или небезопасный ZIP пропускается, чтобы не блокировать picker.
if (typeof cfg.onError === 'function') {
cfg.onError(error, file);
} else if (elements.statusEl) {
elements.statusEl.textContent = `Ошибка чтения архива ${file.name}: ${error.message}`;
}
continue;
}
}
// Единая перерисовка после всей пачки предотвращает промежуточные состояния UI.
render(state, elements, cfg);
}
/**
* Создаёт обработчик change для обычного выбора файлов.
*
* @param {object} state Состояние picker, включая флаг busy.
* @param {object} cfg Конфигурация picker.
* @param {object} elements DOM-элементы picker.
* @returns {Function} Async-обработчик для addEventListener('change', ...).
*
* busy устанавливается синхронно до первого await. Поэтому второе быстрое
* событие выбора не запускает параллельный разбор и не меняет дерево конкурирующе.
*/
export function onFilesChange(state, cfg, elements) {
return async () => {
if (state.busy) return;
state.busy = true;
try {
await addFiles(state, cfg, elements.fileInputEl.files, elements);
} finally {
// Даже исключение вне внутреннего ZIP-catch не оставляет picker заблокированным.
elements.fileInputEl.value = '';
state.busy = false;
}
};
}
+84
View File
@@ -0,0 +1,84 @@
import { listZipFiles } from '../zip/list_zip_files.js';
import { addFileWithDedup } from './add_file_with_dedup.js';
import { rebaseTree } from './rebase_tree.js';
import { render } from './render.js';
/**
* Создаёт async-обработчик выбора каталога через webkitdirectory.
*
* @param {{nodes: Array, fileKeys: Set<string>, busy: boolean}} state Состояние picker.
* @param {{allowedExt: string[]}} cfg Разрешённые расширения.
* @param {{folderInputEl: HTMLInputElement, tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-элементы.
* @returns {Function} Async-обработчик события change.
*
* Браузер отдаёт плоский FileList с webkitRelativePath. Обработчик группирует
* его по первой части пути, создаёт промежуточные папки и затем добавляет каждый
* корень через общую дедупликацию. ZIP внутри каталога раскрывается тем же кодом,
* что и ZIP из обычного file input.
*/
export function onFolderChange(state, cfg, elements) {
return async () => {
if (state.busy) return;
state.busy = true;
try {
const roots = new Map();
for (const file of Array.from(elements.folderInputEl.files)) {
// Для webkitdirectory путь начинается с выбранной корневой папки.
const parts = (file.webkitRelativePath || file.name).split('/');
const relativePath = parts.slice(1).join('/') || file.name;
const lowerPath = relativePath.toLowerCase();
const rootName = parts[0] || file.name;
if (!roots.has(rootName)) {
// Один root на выбранный каталог позволяет сохранить дерево целиком.
roots.set(rootName, { id: crypto.randomUUID(), kind: 'folder', name: rootName,
path: rootName, children: [], expanded: false });
}
const root = roots.get(rootName);
// Вставляет узел по его пути, создавая отсутствующие промежуточные папки.
const addToFolder = (node) => {
let current = root;
const nodeParts = node.path.split('/').slice(1);
nodeParts.forEach((part, index) => {
const last = index === nodeParts.length - 1;
let child = current.children.find((item) => item.name === part);
if (!child) {
child = last ? node : { id: crypto.randomUUID(), kind: 'folder', name: part,
path: `${rootName}/${nodeParts.slice(0, index + 1).join('/')}`,
children: [], expanded: false };
current.children.push(child);
}
current = child;
});
};
if (lowerPath.endsWith('.zip')) {
try {
// ZIP rebased на имя выбранной папки перед вставкой в общий root.
const zip = await listZipFiles(file, cfg.allowedExt, cfg.limits);
if (zip) {
rebaseTree(zip, rootName);
addToFolder(zip);
}
} catch (error) {
// Ошибка одного ZIP не должна терять остальные файлы каталога.
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))) {
addToFolder({ id: crypto.randomUUID(), kind: 'file', name: parts.at(-1),
path: `${rootName}/${relativePath}`, file, children: [], expanded: true });
}
}
// Дедупликация выполняется после сборки каждого корня каталога.
roots.forEach((root) => addFileWithDedup(state, root));
// Сброс value позволяет выбрать тот же каталог повторно.
elements.folderInputEl.value = '';
render(state, elements, cfg);
} finally {
state.busy = false;
}
};
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Добавляет префикс корневой папки к каждому пути дерева.
*
* @param {{path: string, file?: File, children: Array}} root Узел дерева.
* @param {string} prefix Путь выбранной папки, в которую попал узел.
* @returns {object} Тот же узел после изменения путей.
*
* Для leaf-файлов создаётся новый File с тем же базовым именем; логический путь
* хранится отдельно в узле дерева.
*/
export function rebaseTree(root, prefix) {
root.path = `${prefix}/${root.path}`;
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;
}
+124
View File
@@ -0,0 +1,124 @@
import { esc } from './esc.js';
import { fs } from './fs.js';
const DEFAULT_RENDER_CONFIG = {
labels: {
statusReady: 'готов',
remove: 'Удалить',
empty: 'Нет выбранных файлов',
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
count: (count, bytes) => `${count} файлов · ${fs(bytes)}`,
},
layout: { columns: ['path', 'size', 'status'] },
};
function renderConfig(cfg = {}) {
return {
labels: {
...DEFAULT_RENDER_CONFIG.labels,
...(cfg.labels || {}),
columns: {
...DEFAULT_RENDER_CONFIG.labels.columns,
...(cfg.labels?.columns || {}),
},
},
layout: { ...DEFAULT_RENDER_CONFIG.layout, ...(cfg.layout || {}) },
};
}
/**
* Рендерит один узел дерева и его раскрытых потомков в HTML-строки таблицы.
*
* @param {{kind: string, id: string, name: string, path: string, file?: File,
* children: Array, expanded: boolean, error?: string}} node Узел file/folder/zip.
* @param {number} depth Глубина узла; используется для визуального отступа.
* @returns {string} HTML одной строки или поддерева строк.
*
* Имена и пути проходят через esc до вставки в innerHTML. Группа получает
* кнопку раскрытия, leaf-файл — размер, статус и data-path для будущего API статусов.
*/
function renderNode(node, depth, cfg) {
const isGroup = node.kind !== 'file';
const padding = depth * 4;
const name = esc(node.name);
const action = isGroup
? `<button class="tree-name tree-group tree-${node.kind}" style="padding-left:${padding}ch" data-toggle="${node.id}" `
+ `aria-expanded="${node.expanded}"><span class="tree-chevron">${node.expanded ? '▼' : '▶'}</span>${name}</button>`
: `<span class="tree-name" style="padding-left:${padding}ch">${name}</span>`;
const remove = `<button class="remove-btn" type="button" data-remove="${node.id}" aria-label="${esc(cfg.labels.remove)} ${name}">×</button>`;
const pathAttribute = node.kind === 'file' ? ` data-path="${esc(node.path)}"` : '';
const cells = {
path: action,
size: node.kind === 'file' ? fs(node.file.size) : '',
status: node.error ? esc(node.error) : node.kind === 'file' ? esc(cfg.labels.statusReady) : '',
};
const columns = cfg.layout.columns?.length
? cfg.layout.columns
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
const row = `<tr class="tree-row tree-${node.kind}"${pathAttribute}>`
+ columns.map((column) => `<td>${cells[column] || ''}</td>`).join('')
+ `<td>${remove}</td></tr>`;
if (!isGroup || !node.expanded) return row;
// Дочерние строки добавляются только для раскрытой группы.
return row + node.children.map((child) => renderNode(child, depth + 1, cfg)).join('');
}
/**
* Полностью синхронизирует DOM таблицы и счётчик с текущим state.nodes.
*
* @param {{nodes: Array}} state Состояние дерева.
* @param {{tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-вывод.
* @returns {void}
*
* Обход для счётчика независим от раскрытия: свернутая группа всё равно входит
* в количество leaf-файлов и суммарный размер.
*/
export function render(state, elements, cfg) {
const normalized = renderConfig(cfg);
const columns = normalized.layout.columns?.length
? normalized.layout.columns
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
elements.tableBodyEl.innerHTML = state.nodes.length
? state.nodes.map((node) => renderNode(node, 0, normalized)).join('')
: `<tr><td colspan="${columns.length + 1}">${esc(normalized.labels.empty)}</td></tr>`;
const files = [];
// Собирает все leaf-файлы, включая содержимое свернутых групп.
const visit = (node) => {
if (node.kind === 'file') files.push(node);
else node.children.forEach(visit);
};
state.nodes.forEach(visit);
const totalBytes = files.reduce((sum, node) => sum + node.file.size, 0);
elements.countEl.textContent = normalized.labels.count(files.length, totalBytes);
}
/**
* Ищет узел по уникальному id и возвращает также массив его непосредственного родителя.
*
* @param {Array} nodes Массив узлов текущего уровня.
* @param {string} id Идентификатор, созданный через crypto.randomUUID().
* @returns {{node: object, nodes: Array}|null} Узел и контейнер для удаления либо null.
*/
export function findNode(nodes, id) {
for (const node of nodes) {
if (node.id === id) return { node, nodes };
const found = findNode(node.children || [], id);
if (found) return found;
}
return null;
}
/**
* Преобразует иерархическое дерево в плоский список leaf-файлов.
*
* @param {Array} nodes Узлы любого уровня.
* @param {Array} result Внутренний аккумулятор рекурсивного вызова.
* @returns {Array<{path: string, name: string, size: number, file: File}>} Файлы для приложения.
*/
export function flattenFiles(nodes, result = []) {
nodes.forEach((node) => {
if (node.kind === 'file') result.push({ path: node.path, name: node.name, size: node.file.size, file: node.file });
else flattenFiles(node.children, result);
});
return result;
}
+78
View File
@@ -0,0 +1,78 @@
/**
* putToVm — отправка одного файла в буфер методом HTTP PUT (сырое бинарное тело).
*
* @param {Blob|File} file Файл для отправки.
* @param {string} url Полный целевой URL в буфере.
* @param {Object} options Опции отправки:
* @param {function(number, number, number): void} [options.onProgress] Колбэк прогресса (pct, loaded, total).
* @param {function(XMLHttpRequest): void} [options.onXHR] Колбэк регистрации XHR для прямого контроля.
* @param {AbortSignal} [options.signal] Сигнал прерывания запроса (AbortController.signal).
* @param {number} [options.timeoutMs=300000] Таймаут в миллисекундах (по умолчанию 300с).
* @returns {Promise<void>} Разрешается при ответе 2xx, отклоняется при ошибке или прерывании.
*/
export function putToVm(file, url, options = {}) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
if (options.onXHR) {
options.onXHR(xhr);
}
const abortHandler = () => {
xhr.abort();
reject(new DOMException('Upload aborted', 'AbortError'));
};
if (options.signal) {
if (options.signal.aborted) {
reject(new DOMException('Upload aborted', 'AbortError'));
return;
}
options.signal.addEventListener('abort', abortHandler, { once: true });
}
xhr.open('PUT', url);
xhr.timeout = options.timeoutMs || 300000;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && options.onProgress) {
const pct = Math.round((e.loaded / e.total) * 100);
options.onProgress(pct, e.loaded, e.total);
}
};
xhr.onload = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(`Буфер: HTTP ${xhr.status} ${xhr.statusText}`));
}
};
xhr.onerror = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
reject(new Error('Сетевая ошибка при отправке в буфер'));
};
xhr.ontimeout = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
reject(new Error(`Таймаут ожидания ответа буфера (${Math.round(xhr.timeout / 1000)}с)`));
};
xhr.onabort = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
reject(new DOMException('Upload aborted', 'AbortError'));
};
xhr.send(file);
});
}
+188
View File
@@ -0,0 +1,188 @@
/**
* uploadViaVM — Слой 2 (Фронтенд): пофайловый транзит через ВМ-буфер в сессию бэкенда.
*
* Архитектура потокового транзита:
* Для каждого файла k:
* 1. Браузер отправляет файл k методом PUT в буфер на ВМ.
* 2. Браузер сразу делает POST /api/upload_refs для одного файла k.
* 3. Бэкенд забирает файл исходящим GET в память RAM и сразу шлёт DELETE в буфер.
* 4. Файл удалён из буфера ВМ, память чиста.
* 5. Браузер переходит к файлу k+1.
*/
import { putToVm } from './put_to_vm.js';
import { fs } from '../table/fs.js';
function generateUuid() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* Нормализует элемент списка файлов (поддерживает как File, так и объект из FilePicker.getFiles()).
*/
function normalizeFileEntry(item) {
if (item instanceof File || item instanceof Blob) {
return { name: item.name || 'unnamed', size: item.size, file: item, path: item.name || '' };
}
if (item && item.file) {
return {
name: item.name || item.file.name || 'unnamed',
size: item.size ?? item.file.size ?? 0,
file: item.file,
path: item.path || item.name || '',
};
}
throw new Error('Некорректный объект файла');
}
/**
* Выполняет пофайловую транзитную загрузку файлов в сессию бэкенда через буфер.
*
* @param {Array<File|{name: string, size: number, file: File, path?: string}>} files Список файлов.
* @param {Object} options Параметры загрузки:
* @param {string} options.vmUploadUrl Базовый URL буфера (обязателен).
* @param {string} [options.backendUploadUrl='/api/upload_refs'] URL эндпоинта приёма ссылок.
* @param {string} [options.session=''] Идентификатор существующей сессии (если есть).
* @param {AbortSignal} [options.signal] Сигнал отмены загрузки.
* @param {function(string): void} [options.onStatus] Колбэк общего текстового статуса.
* @param {function(number, string): void} [options.onFileStatus] Колбэк обновления статуса конкретного файла (индекс, текст/html).
* @param {function(Object): void} [options.onProgress] Колбэк прогресса текущего файла ({ index, total, pct, speed }).
* @param {function(Object): void} [options.onFileComplete] Колбэк успешной доставки файла ({ index, name, session, totalAdded }).
* @returns {Promise<{ok: boolean, session?: string, count?: number, error?: string, aborted?: boolean}>}
*/
export async function uploadViaVM(files, options = {}) {
if (!options.vmUploadUrl) {
return { ok: false, error: 'Параметр options.vmUploadUrl обязателен' };
}
const normalizedFiles = Array.from(files).map(normalizeFileEntry);
const total = normalizedFiles.length;
if (total === 0) {
return { ok: true, session: options.session || '', count: 0 };
}
const backendUrl = options.backendUploadUrl || (options.apiBase || '') + '/api/upload_refs';
const vmBase = options.vmUploadUrl.endsWith('/') ? options.vmUploadUrl : `${options.vmUploadUrl}/`;
let currentSession = options.session || '';
let totalAdded = 0;
for (let k = 0; k < total; k++) {
if (options.signal?.aborted) {
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
}
const { name, size, file } = normalizedFiles[k];
const fileKey = `${generateUuid()}_${k}`;
const fileUrl = `${vmBase}${fileKey}`;
if (options.onStatus) {
options.onStatus(`Загрузка в буфер (${k + 1}/${total}): ${name}`);
}
if (options.onFileStatus) {
options.onFileStatus(k, '⏳ В буфер...');
}
// Этап 1: Отправка одного файла в буфер
const t0 = performance.now();
try {
await putToVm(file, fileUrl, {
signal: options.signal,
onXHR: options.onXHR,
onProgress(pct, loaded, fileTotal) {
const elapsed = (performance.now() - t0) / 1000;
const speed = elapsed > 0 ? (loaded / elapsed) : 0;
if (options.onFileStatus) {
options.onFileStatus(k, `⏳ ${pct}% (${fs(speed)}/s)`);
}
if (options.onProgress) {
options.onProgress({ index: k, total, pct, speed, loaded, fileTotal, name });
}
},
});
} catch (err) {
if (options.signal?.aborted) {
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
}
if (options.onFileStatus) {
options.onFileStatus(k, '✗ Ошибка буфера');
}
return {
ok: false,
error: `Ошибка отправки файла ${name} в буфер: ${err.message}`,
failedIndex: k,
session: currentSession,
};
}
// Этап 2: Вызов /api/upload_refs для немедленного переноса файла с буфера в RAM сессии
if (options.onStatus) {
options.onStatus(`Приём сервисом (${k + 1}/${total}): ${name}`);
}
if (options.onFileStatus) {
options.onFileStatus(k, '⚡ В сессию...');
}
try {
const resp = await fetch(backendUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session: currentSession,
files: [{ name, size, url: fileUrl }],
}),
signal: options.signal,
});
if (!resp.ok) {
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
}
const data = await resp.json();
if (!data.ok) {
throw new Error(data.error || 'Бэкенд вернул ошибку');
}
currentSession = data.session || currentSession;
totalAdded = data.count ?? (totalAdded + 1);
if (options.onFileStatus) {
options.onFileStatus(k, '✓ Доставлен');
}
if (options.onFileComplete) {
options.onFileComplete({
fileIndex: k,
name,
size,
session: currentSession,
totalAdded,
});
}
} catch (err) {
if (options.signal?.aborted) {
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
}
if (options.onFileStatus) {
options.onFileStatus(k, '✗ Ошибка приёма');
}
return {
ok: false,
error: `Ошибка приёма файла ${name} бэкендом: ${err.message}`,
failedIndex: k,
session: currentSession,
};
}
}
if (options.onStatus) {
options.onStatus(`Завершено. Успешно передано файлов: ${total}`);
}
return { ok: true, session: currentSession, count: totalAdded };
}
+125
View File
@@ -0,0 +1,125 @@
// Построить дерево 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<object|null>} Дерево или 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<object|null>} Корневой 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 });
}