Files
contracts-flask/upload

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-проверок и дальнейший план описаны в отчёте об инциденте.