feat: implement Layer 2 per-file transit, RAM session, mock buffer, and tests (v0.2.0)

This commit is contained in:
“Naeel”
2026-09-06 12:01:56 +03:00
parent 0ea7ba058c
commit 112c84f11a
34 changed files with 2200 additions and 162 deletions
@@ -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`.
+163 -96
View File
@@ -1,129 +1,196 @@
# Upload Platform # Upload Platform
Переиспользуемый browser-only file-picker версии `0.1.13`. Он выбирает файлы, Модульная платформа загрузки и транзита документов (версия `0.2.0`). Состоит из двух независимых автономных слоёв с чистыми API:
папки и ZIP, строит дерево и возвращает интегрирующему приложению browser
`File` objects. Содержимое файлов остаётся в браузере: текущий проект не
загружает его на VM, в Flask или в другое хранилище.
## Карта файлов 1. **Слой 1 (File Picker)**: Клиентский выбор файлов, папок и вложенных ZIP-архивов с дедупликацией, фильтрацией по расширениям, проверкой лимитов и визуализацией в виде дерева.
2. **Слой 2 (Transit Upload via VM Buffer)**: Пофайловый потоковый транзит файлов из браузера в оперативную память (RAM) сессии бэкенда через внешний WebDAV-буфер на ВМ. Решает проблему ограничения Ingress Kubernetes на входящий payload (64 КБ) за счёт исходящего pull из кластера.
### Корень проекта ---
| Путь | Назначение | Статус | ## Архитектура слоёв и API
|---|---|---|
| `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/` | Архив решений, ревью и результатов проверок. | Только история |
### `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 ]
```
| Путь | Назначение | ### Слой 1: File Picker API
|---|---| - **Вход**: DOM-контейнер (`mount`), разрешённые расширения (`allowedExt`), лимиты (`limits`).
| `upload/README.md` | Краткая инструкция интеграции готового bundle. | - **Выход**: `picker.getFiles()` возвращает плоский массив объектов `{ path, name, size, file }`.
| `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`. |
### `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/` ## Структура репозитория
| Путь | Назначение | Статус | ```
|---|---|---| upload-platform/
| `docs/CODE-REFERENCE.md` | Справочник актуального picker-кода и API. | Использовать | ├── upload/ # Переиспользуемый встраиваемый модуль
| `docs/PLAN-componentization.md` | Исторический план перехода к bundle API. | LEGACY, не использовать как план | │ ├── frontend/
| `docs/sonnet-architecture-review-prompt.md` | Исторический prompt ревью старой архитектуры. | LEGACY, не использовать | │ │ ├── index.js # Точка входа фронтенда (initFilePicker, uploadViaVM, putToVm)
| `docs/*-architecture-review-response.md` | Ответы на исторические ревью. | История, не спецификация | │ │ ├── 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 ```bash
pip install -r requirements.txt pip install -r requirements.txt
npm install npm install
npm run build
python site/app.py
# → http://127.0.0.1:5000/
``` ```
Для запуска уже собранного demo достаточно иметь зависимости Python и ### 2. Сборка фронтенд-бандлов
содержимое `dist/`. `npm run build` требуется после изменения исходников ```bash
`upload/frontend/`. 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`). ### 4. Запуск всех тестов
- `app.run(host="0.0.0.0", port=5000, debug=False)`. ```bash
- Маршрут `/health``200` (иначе liveness-проба платформы убивает под). # Бэкенд тесты (pytest)
- Без `site/__init__.py` и factory pattern. 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; ## Инструкция: интеграция в сторонний сервис за 2 минуты
встроенная библиотека `fflate` уже включена в готовые bundle.
Разрешённые расширения демо: `.pdf`, `.doc`, `.docx`, `.txt`, `.md`. Чтобы встроить оба слоя в целевой сервис (`drhider`, `contractor` и др.):
ZIP-файлы используются как контейнеры и раскрываются в браузере; сам ZIP не
возвращается как leaf-файл. По умолчанию действуют ограничения: 1000 entries,
100 MiB суммарно, 50 MiB на entry и глубина 20.
## 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_bp = create_upload_refs_blueprint({
- `upload/frontend/table/init_upload_table.js` — старый entry point с готовыми DOM-узлами; "apiPrefix": "/api",
- `upload/frontend/table/set_status.js` — неиспользуемый legacy helper; "vmUploadPrefix": "https://contracts.kube5s.ru/drhider-upload/", # Префикс вашего буфера
- `upload/frontend/upload/` — старый frontend VM-upload layer. "maxFileBytes": 50 * 1024 * 1024,
"maxSessionBytes": 500 * 1024 * 1024,
})
app.register_blueprint(upload_bp)
```
Эти части не входят в сборку `build.mjs`, не импортируются текущим picker-ом и ### 3. Подключить фронтенд
не являются частью API. Не добавлять их обратно при интеграции. В шаблоне страницы:
```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/` document.getElementById('upload-btn').addEventListener('click', async () => {
могут содержать описания старой VM/backend-архитектуры. Они нужны для аудита 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/).
+3 -1
View File
@@ -1,3 +1,5 @@
{ {
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"] "allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"],
"vmUploadUrl": "/mock-buffer/",
"vmUploadPrefix": "/mock-buffer/"
} }
+200 -1
View File
@@ -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 // upload/frontend/index.js
var DEFAULTS = { var DEFAULTS = {
allowedExt: [], allowedExt: [],
@@ -991,5 +1188,7 @@ function initFilePicker(config) {
} }
export { export {
DEFAULTS, DEFAULTS,
initFilePicker initFilePicker,
putToVm,
uploadViaVM
}; };
+200 -1
View File
@@ -21,7 +21,9 @@ var FilePicker = (() => {
var index_exports = {}; var index_exports = {};
__export(index_exports, { __export(index_exports, {
DEFAULTS: () => DEFAULTS, DEFAULTS: () => DEFAULTS,
initFilePicker: () => initFilePicker initFilePicker: () => initFilePicker,
putToVm: () => putToVm,
uploadViaVM: () => uploadViaVM
}); });
// node_modules/fflate/esm/browser.js // 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 // upload/frontend/index.js
var DEFAULTS = { var DEFAULTS = {
allowedExt: [], allowedExt: [],
+4 -2
View File
@@ -1,10 +1,11 @@
{ {
"name": "upload-platform-file-picker", "name": "upload-platform-file-picker",
"private": true, "private": true,
"version": "0.1.13", "version": "0.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "node build.mjs" "build": "node build.mjs",
"test": "node --test tests/*.test.mjs"
}, },
"dependencies": { "dependencies": {
"fflate": "^0.8.3" "fflate": "^0.8.3"
@@ -13,3 +14,4 @@
"esbuild": "^0.25.0" "esbuild": "^0.25.0"
} }
} }
+2
View File
@@ -1,3 +1,5 @@
Flask>=3.0 Flask>=3.0
gunicorn>=21.2 gunicorn>=21.2
requests>=2.31 requests>=2.31
httpx>=0.27.0
pytest>=8.0.0
+114 -12
View File
@@ -1,46 +1,148 @@
import json import json
import logging
from pathlib import Path 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 ROOT = Path(__file__).resolve().parent.parent
# Конфигурация демо содержит только разрешённые расширения для browser picker. if str(ROOT) not in sys.path:
DEFAULT_CONFIG = {"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"]} 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: try:
with (ROOT / "config.json").open(encoding="utf-8") as config_file: 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): except (OSError, json.JSONDecodeError):
CONFIG = DEFAULT_CONFIG 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/<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("/") @app.route("/")
def index(): def index():
"""Возвращает demo-страницу и передаёт в неё версию и JSON-конфигурацию.""" """Возвращает demo-страницу со Слой 1 (picker) и Слой 2 (transit upload)."""
return render_template("index.html", version=VERSION, config=CONFIG) return render_template("index.html", version=VERSION, config=CONFIG)
@app.route("/health") @app.route("/health")
def health(): def health():
"""Возвращает 200 для liveness-проверки платформы.""" """Liveness-проверка платформы."""
return "ok", 200 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>") @app.get("/upload-frontend/<path:filename>")
def upload_frontend(filename): def upload_frontend(filename):
"""Отдаёт один frontend-файл из изолированного каталога upload/frontend.""" """Отдаёт исходные модули фронтенда из каталога upload/frontend."""
return send_from_directory(ROOT / "upload" / "frontend", filename) return send_from_directory(ROOT / "upload" / "frontend", filename)
@app.get("/file-picker/<path:filename>") @app.get("/file-picker/<path:filename>")
def file_picker_dist(filename): def file_picker_dist(filename):
"""Отдаёт собранный универсальный picker-бандл из каталога dist.""" """Отдаёт собранный бандл из каталога dist."""
return send_from_directory(ROOT / "dist", filename) return send_from_directory(ROOT / "dist", filename)
+95 -6
View File
@@ -7,24 +7,113 @@
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head> </head>
<body> <body>
<!-- Основной контейнер демо: Flask только подставляет version, остальное работает в браузере. --> <!-- Основной контейнер стенда: Слой 1 (выбор) + Слой 2 (пофайловый транзит через RAM) -->
<main class="page"> <main class="page">
<!-- Заголовок показывает назначение picker и текущую версию сборки. -->
<header> <header>
<p class="eyebrow">FILE INTAKE / {{ version }}</p> <p class="eyebrow">FILE INTAKE &amp; TRANSIT / {{ version }}</p>
<h1>Загрузка документов</h1> <h1>Загрузка документов</h1>
<p class="lede">Выберите отдельные файлы или целую папку. Архивы будут раскрыты автоматически.</p> <p class="lede">Выберите отдельные файлы или целую папку. Архивы будут распакованы на клиенте, после чего пофайлово доставлены в память сессии через буфер.</p>
</header> </header>
<!-- Скрытые input дают браузеру FileList; кнопки ниже программно вызывают click(). -->
<section class="toolbar" aria-label="Выбор файлов"> <section class="toolbar" aria-label="Выбор файлов">
<div id="file-picker"></div> <div id="file-picker"></div>
</section> </section>
<!-- Секция действий Слоя 2: пофайловый транзит в RAM сессии бэкенда -->
<section class="upload-section" style="margin-top: 24px; padding: 20px; background: #f2f6f1; border-radius: 6px; border: 1px solid #c8d6c7;">
<div style="display: flex; gap: 12px; align-items: center; flex-wrap: wrap;">
<button id="upload-btn" type="button" disabled style="background: #1b4d3e;">
⚡ Загрузить в сессию (Слой 2 транзит)
</button>
<button id="cancel-btn" type="button" class="quiet" style="display: none; color: #8c5148;">
✕ Отмена
</button>
<span id="upload-status" style="font: 14px sans-serif; color: #2d4a36;"></span>
</div>
<div id="session-info" style="margin-top: 14px; font: 13px sans-serif; display: none;">
<strong>Сессия в RAM:</strong> <code id="session-id"></code>
<div id="session-files" style="margin-top: 6px; color: #46604e;"></div>
</div>
</section>
</main> </main>
<script src="{{ url_for('file_picker_dist', filename='file-picker.iife.js', v=version) }}"></script> <script src="{{ url_for('file_picker_dist', filename='file-picker.iife.js', v=version) }}"></script>
<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');
let abortCtrl = null;
let selectedFiles = [];
const picker = FilePicker.initFilePicker({
mount: '#file-picker', mount: '#file-picker',
allowedExt: {{ config.allowedExt|tojson }}, allowedExt: {{ config.allowedExt|tojson }},
onChange: (files) => {
selectedFiles = files;
uploadBtn.disabled = files.length === 0;
uploadStatus.textContent = files.length ? `Готово к отправке: ${files.length} файл(ов)` : '';
}
});
uploadBtn.addEventListener('click', async () => {
const files = picker.getFiles();
if (!files.length) return;
uploadBtn.disabled = true;
cancelBtn.style.display = 'inline-block';
sessionInfo.style.display = 'none';
abortCtrl = new AbortController();
const vmUploadUrl = new URL({{ config.vmUploadUrl|tojson }}, window.location.origin).href;
const res = await FilePicker.uploadViaVM(files, {
vmUploadUrl: vmUploadUrl,
backendUploadUrl: '/api/upload_refs',
signal: abortCtrl.signal,
onStatus: (msg) => { uploadStatus.textContent = msg; },
onFileStatus: (k, statusText) => {
const row = document.querySelectorAll('.tree-row.tree-file')[k];
if (row) {
const statusCell = row.querySelectorAll('td')[2];
if (statusCell) statusCell.innerHTML = statusText;
}
},
onFileComplete: (info) => {
console.log('File delivered to RAM:', info);
}
});
cancelBtn.style.display = 'none';
uploadBtn.disabled = selectedFiles.length === 0;
if (res.ok) {
uploadStatus.innerHTML = '<span style="color:#1f5a3b; font-weight: bold;">✓ Все ' + res.count + ' файл(ов) успешно доставлены в RAM бэкенда!</span>';
sessionIdEl.textContent = res.session;
sessionInfo.style.display = 'block';
try {
const sResp = await fetch(`/api/session/${res.session}/files`);
const sData = await sResp.json();
if (sData.ok) {
sessionFilesEl.innerHTML = 'Сохранено в сессии RAM: ' +
sData.files.map(f => `<code>${f.name}</code> (${f.size} B)`).join(', ');
}
} catch (e) {
console.error('Failed to load session files', e);
}
} else if (res.aborted) {
uploadStatus.innerHTML = '<span style="color:#8c5148;">Загрузка отменена пользователем</span>';
} else {
uploadStatus.innerHTML = '<span style="color:#b32f22; font-weight: bold;">✗ Ошибка: ' + res.error + '</span>';
}
});
cancelBtn.addEventListener('click', () => {
if (abortCtrl) abortCtrl.abort();
}); });
</script> </script>
</body> </body>
+115
View File
@@ -0,0 +1,115 @@
import importlib.util
import json
from pathlib import Path
import pytest
app_path = Path(__file__).resolve().parent.parent / "site" / "app.py"
spec = importlib.util.spec_from_file_location("site_app", app_path)
site_app = importlib.util.module_from_spec(spec)
spec.loader.exec_module(site_app)
app = site_app.app
_mock_storage = site_app._mock_storage
_mock_storage_lock = site_app._mock_storage_lock
from upload.backend.session import cleanup, get_files
import httpx
@pytest.fixture
def client():
app.config["TESTING"] = True
app.config["UPLOAD_HTTPX_TRANSPORT"] = httpx.WSGITransport(app=app)
with app.test_client() as client:
yield client
def test_health(client):
res = client.get("/health")
assert res.status_code == 200
assert res.data == b"ok"
def test_index_page(client):
res = client.get("/")
assert res.status_code == 200
assert b"upload-btn" in res.data
assert b"file-picker" in res.data
def test_mock_buffer_crud(client):
key = "test_item_1"
# PUT
res = client.put(f"/mock-buffer/{key}", data=b"binary_payload_123")
assert res.status_code == 201
# GET
res = client.get(f"/mock-buffer/{key}")
assert res.status_code == 200
assert res.data == b"binary_payload_123"
# Status
res = client.get("/mock-buffer/status")
assert res.status_code == 200
data = res.get_json()
assert data["count"] >= 1
assert any(item["key"] == key for item in data["items"])
# DELETE
res = client.delete(f"/mock-buffer/{key}")
assert res.status_code == 204
# GET after delete -> 404
res = client.get(f"/mock-buffer/{key}")
assert res.status_code == 404
def test_full_transit_flow_mock(client):
"""Тестирует пофайловый транзит через mock-буфер и приём в сессию RAM."""
with _mock_storage_lock:
_mock_storage.clear()
# Шаг 1: Браузер кладёт файл в mock-буфер
key = "token_uuid_0"
file_bytes = b"%PDF-1.4 test document content for transit"
res = client.put(f"/mock-buffer/{key}", data=file_bytes)
assert res.status_code == 201
# Проверяем, что файл в буфере
with _mock_storage_lock:
assert key in _mock_storage
# Шаг 2: Браузер вызывает /api/upload_refs для этого файла
# Используем относительный URL пути /mock-buffer/key
res = client.post("/api/upload_refs", json={
"files": [
{"name": "contract.pdf", "size": len(file_bytes), "url": f"/mock-buffer/{key}"}
]
})
assert res.status_code == 200
data = res.get_json()
assert data["ok"] is True
sid = data["session"]
assert data["count"] == 1
assert data["added"] == 1
# Шаг 3: Проверяем, что файл переместился в RAM сессии
files = get_files(sid)
assert len(files) == 1
assert files[0] == ("contract.pdf", file_bytes)
# Шаг 4: Проверяем, что файл удалился из mock-буфера (RAM буфера очищен)
with _mock_storage_lock:
assert key not in _mock_storage
# Шаг 5: Проверяем эндпоинт проверки сессии
res = client.get(f"/api/session/{sid}/files")
assert res.status_code == 200
s_data = res.get_json()
assert s_data["ok"] is True
assert s_data["session"] == sid
assert len(s_data["files"]) == 1
assert s_data["files"][0]["name"] == "contract.pdf"
assert s_data["files"][0]["size"] == len(file_bytes)
cleanup(sid)
+17
View File
@@ -0,0 +1,17 @@
import pytest
from upload.backend.upload_refs.safe_name import safe_name
def test_safe_name_simple():
assert safe_name("test.txt") == "test.txt"
assert safe_name("folder/subfolder/file.pdf") == "folder/subfolder/file.pdf"
assert safe_name("folder\\subfolder\\file.pdf") == "folder/subfolder/file.pdf"
def test_safe_name_traversal():
assert safe_name("../etc/passwd") == ""
assert safe_name("folder/../../etc/passwd") == ""
assert safe_name("/root/file.txt") == "root/file.txt"
assert safe_name("..") == ""
assert safe_name("") == ""
assert safe_name(None) == ""
+61
View File
@@ -0,0 +1,61 @@
import pytest
from upload.backend.session import (
create_session, add_file, get_files, file_count,
store_result, get_result, store_csv, get_csv,
touch, pause_ttl, resume_ttl, request_cancel, get_cancel_event,
cleanup, configure, MAX_FILE_BYTES, MAX_SESSION_BYTES
)
from upload.backend.session.state import _sessions
def test_session_lifecycle():
sid = create_session()
assert sid in _sessions
assert file_count(sid) == 0
assert get_files(sid) == []
# Add file
ok = add_file(sid, "doc.txt", b"hello world")
assert ok is True
assert file_count(sid) == 1
files = get_files(sid)
assert len(files) == 1
assert files[0] == ("doc.txt", b"hello world")
# Store result and csv
assert store_result(sid, b"PK...zip") is True
assert get_result(sid) == b"PK...zip"
assert store_csv(sid, "col1,col2\nval1,val2") is True
assert get_csv(sid) == "col1,col2\nval1,val2"
# Cancel event
ev = get_cancel_event(sid)
assert ev is not None
assert not ev.is_set()
assert request_cancel(sid) is True
assert ev.is_set()
# TTL methods do not raise
touch(sid)
pause_ttl(sid)
resume_ttl(sid)
# Cleanup
cleanup(sid)
assert sid not in _sessions
assert get_files(sid) is None
assert file_count(sid) == 0
def test_session_limits():
sid = create_session()
configure(max_session_bytes=100)
try:
# Add 60 bytes - ok
assert add_file(sid, "f1.bin", b"x" * 60) is True
# Add 50 bytes - should fail (60 + 50 > 100)
assert add_file(sid, "f2.bin", b"x" * 50) is False
assert file_count(sid) == 1
finally:
configure(max_session_bytes=500 * 1024 * 1024)
cleanup(sid)
+233
View File
@@ -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);
});
+120
View File
@@ -0,0 +1,120 @@
import threading
from http.server import HTTPServer, BaseHTTPRequestHandler
import pytest
from flask import Flask
from upload.backend.upload_refs.blueprint import create_upload_refs_blueprint
from upload.backend.session import get_files, cleanup
class MockBufferHandler(BaseHTTPRequestHandler):
storage = {}
def do_PUT(self):
length = int(self.headers.get("Content-Length", 0))
data = self.rfile.read(length)
MockBufferHandler.storage[self.path] = data
self.send_response(201)
self.end_headers()
def do_GET(self):
data = MockBufferHandler.storage.get(self.path)
if data is None:
self.send_response(404)
self.end_headers()
return
self.send_response(200)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_DELETE(self):
MockBufferHandler.storage.pop(self.path, None)
self.send_response(204)
self.end_headers()
def log_message(self, format, *args):
pass # suppress console logs in tests
@pytest.fixture(scope="module")
def mock_server():
server = HTTPServer(("127.0.0.1", 0), MockBufferHandler)
port = server.server_port
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
yield f"http://127.0.0.1:{port}/buffer/"
server.shutdown()
def test_upload_refs_pull_and_delete(mock_server):
# Put a file into mock buffer
file_path = "/buffer/token123_0"
content = b"Content of test document for layer 2 transit"
MockBufferHandler.storage[file_path] = content
received_events = []
def on_received(sid, name, data):
received_events.append((sid, name, data))
app = Flask(__name__)
bp = create_upload_refs_blueprint({
"vmUploadPrefix": mock_server,
"pullRetries": 1,
"pullRetryDelay": 0.1,
"onFileReceived": on_received,
})
app.register_blueprint(bp)
client = app.test_client()
file_url = mock_server + "token123_0"
resp = client.post("/api/upload_refs", json={
"files": [
{"name": "test_doc.pdf", "size": len(content), "url": file_url}
]
})
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
sid = data["session"]
assert data["count"] == 1
assert data["added"] == 1
# Verify file is in session memory
files = get_files(sid)
assert len(files) == 1
assert files[0] == ("test_doc.pdf", content)
# Verify callback for Layer 3 was triggered
assert len(received_events) == 1
assert received_events[0] == (sid, "test_doc.pdf", content)
# Verify file was DELETED from mock buffer (RAM clean!)
assert file_path not in MockBufferHandler.storage
cleanup(sid)
def test_upload_refs_ssrf_protection(mock_server):
app = Flask(__name__)
bp = create_upload_refs_blueprint({
"vmUploadPrefix": mock_server,
})
app.register_blueprint(bp)
client = app.test_client()
# Try to pass an evil URL outside vmUploadPrefix
evil_url = "http://169.254.169.254/latest/meta-data/"
resp = client.post("/api/upload_refs", json={
"files": [
{"name": "evil.txt", "size": 100, "url": evil_url}
]
})
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
sid = data["session"]
assert data["added"] == 0
assert data["count"] == 0
cleanup(sid)
+26 -39
View File
@@ -1,44 +1,31 @@
# Upload module # Upload module (v0.2.0)
Для встраивания скопируйте один готовый bundle из `dist/`: Переиспользуемый модуль загрузки, состоящий из двух независимых слоёв:
- IIFE: `FilePicker.initFilePicker(config)`; 1. **Слой 1 (`upload/frontend/`)**File Picker:
- ESM: `import { initFilePicker } from './file-picker.esm.js'`. - 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-селектор. Основные 2. **Слой 2 (`upload/frontend/upload/` + `upload/backend/`)** — Пофайловый транзит в RAM сессии:
параметры: `allowedExt`, `labels`, `layout`, `limits`, `onChange(files)` и - Фронтенд: `uploadViaVM(files, options)` или `FilePicker.uploadViaVM(files, options)`:
`onError(error, file)`. `onError` вызывается для ошибок разбора ZIP; обычные - Пофайловый стриминг: для каждого файла выполняется `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()` — открыть системный выбор; 1. Скопировать каталог `upload/` в проект.
- `getFiles()` — получить leaf-файлы с `path`, `name`, `size` и `file`; 2. Во Flask подключить Blueprint:
- `addFiles(files)` — добавить файлы программно; ```python
- `remove(id)` и `clear()` — удалить узел или очистить дерево; from upload.backend.upload_refs import create_upload_refs_blueprint
- `render()` и `destroy()` — перерисовать или удалить экземпляр. app.register_blueprint(create_upload_refs_blueprint({"vmUploadPrefix": "..."}))
```
Picker поддерживает разрешённые документы, папки и вложенные ZIP, хранит 3. На фронтенде подключить `file-picker.iife.js` или импортировать ESM.
исходные browser `File` objects и выполняет дедупликацию по пути и размеру. 4. Вызвать `initFilePicker`, а при отправке вызвать `uploadViaVM(picker.getFiles(), options)`.
Загрузка на 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
View File
@@ -0,0 +1 @@
"""Backend переиспользуемого модуля загрузки: upload_refs + session."""
+38
View File
@@ -0,0 +1,38 @@
"""In-memory хранилище сессий с TTL и лимитами.
Каждая операция — в отдельном файле, общее состояние — в state.py.
Импорт как единый пакет:
from upload.backend.session import create_session, add_file, get_files
"""
from .create_session import create_session
from .add_file import add_file
from .get_files import get_files, file_count
from .store_result import store_result, get_result
from .store_csv import store_csv, get_csv
from .ttl import touch, pause_ttl, resume_ttl
from .cancel import request_cancel, get_cancel_event
from .cleanup import cleanup
from .state import TTL_SECONDS, MAX_FILE_BYTES, MAX_SESSION_BYTES, configure
__all__ = [
"create_session",
"add_file",
"get_files",
"file_count",
"store_result",
"get_result",
"store_csv",
"get_csv",
"touch",
"pause_ttl",
"resume_ttl",
"request_cancel",
"get_cancel_event",
"cleanup",
"configure",
"TTL_SECONDS",
"MAX_FILE_BYTES",
"MAX_SESSION_BYTES",
]
+25
View File
@@ -0,0 +1,25 @@
"""add_file — добавить файл в сессию (с проверкой суммарного лимита)."""
from . import state
def add_file(sid: str, filename: str, content: bytes) -> bool:
"""Добавить файл в сессию.
Args:
sid: Идентификатор сессии
filename: Имя файла
content: Бинарное содержимое
Returns:
True если добавлено; False если сессии нет или превышен лимит сессии.
"""
with state._lock:
s = state._sessions.get(sid)
if not s:
return False
total = sum(len(c) for _, c in s["files"])
if total + len(content) > state.MAX_SESSION_BYTES:
return False # превышен суммарный лимит сессии
s["files"].append((filename, content))
return True
+27
View File
@@ -0,0 +1,27 @@
"""request_cancel / get_cancel_event — мягкое прерывание обработки сессии."""
import threading
from typing import Optional
from .state import _sessions, _lock
def request_cancel(sid: str) -> bool:
"""Запросить мягкое прерывание обработки сессии.
Returns:
True если сессия существует и отмена запрошена, False если нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["cancel"].set()
return True
def get_cancel_event(sid: str) -> Optional[threading.Event]:
"""Получить событие отмены сессии (или None, если сессии нет)."""
with _lock:
s = _sessions.get(sid)
return s["cancel"] if s else None
+11
View File
@@ -0,0 +1,11 @@
"""cleanup — удалить сессию."""
from .state import _sessions, _lock
def cleanup(sid: str):
"""Удалить сессию и остановить её TTL-таймер."""
with _lock:
s = _sessions.pop(sid, None)
if s and s.get("timer"):
s["timer"].cancel()
+23
View File
@@ -0,0 +1,23 @@
"""create_session — создать новую сессию."""
import threading
import uuid
from .state import _sessions, _lock, _start_timer
def create_session() -> str:
"""Создать новую сессию.
Returns:
Уникальный идентификатор сессии (UUID).
"""
sid = uuid.uuid4().hex
with _lock:
_sessions[sid] = {
"files": [],
"result": None,
"cancel": threading.Event(),
"timer": _start_timer(sid),
}
return sid
+23
View File
@@ -0,0 +1,23 @@
"""get_files / file_count — чтение файлов сессии."""
from typing import List, Optional, Tuple
from .state import _sessions, _lock
def get_files(sid: str) -> Optional[List[Tuple[str, bytes]]]:
"""Получить все файлы сессии.
Returns:
[(filename, content), ...] или None если сессия не найдена.
"""
with _lock:
s = _sessions.get(sid)
return list(s["files"]) if s else None
def file_count(sid: str) -> int:
"""Количество файлов в сессии."""
with _lock:
s = _sessions.get(sid)
return len(s["files"]) if s else 0
+45
View File
@@ -0,0 +1,45 @@
"""Общее состояние сессий: хранилище, блокировка, константы, TTL-таймер.
Единая точка хранения состояния — все операции импортируют её.
Дробить state.py на файл-на-переменную не нужно: это данные, а не функции.
"""
import threading
# TTL сессии: 30 минут
TTL_SECONDS = 30 * 60
# Максимальный объём одного файла и суммарный объём файлов в сессии (защита памяти)
MAX_FILE_BYTES = 50 * 1024 * 1024 # 50 MB на один файл
MAX_SESSION_BYTES = 500 * 1024 * 1024 # 500 MB суммарно на сессию
_sessions: dict = {}
_lock = threading.Lock()
def _start_timer(sid: str) -> threading.Timer:
"""Запустить таймер автоочистки сессии через TTL."""
def _clean():
with _lock:
_sessions.pop(sid, None)
timer = threading.Timer(TTL_SECONDS, _clean)
timer.daemon = True
timer.start()
return timer
def configure(max_file_bytes: int = None, max_session_bytes: int = None,
ttl_seconds: int = None):
"""Переопределить лимиты/TTL из конфига приложения (глобально).
None — оставить текущее значение.
"""
global MAX_FILE_BYTES, MAX_SESSION_BYTES, TTL_SECONDS
if max_file_bytes is not None:
MAX_FILE_BYTES = max_file_bytes
if max_session_bytes is not None:
MAX_SESSION_BYTES = max_session_bytes
if ttl_seconds is not None:
TTL_SECONDS = ttl_seconds
+30
View File
@@ -0,0 +1,30 @@
"""store_csv / get_csv — сохранение и чтение CSV с таблицей замен."""
from typing import Optional
from .state import _sessions, _lock
def store_csv(sid: str, csv_str: str) -> bool:
"""Сохранить CSV с таблицей замен.
Returns:
True если сохранено, False если сессии нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["csv"] = csv_str
return True
def get_csv(sid: str) -> Optional[str]:
"""Получить CSV с таблицей замен.
Returns:
Строка CSV или None если нет.
"""
with _lock:
s = _sessions.get(sid)
return s.get("csv") if s else None
+30
View File
@@ -0,0 +1,30 @@
"""store_result / get_result — сохранение и чтение результата обработки."""
from typing import Optional
from .state import _sessions, _lock
def store_result(sid: str, zip_data: bytes) -> bool:
"""Сохранить результат обработки (ZIP-архив).
Returns:
True если сохранено, False если сессии нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["result"] = zip_data
return True
def get_result(sid: str) -> Optional[bytes]:
"""Получить результат обработки.
Returns:
ZIP-архив или None если сессия не найдена/результат не готов.
"""
with _lock:
s = _sessions.get(sid)
return s["result"] if s else None
+34
View File
@@ -0,0 +1,34 @@
"""touch / pause_ttl / resume_ttl — управление TTL-таймером сессии."""
from .state import _sessions, _lock, _start_timer
def touch(sid: str):
"""Продлить жизнь сессии: перезапустить TTL-таймер (если сессия существует)."""
with _lock:
s = _sessions.get(sid)
if not s:
return
if s.get("timer"):
s["timer"].cancel()
s["timer"] = _start_timer(sid)
def pause_ttl(sid: str):
"""Приостановить TTL сессии (во время обработки): сессия живёт, пока идёт воркер."""
with _lock:
s = _sessions.get(sid)
if s and s.get("timer"):
s["timer"].cancel()
s["timer"] = None
def resume_ttl(sid: str):
"""Возобновить TTL сессии (после завершения обработки): результат доступен ещё TTL."""
with _lock:
s = _sessions.get(sid)
if not s:
return
if s.get("timer"):
s["timer"].cancel()
s["timer"] = _start_timer(sid)
+7
View File
@@ -0,0 +1,7 @@
"""upload_refs — Flask Blueprint для приёма ссылок и pull с ВМ-буфера."""
from .blueprint import create_upload_refs_blueprint
from .safe_name import safe_name
from .pull_file import pull_file
__all__ = ["create_upload_refs_blueprint", "safe_name", "pull_file"]
+145
View File
@@ -0,0 +1,145 @@
"""Переиспользуемый Blueprint слоя 2: POST /upload_refs (pull с ВМ-буфера в сессию).
Поведение:
- safe_name (защита от path traversal)
- SSRF-валидация url.startswith(vm_prefix)
- лимит на один файл -> delete + skip
- pull с ретраями через httpx stream
- лимит сессии -> skip; отсутствие сессии -> 404
- delete url с ВМ (best-effort)
- поддержка пофайлового транзита и пачек
- опциональный callback для интеграции/эмуляции Слоя 3 (on_file_received)
"""
import httpx
import logging
from urllib.parse import urlsplit
from flask import Blueprint, request, jsonify, current_app
from ..session import (create_session, add_file, get_files, file_count,
MAX_FILE_BYTES, configure)
from .config import PULL_RETRIES, PULL_RETRY_DELAY, VM_UPLOAD_PREFIX
from .safe_name import safe_name
from .pull_file import pull_file
log = logging.getLogger("upload.upload_refs")
def create_upload_refs_blueprint(cfg: dict = None) -> Blueprint:
"""Создать Blueprint с эндпоинтом upload_refs.
cfg (все ключи опциональны, есть дефолты):
apiPrefix (str) — префикс Blueprint, по умолчанию "/api"
vmUploadPrefix (str) — доверенный префикс ВМ-буфера (SSRF-валидация)
maxFileBytes (int) — лимит на один файл
maxSessionBytes (int) — суммарный лимит сессии (применяется к сессиям)
ttlSeconds (int) — TTL сессии
pullRetries (int) — ретраи pull
pullRetryDelay (int) — пауза между ретраями (сек)
pullTimeout (int) — таймаут одного GET pull
onFileReceived (func) — опциональный callback (sid, name, content) для Слоя 3
"""
cfg = cfg or {}
prefix = cfg.get("apiPrefix", "/api")
vm_prefix = cfg.get("vmUploadPrefix", VM_UPLOAD_PREFIX)
max_file_bytes = cfg.get("maxFileBytes", MAX_FILE_BYTES)
pull_retries = cfg.get("pullRetries", PULL_RETRIES)
pull_delay = cfg.get("pullRetryDelay", PULL_RETRY_DELAY)
pull_timeout = cfg.get("pullTimeout", 120)
on_file_received = cfg.get("onFileReceived")
# Применить лимиты сессии/TTL из конфига (глобально для всех сессий)
configure(
max_file_bytes=cfg.get("maxFileBytes"),
max_session_bytes=cfg.get("maxSessionBytes"),
ttl_seconds=cfg.get("ttlSeconds"),
)
is_path_only_prefix = vm_prefix.startswith("/")
bp = Blueprint("upload_refs", __name__, url_prefix=prefix)
@bp.route("/upload_refs", methods=["POST"])
def upload_refs():
"""Принять ссылки на файлы (загружены на ВМ-буфер), забрать по egress.
Вход: JSON {"session": "...", "files": [{"name": str, "size": int, "url": str}]}.
Каждый файл тянется исходящим GET с ВМ (egress не ограничен шлюзом),
читается по частям (stream), кладётся в сессию. После успешного pull файл
удаляется с ВМ (DELETE).
"""
data = request.get_json(silent=True) or {}
sid = data.get("session") or create_session()
refs = data.get("files") or []
if not refs:
log.warning("upload_refs: no files, sid=%s", sid)
return jsonify({"ok": False, "error": "No files"}), 400
added = 0
transport = cfg.get("httpxTransport") or current_app.config.get("UPLOAD_HTTPX_TRANSPORT")
try:
with httpx.Client(transport=transport, timeout=pull_timeout, follow_redirects=True) as client:
for ref in refs:
name = safe_name(ref.get("name") or "")
url = ref.get("url") or ""
if not name or not url:
continue
# SSRF-защита: тянуть можно ТОЛЬКО с доверенного ВМ-буфера
if is_path_only_prefix:
if not urlsplit(url).path.startswith(vm_prefix):
log.warning("upload_refs: unsafe URL path, skip sid=%s url=%r", sid, url)
continue
if not url.startswith(("http://", "https://")):
url = request.host_url.rstrip("/") + ("/" if not url.startswith("/") else "") + url
else:
if not url.startswith(vm_prefix):
log.warning("upload_refs: unsafe URL, skip sid=%s url=%r", sid, url)
continue
# Лимит на один файл: сверх лимита — пропускаем
if (ref.get("size") or 0) > max_file_bytes:
log.warning("upload_refs: file exceeds %dMB, skip sid=%s file=%r size=%s",
max_file_bytes // (1024 * 1024), sid, name, ref.get("size"))
try:
client.delete(url)
except Exception:
pass
continue
# Pull с ретраями
content = pull_file(client, url, pull_retries, pull_delay, sid=sid, name=name)
log.info("upload_refs: pulled sid=%s file=%r size=%d", sid, name, len(content))
if len(content) > max_file_bytes:
log.warning("upload_refs: pulled file exceeds %dMB, skip sid=%s file=%r size=%d",
max_file_bytes // (1024 * 1024), sid, name, len(content))
try:
client.delete(url)
except Exception:
pass
continue
if not add_file(sid, name, content):
if get_files(sid) is None:
log.warning("upload_refs: session not found, sid=%s file=%r", sid, name)
return jsonify({"ok": False, "error": "Session not found"}), 404
log.warning("upload_refs: session limit exceeded, skip sid=%s file=%r", sid, name)
try:
client.delete(url)
except Exception:
pass
continue
# Успешно добавлен в сессию — удаляем с ВМ-буфера
try:
client.delete(url)
except Exception:
pass
# Если передан callback для Слоя 3 (эмуляция или реальный процессинг)
if callable(on_file_received):
try:
on_file_received(sid, name, content)
except Exception as cb_err:
log.warning("upload_refs: on_file_received callback error: %r", cb_err)
added += 1
except Exception as e:
log.error("upload_refs: pull error sid=%s: %r", sid, e)
return jsonify({"ok": False, "error": "Pull failed: %s" % e}), 502
log.info("upload_refs: done sid=%s added=%d total=%d", sid, added, file_count(sid))
return jsonify({"ok": True, "session": sid, "count": file_count(sid), "added": added})
return bp
+11
View File
@@ -0,0 +1,11 @@
"""Параметры слоя 2 (бэк) по умолчанию.
Переопределяются из конфига приложения через create_upload_refs_blueprint(cfg).
"""
# Ретраи pull из ВМ-буфера: защита от разовых DNS/сетевых сбоев (gaierror -5 и т.п.)
PULL_RETRIES = 3
PULL_RETRY_DELAY = 2 # секунды между попытками
# Доверенный префикс ВМ-буфера по умолчанию — валидация URL при pull (защита от SSRF)
VM_UPLOAD_PREFIX = "https://contracts.kube5s.ru/drhider-upload/"
+39
View File
@@ -0,0 +1,39 @@
"""pull_file — вытащить файл с ВМ-буфера исходящим GET с ретраями."""
import logging
import time
from .config import PULL_RETRIES, PULL_RETRY_DELAY
log = logging.getLogger("upload.upload_refs.pull")
def pull_file(client, url: str, retries: int = PULL_RETRIES,
delay: float = PULL_RETRY_DELAY, sid: str = None, name: str = None) -> bytes:
"""GET url с ретраями; читает по частям (stream).
Args:
client: httpx.Client
url: URL файла на ВМ-буфере.
retries: число попыток.
delay: пауза между попытками (сек).
sid/name: для логирования (опционально).
Returns:
Содержимое файла (bytes).
Raises:
Последнюю ошибку попытки, если все ретраи не удались.
"""
last_err = None
for attempt in range(retries):
try:
with client.stream("GET", url) as resp:
resp.raise_for_status()
return b"".join(resp.iter_bytes())
except Exception as e:
last_err = e
log.warning("pull: attempt %d/%d failed sid=%s file=%r: %r",
attempt + 1, retries, sid, name, e)
time.sleep(delay)
raise last_err if last_err else RuntimeError("pull failed")
+16
View File
@@ -0,0 +1,16 @@
"""safe_name — санитизация имени файла (защита от path traversal)."""
def safe_name(name: str) -> str:
"""Санитизировать имя файла: защита от path traversal, сохраняя подпапки.
Запрещает '..' и абсолютные пути; нормализует слэши. Возвращает "" если
имя пустое или небезопасное.
"""
if not name:
return ""
name = name.replace("\\", "/")
parts = [p for p in name.split("/") if p and p != "."]
if not parts or any(p == ".." for p in parts):
return ""
return "/".join(parts)
+3
View File
@@ -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 }; export { DEFAULTS };
+78
View File
@@ -0,0 +1,78 @@
/**
* putToVm — отправка одного файла в буфер методом HTTP PUT (сырое бинарное тело).
*
* @param {Blob|File} file Файл для отправки.
* @param {string} url Полный целевой URL в буфере.
* @param {Object} options Опции отправки:
* @param {function(number, number, number): void} [options.onProgress] Колбэк прогресса (pct, loaded, total).
* @param {function(XMLHttpRequest): void} [options.onXHR] Колбэк регистрации XHR для прямого контроля.
* @param {AbortSignal} [options.signal] Сигнал прерывания запроса (AbortController.signal).
* @param {number} [options.timeoutMs=300000] Таймаут в миллисекундах (по умолчанию 300с).
* @returns {Promise<void>} Разрешается при ответе 2xx, отклоняется при ошибке или прерывании.
*/
export function putToVm(file, url, options = {}) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
if (options.onXHR) {
options.onXHR(xhr);
}
const abortHandler = () => {
xhr.abort();
reject(new DOMException('Upload aborted', 'AbortError'));
};
if (options.signal) {
if (options.signal.aborted) {
reject(new DOMException('Upload aborted', 'AbortError'));
return;
}
options.signal.addEventListener('abort', abortHandler, { once: true });
}
xhr.open('PUT', url);
xhr.timeout = options.timeoutMs || 300000;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && options.onProgress) {
const pct = Math.round((e.loaded / e.total) * 100);
options.onProgress(pct, e.loaded, e.total);
}
};
xhr.onload = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(`Буфер: HTTP ${xhr.status} ${xhr.statusText}`));
}
};
xhr.onerror = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
reject(new Error('Сетевая ошибка при отправке в буфер'));
};
xhr.ontimeout = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
reject(new Error(`Таймаут ожидания ответа буфера (${Math.round(xhr.timeout / 1000)}с)`));
};
xhr.onabort = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
reject(new DOMException('Upload aborted', 'AbortError'));
};
xhr.send(file);
});
}
+188
View File
@@ -0,0 +1,188 @@
/**
* uploadViaVM — Слой 2 (Фронтенд): пофайловый транзит через ВМ-буфер в сессию бэкенда.
*
* Архитектура потокового транзита:
* Для каждого файла k:
* 1. Браузер отправляет файл k методом PUT в буфер на ВМ.
* 2. Браузер сразу делает POST /api/upload_refs для одного файла k.
* 3. Бэкенд забирает файл исходящим GET в память RAM и сразу шлёт DELETE в буфер.
* 4. Файл удалён из буфера ВМ, память чиста.
* 5. Браузер переходит к файлу k+1.
*/
import { putToVm } from './put_to_vm.js';
import { fs } from '../table/fs.js';
function generateUuid() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* Нормализует элемент списка файлов (поддерживает как File, так и объект из FilePicker.getFiles()).
*/
function normalizeFileEntry(item) {
if (item instanceof File || item instanceof Blob) {
return { name: item.name || 'unnamed', size: item.size, file: item, path: item.name || '' };
}
if (item && item.file) {
return {
name: item.name || item.file.name || 'unnamed',
size: item.size ?? item.file.size ?? 0,
file: item.file,
path: item.path || item.name || '',
};
}
throw new Error('Некорректный объект файла');
}
/**
* Выполняет пофайловую транзитную загрузку файлов в сессию бэкенда через буфер.
*
* @param {Array<File|{name: string, size: number, file: File, path?: string}>} files Список файлов.
* @param {Object} options Параметры загрузки:
* @param {string} options.vmUploadUrl Базовый URL буфера (обязателен).
* @param {string} [options.backendUploadUrl='/api/upload_refs'] URL эндпоинта приёма ссылок.
* @param {string} [options.session=''] Идентификатор существующей сессии (если есть).
* @param {AbortSignal} [options.signal] Сигнал отмены загрузки.
* @param {function(string): void} [options.onStatus] Колбэк общего текстового статуса.
* @param {function(number, string): void} [options.onFileStatus] Колбэк обновления статуса конкретного файла (индекс, текст/html).
* @param {function(Object): void} [options.onProgress] Колбэк прогресса текущего файла ({ index, total, pct, speed }).
* @param {function(Object): void} [options.onFileComplete] Колбэк успешной доставки файла ({ index, name, session, totalAdded }).
* @returns {Promise<{ok: boolean, session?: string, count?: number, error?: string, aborted?: boolean}>}
*/
export async function uploadViaVM(files, options = {}) {
if (!options.vmUploadUrl) {
return { ok: false, error: 'Параметр options.vmUploadUrl обязателен' };
}
const normalizedFiles = Array.from(files).map(normalizeFileEntry);
const total = normalizedFiles.length;
if (total === 0) {
return { ok: true, session: options.session || '', count: 0 };
}
const backendUrl = options.backendUploadUrl || (options.apiBase || '') + '/api/upload_refs';
const vmBase = options.vmUploadUrl.endsWith('/') ? options.vmUploadUrl : `${options.vmUploadUrl}/`;
let currentSession = options.session || '';
let totalAdded = 0;
for (let k = 0; k < total; k++) {
if (options.signal?.aborted) {
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
}
const { name, size, file } = normalizedFiles[k];
const fileKey = `${generateUuid()}_${k}`;
const fileUrl = `${vmBase}${fileKey}`;
if (options.onStatus) {
options.onStatus(`Загрузка в буфер (${k + 1}/${total}): ${name}`);
}
if (options.onFileStatus) {
options.onFileStatus(k, '⏳ В буфер...');
}
// Этап 1: Отправка одного файла в буфер
const t0 = performance.now();
try {
await putToVm(file, fileUrl, {
signal: options.signal,
onXHR: options.onXHR,
onProgress(pct, loaded, fileTotal) {
const elapsed = (performance.now() - t0) / 1000;
const speed = elapsed > 0 ? (loaded / elapsed) : 0;
if (options.onFileStatus) {
options.onFileStatus(k, `${pct}% (${fs(speed)}/s)`);
}
if (options.onProgress) {
options.onProgress({ index: k, total, pct, speed, loaded, fileTotal, name });
}
},
});
} catch (err) {
if (options.signal?.aborted) {
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
}
if (options.onFileStatus) {
options.onFileStatus(k, '✗ Ошибка буфера');
}
return {
ok: false,
error: `Ошибка отправки файла ${name} в буфер: ${err.message}`,
failedIndex: k,
session: currentSession,
};
}
// Этап 2: Вызов /api/upload_refs для немедленного переноса файла с буфера в RAM сессии
if (options.onStatus) {
options.onStatus(`Приём сервисом (${k + 1}/${total}): ${name}`);
}
if (options.onFileStatus) {
options.onFileStatus(k, '⚡ В сессию...');
}
try {
const resp = await fetch(backendUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session: currentSession,
files: [{ name, size, url: fileUrl }],
}),
signal: options.signal,
});
if (!resp.ok) {
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
}
const data = await resp.json();
if (!data.ok) {
throw new Error(data.error || 'Бэкенд вернул ошибку');
}
currentSession = data.session || currentSession;
totalAdded = data.count ?? (totalAdded + 1);
if (options.onFileStatus) {
options.onFileStatus(k, '✓ Доставлен');
}
if (options.onFileComplete) {
options.onFileComplete({
fileIndex: k,
name,
size,
session: currentSession,
totalAdded,
});
}
} catch (err) {
if (options.signal?.aborted) {
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
}
if (options.onFileStatus) {
options.onFileStatus(k, '✗ Ошибка приёма');
}
return {
ok: false,
error: `Ошибка приёма файла ${name} бэкендом: ${err.message}`,
failedIndex: k,
session: currentSession,
};
}
}
if (options.onStatus) {
options.onStatus(`Завершено. Успешно передано файлов: ${total}`);
}
return { ok: true, session: currentSession, count: totalAdded };
}