Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
8.9 KiB
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-бандл:
cd upload/frontend
npm install
npm run build
Бандл появится в site/static/dist/file-picker.iife.js; Contracts отдаёт его по
/file-picker/file-picker.iife.js. Инициализация picker:
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.
Передача файлов выполняется по одному файлу за вызов/итерацию:
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. Обработку и сохранение
результатов приложение определяет самостоятельно.
Подключение бэка
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:
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-проверок и дальнейший план описаны в отчёте об инциденте.