Deploy contracts-flask / validate (push) Canceled after 0s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
174 lines
8.9 KiB
Markdown
174 lines
8.9 KiB
Markdown
# upload — переиспользуемые слои загрузки через ВМ
|
||
|
||
Два самодостаточных слоя для выноса в любой другой проект БЕЗ изменения кода
|
||
(меняется только конфиг). Поведение 1:1 с drhider v0.0.75.
|
||
|
||
```
|
||
upload/
|
||
frontend/
|
||
zip/ # распаковка ZIP (чистые функции)
|
||
table/ # слой 1: выбор файлов/папки/архива, дедуп, статусы, таблица
|
||
upload/ # слой 2 (фронт): PUT на ВМ + POST /api/upload_refs
|
||
backend/
|
||
upload_refs/ # слой 2 (бэк): Blueprint upload_refs (SSRF, _safe_name, ретраи)
|
||
session/ # in-memory сессия с TTL и лимитами
|
||
config.example.json
|
||
```
|
||
|
||
## Что это
|
||
|
||
| Слой | Где | Ответственность |
|
||
|---|---|---|
|
||
| **1. Выбор файлов** | фронт | таблица, дедуп, раскрытие ZIP, путь, статусы, кнопки |
|
||
| **2. Закачка через ВМ** | фронт + бэк | PUT на ВМ-буфер (фронт) → `upload_refs` pull (бэк) → сессия |
|
||
| **3. Логика приложения** | — | у каждого приложения своя (обфускация, SSE и т.п.). В модуле её НЕТ |
|
||
|
||
Паттерн (зачем ВМ): шлюз managed-кластера рвёт тела >64КБ, egress не ограничен.
|
||
Поэтому: браузер → `PUT` на ВМ-буфер → Flask `POST /api/upload_refs` → egress `GET` → сессия.
|
||
|
||
---
|
||
|
||
## Подключение фронта
|
||
|
||
Исходники — ES-модули. Для обычной страницы соберите IIFE-бандл:
|
||
|
||
```sh
|
||
cd upload/frontend
|
||
npm install
|
||
npm run build
|
||
```
|
||
|
||
Бандл появится в `site/static/dist/file-picker.iife.js`; Contracts отдаёт его по
|
||
`/file-picker/file-picker.iife.js`. Инициализация picker:
|
||
|
||
```js
|
||
const picker = window.FilePicker.initFilePicker({
|
||
mount: '#filePicker',
|
||
allowedExt: ['.pdf', '.doc', '.docx'],
|
||
onChange(files) { /* синхронизировать выбранные leaf-файлы с приложением */ },
|
||
});
|
||
```
|
||
|
||
Picker строит дерево файлов/папок/ZIP, раскрывает вложенные ZIP, фильтрует
|
||
неподдерживаемые расширения и удаляет дубликаты по `path + size`. В выбранной
|
||
папке корневой `obscure.lst` применяется как manifest включения/исключения.
|
||
`getFiles()` возвращает leaf-записи `{id, path, name, size, file}`. Методы
|
||
`setStatus(id, {kind, text})`, `setDocument(id, docId)`, `setMessage(text)`,
|
||
`remove(id)`, `clear()`, `render()` и `destroy()` обслуживают UI.
|
||
|
||
Передача файлов выполняется по одному файлу за вызов/итерацию:
|
||
|
||
```js
|
||
const result = await window.FilePicker.uploadViaVM(files, {
|
||
vmUploadUrl: 'https://example.invalid/contracts-upload/',
|
||
backendUploadUrl: '/api/upload_refs',
|
||
getExtraPayload(fileEntry) {
|
||
return {batch_id, contract_id, zip_source: fileEntry.zip_source};
|
||
},
|
||
onFileComplete({result}) { /* результат sink: doc_id, parsed или ошибка */ },
|
||
});
|
||
```
|
||
|
||
`uploadViaVM` принимает `AbortSignal`, сообщает прогресс и передаёт поля
|
||
`getExtraPayload` в JSON POST к `upload_refs`. При настроенном `sink` результат
|
||
каждого файла передаётся в `onFileComplete` как `result`. Обработку и сохранение
|
||
результатов приложение определяет самостоятельно.
|
||
|
||
---
|
||
|
||
## Подключение бэка
|
||
|
||
```python
|
||
from flask import Flask
|
||
from upload.backend.upload_refs import create_upload_refs_blueprint
|
||
from upload.backend.session import create_session, add_file, get_files
|
||
|
||
app = Flask(__name__)
|
||
app.register_blueprint(create_upload_refs_blueprint({
|
||
"apiPrefix": "/api", # префикс эндпоинтов
|
||
"vmUploadPrefix": "https://.../drhider-upload/", # доверенный префикс (SSRF)
|
||
"maxFileBytes": 50 * 1024 * 1024,
|
||
"maxSessionBytes": 500 * 1024 * 1024,
|
||
"ttlSeconds": 1800,
|
||
"pullRetries": 3,
|
||
"pullRetryDelay": 2,
|
||
}))
|
||
```
|
||
|
||
Эндпоинт: `POST {apiPrefix}/upload_refs` — принимает JSON с `session`, `files`
|
||
и сквозными полями `batch_id`, `contract_id`, `zip_source`; тянет каждый файл с
|
||
ВМ (SSRF-валидация по `vmUploadPrefix`, безопасное имя, ретраи). Без `sink`
|
||
файлы кладутся в in-memory сессию и ответ содержит `session`/`count`. С `sink`
|
||
ответ содержит `results` с результатом каждого файла и обработку/хранение
|
||
определяет приложение.
|
||
|
||
Сессия: `create_session()` → sid; `add_file(sid, name, content)` (лимит 500МБ);
|
||
`get_files(sid)` → `[(name, bytes), ...]` или `None`. TTL 30 мин (таймер в фоне).
|
||
|
||
---
|
||
|
||
## Раздача фронтенда
|
||
|
||
В `contracts-flask` `pages_bp.py` отдаёт исходные модули по `/upload/<path>`
|
||
и собранный IIFE по `/file-picker/<path>`. Для другого приложения нужно
|
||
раздавать его собранный `file-picker.iife.js` по HTTP и подключать `<script src>`.
|
||
|
||
---
|
||
|
||
## Слой 3: обработка приложения
|
||
|
||
Без `sink` модуль отдаёт файлы в in-memory сессию, а обрабатываете их ВЫ
|
||
(обфускация, конвертация и т. п.). После `uploadViaVM` файлы лежат в `res.session`:
|
||
|
||
```python
|
||
files = get_files(res.session) # [(name, bytes), ...]
|
||
# ... ваша обработка ...
|
||
store_result(res.session, result_zip) # если нужно отдать результат обратно
|
||
```
|
||
|
||
С `sink` файлы не попадают в in-memory сессию: обработчик получает их как
|
||
`sink(name, content, batch_id=..., contract_id=..., zip_source=...)`, а результат
|
||
каждой обработки возвращается в `onFileComplete`.
|
||
|
||
---
|
||
|
||
## Конфиг (слой 0)
|
||
|
||
Всё drhider-специфичное задаётся конфигом, а не кодом слоёв:
|
||
|
||
| Поле | Назначение |
|
||
|---|---|
|
||
| `vmUploadUrl` | базовый URL ВМ-буфера для PUT (фронт) |
|
||
| `vmUploadPrefix` | тот же префикс для SSRF-валидации (бэк) |
|
||
| `allowedExt` | расширения документов из папки/архивов |
|
||
| `maxFileBytes` / `maxSessionBytes` | лимиты 50 МБ / 500 МБ |
|
||
| `apiPrefix` | префикс Blueprint `/api` |
|
||
| `pullRetries` / `pullRetryDelay` | ретраи pull (3 × 2с) |
|
||
| `pullTimeout` | таймаут одного GET pull (сек) |
|
||
| `ttlSeconds` | TTL сессии (по умолчанию 1800) |
|
||
| `estMbSec` | оценка времени обработки, сек/МБ (только UI) |
|
||
|
||
---
|
||
|
||
## Что НЕ трогать
|
||
|
||
- Слой 3 — логика приложения (обработка файлов из сессии, SSE-прогресс) у каждого своя.
|
||
- CORS на ВМ-буфере — если домен приложения другой, правится nginx на ВМ, а не код модуля.
|
||
|
||
## Инцидент Contracts VM-буфера (2026-10-30)
|
||
|
||
Контрактор использовал `contracts.kube5s.ru/contracts-upload/`, но DNS для
|
||
`contracts.kube5s.ru` возвращал `NXDOMAIN`. DNS-запись для отдельного hostname
|
||
`contracts-upload.sinefo.ru` теперь добавлена и проверена на публичных
|
||
резолверах. Переключение только на существующий
|
||
DrHider или Obfuscator endpoint недостаточно: у них другие пути и CORS allowlist,
|
||
и тестовый путь `/contracts-upload/` на этих хостах отвечал `404`. Новый
|
||
hostname теперь настроен на VM213 с отдельным TLS-сертификатом, Nginx vhost и
|
||
cron-очисткой временных файлов. Проверены HTTPS, CORS для Контрактора и прямой
|
||
синтетический цикл PUT/GET/DELETE.
|
||
|
||
Код приложения и source Nginx настроены локально; Nginx-конфиг буфера уже
|
||
установлен на VM213, но само приложение пока не redeployed. Причина, результаты
|
||
DNS/CORS/TLS-проверок и дальнейший план описаны в
|
||
[отчёте об инциденте](../docs/upload-buffer-incident-2026-10-30.md).
|