From 112c84f11aaed5f18044d33cdd110fcf2c99fef3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sun, 6 Sep 2026 12:01:56 +0300 Subject: [PATCH] feat: implement Layer 2 per-file transit, RAM session, mock buffer, and tests (v0.2.0) --- ...6-09-06-layer2-implementation-and-tests.md | 69 +++++ README.md | 259 +++++++++++------- config.json | 6 +- dist/file-picker.esm.js | 201 +++++++++++++- dist/file-picker.iife.js | 201 +++++++++++++- package.json | 6 +- requirements.txt | 4 +- site/app.py | 128 ++++++++- site/templates/index.html | 103 ++++++- tests/test_app_integration.py | 115 ++++++++ tests/test_safe_name.py | 17 ++ tests/test_session.py | 61 +++++ tests/test_upload_layer2.test.mjs | 233 ++++++++++++++++ tests/test_upload_refs.py | 120 ++++++++ upload/README.md | 65 ++--- upload/backend/__init__.py | 1 + upload/backend/session/__init__.py | 38 +++ upload/backend/session/add_file.py | 25 ++ upload/backend/session/cancel.py | 27 ++ upload/backend/session/cleanup.py | 11 + upload/backend/session/create_session.py | 23 ++ upload/backend/session/get_files.py | 23 ++ upload/backend/session/state.py | 45 +++ upload/backend/session/store_csv.py | 30 ++ upload/backend/session/store_result.py | 30 ++ upload/backend/session/ttl.py | 34 +++ upload/backend/upload_refs/__init__.py | 7 + upload/backend/upload_refs/blueprint.py | 145 ++++++++++ upload/backend/upload_refs/config.py | 11 + upload/backend/upload_refs/pull_file.py | 39 +++ upload/backend/upload_refs/safe_name.py | 16 ++ upload/frontend/index.js | 3 + upload/frontend/upload/put_to_vm.js | 78 ++++++ upload/frontend/upload/upload_via_vm.js | 188 +++++++++++++ 34 files changed, 2200 insertions(+), 162 deletions(-) create mode 100644 HISTORY/2026-09-06-layer2-implementation-and-tests.md create mode 100644 tests/test_app_integration.py create mode 100644 tests/test_safe_name.py create mode 100644 tests/test_session.py create mode 100644 tests/test_upload_layer2.test.mjs create mode 100644 tests/test_upload_refs.py create mode 100644 upload/backend/__init__.py create mode 100644 upload/backend/session/__init__.py create mode 100644 upload/backend/session/add_file.py create mode 100644 upload/backend/session/cancel.py create mode 100644 upload/backend/session/cleanup.py create mode 100644 upload/backend/session/create_session.py create mode 100644 upload/backend/session/get_files.py create mode 100644 upload/backend/session/state.py create mode 100644 upload/backend/session/store_csv.py create mode 100644 upload/backend/session/store_result.py create mode 100644 upload/backend/session/ttl.py create mode 100644 upload/backend/upload_refs/__init__.py create mode 100644 upload/backend/upload_refs/blueprint.py create mode 100644 upload/backend/upload_refs/config.py create mode 100644 upload/backend/upload_refs/pull_file.py create mode 100644 upload/backend/upload_refs/safe_name.py create mode 100644 upload/frontend/upload/put_to_vm.js create mode 100644 upload/frontend/upload/upload_via_vm.js diff --git a/HISTORY/2026-09-06-layer2-implementation-and-tests.md b/HISTORY/2026-09-06-layer2-implementation-and-tests.md new file mode 100644 index 0000000..471fc72 --- /dev/null +++ b/HISTORY/2026-09-06-layer2-implementation-and-tests.md @@ -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/`), хранящий данные в 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`. diff --git a/README.md b/README.md index 030a6ea..e07bb6b 100644 --- a/README.md +++ b/README.md @@ -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 через ` + +``` -Актуальная инструкция интеграции находится в `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/). diff --git a/config.json b/config.json index 831d2db..61669ac 100644 --- a/config.json +++ b/config.json @@ -1,3 +1,5 @@ { - "allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"] -} \ No newline at end of file + "allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"], + "vmUploadUrl": "/mock-buffer/", + "vmUploadPrefix": "/mock-buffer/" +} diff --git a/dist/file-picker.esm.js b/dist/file-picker.esm.js index 81342e6..a4e0f78 100644 --- a/dist/file-picker.esm.js +++ b/dist/file-picker.esm.js @@ -805,6 +805,203 @@ function onFolderChange(state, cfg, elements) { }; } +// upload/frontend/upload/put_to_vm.js +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 || 3e5; + 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(`\u0411\u0443\u0444\u0435\u0440: HTTP ${xhr.status} ${xhr.statusText}`)); + } + }; + xhr.onerror = () => { + if (options.signal) { + options.signal.removeEventListener("abort", abortHandler); + } + reject(new Error("\u0421\u0435\u0442\u0435\u0432\u0430\u044F \u043E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u0438 \u043E\u0442\u043F\u0440\u0430\u0432\u043A\u0435 \u0432 \u0431\u0443\u0444\u0435\u0440")); + }; + xhr.ontimeout = () => { + if (options.signal) { + options.signal.removeEventListener("abort", abortHandler); + } + reject(new Error(`\u0422\u0430\u0439\u043C\u0430\u0443\u0442 \u043E\u0436\u0438\u0434\u0430\u043D\u0438\u044F \u043E\u0442\u0432\u0435\u0442\u0430 \u0431\u0443\u0444\u0435\u0440\u0430 (${Math.round(xhr.timeout / 1e3)}\u0441)`)); + }; + xhr.onabort = () => { + if (options.signal) { + options.signal.removeEventListener("abort", abortHandler); + } + reject(new DOMException("Upload aborted", "AbortError")); + }; + xhr.send(file); + }); +} + +// upload/frontend/upload/upload_via_vm.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 & 3 | 8; + return v.toString(16); + }); +} +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("\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 \u043E\u0431\u044A\u0435\u043A\u0442 \u0444\u0430\u0439\u043B\u0430"); +} +async function uploadViaVM(files, options = {}) { + if (!options.vmUploadUrl) { + return { ok: false, error: "\u041F\u0430\u0440\u0430\u043C\u0435\u0442\u0440 options.vmUploadUrl \u043E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u0435\u043D" }; + } + 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: "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u043E\u0442\u043C\u0435\u043D\u0435\u043D\u0430", aborted: true, session: currentSession }; + } + const { name, size, file } = normalizedFiles[k]; + const fileKey = `${generateUuid()}_${k}`; + const fileUrl = `${vmBase}${fileKey}`; + if (options.onStatus) { + options.onStatus(`\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u0432 \u0431\u0443\u0444\u0435\u0440 (${k + 1}/${total}): ${name}`); + } + if (options.onFileStatus) { + options.onFileStatus(k, "\u23F3 \u0412 \u0431\u0443\u0444\u0435\u0440..."); + } + const t0 = performance.now(); + try { + await putToVm(file, fileUrl, { + signal: options.signal, + onXHR: options.onXHR, + onProgress(pct, loaded, fileTotal) { + const elapsed = (performance.now() - t0) / 1e3; + const speed = elapsed > 0 ? loaded / elapsed : 0; + if (options.onFileStatus) { + options.onFileStatus(k, `\u23F3 ${pct}% (${fs(speed)}/s)`); + } + if (options.onProgress) { + options.onProgress({ index: k, total, pct, speed, loaded, fileTotal, name }); + } + } + }); + } catch (err2) { + if (options.signal?.aborted) { + return { ok: false, error: "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u043E\u0442\u043C\u0435\u043D\u0435\u043D\u0430", aborted: true, session: currentSession }; + } + if (options.onFileStatus) { + options.onFileStatus(k, "\u2717 \u041E\u0448\u0438\u0431\u043A\u0430 \u0431\u0443\u0444\u0435\u0440\u0430"); + } + return { + ok: false, + error: `\u041E\u0448\u0438\u0431\u043A\u0430 \u043E\u0442\u043F\u0440\u0430\u0432\u043A\u0438 \u0444\u0430\u0439\u043B\u0430 ${name} \u0432 \u0431\u0443\u0444\u0435\u0440: ${err2.message}`, + failedIndex: k, + session: currentSession + }; + } + if (options.onStatus) { + options.onStatus(`\u041F\u0440\u0438\u0451\u043C \u0441\u0435\u0440\u0432\u0438\u0441\u043E\u043C (${k + 1}/${total}): ${name}`); + } + if (options.onFileStatus) { + options.onFileStatus(k, "\u26A1 \u0412 \u0441\u0435\u0441\u0441\u0438\u044E..."); + } + 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 || "\u0411\u044D\u043A\u0435\u043D\u0434 \u0432\u0435\u0440\u043D\u0443\u043B \u043E\u0448\u0438\u0431\u043A\u0443"); + } + currentSession = data.session || currentSession; + totalAdded = data.count ?? totalAdded + 1; + if (options.onFileStatus) { + options.onFileStatus(k, "\u2713 \u0414\u043E\u0441\u0442\u0430\u0432\u043B\u0435\u043D"); + } + if (options.onFileComplete) { + options.onFileComplete({ + fileIndex: k, + name, + size, + session: currentSession, + totalAdded + }); + } + } catch (err2) { + if (options.signal?.aborted) { + return { ok: false, error: "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u043E\u0442\u043C\u0435\u043D\u0435\u043D\u0430", aborted: true, session: currentSession }; + } + if (options.onFileStatus) { + options.onFileStatus(k, "\u2717 \u041E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u0438\u0451\u043C\u0430"); + } + return { + ok: false, + error: `\u041E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u0438\u0451\u043C\u0430 \u0444\u0430\u0439\u043B\u0430 ${name} \u0431\u044D\u043A\u0435\u043D\u0434\u043E\u043C: ${err2.message}`, + failedIndex: k, + session: currentSession + }; + } + } + if (options.onStatus) { + options.onStatus(`\u0417\u0430\u0432\u0435\u0440\u0448\u0435\u043D\u043E. \u0423\u0441\u043F\u0435\u0448\u043D\u043E \u043F\u0435\u0440\u0435\u0434\u0430\u043D\u043E \u0444\u0430\u0439\u043B\u043E\u0432: ${total}`); + } + return { ok: true, session: currentSession, count: totalAdded }; +} + // upload/frontend/index.js var DEFAULTS = { allowedExt: [], @@ -991,5 +1188,7 @@ function initFilePicker(config) { } export { DEFAULTS, - initFilePicker + initFilePicker, + putToVm, + uploadViaVM }; diff --git a/dist/file-picker.iife.js b/dist/file-picker.iife.js index 4dbcf5d..ca25259 100644 --- a/dist/file-picker.iife.js +++ b/dist/file-picker.iife.js @@ -21,7 +21,9 @@ var FilePicker = (() => { var index_exports = {}; __export(index_exports, { DEFAULTS: () => DEFAULTS, - initFilePicker: () => initFilePicker + initFilePicker: () => initFilePicker, + putToVm: () => putToVm, + uploadViaVM: () => uploadViaVM }); // node_modules/fflate/esm/browser.js @@ -831,6 +833,203 @@ var FilePicker = (() => { }; } + // upload/frontend/upload/put_to_vm.js + 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 || 3e5; + 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(`\u0411\u0443\u0444\u0435\u0440: HTTP ${xhr.status} ${xhr.statusText}`)); + } + }; + xhr.onerror = () => { + if (options.signal) { + options.signal.removeEventListener("abort", abortHandler); + } + reject(new Error("\u0421\u0435\u0442\u0435\u0432\u0430\u044F \u043E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u0438 \u043E\u0442\u043F\u0440\u0430\u0432\u043A\u0435 \u0432 \u0431\u0443\u0444\u0435\u0440")); + }; + xhr.ontimeout = () => { + if (options.signal) { + options.signal.removeEventListener("abort", abortHandler); + } + reject(new Error(`\u0422\u0430\u0439\u043C\u0430\u0443\u0442 \u043E\u0436\u0438\u0434\u0430\u043D\u0438\u044F \u043E\u0442\u0432\u0435\u0442\u0430 \u0431\u0443\u0444\u0435\u0440\u0430 (${Math.round(xhr.timeout / 1e3)}\u0441)`)); + }; + xhr.onabort = () => { + if (options.signal) { + options.signal.removeEventListener("abort", abortHandler); + } + reject(new DOMException("Upload aborted", "AbortError")); + }; + xhr.send(file); + }); + } + + // upload/frontend/upload/upload_via_vm.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 & 3 | 8; + return v.toString(16); + }); + } + 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("\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 \u043E\u0431\u044A\u0435\u043A\u0442 \u0444\u0430\u0439\u043B\u0430"); + } + async function uploadViaVM(files, options = {}) { + if (!options.vmUploadUrl) { + return { ok: false, error: "\u041F\u0430\u0440\u0430\u043C\u0435\u0442\u0440 options.vmUploadUrl \u043E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u0435\u043D" }; + } + 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: "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u043E\u0442\u043C\u0435\u043D\u0435\u043D\u0430", aborted: true, session: currentSession }; + } + const { name, size, file } = normalizedFiles[k]; + const fileKey = `${generateUuid()}_${k}`; + const fileUrl = `${vmBase}${fileKey}`; + if (options.onStatus) { + options.onStatus(`\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u0432 \u0431\u0443\u0444\u0435\u0440 (${k + 1}/${total}): ${name}`); + } + if (options.onFileStatus) { + options.onFileStatus(k, "\u23F3 \u0412 \u0431\u0443\u0444\u0435\u0440..."); + } + const t0 = performance.now(); + try { + await putToVm(file, fileUrl, { + signal: options.signal, + onXHR: options.onXHR, + onProgress(pct, loaded, fileTotal) { + const elapsed = (performance.now() - t0) / 1e3; + const speed = elapsed > 0 ? loaded / elapsed : 0; + if (options.onFileStatus) { + options.onFileStatus(k, `\u23F3 ${pct}% (${fs(speed)}/s)`); + } + if (options.onProgress) { + options.onProgress({ index: k, total, pct, speed, loaded, fileTotal, name }); + } + } + }); + } catch (err2) { + if (options.signal?.aborted) { + return { ok: false, error: "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u043E\u0442\u043C\u0435\u043D\u0435\u043D\u0430", aborted: true, session: currentSession }; + } + if (options.onFileStatus) { + options.onFileStatus(k, "\u2717 \u041E\u0448\u0438\u0431\u043A\u0430 \u0431\u0443\u0444\u0435\u0440\u0430"); + } + return { + ok: false, + error: `\u041E\u0448\u0438\u0431\u043A\u0430 \u043E\u0442\u043F\u0440\u0430\u0432\u043A\u0438 \u0444\u0430\u0439\u043B\u0430 ${name} \u0432 \u0431\u0443\u0444\u0435\u0440: ${err2.message}`, + failedIndex: k, + session: currentSession + }; + } + if (options.onStatus) { + options.onStatus(`\u041F\u0440\u0438\u0451\u043C \u0441\u0435\u0440\u0432\u0438\u0441\u043E\u043C (${k + 1}/${total}): ${name}`); + } + if (options.onFileStatus) { + options.onFileStatus(k, "\u26A1 \u0412 \u0441\u0435\u0441\u0441\u0438\u044E..."); + } + 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 || "\u0411\u044D\u043A\u0435\u043D\u0434 \u0432\u0435\u0440\u043D\u0443\u043B \u043E\u0448\u0438\u0431\u043A\u0443"); + } + currentSession = data.session || currentSession; + totalAdded = data.count ?? totalAdded + 1; + if (options.onFileStatus) { + options.onFileStatus(k, "\u2713 \u0414\u043E\u0441\u0442\u0430\u0432\u043B\u0435\u043D"); + } + if (options.onFileComplete) { + options.onFileComplete({ + fileIndex: k, + name, + size, + session: currentSession, + totalAdded + }); + } + } catch (err2) { + if (options.signal?.aborted) { + return { ok: false, error: "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u043E\u0442\u043C\u0435\u043D\u0435\u043D\u0430", aborted: true, session: currentSession }; + } + if (options.onFileStatus) { + options.onFileStatus(k, "\u2717 \u041E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u0438\u0451\u043C\u0430"); + } + return { + ok: false, + error: `\u041E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u0438\u0451\u043C\u0430 \u0444\u0430\u0439\u043B\u0430 ${name} \u0431\u044D\u043A\u0435\u043D\u0434\u043E\u043C: ${err2.message}`, + failedIndex: k, + session: currentSession + }; + } + } + if (options.onStatus) { + options.onStatus(`\u0417\u0430\u0432\u0435\u0440\u0448\u0435\u043D\u043E. \u0423\u0441\u043F\u0435\u0448\u043D\u043E \u043F\u0435\u0440\u0435\u0434\u0430\u043D\u043E \u0444\u0430\u0439\u043B\u043E\u0432: ${total}`); + } + return { ok: true, session: currentSession, count: totalAdded }; + } + // upload/frontend/index.js var DEFAULTS = { allowedExt: [], diff --git a/package.json b/package.json index d09315b..6ee2367 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "upload-platform-file-picker", "private": true, - "version": "0.1.13", + "version": "0.2.0", "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" } } + diff --git a/requirements.txt b/requirements.txt index 0a5d8dd..1088040 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ Flask>=3.0 gunicorn>=21.2 -requests>=2.31 \ No newline at end of file +requests>=2.31 +httpx>=0.27.0 +pytest>=8.0.0 diff --git a/site/app.py b/site/app.py index fb76731..0103b07 100644 --- a/site/app.py +++ b/site/app.py @@ -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.0" -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/", 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/", 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/", 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//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/") def upload_frontend(filename): - """Отдаёт один frontend-файл из изолированного каталога upload/frontend.""" + """Отдаёт исходные модули фронтенда из каталога upload/frontend.""" return send_from_directory(ROOT / "upload" / "frontend", filename) @app.get("/file-picker/") 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) \ No newline at end of file + app.run(debug=False, host="0.0.0.0", port=5000) diff --git a/site/templates/index.html b/site/templates/index.html index f7a1bfb..9c4a2db 100644 --- a/site/templates/index.html +++ b/site/templates/index.html @@ -7,25 +7,114 @@ - +
-
-

FILE INTAKE / {{ version }}

+

FILE INTAKE & TRANSIT / {{ version }}

Загрузка документов

-

Выберите отдельные файлы или целую папку. Архивы будут раскрыты автоматически.

+

Выберите отдельные файлы или целую папку. Архивы будут распакованы на клиенте, после чего пофайлово доставлены в память сессии через буфер.

- +
+ + +
+
+ + + +
+ +
+ - \ No newline at end of file + diff --git a/tests/test_app_integration.py b/tests/test_app_integration.py new file mode 100644 index 0000000..8d4fbd6 --- /dev/null +++ b/tests/test_app_integration.py @@ -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) diff --git a/tests/test_safe_name.py b/tests/test_safe_name.py new file mode 100644 index 0000000..4193dde --- /dev/null +++ b/tests/test_safe_name.py @@ -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) == "" diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..1405cef --- /dev/null +++ b/tests/test_session.py @@ -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) diff --git a/tests/test_upload_layer2.test.mjs b/tests/test_upload_layer2.test.mjs new file mode 100644 index 0000000..8febfea --- /dev/null +++ b/tests/test_upload_layer2.test.mjs @@ -0,0 +1,233 @@ +// Юнит-тесты слоя 2 (фронт): putToVm + uploadViaVM с пофайловым транзитом. +// Запуск: node --test tests/test_upload_layer2.test.mjs + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +// Полифилл File/Blob при необходимости +if (typeof globalThis.File === 'undefined') { + globalThis.File = class File extends Blob { + constructor(parts, name, opts) { + super(parts, opts); + this.name = name; + this.lastModified = (opts && opts.lastModified) || Date.now(); + } + }; +} + +import { putToVm } from '../upload/frontend/upload/put_to_vm.js'; +import { uploadViaVM } from '../upload/frontend/upload/upload_via_vm.js'; + +const tick = () => new Promise(r => setTimeout(r, 0)); + +// Мок XMLHttpRequest +let lastXHR = null; +class FakeXHR { + constructor() { + this.upload = {}; + this.status = 0; + this.timeout = 0; + this.onload = null; + this.onerror = null; + this.ontimeout = null; + this.onabort = null; + this.method = null; + this.url = null; + this.body = null; + this.aborted = false; + lastXHR = this; + } + open(method, url) { this.method = method; this.url = url; } + send(body) { this.body = body; } + abort() { + this.aborted = true; + if (this.onabort) this.onabort(); + } +} +globalThis.XMLHttpRequest = FakeXHR; + +// Мок fetch +let fetchCalls = []; +function installFetch(handler) { + fetchCalls = []; + globalThis.fetch = async (url, opts) => { + fetchCalls.push({ url, opts }); + return handler(url, opts); + }; +} + +test('putToVm: успешная отправка', async () => { + const f = new File(['test-content'], 'doc.pdf'); + const p = putToVm(f, 'https://vm-buffer/token_0'); + lastXHR.status = 200; + lastXHR.onload(); + await p; + assert.equal(lastXHR.method, 'PUT'); + assert.equal(lastXHR.url, 'https://vm-buffer/token_0'); + assert.equal(lastXHR.body, f); +}); + +test('putToVm: ошибка HTTP статуса', async () => { + const p = putToVm(new File(['abc'], 'doc.pdf'), 'https://vm-buffer/token_0'); + lastXHR.status = 502; + lastXHR.statusText = 'Bad Gateway'; + lastXHR.onload(); + await assert.rejects(p, /HTTP 502/); +}); + +test('putToVm: сетевая ошибка', async () => { + const p = putToVm(new File(['abc'], 'doc.pdf'), 'https://vm-buffer/token_0'); + lastXHR.onerror(); + await assert.rejects(p, /Сетевая ошибка/); +}); + +test('putToVm: таймаут', async () => { + const p = putToVm(new File(['abc'], 'doc.pdf'), 'https://vm-buffer/token_0'); + lastXHR.ontimeout(); + await assert.rejects(p, /Таймаут/); +}); + +test('putToVm: прерывание через AbortSignal', async () => { + const ac = new AbortController(); + const p = putToVm(new File(['abc'], 'doc.pdf'), 'https://vm-buffer/token_0', { signal: ac.signal }); + ac.abort(); + await assert.rejects(p, (err) => err.name === 'AbortError'); + assert.equal(lastXHR.aborted, true); +}); + +test('uploadViaVM: пофайловый транзит (N файлов -> N PUT + N upload_refs)', async () => { + let callCount = 0; + installFetch(async (url, opts) => { + callCount++; + const body = JSON.parse(opts.body); + return { + ok: true, + json: async () => ({ + ok: true, + session: body.session || 'created-sid-1', + count: callCount, + }), + }; + }); + + const files = [ + new File(['hello'], 'first.txt'), + new File(['world-data'], 'second.pdf'), + ]; + + const statuses = []; + const completed = []; + + const p = uploadViaVM(files, { + vmUploadUrl: 'https://vm-buffer/upload/', + backendUploadUrl: '/api/upload_refs', + onFileStatus: (idx, status) => statuses.push({ idx, status }), + onFileComplete: (info) => completed.push(info), + }); + + // Файл 1: завершаем PUT + await tick(); + assert.equal(lastXHR.method, 'PUT'); + assert.ok(lastXHR.url.startsWith('https://vm-buffer/upload/')); + lastXHR.status = 201; + lastXHR.onload(); + + // Файл 2: завершаем PUT + await tick(); + assert.equal(lastXHR.method, 'PUT'); + assert.ok(lastXHR.url.startsWith('https://vm-buffer/upload/')); + lastXHR.status = 201; + lastXHR.onload(); + + const res = await p; + assert.equal(res.ok, true); + assert.equal(res.session, 'created-sid-1'); + assert.equal(res.count, 2); + + // Проверяем, что было РОВНО 2 вызова fetch (/api/upload_refs), по одному на каждый файл + assert.equal(fetchCalls.length, 2); + + const call1Body = JSON.parse(fetchCalls[0].opts.body); + assert.equal(call1Body.files.length, 1); + assert.equal(call1Body.files[0].name, 'first.txt'); + + const call2Body = JSON.parse(fetchCalls[1].opts.body); + assert.equal(call2Body.files.length, 1); + assert.equal(call2Body.files[0].name, 'second.pdf'); + // Сессия, созданная на 1 шаге, проброшена во 2 шаг + assert.equal(call2Body.session, 'created-sid-1'); + + assert.equal(completed.length, 2); + assert.equal(completed[0].name, 'first.txt'); + assert.equal(completed[1].name, 'second.pdf'); +}); + +test('uploadViaVM: поддержка формата FilePicker.getFiles() ({name, size, file, path})', async () => { + installFetch(async (url, opts) => { + return { + ok: true, + json: async () => ({ ok: true, session: 'sid-p', count: 1 }), + }; + }); + + const pickerFiles = [ + { + name: 'archive_doc.txt', + size: 42, + file: new File(['x'.repeat(42)], 'archive_doc.txt'), + path: 'folder/archive_doc.txt', + }, + ]; + + const p = uploadViaVM(pickerFiles, { + vmUploadUrl: 'https://vm-buffer/upload/', + }); + + await tick(); + lastXHR.status = 200; + lastXHR.onload(); + + const res = await p; + assert.equal(res.ok, true); + assert.equal(res.count, 1); + + const body = JSON.parse(fetchCalls[0].opts.body); + assert.equal(body.files[0].name, 'archive_doc.txt'); + assert.equal(body.files[0].size, 42); +}); + +test('uploadViaVM: обработка ошибки PUT', async () => { + installFetch(async () => ({ ok: true, json: async () => ({ ok: true }) })); + + const files = [new File(['bad'], 'err.txt')]; + const p = uploadViaVM(files, { + vmUploadUrl: 'https://vm-buffer/upload/', + }); + + await tick(); + lastXHR.onerror(); + + const res = await p; + assert.equal(res.ok, false); + assert.ok(res.error.includes('Ошибка отправки файла')); + assert.equal(fetchCalls.length, 0); // POST не вызывался +}); + +test('uploadViaVM: прерывание через signal', async () => { + installFetch(async () => ({ ok: true, json: async () => ({ ok: true }) })); + + const ac = new AbortController(); + const files = [new File(['1'], 'f1.txt'), new File(['2'], 'f2.txt')]; + + const p = uploadViaVM(files, { + vmUploadUrl: 'https://vm-buffer/upload/', + signal: ac.signal, + }); + + await tick(); + ac.abort(); + + const res = await p; + assert.equal(res.ok, false); + assert.equal(res.aborted, true); +}); diff --git a/tests/test_upload_refs.py b/tests/test_upload_refs.py new file mode 100644 index 0000000..bfb4562 --- /dev/null +++ b/tests/test_upload_refs.py @@ -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) diff --git a/upload/README.md b/upload/README.md index 9178f1c..943beb1 100644 --- a/upload/README.md +++ b/upload/README.md @@ -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`. \ No newline at end of file +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)`. diff --git a/upload/backend/__init__.py b/upload/backend/__init__.py new file mode 100644 index 0000000..0cdaa2f --- /dev/null +++ b/upload/backend/__init__.py @@ -0,0 +1 @@ +"""Backend переиспользуемого модуля загрузки: upload_refs + session.""" diff --git a/upload/backend/session/__init__.py b/upload/backend/session/__init__.py new file mode 100644 index 0000000..1b8b0f4 --- /dev/null +++ b/upload/backend/session/__init__.py @@ -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", +] diff --git a/upload/backend/session/add_file.py b/upload/backend/session/add_file.py new file mode 100644 index 0000000..f598d63 --- /dev/null +++ b/upload/backend/session/add_file.py @@ -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 diff --git a/upload/backend/session/cancel.py b/upload/backend/session/cancel.py new file mode 100644 index 0000000..ad39d8a --- /dev/null +++ b/upload/backend/session/cancel.py @@ -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 diff --git a/upload/backend/session/cleanup.py b/upload/backend/session/cleanup.py new file mode 100644 index 0000000..be0d8f2 --- /dev/null +++ b/upload/backend/session/cleanup.py @@ -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() diff --git a/upload/backend/session/create_session.py b/upload/backend/session/create_session.py new file mode 100644 index 0000000..6c29d8a --- /dev/null +++ b/upload/backend/session/create_session.py @@ -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 diff --git a/upload/backend/session/get_files.py b/upload/backend/session/get_files.py new file mode 100644 index 0000000..74a6bca --- /dev/null +++ b/upload/backend/session/get_files.py @@ -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 diff --git a/upload/backend/session/state.py b/upload/backend/session/state.py new file mode 100644 index 0000000..dc70a72 --- /dev/null +++ b/upload/backend/session/state.py @@ -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 diff --git a/upload/backend/session/store_csv.py b/upload/backend/session/store_csv.py new file mode 100644 index 0000000..f7b6d4a --- /dev/null +++ b/upload/backend/session/store_csv.py @@ -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 diff --git a/upload/backend/session/store_result.py b/upload/backend/session/store_result.py new file mode 100644 index 0000000..e39542a --- /dev/null +++ b/upload/backend/session/store_result.py @@ -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 diff --git a/upload/backend/session/ttl.py b/upload/backend/session/ttl.py new file mode 100644 index 0000000..06abb9a --- /dev/null +++ b/upload/backend/session/ttl.py @@ -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) diff --git a/upload/backend/upload_refs/__init__.py b/upload/backend/upload_refs/__init__.py new file mode 100644 index 0000000..71d986b --- /dev/null +++ b/upload/backend/upload_refs/__init__.py @@ -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"] diff --git a/upload/backend/upload_refs/blueprint.py b/upload/backend/upload_refs/blueprint.py new file mode 100644 index 0000000..e1c53f9 --- /dev/null +++ b/upload/backend/upload_refs/blueprint.py @@ -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 diff --git a/upload/backend/upload_refs/config.py b/upload/backend/upload_refs/config.py new file mode 100644 index 0000000..b6ccc17 --- /dev/null +++ b/upload/backend/upload_refs/config.py @@ -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/" diff --git a/upload/backend/upload_refs/pull_file.py b/upload/backend/upload_refs/pull_file.py new file mode 100644 index 0000000..9fab75c --- /dev/null +++ b/upload/backend/upload_refs/pull_file.py @@ -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") diff --git a/upload/backend/upload_refs/safe_name.py b/upload/backend/upload_refs/safe_name.py new file mode 100644 index 0000000..8eca581 --- /dev/null +++ b/upload/backend/upload_refs/safe_name.py @@ -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) diff --git a/upload/frontend/index.js b/upload/frontend/index.js index c3b3edc..f360dd7 100644 --- a/upload/frontend/index.js +++ b/upload/frontend/index.js @@ -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 }; + diff --git a/upload/frontend/upload/put_to_vm.js b/upload/frontend/upload/put_to_vm.js new file mode 100644 index 0000000..b2e6fa4 --- /dev/null +++ b/upload/frontend/upload/put_to_vm.js @@ -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} Разрешается при ответе 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); + }); +} diff --git a/upload/frontend/upload/upload_via_vm.js b/upload/frontend/upload/upload_via_vm.js new file mode 100644 index 0000000..8386e58 --- /dev/null +++ b/upload/frontend/upload/upload_via_vm.js @@ -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} 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 }; +}