Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
112c84f11a | ||
|
|
0ea7ba058c | ||
|
|
6586b5d893 | ||
|
|
017bd8c354 | ||
|
|
f6b2f6d1b3 | ||
|
|
a0bfd9f0e3 | ||
|
|
3d227f1daa | ||
|
|
aec8ad4fbe | ||
|
|
3bd8ee2b23 | ||
|
|
e4b4dca53a | ||
|
|
13ea0efcd7 | ||
|
|
edf1de5aa2 | ||
|
|
5a0f82b75e | ||
|
|
23c1c86968 | ||
|
|
8df4d22cfb | ||
|
|
5d44c836e7 | ||
|
|
1e305485be | ||
|
|
eda5263d8b | ||
|
|
317526b246 | ||
|
|
8f775e34a9 | ||
|
|
6fea5bce49 | ||
|
|
a965f181d9 | ||
|
|
85605fe8c6 | ||
|
|
c0b87880ae |
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
@@ -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__` и любых каталогов не выполнялось.
|
||||
@@ -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-дефектами. Исправления кода в рамках этой записи не
|
||||
выполнялись.
|
||||
@@ -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.
|
||||
@@ -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-а не изменялся.
|
||||
@@ -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`, сохранение имён и размеров, массовую загрузку и
|
||||
отображение документов на русском, английском и смешанном наборе имён.
|
||||
@@ -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,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,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,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 минуты.
|
||||
- Фиксация коммитом и пуш.
|
||||
@@ -1,5 +1,18 @@
|
||||
# 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).
|
||||
>
|
||||
> Весь текст ниже сохранён только как исторический план и не должен использоваться
|
||||
> для новых изменений без отдельного пересмотра требований.
|
||||
|
||||
> Целевой исполнитель: 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 +77,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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"],
|
||||
"vmUploadUrl": "/mock-buffer/",
|
||||
"vmUploadPrefix": "/mock-buffer/"
|
||||
}
|
||||
Vendored
+1194
File diff suppressed because it is too large
Load Diff
Vendored
+1218
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Generated
+480
@@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "upload-platform-file-picker",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"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
@@ -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
@@ -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.0"
|
||||
|
||||
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)
|
||||
|
||||
+49
-2
@@ -1,8 +1,55 @@
|
||||
/* Базовые цвета и типографика всего 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; }
|
||||
/* Горизонтальный 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%; }
|
||||
}
|
||||
Vendored
+1
@@ -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&<(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&>(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&<(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});
|
||||
+110
-5
@@ -3,13 +3,118 @@
|
||||
<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 & 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="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');
|
||||
|
||||
let abortCtrl = null;
|
||||
let selectedFiles = [];
|
||||
|
||||
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();
|
||||
|
||||
const vmUploadUrl = new URL({{ config.vmUploadUrl|tojson }}, window.location.origin).href;
|
||||
|
||||
const res = await FilePicker.uploadViaVM(files, {
|
||||
vmUploadUrl: vmUploadUrl,
|
||||
backendUploadUrl: '/api/upload_refs',
|
||||
signal: abortCtrl.signal,
|
||||
onStatus: (msg) => { uploadStatus.textContent = msg; },
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
cancelBtn.style.display = 'none';
|
||||
uploadBtn.disabled = selectedFiles.length === 0;
|
||||
|
||||
if (res.ok) {
|
||||
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) {
|
||||
uploadStatus.innerHTML = '<span style="color:#8c5148;">Загрузка отменена пользователем</span>';
|
||||
} else {
|
||||
uploadStatus.innerHTML = '<span style="color:#b32f22; font-weight: bold;">✗ Ошибка: ' + res.error + '</span>';
|
||||
}
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
if (abortCtrl) abortCtrl.abort();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -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)
|
||||
@@ -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) == ""
|
||||
@@ -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)
|
||||
@@ -0,0 +1,233 @@
|
||||
// Юнит-тесты слоя 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);
|
||||
});
|
||||
@@ -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)
|
||||
@@ -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)`.
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Переиспользуемые слои загрузки файлов.
|
||||
|
||||
Пакет не выполняет импортов и не запускает побочных действий при загрузке.
|
||||
Рабочая picker-only логика находится в подпакетах ``frontend`` и ``zip``.
|
||||
"""
|
||||
@@ -0,0 +1 @@
|
||||
"""Backend переиспользуемого модуля загрузки: upload_refs + session."""
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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/"
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"]
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[character]));
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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: true });
|
||||
}
|
||||
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: true };
|
||||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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: true };
|
||||
}
|
||||
|
||||
/** Вставляет 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user