# 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/` и собранный IIFE по `/file-picker/`. Для другого приложения нужно раздавать его собранный `file-picker.iife.js` по HTTP и подключать `