этап 2: переиспользуемый модуль upload (sink) вместо рукописного транспорта

- копирую модуль upload/ из drhider (слои 1-2)
- blueprint: параметр sink (drhider-сессия по умолчанию, сверка — DB+парсинг)
- upload_bp: contracts_upload_sink = _store_and_parse
- routes: регистрирую create_upload_refs_blueprint(cfg, sink=...)
- app.py: корень репо в sys.path (для import upload)
- History: план переиспользования + ревью Соннета
This commit is contained in:
“Naeel”
2026-08-26 08:07:43 +03:00
parent 55bd7a027d
commit db58a433fb
46 changed files with 1986 additions and 27 deletions
@@ -0,0 +1,100 @@
# Переиспользование модуля загрузки drhider в contracts-flask
Дата: 2026-08-26
## Контекст
- Шлюз managed-кластера рвёт тела запросов >~64 КБ, egress не ограничен.
- Паттерн загрузки: браузер `PUT` файла на ВМ-буфер (WebDAV) → Flask `pull` (egress GET) → обработка.
- В drhider этот паттерн отлажен и вынесен в переиспользуемый модуль `upload/`
(слой 1 — выбор файлов/папок/архивов → таблица; слой 2 — закачка PUT→pull; слой 3 — логика приложения, НЕ в модуле).
- Задача: взять из drhider выбор+загрузку, сшить с логикой сверки contracts-flask,
**НЕ меняя саму логику сверки** (классификация/группы/сравнение).
Текущее состояние contracts-flask: v2.0.11 (рабочая загрузка через ВМ, написана вручную в этой сессии).
---
## Ревью Соннета (итог)
Вердикт: **«с оговорками»** — одна критическая: слои 1+2 нельзя взять AS-IS для
фронт-части слоя 2. `uploadViaVM.js` шлёт `{session, files:[...]}`, а сверка ожидает
`{batch_id, contract_id, zip_source, files}`. Несовместимые форматы.
### Ключевые находки Соннета
1. **Привязка к in-memory сессии** (blueprint.py, 4 точки): `create_session()`,
`add_file(sid, name, content)`, `get_files(sid) is None`, `file_count(sid)`.
→ заменить одним `sink(name, content, **ctx)`, где `ctx = {batch_id, contract_id, zip_source}`.
2. **Граница «логика сверки» — НЕЛЬЗЯ трогать:**
| Файл | Функции |
|---|---|
| `pipeline_bp.py` | `process_v2`, `classify_batch_route` (SSE + classify) |
| `services/process.py` | `run_pipeline` |
| `services/classify.py` | `classify_batch`, `_call_llm_classify`, garbage filters |
| `services/grouping.py` | `group_documents`, `apply_groups`, `normalize_number` |
| `db/documents.py` | ВСЕ (контракт данных) |
| `db/supplements.py` | ВСЕ |
3. **Риски:**
- клиентский ZIP — полная распаковка в память браузера (у сверки PDF ~19 МБ);
- `allowedExt` разный: drhider `[.pdf,.doc,.docx,.txt,.md]` vs сверка `{pdf,docx,doc,zip}`;
- `parse.py` уже пытается парсить `.doc` напрямую (`elif ext=="doc": _parse_docx(data)`) — sink должен перехватывать `.doc` ДО `parse_file`;
- дедуп: name+size (слой 1) vs content-hash (sink) — не ошибка, двойная защита;
- `session` (drhider) vs `batch_id` (сверка, `crypto.randomUUID()` в state.js);
- `zip_source` — поле documents, одно на вызов (для UI-группировки).
4. **Безопасный порядок (Соннет):** расширить blueprint (sink) → extra-поля → contracts_sink → backend → frontend layer1 → frontend layer2 → удалить `/api/unzip_refs`,`/api/convert_refs`.
### Противоречия в плане (Соннет)
- «Слои 1+2 КАК ЕСТЬ» неверно для фронт-части слоя 2 (`{session}` vs `{batch_id,...}`).
- `.doc` в sink неполно описан (порядок «конвертация ДО parse»).
- `allowedExt` при интеграции не упомянут.
---
## Моё решение (3 этапа)
| Этап | Что | Риск |
|---|---|---|
| **1. Транспорт** | скопировать `upload/` в contracts-flask; заменить рукописные `_safe_name` + `_pull_with_retries` на модульные | низкий |
| **2. Sink** | `create_upload_refs_blueprint(cfg, sink=...)`; sink = `_store_and_parse` + перехват `.doc` → внешний LibreOffice → `parse_file` | средний |
| **3. UI** | `initUploadTable` (файлы+папки+архивы) | высокий, последним |
### Принятые решения
- **ZIP → серверный** (оставить `/api/unzip_refs`): у сверки крупные PDF, клиентская распаковка = регресс по памяти (осознанное отступление от «как в хайдере»).
- **`.doc` → конвертация в sink** через внешний LibreOffice-сервис (`CONVERT_SERVICE_URL`), последовательно (один тяжёлый `.doc` блокирует пакет; параллелить потом).
- **UI → последним, изолированно** от «загрузка не работает».
- **Реализация:** я (спецификация + sink), субагент-младшая модель (механика этапа 1), я ревьюю дифф.
### Что НЕ переносить из drhider
- in-memory сессию (сверка хранит в SQLite `documents` по `batch_id`/`doc_id`);
- клиентскую распаковку ZIP;
- обфускацию/слой 3 drhider.
---
## Находка при чтении транспортных файлов модуля (2026-08-26)
Модуль — это **целостный Blueprint**, а не набор drop-in функций:
- `safe_name(name)` **сохраняет подпапки** и возвращает `""` при небезопасном имени.
У сверки `_safe_name`**basename-only** (rsplit) и возвращает `"file.bin"`. НЕ 1:1.
- `pull_file(client, url, ...)` требует `httpx.Client` + стриминг (`client.stream`).
У сверки `_pull_with_retries(url)``httpx.get` внутри. Разные сигнатуры.
- `blueprint.py` жёстко завязан на сессию: `create_session`, `add_file`, `get_files`,
`file_count`.
**Следствие:** «этап 1: заменить 2 функции 1:1» НЕ выполним. Переиспользование идёт
на уровне **blueprint через sink** (этап 2), с адаптацией формы запроса/ответа.
## Статус
- [x] Скопировать `upload/` в contracts-flask
- [ ] Этап 2 — sink (доработка blueprint + contracts-sink + регистрация)
- [ ] Удалить рукописный транспорт (после этапа 2)
- [ ] Этап 3 — UI
+4
View File
@@ -4,6 +4,10 @@
import sys, os import sys, os
# site/ в sys.path — импортируем модули напрямую, без префиксов # site/ в sys.path — импортируем модули напрямую, без префиксов
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# корень репо — для переиспользуемого модуля upload/ (как в drhider)
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from flask import Flask from flask import Flask
from config import VERSION, MAX_CONTENT_LENGTH from config import VERSION, MAX_CONTENT_LENGTH
+12 -1
View File
@@ -2,12 +2,14 @@
def register_routes(app): def register_routes(app):
from routes.upload_bp import upload_bp import config
from routes.upload_bp import upload_bp, contracts_upload_sink
from routes.pipeline_bp import pipeline_bp from routes.pipeline_bp import pipeline_bp
from routes.api_bp import api_bp from routes.api_bp import api_bp
from routes.prompts_bp import prompts_bp from routes.prompts_bp import prompts_bp
from routes.health_bp import health_bp from routes.health_bp import health_bp
from routes.pages_bp import pages_bp from routes.pages_bp import pages_bp
from upload.backend.upload_refs import create_upload_refs_blueprint
app.register_blueprint(upload_bp) app.register_blueprint(upload_bp)
app.register_blueprint(pipeline_bp) app.register_blueprint(pipeline_bp)
@@ -15,3 +17,12 @@ def register_routes(app):
app.register_blueprint(prompts_bp) app.register_blueprint(prompts_bp)
app.register_blueprint(health_bp) app.register_blueprint(health_bp)
app.register_blueprint(pages_bp) app.register_blueprint(pages_bp)
# Переиспользуемый слой закачки через ВМ (модуль upload, паттерн drhider)
app.register_blueprint(create_upload_refs_blueprint({
"apiPrefix": "/api",
"vmUploadPrefix": config.VM_UPLOAD_PREFIX,
"maxFileBytes": config.VM_UPLOAD_MAX_BYTES,
"pullRetries": config.PULL_RETRIES,
"pullRetryDelay": config.PULL_RETRY_DELAY,
}, sink=contracts_upload_sink))
+5 -26
View File
@@ -179,34 +179,13 @@ def upload():
return jsonify(ok=True, doc_id=result["doc_id"], contract_id=result["contract_id"], parsed=result["parsed"]) return jsonify(ok=True, doc_id=result["doc_id"], contract_id=result["contract_id"], parsed=result["parsed"])
@upload_bp.route("/api/upload_refs", methods=["POST"]) def contracts_upload_sink(name, content, batch_id=None, contract_id=None, zip_source=None):
def upload_refs(): """Sink для переиспользуемого модуля upload: вставить файл в documents + авто-парсинг.
"""Загрузка через ВМ-буфер (паттерн drhider): бэк тянет файлы с ВМ.
Тело (маленькое, <64КБ): {batch_id, contract_id, zip_source, files:[{name,size,url}]}. Вызывается модулем create_upload_refs_blueprint(cfg, sink=...) на каждый
Для каждой ссылки: SSRF-проверка → лимит → pull с ретраями → store+parse → DELETE с ВМ. вытянутый с ВМ файл. Возвращает dict с ключами ok/doc_id/contract_id/parsed.
""" """
data = request.get_json(silent=True) or {} return _store_and_parse(name, content, batch_id, contract_id, zip_source)
files = data.get("files") or []
batch_id = data.get("batch_id")
contract_id = data.get("contract_id")
zip_source = data.get("zip_source")
if not files:
return jsonify(ok=False, error="no files"), 400
results = []
for ref in files:
ref_name = _safe_name(str(ref.get("name", "") or ""))
try:
name, content = _pull_from_ref(ref)
except Exception as e:
results.append({"name": ref_name, "ok": False, "error": str(e)})
continue
stored = _store_and_parse(name, content, batch_id, contract_id, zip_source)
results.append({"name": name, **stored})
return jsonify(ok=True, results=results)
@upload_bp.route("/convert-doc", methods=["POST"]) @upload_bp.route("/convert-doc", methods=["POST"])
+183
View File
@@ -0,0 +1,183 @@
# 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-модули (`<script type="module">`) и собрать слой 1:
```js
import { initUploadTable } from './upload/frontend/table/init_upload_table.js';
import { uploadViaVM } from './upload/frontend/upload/upload_via_vm.js';
const cfg = {
allowedExt: ['.pdf', '.doc', '.docx', '.txt', '.md'],
maxFileBytes: 50 * 1024 * 1024,
maxSessionBytes: 500 * 1024 * 1024,
estMbSec: 12,
};
const table = initUploadTable(cfg, {
fileInput: document.getElementById('fileInput'), // <input type="file" multiple>
folderInput: document.getElementById('folderInput'), // <input webkitdirectory>
tableBody: document.getElementById('fileList'), // <tbody>
countEl: document.getElementById('fileCount'),
uploadBtnEl: document.getElementById('uploadBtn'),
onStatus(cls, text) { /* сообщения (cls: ''|'progress'|'done'|'error') */ },
});
// Слой 2 — закачка учитываемых файлов на ВМ:
// idxInSf[k] — индекс k-го отправляемого файла в строках таблицы (своя логика маппинга)
const res = await uploadViaVM(toSend, cfg.vmUploadUrl, {
session: currentSid,
onStatus(k, html) { table.setStatus(idxInSf[k], html); }, // прогресс → ячейка таблицы
onUploadStatus(text) { /* статусная строка */ },
onXHR(xhr) { activeXHR = xhr; }, // регистрация активного PUT для отмены (abort)
});
// res = {ok:true, session, count} | {ok:false, error}
// После этого у вас в сессии res.session лежат файлы — запускайте СВОЮ обработку.
```
API слоя 1 (`initUploadTable(cfg, els)``table`):
- `addFiles(File[])` — дедуп + раскрытие ZIP + фильтр + лимиты;
- `getFiles()``[{name, size, file}]`**только учитываемые** (без сверхлимитных);
- `getOverNames()``Set` — имена сверх лимита;
- `setStatus(idx, html)` — статус в ячейке таблицы;
- `render()` — перерисовать таблицу;
- `clear()` — очистить список;
- `setBusy(bool)` — заблокировать список на время загрузки/обработки;
- `state` — доступ к состоянию (для слоя 3: установить `state.proc` для 3-секционной таблицы).
---
## Подключение бэка
```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": [{"name", "size", "url"}]}`, тянет каждый файл с ВМ
(SSRF-валидация по `vmUploadPrefix`, `_safe_name`, ретраи), кладёт в сессию.
Возвращает `{"ok": true, "session", "count"}`.
Сессия: `create_session()` → sid; `add_file(sid, name, content)` (лимит 500МБ);
`get_files(sid)``[(name, bytes), ...]` или `None`. TTL 30 мин (таймер в фоне).
---
## Раздача модуля в Flask (обязательно)
Фронт-модули — ES-модули, браузер грузит их по HTTP (не через file://).
Добавьте route, который отдаёт папку `upload/frontend` (как в drhider `site/routes/main_bp.py`):
```python
# в любом blueprint приложения
import os
from flask import send_from_directory
_UPLOAD_FRONTEND = os.path.join(os.path.dirname(os.path.dirname(__file__)),
"upload", "frontend")
@bp.route("/upload/<path:filename>")
def upload_frontend(filename):
return send_from_directory(_UPLOAD_FRONTEND, filename)
```
Тогда импорты в браузере: `import { initUploadTable } from '/upload/table/init_upload_table.js';`.
> Если фронт-модули хостятся отдельно (другой домен/приложение) — путь `/upload/...`
> и CORS настраиваются под ваш случай (правка приложения/nginx, не кода модуля).
---
## Слой 3: как подключить обработку (опционально)
Модуль отдаёт файлы в сессию, а обрабатываете их ВЫ (обфускация, конвертация, ...).
После `uploadViaVM` файлы лежат в `res.session`:
```python
files = get_files(res.session) # [(name, bytes), ...]
# ... ваша обработка ...
store_result(res.session, result_zip) # если нужно отдать результат обратно
```
Для 3-секционной таблицы прогресса (готово/текущий/ожидают) модуль предоставляет
`renderProcTable` — достаточно дать слою 3 доступ к `table.state.proc`:
```js
table.state.proc = { phase: 'processing', procState: {}, procExtractRate: null };
function syncProcCtx() {
table.state.proc.phase = phase;
table.state.proc.procState = procState; // {idx: {st, elapsed, eta, chars, t0}}
table.state.proc.procExtractRate = rate; // сек/МБ (для оценок ожидающих)
}
syncProcCtx();
table.render(); // сам выберет renderProcTable при phase==='processing'
```
Статусы в ячейках: `table.setStatus(idx, html)`.
---
## Конфиг (слой 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 на ВМ, а не код модуля.
+8
View File
@@ -0,0 +1,8 @@
"""Переиспользуемый модуль загрузки через ВМ (2 слоя).
Структура:
- frontend/ — слой 1 (выбор файлов) + слой 2 (закачка через ВМ), JS.
- backend/ — слой 2 (бэк): Blueprint upload_refs + in-memory сессия, Python.
Подключение в новый проект — см. README.md.
"""
+1
View File
@@ -0,0 +1 @@
"""Backend переиспользуемого модуля загрузки: upload_refs + session."""
+38
View File
@@ -0,0 +1,38 @@
"""In-memory хранилище сессий с TTL и лимитами.
Каждая операция — в отдельном файле (см. ниже), общее состояние — в state.py.
Импорт как единый пакет:
from upload.backend.session import create_session, add_file, get_files
"""
from .create_session import create_session
from .add_file import add_file
from .get_files import get_files, file_count
from .store_result import store_result, get_result
from .store_csv import store_csv, get_csv
from .ttl import touch, pause_ttl, resume_ttl
from .cancel import request_cancel, get_cancel_event
from .cleanup import cleanup
from .state import TTL_SECONDS, MAX_FILE_BYTES, MAX_SESSION_BYTES, configure
__all__ = [
"create_session",
"add_file",
"get_files",
"file_count",
"store_result",
"get_result",
"store_csv",
"get_csv",
"touch",
"pause_ttl",
"resume_ttl",
"request_cancel",
"get_cancel_event",
"cleanup",
"configure",
"TTL_SECONDS",
"MAX_FILE_BYTES",
"MAX_SESSION_BYTES",
]
+25
View File
@@ -0,0 +1,25 @@
"""add_file — добавить файл в сессию (с проверкой суммарного лимита)."""
from . import state
def add_file(sid: str, filename: str, content: bytes) -> bool:
"""Добавить файл в сессию.
Args:
sid: Идентификатор сессии
filename: Имя файла
content: Бинарное содержимое
Returns:
True если добавлено; False если сессии нет или превышен лимит сессии.
"""
with state._lock:
s = state._sessions.get(sid)
if not s:
return False
total = sum(len(c) for _, c in s["files"])
if total + len(content) > state.MAX_SESSION_BYTES:
return False # превышен суммарный лимит сессии
s["files"].append((filename, content))
return True
+27
View File
@@ -0,0 +1,27 @@
"""request_cancel / get_cancel_event — мягкое прерывание обработки сессии."""
import threading
from typing import Optional
from .state import _sessions, _lock
def request_cancel(sid: str) -> bool:
"""Запросить мягкое прерывание обработки сессии.
Returns:
True если сессия существует и отмена запрошена, False если нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["cancel"].set()
return True
def get_cancel_event(sid: str) -> Optional[threading.Event]:
"""Получить событие отмены сессии (или None, если сессии нет)."""
with _lock:
s = _sessions.get(sid)
return s["cancel"] if s else None
+11
View File
@@ -0,0 +1,11 @@
"""cleanup — удалить сессию."""
from .state import _sessions, _lock
def cleanup(sid: str):
"""Удалить сессию и остановить её TTL-таймер."""
with _lock:
s = _sessions.pop(sid, None)
if s and s.get("timer"):
s["timer"].cancel()
+23
View File
@@ -0,0 +1,23 @@
"""create_session — создать новую сессию."""
import threading
import uuid
from .state import _sessions, _lock, _start_timer
def create_session() -> str:
"""Создать новую сессию.
Returns:
Уникальный идентификатор сессии (UUID).
"""
sid = uuid.uuid4().hex
with _lock:
_sessions[sid] = {
"files": [],
"result": None,
"cancel": threading.Event(),
"timer": _start_timer(sid),
}
return sid
+23
View File
@@ -0,0 +1,23 @@
"""get_files / file_count — чтение файлов сессии."""
from typing import List, Optional, Tuple
from .state import _sessions, _lock
def get_files(sid: str) -> Optional[List[Tuple[str, bytes]]]:
"""Получить все файлы сессии.
Returns:
[(filename, content), ...] или None если сессия не найдена.
"""
with _lock:
s = _sessions.get(sid)
return list(s["files"]) if s else None
def file_count(sid: str) -> int:
"""Количество файлов в сессии."""
with _lock:
s = _sessions.get(sid)
return len(s["files"]) if s else 0
+45
View File
@@ -0,0 +1,45 @@
"""Общее состояние сессий: хранилище, блокировка, константы, TTL-таймер.
Единая точка хранения состояния — все операции импортируют её.
Дробить state.py на файл-на-переменную не нужно: это данные, а не функции.
"""
import threading
# TTL сессии: 30 минут
TTL_SECONDS = 30 * 60
# Максимальный объём одного файла и суммарный объём файлов в сессии (защита памяти)
MAX_FILE_BYTES = 50 * 1024 * 1024 # 50 MB на один файл
MAX_SESSION_BYTES = 500 * 1024 * 1024 # 500 MB суммарно на сессию
_sessions: dict = {}
_lock = threading.Lock()
def _start_timer(sid: str) -> threading.Timer:
"""Запустить таймер автоочистки сессии через TTL."""
def _clean():
with _lock:
_sessions.pop(sid, None)
timer = threading.Timer(TTL_SECONDS, _clean)
timer.daemon = True
timer.start()
return timer
def configure(max_file_bytes: int = None, max_session_bytes: int = None,
ttl_seconds: int = None):
"""Переопределить лимиты/TTL из конфига приложения (глобально).
None — оставить текущее значение.
"""
global MAX_FILE_BYTES, MAX_SESSION_BYTES, TTL_SECONDS
if max_file_bytes is not None:
MAX_FILE_BYTES = max_file_bytes
if max_session_bytes is not None:
MAX_SESSION_BYTES = max_session_bytes
if ttl_seconds is not None:
TTL_SECONDS = ttl_seconds
+30
View File
@@ -0,0 +1,30 @@
"""store_csv / get_csv — сохранение и чтение CSV с таблицей замен."""
from typing import Optional
from .state import _sessions, _lock
def store_csv(sid: str, csv_str: str) -> bool:
"""Сохранить CSV с таблицей замен.
Returns:
True если сохранено, False если сессии нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["csv"] = csv_str
return True
def get_csv(sid: str) -> Optional[str]:
"""Получить CSV с таблицей замен.
Returns:
Строка CSV или None если нет.
"""
with _lock:
s = _sessions.get(sid)
return s.get("csv") if s else None
+30
View File
@@ -0,0 +1,30 @@
"""store_result / get_result — сохранение и чтение результата обработки."""
from typing import Optional
from .state import _sessions, _lock
def store_result(sid: str, zip_data: bytes) -> bool:
"""Сохранить результат обработки (ZIP-архив).
Returns:
True если сохранено, False если сессии нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["result"] = zip_data
return True
def get_result(sid: str) -> Optional[bytes]:
"""Получить результат обработки.
Returns:
ZIP-архив или None если сессия не найдена/результат не готов.
"""
with _lock:
s = _sessions.get(sid)
return s["result"] if s else None
+34
View File
@@ -0,0 +1,34 @@
"""touch / pause_ttl / resume_ttl — управление TTL-таймером сессии."""
from .state import _sessions, _lock, _start_timer
def touch(sid: str):
"""Продлить жизнь сессии: перезапустить TTL-таймер (если сессия существует)."""
with _lock:
s = _sessions.get(sid)
if not s:
return
if s.get("timer"):
s["timer"].cancel()
s["timer"] = _start_timer(sid)
def pause_ttl(sid: str):
"""Приостановить TTL сессии (во время обработки): сессия живёт, пока идёт воркер."""
with _lock:
s = _sessions.get(sid)
if s and s.get("timer"):
s["timer"].cancel()
s["timer"] = None
def resume_ttl(sid: str):
"""Возобновить TTL сессии (после завершения обработки): результат доступен ещё TTL."""
with _lock:
s = _sessions.get(sid)
if not s:
return
if s.get("timer"):
s["timer"].cancel()
s["timer"] = _start_timer(sid)
+20
View File
@@ -0,0 +1,20 @@
"""Слой 2 (бэк): переиспользуемый Blueprint закачки через ВМ.
Использование:
from upload.backend.upload_refs import create_upload_refs_blueprint
app.register_blueprint(create_upload_refs_blueprint(cfg))
"""
from .blueprint import create_upload_refs_blueprint
from .safe_name import safe_name
from .pull_file import pull_file
from .config import PULL_RETRIES, PULL_RETRY_DELAY, VM_UPLOAD_PREFIX
__all__ = [
"create_upload_refs_blueprint",
"safe_name",
"pull_file",
"PULL_RETRIES",
"PULL_RETRY_DELAY",
"VM_UPLOAD_PREFIX",
]
+160
View File
@@ -0,0 +1,160 @@
"""Переиспользуемый Blueprint слоя 2: POST /upload_refs (pull с ВМ-буфера в сессию).
Поведение 1:1 с drhider v0.0.75 (site/routes/api_bp.py):
- _safe_name (path traversal)
- SSRF-валидация url.startswith(VM_UPLOAD_PREFIX)
- лимит на один файл -> delete+skip
- pull с ретраями
- лимит сессии -> skip; отсутствие сессии -> 404
- delete url с ВМ (best-effort)
"""
import httpx
import logging
from flask import Blueprint, request, jsonify
from ..session import (create_session, add_file, get_files, file_count,
MAX_FILE_BYTES, configure)
from .config import PULL_RETRIES, PULL_RETRY_DELAY, VM_UPLOAD_PREFIX
from .safe_name import safe_name
from .pull_file import pull_file
log = logging.getLogger("upload.upload_refs")
def create_upload_refs_blueprint(cfg: dict, sink=None) -> Blueprint:
"""Создать Blueprint с эндпоинтом upload_refs.
cfg (все ключи опциональны, есть дефолты):
apiPrefix (str) — префикс Blueprint, по умолчанию "/api"
vmUploadPrefix (str) — доверенный префикс ВМ-буфера (SSRF-валидация)
maxFileBytes (int) — лимит на один файл
maxSessionBytes (int) — суммарный лимит сессии (применяется к сессиям)
ttlSeconds (int) — TTL сессии
pullRetries (int) — ретраи pull
pullRetryDelay (int) — пауза между ретраями (сек)
pullTimeout (int) — таймаут одного GET pull
sink (callable | None): если задан — вместо add_file в in-memory сессию
вызывается sink(name, content, **extra) на каждый вытянутый файл,
endpoint возвращает {"ok": true, "results": [...]}. extra — сквозные
поля тела запроса (batch_id, contract_id, zip_source). Если None —
прежнее поведение drhider (in-memory сессия).
"""
prefix = cfg.get("apiPrefix", "/api")
vm_prefix = cfg.get("vmUploadPrefix", VM_UPLOAD_PREFIX)
max_file_bytes = cfg.get("maxFileBytes", MAX_FILE_BYTES)
pull_retries = cfg.get("pullRetries", PULL_RETRIES)
pull_delay = cfg.get("pullRetryDelay", PULL_RETRY_DELAY)
pull_timeout = cfg.get("pullTimeout", 120)
# Применить лимиты сессии/TTL из конфига (глобально для всех сессий)
configure(
max_file_bytes=cfg.get("maxFileBytes"),
max_session_bytes=cfg.get("maxSessionBytes"),
ttl_seconds=cfg.get("ttlSeconds"),
)
bp = Blueprint("upload_refs", __name__, url_prefix=prefix)
@bp.route("/upload_refs", methods=["POST"])
def upload_refs():
"""Принять ссылки на файлы (загружены на ВМ-буфер), забрать по egress.
Вход: JSON {"session": "...", "files": [{"name": str, "size": int, "url": str}]}.
Каждый файл тянется ИСХОДЯЩИМ GET'ом с ВМ (egress не ограничен шлюзом),
читается по частям (stream), кладётся в сессию. После успешного pull файл
удаляется с ВМ (best-effort; TTL-чистка на ВМ тоже есть).
"""
data = request.get_json(silent=True) or {}
sid = data.get("session") or create_session()
refs = data.get("files") or []
if not refs:
log.warning("upload_refs: no files, sid=%s", sid)
return jsonify({"ok": False, "error": "No files"}), 400
extra = {k: data[k] for k in ("batch_id", "contract_id", "zip_source") if data.get(k) is not None}
results = [] if sink else None
added = 0
try:
with httpx.Client(timeout=pull_timeout, follow_redirects=True) as client:
for ref in refs:
name = safe_name(ref.get("name") or "")
url = ref.get("url")
if not name or not url:
continue
# SSRF-защита: тянуть можно ТОЛЬКО с доверенного ВМ-буфера
if not url.startswith(vm_prefix):
log.warning("upload_refs: unsafe URL, skip sid=%s url=%r", sid, url)
if sink:
results.append({"name": name, "ok": False, "error": "invalid url (SSRF guard)"})
continue
# Лимит на один файл: сверх лимита — пропускаем (не участвует)
if (ref.get("size") or 0) > max_file_bytes:
log.warning("upload_refs: file exceeds %dMB, skip sid=%s file=%r size=%s",
max_file_bytes // (1024 * 1024), sid, name, ref.get("size"))
try:
client.delete(url)
except Exception:
pass
if sink:
results.append({"name": name, "ok": False, "error": "file too large"})
continue
# Pull с ретраями: разовые DNS/сетевые сбои не роняют всю загрузку
try:
content = pull_file(client, url, pull_retries, pull_delay, sid=sid, name=name)
except Exception as e:
log.warning("upload_refs: pull failed sid=%s file=%r: %r", sid, name, e)
if sink:
results.append({"name": name, "ok": False, "error": "pull failed: %s" % e})
continue
log.info("upload_refs: pulled sid=%s file=%r size=%d", sid, name, len(content))
if len(content) > max_file_bytes:
log.warning("upload_refs: pulled file exceeds %dMB, skip sid=%s file=%r size=%d",
max_file_bytes // (1024 * 1024), sid, name, len(content))
try:
client.delete(url)
except Exception:
pass
if sink:
results.append({"name": name, "ok": False, "error": "file too large"})
continue
if sink:
# Отдать файл приложению через sink (вместо in-memory сессии)
try:
client.delete(url)
except Exception:
pass
try:
r = sink(name, content, **extra) or {}
results.append({"name": name, **r})
except Exception as e:
log.error("upload_refs: sink failed file=%r: %r", name, e)
results.append({"name": name, "ok": False, "error": str(e)})
continue
if not add_file(sid, name, content):
# Различить: сессия исчезла vs превышен суммарный лимит сессии
if get_files(sid) is None:
log.warning("upload_refs: session not found, sid=%s file=%r", sid, name)
return jsonify({"ok": False, "error": "Session not found"}), 404
log.warning("upload_refs: session limit exceeded, skip sid=%s file=%r", sid, name)
try:
client.delete(url)
except Exception:
pass
continue
try:
client.delete(url) # убрать файл с ВМ после загрузки
except Exception:
pass
added += 1
except Exception as e:
log.error("upload_refs: pull error sid=%s: %r", sid, e)
return jsonify({"ok": False, "error": "Pull failed: %s" % e}), 502
if sink:
return jsonify({"ok": True, "results": results})
log.info("upload_refs: done sid=%s added=%d total=%d", sid, added, file_count(sid))
return jsonify({"ok": True, "session": sid, "count": file_count(sid)})
return bp
+11
View File
@@ -0,0 +1,11 @@
"""Параметры слоя 2 (бэк) по умолчанию.
Переопределяются из конфига приложения через create_upload_refs_blueprint(cfg).
"""
# Ретраи pull из ВМ-буфера: защита от разовых DNS/сетевых сбоев (gaierror -5 и т.п.)
PULL_RETRIES = 3
PULL_RETRY_DELAY = 2 # секунды между попытками
# Доверенный префикс ВМ-буфера — валидация URL при pull (защита от SSRF)
VM_UPLOAD_PREFIX = "https://contracts.kube5s.ru/drhider-upload/"
+39
View File
@@ -0,0 +1,39 @@
"""pull_file — вытащить файл с ВМ-буфера исходящим GET с ретраями."""
import logging
import time
from .config import PULL_RETRIES, PULL_RETRY_DELAY
log = logging.getLogger("upload.upload_refs.pull")
def pull_file(client, url: str, retries: int = PULL_RETRIES,
delay: float = PULL_RETRY_DELAY, sid: str = None, name: str = None) -> bytes:
"""GET url с ретраями; читает по частям (stream).
Args:
client: httpx.Client
url: URL файла на ВМ-буфере.
retries: число попыток.
delay: пауза между попытками (сек).
sid/name: для логирования (опционально).
Returns:
Содержимое файла (bytes).
Raises:
Последнюю ошибку попытки, если все ретраи не удались.
"""
last_err = None
for attempt in range(retries):
try:
with client.stream("GET", url) as resp:
resp.raise_for_status()
return b"".join(resp.iter_bytes())
except Exception as e:
last_err = e
log.warning("pull: attempt %d/%d failed sid=%s file=%r: %r",
attempt + 1, retries, sid, name, e)
time.sleep(delay)
raise last_err if last_err else RuntimeError("pull failed")
+16
View File
@@ -0,0 +1,16 @@
"""safe_name — санитизация имени файла (защита от path traversal)."""
def safe_name(name: str) -> str:
"""Санитизировать имя файла: защита от path traversal, сохраняя подпапки.
Запрещает '..' и абсолютные пути; нормализует слэши. Возвращает "" если
имя пустое или небезопасное.
"""
if not name:
return ""
name = name.replace("\\", "/")
parts = [p for p in name.split("/") if p and p != "."]
if not parts or any(p == ".." for p in parts):
return ""
return "/".join(parts)
+13
View File
@@ -0,0 +1,13 @@
{
"vmUploadUrl": "https://contracts.kube5s.ru/drhider-upload/",
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"],
"maxFileBytes": 52428800,
"maxSessionBytes": 524288000,
"apiPrefix": "/api",
"vmUploadPrefix": "https://contracts.kube5s.ru/drhider-upload/",
"pullRetries": 3,
"pullRetryDelay": 2,
"pullTimeout": 120,
"ttlSeconds": 1800,
"estMbSec": 12
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "upload-frontend",
"private": true,
"type": "module",
"description": "Переиспользуемый фронт слоёв 1 и 2 (выбор файлов + закачка через ВМ). Модули ES — подключаются в браузере через <script type=\"module\">; Node-режим нужен для юнит-тестов zip."
}
@@ -0,0 +1,47 @@
// addFileWithDedup — дедуп «имя+размер», суффиксы _2/_3, лимиты (over).
// Мутирует state. Возвращает true если файл добавлен (или переведён в over),
// false если это дедуп (обновлена только дата).
export function addFileWithDedup(state, file, cfg) {
const size = file.size;
const mtime = file.lastModified;
let name = file.name;
const maxFileBytes = cfg.maxFileBytes;
const maxSessionBytes = cfg.maxSessionBytes;
if (state.fileMeta.has(name)) {
const e = state.fileMeta.get(name);
if (e.size === size) {
// Тот же файл (имя+размер) — дедуп. Дату не сравниваем: файл могли пересохранить
// с тем же содержимым. Оставляем более свежий по дате.
if (mtime > e.mtime) {
e.mtime = mtime;
const idx = state.files.findIndex(f => f.name === name);
if (idx >= 0) state.files[idx] = new File([file], name, { lastModified: mtime });
}
return false; // дедуп: файл не добавлен (обновлена только дата)
}
// Имя то же, размер другой — добавить с суффиксом _2, _3...
const dot = name.lastIndexOf('.');
const base = dot > 0 ? name.slice(0, dot) : name;
const ext = dot > 0 ? name.slice(dot) : '';
let n = 2;
while (state.fileMeta.has(base + '_' + n + ext)) n++;
name = base + '_' + n + ext;
}
state.fileMeta.set(name, { size, mtime });
// Определяем, превышает ли файл лимит (по размеру файла или суммарный) — не участвует в обфускации
let over = false;
if (size > maxFileBytes) over = true; // лимит на один файл
const sum = state.files.reduce((s, f) => s + (state.overNames.has(f.name) ? 0 : f.size), 0);
if (sum + size > maxSessionBytes) over = true; // суммарный лимит сессии
if (over) {
state.overNames.add(name);
state.files.push(new File([file], name, { lastModified: mtime }));
return true;
}
state.files.push(new File([file], name, { lastModified: mtime }));
return true;
}
+5
View File
@@ -0,0 +1,5 @@
// esc — экранирование HTML (защита от self-XSS именами файлов).
export function esc(s) {
return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
+8
View File
@@ -0,0 +1,8 @@
// estForFile — эмпирическая оценка времени обработки файла: сек/МБ (ориентировочно, до старта).
export const DEFAULT_EST_MB_SEC = 12;
export function estForFile(f, estMbSec) {
const k = estMbSec || DEFAULT_EST_MB_SEC;
return f ? Math.max(1, Math.round(f.size / 1048576 * k)) : 0;
}
+6
View File
@@ -0,0 +1,6 @@
// fmtSec — формат секунд: "Nс" / "Nм Nс".
export function fmtSec(s) {
s = Math.max(0, Math.round(s));
return s >= 60 ? Math.floor(s / 60) + 'м ' + (s % 60) + 'с' : s + 'с';
}
+7
View File
@@ -0,0 +1,7 @@
// fs — формат размера: B / KB / MB.
export function fs(b) {
return b < 1024 ? b + ' B'
: b < 1048576 ? (b / 1024).toFixed(1) + ' KB'
: (b / 1048576).toFixed(1) + ' MB';
}
@@ -0,0 +1,56 @@
// initUploadTable — сборка слоя 1 (выбор файлов/папки/архива).
// Состояние (files/fileMeta/overNames) живёт внутри модуля.
import { addFiles as addFilesToState, makeFilesChangeHandler } from './on_files_change.js';
import { makeFolderChangeHandler } from './on_folder_change.js';
import { render } from './render.js';
import { setStatus } from './set_status.js';
import { rmFile } from './rm_file.js';
// cfg: {allowedExt, maxFileBytes, maxSessionBytes, estMbSec}
// els: {fileInput, folderInput, tableBody, countEl, uploadBtnEl, onStatus(cls, text)}
// returns api (см. README.md)
export function initUploadTable(cfg, els) {
const state = {
files: [], // File[]
fileMeta: new Map(), // имя -> {size, mtime} для дедупа/суффиксов
overNames: new Set(), // имена файлов сверх лимита (не участвуют)
busy: false, // идёт загрузка/обработка — список заблокирован
proc: null, // {phase, procState, procExtractRate} — задаёт слой 3
};
const onFilesChange = makeFilesChangeHandler({ state, cfg, els, onStatus: els.onStatus });
const onFolderChange = makeFolderChangeHandler({ state, cfg, els, onStatus: els.onStatus });
els.fileInput.addEventListener('change', onFilesChange);
els.folderInput.addEventListener('change', onFolderChange);
// Делегирование кликов по кнопкам «✕» (удалить строку)
els.tableBody.addEventListener('click', e => {
const btn = e.target.closest('.remove-btn');
if (btn) rmFile(state, els, cfg, Number(btn.dataset.idx));
});
return {
state, // доступ для слоя 3 (установить state.proc для render)
addFiles(files) { return addFilesToState(state, cfg, files, els, els.onStatus); },
getFiles() { // ТОЛЬКО учитываемые (без over): [{name, size, file}]
return state.files.filter(f => !state.overNames.has(f.name))
.map(f => ({ name: f.name, size: f.size, file: f }));
},
getOverNames() { return state.overNames; },
setStatus(idx, html) { setStatus(idx, html); },
render() { render(state, els, cfg); },
clear() {
state.files = [];
state.fileMeta = new Map();
state.overNames = new Set();
if (els.fileInput) els.fileInput.value = '';
render(state, els, cfg);
},
setBusy(b) {
state.busy = b;
if (els.fileInput) els.fileInput.disabled = b;
},
_rm(i) { rmFile(state, els, cfg, i); },
};
}
+56
View File
@@ -0,0 +1,56 @@
// Обработчик <input type="file" multiple> change: дедуп + раскрытие ZIP + фильтр.
// Экспортируется и чистая логика добавления массива файлов (addFiles),
// и фабрика обработчика для input (makeFilesChangeHandler).
import { listZipFiles } from '../zip/list_zip_files.js';
import { addFileWithDedup } from './add_file_with_dedup.js';
import { render } from './render.js';
// Добавить массив файлов в список (дедуп + раскрытие ZIP + лимиты).
// files: File[]. els.fileInput — для синхронизации input.files (DataTransfer),
// чтобы повторный выбор того же файла сработал. onStatus(cls, text) — колбэк сообщений.
export async function addFiles(state, cfg, files, els, onStatus) {
const incoming = Array.from(files);
const hasZip = incoming.some(f => f.name.toLowerCase().endsWith('.zip'));
if (hasZip) {
document.body.style.cursor = 'wait';
if (onStatus) onStatus('progress', 'Разбираю архивы…');
}
try {
for (const f of incoming) {
if (f.name.toLowerCase().endsWith('.zip')) {
try {
const nested = await listZipFiles(f, cfg.allowedExt);
if (nested.length) nested.forEach(x => addFileWithDedup(state, x, cfg));
else addFileWithDedup(state, f, cfg); // в архиве нет документов — архив как есть
} catch (err) {
addFileWithDedup(state, f, cfg); // не удалось распаковать — zip как есть
}
} else {
addFileWithDedup(state, f, cfg);
}
}
} finally {
if (hasZip) {
document.body.style.cursor = '';
if (onStatus) onStatus('', '');
}
}
// Синхронизировать input.files — чтобы повторный выбор того же файла сработал
if (els && els.fileInput && els.fileInput.files) {
const d = new DataTransfer();
state.files.forEach(f => d.items.add(f));
els.fileInput.files = d.files;
}
render(state, els, cfg);
}
// Фабрика обработчика change для <input type="file">.
// ctx = { state, cfg, els, onStatus }
export function makeFilesChangeHandler(ctx) {
const { state, cfg, els, onStatus } = ctx;
return () => {
if (state.busy) return; // во время загрузки/обработки менять список нельзя
addFiles(state, cfg, els.fileInput.files, els, onStatus);
};
}
+58
View File
@@ -0,0 +1,58 @@
// Обработчик <input webkitdirectory> change: выбор целой папки, рекурсивно,
// относительный путь сохраняется (верхняя папка отбрасывается).
import { listZipFiles } from '../zip/list_zip_files.js';
import { addFileWithDedup } from './add_file_with_dedup.js';
import { render } from './render.js';
// Фабрика обработчика change для input выбора папки.
// ctx = { state, cfg, els, onStatus }
export function makeFolderChangeHandler(ctx) {
const { state, cfg, els, onStatus } = ctx;
return async () => {
if (state.busy) return; // во время загрузки/обработки менять список нельзя
const incoming = Array.from(els.folderInput.files);
if (!incoming.length) return;
document.body.style.cursor = 'wait';
if (onStatus) onStatus('progress', 'Разбираю папку…');
let added = 0;
try {
for (const f of incoming) {
// webkitRelativePath: "TopFolder/Подпапка/file.pdf" — отбрасываем верхнюю папку
const parts = (f.webkitRelativePath || f.name).split('/');
const rel = parts.slice(1).join('/') || f.name;
const low = rel.toLowerCase();
const slashIdx = rel.lastIndexOf('/');
const relDir = slashIdx >= 0 ? rel.slice(0, slashIdx) : '';
if (low.endsWith('.zip')) {
try {
const nested = await listZipFiles(f, cfg.allowedExt);
if (nested.length) {
for (const nf of nested) {
const nm = relDir ? relDir + '/' + nf.name : nf.name;
if (addFileWithDedup(state, new File([nf], nm, { lastModified: nf.lastModified }), cfg)) added++;
}
} else {
// в архиве нет документов — добавить архив как есть, чтобы не терялся
if (addFileWithDedup(state, new File([f], rel, { lastModified: f.lastModified }), cfg)) added++;
}
} catch (err) {
if (addFileWithDedup(state, new File([f], rel, { lastModified: f.lastModified }), cfg)) added++; // zip как есть
}
} else if (cfg.allowedExt.some(e => low.endsWith(e))) {
if (addFileWithDedup(state, new File([f], rel, { lastModified: f.lastModified }), cfg)) added++;
}
// иначе — не документ, пропускаем
}
} finally {
document.body.style.cursor = '';
}
els.folderInput.value = ''; // чтобы повторный выбор той же папки сработал
render(state, els, cfg);
if (added && onStatus) {
const n = added;
const w = (n % 10 === 1 && n % 100 !== 11) ? 'файл' : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 'файла' : 'файлов';
onStatus('done', '✅ Добавлено из папки: ' + n + ' ' + w);
}
};
}
+13
View File
@@ -0,0 +1,13 @@
// procRow — строка таблицы обработки (3-секционная).
import { esc } from './esc.js';
import { fs } from './fs.js';
export function procRow(state, i, stTxt) {
const f = state.files[i];
const over = state.overNames.has(f.name);
const p = state.proc && state.proc.procState ? state.proc.procState[i] : null;
const cls = (p && p.st === 'current') ? ' class="row-current"'
: (over ? ' class="row-over"' : '');
return '<tr' + cls + '><td class="name-cell">' + esc(f.name) + '</td><td class="num-cell">' + fs(f.size) + '</td><td class="num-cell" style="font-size:12px;">' + stTxt + '</td><td></td></tr>';
}
+29
View File
@@ -0,0 +1,29 @@
// render — рендер таблицы выбора файлов (обычная).
// Если идёт обработка (state.proc.phase === 'processing') — 3-секционная.
import { esc } from './esc.js';
import { fs } from './fs.js';
import { fmtSec } from './fmt_sec.js';
import { estForFile } from './est_for_file.js';
import { renderProcTable } from './render_proc_table.js';
export function render(state, els, cfg) {
if (state.proc && state.proc.phase === 'processing') { renderProcTable(state, els, cfg); return; }
if (state.files.length === 0) {
els.tableBody.innerHTML = '<tr class="empty-row"><td colspan="4">Нет выбранных файлов</td></tr>';
} else {
els.tableBody.innerHTML = state.files.map((f, i) => {
const over = state.overNames.has(f.name);
const rowCls = over ? ' class="row-over"' : '';
const stTxt = over ? '<span style="color:#c0392b;">🔥 не учитывается</span>'
: '<span style="color:#7d3c98;">~' + fmtSec(estForFile(f, cfg.estMbSec)) + '</span>';
return '<tr id="row-' + i + '"' + rowCls + '><td class="name-cell">' + esc(f.name) + '</td><td class="num-cell">' + fs(f.size) + '</td><td class="num-cell" id="st-' + i + '" style="font-size:12px;">' + stTxt + '</td><td><button class="remove-btn" data-idx="' + i + '">✕</button></td></tr>';
}).join('');
}
const overCount = state.files.filter(f => state.overNames.has(f.name)).length;
const totalSize = state.files.reduce((s, f) => s + (state.overNames.has(f.name) ? 0 : f.size), 0);
const cntMain = state.files.length - overCount;
const totEst = state.files.reduce((s, f) => s + (state.overNames.has(f.name) ? 0 : estForFile(f, cfg.estMbSec)), 0);
els.countEl.textContent = (overCount ? cntMain + ' учитываются + ' + overCount + ' свыше лимита' : state.files.length) + ' файлов · ' + fs(totalSize) + ' · ~' + fmtSec(totEst);
els.uploadBtnEl.disabled = cntMain === 0;
}
@@ -0,0 +1,74 @@
// renderProcTable — 3-секционная таблица во время обработки
// (готово / текущий / ожидают / пропущены сверх лимита).
import { fmtSec } from './fmt_sec.js';
import { estForFile, DEFAULT_EST_MB_SEC } from './est_for_file.js';
import { procRow } from './proc_row.js';
export function renderProcTable(state, els, cfg) {
const procState = state.proc.procState;
const groups = { done: [], current: [], pending: [], over: [] };
for (let i = 0; i < state.files.length; i++) {
if (state.overNames.has(state.files[i].name)) { groups.over.push(i); continue; }
const st = procState[i] ? procState[i].st : 'pending';
if (st === 'done' || st === 'skipped') groups.done.push(i);
else if (st === 'current') groups.current.push(i);
else groups.pending.push(i);
}
// Оценка скорости из текущего файла (сек/символ) — для «ожидающих»
let rate = null;
for (const i of groups.current) {
const p = procState[i];
const cur = (performance.now() - p.t0) / 1000;
const total = cur + (p.eta != null ? p.eta : 0);
if (p.chars > 0 && total > 0) rate = total / p.chars;
}
const rows = [];
if (groups.done.length) {
rows.push('<tr class="grp-row"><td colspan="4">✓ Обработанные (' + groups.done.length + ')</td></tr>');
for (const i of groups.done) {
const p = procState[i];
const txt = p.st === 'skipped'
? '<span style="color:#c0392b;" title="Не удалось прочитать файл: пустой, повреждённый или скан без текста">не извлечён</span>'
: '<span style="color:#22c55e;">✓ ' + (p.elapsed ? p.elapsed.toFixed(1) : '0.0') + 'с</span>';
rows.push(procRow(state, i, txt));
}
}
if (groups.over.length) {
rows.push('<tr class="grp-row"><td colspan="4">⛔ Пропущены (сверх лимита) (' + groups.over.length + ')</td></tr>');
for (const i of groups.over) {
rows.push(procRow(state, i, '<span style="color:#c0392b;">пропущен (лимит)</span>'));
}
}
if (groups.current.length) {
rows.push('<tr class="grp-row"><td colspan="4">▶ Текущий файл</td></tr>');
for (const i of groups.current) {
const p = procState[i];
const cur = ((performance.now() - p.t0) / 1000).toFixed(1);
const eta = (p.eta != null) ? ' / ~' + fmtSec(p.eta) : '';
rows.push(procRow(state, i, '<span style="color:#2563eb;">⏳ ' + cur + 'с' + eta + '</span>'));
}
}
if (groups.pending.length) {
rows.push('<tr class="grp-row"><td colspan="4">○ Ожидают обработки (' + groups.pending.length + ')</td></tr>');
for (const i of groups.pending) {
const p = procState[i] || {};
// Оценка: LLM (chars x rate) если известна; иначе грубая по размеру/скорости извлечения
if (rate && !p.est && p.chars > 0) p.est = p.chars * rate;
if (!p.est) {
const szMB = (state.files[i] ? state.files[i].size : 0) / 1048576;
const k = (state.proc && state.proc.procExtractRate) || cfg.estMbSec || DEFAULT_EST_MB_SEC; // сек/МБ
p.est = Math.max(1, Math.round(szMB * k));
}
let txt;
if (p.st === 'analyzed') txt = '<span style="color:#7d3c98;">анализ ✓</span>';
else if (p.st === 'current') txt = '<span style="color:#2563eb;">⏳</span>';
else if (p.est != null) txt = '<span style="color:#999;">~' + fmtSec(p.est) + '</span>';
else txt = '<span style="color:#999;">—</span>';
rows.push(procRow(state, i, txt));
}
}
els.tableBody.innerHTML = rows.join('');
const cntDone = groups.done.length;
els.countEl.textContent = 'Готово ' + cntDone + ' / ' + state.files.length + ' файлов';
}
+19
View File
@@ -0,0 +1,19 @@
// rmFile — удалить файл из списка (если не busy).
import { render } from './render.js';
export function rmFile(state, els, cfg, i) {
if (state.busy) return;
const nm = state.files[i].name;
state.overNames.delete(nm);
state.fileMeta.delete(nm);
state.files.splice(i, 1);
// Синхронизировать input.files (DataTransfer) — чтобы повторный выбор того же
// файла сработал (change не сработает, если files не обновлён).
if (els.fileInput && els.fileInput.files) {
const d = new DataTransfer();
state.files.forEach(f => d.items.add(f));
els.fileInput.files = d.files;
}
render(state, els, cfg);
}
+6
View File
@@ -0,0 +1,6 @@
// setStatus — записать HTML-статус в ячейку таблицы (st-<idx>).
export function setStatus(idx, html) {
const e = document.getElementById('st-' + idx);
if (e) e.innerHTML = html;
}
+344
View File
@@ -0,0 +1,344 @@
// Юнит-тесты фронта модуля upload: zip (кириллица, вложенный zip, не-doc) + дедуп/лимиты.
// Запуск: node upload/frontend/test_upload_frontend.mjs
import assert from 'node:assert/strict';
import { deflateRawSync } from 'node:zlib';
// ── Полифилл File (Node 18 не имеет global File) ──
if (typeof globalThis.File === 'undefined') {
globalThis.File = class File extends Blob {
constructor(parts, name, opts) {
super(parts, opts);
this.name = name;
this.lastModified = (opts && opts.lastModified) || Date.now();
}
};
}
import { listZipFiles } from './zip/list_zip_files.js';
import { parseZip } from './zip/parse_zip.js';
import { decodeZipName } from './zip/decode_zip_name.js';
import { addFileWithDedup } from './table/add_file_with_dedup.js';
import { esc } from './table/esc.js';
import { fs } from './table/fs.js';
import { fmtSec } from './table/fmt_sec.js';
import { estForFile } from './table/est_for_file.js';
const ALLOWED = ['.pdf', '.doc', '.docx', '.txt', '.md'];
// ── CRC32 (для сборки тестовых ZIP) ──
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
t[n] = c >>> 0;
}
return t;
})();
function crc32(bytes) {
let c = 0xffffffff;
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
// ── Сборка минимального ZIP (method 0, UTF-8-флаг) ──
function makeZip(entries) {
const enc = new TextEncoder();
const chunks = [];
const central = [];
let offset = 0;
const utf8Flag = 0x0800;
for (const e of entries) {
const nameB = enc.encode(e.name);
const dataB = typeof e.data === 'string' ? enc.encode(e.data) : e.data;
const crc = crc32(dataB);
const lh = new DataView(new ArrayBuffer(30));
lh.setUint32(0, 0x04034b50, true);
lh.setUint16(4, 20, true);
lh.setUint16(6, utf8Flag, true);
lh.setUint16(8, 0, true);
lh.setUint16(10, 0, true);
lh.setUint16(12, 0x21, true);
lh.setUint32(14, crc, true);
lh.setUint32(18, dataB.length, true);
lh.setUint32(22, dataB.length, true);
lh.setUint16(26, nameB.length, true);
lh.setUint16(28, 0, true);
chunks.push(Buffer.from(lh.buffer), Buffer.from(nameB), Buffer.from(dataB));
const cd = new DataView(new ArrayBuffer(46));
cd.setUint32(0, 0x02014b50, true);
cd.setUint16(4, 20, true);
cd.setUint16(6, 20, true);
cd.setUint16(8, utf8Flag, true);
cd.setUint16(10, 0, true);
cd.setUint16(12, 0, true);
cd.setUint16(14, 0x21, true);
cd.setUint32(16, crc, true);
cd.setUint32(20, dataB.length, true);
cd.setUint32(24, dataB.length, true);
cd.setUint16(28, nameB.length, true);
cd.setUint16(30, 0, true);
cd.setUint16(32, 0, true);
cd.setUint16(34, 0, true);
cd.setUint16(36, 0, true);
cd.setUint32(38, 0, true);
cd.setUint32(42, offset, true);
central.push(Buffer.from(cd.buffer), Buffer.from(nameB));
offset += 30 + nameB.length + dataB.length;
}
const cdSize = central.reduce((s, x) => s + x.byteLength, 0);
const cdStart = offset;
const eocd = new DataView(new ArrayBuffer(22));
eocd.setUint32(0, 0x06054b50, true);
eocd.setUint16(4, 0, true);
eocd.setUint16(6, 0, true);
eocd.setUint16(8, entries.length, true);
eocd.setUint16(10, entries.length, true);
eocd.setUint32(12, cdSize, true);
eocd.setUint32(16, cdStart, true);
eocd.setUint16(20, 0, true);
chunks.push(...central, Buffer.from(eocd.buffer));
return Buffer.concat(chunks);
}
// ── Сборка ZIP с методом 8 (deflate) — для проверки inflateRaw ──
async function deflateRaw(bytes) {
// zlib.deflateRawSync — deflate без zlib-заголовка (то, что ждёт inflateRaw)
return deflateRawSync(Buffer.from(bytes));
}
async function makeZipDeflate(entries) {
const enc = new TextEncoder();
const chunks = [];
const central = [];
let offset = 0;
const utf8Flag = 0x0800;
for (const e of entries) {
const nameB = enc.encode(e.name);
const raw = enc.encode(e.data);
const comp = await deflateRaw(raw);
const crc = crc32(raw);
const lh = new DataView(new ArrayBuffer(30));
lh.setUint32(0, 0x04034b50, true);
lh.setUint16(4, 20, true);
lh.setUint16(6, utf8Flag, true);
lh.setUint16(8, 8, true); // method 8 deflate
lh.setUint16(10, 0, true);
lh.setUint16(12, 0x21, true);
lh.setUint32(14, crc, true);
lh.setUint32(18, comp.length, true);
lh.setUint32(22, raw.length, true);
lh.setUint16(26, nameB.length, true);
lh.setUint16(28, 0, true);
chunks.push(Buffer.from(lh.buffer), Buffer.from(nameB), Buffer.from(comp));
const cd = new DataView(new ArrayBuffer(46));
cd.setUint32(0, 0x02014b50, true);
cd.setUint16(4, 20, true);
cd.setUint16(6, 20, true);
cd.setUint16(8, utf8Flag, true);
cd.setUint16(10, 8, true);
cd.setUint16(12, 0, true);
cd.setUint16(14, 0x21, true);
cd.setUint32(16, crc, true);
cd.setUint32(20, comp.length, true);
cd.setUint32(24, raw.length, true);
cd.setUint16(28, nameB.length, true);
cd.setUint16(30, 0, true);
cd.setUint16(32, 0, true);
cd.setUint16(34, 0, true);
cd.setUint16(36, 0, true);
cd.setUint32(38, 0, true);
cd.setUint32(42, offset, true);
central.push(Buffer.from(cd.buffer), Buffer.from(nameB));
offset += 30 + nameB.length + comp.length;
}
const cdSize = central.reduce((s, x) => s + x.byteLength, 0);
const cdStart = offset;
const eocd = new DataView(new ArrayBuffer(22));
eocd.setUint32(0, 0x06054b50, true);
eocd.setUint16(4, 0, true);
eocd.setUint16(6, 0, true);
eocd.setUint16(8, entries.length, true);
eocd.setUint16(10, entries.length, true);
eocd.setUint32(12, cdSize, true);
eocd.setUint32(16, cdStart, true);
eocd.setUint16(20, 0, true);
chunks.push(...central, Buffer.from(eocd.buffer));
return Buffer.concat(chunks);
}
// ── Тесты decodeZipName ──
function testDecodeZipName() {
const utf8 = new TextEncoder().encode('договор.pdf');
assert.equal(decodeZipName(utf8, true), 'договор.pdf');
// Без UTF-8-флага, но байты — валидный UTF-8 с кириллицей → эвристика берёт как есть
assert.equal(decodeZipName(utf8, false), 'договор.pdf');
console.log(' decodeZipName: ok');
}
// ── Тесты listZipFiles ──
async function testZipCyrillic() {
const zip = makeZip([{ name: 'договор.pdf', data: '%PDF-1.4 fake' }]);
const files = await listZipFiles(new File([zip], 'a.zip'), ALLOWED);
assert.equal(files.length, 1);
assert.equal(files[0].name, 'договор.pdf');
console.log(' zip кириллица: ok');
}
async function testZipNested() {
const inner = makeZip([{ name: 'inner.txt', data: 'hi' }]);
const outer = makeZip([
{ name: 'inner.zip', data: inner },
{ name: 'skip.txt', data: 'x' },
]);
const files = await listZipFiles(new File([outer], 'outer.zip'), ALLOWED);
const names = files.map(f => f.name).sort();
assert.deepEqual(names, ['inner.txt', 'skip.txt']);
console.log(' вложенный zip: ok');
}
async function testZipNonDoc() {
const zip = makeZip([
{ name: 'doc.pdf', data: 'pdf' },
{ name: 'img.png', data: 'png' },
{ name: 'readme.txt', data: 'readme' },
]);
const files = await listZipFiles(new File([zip], 'a.zip'), ALLOWED);
const names = files.map(f => f.name).sort();
assert.deepEqual(names, ['doc.pdf', 'readme.txt']);
console.log(' не-doc фильтр: ok');
}
async function testZipDeflate() {
// deflate-raw требует DecompressionStream('deflate-raw') — в браузере есть, в Node 18 нет
let supported = true;
try { new DecompressionStream('deflate-raw'); } catch (e) { supported = false; }
if (!supported) {
console.log(' zip deflate (метод 8): skip — Node 18 не поддерживает deflate-raw');
return;
}
const content = 'Hello deflate world '.repeat(50);
const zip = await makeZipDeflate([{ name: 'deflated.txt', data: content }]);
const files = await listZipFiles(new File([zip], 'a.zip'), ALLOWED);
assert.equal(files.length, 1);
assert.equal(files[0].name, 'deflated.txt');
assert.equal(await files[0].text(), content);
console.log(' zip deflate (метод 8): ok');
}
async function testParseZipNotZip() {
await assert.rejects(() => parseZip(new Uint8Array([1, 2, 3])), /Не ZIP/);
console.log(' не-ZIP бросок: ok');
}
// ── Тесты addFileWithDedup ──
function newState() {
return { files: [], fileMeta: new Map(), overNames: new Set(), busy: false, proc: null };
}
function testDedup() {
const st = newState();
const c = { maxFileBytes: 100, maxSessionBytes: 300 };
// одинаковое имя+размер → дедуп (false), список не растёт
assert.equal(addFileWithDedup(st, new File(['aaa'], 'a.txt'), c), true);
assert.equal(st.files.length, 1);
assert.equal(addFileWithDedup(st, new File(['aaa'], 'a.txt'), c), false);
assert.equal(st.files.length, 1);
// имя то же, размер другой → суффикс _2
assert.equal(addFileWithDedup(st, new File(['bbbb'], 'a.txt'), c), true);
assert.equal(st.files.length, 2);
assert.equal(st.files[1].name, 'a_2.txt');
console.log(' дедуп/суффиксы: ok');
}
function testLimits() {
// лимит на один файл
const st = newState();
const c = { maxFileBytes: 100, maxSessionBytes: 300 };
addFileWithDedup(st, new File([new Uint8Array(150)], 'big.bin'), c);
assert.ok(st.overNames.has('big.bin'));
assert.equal(st.files.length, 1);
// суммарный лимит сессии
const st2 = newState();
const c2 = { maxFileBytes: 1000, maxSessionBytes: 200 };
addFileWithDedup(st2, new File([new Uint8Array(150)], 'f1.bin'), c2);
addFileWithDedup(st2, new File([new Uint8Array(60)], 'f2.bin'), c2); // 150+60=210 > 200 → over
assert.ok(st2.overNames.has('f2.bin'));
console.log(' лимиты over: ok');
}
function testSuffixes() {
const st = newState();
const c = { maxFileBytes: 10000, maxSessionBytes: 100000 };
addFileWithDedup(st, new File(['aaa'], 'a.txt'), c);
addFileWithDedup(st, new File(['bbbb'], 'a.txt'), c); // a_2.txt
addFileWithDedup(st, new File(['ccccc'], 'a.txt'), c); // a_3.txt
assert.deepEqual(st.files.map(f => f.name), ['a.txt', 'a_2.txt', 'a_3.txt']);
console.log(' суффиксы _2/_3: ok');
}
function testEsc() {
assert.equal(esc('<a href="x">&\'</a>'), '&lt;a href=&quot;x&quot;&gt;&amp;&#39;&lt;/a&gt;');
console.log(' esc (XSS-экранирование): ok');
}
function testFs() {
assert.equal(fs(0), '0 B');
assert.equal(fs(1023), '1023 B');
assert.equal(fs(1024), '1.0 KB');
assert.equal(fs(1048576), '1.0 MB');
console.log(' fs (размер): ok');
}
function testFmtSec() {
assert.equal(fmtSec(0), '0с');
assert.equal(fmtSec(59), '59с');
assert.equal(fmtSec(60), '1м 0с');
assert.equal(fmtSec(125), '2м 5с');
console.log(' fmtSec: ok');
}
function testEstForFile() {
assert.equal(estForFile({ size: 1048576 }, 12), 12); // 1 МБ × 12 = 12с
assert.equal(estForFile(null, 12), 0);
console.log(' estForFile: ok');
}
// ── Прогон ──
const TESTS = [
['decodeZipName', testDecodeZipName],
['zip кириллица', testZipCyrillic],
['вложенный zip', testZipNested],
['не-doc фильтр', testZipNonDoc],
['zip deflate (метод 8)', testZipDeflate],
['не-ZIP бросок', testParseZipNotZip],
['дедуп/суффиксы', testDedup],
['лимиты over', testLimits],
['суффиксы _2/_3', testSuffixes],
['esc XSS', testEsc],
['fs размер', testFs],
['fmtSec', testFmtSec],
['estForFile', testEstForFile],
];
let failed = 0;
for (const [name, fn] of TESTS) {
try {
await fn();
console.log('PASS ' + name);
} catch (err) {
failed++;
console.error('FAIL ' + name + ': ' + err.message);
}
}
if (failed) {
console.error(failed + ' тестов упало');
process.exit(1);
}
console.log('Все фронт-тесты прошли');
+199
View File
@@ -0,0 +1,199 @@
// Юнит-тесты слоя 2 (фронт): putToVm + uploadViaVM с моками XMLHttpRequest и fetch.
// Запуск: node upload/frontend/test_upload_layer2.mjs
import assert from 'node:assert/strict';
// ── Полифилл File (Node 18 не имеет global File) ──
if (typeof globalThis.File === 'undefined') {
globalThis.File = class File extends Blob {
constructor(parts, name, opts) {
super(parts, opts);
this.name = name;
this.lastModified = (opts && opts.lastModified) || Date.now();
}
};
}
import { putToVm } from './upload/put_to_vm.js';
import { uploadViaVM } from './upload/upload_via_vm.js';
const tick = () => new Promise(r => setTimeout(r, 0));
// Node 18 не имеет globalThis.crypto (в браузере есть) — заглушка для uploadViaVM
globalThis.crypto = globalThis.crypto || { randomUUID: () => 'test-uuid-1234' };
// ── мок XMLHttpRequest ──
let lastXHR = null;
class FakeXHR {
constructor() {
this.upload = {};
this.status = 0;
this.timeout = 0;
this.onload = null;
this.onerror = null;
this.ontimeout = null;
this.method = null;
this.url = null;
this.body = null;
lastXHR = this;
}
open(method, url) { this.method = method; this.url = url; }
send(body) { this.body = body; }
}
globalThis.XMLHttpRequest = FakeXHR;
// ── мок fetch ──
let fetchCalls = [];
function installFetch(respond) {
fetchCalls = [];
globalThis.fetch = async (url, opts) => {
fetchCalls.push({ url, opts });
return respond();
};
}
// ── putToVm ──
async function testPutToVmSuccess() {
const f = new File(['abc'], 'a.txt');
const p = putToVm(f, 'https://vm/tok_0');
lastXHR.status = 200;
lastXHR.onload();
await p;
assert.equal(lastXHR.method, 'PUT');
assert.equal(lastXHR.url, 'https://vm/tok_0');
assert.equal(lastXHR.body, f);
console.log(' putToVm success: ok');
}
async function testPutToVmHttpError() {
const p = putToVm(new File(['abc'], 'a.txt'), 'https://vm/tok_0');
lastXHR.status = 500;
lastXHR.onload();
await assert.rejects(p, /ВМ: HTTP 500/);
console.log(' putToVm HTTP error: ok');
}
async function testPutToVmNetworkError() {
const p = putToVm(new File(['abc'], 'a.txt'), 'https://vm/tok_0');
lastXHR.onerror();
await assert.rejects(p, /Сеть/);
console.log(' putToVm network error: ok');
}
async function testPutToVmTimeout() {
const p = putToVm(new File(['abc'], 'a.txt'), 'https://vm/tok_0');
lastXHR.ontimeout();
await assert.rejects(p, /Таймаут/);
console.log(' putToVm timeout: ok');
}
async function testPutToVmOnXHR() {
let registered = null;
const p = putToVm(new File(['abc'], 'a.txt'), 'https://vm/tok_0', { onXHR(x) { registered = x; } });
assert.ok(registered, 'onXHR должен получить XHR для отмены');
registered.status = 200;
registered.onload();
await p;
console.log(' putToVm onXHR регистрация: ok');
}
// ── uploadViaVM ──
async function testUploadViaVMSuccess() {
installFetch(() => ({ ok: true, json: async () => ({ ok: true, session: 'sid123', count: 2 }) }));
const files = [new File(['a'], 'a.txt'), new File(['bb'], 'b.txt')];
const p = uploadViaVM(files, 'https://vm/', { session: '' });
await tick();
lastXHR.status = 200; lastXHR.onload();
await tick();
lastXHR.status = 200; lastXHR.onload();
const res = await p;
assert.equal(res.ok, true);
assert.equal(res.session, 'sid123');
assert.equal(res.count, 2);
assert.equal(fetchCalls.length, 1);
const body = JSON.parse(fetchCalls[0].opts.body);
assert.equal(body.files.length, 2);
assert.equal(body.files[0].name, 'a.txt');
assert.equal(body.files[1].name, 'b.txt');
assert.ok(body.files[0].url.startsWith('https://vm/'));
console.log(' uploadViaVM success: ok');
}
async function testUploadViaVMPutError() {
installFetch(() => ({ ok: true, json: async () => ({}) }));
const files = [new File(['a'], 'a.txt'), new File(['bb'], 'b.txt')];
const p = uploadViaVM(files, 'https://vm/', { session: '' });
await tick();
lastXHR.onerror(); // первый PUT падает
const res = await p;
assert.equal(res.ok, false);
assert.ok(res.error.includes('Ошибка загрузки на ВМ'));
assert.equal(fetchCalls.length, 0); // POST не вызван
console.log(' uploadViaVM PUT error: ok');
}
async function testUploadViaVMPostError() {
installFetch(() => ({ ok: true, json: async () => ({ ok: false, error: 'Session not found' }) }));
const files = [new File(['a'], 'a.txt')];
const p = uploadViaVM(files, 'https://vm/', { session: '' });
await tick();
lastXHR.status = 200; lastXHR.onload();
const res = await p;
assert.equal(res.ok, false);
assert.ok(res.error.includes('Ошибка передачи ссылок'));
console.log(' uploadViaVM POST error: ok');
}
async function testUploadViaVMProgress() {
installFetch(() => ({ ok: true, json: async () => ({ ok: true, session: 's', count: 1 }) }));
const statuses = [];
const texts = [];
const files = [new File(['abc'], 'a.txt')];
const p = uploadViaVM(files, 'https://vm/', {
session: '',
onStatus(k, html) { statuses.push({ k, html }); },
onUploadStatus(t) { texts.push(t); },
});
await tick();
lastXHR.status = 200; lastXHR.onload();
await p;
assert.ok(statuses.some(s => s.html.includes('✓')));
assert.ok(texts.some(t => t.includes('Загрузка на ВМ')));
assert.ok(texts.some(t => t.includes('Передача ссылок')));
console.log(' uploadViaVM progress/статусы: ok');
}
async function testUploadViaVMSessionPassed() {
// session из options пробрасывается в POST-тело
installFetch(() => ({ ok: true, json: async () => ({ ok: true, session: 'prev', count: 1 }) }));
const files = [new File(['a'], 'a.txt')];
const p = uploadViaVM(files, 'https://vm/', { session: 'existingsid' });
await tick();
lastXHR.status = 200; lastXHR.onload();
await p;
const body = JSON.parse(fetchCalls[0].opts.body);
assert.equal(body.session, 'existingsid');
console.log(' uploadViaVM проброс session: ok');
}
// ── прогон ──
const TESTS = [
['putToVm success', testPutToVmSuccess],
['putToVm HTTP error', testPutToVmHttpError],
['putToVm network error', testPutToVmNetworkError],
['putToVm timeout', testPutToVmTimeout],
['putToVm onXHR', testPutToVmOnXHR],
['uploadViaVM success', testUploadViaVMSuccess],
['uploadViaVM PUT error', testUploadViaVMPutError],
['uploadViaVM POST error', testUploadViaVMPostError],
['uploadViaVM progress', testUploadViaVMProgress],
['uploadViaVM проброс session', testUploadViaVMSessionPassed],
];
let failed = 0;
for (const [name, fn] of TESTS) {
try { await fn(); console.log('PASS ' + name); }
catch (e) { failed++; console.error('FAIL ' + name + ': ' + e.message); }
}
if (failed) { console.error(failed + ' тестов упало'); process.exit(1); }
console.log('Все тесты слоя 2 (фронт) прошли');
+23
View File
@@ -0,0 +1,23 @@
// putToVm — PUT одного файла на ВМ-буфер (XHR, сырое тело).
// onProgress(pct) — прогресс загрузки. Разрешается при HTTP 2xx.
export function putToVm(file, url, options = {}) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
if (options.onXHR) options.onXHR(xhr); // регистрация для отмены (abort)
xhr.open('PUT', url);
xhr.timeout = options.timeoutMs || 300000; // 300с: большие файлы
xhr.upload.onprogress = function(e) {
if (e.lengthComputable && options.onProgress) {
options.onProgress(Math.round(e.loaded / e.total * 100));
}
};
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error('ВМ: HTTP ' + xhr.status));
};
xhr.onerror = function() { reject(new Error('Сеть (ВМ)')); };
xhr.ontimeout = function() { reject(new Error('Таймаут 300с (ВМ)')); };
xhr.send(file); // сырое тело файла
});
}
+59
View File
@@ -0,0 +1,59 @@
// uploadViaVM — слой 2 (фронт): закачать файлы через ВМ-буфер.
// Шаг 1: PUT каждого файла на ВМ (token-ключ в URL). Шаг 2: POST /api/upload_refs.
//
// files: File[] — ТОЛЬКО учитываемые (без сверхлимитных).
// vmUploadUrl: базовый URL ВМ-буфера (напр. "https://.../drhider-upload/").
// options: {
// session, // текущий sid (или '')
// apiBase, // базовый путь бэка, по умолчанию '' (тот же origin)
// onUploadStatus, // (text) — сообщение статуса
// onStatus, // (k, html) — статус в таблице для k-го файла
// }
// Returns: Promise<{ok, session, count} | {ok:false, error}>
import { putToVm } from './put_to_vm.js';
import { fs } from '../table/fs.js';
export async function uploadViaVM(files, vmUploadUrl, options = {}) {
const token = crypto.randomUUID();
const refs = []; // {name, size, url} — для POST /api/upload_refs
const total = files.length;
for (let k = 0; k < total; k++) {
const f = files[k];
const vmUrl = vmUploadUrl + token + '_' + k; // имя файла в URL не несём (токен-ключ)
if (options.onUploadStatus) {
options.onUploadStatus('Загрузка на ВМ (этап 1/2) ' + (k + 1) + '/' + total + ': ' + f.name);
}
try {
const t0 = performance.now();
await putToVm(f, vmUrl, {
onProgress(pct) {
if (options.onStatus) options.onStatus(k, '<span style="color:#2563eb">⏳ ' + pct + '%</span>');
},
onXHR: options.onXHR,
});
const elapsed = (performance.now() - t0) / 1000;
const speed = f.size / elapsed;
if (options.onStatus) options.onStatus(k, '<span style="color:#22c55e">✓ ' + fs(speed) + '/s</span>');
refs.push({ name: f.name, size: f.size, url: vmUrl });
} catch (err) {
if (options.onStatus) options.onStatus(k, '<span style="color:#ef4444">✗</span>');
return { ok: false, error: 'Ошибка загрузки на ВМ: ' + err.message };
}
}
// Шаг 1b: один маленький POST во Flask со ссылками на файлы ВМ (<64КБ)
if (options.onUploadStatus) options.onUploadStatus('Передача ссылок в сервис…');
try {
const resp = await fetch((options.apiBase || '') + '/api/upload_refs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: options.session || '', files: refs })
});
const data = await resp.json();
if (!data.ok) throw new Error(data.error || 'HTTP ' + resp.status);
return { ok: true, session: data.session, count: data.count || refs.length };
} catch (err) {
return { ok: false, error: 'Ошибка передачи ссылок: ' + err.message };
}
}
+22
View File
@@ -0,0 +1,22 @@
// decodeZipName — декодирование имени из ZIP: UTF-8-флаг / эвристика (UTF-8 → CP437 → CP866).
export function decodeZipName(bytes, isUtf8) {
if (isUtf8) return new TextDecoder('utf-8').decode(bytes);
// Многие архиваторы пишут имя в UTF-8, но НЕ выставляют UTF-8-флаг (bit 11).
// Сначала строго пробуем UTF-8: если байты — валидный UTF-8 с кириллицей/текстом,
// берём их как есть (иначе декодирование CP437→CP866 превратит их в «╨╣…»-мусор).
try {
const s = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
// Кириллица — точно UTF-8; либо чистый печатаемый текст без управляющих символов.
if (/[\u0400-\u04FF]/.test(s) || !/[^\u0020-\u007e]/.test(s)) return s;
} catch (e) { /* не UTF-8 — legacy (CP437/CP866) */ }
let name;
try { name = new TextDecoder('ibm437').decode(bytes); }
catch (e) { name = new TextDecoder('utf-8').decode(bytes); }
// Кириллица из 1С (CP866) — перекодировать, если имя пришло как CP437-мусор
if (/[^\x00-\x7f]/.test(name)) {
try { name = new TextDecoder('ibm866').decode(bytes); }
catch (e) { /* оставить как есть */ }
}
return name;
}
+11
View File
@@ -0,0 +1,11 @@
// dosToMs — преобразование DOS-даты/времени (ZIP) в миллисекунды (unix epoch).
export function dosToMs(date, time) {
const year = 1980 + ((date >> 9) & 0x7f);
const month = (date >> 5) & 0x0f;
const day = date & 0x1f;
const hour = (time >> 11) & 0x1f;
const min = (time >> 5) & 0x3f;
const sec = (time & 0x1f) * 2;
return new Date(year, month - 1, day, hour, min, sec).getTime();
}
+8
View File
@@ -0,0 +1,8 @@
// inflateRaw — распаковка deflate-raw (метод 8) через нативный DecompressionStream.
export async function inflateRaw(bytes) {
const ds = new DecompressionStream('deflate-raw');
const stream = new Blob([bytes]).stream().pipeThrough(ds);
const ab = await new Response(stream).arrayBuffer();
return new Uint8Array(ab);
}
+22
View File
@@ -0,0 +1,22 @@
// listZipFiles — рекурсивно достать из ZIP только документы (вложенные zip — разворачиваются).
import { parseZip } from './parse_zip.js';
export async function listZipFiles(file, allowedExt) {
const buf = new Uint8Array(await file.arrayBuffer());
const entries = await parseZip(buf);
const out = [];
// Из ZIP вытаскиваем только документы. Всё прочее (изображения и т.п.) пропускаем.
for (const e of entries) {
if (e.isDir) continue;
const low = e.name.toLowerCase();
if (low.endsWith('.zip')) {
const sub = new File([e.data], e.name, { lastModified: e.dosMs });
out.push(...(await listZipFiles(sub, allowedExt)));
} else if (allowedExt.some(ext => low.endsWith(ext))) {
out.push(new File([e.data], e.name, { lastModified: e.dosMs }));
}
// иначе — не документ, пропускаем
}
return out;
}
+50
View File
@@ -0,0 +1,50 @@
// parseZip — разбор ZIP: центральный каталог → записи {name, data, dosMs, isDir}.
// Поддерживаются методы 0 (store) и 8 (deflate). Без внешних библиотек.
import { decodeZipName } from './decode_zip_name.js';
import { inflateRaw } from './inflate_raw.js';
import { dosToMs } from './dos_to_ms.js';
export async function parseZip(buf) {
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
let eocd = -1;
for (let i = buf.length - 22; i >= 0; i--) {
if (dv.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
}
if (eocd < 0) throw new Error('Не ZIP');
const cdSize = dv.getUint32(eocd + 12, true);
const cdOffset = dv.getUint32(eocd + 16, true);
const entries = [];
let pos = cdOffset;
const cdEnd = cdOffset + cdSize;
while (pos < cdEnd) {
if (dv.getUint32(pos, true) !== 0x02014b50) break;
const flags = dv.getUint16(pos + 8, true);
const method = dv.getUint16(pos + 10, true);
const modTime = dv.getUint16(pos + 12, true);
const modDate = dv.getUint16(pos + 14, true);
const compSize = dv.getUint32(pos + 20, true);
const nameLen = dv.getUint16(pos + 28, true);
const extraLen = dv.getUint16(pos + 30, true);
const commentLen = dv.getUint16(pos + 32, true);
const localOffset = dv.getUint32(pos + 42, true);
const nameBytes = buf.slice(pos + 46, pos + 46 + nameLen);
const name = decodeZipName(nameBytes, (flags & 0x800) !== 0);
const lhNameLen = dv.getUint16(localOffset + 26, true);
const lhExtraLen = dv.getUint16(localOffset + 28, true);
const dataStart = localOffset + 30 + lhNameLen + lhExtraLen;
const comp = buf.slice(dataStart, dataStart + compSize);
let data;
if (method === 0) data = comp;
else if (method === 8) data = await inflateRaw(comp);
else throw new Error('Метод сжатия ' + method + ' не поддерживается');
entries.push({ name, data, dosMs: dosToMs(modDate, modTime), isDir: name.endsWith('/') });
pos += 46 + nameLen + extraLen + commentLen;
}
return entries;
}