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 на ВМ, а не код модуля.