Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1750a8497f | ||
|
|
09016b22e8 | ||
|
|
523b52a26c | ||
|
|
67a4caa43c | ||
|
|
05a035471d | ||
|
|
bfe487164f | ||
|
|
df0576aebd | ||
|
|
f7687d3f12 | ||
|
|
2b202795b8 | ||
|
|
5e2c90f8db | ||
|
|
8316e19725 | ||
|
|
860cdd4214 | ||
|
|
fca6f6aafb | ||
|
|
87de22306f | ||
|
|
8768fd96ec | ||
|
|
112c84f11a |
@@ -0,0 +1,17 @@
|
||||
# 2026-09-06: правило документирования и частых коммитов
|
||||
|
||||
## Подтверждённое правило работы
|
||||
|
||||
Пользователь явно потребовал:
|
||||
|
||||
- документировать все обнаруженные факты, решения и результаты сразу;
|
||||
- делать отдельный коммит после каждой правки или логического изменения;
|
||||
- не накапливать несколько независимых изменений до одного общего коммита.
|
||||
|
||||
## Результат текущего шага
|
||||
|
||||
- UX-правка таймера и пояснения загрузки зафиксирована коммитом `87de223`.
|
||||
- Коммит `87de223` успешно отправлен в `origin/master`.
|
||||
- На момент документирования локальная ветка `master` синхронизирована с `origin/master`.
|
||||
|
||||
Это правило применяется к дальнейшей работе в проекте upload-platform.
|
||||
@@ -0,0 +1,14 @@
|
||||
# 2026-09-06: план дополнительных hardening-тестов
|
||||
|
||||
После полного прогона `0.2.2` выявлены тестовые пробелы, которые не покрывались штатными 10 Python и 10 Node тестами:
|
||||
|
||||
- границы `MAX_FILE_BYTES` (ровно лимит и лимит + 1 байт);
|
||||
- превышение фактического размера файла после pull при заниженном заявленном размере;
|
||||
- граница `MAX_SESSION_BYTES`;
|
||||
- retry pull после временного HTTP-сбоя;
|
||||
- удаление сессии по TTL;
|
||||
- удаление файла из буфера при пропуске по лимиту;
|
||||
- отмена frontend после частично завершённого пофайлового транзита;
|
||||
- повторный выбор/двойной запуск picker без параллельной обработки.
|
||||
|
||||
Первым шагом добавляются backend hardening-тесты для лимитов, retry, TTL и очистки буфера. После каждого логического изменения выполняется отдельный коммит и проверка.
|
||||
@@ -0,0 +1,39 @@
|
||||
# 2026-09-06: результат дополнительных hardening-тестов
|
||||
|
||||
## Добавленные тесты
|
||||
|
||||
Backend:
|
||||
|
||||
- точное достижение `MAX_SESSION_BYTES` и отказ при переполнении;
|
||||
- отказ при фактическом размере файла больше `MAX_FILE_BYTES`, даже если заявленный размер допустим;
|
||||
- обязательное удаление oversize-файла из буфера;
|
||||
- retry pull после временного HTTP `503`;
|
||||
- удаление файла после успешного pull;
|
||||
- автоматическое удаление RAM-сессии по короткому TTL.
|
||||
|
||||
Frontend:
|
||||
|
||||
- отмена после первого успешно доставленного файла;
|
||||
- подтверждение, что второй `PUT` не начинается;
|
||||
- подтверждение частичного результата и `aborted: true`;
|
||||
- проверка callback статуса доставленного первого файла.
|
||||
|
||||
## Результаты
|
||||
|
||||
- hardening backend: `4/4` PASS;
|
||||
- полный Node suite: `11/11` PASS;
|
||||
- полный Python suite: `14/14` PASS;
|
||||
- syntax sweep: `22` Python-файла и `13` JavaScript-файлов PASS;
|
||||
- frontend build: PASS;
|
||||
- runtime `/health`: `ok`;
|
||||
- runtime `/`: HTTP 200, `10035` байт;
|
||||
- `git diff --check`: PASS.
|
||||
|
||||
## Исправленные ошибки тестового harness
|
||||
|
||||
- Для backend pull использован корректный `httpx.WSGITransport`.
|
||||
- Конфигурационный ключ лимита приведён к API-формату `maxFileBytes`.
|
||||
- Timing partial-cancel теста синхронизирован с первым `PUT`.
|
||||
- Assertion статуса приведён к фактическому callback-тексту `✓ Доставлен`.
|
||||
|
||||
Production-код не изменялся, поэтому версия остаётся `0.2.2`.
|
||||
@@ -0,0 +1,37 @@
|
||||
# 2026-09-06: группы слоя 1 по умолчанию свёрнуты
|
||||
|
||||
## Запрос
|
||||
|
||||
При большом количестве файлов в папках и ZIP раскрытая таблица становится неудобной: все файлы сразу занимают много строк, и пользователь вынужден вручную сворачивать группы.
|
||||
|
||||
Требование: папки и ZIP должны быть свёрнуты по умолчанию, с возможностью раскрывать каждую группу отдельно.
|
||||
|
||||
## Найденная причина
|
||||
|
||||
Рендер слоя 1 уже поддерживал `expanded === false`: дочерние строки не выводятся, а кнопка группы показывает стрелку раскрытия. Проблема находилась в создании узлов:
|
||||
|
||||
- корень ZIP и вложенные папки создавались с `expanded: true`;
|
||||
- корни выбранных папок и промежуточные папки также создавались с `expanded: true`.
|
||||
|
||||
## План исправления
|
||||
|
||||
- сделать `expanded: false` только для групп `folder` и `zip`;
|
||||
- оставить leaf-файлы обычными строками;
|
||||
- сохранить независимое ручное раскрытие групп;
|
||||
- добавить регрессионный тест рендера свернутой группы.
|
||||
|
||||
## Результат
|
||||
|
||||
- узлы `folder` и `zip` теперь создаются с `expanded: false`;
|
||||
- leaf-файлы доступны после раскрытия родительской группы;
|
||||
- ручное раскрытие каждой группы сохранено;
|
||||
- добавлен регрессионный тест рендера свернутой папки;
|
||||
- пересобраны ESM и IIFE-бандлы в `dist/`.
|
||||
|
||||
## Проверка
|
||||
|
||||
- `npm run build` выполнен успешно;
|
||||
- `npm test`: `10/10` тестов пройдены;
|
||||
- `python3 -m pytest tests/ -q`: `10/10` тестов пройдены;
|
||||
- `python3 -m py_compile site/app.py` выполнен успешно;
|
||||
- `git diff --check` без замечаний.
|
||||
@@ -0,0 +1,52 @@
|
||||
# 2026-09-06: Финальная фиксация состояния Слоя 2
|
||||
|
||||
## Репозиторий
|
||||
|
||||
- Проект: `upload-platform`.
|
||||
- Основная ветка: `master`.
|
||||
- Рабочее дерево на момент фиксации чистое.
|
||||
- Основной коммит реализации: `112c84f`.
|
||||
- Изменения отправлены в `origin/master`.
|
||||
- Контрольное состояние сохранено в ветке `checkpoint-layer2-complete`, опубликованной в origin.
|
||||
|
||||
## Реализованное состояние
|
||||
|
||||
В `upload-platform` реализованы и изолированы два API-слоя:
|
||||
|
||||
1. **Слой 1: File Picker**
|
||||
- Выбор файлов и папок.
|
||||
- Клиентская распаковка ZIP и вложенных архивов.
|
||||
- Дедупликация, фильтрация и лимиты.
|
||||
- Контракт: `picker.getFiles()` возвращает плоский список объектов `{ path, name, size, file }`.
|
||||
|
||||
2. **Слой 2: Per-file Transit**
|
||||
- Для каждого файла выполняется последовательность `PUT` в буфер ВМ -> `POST /api/upload_refs` -> исходящий потоковый `GET` в RAM сессии -> `DELETE` из буфера ВМ.
|
||||
- Следующий файл начинает передаваться только после завершения текущего.
|
||||
- В буфере ВМ одновременно находится не более одного файла.
|
||||
- Файлы сессии хранятся только в оперативной памяти.
|
||||
- URL буфера задаются конфигурацией через `vmUploadUrl` и `vmUploadPrefix`; боевые домены не зашиваются в интеграционный код.
|
||||
- Входящие URL проходят SSRF-проверку, имена файлов санитизируются.
|
||||
- Для будущего Слоя 3 предусмотрен callback `onFileReceived(sid, name, content)`.
|
||||
|
||||
## Автономный тестовый стенд
|
||||
|
||||
`site/app.py` содержит RAM-only mock WebDAV с операциями `PUT`, `GET`, `DELETE` и маршрутом статуса. Это позволяет тестировать полный транзит без внешней ВМ:
|
||||
|
||||
`browser -> mock buffer -> upload_refs -> RAM session -> Layer 3 callback`.
|
||||
|
||||
## Проверка
|
||||
|
||||
- `npm run build`: успешно.
|
||||
- `npm test`: 9 тестов пройдены.
|
||||
- `pytest tests/ -v`: 10 тестов пройдены.
|
||||
- Итого: 19 тестов, все пройдены.
|
||||
- Приложение `site/app.py` импортируется и регистрирует 11 маршрутов.
|
||||
|
||||
## Версия и документация
|
||||
|
||||
- Версия платформы: `0.2.0`.
|
||||
- Обновлены `README.md` и `upload/README.md`.
|
||||
- Техническое задание: `LAYER2-RESUME.md`.
|
||||
- Подробный отчёт реализации: `HISTORY/2026-09-06-layer2-implementation-and-tests.md`.
|
||||
|
||||
Слой 3 в текущую реализацию не входит; он подключается потребляющим сервисом через API сессии и callback-контракт.
|
||||
@@ -0,0 +1,69 @@
|
||||
# 2026-09-06: Реализация и верификация Слоя 2 (пофайловый транзит через RAM)
|
||||
|
||||
## 1. Контекст задачи
|
||||
В рамках репозитория `upload-platform` выполнена реализация Слоя 2 (транзитная доставка файлов в сессию бэкенда через внешний буфер) поверх завершённого Слоя 1 (File Picker v0.1.13) с поднятием версии платформы до `0.2.0`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Ключевые архитектурные решения и реализация
|
||||
|
||||
### 1. Пофайловый транзит (Per-File Transit)
|
||||
Вместо накопления всей пачки файлов на ВМ реализован потоковый пофайловый цикл:
|
||||
1. Браузер берёт файл $k$ и отправляет его методом `PUT` в буфер на ВМ (`putToVm` через XHR со стримингом прогресса и поддержкой `AbortSignal`).
|
||||
2. Браузер сразу делает `POST /api/upload_refs` только для этого одного файла $k$.
|
||||
3. Бэкенд забирает файл исходящим потоковым `GET` (`httpx.stream`) прямо в оперативную память сессии (`upload/backend/session`).
|
||||
4. Бэкенд немедленно отправляет `DELETE` на буфер ВМ.
|
||||
5. Файл на ВМ удалён, буфер чист. Браузер переходит к файлу $k+1$.
|
||||
- **Результат**: в буфере на ВМ в любой момент времени находится максимум один файл; объем потребляемой буфером памяти минимален.
|
||||
|
||||
### 2. Хранение строго в оперативной памяти (RAM-only)
|
||||
- На бэкенде: файлы сессии хранятся в RAM-структуре `_sessions` (`upload/backend/session/state.py`). Никаких временных файлов на диске.
|
||||
- Защита памяти: настраиваемые лимиты `MAX_FILE_BYTES` (по умолчанию 50 МБ) и `MAX_SESSION_BYTES` (по умолчанию 500 МБ).
|
||||
- Автоматическая очистка: TTL-таймер (по умолчанию 30 мин), функции `touch`, `pause_ttl`, `resume_ttl`, `cleanup`.
|
||||
|
||||
### 3. Защита и параметры (Zero Hardcode & Security)
|
||||
- **SSRF-защита**: `create_upload_refs_blueprint` валидирует входящие URL по префиксу `vmUploadPrefix` (поддерживаются абсолютные URL для прода и относительные для локального мока). Запросы к сторонним хостам отсекаются.
|
||||
- **Path Traversal защита**: модуль `safe_name` нормализует пути, запрещает `..` и сохраняет безопасные относительные подпапки.
|
||||
- **Мягкая отмена**: поддержка `AbortSignal` на фронтенде и `threading.Event` на бэкенде (`request_cancel`, `get_cancel_event`).
|
||||
- **Слой 3 (эмуляция/интеграция)**: в `create_upload_refs_blueprint` добавлен колбэк `onFileReceived(sid, name, content)`, вызываемый при успешной доставке файла в RAM.
|
||||
|
||||
### 4. Автономный mock-буфер и тестовый стенд
|
||||
- В `site/app.py` встроен in-memory mock WebDAV (`PUT`, `GET`, `DELETE` по пути `/mock-buffer/<key>`), хранящий данные в RAM.
|
||||
- Позволяет запускать тесты и локальный demo-стенд на 100% автономно без доступа к внешней сети или боевой ВМ.
|
||||
- В шаблоне `site/templates/index.html` добавлена панель запуска Слоя 2, отображение прогресса пофайловой передачи и просмотр файлов, сохранённых в RAM сессии.
|
||||
|
||||
---
|
||||
|
||||
## 3. Автоматическое тестирование
|
||||
|
||||
Создан полный набор автоматических тестов (19 тестов, 100% PASS):
|
||||
|
||||
1. **Бэкенд тесты (`pytest tests/ -v`, 10 тестов, 0.99s)**:
|
||||
- `test_safe_name_simple` — проверка корректных путей и слэшей.
|
||||
- `test_safe_name_traversal` — отсечение атак `..`, абсолютных путей, пустых строк.
|
||||
- `test_session_lifecycle` — полный жизненный цикл сессии (создание, добавление, чтение, TTL, отмена, cleanup).
|
||||
- `test_session_limits` — проверка ограничения суммарного размера сессии в RAM.
|
||||
- `test_upload_refs_pull_and_delete` — проверка pull по исходящему GET, удаления из буфера, вызова колбэка Слоя 3.
|
||||
- `test_upload_refs_ssrf_protection` — блокировка попыток pull с недоверенных хостов (SSRF).
|
||||
- `test_health` — liveness-проверка платформы.
|
||||
- `test_index_page` — проверка отдачи UI стенда.
|
||||
- `test_mock_buffer_crud` — операции PUT, GET, DELETE и статус mock-буфера.
|
||||
- `test_full_transit_flow_mock` — сквозной тест пофайлового транзита от mock-буфера до сессии Flask.
|
||||
|
||||
2. **Фронтенд тесты (`npm test`, `node:test`, 9 тестов, 329ms)**:
|
||||
- `putToVm: успешная отправка` — проверка корректности HTTP PUT с сырым бинарным телом.
|
||||
- `putToVm: ошибка HTTP статуса` — обработка ответов 4xx/5xx.
|
||||
- `putToVm: сетевая ошибка` — обработка XHR onerror.
|
||||
- `putToVm: таймаут` — обработка XHR ontimeout.
|
||||
- `putToVm: прерывание через AbortSignal` — мгновенный abort текущего XHR.
|
||||
- `uploadViaVM: пофайловый транзит` — проверка, что $N$ файлов вызывают $N$ парных запросов PUT + upload_refs последовательно, передавая `session_id`.
|
||||
- `uploadViaVM: поддержка формата FilePicker.getFiles()` — совместимость со Слой 1.
|
||||
- `uploadViaVM: обработка ошибки PUT` — остановка конвейера и возврат ошибки.
|
||||
- `uploadViaVM: прерывание через signal` — отмена всего пофайлового цикла.
|
||||
|
||||
---
|
||||
|
||||
## 4. Версионирование и сборка
|
||||
- Версия поднята с `0.1.13` до `0.2.0` в `package.json` и `site/app.py`.
|
||||
- Собраны актуальные дистрибутивные бандлы `dist/file-picker.esm.js` (40.1 KB) и `dist/file-picker.iife.js` (43.4 KB).
|
||||
- Документация в `README.md` и `upload/README.md` полностью обновлена с пошаговой инструкцией интеграции в `drhider` и `contractor`.
|
||||
@@ -0,0 +1,26 @@
|
||||
# 2026-09-06: UX-индикация ожидания первого файла
|
||||
|
||||
## Наблюдение
|
||||
|
||||
При начале загрузки пользователь мог увидеть длительное отсутствие визуального прогресса на первом файле. Причина: первый PUT ещё не успел вызвать событие `XHR upload.onprogress`, а цепочка дополнительно выполняет первичное установление соединений и передачу файла в буфер ВМ.
|
||||
|
||||
Это не является зависанием, но без пояснения пользователь может нажать «Отмена».
|
||||
|
||||
## Изменение
|
||||
|
||||
В demo `site/templates/index.html` добавлены:
|
||||
|
||||
- отдельный блок текущей фазы загрузки;
|
||||
- пояснение, что выполняются соединение с буфером ВМ, PUT, перенос в RAM и очистка буфера;
|
||||
- таймер `Прошло: MM:SS`, запускающийся до первого сетевого ответа;
|
||||
- остановка таймера при завершении, ошибке или отмене;
|
||||
- сообщение о том, что уже переданные до отмены файлы остаются в RAM сессии.
|
||||
|
||||
В `PLAN.md` добавлен TODO следующей UX-правки: отдельные этапы `PUT`, `GET`, `DELETE`, прогресс пачки, обратная связь при ретраях и проверка мобильного сценария.
|
||||
|
||||
## Проверка
|
||||
|
||||
- `node build.mjs` выполнен успешно.
|
||||
- `node --test tests/test_upload_layer2.test.mjs`: 9 тестов пройдены.
|
||||
- `python3 -m pytest tests/ -v`: 10 тестов пройдены.
|
||||
- Версия приложения повышена до `0.2.1` в `package.json` и `site/app.py`.
|
||||
@@ -0,0 +1,37 @@
|
||||
# 2026-09-06: полный тест релиза 0.2.2
|
||||
|
||||
## Релиз
|
||||
|
||||
Проверен коммит `f7687d3` с версией `0.2.2`. Локальная ветка на момент проверки совпадала с `origin/master`.
|
||||
|
||||
## Результаты
|
||||
|
||||
- `npm run build` — успешно;
|
||||
- `npm test` — `10/10` Node-тестов пройдены;
|
||||
- `python3 -m pytest tests/ -v` — `10/10` Python-тестов пройдены;
|
||||
- `python3 -m py_compile site/app.py` — успешно;
|
||||
- `node --version` — `v18.19.1`;
|
||||
- `python` — `3.12.3`;
|
||||
- `git diff --check` — без замечаний;
|
||||
- запуск `python3 site/app.py` — успешный, Flask слушает `0.0.0.0:5000`;
|
||||
- живой `GET /health` — HTTP 200, тело `ok`;
|
||||
- живой `GET /` — HTTP 200, получено `10035` байт HTML.
|
||||
|
||||
## Состав Node-тестов
|
||||
|
||||
Проверены:
|
||||
|
||||
- рендер папок по умолчанию в свёрнутом состоянии;
|
||||
- успешный PUT в буфер ВМ;
|
||||
- HTTP-ошибка PUT;
|
||||
- сетевая ошибка;
|
||||
- таймаут;
|
||||
- отмена через `AbortSignal`;
|
||||
- пофайловый транзит Layer 2;
|
||||
- формат файлов `FilePicker.getFiles()`;
|
||||
- ошибка PUT в `uploadViaVM`;
|
||||
- отмена загрузки через signal.
|
||||
|
||||
## Вывод
|
||||
|
||||
Релиз `0.2.2` проходит полный автоматический и локальный runtime-прогон. После документирования запись должна быть отдельным коммитом и отправлена в `origin/master`.
|
||||
@@ -0,0 +1,55 @@
|
||||
# 2026-09-06: жёсткий полный тест релиза 0.2.2
|
||||
|
||||
## Объём проверки
|
||||
|
||||
Проверен опубликованный релиз `0.2.2`, commit `df0576a` на `origin/master`.
|
||||
|
||||
- frontend build;
|
||||
- весь Node test suite;
|
||||
- весь Python test suite с `-vv --tb=long`;
|
||||
- синтаксис всех исходных Python и JavaScript файлов;
|
||||
- runtime запуск Flask;
|
||||
- HTTP endpoints и раздача собранных бандлов;
|
||||
- отрицательные API-сценарии безопасности и целостности транзита.
|
||||
|
||||
## Результаты
|
||||
|
||||
- `npm run build` — PASS;
|
||||
- `npm test` — `10/10` PASS;
|
||||
- `python3 -m pytest tests/ -vv --tb=long` — `10/10` PASS;
|
||||
- `py_compile` — `22` Python-файла PASS;
|
||||
- `node --check` — `13` JavaScript-файлов PASS;
|
||||
- `git diff --check` — PASS;
|
||||
- `/health` — HTTP 200, тело `ok`;
|
||||
- `/` — HTTP 200;
|
||||
- `/file-picker/file-picker.esm.js` — HTTP 200;
|
||||
- `/file-picker/file-picker.iife.js` — HTTP 200;
|
||||
- `/upload-frontend/index.js` — HTTP 200;
|
||||
- Flask слушает `0.0.0.0:5000`.
|
||||
|
||||
## Отрицательные сценарии
|
||||
|
||||
Отдельный smoke-набор подтвердил:
|
||||
|
||||
- пустой `upload_refs` возвращает HTTP 400;
|
||||
- malformed JSON возвращает HTTP 400;
|
||||
- traversal-имя не попадает в сессию и не удаляет файл из буфера;
|
||||
- SSRF URL вне разрешённого prefix пропускается без pull;
|
||||
- валидный файл создаёт новую RAM-сессию;
|
||||
- файл переносится в RAM;
|
||||
- callback Layer 3 вызывается;
|
||||
- файл удаляется из mock buffer после успешного pull;
|
||||
- повторное чтение удалённого файла возвращает 404.
|
||||
|
||||
## Исправления harness во время проверки
|
||||
|
||||
Два первых запуска расширенного smoke-набора остановились в тестовом harness, не в приложении:
|
||||
|
||||
1. Для in-process Flask pull не был задан `httpx.WSGITransport`, поэтому backend получил `Connection refused`.
|
||||
2. Следующий запуск передал несуществующий `session`; API корректно вернул сценарий `Session not found` вместо создания сессии. Новая сессия создаётся при отсутствии поля `session`.
|
||||
|
||||
После исправления harness smoke-набор завершился `strict_negative_smoke=PASS`.
|
||||
|
||||
## Итог
|
||||
|
||||
Релиз `0.2.2` прошёл полный автоматический, синтаксический, runtime и отрицательный security-тест. Код не изменялся, поэтому версию не повышали.
|
||||
@@ -9,6 +9,15 @@
|
||||
> Актуальное описание: [README.md](README.md) и
|
||||
> [upload/README.md](upload/README.md). История перехода на picker-only и
|
||||
> исправлений находится в [HISTORY/2026-09-05-file-picker.md](HISTORY/2026-09-05-file-picker.md).
|
||||
|
||||
## TODO следующей правки: UX загрузки
|
||||
|
||||
- Показывать таймер и понятное пояснение уже до первого сетевого ответа.
|
||||
- Отдельно отображать этапы `PUT в буфер ВМ`, `GET в RAM`, `DELETE из буфера`.
|
||||
- Показывать прогресс пачки: текущий файл, общее количество, уже доставленные файлы.
|
||||
- Не оставлять пользователя без обратной связи при DNS/TLS/CORS-задержке и повторных попытках.
|
||||
- Перед отменой явно сообщать, сколько файлов уже доставлено в RAM и что будет сохранено.
|
||||
- Проверить UX на мобильном экране и при медленном соединении.
|
||||
>
|
||||
> Весь текст ниже сохранён только как исторический план и не должен использоваться
|
||||
> для новых изменений без отдельного пересмотра требований.
|
||||
|
||||
@@ -1,129 +1,196 @@
|
||||
# Upload Platform
|
||||
|
||||
Переиспользуемый browser-only file-picker версии `0.1.13`. Он выбирает файлы,
|
||||
папки и ZIP, строит дерево и возвращает интегрирующему приложению browser
|
||||
`File` objects. Содержимое файлов остаётся в браузере: текущий проект не
|
||||
загружает его на VM, в Flask или в другое хранилище.
|
||||
Модульная платформа загрузки и транзита документов (версия `0.2.0`). Состоит из двух независимых автономных слоёв с чистыми API:
|
||||
|
||||
## Карта файлов
|
||||
1. **Слой 1 (File Picker)**: Клиентский выбор файлов, папок и вложенных ZIP-архивов с дедупликацией, фильтрацией по расширениям, проверкой лимитов и визуализацией в виде дерева.
|
||||
2. **Слой 2 (Transit Upload via VM Buffer)**: Пофайловый потоковый транзит файлов из браузера в оперативную память (RAM) сессии бэкенда через внешний WebDAV-буфер на ВМ. Решает проблему ограничения Ingress Kubernetes на входящий payload (64 КБ) за счёт исходящего pull из кластера.
|
||||
|
||||
### Корень проекта
|
||||
---
|
||||
|
||||
| Путь | Назначение | Статус |
|
||||
|---|---|---|
|
||||
| `package.json` | Версия пакета и команда `npm run build`. | Используется |
|
||||
| `package-lock.json` | Зафиксированные npm-зависимости. | Используется |
|
||||
| `build.mjs` | esbuild-сборка ESM и IIFE bundle. | Используется |
|
||||
| `requirements.txt` | Python-зависимости demo-сервера. | Используется demo |
|
||||
| `config.json` | Конфигурация demo: разрешённые расширения. | Используется demo |
|
||||
| `dist/file-picker.esm.js` | Готовый ESM bundle для интеграции. | Используется |
|
||||
| `dist/file-picker.iife.js` | Готовый IIFE bundle `FilePicker`. | Используется |
|
||||
| `upload/` | Исходники переиспользуемого picker-модуля. | Используется |
|
||||
| `site/` | Flask demo, который раздаёт страницу и bundle. | Используется только demo |
|
||||
| `docs/` | Справочная и историческая документация. | См. раздел LEGACY |
|
||||
| `HISTORY/` | Архив решений, ревью и результатов проверок. | Только история |
|
||||
## Архитектура слоёв и API
|
||||
|
||||
### `upload/` — исходники picker-а
|
||||
```
|
||||
[ Браузер ]
|
||||
│
|
||||
├─► Слой 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 ]
|
||||
```
|
||||
|
||||
| Путь | Назначение |
|
||||
|---|---|
|
||||
| `upload/README.md` | Краткая инструкция интеграции готового bundle. |
|
||||
| `upload/config.example.json` | Пример конфигурации разрешённых расширений. |
|
||||
| `upload/frontend/index.js` | Единственная актуальная точка входа `initFilePicker(config)`, DOM, lifecycle API. |
|
||||
| `upload/frontend/table/add_file_with_dedup.js` | Дедупликация файлов и слияние одинаковых корней. |
|
||||
| `upload/frontend/table/esc.js` | HTML-экранирование имён, путей и атрибутов. |
|
||||
| `upload/frontend/table/fs.js` | Форматирование размеров файлов. |
|
||||
| `upload/frontend/table/on_files_change.js` | Выбор файлов, фильтрация, ZIP-разбор и fallback ошибок. |
|
||||
| `upload/frontend/table/on_folder_change.js` | Выбор папки через `webkitdirectory` и построение дерева. |
|
||||
| `upload/frontend/table/rebase_tree.js` | Добавление префикса пути без изменения базового `File.name`. |
|
||||
| `upload/frontend/table/render.js` | Рендер дерева, счётчик, поиск узлов и `flattenFiles()`. |
|
||||
| `upload/frontend/zip/list_zip_files.js` | Безопасный рекурсивный разбор ZIP через встроенный `fflate`. |
|
||||
### Слой 1: File Picker API
|
||||
- **Вход**: DOM-контейнер (`mount`), разрешённые расширения (`allowedExt`), лимиты (`limits`).
|
||||
- **Выход**: `picker.getFiles()` возвращает плоский массив объектов `{ path, name, size, file }`.
|
||||
- **Автономность**: Не зависит от транспорта и бэкенда.
|
||||
|
||||
### `site/` — demo-обёртка
|
||||
### Слой 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.
|
||||
|
||||
| Путь | Назначение |
|
||||
|---|---|
|
||||
| `site/app.py` | Flask entrypoint, `/`, `/health`, раздача bundle и совместимый маршрут исходников. |
|
||||
| `site/templates/index.html` | Demo-страница и вызов `FilePicker.initFilePicker()`. |
|
||||
| `site/static/style.css` | Стили demo-страницы. |
|
||||
| `site/routes/` | Текущий каталог маршрутов; прикладного upload backend в нём нет. |
|
||||
---
|
||||
|
||||
### `docs/`
|
||||
## Структура репозитория
|
||||
|
||||
| Путь | Назначение | Статус |
|
||||
|---|---|---|
|
||||
| `docs/CODE-REFERENCE.md` | Справочник актуального picker-кода и API. | Использовать |
|
||||
| `docs/PLAN-componentization.md` | Исторический план перехода к bundle API. | LEGACY, не использовать как план |
|
||||
| `docs/sonnet-architecture-review-prompt.md` | Исторический prompt ревью старой архитектуры. | LEGACY, не использовать |
|
||||
| `docs/*-architecture-review-response.md` | Ответы на исторические ревью. | История, не спецификация |
|
||||
```
|
||||
upload-platform/
|
||||
├── 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/
|
||||
│ ├── 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)
|
||||
```
|
||||
|
||||
### `HISTORY/`
|
||||
---
|
||||
|
||||
Файлы `HISTORY/` фиксируют состояние проекта на даты ревью, тестов и решений.
|
||||
Они могут описывать удалённые файлы, VM-upload или старые версии. Это архив,
|
||||
а не инструкция: при расхождении с кодом руководствоваться только исходниками,
|
||||
`README.md`, `upload/README.md` и `docs/CODE-REFERENCE.md`.
|
||||
|
||||
## Локальный запуск
|
||||
## Быстрый старт и запуск
|
||||
|
||||
### 1. Установка зависимостей
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
npm install
|
||||
npm run build
|
||||
python site/app.py
|
||||
# → http://127.0.0.1:5000/
|
||||
```
|
||||
|
||||
Для запуска уже собранного demo достаточно иметь зависимости Python и
|
||||
содержимое `dist/`. `npm run build` требуется после изменения исходников
|
||||
`upload/frontend/`.
|
||||
### 2. Сборка фронтенд-бандлов
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Деплой на Nubes
|
||||
### 3. Запуск локального demo-сервера
|
||||
```bash
|
||||
python site/app.py
|
||||
# Сервер доступен по адресу http://127.0.0.1:5000/
|
||||
```
|
||||
В стенде встроен локальный **in-memory mock-буфер** (`/mock-buffer/`), поэтому demo полностью функционально локально без подключения к боевой ВМ.
|
||||
|
||||
- Точка входа: `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.
|
||||
### 4. Запуск всех тестов
|
||||
```bash
|
||||
# Бэкенд тесты (pytest)
|
||||
pytest tests/ -v
|
||||
|
||||
## Как интегрировать picker в другой проект
|
||||
# Фронтенд тесты (Node.js test runner)
|
||||
npm test
|
||||
```
|
||||
|
||||
1. Скопировать `dist/file-picker.iife.js` или `dist/file-picker.esm.js` в свой проект.
|
||||
2. Подключить IIFE через `<script>` и вызвать `FilePicker.initFilePicker`, либо импортировать ESM-бандл.
|
||||
3. Передать `mount`, `allowedExt`, а при необходимости `labels`, `layout`, `limits` и `onChange`.
|
||||
4. Получить выбранные leaf-файлы через `picker.getFiles()`; каждый элемент содержит
|
||||
`path`, `name`, `size` и исходный `file`.
|
||||
5. После удаления компонента вызвать `picker.destroy()`.
|
||||
---
|
||||
|
||||
Исходники `upload/frontend/` нужны только для разработки и пересборки bundle;
|
||||
встроенная библиотека `fflate` уже включена в готовые bundle.
|
||||
## Инструкция: интеграция в сторонний сервис за 2 минуты
|
||||
|
||||
Разрешённые расширения демо: `.pdf`, `.doc`, `.docx`, `.txt`, `.md`.
|
||||
ZIP-файлы используются как контейнеры и раскрываются в браузере; сам ZIP не
|
||||
возвращается как leaf-файл. По умолчанию действуют ограничения: 1000 entries,
|
||||
100 MiB суммарно, 50 MiB на entry и глубина 20.
|
||||
Чтобы встроить оба слоя в целевой сервис (`drhider`, `contractor` и др.):
|
||||
|
||||
## Legacy
|
||||
### 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/backend/` — старый backend VM-upload, sessions и pull API;
|
||||
- `upload/frontend/table/init_upload_table.js` — старый entry point с готовыми DOM-узлами;
|
||||
- `upload/frontend/table/set_status.js` — неиспользуемый legacy helper;
|
||||
- `upload/frontend/upload/` — старый frontend VM-upload layer.
|
||||
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)
|
||||
```
|
||||
|
||||
Эти части не входят в сборку `build.mjs`, не импортируются текущим picker-ом и
|
||||
не являются частью API. Не добавлять их обратно при интеграции.
|
||||
### 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'],
|
||||
});
|
||||
|
||||
`PLAN.md`, `docs/PLAN-componentization.md`, review prompts и записи `HISTORY/`
|
||||
могут содержать описания старой VM/backend-архитектуры. Они нужны для аудита
|
||||
решений, но не являются актуальной спецификацией и не должны использоваться
|
||||
как план разработки.
|
||||
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>
|
||||
```
|
||||
|
||||
Актуальная инструкция интеграции находится в `upload/README.md`.
|
||||
### 4. Получение файлов в Слое 3
|
||||
Бизнес-логика получает файлы из оперативной памяти:
|
||||
```python
|
||||
files = get_files(session_id)
|
||||
# files: [("document.pdf", b"...bytes..."), ...]
|
||||
```
|
||||
Никаких временных файлов на диске. Полная безопасность и изоляция.
|
||||
|
||||
Полный справочник функций, состояния, DOM-контрактов и ограничений находится в
|
||||
[`docs/CODE-REFERENCE.md`](docs/CODE-REFERENCE.md).
|
||||
|
||||
История решений и проверок находится в [`HISTORY/`](HISTORY/).
|
||||
|
||||
+4
-2
@@ -1,3 +1,5 @@
|
||||
{
|
||||
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"]
|
||||
}
|
||||
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"],
|
||||
"vmUploadUrl": "/mock-buffer/",
|
||||
"vmUploadPrefix": "/mock-buffer/"
|
||||
}
|
||||
|
||||
Vendored
-995
@@ -1,995 +0,0 @@
|
||||
// node_modules/fflate/esm/browser.js
|
||||
var u8 = Uint8Array;
|
||||
var u16 = Uint16Array;
|
||||
var i32 = Int32Array;
|
||||
var fleb = new u8([
|
||||
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,
|
||||
/* unused */
|
||||
0,
|
||||
0,
|
||||
/* impossible */
|
||||
0
|
||||
]);
|
||||
var fdeb = new u8([
|
||||
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,
|
||||
/* unused */
|
||||
0,
|
||||
0
|
||||
]);
|
||||
var clim = new u8([16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]);
|
||||
var freb = function(eb, start) {
|
||||
var b = new u16(31);
|
||||
for (var i = 0; i < 31; ++i) {
|
||||
b[i] = start += 1 << eb[i - 1];
|
||||
}
|
||||
var r = new i32(b[30]);
|
||||
for (var i = 1; i < 30; ++i) {
|
||||
for (var j = b[i]; j < b[i + 1]; ++j) {
|
||||
r[j] = j - b[i] << 5 | i;
|
||||
}
|
||||
}
|
||||
return { b, r };
|
||||
};
|
||||
var _a = freb(fleb, 2);
|
||||
var fl = _a.b;
|
||||
var revfl = _a.r;
|
||||
fl[28] = 258, revfl[258] = 28;
|
||||
var _b = freb(fdeb, 0);
|
||||
var fd = _b.b;
|
||||
var revfd = _b.r;
|
||||
var rev = new u16(32768);
|
||||
for (i = 0; i < 32768; ++i) {
|
||||
x = (i & 43690) >> 1 | (i & 21845) << 1;
|
||||
x = (x & 52428) >> 2 | (x & 13107) << 2;
|
||||
x = (x & 61680) >> 4 | (x & 3855) << 4;
|
||||
rev[i] = ((x & 65280) >> 8 | (x & 255) << 8) >> 1;
|
||||
}
|
||||
var x;
|
||||
var i;
|
||||
var hMap = (function(cd, mb, r) {
|
||||
var s = cd.length;
|
||||
var i = 0;
|
||||
var l = new u16(mb);
|
||||
for (; i < s; ++i) {
|
||||
if (cd[i])
|
||||
++l[cd[i] - 1];
|
||||
}
|
||||
var le = new u16(mb);
|
||||
for (i = 1; i < mb; ++i) {
|
||||
le[i] = le[i - 1] + l[i - 1] << 1;
|
||||
}
|
||||
var co;
|
||||
if (r) {
|
||||
co = new u16(1 << mb);
|
||||
var rvb = 15 - mb;
|
||||
for (i = 0; i < s; ++i) {
|
||||
if (cd[i]) {
|
||||
var sv = i << 4 | cd[i];
|
||||
var r_1 = mb - cd[i];
|
||||
var v = le[cd[i] - 1]++ << r_1;
|
||||
for (var m = v | (1 << r_1) - 1; v <= m; ++v) {
|
||||
co[rev[v] >> rvb] = sv;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
co = new u16(s);
|
||||
for (i = 0; i < s; ++i) {
|
||||
if (cd[i]) {
|
||||
co[i] = rev[le[cd[i] - 1]++] >> 15 - cd[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return co;
|
||||
});
|
||||
var flt = new u8(288);
|
||||
for (i = 0; i < 144; ++i)
|
||||
flt[i] = 8;
|
||||
var i;
|
||||
for (i = 144; i < 256; ++i)
|
||||
flt[i] = 9;
|
||||
var i;
|
||||
for (i = 256; i < 280; ++i)
|
||||
flt[i] = 7;
|
||||
var i;
|
||||
for (i = 280; i < 288; ++i)
|
||||
flt[i] = 8;
|
||||
var i;
|
||||
var fdt = new u8(32);
|
||||
for (i = 0; i < 32; ++i)
|
||||
fdt[i] = 5;
|
||||
var i;
|
||||
var flrm = /* @__PURE__ */ hMap(flt, 9, 1);
|
||||
var fdrm = /* @__PURE__ */ hMap(fdt, 5, 1);
|
||||
var max = function(a) {
|
||||
var m = a[0];
|
||||
for (var i = 1; i < a.length; ++i) {
|
||||
if (a[i] > m)
|
||||
m = a[i];
|
||||
}
|
||||
return m;
|
||||
};
|
||||
var bits = function(d, p, m) {
|
||||
var o = p / 8 | 0;
|
||||
return (d[o] | d[o + 1] << 8) >> (p & 7) & m;
|
||||
};
|
||||
var bits16 = function(d, p) {
|
||||
var o = p / 8 | 0;
|
||||
return (d[o] | d[o + 1] << 8 | d[o + 2] << 16) >> (p & 7);
|
||||
};
|
||||
var shft = function(p) {
|
||||
return (p + 7) / 8 | 0;
|
||||
};
|
||||
var slc = function(v, s, e) {
|
||||
if (s == null || s < 0)
|
||||
s = 0;
|
||||
if (e == null || e > v.length)
|
||||
e = v.length;
|
||||
return new u8(v.subarray(s, e));
|
||||
};
|
||||
var ec = [
|
||||
"unexpected EOF",
|
||||
"invalid block type",
|
||||
"invalid length/literal",
|
||||
"invalid distance",
|
||||
"stream finished",
|
||||
"no stream handler",
|
||||
,
|
||||
// determined by compression function
|
||||
"no callback",
|
||||
"invalid UTF-8 data",
|
||||
"extra field too long",
|
||||
"date not in range 1980-2099",
|
||||
"filename too long",
|
||||
"stream finishing",
|
||||
"invalid zip data"
|
||||
// determined by unknown compression method
|
||||
];
|
||||
var err = function(ind, msg, nt) {
|
||||
var e = new Error(msg || ec[ind]);
|
||||
e.code = ind;
|
||||
if (Error.captureStackTrace)
|
||||
Error.captureStackTrace(e, err);
|
||||
if (!nt)
|
||||
throw e;
|
||||
return e;
|
||||
};
|
||||
var inflt = function(dat, st, buf, dict) {
|
||||
var sl = dat.length, dl = dict ? dict.length : 0;
|
||||
if (!sl || st.f && !st.l)
|
||||
return buf || new u8(0);
|
||||
var noBuf = !buf;
|
||||
var resize = noBuf || st.i != 2;
|
||||
var noSt = st.i;
|
||||
if (noBuf)
|
||||
buf = new u8(sl * 3);
|
||||
var cbuf = function(l2) {
|
||||
var bl = buf.length;
|
||||
if (l2 > bl) {
|
||||
var nbuf = new u8(Math.max(bl * 2, l2));
|
||||
nbuf.set(buf);
|
||||
buf = nbuf;
|
||||
}
|
||||
};
|
||||
var final = st.f || 0, pos = st.p || 0, bt = st.b || 0, lm = st.l, dm = st.d, lbt = st.m, dbt = st.n;
|
||||
var tbts = sl * 8;
|
||||
do {
|
||||
if (!lm) {
|
||||
final = bits(dat, pos, 1);
|
||||
var type = bits(dat, pos + 1, 3);
|
||||
pos += 3;
|
||||
if (!type) {
|
||||
var s = shft(pos) + 4, l = dat[s - 4] | dat[s - 3] << 8, t = s + l;
|
||||
if (t > sl) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
if (resize)
|
||||
cbuf(bt + l);
|
||||
buf.set(dat.subarray(s, t), bt);
|
||||
st.b = bt += l, st.p = pos = t * 8, st.f = final;
|
||||
continue;
|
||||
} else if (type == 1)
|
||||
lm = flrm, dm = fdrm, lbt = 9, dbt = 5;
|
||||
else if (type == 2) {
|
||||
var hLit = bits(dat, pos, 31) + 257, hcLen = bits(dat, pos + 10, 15) + 4;
|
||||
var tl = hLit + bits(dat, pos + 5, 31) + 1;
|
||||
pos += 14;
|
||||
var ldt = new u8(tl);
|
||||
var clt = new u8(19);
|
||||
for (var i = 0; i < hcLen; ++i) {
|
||||
clt[clim[i]] = bits(dat, pos + i * 3, 7);
|
||||
}
|
||||
pos += hcLen * 3;
|
||||
var clb = max(clt), clbmsk = (1 << clb) - 1;
|
||||
var clm = hMap(clt, clb, 1);
|
||||
for (var i = 0; i < tl; ) {
|
||||
var r = clm[bits(dat, pos, clbmsk)];
|
||||
pos += r & 15;
|
||||
var s = r >> 4;
|
||||
if (s < 16) {
|
||||
ldt[i++] = s;
|
||||
} else {
|
||||
var c = 0, n = 0;
|
||||
if (s == 16)
|
||||
n = 3 + bits(dat, pos, 3), pos += 2, c = ldt[i - 1];
|
||||
else if (s == 17)
|
||||
n = 3 + bits(dat, pos, 7), pos += 3;
|
||||
else if (s == 18)
|
||||
n = 11 + bits(dat, pos, 127), pos += 7;
|
||||
while (n--)
|
||||
ldt[i++] = c;
|
||||
}
|
||||
}
|
||||
var lt = ldt.subarray(0, hLit), dt = ldt.subarray(hLit);
|
||||
lbt = max(lt);
|
||||
dbt = max(dt);
|
||||
lm = hMap(lt, lbt, 1);
|
||||
dm = hMap(dt, dbt, 1);
|
||||
} else
|
||||
err(1);
|
||||
if (pos > tbts) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (resize)
|
||||
cbuf(bt + 131072);
|
||||
var lms = (1 << lbt) - 1, dms = (1 << dbt) - 1;
|
||||
var lpos = pos;
|
||||
for (; ; lpos = pos) {
|
||||
var c = lm[bits16(dat, pos) & lms], sym = c >> 4;
|
||||
pos += c & 15;
|
||||
if (pos > tbts) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
if (!c)
|
||||
err(2);
|
||||
if (sym < 256)
|
||||
buf[bt++] = sym;
|
||||
else if (sym == 256) {
|
||||
lpos = pos, lm = null;
|
||||
break;
|
||||
} else {
|
||||
var add = sym - 254;
|
||||
if (sym > 264) {
|
||||
var i = sym - 257, b = fleb[i];
|
||||
add = bits(dat, pos, (1 << b) - 1) + fl[i];
|
||||
pos += b;
|
||||
}
|
||||
var d = dm[bits16(dat, pos) & dms], dsym = d >> 4;
|
||||
if (!d)
|
||||
err(3);
|
||||
pos += d & 15;
|
||||
var dt = fd[dsym];
|
||||
if (dsym > 3) {
|
||||
var b = fdeb[dsym];
|
||||
dt += bits16(dat, pos) & (1 << b) - 1, pos += b;
|
||||
}
|
||||
if (pos > tbts) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
if (resize)
|
||||
cbuf(bt + 131072);
|
||||
var end = bt + add;
|
||||
if (bt < dt) {
|
||||
var shift = dl - dt, dend = Math.min(dt, end);
|
||||
if (shift + bt < 0)
|
||||
err(3);
|
||||
for (; bt < dend; ++bt)
|
||||
buf[bt] = dict[shift + bt];
|
||||
}
|
||||
for (; bt < end; ++bt)
|
||||
buf[bt] = buf[bt - dt];
|
||||
}
|
||||
}
|
||||
st.l = lm, st.p = lpos, st.b = bt, st.f = final;
|
||||
if (lm)
|
||||
final = 1, st.m = lbt, st.d = dm, st.n = dbt;
|
||||
} while (!final);
|
||||
return bt != buf.length && noBuf ? slc(buf, 0, bt) : buf.subarray(0, bt);
|
||||
};
|
||||
var et = /* @__PURE__ */ new u8(0);
|
||||
var b2 = function(d, b) {
|
||||
return d[b] | d[b + 1] << 8;
|
||||
};
|
||||
var b4 = function(d, b) {
|
||||
return (d[b] | d[b + 1] << 8 | d[b + 2] << 16 | d[b + 3] << 24) >>> 0;
|
||||
};
|
||||
var b8 = function(d, b) {
|
||||
return b4(d, b) + b4(d, b + 4) * 4294967296;
|
||||
};
|
||||
function inflateSync(data, opts) {
|
||||
return inflt(data, { i: 2 }, opts && opts.out, opts && opts.dictionary);
|
||||
}
|
||||
var td = typeof TextDecoder != "undefined" && /* @__PURE__ */ new TextDecoder();
|
||||
var tds = 0;
|
||||
try {
|
||||
td.decode(et, { stream: true });
|
||||
tds = 1;
|
||||
} catch (e) {
|
||||
}
|
||||
var dutf8 = function(d) {
|
||||
for (var r = "", i = 0; ; ) {
|
||||
var c = d[i++];
|
||||
var eb = (c > 127) + (c > 223) + (c > 239);
|
||||
if (i + eb > d.length)
|
||||
return { s: r, r: slc(d, i - 1) };
|
||||
if (!eb)
|
||||
r += String.fromCharCode(c);
|
||||
else if (eb == 3) {
|
||||
c = ((c & 15) << 18 | (d[i++] & 63) << 12 | (d[i++] & 63) << 6 | d[i++] & 63) - 65536, r += String.fromCharCode(55296 | c >> 10, 56320 | c & 1023);
|
||||
} else if (eb & 1)
|
||||
r += String.fromCharCode((c & 31) << 6 | d[i++] & 63);
|
||||
else
|
||||
r += String.fromCharCode((c & 15) << 12 | (d[i++] & 63) << 6 | d[i++] & 63);
|
||||
}
|
||||
};
|
||||
function strFromU8(dat, latin1) {
|
||||
if (latin1) {
|
||||
var r = "";
|
||||
for (var i = 0; i < dat.length; i += 16384)
|
||||
r += String.fromCharCode.apply(null, dat.subarray(i, i + 16384));
|
||||
return r;
|
||||
} else if (td) {
|
||||
return td.decode(dat);
|
||||
} else {
|
||||
var _a2 = dutf8(dat), s = _a2.s, r = _a2.r;
|
||||
if (r.length)
|
||||
err(8);
|
||||
return s;
|
||||
}
|
||||
}
|
||||
var slzh = function(d, b) {
|
||||
return b + 30 + b2(d, b + 26) + b2(d, b + 28);
|
||||
};
|
||||
var zh = function(d, b, z) {
|
||||
var fnl = b2(d, b + 28), efl = b2(d, b + 30), fn = strFromU8(d.subarray(b + 46, b + 46 + fnl), !(b2(d, b + 8) & 2048)), es = b + 46 + fnl;
|
||||
var _a2 = z64hs(d, es, efl, z, b4(d, b + 20), b4(d, b + 24), b4(d, b + 42)), sc = _a2[0], su = _a2[1], off = _a2[2];
|
||||
return [b2(d, b + 10), sc, su, fn, es + efl + b2(d, b + 32), off];
|
||||
};
|
||||
var z64hs = function(d, b, l, z, sc, su, off) {
|
||||
var nsc = sc == 4294967295, nsu = su == 4294967295, noff = off == 4294967295, e = b + l;
|
||||
var nf = nsc + nsu + noff;
|
||||
if (z && nf) {
|
||||
for (; b + 4 < e; b += 4 + b2(d, b + 2)) {
|
||||
if (b2(d, b) == 1) {
|
||||
return [
|
||||
nsc ? b8(d, b + 4 + 8 * nsu) : sc,
|
||||
nsu ? b8(d, b + 4) : su,
|
||||
noff ? b8(d, b + 4 + 8 * (nsu + nsc)) : off,
|
||||
1
|
||||
];
|
||||
}
|
||||
}
|
||||
if (z < 2)
|
||||
err(13);
|
||||
}
|
||||
return [sc, su, off, 0];
|
||||
};
|
||||
function unzipSync(data, opts) {
|
||||
var files = {};
|
||||
var e = data.length - 22;
|
||||
for (; b4(data, e) != 101010256; --e) {
|
||||
if (!e || data.length - e > 65558)
|
||||
err(13);
|
||||
}
|
||||
;
|
||||
var c = b2(data, e + 8);
|
||||
if (!c)
|
||||
return {};
|
||||
var o = b4(data, e + 16);
|
||||
var z = b4(data, e - 20) == 117853008;
|
||||
if (z) {
|
||||
var ze = b4(data, e - 12);
|
||||
z = b4(data, ze) == 101075792;
|
||||
if (z) {
|
||||
c = b4(data, ze + 32);
|
||||
o = b4(data, ze + 48);
|
||||
}
|
||||
}
|
||||
var fltr = opts && opts.filter;
|
||||
for (var i = 0; i < c; ++i) {
|
||||
var _a2 = zh(data, o, z), c_2 = _a2[0], sc = _a2[1], su = _a2[2], fn = _a2[3], no = _a2[4], off = _a2[5], b = slzh(data, off);
|
||||
o = no;
|
||||
if (!fltr || fltr({
|
||||
name: fn,
|
||||
size: sc,
|
||||
originalSize: su,
|
||||
compression: c_2
|
||||
})) {
|
||||
if (!c_2)
|
||||
files[fn] = slc(data, b, b + sc);
|
||||
else if (c_2 == 8)
|
||||
files[fn] = inflateSync(data.subarray(b, b + sc), { out: new u8(su) });
|
||||
else
|
||||
err(14, "unknown compression type " + c_2);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
// upload/frontend/table/rebase_tree.js
|
||||
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;
|
||||
}
|
||||
|
||||
// upload/frontend/zip/list_zip_files.js
|
||||
var DEFAULT_LIMITS = {
|
||||
maxEntries: 1e3,
|
||||
maxTotalBytes: 100 * 1024 * 1024,
|
||||
maxEntryBytes: 50 * 1024 * 1024,
|
||||
maxDepth: 20
|
||||
};
|
||||
function extensionAllowed(name, allowedExt) {
|
||||
const lowerName = name.toLowerCase();
|
||||
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
|
||||
}
|
||||
function makeFile(data, name) {
|
||||
return new File([data], name.split("/").at(-1));
|
||||
}
|
||||
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;
|
||||
}
|
||||
function node(kind, name, path, children = [], file = null) {
|
||||
return { id: crypto.randomUUID(), kind, name, path, children, file, expanded: true };
|
||||
}
|
||||
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;
|
||||
});
|
||||
}
|
||||
async function listEntries(data, zipName, allowedExt, depth, limits, budget) {
|
||||
if (depth > limits.maxDepth) throw new Error("\u0421\u043B\u0438\u0448\u043A\u043E\u043C \u0433\u043B\u0443\u0431\u043E\u043A\u0430\u044F \u0432\u043B\u043E\u0436\u0435\u043D\u043D\u043E\u0441\u0442\u044C ZIP");
|
||||
const entries = unzipSync(data, {
|
||||
filter: (entry) => {
|
||||
budget.entries += 1;
|
||||
if (budget.entries > limits.maxEntries) throw new Error("\u0421\u043B\u0438\u0448\u043A\u043E\u043C \u043C\u043D\u043E\u0433\u043E 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("\u041F\u0440\u0435\u0432\u044B\u0448\u0435\u043D \u0441\u0443\u043C\u043C\u0430\u0440\u043D\u044B\u0439 \u0440\u0430\u0437\u043C\u0435\u0440 \u0440\u0430\u0441\u043F\u0430\u043A\u043E\u0432\u0430\u043D\u043D\u044B\u0445 ZIP entries");
|
||||
}
|
||||
budget.totalBytes += entry.originalSize;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
const root = node("zip", zipName, zipName);
|
||||
for (const [entryName, entryData] of Object.entries(entries)) {
|
||||
if (entryName.endsWith("/")) continue;
|
||||
const parts = safeEntryParts(entryName);
|
||||
if (!parts) continue;
|
||||
const normalizedName = parts.join("/");
|
||||
if (normalizedName.toLowerCase().endsWith(".zip")) {
|
||||
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)) {
|
||||
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;
|
||||
}
|
||||
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 });
|
||||
}
|
||||
|
||||
// upload/frontend/table/add_file_with_dedup.js
|
||||
function addFileWithDedup(state, fileNode) {
|
||||
const accept = (node2) => {
|
||||
if (node2.kind === "file") {
|
||||
const key = `${node2.path}\0${node2.file.size}`;
|
||||
if (state.fileKeys.has(key)) return null;
|
||||
state.fileKeys.add(key);
|
||||
return node2;
|
||||
}
|
||||
node2.children = node2.children.map(accept).filter(Boolean);
|
||||
return node2.children.length ? node2 : null;
|
||||
};
|
||||
const accepted = accept(fileNode);
|
||||
if (!accepted || accepted.kind !== "file" && !accepted.children.length) return false;
|
||||
if (accepted.kind !== "file") {
|
||||
const existing = state.nodes.find((node2) => node2.kind !== "file" && node2.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((existing2) => existing2.kind === "file" && existing2.path === child.path && existing2.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);
|
||||
});
|
||||
}
|
||||
|
||||
// upload/frontend/table/esc.js
|
||||
function esc(value) {
|
||||
return String(value).replace(/[&<>"']/g, (character) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
})[character]);
|
||||
}
|
||||
|
||||
// upload/frontend/table/fs.js
|
||||
function fs(bytes) {
|
||||
return bytes < 1024 ? `${bytes} B` : bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
// upload/frontend/table/render.js
|
||||
var DEFAULT_RENDER_CONFIG = {
|
||||
labels: {
|
||||
statusReady: "\u0433\u043E\u0442\u043E\u0432",
|
||||
remove: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C",
|
||||
empty: "\u041D\u0435\u0442 \u0432\u044B\u0431\u0440\u0430\u043D\u043D\u044B\u0445 \u0444\u0430\u0439\u043B\u043E\u0432",
|
||||
columns: { path: "\u041F\u0443\u0442\u044C", size: "\u0420\u0430\u0437\u043C\u0435\u0440", status: "\u0421\u0442\u0430\u0442\u0443\u0441" },
|
||||
count: (count, bytes) => `${count} \u0444\u0430\u0439\u043B\u043E\u0432 \xB7 ${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 || {} }
|
||||
};
|
||||
}
|
||||
function renderNode(node2, depth, cfg) {
|
||||
const isGroup = node2.kind !== "file";
|
||||
const padding = depth * 4;
|
||||
const name = esc(node2.name);
|
||||
const action = isGroup ? `<button class="tree-name tree-group tree-${node2.kind}" style="padding-left:${padding}ch" data-toggle="${node2.id}" aria-expanded="${node2.expanded}"><span class="tree-chevron">${node2.expanded ? "\u25BC" : "\u25B6"}</span>${name}</button>` : `<span class="tree-name" style="padding-left:${padding}ch">${name}</span>`;
|
||||
const remove = `<button class="remove-btn" type="button" data-remove="${node2.id}" aria-label="${esc(cfg.labels.remove)} ${name}">\xD7</button>`;
|
||||
const pathAttribute = node2.kind === "file" ? ` data-path="${esc(node2.path)}"` : "";
|
||||
const cells = {
|
||||
path: action,
|
||||
size: node2.kind === "file" ? fs(node2.file.size) : "",
|
||||
status: node2.error ? esc(node2.error) : node2.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-${node2.kind}"${pathAttribute}>` + columns.map((column) => `<td>${cells[column] || ""}</td>`).join("") + `<td>${remove}</td></tr>`;
|
||||
if (!isGroup || !node2.expanded) return row;
|
||||
return row + node2.children.map((child) => renderNode(child, depth + 1, cfg)).join("");
|
||||
}
|
||||
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((node2) => renderNode(node2, 0, normalized)).join("") : `<tr><td colspan="${columns.length + 1}">${esc(normalized.labels.empty)}</td></tr>`;
|
||||
const files = [];
|
||||
const visit = (node2) => {
|
||||
if (node2.kind === "file") files.push(node2);
|
||||
else node2.children.forEach(visit);
|
||||
};
|
||||
state.nodes.forEach(visit);
|
||||
const totalBytes = files.reduce((sum, node2) => sum + node2.file.size, 0);
|
||||
elements.countEl.textContent = normalized.labels.count(files.length, totalBytes);
|
||||
}
|
||||
function findNode(nodes, id) {
|
||||
for (const node2 of nodes) {
|
||||
if (node2.id === id) return { node: node2, nodes };
|
||||
const found = findNode(node2.children || [], id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function flattenFiles(nodes, result = []) {
|
||||
nodes.forEach((node2) => {
|
||||
if (node2.kind === "file") result.push({ path: node2.path, name: node2.name, size: node2.file.size, file: node2.file });
|
||||
else flattenFiles(node2.children, result);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// upload/frontend/table/on_files_change.js
|
||||
async function addFiles(state, cfg, files, elements) {
|
||||
for (const file of Array.from(files)) {
|
||||
if (!file.name.toLowerCase().endsWith(".zip")) {
|
||||
if (!cfg.allowedExt.some((extension) => file.name.toLowerCase().endsWith(extension.toLowerCase()))) {
|
||||
continue;
|
||||
}
|
||||
addFileWithDedup(state, {
|
||||
id: crypto.randomUUID(),
|
||||
kind: "file",
|
||||
name: file.name,
|
||||
path: file.name,
|
||||
file,
|
||||
children: [],
|
||||
expanded: true
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits);
|
||||
if (zipTree) addFileWithDedup(state, zipTree);
|
||||
} catch (error) {
|
||||
if (typeof cfg.onError === "function") {
|
||||
cfg.onError(error, file);
|
||||
} else if (elements.statusEl) {
|
||||
elements.statusEl.textContent = `\u041E\u0448\u0438\u0431\u043A\u0430 \u0447\u0442\u0435\u043D\u0438\u044F \u0430\u0440\u0445\u0438\u0432\u0430 ${file.name}: ${error.message}`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
render(state, elements, cfg);
|
||||
}
|
||||
function onFilesChange(state, cfg, elements) {
|
||||
return async () => {
|
||||
if (state.busy) return;
|
||||
state.busy = true;
|
||||
try {
|
||||
await addFiles(state, cfg, elements.fileInputEl.files, elements);
|
||||
} finally {
|
||||
elements.fileInputEl.value = "";
|
||||
state.busy = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// upload/frontend/table/on_folder_change.js
|
||||
function onFolderChange(state, cfg, elements) {
|
||||
return async () => {
|
||||
if (state.busy) return;
|
||||
state.busy = true;
|
||||
try {
|
||||
const roots = /* @__PURE__ */ new Map();
|
||||
for (const file of Array.from(elements.folderInputEl.files)) {
|
||||
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)) {
|
||||
roots.set(rootName, {
|
||||
id: crypto.randomUUID(),
|
||||
kind: "folder",
|
||||
name: rootName,
|
||||
path: rootName,
|
||||
children: [],
|
||||
expanded: true
|
||||
});
|
||||
}
|
||||
const root = roots.get(rootName);
|
||||
const addToFolder = (node2) => {
|
||||
let current = root;
|
||||
const nodeParts = node2.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 ? node2 : {
|
||||
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 {
|
||||
const zip = await listZipFiles(file, cfg.allowedExt, cfg.limits);
|
||||
if (zip) {
|
||||
rebaseTree(zip, rootName);
|
||||
addToFolder(zip);
|
||||
}
|
||||
} catch (error) {
|
||||
if (typeof cfg.onError === "function") {
|
||||
cfg.onError(error, file);
|
||||
} else if (elements.statusEl) {
|
||||
elements.statusEl.textContent = `\u041E\u0448\u0438\u0431\u043A\u0430 \u0447\u0442\u0435\u043D\u0438\u044F \u0430\u0440\u0445\u0438\u0432\u0430 ${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));
|
||||
elements.folderInputEl.value = "";
|
||||
render(state, elements, cfg);
|
||||
} finally {
|
||||
state.busy = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// upload/frontend/index.js
|
||||
var DEFAULTS = {
|
||||
allowedExt: [],
|
||||
labels: {
|
||||
pickFiles: "\u0412\u044B\u0431\u0440\u0430\u0442\u044C \u0444\u0430\u0439\u043B\u044B",
|
||||
pickFolder: "\u0412\u044B\u0431\u0440\u0430\u0442\u044C \u043F\u0430\u043F\u043A\u0443",
|
||||
clear: "\u041E\u0447\u0438\u0441\u0442\u0438\u0442\u044C",
|
||||
empty: "\u041D\u0435\u0442 \u0432\u044B\u0431\u0440\u0430\u043D\u043D\u044B\u0445 \u0444\u0430\u0439\u043B\u043E\u0432",
|
||||
statusReady: "\u0433\u043E\u0442\u043E\u0432",
|
||||
remove: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C",
|
||||
columns: { path: "\u041F\u0443\u0442\u044C", size: "\u0420\u0430\u0437\u043C\u0435\u0440", status: "\u0421\u0442\u0430\u0442\u0443\u0441" },
|
||||
count: (count, bytes) => `${count} \u0444\u0430\u0439\u043B\u043E\u0432 \xB7 ${bytes} B`
|
||||
},
|
||||
layout: { columns: ["path", "size", "status"], controls: ["files", "folder", "clear"] },
|
||||
limits: {
|
||||
maxEntries: 1e3,
|
||||
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 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D");
|
||||
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, node2) {
|
||||
if (node2.kind === "file") {
|
||||
state.fileKeys.delete(`${node2.path}\0${node2.file.size}`);
|
||||
return;
|
||||
}
|
||||
node2.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;
|
||||
}
|
||||
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: /* @__PURE__ */ 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 {
|
||||
DEFAULTS,
|
||||
initFilePicker
|
||||
};
|
||||
Vendored
-1019
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "upload-platform-file-picker",
|
||||
"private": true,
|
||||
"version": "0.1.13",
|
||||
"version": "0.2.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node build.mjs"
|
||||
"build": "node build.mjs",
|
||||
"test": "node --test tests/*.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.3"
|
||||
@@ -13,3 +14,4 @@
|
||||
"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
|
||||
|
||||
+115
-13
@@ -1,48 +1,150 @@
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
|
||||
from flask import Flask, render_template, send_from_directory
|
||||
|
||||
from flask import Blueprint, Flask, Response, jsonify, render_template, request, send_from_directory
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
# Конфигурация демо содержит только разрешённые расширения для browser picker.
|
||||
DEFAULT_CONFIG = {"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"]}
|
||||
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 = json.load(config_file)
|
||||
CONFIG = {**DEFAULT_CONFIG, **json.load(config_file)}
|
||||
except (OSError, json.JSONDecodeError):
|
||||
CONFIG = DEFAULT_CONFIG
|
||||
|
||||
VERSION = "0.2.2"
|
||||
|
||||
VERSION = "0.1.13"
|
||||
app = Flask(
|
||||
__name__,
|
||||
template_folder=str(ROOT / "site" / "templates"),
|
||||
static_folder=str(ROOT / "site" / "static"),
|
||||
)
|
||||
|
||||
# Flask нужен здесь только как статический сервер HTML, CSS и ES-модулей.
|
||||
app = Flask(__name__, template_folder="templates", static_folder="static")
|
||||
# -----------------------------------------------------------------------------
|
||||
# In-memory Mock Buffer (эмуляция WebDAV буфера на ВМ для локальных тестов)
|
||||
# -----------------------------------------------------------------------------
|
||||
_mock_storage = {}
|
||||
_mock_storage_lock = threading.Lock()
|
||||
mock_buffer_bp = Blueprint("mock_buffer", __name__)
|
||||
|
||||
|
||||
@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():
|
||||
"""Возвращает demo-страницу и передаёт в неё версию и JSON-конфигурацию."""
|
||||
"""Возвращает demo-страницу со Слой 1 (picker) и Слой 2 (transit upload)."""
|
||||
return render_template("index.html", version=VERSION, config=CONFIG)
|
||||
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
"""Возвращает 200 для liveness-проверки платформы."""
|
||||
"""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):
|
||||
"""Отдаёт один frontend-файл из изолированного каталога upload/frontend."""
|
||||
"""Отдаёт исходные модули фронтенда из каталога upload/frontend."""
|
||||
return send_from_directory(ROOT / "upload" / "frontend", filename)
|
||||
|
||||
|
||||
@app.get("/file-picker/<path:filename>")
|
||||
def file_picker_dist(filename):
|
||||
"""Отдаёт собранный универсальный picker-бандл из каталога dist."""
|
||||
"""Отдаёт собранный бандл из каталога 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)
|
||||
|
||||
@@ -28,6 +28,9 @@ button.quiet { color: #5d6a61; background: transparent; }
|
||||
button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
/* Резервная область сообщения интегратора сохраняет высоту и не двигает таблицу. */
|
||||
.status { min-height: 22px; color: #56745f; font: 13px sans-serif; }
|
||||
.upload-progress-explanation { display: grid; gap: 4px; margin-top: 14px; color: #46604e; font: 13px/1.45 sans-serif; }
|
||||
.upload-progress-explanation strong { color: #1b4d3e; }
|
||||
.upload-progress-explanation span:last-child { color: #6b796f; font-variant-numeric: tabular-nums; }
|
||||
/* Горизонтальный scroll на узких экранах не ломает таблицу дерева. */
|
||||
.table-wrap { overflow-x: auto; border-top: 1px solid #b9c7ba; }
|
||||
table { width: 100%; border-collapse: collapse; font: 14px/1.4 sans-serif; }
|
||||
|
||||
+160
-7
@@ -7,25 +7,178 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Основной контейнер демо: Flask только подставляет version, остальное работает в браузере. -->
|
||||
<!-- Основной контейнер стенда: Слой 1 (выбор) + Слой 2 (пофайловый транзит через RAM) -->
|
||||
<main class="page">
|
||||
<!-- Заголовок показывает назначение picker и текущую версию сборки. -->
|
||||
<header>
|
||||
<p class="eyebrow">FILE INTAKE / {{ version }}</p>
|
||||
<p class="eyebrow">FILE INTAKE & TRANSIT / {{ version }}</p>
|
||||
<h1>Загрузка документов</h1>
|
||||
<p class="lede">Выберите отдельные файлы или целую папку. Архивы будут раскрыты автоматически.</p>
|
||||
<p class="lede">Выберите отдельные файлы или целую папку. Архивы будут распакованы на клиенте, после чего пофайлово доставлены в память сессии через буфер.</p>
|
||||
</header>
|
||||
<!-- Скрытые input дают браузеру FileList; кнопки ниже программно вызывают click(). -->
|
||||
|
||||
<section class="toolbar" aria-label="Выбор файлов">
|
||||
<div id="file-picker"></div>
|
||||
</section>
|
||||
|
||||
<!-- Секция действий Слоя 2: пофайловый транзит в RAM сессии бэкенда -->
|
||||
<section class="upload-section" style="margin-top: 24px; padding: 20px; background: #f2f6f1; border-radius: 6px; border: 1px solid #c8d6c7;">
|
||||
<div style="display: flex; gap: 12px; align-items: center; flex-wrap: wrap;">
|
||||
<button id="upload-btn" type="button" disabled style="background: #1b4d3e;">
|
||||
⚡ Загрузить в сессию (Слой 2 транзит)
|
||||
</button>
|
||||
<button id="cancel-btn" type="button" class="quiet" style="display: none; color: #8c5148;">
|
||||
✕ Отмена
|
||||
</button>
|
||||
<span id="upload-status" style="font: 14px sans-serif; color: #2d4a36;"></span>
|
||||
</div>
|
||||
<div id="upload-progress-explanation" class="upload-progress-explanation" hidden>
|
||||
<strong id="upload-phase">Подготовка загрузки</strong>
|
||||
<span id="upload-phase-help">Первое соединение может занять время: устанавливаются соединения с буфером ВМ и сервисом.</span>
|
||||
<span id="upload-elapsed">Прошло: 00:00</span>
|
||||
</div>
|
||||
<div id="session-info" style="margin-top: 14px; font: 13px sans-serif; display: none;">
|
||||
<strong>Сессия в RAM:</strong> <code id="session-id"></code>
|
||||
<div id="session-files" style="margin-top: 6px; color: #46604e;"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="{{ url_for('file_picker_dist', filename='file-picker.iife.js', v=version) }}"></script>
|
||||
<script>
|
||||
FilePicker.initFilePicker({
|
||||
const uploadBtn = document.getElementById('upload-btn');
|
||||
const cancelBtn = document.getElementById('cancel-btn');
|
||||
const uploadStatus = document.getElementById('upload-status');
|
||||
const sessionInfo = document.getElementById('session-info');
|
||||
const sessionIdEl = document.getElementById('session-id');
|
||||
const sessionFilesEl = document.getElementById('session-files');
|
||||
const progressExplanation = document.getElementById('upload-progress-explanation');
|
||||
const uploadPhase = document.getElementById('upload-phase');
|
||||
const uploadPhaseHelp = document.getElementById('upload-phase-help');
|
||||
const uploadElapsed = document.getElementById('upload-elapsed');
|
||||
|
||||
let abortCtrl = null;
|
||||
let selectedFiles = [];
|
||||
let uploadTimer = null;
|
||||
let uploadStartedAt = 0;
|
||||
|
||||
const formatElapsed = (seconds) => {
|
||||
const minutes = Math.floor(seconds / 60).toString().padStart(2, '0');
|
||||
const remainder = (seconds % 60).toString().padStart(2, '0');
|
||||
return `${minutes}:${remainder}`;
|
||||
};
|
||||
|
||||
const stopUploadTimer = () => {
|
||||
if (uploadTimer) window.clearInterval(uploadTimer);
|
||||
uploadTimer = null;
|
||||
};
|
||||
|
||||
const startUploadTimer = () => {
|
||||
stopUploadTimer();
|
||||
uploadStartedAt = Date.now();
|
||||
progressExplanation.hidden = false;
|
||||
uploadTimer = window.setInterval(() => {
|
||||
uploadElapsed.textContent = `Прошло: ${formatElapsed(Math.floor((Date.now() - uploadStartedAt) / 1000))}`;
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const showUploadPhase = (phase, explanation) => {
|
||||
progressExplanation.hidden = false;
|
||||
uploadPhase.textContent = phase;
|
||||
uploadPhaseHelp.textContent = explanation;
|
||||
};
|
||||
|
||||
const picker = FilePicker.initFilePicker({
|
||||
mount: '#file-picker',
|
||||
allowedExt: {{ config.allowedExt|tojson }},
|
||||
onChange: (files) => {
|
||||
selectedFiles = files;
|
||||
uploadBtn.disabled = files.length === 0;
|
||||
uploadStatus.textContent = files.length ? `Готово к отправке: ${files.length} файл(ов)` : '';
|
||||
}
|
||||
});
|
||||
|
||||
uploadBtn.addEventListener('click', async () => {
|
||||
const files = picker.getFiles();
|
||||
if (!files.length) return;
|
||||
|
||||
uploadBtn.disabled = true;
|
||||
cancelBtn.style.display = 'inline-block';
|
||||
sessionInfo.style.display = 'none';
|
||||
abortCtrl = new AbortController();
|
||||
startUploadTimer();
|
||||
showUploadPhase(
|
||||
'Первый файл: устанавливаем соединение',
|
||||
'Это не зависание. Сначала файл отправляется в буфер ВМ, затем сервис забирает его в RAM и очищает буфер.'
|
||||
);
|
||||
|
||||
const vmUploadUrl = new URL({{ config.vmUploadUrl|tojson }}, window.location.origin).href;
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await FilePicker.uploadViaVM(files, {
|
||||
vmUploadUrl: vmUploadUrl,
|
||||
backendUploadUrl: '/api/upload_refs',
|
||||
signal: abortCtrl.signal,
|
||||
onStatus: (msg) => {
|
||||
uploadStatus.textContent = msg;
|
||||
if (msg.startsWith('Загрузка в буфер')) {
|
||||
showUploadPhase('Отправка файла в буфер ВМ', 'Идёт PUT текущего файла. После этого сервис сразу перенесёт его в RAM.');
|
||||
} else if (msg.startsWith('Приём сервисом')) {
|
||||
showUploadPhase('Перенос файла в RAM', 'Сервис забирает текущий файл из буфера ВМ и удаляет его после успешного приёма.');
|
||||
}
|
||||
},
|
||||
onFileStatus: (k, statusText) => {
|
||||
const row = document.querySelectorAll('.tree-row.tree-file')[k];
|
||||
if (row) {
|
||||
const statusCell = row.querySelectorAll('td')[2];
|
||||
if (statusCell) statusCell.innerHTML = statusText;
|
||||
}
|
||||
},
|
||||
onFileComplete: (info) => {
|
||||
console.log('File delivered to RAM:', info);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
stopUploadTimer();
|
||||
cancelBtn.style.display = 'none';
|
||||
uploadBtn.disabled = selectedFiles.length === 0;
|
||||
showUploadPhase('Загрузка остановлена', 'Произошла непредвиденная ошибка. Повторите загрузку.');
|
||||
uploadStatus.innerHTML = '<span style="color:#b32f22; font-weight: bold;">✗ Ошибка загрузки</span>';
|
||||
console.error('Upload failed unexpectedly', error);
|
||||
return;
|
||||
}
|
||||
|
||||
stopUploadTimer();
|
||||
cancelBtn.style.display = 'none';
|
||||
uploadBtn.disabled = selectedFiles.length === 0;
|
||||
|
||||
if (res.ok) {
|
||||
showUploadPhase('Загрузка завершена', 'Все файлы доставлены в RAM сессии.');
|
||||
uploadStatus.innerHTML = '<span style="color:#1f5a3b; font-weight: bold;">✓ Все ' + res.count + ' файл(ов) успешно доставлены в RAM бэкенда!</span>';
|
||||
sessionIdEl.textContent = res.session;
|
||||
sessionInfo.style.display = 'block';
|
||||
|
||||
try {
|
||||
const sResp = await fetch(`/api/session/${res.session}/files`);
|
||||
const sData = await sResp.json();
|
||||
if (sData.ok) {
|
||||
sessionFilesEl.innerHTML = 'Сохранено в сессии RAM: ' +
|
||||
sData.files.map(f => `<code>${f.name}</code> (${f.size} B)`).join(', ');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load session files', e);
|
||||
}
|
||||
} else if (res.aborted) {
|
||||
showUploadPhase('Загрузка отменена', 'Переданные до отмены файлы уже находятся в RAM сессии.');
|
||||
uploadStatus.innerHTML = '<span style="color:#8c5148;">Загрузка отменена пользователем</span>';
|
||||
} else {
|
||||
showUploadPhase('Загрузка остановлена', 'Проверьте сообщение об ошибке. Уже доставленные файлы остаются в RAM сессии.');
|
||||
uploadStatus.innerHTML = '<span style="color:#b32f22; font-weight: bold;">✗ Ошибка: ' + res.error + '</span>';
|
||||
}
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
if (abortCtrl) abortCtrl.abort();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -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,116 @@
|
||||
import threading
|
||||
from time import monotonic
|
||||
|
||||
import httpx
|
||||
from flask import Flask
|
||||
|
||||
from upload.backend.session import add_file, cleanup, configure, create_session, get_files
|
||||
from upload.backend.session import state as session_state
|
||||
from upload.backend.upload_refs.blueprint import create_upload_refs_blueprint
|
||||
|
||||
|
||||
def build_app(transport, **config):
|
||||
app = Flask(__name__)
|
||||
app.config['TESTING'] = True
|
||||
app.config['UPLOAD_HTTPX_TRANSPORT'] = transport
|
||||
app.register_blueprint(create_upload_refs_blueprint({
|
||||
'vmUploadPrefix': 'https://vm.test/buffer/',
|
||||
'httpxTransport': transport,
|
||||
'pullRetries': 3,
|
||||
'pullRetryDelay': 0,
|
||||
**config,
|
||||
}))
|
||||
return app
|
||||
|
||||
|
||||
def test_session_limit_accepts_exact_boundary_and_rejects_overflow():
|
||||
sid = create_session()
|
||||
previous = session_state.MAX_SESSION_BYTES
|
||||
configure(max_session_bytes=10)
|
||||
try:
|
||||
assert add_file(sid, 'exact.bin', b'x' * 10) is True
|
||||
assert add_file(sid, 'overflow.bin', b'x') is False
|
||||
assert len(get_files(sid)) == 1
|
||||
finally:
|
||||
configure(max_session_bytes=previous)
|
||||
cleanup(sid)
|
||||
|
||||
|
||||
def test_upload_refs_rejects_actual_oversize_and_deletes_buffer():
|
||||
storage = {'oversize': b'123456789'}
|
||||
deleted = []
|
||||
|
||||
def handler(request):
|
||||
if request.method == 'GET':
|
||||
return httpx.Response(200, content=storage['oversize'])
|
||||
if request.method == 'DELETE':
|
||||
deleted.append(request.url.path)
|
||||
storage.clear()
|
||||
return httpx.Response(204)
|
||||
return httpx.Response(405)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
app = build_app(transport, maxFileBytes=8)
|
||||
sid = create_session()
|
||||
try:
|
||||
with app.test_client() as client:
|
||||
response = client.post('/api/upload_refs', json={
|
||||
'session': sid,
|
||||
'files': [{
|
||||
'name': 'oversize.txt',
|
||||
'size': 8,
|
||||
'url': 'https://vm.test/buffer/oversize',
|
||||
}],
|
||||
})
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['added'] == 0
|
||||
assert get_files(sid) == []
|
||||
assert deleted == ['/buffer/oversize']
|
||||
assert storage == {}
|
||||
finally:
|
||||
cleanup(sid)
|
||||
|
||||
|
||||
def test_upload_refs_retries_transient_pull_and_deletes_after_success():
|
||||
attempts = []
|
||||
deleted = []
|
||||
|
||||
def handler(request):
|
||||
if request.method == 'GET':
|
||||
attempts.append(request.url.path)
|
||||
if len(attempts) == 1:
|
||||
return httpx.Response(503)
|
||||
return httpx.Response(200, content=b'retry-content')
|
||||
if request.method == 'DELETE':
|
||||
deleted.append(request.url.path)
|
||||
return httpx.Response(204)
|
||||
return httpx.Response(405)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
app = build_app(transport)
|
||||
with app.test_client() as client:
|
||||
response = client.post('/api/upload_refs', json={'files': [{
|
||||
'name': 'retry.txt',
|
||||
'size': 13,
|
||||
'url': 'https://vm.test/buffer/retry',
|
||||
}]})
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['added'] == 1
|
||||
assert len(attempts) == 2
|
||||
assert deleted == ['/buffer/retry']
|
||||
cleanup(data['session'])
|
||||
|
||||
|
||||
def test_session_ttl_removes_session():
|
||||
previous = session_state.TTL_SECONDS
|
||||
configure(ttl_seconds=0.05)
|
||||
sid = create_session()
|
||||
try:
|
||||
deadline = monotonic() + 1
|
||||
while monotonic() < deadline and get_files(sid) is not None:
|
||||
threading.Event().wait(0.01)
|
||||
assert get_files(sid) is None
|
||||
finally:
|
||||
configure(ttl_seconds=previous)
|
||||
cleanup(sid)
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { render } from '../upload/frontend/table/render.js';
|
||||
|
||||
function elements() {
|
||||
return { tableBodyEl: { innerHTML: '' }, countEl: { textContent: '' } };
|
||||
}
|
||||
|
||||
function fileNode() {
|
||||
return {
|
||||
id: 'file-1',
|
||||
kind: 'file',
|
||||
name: 'document.txt',
|
||||
path: 'folder/document.txt',
|
||||
file: { size: 7 },
|
||||
children: [],
|
||||
expanded: true,
|
||||
};
|
||||
}
|
||||
|
||||
test('render: папка по умолчанию скрывает дочерние файлы', () => {
|
||||
const child = fileNode();
|
||||
const state = {
|
||||
nodes: [{
|
||||
id: 'folder-1',
|
||||
kind: 'folder',
|
||||
name: 'folder',
|
||||
path: 'folder',
|
||||
children: [child],
|
||||
expanded: false,
|
||||
}],
|
||||
};
|
||||
const output = elements();
|
||||
|
||||
render(state, output);
|
||||
|
||||
assert.match(output.tableBodyEl.innerHTML, /data-toggle="folder-1"/);
|
||||
assert.match(output.tableBodyEl.innerHTML, /aria-expanded="false"/);
|
||||
assert.doesNotMatch(output.tableBodyEl.innerHTML, /data-path="folder\/document\.txt"/);
|
||||
assert.equal(output.countEl.textContent, '1 файлов · 7 B');
|
||||
|
||||
state.nodes[0].expanded = true;
|
||||
render(state, output);
|
||||
|
||||
assert.match(output.tableBodyEl.innerHTML, /data-path="folder\/document\.txt"/);
|
||||
assert.match(output.tableBodyEl.innerHTML, /aria-expanded="true"/);
|
||||
});
|
||||
@@ -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,262 @@
|
||||
// Юнит-тесты слоя 2 (фронт): putToVm + uploadViaVM с пофайловым транзитом.
|
||||
// Запуск: node --test tests/test_upload_layer2.test.mjs
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// Полифилл File/Blob при необходимости
|
||||
if (typeof globalThis.File === 'undefined') {
|
||||
globalThis.File = class File extends Blob {
|
||||
constructor(parts, name, opts) {
|
||||
super(parts, opts);
|
||||
this.name = name;
|
||||
this.lastModified = (opts && opts.lastModified) || Date.now();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
import { putToVm } from '../upload/frontend/upload/put_to_vm.js';
|
||||
import { uploadViaVM } from '../upload/frontend/upload/upload_via_vm.js';
|
||||
|
||||
const tick = () => new Promise(r => setTimeout(r, 0));
|
||||
|
||||
// Мок XMLHttpRequest
|
||||
let lastXHR = null;
|
||||
class FakeXHR {
|
||||
constructor() {
|
||||
this.upload = {};
|
||||
this.status = 0;
|
||||
this.timeout = 0;
|
||||
this.onload = null;
|
||||
this.onerror = null;
|
||||
this.ontimeout = null;
|
||||
this.onabort = null;
|
||||
this.method = null;
|
||||
this.url = null;
|
||||
this.body = null;
|
||||
this.aborted = false;
|
||||
lastXHR = this;
|
||||
}
|
||||
open(method, url) { this.method = method; this.url = url; }
|
||||
send(body) { this.body = body; }
|
||||
abort() {
|
||||
this.aborted = true;
|
||||
if (this.onabort) this.onabort();
|
||||
}
|
||||
}
|
||||
globalThis.XMLHttpRequest = FakeXHR;
|
||||
|
||||
// Мок fetch
|
||||
let fetchCalls = [];
|
||||
function installFetch(handler) {
|
||||
fetchCalls = [];
|
||||
globalThis.fetch = async (url, opts) => {
|
||||
fetchCalls.push({ url, opts });
|
||||
return handler(url, opts);
|
||||
};
|
||||
}
|
||||
|
||||
test('putToVm: успешная отправка', async () => {
|
||||
const f = new File(['test-content'], 'doc.pdf');
|
||||
const p = putToVm(f, 'https://vm-buffer/token_0');
|
||||
lastXHR.status = 200;
|
||||
lastXHR.onload();
|
||||
await p;
|
||||
assert.equal(lastXHR.method, 'PUT');
|
||||
assert.equal(lastXHR.url, 'https://vm-buffer/token_0');
|
||||
assert.equal(lastXHR.body, f);
|
||||
});
|
||||
|
||||
test('putToVm: ошибка HTTP статуса', async () => {
|
||||
const p = putToVm(new File(['abc'], 'doc.pdf'), 'https://vm-buffer/token_0');
|
||||
lastXHR.status = 502;
|
||||
lastXHR.statusText = 'Bad Gateway';
|
||||
lastXHR.onload();
|
||||
await assert.rejects(p, /HTTP 502/);
|
||||
});
|
||||
|
||||
test('putToVm: сетевая ошибка', async () => {
|
||||
const p = putToVm(new File(['abc'], 'doc.pdf'), 'https://vm-buffer/token_0');
|
||||
lastXHR.onerror();
|
||||
await assert.rejects(p, /Сетевая ошибка/);
|
||||
});
|
||||
|
||||
test('putToVm: таймаут', async () => {
|
||||
const p = putToVm(new File(['abc'], 'doc.pdf'), 'https://vm-buffer/token_0');
|
||||
lastXHR.ontimeout();
|
||||
await assert.rejects(p, /Таймаут/);
|
||||
});
|
||||
|
||||
test('putToVm: прерывание через AbortSignal', async () => {
|
||||
const ac = new AbortController();
|
||||
const p = putToVm(new File(['abc'], 'doc.pdf'), 'https://vm-buffer/token_0', { signal: ac.signal });
|
||||
ac.abort();
|
||||
await assert.rejects(p, (err) => err.name === 'AbortError');
|
||||
assert.equal(lastXHR.aborted, true);
|
||||
});
|
||||
|
||||
test('uploadViaVM: пофайловый транзит (N файлов -> N PUT + N upload_refs)', async () => {
|
||||
let callCount = 0;
|
||||
installFetch(async (url, opts) => {
|
||||
callCount++;
|
||||
const body = JSON.parse(opts.body);
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
session: body.session || 'created-sid-1',
|
||||
count: callCount,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const files = [
|
||||
new File(['hello'], 'first.txt'),
|
||||
new File(['world-data'], 'second.pdf'),
|
||||
];
|
||||
|
||||
const statuses = [];
|
||||
const completed = [];
|
||||
|
||||
const p = uploadViaVM(files, {
|
||||
vmUploadUrl: 'https://vm-buffer/upload/',
|
||||
backendUploadUrl: '/api/upload_refs',
|
||||
onFileStatus: (idx, status) => statuses.push({ idx, status }),
|
||||
onFileComplete: (info) => completed.push(info),
|
||||
});
|
||||
|
||||
// Файл 1: завершаем PUT
|
||||
await tick();
|
||||
assert.equal(lastXHR.method, 'PUT');
|
||||
assert.ok(lastXHR.url.startsWith('https://vm-buffer/upload/'));
|
||||
lastXHR.status = 201;
|
||||
lastXHR.onload();
|
||||
|
||||
// Файл 2: завершаем PUT
|
||||
await tick();
|
||||
assert.equal(lastXHR.method, 'PUT');
|
||||
assert.ok(lastXHR.url.startsWith('https://vm-buffer/upload/'));
|
||||
lastXHR.status = 201;
|
||||
lastXHR.onload();
|
||||
|
||||
const res = await p;
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.session, 'created-sid-1');
|
||||
assert.equal(res.count, 2);
|
||||
|
||||
// Проверяем, что было РОВНО 2 вызова fetch (/api/upload_refs), по одному на каждый файл
|
||||
assert.equal(fetchCalls.length, 2);
|
||||
|
||||
const call1Body = JSON.parse(fetchCalls[0].opts.body);
|
||||
assert.equal(call1Body.files.length, 1);
|
||||
assert.equal(call1Body.files[0].name, 'first.txt');
|
||||
|
||||
const call2Body = JSON.parse(fetchCalls[1].opts.body);
|
||||
assert.equal(call2Body.files.length, 1);
|
||||
assert.equal(call2Body.files[0].name, 'second.pdf');
|
||||
// Сессия, созданная на 1 шаге, проброшена во 2 шаг
|
||||
assert.equal(call2Body.session, 'created-sid-1');
|
||||
|
||||
assert.equal(completed.length, 2);
|
||||
assert.equal(completed[0].name, 'first.txt');
|
||||
assert.equal(completed[1].name, 'second.pdf');
|
||||
});
|
||||
|
||||
test('uploadViaVM: поддержка формата FilePicker.getFiles() ({name, size, file, path})', async () => {
|
||||
installFetch(async (url, opts) => {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, session: 'sid-p', count: 1 }),
|
||||
};
|
||||
});
|
||||
|
||||
const pickerFiles = [
|
||||
{
|
||||
name: 'archive_doc.txt',
|
||||
size: 42,
|
||||
file: new File(['x'.repeat(42)], 'archive_doc.txt'),
|
||||
path: 'folder/archive_doc.txt',
|
||||
},
|
||||
];
|
||||
|
||||
const p = uploadViaVM(pickerFiles, {
|
||||
vmUploadUrl: 'https://vm-buffer/upload/',
|
||||
});
|
||||
|
||||
await tick();
|
||||
lastXHR.status = 200;
|
||||
lastXHR.onload();
|
||||
|
||||
const res = await p;
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.count, 1);
|
||||
|
||||
const body = JSON.parse(fetchCalls[0].opts.body);
|
||||
assert.equal(body.files[0].name, 'archive_doc.txt');
|
||||
assert.equal(body.files[0].size, 42);
|
||||
});
|
||||
|
||||
test('uploadViaVM: обработка ошибки PUT', async () => {
|
||||
installFetch(async () => ({ ok: true, json: async () => ({ ok: true }) }));
|
||||
|
||||
const files = [new File(['bad'], 'err.txt')];
|
||||
const p = uploadViaVM(files, {
|
||||
vmUploadUrl: 'https://vm-buffer/upload/',
|
||||
});
|
||||
|
||||
await tick();
|
||||
lastXHR.onerror();
|
||||
|
||||
const res = await p;
|
||||
assert.equal(res.ok, false);
|
||||
assert.ok(res.error.includes('Ошибка отправки файла'));
|
||||
assert.equal(fetchCalls.length, 0); // POST не вызывался
|
||||
});
|
||||
|
||||
test('uploadViaVM: прерывание через signal', async () => {
|
||||
installFetch(async () => ({ ok: true, json: async () => ({ ok: true }) }));
|
||||
|
||||
const ac = new AbortController();
|
||||
const files = [new File(['1'], 'f1.txt'), new File(['2'], 'f2.txt')];
|
||||
|
||||
const p = uploadViaVM(files, {
|
||||
vmUploadUrl: 'https://vm-buffer/upload/',
|
||||
signal: ac.signal,
|
||||
});
|
||||
|
||||
await tick();
|
||||
ac.abort();
|
||||
|
||||
const res = await p;
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.aborted, true);
|
||||
});
|
||||
|
||||
test('uploadViaVM: отмена после первого файла не начинает второй PUT', async () => {
|
||||
installFetch(async (url, opts) => ({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, session: 'partial-sid', count: 1 }),
|
||||
}));
|
||||
|
||||
const ac = new AbortController();
|
||||
const statuses = [];
|
||||
const files = [new File(['first'], 'first.txt'), new File(['second'], 'second.txt')];
|
||||
const p = uploadViaVM(files, {
|
||||
vmUploadUrl: 'https://vm-buffer/upload/',
|
||||
signal: ac.signal,
|
||||
onFileStatus: (idx, status) => statuses.push({ idx, status }),
|
||||
});
|
||||
|
||||
await tick();
|
||||
assert.equal(lastXHR.url.endsWith('_0'), true);
|
||||
lastXHR.status = 201;
|
||||
lastXHR.onload();
|
||||
ac.abort();
|
||||
const res = await p;
|
||||
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.aborted, true);
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.equal(lastXHR.url.endsWith('_0'), true);
|
||||
assert.ok(statuses.some(({ idx, status }) => idx === 0 && status.includes('Доставлен')));
|
||||
});
|
||||
@@ -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)
|
||||
+26
-39
@@ -1,44 +1,31 @@
|
||||
# Upload module
|
||||
# Upload module (v0.2.0)
|
||||
|
||||
Для встраивания скопируйте один готовый bundle из `dist/`:
|
||||
Переиспользуемый модуль загрузки, состоящий из двух независимых слоёв:
|
||||
|
||||
- IIFE: `FilePicker.initFilePicker(config)`;
|
||||
- ESM: `import { initFilePicker } from './file-picker.esm.js'`.
|
||||
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) и проверку лимитов.
|
||||
|
||||
`config` должен содержать `mount` как DOM-элемент или CSS-селектор. Основные
|
||||
параметры: `allowedExt`, `labels`, `layout`, `limits`, `onChange(files)` и
|
||||
`onError(error, file)`. `onError` вызывается для ошибок разбора ZIP; обычные
|
||||
файлы с неподдерживаемым расширением просто пропускаются.
|
||||
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`).
|
||||
- Никакой записи на диск.
|
||||
|
||||
Публичный API picker-а:
|
||||
## Интеграция в сторонний проект
|
||||
|
||||
- `pickFiles()` и `pickFolder()` — открыть системный выбор;
|
||||
- `getFiles()` — получить leaf-файлы с `path`, `name`, `size` и `file`;
|
||||
- `addFiles(files)` — добавить файлы программно;
|
||||
- `remove(id)` и `clear()` — удалить узел или очистить дерево;
|
||||
- `render()` и `destroy()` — перерисовать или удалить экземпляр.
|
||||
|
||||
Picker поддерживает разрешённые документы, папки и вложенные ZIP, хранит
|
||||
исходные browser `File` objects и выполняет дедупликацию по пути и размеру.
|
||||
Загрузка на VM, backend sessions и upload API не используются. ZIP обрабатывается
|
||||
локально через встроенный в bundle `fflate`; отдельный vendor script не требуется.
|
||||
|
||||
## Legacy
|
||||
|
||||
Старые `uploadViaVM`, VM-буфер, backend sessions и upload API относятся к
|
||||
предыдущей архитектуре и в текущем модуле отсутствуют. Упоминания этих названий
|
||||
в `HISTORY/` являются историческими и не должны использоваться как инструкция.
|
||||
|
||||
Подробная таблица функций, входов, выходов и побочных эффектов находится в
|
||||
[`../docs/CODE-REFERENCE.md`](../docs/CODE-REFERENCE.md).
|
||||
|
||||
## Сборка из исходников
|
||||
|
||||
Исходники находятся в `frontend/`. В корне проекта:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
Результат сборки: `dist/file-picker.esm.js` и `dist/file-picker.iife.js`.
|
||||
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 @@
|
||||
"""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)
|
||||
@@ -187,4 +187,7 @@ export function initFilePicker(config) {
|
||||
};
|
||||
}
|
||||
|
||||
export { putToVm } from './upload/put_to_vm.js';
|
||||
export { uploadViaVM } from './upload/upload_via_vm.js';
|
||||
export { DEFAULTS };
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export function onFolderChange(state, cfg, elements) {
|
||||
if (!roots.has(rootName)) {
|
||||
// Один root на выбранный каталог позволяет сохранить дерево целиком.
|
||||
roots.set(rootName, { id: crypto.randomUUID(), kind: 'folder', name: rootName,
|
||||
path: rootName, children: [], expanded: true });
|
||||
path: rootName, children: [], expanded: false });
|
||||
}
|
||||
const root = roots.get(rootName);
|
||||
// Вставляет узел по его пути, создавая отсутствующие промежуточные папки.
|
||||
@@ -44,7 +44,7 @@ export function onFolderChange(state, cfg, elements) {
|
||||
if (!child) {
|
||||
child = last ? node : { id: crypto.randomUUID(), kind: 'folder', name: part,
|
||||
path: `${rootName}/${nodeParts.slice(0, index + 1).join('/')}`,
|
||||
children: [], expanded: true };
|
||||
children: [], expanded: false };
|
||||
current.children.push(child);
|
||||
}
|
||||
current = child;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -36,9 +36,9 @@ function safeEntryParts(entryName) {
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** Создаёт единый узел file, folder или zip с уникальным id и раскрытым состоянием. */
|
||||
/** Создаёт единый узел file, folder или zip с уникальным id и начальным состоянием раскрытия. */
|
||||
function node(kind, name, path, children = [], file = null) {
|
||||
return { id: crypto.randomUUID(), kind, name, path, children, file, expanded: true };
|
||||
return { id: crypto.randomUUID(), kind, name, path, children, file, expanded: kind === 'file' };
|
||||
}
|
||||
|
||||
/** Вставляет leaf или вложенное дерево по сегментам пути, создавая folder-узлы. */
|
||||
|
||||
Reference in New Issue
Block a user