Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87524c54a4 | ||
|
|
021c925e3c | ||
|
|
e3f5581712 | ||
|
|
ebdab731ff | ||
|
|
5b49e88f1e | ||
|
|
d2894ad7c5 | ||
|
|
c2344f9738 | ||
|
|
db58a433fb | ||
|
|
55bd7a027d | ||
|
|
37ea5e2c31 | ||
|
|
78045f2c81 | ||
|
|
4653aa5e8e | ||
|
|
956aed0971 | ||
|
|
3b2e576284 | ||
|
|
de05d746cc | ||
|
|
c677206d03 | ||
|
|
d1415d5d21 | ||
|
|
5d8bcd61ea |
@@ -7,6 +7,7 @@ COPY requirements.txt .
|
|||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY site /app/site
|
COPY site /app/site
|
||||||
|
COPY upload /app/upload
|
||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# full_replace: фикс дублирования строк спецификации (v2.0.16)
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
`mode="full_replace"` (LLM возвращает полную новую редакцию — все ADD)
|
||||||
|
применялся поверх существующей `spec_current` без очистки.
|
||||||
|
|
||||||
|
`reset(contract_id)` срабатывает только один раз на старте пайплайна, а
|
||||||
|
full_replace происходит ПОСЛЕ — строки новой редакции плюсовались к старым →
|
||||||
|
дубли в `spec_current`.
|
||||||
|
|
||||||
|
## Фикс
|
||||||
|
|
||||||
|
1. `site/db/spec_events.py` — добавлена `clear_current(contract_id)`:
|
||||||
|
очищает ТОЛЬКО `spec_current` (история `spec_events` сохраняется).
|
||||||
|
|
||||||
|
2. `site/services/process.py` — перед `apply_ops()`:
|
||||||
|
```python
|
||||||
|
if mode == "full_replace":
|
||||||
|
spec_events.clear_current(contract_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Почему не `reset()`
|
||||||
|
|
||||||
|
`reset()` чистит и `spec_events`, и `spec_current` — потеряли бы историю
|
||||||
|
операций текущей редакции. Для full_replace нужна только очистка текущего
|
||||||
|
состояния, журнал ADDs должен остаться.
|
||||||
|
|
||||||
|
## Сопутствующее
|
||||||
|
|
||||||
|
- Скорректировано архитектурное описание → `/home/naeel/nubes/contracts/DOC/architecture-contracts-flask.md`.
|
||||||
|
Исправлена неточность: ZIP раскрывается **на клиенте** (`expandZipClient` →
|
||||||
|
`window.listZipFiles`), а не серверным `/unzip-upload` (legacy).
|
||||||
|
- Версия: 2.0.15 → 2.0.16.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Sonnet: код-ревью contracts-flask (2026-08-26)
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
- Написан промпт для Sonnet: `contracts-flask/docs/prompt-sonnet-architecture.md`
|
||||||
|
(задача — прочитать файлы текущего сервиса сверки и дать описание/архитектуру).
|
||||||
|
- Sonnet прочитал все указанные файлы и вместо описания выдал **код-ревью** с 10 находками
|
||||||
|
(2 критические, 3 средних, 2 XSS, 3 мелких). Архитектурное описание — отдельным документом (не выдано).
|
||||||
|
|
||||||
|
## Находки Sonnet
|
||||||
|
|
||||||
|
### 🔴 Критические
|
||||||
|
|
||||||
|
1. **`process.py` + `spec_events.py` — UPDATE/DELETE никогда не применяются (сломана частичная сверка).**
|
||||||
|
LLM возвращает `target_id: "r1"` (индекс строки), а `apply_ops()` читает `op.get("target_hash", "")`.
|
||||||
|
Поля `target_hash` в ответе LLM нет → все UPDATE/DELETE из `mode=partial` молча уходят в `UNRESOLVED`.
|
||||||
|
Трассировка: `_build_spec_text()` показывает строки `[id: r1]` → LLM возвращает `target_id: "r1"` →
|
||||||
|
`process.py` передаёт ops без трансляции r1→hash → `spec_events.py` `th = op.get("target_hash","")` = "" → UNRESOLVED.
|
||||||
|
Работает только `mode=full_replace` (все ADD) и первый документ (extract, только ADD).
|
||||||
|
|
||||||
|
2. **`Dockerfile` — образ не запустится: `upload/` не скопирован.**
|
||||||
|
`COPY site /app/site` — только site/, `upload/` отсутствует.
|
||||||
|
При старте `routes/__init__.py` делает `from upload.backend.upload_refs import ...`, `app.py` добавляет `/app` в sys.path.
|
||||||
|
В образе `/app/upload/` нет → `ModuleNotFoundError`.
|
||||||
|
|
||||||
|
### 🟠 Средние
|
||||||
|
|
||||||
|
3. **`db/prompts.py` — `list_by_role()` запрашивает несуществующий столбец `created_by`.**
|
||||||
|
В схеме `prompts` нет `created_by` → `sqlite3.OperationalError` → `/api/prompts/list` всегда 500.
|
||||||
|
|
||||||
|
4. **`routes/prompts_bp.py` — вызов несуществующих функций:**
|
||||||
|
- `db_prompts.insert(...)` — нет (есть `save_new_version()`);
|
||||||
|
- `db_prompts.delete(...)` — нет (есть `delete_prompt()`).
|
||||||
|
`/api/prompts/save` и `/api/prompts/delete` падают с `AttributeError`.
|
||||||
|
|
||||||
|
5. **`services/llm.py`, `services/classify.py` — захардкожены `LLM_URL/LLM_KEY/LLM_MODEL` в обход `config.py`.**
|
||||||
|
`config.py` читает из `LLM_API_URL`, а compare/classify игнорируют его. Конфигурация расщеплена на 3 блока.
|
||||||
|
|
||||||
|
### 🟡 XSS (frontend)
|
||||||
|
|
||||||
|
6. **`compare.js` — `nr.name` в таблице операций не экранирован** (`escHtml()` отсутствует).
|
||||||
|
Имя услуги из договора (текст LLM) вставляется в innerHTML → XSS.
|
||||||
|
|
||||||
|
7. **`compare.js` — `sec.filename` в заголовке секции не экранирован.** Имя файла от пользователя → XSS.
|
||||||
|
|
||||||
|
### ⚪ Мелкие
|
||||||
|
|
||||||
|
8. **`llm_prompt.py`** — устаревший комментарий «Lucee API» (читает напрямую из SQLite).
|
||||||
|
9. **`db/connection.py`** — `_pg_to_sqlite`: `gen_random_uuid()` добавляет `?` в SQL, но не добавляет значение в params (мёртвый код, но при исполнении сломает запрос).
|
||||||
|
10. **`services/classify.py`** — комментарий «re-classify after file changes» вводит в заблуждение: `reset_classify_status()` сбрасывает только `processing`→`pending`, уже классифицированные не трогает.
|
||||||
|
|
||||||
|
## Итоговая таблица
|
||||||
|
|
||||||
|
| # | Файл | Проблема | Критичность |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | process.py, spec_events.py | UPDATE/DELETE → UNRESOLVED, частичная сверка сломана | 🔴 критический |
|
||||||
|
| 2 | Dockerfile | upload/ не скопирован → образ не стартует | 🔴 критический |
|
||||||
|
| 3 | db/prompts.py | created_by отсутствует → 500 на /api/prompts/list | 🟠 средний |
|
||||||
|
| 4 | prompts_bp.py | insert()/delete() — не те имена функций → 500 | 🟠 средний |
|
||||||
|
| 5 | llm.py, classify.py | захардкожены LLM_URL/KEY/MODEL в обход config.py | 🟠 средний |
|
||||||
|
| 6 | compare.js | nr.name не экранирован → XSS | 🟡 XSS |
|
||||||
|
| 7 | compare.js | sec.filename не экранирован → XSS | 🟡 XSS |
|
||||||
|
| 8 | llm_prompt.py | устаревший комментарий | ⚪ мелкое |
|
||||||
|
| 9 | connection.py | gen_random_uuid() мёртвый код с ошибкой | ⚪ мелкое |
|
||||||
|
| 10 | classify.py | комментарий re-classify вводит в заблуждение | ⚪ мелкое |
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
|
||||||
|
- ✅ Все 10 находок исправлены (v2.0.15, commit `e3f5581`, запушено).
|
||||||
|
- #1 (критично) — сам: `process.py` трансляция `target_id`→`target_hash`.
|
||||||
|
- #2–#10 (механика) — Флаш-субагент: Dockerfile, prompts CRUD, XSS, конфиг LLM, комментарии.
|
||||||
|
- Верифицировано: `py_compile`, `node -c`, `import app` — OK.
|
||||||
|
- Архитектурное описание от Sonnet — так и не получено (вместо него — ревью). Если нужно — отдельным запросом.
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Переиспользование модуля загрузки 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 (commit db58a43)
|
||||||
|
- [x] Этап 2 — sink (blueprint + contracts_upload_sink + регистрация), проверено
|
||||||
|
- [x] Удалён рукописный `/api/upload_refs` (основной путь теперь через модуль)
|
||||||
|
- [x] Этап 3 — UI: выбор папок (webkitdirectory) + рекурсивный клиентский ZIP (commit d2894ad)
|
||||||
|
- [ ] Осталось (cleanup, опционально): .doc→.docx в sink, удалить `/api/unzip_refs`/`/api/convert_refs` + `addZipFile`/`convertDoc`
|
||||||
|
|
||||||
|
Примечание: `/api/unzip_refs` и `/api/convert_refs` пока оставлены как fallback
|
||||||
|
(server-side ZIP и .doc), фронт `addZipFile`/`convertDoc` не удалены.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Сессия 2026-08-17 — HTTP 502 при парсинге 19 МБ PDF = OOMKill пода
|
||||||
|
|
||||||
|
_Стенд: ТЕСТ, `contractor.pythonk8s.dev.nubes.ru` (приставка `dev.nubes.ru` общая для dev+test).
|
||||||
|
instanceUid: `b4523aba-b5e6-40f1-be56-bb4d2509357c`, realm `iot-naeel`, domain `contractor`._
|
||||||
|
|
||||||
|
## 1. Симптом (из UI, колонка «Парсинг»)
|
||||||
|
|
||||||
|
Файл `0144-03-2023_отчет об оценке.pdf` (19.0 МБ) в списке **дважды**:
|
||||||
|
- первый проход: **✗ HTTP 502**;
|
||||||
|
- повторный: **✓ 4083 эл. (0.0с)**.
|
||||||
|
|
||||||
|
Остальные файлы (623 КБ, 473 КБ, 596 КБ) парсились нормально.
|
||||||
|
Вывод: загрузка файла проходит, падает **парсинг** (тяжёлая операция в запросе).
|
||||||
|
|
||||||
|
## 2. Диагностика kubectl (с ВМ remote-dev = 5.172.178.213)
|
||||||
|
|
||||||
|
```
|
||||||
|
kubectl get pods -n b4523aba-...
|
||||||
|
pythonk8s-589db8db9c-qjjnc 1/1 Running 1 (5m18s ago) 17m
|
||||||
|
```
|
||||||
|
|
||||||
|
`kubectl describe pod`:
|
||||||
|
```
|
||||||
|
Last State: Terminated
|
||||||
|
Reason: OOMKilled
|
||||||
|
Exit Code: 137
|
||||||
|
Restart Count: 1
|
||||||
|
Limits: cpu: 1, memory: 1Gi
|
||||||
|
Requests: cpu: 1, memory: 1Gi
|
||||||
|
```
|
||||||
|
|
||||||
|
`kubectl top pod` (текущий под, простое): **727Mi из 1Gi (~71%)**.
|
||||||
|
|
||||||
|
События:
|
||||||
|
```
|
||||||
|
17m Killing pod/pythonk8s-5895c478b5-7dcqq — Stopping container app
|
||||||
|
```
|
||||||
|
(убитый OOM-ом под; текущий `pythonk8s-589db8db9c-qjjnc` — новый).
|
||||||
|
|
||||||
|
Логи прежнего контейнера (`--previous`): обычные запросы (health, batch-progress,
|
||||||
|
apply-groups, process-v2, cleanup), обрыв на 14:13:14 → OOM.
|
||||||
|
|
||||||
|
## 3. Вывод (по фактам)
|
||||||
|
|
||||||
|
**HTTP 502 = OOMKill пода (exit 137).** Синхронный парсинг 19 МБ PDF
|
||||||
|
(`site/routes/upload_bp.py`: `parse_file` → `set_parsed` в том же запросе,
|
||||||
|
pdfplumber → 4083 элемента) превышает лимит памяти **1Gi** → kubelet убивает под →
|
||||||
|
шлюз не получает ответ → 502. После рестарта повторный парсинг проходит.
|
||||||
|
|
||||||
|
- **Это НЕ** проблема сети/размера тела (как 64KB на `services`), а **нехватка памяти**.
|
||||||
|
- Базовое потребление уже ~71% лимита, парсинг большого PDF добивает под.
|
||||||
|
|
||||||
|
## 4. Связь с прежними находками
|
||||||
|
|
||||||
|
| Симптом | Причина | Платформа |
|
||||||
|
|---|---|---|
|
||||||
|
| «64KB / HTTP 000, Сеть» (исторически) | ddos-guard + таймауты Ingress + медленная обработка | `pythonk8s.services.ngcloud.ru` |
|
||||||
|
| HTTP 502 на 19 МБ PDF (сейчас) | OOMKill: лимит 1Gi, синхронный парсинг | `pythonk8s.dev.nubes.ru` (ТЕСТ) |
|
||||||
|
|
||||||
|
## 5. Варианты решения (НЕ ВНЕСЕНЫ, ждут команды)
|
||||||
|
|
||||||
|
1. Поднять память инстанса: `clusterConfiguration.memory` 1024 → 2048 (быстрый обходной путь).
|
||||||
|
2. Убрать синхронный парсинг из `/upload` — парсинг в фон (thread / отдельный endpoint),
|
||||||
|
ответ сразу; снижает пик памяти в запросе (правильный фикс).
|
||||||
|
3. Оба варианта.
|
||||||
|
|
||||||
|
## 6. Статус (следующий шаг)
|
||||||
|
|
||||||
|
Пользователь планирует **редеплой на обычном облачном кластере со стандартными настройками**
|
||||||
|
(не на своём `iot-naeel`) — проверить, как ведёт себя парсинг 19 МБ при стандартных лимитах.
|
||||||
|
Результат сравнить с данным диагностикой.
|
||||||
@@ -92,6 +92,22 @@ server {
|
|||||||
client_max_body_size 100m;
|
client_max_body_size 100m;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── VM-буфер загрузки contracts (паттерн drhider) ──────────────
|
||||||
|
# Браузер кладёт файл сюда PUT (мимо шлюза кластера ~64КБ), бэк тянет сам.
|
||||||
|
# Файлы: /var/www/contracts-upload/ (нужно создать, chown www-data).
|
||||||
|
# CORS: origin фронтенда = contractor.pythonk8s.dev.nubes.ru (ingress, подтверждено).
|
||||||
|
location /contracts-upload/ {
|
||||||
|
alias /var/www/contracts-upload/;
|
||||||
|
dav_methods PUT DELETE;
|
||||||
|
create_full_put_path on;
|
||||||
|
client_max_body_size 1024m;
|
||||||
|
add_header Access-Control-Allow-Origin https://contractor.pythonk8s.dev.nubes.ru always;
|
||||||
|
add_header Access-Control-Allow-Methods 'PUT, GET, OPTIONS, DELETE' always;
|
||||||
|
add_header Access-Control-Allow-Headers 'Content-Type' always;
|
||||||
|
add_header Access-Control-Max-Age 86400 always;
|
||||||
|
if ($request_method = OPTIONS) { return 204; }
|
||||||
|
}
|
||||||
|
|
||||||
listen 443 ssl; # managed by Certbot
|
listen 443 ssl; # managed by Certbot
|
||||||
ssl_certificate /etc/letsencrypt/live/contracts.kube5s.ru/fullchain.pem; # managed by Certbot
|
ssl_certificate /etc/letsencrypt/live/contracts.kube5s.ru/fullchain.pem; # managed by Certbot
|
||||||
ssl_certificate_key /etc/letsencrypt/live/contracts.kube5s.ru/privkey.pem; # managed by Certbot
|
ssl_certificate_key /etc/letsencrypt/live/contracts.kube5s.ru/privkey.pem; # managed by Certbot
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Задание для Sonnet: описание архитектуры сервиса «Сверка договоров»
|
||||||
|
|
||||||
|
## Роль и задача
|
||||||
|
|
||||||
|
Ты — технический аналитик. Твоя задача — **только прочитать указанные файлы** и написать
|
||||||
|
подробное, структурированное описание сервиса «Сверка договоров»: что это за сервис, как
|
||||||
|
устроен, как работает каждый этап, какими методами и технологиями. **Ничего не менять и
|
||||||
|
не создавать в коде.** Результат — один текстовый документ (markdown).
|
||||||
|
|
||||||
|
## Короткий контекст (чтобы ты понимал, о чём речь)
|
||||||
|
|
||||||
|
Сервис «Сверка договоров» — веб-приложение на **Flask + vanilla JS** (без фреймворков
|
||||||
|
на фронте), развёрнутое на managed-кластере Nubes (Штурвал). Пользователь загружает
|
||||||
|
договоры и допсоглашения (docx/pdf/zip), сервис:
|
||||||
|
1. разбирает их на структурированные спецификации;
|
||||||
|
2. автоматически классифицирует документы (тип/номер/дата/контрагент) через LLM;
|
||||||
|
3. группирует документы по договорам;
|
||||||
|
4. по цепочке допников сравнивает изменения спецификации (ADD/UPDATE/DELETE) через LLM;
|
||||||
|
5. показывает историю изменений и даёт возможность задать вопрос чату по итоговой спецификации.
|
||||||
|
|
||||||
|
Ключевое ограничение: данные конфиденциальны, LLM — только своя (api.aillm.ru, модель
|
||||||
|
gpt-oss-120b). Большие файлы не грузятся напрямую в кластер (шлюз рвёт тела >~64 КБ) —
|
||||||
|
используется «ВМ-буфер»: браузер кладёт файл на ВМ (WebDAV), а бэк сам тянет его
|
||||||
|
исходящим запросом (egress не ограничен).
|
||||||
|
|
||||||
|
## Что прочитать — ТОЛЬКО эти файлы (корень проекта: contracts-flask/)
|
||||||
|
|
||||||
|
### Ядро бэкенда (обязательно, читать все)
|
||||||
|
- `site/app.py` — точка входа, фабрика приложения, sys.path
|
||||||
|
- `site/config.py` — конфигурация (версия, LLM_URL/KEY/MODEL, CONVERT_SERVICE_URL, VM_UPLOAD_*)
|
||||||
|
- `site/llm_prompt.py` — формирование промптов LLM (извлечение/сравнение/классификация)
|
||||||
|
- `site/routes/__init__.py` — регистрация всех blueprint'ов
|
||||||
|
- `site/routes/upload_bp.py` — загрузка файлов (multipart + sink закачки через ВМ)
|
||||||
|
- `site/routes/pipeline_bp.py` — классификация + SSE-сравнение (/process-v2, /api/classify-batch)
|
||||||
|
- `site/routes/api_bp.py` — API (groups, documents, supplements, sync, cleanup, spec-current, chat)
|
||||||
|
- `site/routes/pages_bp.py` — HTML-страницы + раздача ES-модулей upload/
|
||||||
|
- `site/routes/prompts_bp.py` — CRUD и версионирование промптов
|
||||||
|
- `site/routes/health_bp.py` — healthcheck
|
||||||
|
- `site/services/parse.py` — парсинг PDF (pdfplumber), DOCX (python-docx), TXT
|
||||||
|
- `site/services/classify.py` — LLM-классификация документов (garbage-фильтры, выжимка, ThreadPool)
|
||||||
|
- `site/services/grouping.py` — нормализация номеров и группировка по договорам
|
||||||
|
- `site/services/process.py` — SSE-пайплайн сравнения (run_pipeline)
|
||||||
|
- `site/services/llm.py` — вызов LLM для сравнения (call_llm)
|
||||||
|
- `site/services/llm_client.py` — LLM-клиент (HttpxLLMClient, FakeLLMClient для тестов)
|
||||||
|
- `site/services/metrics.py` — проверка арифметики (sum == price*qty), метрики качества
|
||||||
|
- `site/db/connection.py` — SQLite: схема всех таблиц, WAL, thread-local соединения
|
||||||
|
- `site/db/documents.py` — CRUD документов (статусы, классификация, elements_json)
|
||||||
|
- `site/db/contracts.py` — договоры
|
||||||
|
- `site/db/supplements.py` — допники (связь contract↔document)
|
||||||
|
- `site/db/spec_events.py` — event sourcing: apply_ops (ADD/UPDATE/DELETE), reset
|
||||||
|
- `site/db/spec_current.py` — текущее состояние спецификации договора
|
||||||
|
- `site/db/prompts.py` — версии промптов по ролям
|
||||||
|
|
||||||
|
### Фронтенд (для понимания потока на стороне браузера)
|
||||||
|
- `site/static/state.js` — центральное состояние приложения
|
||||||
|
- `site/static/app.js` — инициализация, обработчики загрузки/классификации/сравнения
|
||||||
|
- `site/static/files.js` — выбор файлов/папок/архивов, загрузка через ВМ-буфер
|
||||||
|
- `site/static/groups.js` — классификация и группы на фронте
|
||||||
|
- `site/static/compare.js` — сравнение (SSE) и рендер результатов
|
||||||
|
- `site/templates/index.html` — разметка UI
|
||||||
|
|
||||||
|
### Переиспользуемый модуль загрузки через ВМ (модуль upload/)
|
||||||
|
- `upload/README.md` — описание модуля и паттерна «ВМ-буфер + pull»
|
||||||
|
- `upload/backend/upload_refs/blueprint.py` — blueprint POST /api/upload_refs с параметром sink
|
||||||
|
- `upload/backend/upload_refs/safe_name.py`, `pull_file.py`, `config.py` — транспортные примитивы
|
||||||
|
- `upload/backend/session/__init__.py` — in-memory сессия (используется drhider; сверка его не использует)
|
||||||
|
|
||||||
|
### Инфраструктура
|
||||||
|
- `Dockerfile` — сборка образа
|
||||||
|
- `requirements.txt` — зависимости
|
||||||
|
|
||||||
|
## Что НЕ читать и не трогать
|
||||||
|
|
||||||
|
- `History/` — вся история сессий, ревью, старые планы (НЕ нужно)
|
||||||
|
- `contractor-legacy/` — замороженный Lucee-прод (не актуален)
|
||||||
|
- `contracts-vm/`, `convert-service/`, `loadtest/`, `testgen/`, `sim/`, `dogovora/`, `hz/`, `FILES/`, `DOC/` — смежные/вспомогательные, НЕ ядро сервиса
|
||||||
|
- `deploy/` — устаревший VM-слой (convert_server.py и т.п.), не входит в текущий managed-сервис
|
||||||
|
- `site/services/drhider.py` — совместимость с DrHider (побочное, не ядро сверки)
|
||||||
|
- `site/templates/drhider.html`, `architect.html`, `pipeline.html`, `ci-cd.html` — не ядро сверки
|
||||||
|
|
||||||
|
## Что должно быть в ответе (структура)
|
||||||
|
|
||||||
|
1. **Описание сервиса** — что делает, кому, какие входы/выходы (2-4 абзаца, по-простому).
|
||||||
|
2. **Общая архитектура** — слои (фронт / бэк / БД / LLM / ВМ-буфер), как они связаны.
|
||||||
|
Лучше — схема потока: загрузка → парсинг → классификация → группировка → сравнение → чат.
|
||||||
|
3. **Структура проекта** — дерево `site/` (+ `upload/`) с назначением каждого файла.
|
||||||
|
4. **Как работает каждый этап** (по пунктам, «что делает / как / где в коде»):
|
||||||
|
- загрузка файлов (включая папки/архивы, ВМ-буфер, sink);
|
||||||
|
- парсинг PDF/DOCX/TXT → elements (таблицы + параграфы);
|
||||||
|
- классификация (garbage-фильтры, выжимка, LLM, параллельность);
|
||||||
|
- группировка (нормализация номеров, виртуальные группы, __unresolved__);
|
||||||
|
- сравнение (SSE-пайплайн, ops ADD/UPDATE/DELETE, event sourcing, spec_current);
|
||||||
|
- чат по итоговой спецификации.
|
||||||
|
5. **Технологии и методы** — коротко: SQLite (WAL, thread-local), SSE, ThreadPool, LLM-клиент,
|
||||||
|
промпты (из БД + fallback), event sourcing, метрики качества, ВМ-буфер загрузки.
|
||||||
|
6. **Модель данных** — таблицы БД и их назначение (documents, contracts, supplements,
|
||||||
|
spec_events, spec_current, prompts).
|
||||||
|
|
||||||
|
## Требования к стилю
|
||||||
|
|
||||||
|
- Чётко, структурно, без воды и лирики.
|
||||||
|
- Без излишней заумности — понятно не только девопсу, но и обычному разработчику/аналитику.
|
||||||
|
- Каждый этап: «что делает → как → каким методом → где в коде (файл/функция)».
|
||||||
|
- Ничего не менять в коде. Только текстовый документ.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Задание для Sonnet (v2): описание архитектуры сервиса «Сверка договоров»
|
||||||
|
|
||||||
|
## Роль и задача
|
||||||
|
|
||||||
|
Ты — технический аналитик. Задача — **только прочитать указанные файлы** и написать
|
||||||
|
подробное, структурированное описание сервиса «Сверка договоров»: что это, как устроено,
|
||||||
|
как работает каждый этап, какими методами/технологиями. **Ничего не менять в коде.**
|
||||||
|
Результат — один текстовый документ (markdown).
|
||||||
|
|
||||||
|
## Контекст (что уже было)
|
||||||
|
|
||||||
|
Ранее ты выдал **код-ревью** с 10 находками (вместо описания). Все 10 уже **исправлены**
|
||||||
|
(v2.0.15): критичный баг `target_id` vs `target_hash` (частичная сверка), Dockerfile,
|
||||||
|
prompts CRUD, LLM-конфиг, XSS в `compare.js`, мелочи. Учти это — читай код **после** правок,
|
||||||
|
описывай **текущее** состояние.
|
||||||
|
|
||||||
|
## Сервис в двух словах
|
||||||
|
|
||||||
|
«Сверка договоров» — веб-приложение на **Flask + vanilla JS** (без фреймворков на фронте),
|
||||||
|
на managed-кластере Nubes. Пользователь загружает договоры/допсоглашения (docx/pdf/zip),
|
||||||
|
сервис: разбирает их в спецификации → классифицирует документы через LLM → группирует
|
||||||
|
по договорам → по цепочке допников сравнивает изменения (ADD/UPDATE/DELETE) через LLM →
|
||||||
|
показывает историю + чат по итоговой спецификации. Данные конфиденциальны, LLM — только
|
||||||
|
своя (api.aillm.ru, gpt-oss-120b). Большие файлы грузятся через «ВМ-буфер» (браузер PUT на
|
||||||
|
WebDAV → бэк тянет egress GET), т.к. шлюз кластера рвёт тела >~64 КБ.
|
||||||
|
|
||||||
|
## Что прочитать — ТОЛЬКО эти файлы (корень: contracts-flask/)
|
||||||
|
|
||||||
|
### Ядро бэкенда
|
||||||
|
- `site/app.py` — точка входа, фабрика приложения, sys.path
|
||||||
|
- `site/config.py` — конфигурация (версия, LLM, конвертер .doc, VM_UPLOAD_*)
|
||||||
|
- `site/llm_prompt.py` — формирование промптов LLM (extract/diff/classify + fallback)
|
||||||
|
- `site/routes/__init__.py` — регистрация blueprint'ов
|
||||||
|
- `site/routes/upload_bp.py` — загрузка (multipart + sink закачки через ВМ)
|
||||||
|
- `site/routes/pipeline_bp.py` — классификация + SSE-сравнение (/process-v2, /api/classify-batch)
|
||||||
|
- `site/routes/api_bp.py` — API (groups, documents, supplements, sync, cleanup, spec-current, chat)
|
||||||
|
- `site/routes/pages_bp.py` — HTML-страницы + раздача ES-модулей upload/
|
||||||
|
- `site/routes/prompts_bp.py` — CRUD и версионирование промптов
|
||||||
|
- `site/routes/health_bp.py` — healthcheck
|
||||||
|
- `site/services/parse.py` — парсинг PDF (pdfplumber), DOCX (python-docx), TXT
|
||||||
|
- `site/services/classify.py` — LLM-классификация (garbage-фильтры, выжимка, ThreadPool)
|
||||||
|
- `site/services/grouping.py` — нормализация номеров и группировка по договорам
|
||||||
|
- `site/services/process.py` — SSE-пайплайн сравнения (run_pipeline) + трансляция target_id→hash
|
||||||
|
- `site/services/llm.py` — вызов LLM для сравнения (call_llm)
|
||||||
|
- `site/services/llm_client.py` — LLM-клиент (HttpxLLMClient, FakeLLMClient)
|
||||||
|
- `site/services/metrics.py` — проверка арифметики (sum == price*qty), метрики качества
|
||||||
|
- `site/db/connection.py` — SQLite: схема, WAL, thread-local
|
||||||
|
- `site/db/documents.py` — CRUD документов
|
||||||
|
- `site/db/contracts.py` — договоры
|
||||||
|
- `site/db/supplements.py` — допники (contract↔document)
|
||||||
|
- `site/db/spec_events.py` — event sourcing: apply_ops (ADD/UPDATE/DELETE), reset
|
||||||
|
- `site/db/spec_current.py` — текущее состояние спецификации
|
||||||
|
- `site/db/prompts.py` — версии промптов
|
||||||
|
|
||||||
|
### Фронтенд
|
||||||
|
- `site/static/state.js` — центральное состояние
|
||||||
|
- `site/static/app.js` — инициализация, обработчики
|
||||||
|
- `site/static/files.js` — выбор файлов/папок/архивов, загрузка через ВМ
|
||||||
|
- `site/static/groups.js` — классификация и группы на фронте
|
||||||
|
- `site/static/compare.js` — сравнение (SSE) и рендер
|
||||||
|
- `site/templates/index.html` — UI
|
||||||
|
|
||||||
|
### Модуль upload/ (переиспользуемый)
|
||||||
|
- `upload/README.md` — паттерн «ВМ-буфер + pull»
|
||||||
|
- `upload/backend/upload_refs/blueprint.py` — blueprint POST /api/upload_refs с sink
|
||||||
|
- `upload/backend/upload_refs/safe_name.py`, `pull_file.py`, `config.py`
|
||||||
|
- `upload/backend/session/__init__.py` — in-memory сессия (drhider; сверка не использует)
|
||||||
|
|
||||||
|
### Инфраструктура
|
||||||
|
- `Dockerfile`, `requirements.txt`
|
||||||
|
|
||||||
|
## Что НЕ читать
|
||||||
|
|
||||||
|
`History/`, `contractor-legacy/`, `contracts-vm/`, `convert-service/`, `loadtest/`,
|
||||||
|
`testgen/`, `sim/`, `dogovora/`, `hz/`, `FILES/`, `DOC/`, `deploy/` (устаревший VM-слой),
|
||||||
|
`site/services/drhider.py`, `site/templates/drhider.html|architect.html|pipeline.html|ci-cd.html`.
|
||||||
|
|
||||||
|
## Что должно быть в ответе (структура)
|
||||||
|
|
||||||
|
1. **Описание сервиса** — что делает, входы/выходы (2–4 абзаца, по-простому).
|
||||||
|
2. **Общая архитектура** — слои (фронт/бэк/БД/LLM/ВМ-буфер) + схема потока:
|
||||||
|
загрузка → парсинг → классификация → группировка → сравнение → чат.
|
||||||
|
3. **Структура проекта** — дерево `site/` (+ `upload/`) с назначением каждого файла.
|
||||||
|
4. **Как работает каждый этап** — «что делает → как → каким методом → где в коде (файл/функция)».
|
||||||
|
5. **Технологии и методы** — SQLite (WAL, thread-local), SSE, ThreadPool, LLM-клиент,
|
||||||
|
промпты (БД + fallback), event sourcing, метрики качества, ВМ-буфер.
|
||||||
|
6. **Модель данных** — таблицы и назначение (documents, contracts, supplements,
|
||||||
|
spec_events, spec_current, prompts).
|
||||||
|
|
||||||
|
## Дополнительно: точечная проверка фикса #1
|
||||||
|
|
||||||
|
Отдельно, коротко (3–5 строк): в `site/services/process.py` глянь блок трансляции
|
||||||
|
`target_id ("r1","r2"…) → target_hash` (через `current_spec[_idx]["hash"]`). Подтверди:
|
||||||
|
корректно ли он маппит `rN` (1-based) на индекс `current_spec`, и что при невалидном id
|
||||||
|
операция уходит в UNRESOLVED, а не падает. Если видишь ошибку — укажи, но НЕ правь.
|
||||||
|
|
||||||
|
## Требования к стилю
|
||||||
|
|
||||||
|
- Чётко, структурно, без воды и лирики.
|
||||||
|
- Без излишней заумности — понятно не только девопсу, но и обычному разработчику/аналитику.
|
||||||
|
- Ничего не менять в коде. Только текстовый документ.
|
||||||
@@ -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
|
||||||
|
|||||||
+14
-1
@@ -1,7 +1,7 @@
|
|||||||
"""Конфигурация приложения — все настройки в одном месте."""
|
"""Конфигурация приложения — все настройки в одном месте."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
VERSION = "2.0.5"
|
VERSION = "2.0.16"
|
||||||
|
|
||||||
LLM_URL = os.getenv("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions")
|
LLM_URL = os.getenv("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions")
|
||||||
LLM_KEY = os.getenv("LLM_API_KEY", "")
|
LLM_KEY = os.getenv("LLM_API_KEY", "")
|
||||||
@@ -9,3 +9,16 @@ LLM_MODEL = os.getenv("LLM_MODEL", "gpt-oss-120b")
|
|||||||
|
|
||||||
MAX_CONTENT_LENGTH = 200 * 1024 * 1024 # 200 MB
|
MAX_CONTENT_LENGTH = 200 * 1024 * 1024 # 200 MB
|
||||||
API_KEY = os.getenv("API_KEY", "")
|
API_KEY = os.getenv("API_KEY", "")
|
||||||
|
CONVERT_SERVICE_URL = os.getenv("CONVERT_SERVICE_URL", "http://containerk8s.df36c8af-1a95-4623-b551-0d37b731ccca.svc.cluster.local:5000")
|
||||||
|
|
||||||
|
# ── VM-буфер загрузки (паттерн drhider) ──────────────────────────────
|
||||||
|
# Браузер кладёт файл на ВМ через WebDAV (мимо шлюза кластера ~64КБ),
|
||||||
|
# бэк сам тянет его исходящим GET (egress без лимита).
|
||||||
|
VM_UPLOAD_URL = os.getenv("VM_UPLOAD_URL", "https://contracts.kube5s.ru/contracts-upload/")
|
||||||
|
# SSRF-защита: тянуть можно ТОЛЬКО с этого префикса.
|
||||||
|
VM_UPLOAD_PREFIX = os.getenv("VM_UPLOAD_PREFIX", "https://contracts.kube5s.ru/contracts-upload/")
|
||||||
|
# Лимит на один файл (совпадает с фронтом).
|
||||||
|
VM_UPLOAD_MAX_BYTES = int(os.getenv("VM_UPLOAD_MAX_BYTES", str(50 * 1024 * 1024)))
|
||||||
|
# Ретраи pull с ВМ (разовые DNS/сетевые сбои не роняют загрузку).
|
||||||
|
PULL_RETRIES = int(os.getenv("PULL_RETRIES", "3"))
|
||||||
|
PULL_RETRY_DELAY = 2.0
|
||||||
|
|||||||
@@ -206,10 +206,6 @@ def _pg_to_sqlite(sql):
|
|||||||
sql = sql.replace("%s", "?")
|
sql = sql.replace("%s", "?")
|
||||||
# ::jsonb → убрать (SQLite не типизирует)
|
# ::jsonb → убрать (SQLite не типизирует)
|
||||||
sql = sql.replace("::jsonb", "")
|
sql = sql.replace("::jsonb", "")
|
||||||
# gen_random_uuid() → хекс-UUID через randomblob
|
|
||||||
if "gen_random_uuid()" in sql:
|
|
||||||
import uuid
|
|
||||||
sql = sql.replace("gen_random_uuid()", "?")
|
|
||||||
# BOOLEAN → INTEGER
|
# BOOLEAN → INTEGER
|
||||||
sql = sql.replace(" BOOLEAN ", " INTEGER ")
|
sql = sql.replace(" BOOLEAN ", " INTEGER ")
|
||||||
sql = sql.replace(" bool ", " INTEGER ")
|
sql = sql.replace(" bool ", " INTEGER ")
|
||||||
|
|||||||
+1
-1
@@ -85,7 +85,7 @@ def seed_defaults():
|
|||||||
def list_by_role(role):
|
def list_by_role(role):
|
||||||
"""List all versions for a role, newest first."""
|
"""List all versions for a role, newest first."""
|
||||||
rows = query(
|
rows = query(
|
||||||
"SELECT id, role, name, is_active, created_by, notes, created_at FROM prompts WHERE role=%s ORDER BY created_at DESC",
|
"SELECT id, role, name, is_active, notes, created_at FROM prompts WHERE role=%s ORDER BY created_at DESC",
|
||||||
(role,),
|
(role,),
|
||||||
)
|
)
|
||||||
for r in rows:
|
for r in rows:
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ def reset(contract_id):
|
|||||||
execute("DELETE FROM spec_events WHERE contract_id = %s", (contract_id,))
|
execute("DELETE FROM spec_events WHERE contract_id = %s", (contract_id,))
|
||||||
|
|
||||||
|
|
||||||
|
def clear_current(contract_id):
|
||||||
|
"""Очистить ТОЛЬКО текущее состояние спецификации (история spec_events сохраняется). Для full_replace."""
|
||||||
|
execute("DELETE FROM spec_current WHERE contract_id = %s", (contract_id,))
|
||||||
|
|
||||||
|
|
||||||
def get_next_seq(contract_id):
|
def get_next_seq(contract_id):
|
||||||
"""Get next sequence number. WAL serializes writers — no explicit lock needed."""
|
"""Get next sequence number. WAL serializes writers — no explicit lock needed."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
|
|||||||
+1
-1
@@ -201,7 +201,7 @@ def _build_spec_text(current_spec: list) -> str:
|
|||||||
def build_prompt(current_spec: list, doc_text: str) -> tuple:
|
def build_prompt(current_spec: list, doc_text: str) -> tuple:
|
||||||
"""
|
"""
|
||||||
Формирует промпт для LLM.
|
Формирует промпт для LLM.
|
||||||
1. Пробует получить активный промпт из БД (Lucee API).
|
1. Пробует получить активный промпт из БД (SQLite, db.prompts).
|
||||||
2. При неудаче — fallback на хардкод.
|
2. При неудаче — fallback на хардкод.
|
||||||
Возвращает (текст_промпта, prompt_id).
|
Возвращает (текст_промпта, prompt_id).
|
||||||
prompt_id — UUID версии промпта из БД, или "" если fallback.
|
prompt_id — UUID версии промпта из БД, или "" если fallback.
|
||||||
|
|||||||
+12
-1
@@ -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))
|
||||||
|
|||||||
+14
-1
@@ -1,8 +1,15 @@
|
|||||||
"""HTML-страницы."""
|
"""HTML-страницы."""
|
||||||
from flask import Blueprint, render_template
|
import os
|
||||||
|
from flask import Blueprint, render_template, send_from_directory
|
||||||
|
|
||||||
pages_bp = Blueprint("pages", __name__)
|
pages_bp = Blueprint("pages", __name__)
|
||||||
|
|
||||||
|
# Переиспользуемый модуль upload: отдаём ES-модули фронтенда браузеру (как drhider)
|
||||||
|
_UPLOAD_FRONTEND = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||||
|
"upload", "frontend",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pages_bp.route("/")
|
@pages_bp.route("/")
|
||||||
def index():
|
def index():
|
||||||
@@ -12,3 +19,9 @@ def index():
|
|||||||
@pages_bp.route("/architect")
|
@pages_bp.route("/architect")
|
||||||
def architect():
|
def architect():
|
||||||
return render_template("architect.html")
|
return render_template("architect.html")
|
||||||
|
|
||||||
|
|
||||||
|
@pages_bp.route("/upload/<path:filename>")
|
||||||
|
def upload_frontend(filename):
|
||||||
|
"""Отдать статику ES-модулей upload/frontend (для клиентского ZIP)."""
|
||||||
|
return send_from_directory(_UPLOAD_FRONTEND, filename)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ def prompts_save():
|
|||||||
prompt_body = body.get("body", "")
|
prompt_body = body.get("body", "")
|
||||||
notes = body.get("notes", "")
|
notes = body.get("notes", "")
|
||||||
try:
|
try:
|
||||||
p = db_prompts.insert(role, name, prompt_body, notes)
|
p = db_prompts.save_new_version(role, name, prompt_body, notes)
|
||||||
return jsonify(ok=True, id=p["id"])
|
return jsonify(ok=True, id=p["id"])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify(ok=False, error=str(e)), 500
|
return jsonify(ok=False, error=str(e)), 500
|
||||||
@@ -65,7 +65,7 @@ def prompts_delete():
|
|||||||
return jsonify(ok=False, error="body required"), 400
|
return jsonify(ok=False, error="body required"), 400
|
||||||
prompt_id = body.get("id")
|
prompt_id = body.get("id")
|
||||||
try:
|
try:
|
||||||
db_prompts.delete(prompt_id)
|
db_prompts.delete_prompt(prompt_id)
|
||||||
return jsonify(ok=True)
|
return jsonify(ok=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify(ok=False, error=str(e)), 500
|
return jsonify(ok=False, error=str(e)), 500
|
||||||
|
|||||||
+110
-104
@@ -1,9 +1,10 @@
|
|||||||
"""Upload blueprint — загрузка, конвертация, распаковка."""
|
"""Upload blueprint — загрузка, конвертация, распаковка."""
|
||||||
import io, os, base64, hashlib, zipfile, tempfile, subprocess
|
import io, os, base64, hashlib, zipfile
|
||||||
from flask import Blueprint, request, jsonify, send_file
|
import httpx
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
from services.parse import parse_file
|
from services.parse import parse_file
|
||||||
from db import documents
|
from db import documents
|
||||||
from config import MAX_CONTENT_LENGTH
|
import config
|
||||||
|
|
||||||
upload_bp = Blueprint("upload", __name__)
|
upload_bp = Blueprint("upload", __name__)
|
||||||
|
|
||||||
@@ -17,9 +18,95 @@ def _check_ext(filename: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _unzip(data: bytes):
|
||||||
|
"""Распаковать ZIP → (ok, files, error)."""
|
||||||
|
MAX_FILES = 500
|
||||||
|
MAX_UNCOMPRESSED = 500 * 1024 * 1024 # 500 MB
|
||||||
|
files = []
|
||||||
|
total = 0
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||||
|
if len(zf.namelist()) > MAX_FILES:
|
||||||
|
return False, None, f"too many files in ZIP (max {MAX_FILES})"
|
||||||
|
for info in zf.infolist():
|
||||||
|
if info.is_dir():
|
||||||
|
continue
|
||||||
|
name = os.path.basename(info.filename)
|
||||||
|
if not name or ".." in name or "/" in name or "\\" in name:
|
||||||
|
continue
|
||||||
|
raw = zf.read(info)
|
||||||
|
total += len(raw)
|
||||||
|
if total > MAX_UNCOMPRESSED:
|
||||||
|
return False, None, "total uncompressed size exceeds 500 MB"
|
||||||
|
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
|
||||||
|
files.append({
|
||||||
|
"filename": name,
|
||||||
|
"ext": ext,
|
||||||
|
"size": len(raw),
|
||||||
|
"data_b64": base64.b64encode(raw).decode(),
|
||||||
|
})
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
return False, None, "invalid ZIP archive"
|
||||||
|
return True, files, None
|
||||||
|
|
||||||
|
|
||||||
|
def _convert(filename: str, data: bytes) -> bytes:
|
||||||
|
""".doc → .docx через внешний libreoffice-сервис. Возвращает docx-байты."""
|
||||||
|
try:
|
||||||
|
resp = httpx.post(
|
||||||
|
config.CONVERT_SERVICE_URL + "/convert",
|
||||||
|
files={"file": (filename, data, "application/msword")},
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
raise Exception("conversion timeout")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
try:
|
||||||
|
err = resp.json().get("error", "conversion failed")
|
||||||
|
except Exception:
|
||||||
|
err = "conversion failed"
|
||||||
|
raise Exception(err)
|
||||||
|
return resp.content
|
||||||
|
|
||||||
|
|
||||||
|
def _store_and_parse(filename: str, data: bytes, batch_id, contract_id, zip_source=None, mime_type="application/octet-stream"):
|
||||||
|
"""Общая логика: дедуп → insert в БД → авто-парсинг. Возвращает dict-результат."""
|
||||||
|
content_hash = hashlib.sha256(data).hexdigest()[:16]
|
||||||
|
|
||||||
|
# Дедупликация по хешу
|
||||||
|
if batch_id:
|
||||||
|
existing = documents.get_by_hash(batch_id, content_hash)
|
||||||
|
if existing:
|
||||||
|
return {"ok": False, "error": "duplicate", "doc_id": existing["id"], "duplicate_of": True}
|
||||||
|
|
||||||
|
doc = documents.insert(
|
||||||
|
filename=filename,
|
||||||
|
mime_type=mime_type,
|
||||||
|
original_bytes=base64.b64encode(data).decode(),
|
||||||
|
batch_id=batch_id,
|
||||||
|
zip_source=zip_source,
|
||||||
|
content_hash=content_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Авто-парсинг
|
||||||
|
try:
|
||||||
|
result = parse_file(filename, data)
|
||||||
|
if result["status"] == "parsed":
|
||||||
|
documents.set_parsed(doc["id"], result["elements"])
|
||||||
|
parsed = {"status": "parsed", "element_count": result.get("element_count", 0)}
|
||||||
|
else:
|
||||||
|
documents.set_error(doc["id"], result.get("error", "parse failed"))
|
||||||
|
parsed = {"status": "error", "error": result.get("error", "parse failed")}
|
||||||
|
except Exception as e:
|
||||||
|
documents.set_error(doc["id"], str(e))
|
||||||
|
parsed = {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
return {"ok": True, "doc_id": doc["id"], "contract_id": contract_id, "parsed": parsed}
|
||||||
|
|
||||||
|
|
||||||
@upload_bp.route("/upload", methods=["POST"])
|
@upload_bp.route("/upload", methods=["POST"])
|
||||||
def upload():
|
def upload():
|
||||||
"""Загрузка одного файла + авто-парсинг → БД."""
|
"""Загрузка одного файла + авто-парсинг → БД (прямой multipart)."""
|
||||||
f = request.files.get("files")
|
f = request.files.get("files")
|
||||||
if not f:
|
if not f:
|
||||||
return jsonify(ok=False, error="no file"), 400
|
return jsonify(ok=False, error="no file"), 400
|
||||||
@@ -29,117 +116,36 @@ def upload():
|
|||||||
return jsonify(ok=False, error=err), 400
|
return jsonify(ok=False, error=err), 400
|
||||||
|
|
||||||
data = f.read()
|
data = f.read()
|
||||||
content_hash = hashlib.sha256(data).hexdigest()[:16]
|
|
||||||
batch_id = request.form.get("batch_id")
|
batch_id = request.form.get("batch_id")
|
||||||
zip_source = request.form.get("zip_source")
|
zip_source = request.form.get("zip_source")
|
||||||
|
|
||||||
# Дедупликация по хешу
|
|
||||||
if batch_id:
|
|
||||||
existing = documents.get_by_hash(batch_id, content_hash)
|
|
||||||
if existing:
|
|
||||||
return jsonify(ok=False, error="duplicate", doc_id=existing["id"])
|
|
||||||
|
|
||||||
doc = documents.insert(
|
|
||||||
filename=f.filename,
|
|
||||||
mime_type=f.content_type or "application/octet-stream",
|
|
||||||
original_bytes=base64.b64encode(data).decode(),
|
|
||||||
batch_id=batch_id,
|
|
||||||
zip_source=zip_source,
|
|
||||||
content_hash=content_hash,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Авто-парсинг
|
|
||||||
try:
|
|
||||||
result = parse_file(f.filename, data)
|
|
||||||
if result["status"] == "parsed":
|
|
||||||
documents.set_parsed(doc["id"], result["elements"])
|
|
||||||
else:
|
|
||||||
documents.set_error(doc["id"], result.get("error", "parse failed"))
|
|
||||||
except Exception as e:
|
|
||||||
documents.set_error(doc["id"], str(e))
|
|
||||||
result = {"status": "error", "error": str(e)}
|
|
||||||
|
|
||||||
contract_id = request.form.get("contract_id")
|
contract_id = request.form.get("contract_id")
|
||||||
return jsonify(
|
|
||||||
ok=True,
|
result = _store_and_parse(f.filename, data, batch_id, contract_id, zip_source, f.content_type or "application/octet-stream")
|
||||||
doc_id=doc["id"],
|
if not result["ok"]:
|
||||||
contract_id=contract_id,
|
return jsonify(ok=result["ok"], error=result.get("error"), doc_id=result.get("doc_id")), 200
|
||||||
parsed={"status": result["status"], "element_count": result.get("element_count", 0)},
|
return jsonify(ok=True, doc_id=result["doc_id"], contract_id=result["contract_id"], parsed=result["parsed"])
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@upload_bp.route("/convert-doc", methods=["POST"])
|
def contracts_upload_sink(name, content, batch_id=None, contract_id=None, zip_source=None):
|
||||||
def convert_doc():
|
"""Sink для переиспользуемого модуля upload: вставить файл в documents + авто-парсинг.
|
||||||
""".doc → .docx через libreoffice (без сохранения на диск)."""
|
|
||||||
f = request.files.get("files")
|
|
||||||
if not f:
|
|
||||||
return jsonify(ok=False, error="no file"), 400
|
|
||||||
|
|
||||||
data = f.read()
|
Вызывается модулем create_upload_refs_blueprint(cfg, sink=...) на каждый
|
||||||
doc_path = None
|
вытянутый с ВМ файл. .doc конвертируется в .docx через внешний LibreOffice
|
||||||
tmpdir = None
|
(парсер умеет только .docx). Возвращает dict ok/doc_id/contract_id/parsed.
|
||||||
try:
|
"""
|
||||||
with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as tmp:
|
if name.lower().endswith(".doc"):
|
||||||
tmp.write(data)
|
content = _convert(name, content)
|
||||||
doc_path = tmp.name
|
name = name[:-4] + ".docx"
|
||||||
tmpdir = tempfile.mkdtemp()
|
return _store_and_parse(name, content, batch_id, contract_id, zip_source)
|
||||||
subprocess.run(
|
|
||||||
["libreoffice", "--headless", "--convert-to", "docx", "--outdir", tmpdir, doc_path],
|
|
||||||
timeout=30, capture_output=True,
|
|
||||||
)
|
|
||||||
docx_files = [x for x in os.listdir(tmpdir) if x.endswith(".docx")]
|
|
||||||
if docx_files:
|
|
||||||
with open(os.path.join(tmpdir, docx_files[0]), "rb") as out:
|
|
||||||
return send_file(
|
|
||||||
io.BytesIO(out.read()),
|
|
||||||
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
||||||
)
|
|
||||||
return jsonify(ok=False, error="conversion produced no output"), 500
|
|
||||||
finally:
|
|
||||||
if doc_path and os.path.exists(doc_path):
|
|
||||||
os.unlink(doc_path)
|
|
||||||
if tmpdir and os.path.exists(tmpdir):
|
|
||||||
for x in os.listdir(tmpdir):
|
|
||||||
os.unlink(os.path.join(tmpdir, x))
|
|
||||||
os.rmdir(tmpdir)
|
|
||||||
|
|
||||||
|
|
||||||
@upload_bp.route("/unzip-upload", methods=["POST"])
|
@upload_bp.route("/unzip-upload", methods=["POST"])
|
||||||
def unzip_upload():
|
def unzip_upload():
|
||||||
"""Распаковать ZIP → список файлов (base64 для фронтенда)."""
|
"""Распаковать ZIP → список файлов (base64 для фронтенда, прямой multipart)."""
|
||||||
f = request.files.get("files")
|
f = request.files.get("files")
|
||||||
if not f:
|
if not f:
|
||||||
return jsonify(ok=False, error="no file"), 400
|
return jsonify(ok=False, error="no file"), 400
|
||||||
|
ok, files, err = _unzip(f.read())
|
||||||
data = f.read()
|
if not ok:
|
||||||
MAX_FILES = 500
|
return jsonify(ok=False, error=err), 400
|
||||||
MAX_UNCOMPRESSED = 500 * 1024 * 1024 # 500 MB
|
|
||||||
|
|
||||||
files = []
|
|
||||||
total = 0
|
|
||||||
|
|
||||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
|
||||||
if len(zf.namelist()) > MAX_FILES:
|
|
||||||
return jsonify(ok=False, error=f"too many files in ZIP (max {MAX_FILES})"), 400
|
|
||||||
|
|
||||||
for info in zf.infolist():
|
|
||||||
if info.is_dir():
|
|
||||||
continue
|
|
||||||
name = os.path.basename(info.filename)
|
|
||||||
if not name or ".." in name or "/" in name or "\\" in name:
|
|
||||||
continue
|
|
||||||
|
|
||||||
raw = zf.read(info)
|
|
||||||
total += len(raw)
|
|
||||||
if total > MAX_UNCOMPRESSED:
|
|
||||||
return jsonify(ok=False, error="total uncompressed size exceeds 500 MB"), 400
|
|
||||||
|
|
||||||
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
|
|
||||||
files.append({
|
|
||||||
"filename": name,
|
|
||||||
"ext": ext,
|
|
||||||
"size": len(raw),
|
|
||||||
"data_b64": base64.b64encode(raw).decode(),
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify(ok=True, files=files)
|
return jsonify(ok=True, files=files)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Classify service — LLM-based document classification.
|
|||||||
- Двухпроходная архитектура: LLM извлекает строки (тип/номер/дата/контрагент),
|
- Двухпроходная архитектура: LLM извлекает строки (тип/номер/дата/контрагент),
|
||||||
Python в grouping.py нормализует и группирует детерминированно.
|
Python в grouping.py нормализует и группирует детерминированно.
|
||||||
"""
|
"""
|
||||||
import json, re, os
|
import json, re
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -23,9 +23,7 @@ log = __import__("logging").getLogger(__name__)
|
|||||||
# Увеличивать осторожно — api.aillm.ru может троттлить
|
# Увеличивать осторожно — api.aillm.ru может троттлить
|
||||||
MAX_WORKERS = 4
|
MAX_WORKERS = 4
|
||||||
|
|
||||||
LLM_URL = "https://api.aillm.ru/v1/chat/completions"
|
from config import LLM_URL, LLM_KEY, LLM_MODEL
|
||||||
LLM_KEY = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "")
|
|
||||||
LLM_MODEL = "gpt-oss-120b"
|
|
||||||
|
|
||||||
# Ленивый singleton — обратная совместимость
|
# Ленивый singleton — обратная совместимость
|
||||||
_classify_llm = None
|
_classify_llm = None
|
||||||
@@ -91,7 +89,7 @@ def classify_batch(batch_id, llm_client=None, repo=None):
|
|||||||
_db = repo if repo else db_docs
|
_db = repo if repo else db_docs
|
||||||
_llm = llm_client if llm_client else _get_classify_client()
|
_llm = llm_client if llm_client else _get_classify_client()
|
||||||
|
|
||||||
# Сбросить статус — allow re-classify after file changes
|
# Сбросить 'processing' -> 'pending' (crash recovery); уже классифицированные не трогаем
|
||||||
_db.reset_classify_status(batch_id)
|
_db.reset_classify_status(batch_id)
|
||||||
pending = _db.list_pending(batch_id)
|
pending = _db.list_pending(batch_id)
|
||||||
if not pending:
|
if not pending:
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
"""LLM service — call LLM API with optional DI."""
|
"""LLM service — call LLM API with optional DI."""
|
||||||
import os, json
|
import json
|
||||||
|
|
||||||
LLM_URL = "https://api.aillm.ru/v1/chat/completions"
|
from config import LLM_URL, LLM_KEY, LLM_MODEL
|
||||||
LLM_KEY = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "")
|
|
||||||
LLM_MODEL = "gpt-oss-120b"
|
|
||||||
|
|
||||||
# Ленивый singleton — обратная совместимость
|
# Ленивый singleton — обратная совместимость
|
||||||
_llm_client = None
|
_llm_client = None
|
||||||
|
|||||||
@@ -77,6 +77,18 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
|
|||||||
ops = result.get("ops", [])
|
ops = result.get("ops", [])
|
||||||
mode = result.get("mode", "llm")
|
mode = result.get("mode", "llm")
|
||||||
|
|
||||||
|
# Трансляция target_id ("r1","r2"...) → target_hash (name_hash из current_spec).
|
||||||
|
# LLM возвращает target_id, а apply_ops() читает target_hash — без этого UPDATE/DELETE уходят в UNRESOLVED.
|
||||||
|
for _op in ops:
|
||||||
|
_tid = _op.get("target_id", "")
|
||||||
|
if _tid and isinstance(_tid, str) and _tid.startswith("r"):
|
||||||
|
try:
|
||||||
|
_idx = int(_tid[1:]) - 1
|
||||||
|
if 0 <= _idx < len(current_spec):
|
||||||
|
_op["target_hash"] = current_spec[_idx]["hash"]
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
"type": "llm_done",
|
"type": "llm_done",
|
||||||
"supplement_id": sid,
|
"supplement_id": sid,
|
||||||
@@ -86,6 +98,11 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
|
|||||||
"time_s": round(time.time() - t1, 1),
|
"time_s": round(time.time() - t1, 1),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# full_replace: очистить текущее состояние, чтобы ADD-строки новой редакции
|
||||||
|
# не дублировали старые (reset на старте пайплайна это не покрывает).
|
||||||
|
if mode == "full_replace":
|
||||||
|
spec_events.clear_current(contract_id)
|
||||||
|
|
||||||
# Apply ops to DB via apply_ops
|
# Apply ops to DB via apply_ops
|
||||||
try:
|
try:
|
||||||
summary = spec_events.apply_ops(
|
summary = spec_events.apply_ops(
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ var VM_API = '';
|
|||||||
var UPLOAD_URL = '/upload';
|
var UPLOAD_URL = '/upload';
|
||||||
var CONVERT_URL = '/convert-doc';
|
var CONVERT_URL = '/convert-doc';
|
||||||
var UNZIP_URL = '/unzip-upload';
|
var UNZIP_URL = '/unzip-upload';
|
||||||
|
// ВМ-буфер загрузки (паттерн drhider): браузер кладёт файл сюда (WebDAV, мимо шлюза),
|
||||||
|
// бэк сам тянет его по /api/upload_refs. Origin должен быть в CORS на nginx ВМ.
|
||||||
|
var VM_UPLOAD_URL = 'https://contracts.kube5s.ru/contracts-upload/';
|
||||||
// SITE_URL удалён (Фаза 4) — не использовался
|
// SITE_URL удалён (Фаза 4) — не использовался
|
||||||
|
|
||||||
var fileInput = document.getElementById('fileInput');
|
var fileInput = document.getElementById('fileInput');
|
||||||
@@ -113,6 +116,20 @@ fileInput.addEventListener('change', async function() {
|
|||||||
onFilesSelected(newFiles);
|
onFilesSelected(newFiles);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Выбор папки (webkitdirectory) — та же обработка, что и файлы
|
||||||
|
var folderInput = document.getElementById('folderInput');
|
||||||
|
var folderBtn = document.getElementById('folderBtn');
|
||||||
|
if (folderBtn && folderInput) {
|
||||||
|
folderBtn.addEventListener('click', function() { folderInput.click(); });
|
||||||
|
folderInput.addEventListener('change', function() {
|
||||||
|
var files = Array.from(folderInput.files).filter(function(f) {
|
||||||
|
var n = f.name.toLowerCase();
|
||||||
|
return n.endsWith('.pdf') || n.endsWith('.doc') || n.endsWith('.docx') || n.endsWith('.zip');
|
||||||
|
});
|
||||||
|
if (files.length) onFilesSelected(files);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Классификация ──────────────────────────────────────────
|
// ── Классификация ──────────────────────────────────────────
|
||||||
function showClassifyBtn() {
|
function showClassifyBtn() {
|
||||||
var btn = document.getElementById('classifyBtn');
|
var btn = document.getElementById('classifyBtn');
|
||||||
|
|||||||
@@ -90,15 +90,15 @@ function applyCompareEvent(sections, event) {
|
|||||||
*/
|
*/
|
||||||
function renderCompareSectionHeader(sec) {
|
function renderCompareSectionHeader(sec) {
|
||||||
if (sec.status === 'extracting') {
|
if (sec.status === 'extracting') {
|
||||||
return '⏳ ' + sec.filename;
|
return '⏳ ' + escHtml(sec.filename);
|
||||||
}
|
}
|
||||||
if (sec.status === 'llm_done' || sec.status === 'applied') {
|
if (sec.status === 'llm_done' || sec.status === 'applied') {
|
||||||
return '✓ ' + sec.filename + ' — ' + sec.ops_count + ' оп., ' + sec.mode + ' (' + sec.time_s + 'с)';
|
return '✓ ' + escHtml(sec.filename) + ' — ' + sec.ops_count + ' оп., ' + sec.mode + ' (' + sec.time_s + 'с)';
|
||||||
}
|
}
|
||||||
if (sec.status === 'error') {
|
if (sec.status === 'error') {
|
||||||
return '✗ ' + sec.filename + ': ' + sec.error;
|
return '✗ ' + escHtml(sec.filename) + ': ' + sec.error;
|
||||||
}
|
}
|
||||||
return sec.filename;
|
return escHtml(sec.filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -117,7 +117,7 @@ function renderCompareOpsTable(ops) {
|
|||||||
// Цвет строки зависит от действия: ADD=зелёный, DELETE=красный, UPDATE=жёлтый
|
// Цвет строки зависит от действия: ADD=зелёный, DELETE=красный, UPDATE=жёлтый
|
||||||
var cls = action === 'ADD' ? 'diff-added' : action === 'DELETE' ? 'diff-deleted' : action === 'UPDATE' ? 'diff-changed' : '';
|
var cls = action === 'ADD' ? 'diff-added' : action === 'DELETE' ? 'diff-deleted' : action === 'UPDATE' ? 'diff-changed' : '';
|
||||||
var nr = op.new_row || {};
|
var nr = op.new_row || {};
|
||||||
html += '<tr class="' + cls + '"><td>' + action + '</td><td>' + (nr.name||'') + '</td><td class="num-cell">' + (nr.price!=null?nr.price:'') + '</td><td class="num-cell">' + (nr.qty!=null?nr.qty:'') + '</td><td class="num-cell">' + (nr.sum!=null?nr.sum:'') + '</td><td>' + (nr.date_start||'') + '</td></tr>';
|
html += '<tr class="' + cls + '"><td>' + action + '</td><td>' + escHtml(nr.name||'') + '</td><td class="num-cell">' + (nr.price!=null?nr.price:'') + '</td><td class="num-cell">' + (nr.qty!=null?nr.qty:'') + '</td><td class="num-cell">' + (nr.sum!=null?nr.sum:'') + '</td><td>' + (nr.date_start||'') + '</td></tr>';
|
||||||
});
|
});
|
||||||
html += '</tbody></table></div>';
|
html += '</tbody></table></div>';
|
||||||
return html;
|
return html;
|
||||||
|
|||||||
+55
-4
@@ -1,4 +1,55 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
<rect width="32" height="32" rx="4" fill="#001C34"/>
|
<svg
|
||||||
<text x="16" y="24" text-anchor="middle" font-size="22" font-weight="bold" fill="white" font-family="sans-serif">N</text>
|
width="57"
|
||||||
</svg>
|
height="57"
|
||||||
|
xml:space="preserve"
|
||||||
|
overflow="hidden"
|
||||||
|
version="1.1"
|
||||||
|
id="svg8"
|
||||||
|
sodipodi:docname="U_v3.svg"
|
||||||
|
inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||||
|
id="namedview8"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="16.226667"
|
||||||
|
inkscape:cx="27.146672"
|
||||||
|
inkscape:cy="28.317584"
|
||||||
|
inkscape:window-width="2560"
|
||||||
|
inkscape:window-height="1494"
|
||||||
|
inkscape:window-x="-11"
|
||||||
|
inkscape:window-y="-11"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg8" /><defs
|
||||||
|
id="defs2"><clipPath
|
||||||
|
id="clip0"><rect
|
||||||
|
x="652"
|
||||||
|
y="420"
|
||||||
|
width="64"
|
||||||
|
height="75"
|
||||||
|
id="rect1" /></clipPath><clipPath
|
||||||
|
id="clip1"><rect
|
||||||
|
x="652"
|
||||||
|
y="420"
|
||||||
|
width="64"
|
||||||
|
height="70"
|
||||||
|
id="rect2" /></clipPath></defs><g
|
||||||
|
clip-path="url(#clip0)"
|
||||||
|
transform="matrix(1.0135748,0,0,1.0135748,-664.97034,-440.30567)"
|
||||||
|
id="g8"><g
|
||||||
|
clip-path="url(#clip1)"
|
||||||
|
id="g7"><g
|
||||||
|
id="g6"><path
|
||||||
|
d="m 114.028,43.1492 v 7.6592 c 0,3.7843 -3.08,6.8632 -6.866,6.8632 L 85.3367,57.5419 c -3.7853,0 -6.8655,-3.0805 -6.8655,-6.8643 v -2.5279 l 0.0555,0.009 V 15.8148 l -10.3823,2.0127 0.0045,3.8772 -0.0045,0.0015 v 28.971 c 0,9.481 7.7132,17.1923 17.1867,17.1923 l 21.8359,0.1313 c 9.474,0 17.187,-7.7117 17.187,-17.1928 v -2.6541 l 0.027,0.0045 V 15.8145 l -10.382,2.0126 0.028,25.3214 z"
|
||||||
|
fill="#001c34"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
transform="matrix(1,0,0,1.01337,587.92,420.059)"
|
||||||
|
id="path6" /></g></g></g></svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 246 B After Width: | Height: | Size: 2.0 KiB |
+53
-158
@@ -26,6 +26,7 @@ function statusToHTML(st) {
|
|||||||
if (!st || !st.kind) return '';
|
if (!st || !st.kind) return '';
|
||||||
switch (st.kind) {
|
switch (st.kind) {
|
||||||
case 'connecting': return '⏳ соединение... ' + (st.elapsed || 0) + 'с';
|
case 'connecting': return '⏳ соединение... ' + (st.elapsed || 0) + 'с';
|
||||||
|
case 'converting': return '⏳ конвертация... ' + (st.elapsed || 0) + 'с';
|
||||||
case 'uploading': return '⏳ отправка... ' + (st.elapsed || 0) + 'с';
|
case 'uploading': return '⏳ отправка... ' + (st.elapsed || 0) + 'с';
|
||||||
case 'uploaded': return '<span class="status-ok">✓</span>';
|
case 'uploaded': return '<span class="status-ok">✓</span>';
|
||||||
case 'unzipping': return '⏳ распаковка...';
|
case 'unzipping': return '⏳ распаковка...';
|
||||||
@@ -51,7 +52,7 @@ function statusToHTML(st) {
|
|||||||
*/
|
*/
|
||||||
function renderFiles(state) {
|
function renderFiles(state) {
|
||||||
if (state.files.length === 0) {
|
if (state.files.length === 0) {
|
||||||
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .docx / .pdf</td></tr>';
|
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .doc / .docx / .pdf</td></tr>';
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -204,21 +205,18 @@ window.toggleClassifyDetail = async function(i) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ⛔ НЕ МЕНЯТЬ ⛔ uploadFile — fetch-загрузка с честным счётчиком времени.
|
* ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔ uploadFile — загрузка через ВМ-буфер (паттерн drhider).
|
||||||
*
|
*
|
||||||
* v2.0.3: вместо фейкового ↑N% — честный счётчик ⏳ соединение... Nс → ⏳ отправка... Nс.
|
* Фаза 1: PUT файла на ВМ (WebDAV /contracts-upload/, мимо шлюза кластера ~64КБ).
|
||||||
* fetch() не даёт реальный upload progress, поэтому считаем секунды.
|
* Фаза 2: POST /api/upload_refs {files:[{name,size,url}]} — бэк сам тянет файл с ВМ.
|
||||||
* Кэш-бастинг: ?_=Date.now()
|
* Честный счётчик времени: ⏳ соединение... Nс → ⏳ отправка... Nс (fetch не даёт progress).
|
||||||
*/
|
*/
|
||||||
function uploadFile(file, onProgress, zipSource) {
|
function uploadFile(file, onProgress, zipSource) {
|
||||||
var startTime = Date.now();
|
var startTime = Date.now();
|
||||||
var phase = 'connecting'; // connecting → uploading
|
var phase = 'connecting'; // connecting → uploading
|
||||||
if (onProgress) onProgress({ kind: 'connecting', elapsed: 0 });
|
if (onProgress) onProgress({ kind: 'connecting', elapsed: 0 });
|
||||||
var fd = new FormData();
|
var token = crypto.randomUUID();
|
||||||
fd.append('files', file, file.name);
|
var vmUrl = VM_UPLOAD_URL + token + '_0';
|
||||||
if (state.contractId) fd.append('contract_id', state.contractId);
|
|
||||||
fd.append('batch_id', state.batchId);
|
|
||||||
if (zipSource) fd.append('zip_source', zipSource);
|
|
||||||
|
|
||||||
// Честный счётчик: каждую секунду обновляем elapsed
|
// Честный счётчик: каждую секунду обновляем elapsed
|
||||||
var timer = setInterval(function() {
|
var timer = setInterval(function() {
|
||||||
@@ -226,17 +224,33 @@ function uploadFile(file, onProgress, zipSource) {
|
|||||||
if (onProgress) onProgress({ kind: phase, elapsed: elapsed });
|
if (onProgress) onProgress({ kind: phase, elapsed: elapsed });
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
return fetch(UPLOAD_URL + '?_=' + Date.now(), { method: 'POST', body: fd })
|
// Фаза 1: PUT файла на ВМ-буфер
|
||||||
|
return fetch(vmUrl, { method: 'PUT', body: file, headers: { 'Content-Type': 'application/octet-stream' } })
|
||||||
|
.then(function(r) {
|
||||||
|
if (!r.ok) throw new Error('VM upload HTTP ' + r.status);
|
||||||
|
phase = 'uploading';
|
||||||
|
// Фаза 2: refs на бэкенд → pull с ВМ
|
||||||
|
var body = { batch_id: state.batchId, files: [{ name: file.name, size: file.size, url: vmUrl }] };
|
||||||
|
if (state.contractId) body.contract_id = state.contractId;
|
||||||
|
if (zipSource) body.zip_source = zipSource;
|
||||||
|
return fetch('/api/upload_refs?_=' + Date.now(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
})
|
||||||
.then(function(r) {
|
.then(function(r) {
|
||||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
return r.json();
|
return r.json();
|
||||||
})
|
})
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
if (data.ok) {
|
if (data.ok && data.results && data.results.length === 1) {
|
||||||
|
var res = data.results[0];
|
||||||
|
if (!res.ok) throw new Error(res.error || 'Неизвестная ошибка');
|
||||||
var elapsed = Math.floor((Date.now() - startTime) / 1000);
|
var elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||||
if (onProgress) onProgress({ kind: 'uploading', elapsed: elapsed });
|
if (onProgress) onProgress({ kind: 'uploading', elapsed: elapsed });
|
||||||
return data;
|
return res; // {ok, doc_id, contract_id, parsed}
|
||||||
}
|
}
|
||||||
throw new Error(data.error || 'Неизвестная ошибка');
|
throw new Error(data.error || 'Неизвестная ошибка');
|
||||||
})
|
})
|
||||||
@@ -321,150 +335,6 @@ function applyParseResult(entry, parsed, elapsed) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* addZipFile(file) — Async-action: обработка ZIP-файла (Фаза 1, упрощена).
|
|
||||||
*
|
|
||||||
* 1. Отправляет ZIP на бэкенд (только распаковка, без БД/парсинга)
|
|
||||||
* 2. Для каждого файла из архива: base64 → Blob → File → addRegularFile()
|
|
||||||
*
|
|
||||||
* addRegularFile делает ВСЁ: upload, parse, дубликаты, статусы, render.
|
|
||||||
* Никакого дублирования логики — единый путь для обычных и ZIP-файлов.
|
|
||||||
*/
|
|
||||||
async function addZipFile(file) {
|
|
||||||
var zipEntry = { name: file.name, lastModified: file.lastModified, size: file.size, status: { kind: 'unzipping' } };
|
|
||||||
state.files.push(zipEntry);
|
|
||||||
render(state);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Шаг 1: распаковать ZIP на бэкенде
|
|
||||||
var zipResp = await new Promise(function(resolve, reject) {
|
|
||||||
var xhr = new XMLHttpRequest();
|
|
||||||
xhr.open('POST', UNZIP_URL);
|
|
||||||
xhr.responseType = 'json';
|
|
||||||
xhr.onload = function() { resolve(xhr.response); };
|
|
||||||
xhr.onerror = function() { reject(new Error('Сеть')); };
|
|
||||||
xhr.ontimeout = function() { reject(new Error('Таймаут')); };
|
|
||||||
xhr.timeout = 60000;
|
|
||||||
var fd = new FormData();
|
|
||||||
fd.append('files', file);
|
|
||||||
xhr.send(fd);
|
|
||||||
});
|
|
||||||
if (!zipResp.ok || !zipResp.files) throw new Error('unzip failed');
|
|
||||||
|
|
||||||
// Подтверждение: показать первые 10 файлов + итог
|
|
||||||
var preview = zipResp.files.slice(0, 10).map(function(f) { return ' • ' + f.filename + ' (' + (f.size/1024).toFixed(1) + ' KB)'; }).join('\n');
|
|
||||||
if (total > 10) preview += '\n ... и ещё ' + (total - 10) + ' файлов';
|
|
||||||
if (!confirm('Архив «' + file.name + '» — ' + total + ' файлов:\n\n' + preview + '\n\nЗагрузить эти файлы?')) {
|
|
||||||
// Отмена — убрать строку ZIP
|
|
||||||
var zipPos2 = state.files.indexOf(zipEntry);
|
|
||||||
if (zipPos2 >= 0) state.files.splice(zipPos2, 1);
|
|
||||||
render(state);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Шаг 2: обработать каждый файл из архива
|
|
||||||
// Временная строка ZIP остаётся в таблице — обновляем её статус
|
|
||||||
var total = zipResp.files.length;
|
|
||||||
for (var zi = 0; zi < total; zi++) {
|
|
||||||
var zf = zipResp.files[zi];
|
|
||||||
zipEntry.status = { kind: 'unzipping' }; // обновляем статус
|
|
||||||
render(state);
|
|
||||||
|
|
||||||
if (zf.error) continue;
|
|
||||||
|
|
||||||
// base64 → File → addRegularFile (единый путь)
|
|
||||||
var byteStr = atob(zf.data_b64);
|
|
||||||
var bytes = new Uint8Array(byteStr.length);
|
|
||||||
for (var b = 0; b < byteStr.length; b++) bytes[b] = byteStr.charCodeAt(b);
|
|
||||||
var mime = {docx:'application/vnd.openxmlformats-officedocument.wordprocessingml.document',doc:'application/msword',pdf:'application/pdf'}[zf.ext] || 'application/octet-stream';
|
|
||||||
var extractedFile = new File([bytes], zf.filename, { type: mime, lastModified: Date.now() });
|
|
||||||
|
|
||||||
await addRegularFile(extractedFile, file.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Шаг 3: убрать временную строку ZIP (только после успешной обработки всех файлов)
|
|
||||||
var zipPos = state.files.indexOf(zipEntry);
|
|
||||||
if (zipPos >= 0) state.files.splice(zipPos, 1);
|
|
||||||
render(state);
|
|
||||||
|
|
||||||
} catch(ze) {
|
|
||||||
// Ошибка — показать на строке ZIP (zipEntry всё ещё в state.files)
|
|
||||||
zipEntry.status = { kind: 'error', text: 'ZIP: ' + ze.message };
|
|
||||||
render(state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* addRegularFile(file) — Async-action: обработка обычного файла (Фаза 1).
|
|
||||||
*
|
|
||||||
* 1. Добавляет/заменяет файл в state.files
|
|
||||||
* 2. Загружает через XHR (uploadFile) с индикатором прогресса
|
|
||||||
* 3. После загрузки: проставляет doc_id, contractId
|
|
||||||
* 4. applyParseResult — применяет результат парсинга
|
|
||||||
*
|
|
||||||
* Мутирует state.files, вызывает render(state) после каждого изменения.
|
|
||||||
*/
|
|
||||||
async function addRegularFile(file, zipSource) {
|
|
||||||
// Создать запись с начальным статусом
|
|
||||||
var entry = { name: file.name, lastModified: file.lastModified, size: file.size, file: file, status: { kind: 'connecting', elapsed: 0 }, zip_source: zipSource || null };
|
|
||||||
|
|
||||||
// ДЕДУПЛИКАЦИЯ: ключ = (zip_source, name), чтобы одноимённые файлы из разных ZIP не затирались
|
|
||||||
var dupKey = (zipSource || '') + '/' + file.name;
|
|
||||||
var existingIdx = -1;
|
|
||||||
for (var j = 0; j < state.files.length; j++) {
|
|
||||||
var ejKey = (state.files[j].zip_source || '') + '/' + state.files[j].name;
|
|
||||||
if (ejKey === dupKey) { existingIdx = j; break; }
|
|
||||||
}
|
|
||||||
var rowIdx;
|
|
||||||
if (existingIdx >= 0) {
|
|
||||||
// Файл с таким именем уже есть — спросить пользователя
|
|
||||||
if (!confirm('Файл «' + file.name + '» уже есть в списке.\n\nOK — перезаписать\nОтмена — пропустить')) {
|
|
||||||
return; // пользователь выбрал «пропустить»
|
|
||||||
}
|
|
||||||
state.files[existingIdx] = entry;
|
|
||||||
rowIdx = existingIdx;
|
|
||||||
} else {
|
|
||||||
state.files.push(entry);
|
|
||||||
rowIdx = state.files.length - 1;
|
|
||||||
}
|
|
||||||
render(state);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// fetch-загрузка с честным счётчиком времени
|
|
||||||
var resp = await uploadFile(file, function(st) {
|
|
||||||
state.files[rowIdx].status = st;
|
|
||||||
render(state);
|
|
||||||
}, zipSource);
|
|
||||||
// Бэкенд возвращает doc_id и contract_id (нижний регистр — Python keys)
|
|
||||||
if (resp && resp.contract_id) state.contractId = resp.contract_id;
|
|
||||||
state.files[rowIdx].doc_id = resp.doc_id;
|
|
||||||
|
|
||||||
// Дубликат?
|
|
||||||
if (resp && resp.duplicate_of) {
|
|
||||||
state.files[rowIdx].status = { kind: 'duplicate' };
|
|
||||||
render(state);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Warning от парсинга?
|
|
||||||
if (resp && resp.warning) {
|
|
||||||
state.files[rowIdx].status = { kind: 'warning', text: resp.warning };
|
|
||||||
render(state);
|
|
||||||
} else {
|
|
||||||
state.files[rowIdx].status = { kind: 'uploaded' };
|
|
||||||
}
|
|
||||||
state.files[rowIdx].uploaded = true;
|
|
||||||
|
|
||||||
// Авто-парсинг: бэкенд парсит всё (PDF + DOCX + DOC)
|
|
||||||
var t0 = Date.now();
|
|
||||||
var pr = resp.parsed;
|
|
||||||
var elapsed = pr && pr.status === 'parsed' ? ((Date.now() - t0) / 1000).toFixed(1) : null;
|
|
||||||
applyParseResult(state.files[rowIdx], pr, elapsed);
|
|
||||||
} catch(err) {
|
|
||||||
state.files[rowIdx].status = { kind: 'error', text: err.message };
|
|
||||||
}
|
|
||||||
render(state);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* finalizeUpload() — Завершение цикла загрузки (Фаза 1).
|
* finalizeUpload() — Завершение цикла загрузки (Фаза 1).
|
||||||
*
|
*
|
||||||
@@ -491,6 +361,31 @@ async function finalizeUpload() {
|
|||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* expandZipClient(f) — клиентское рекурсивное раскрытие ZIP (drhider listZipFiles).
|
||||||
|
* Каждый документ из архива (включая вложенные zip) добавляется в state.files
|
||||||
|
* с zip_source = имя архива. Fallback: если раскрыть не удалось — архив как есть.
|
||||||
|
*/
|
||||||
|
async function expandZipClient(f) {
|
||||||
|
var nested = [];
|
||||||
|
try {
|
||||||
|
if (window.listZipFiles) {
|
||||||
|
nested = await window.listZipFiles(f, ['.pdf', '.doc', '.docx']);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
nested = [];
|
||||||
|
}
|
||||||
|
if (!nested || nested.length === 0) {
|
||||||
|
state.files.push({ name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: { kind: 'connecting', elapsed: 0 }, zip_source: null });
|
||||||
|
state.files[state.files.length - 1]._pendingFile = f;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (var z = 0; z < nested.length; z++) {
|
||||||
|
state.files.push({ name: nested[z].name, lastModified: nested[z].lastModified, size: nested[z].size, file: nested[z], status: { kind: 'connecting', elapsed: 0 }, zip_source: f.name });
|
||||||
|
state.files[state.files.length - 1]._pendingFile = nested[z];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ⛔ НЕ МЕНЯТЬ ⛔ onFilesSelected — все строки сразу, потом загрузка.
|
* ⛔ НЕ МЕНЯТЬ ⛔ onFilesSelected — все строки сразу, потом загрузка.
|
||||||
*
|
*
|
||||||
@@ -507,7 +402,7 @@ async function onFilesSelected(newFiles) {
|
|||||||
for (var i = 0; i < newFiles.length; i++) {
|
for (var i = 0; i < newFiles.length; i++) {
|
||||||
var f = newFiles[i];
|
var f = newFiles[i];
|
||||||
if (f.name.toLowerCase().endsWith('.zip')) {
|
if (f.name.toLowerCase().endsWith('.zip')) {
|
||||||
await addZipFile(f);
|
await expandZipClient(f);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
state.files.push({ name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: { kind: 'connecting', elapsed: 0 }, zip_source: null });
|
state.files.push({ name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: { kind: 'connecting', elapsed: 0 }, zip_source: null });
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<img src="/static/logo.svg" alt="Nubes">
|
<img src="/static/logo.svg" alt="Nubes">
|
||||||
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v2.0.5</span></span>
|
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v2.0.15</span></span>
|
||||||
<div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);">
|
<div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);">
|
||||||
<span id="stepUpload">○ Загрузка</span><span>→</span>
|
<span id="stepUpload">○ Загрузка</span><span>→</span>
|
||||||
<span id="stepClassify">○ Классификация</span><span>→</span>
|
<span id="stepClassify">○ Классификация</span><span>→</span>
|
||||||
@@ -90,7 +90,11 @@
|
|||||||
Загрузка договоров/приложений/спецификаций
|
Загрузка договоров/приложений/спецификаций
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<input type="file" id="fileInput" accept=".docx,.pdf,.zip" multiple style="margin-bottom:6px;width:100%;">
|
<input type="file" id="fileInput" accept=".doc,.docx,.pdf,.zip" multiple style="margin-bottom:6px;width:100%;">
|
||||||
|
<input type="file" id="folderInput" webkitdirectory multiple style="display:none;">
|
||||||
|
<div style="margin-bottom:6px;">
|
||||||
|
<button type="button" class="btn" id="folderBtn" style="font-size:12px;height:30px;">📁 Выбрать папку</button>
|
||||||
|
</div>
|
||||||
<div style="text-align:right;font-size:11px;color:var(--muted);margin-bottom:6px;">Порядок определяется автоматически при классификации</div>
|
<div style="text-align:right;font-size:11px;color:var(--muted);margin-bottom:6px;">Порядок определяется автоматически при классификации</div>
|
||||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px;">⚠ При совпадении имён — запрос на перезапись (OK / Отмена). Файлы из ZIP-архивов загружаются через тот же поток.</div>
|
<div style="font-size:11px;color:var(--muted);margin-bottom:6px;">⚠ При совпадении имён — запрос на перезапись (OK / Отмена). Файлы из ZIP-архивов загружаются через тот же поток.</div>
|
||||||
|
|
||||||
@@ -216,11 +220,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/state.js?v=2.0.5"></script>
|
<script type="module">
|
||||||
<script src="/static/app_utils.js?v=2.0.5"></script>
|
import { listZipFiles } from '/upload/zip/list_zip_files.js';
|
||||||
<script src="/static/files.js?v=2.0.5"></script>
|
window.listZipFiles = listZipFiles;
|
||||||
<script src="/static/groups.js?v=2.0.5"></script>
|
</script>
|
||||||
<script src="/static/compare.js?v=2.0.5"></script>
|
<script src="/static/state.js?v=2.0.8"></script>
|
||||||
<script src="/static/app.js?v=2.0.5"></script>
|
<script src="/static/app_utils.js?v=2.0.8"></script>
|
||||||
|
<script src="/static/files.js?v=2.0.14"></script>
|
||||||
|
<script src="/static/groups.js?v=2.0.8"></script>
|
||||||
|
<script src="/static/compare.js?v=2.0.15"></script>
|
||||||
|
<script src="/static/app.js?v=2.0.13"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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 на ВМ, а не код модуля.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Переиспользуемый модуль загрузки через ВМ (2 слоя).
|
||||||
|
|
||||||
|
Структура:
|
||||||
|
- frontend/ — слой 1 (выбор файлов) + слой 2 (закачка через ВМ), JS.
|
||||||
|
- backend/ — слой 2 (бэк): Blueprint upload_refs + in-memory сессия, Python.
|
||||||
|
|
||||||
|
Подключение в новый проект — см. README.md.
|
||||||
|
"""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Backend переиспользуемого модуля загрузки: upload_refs + session."""
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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/"
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// esc — экранирование HTML (защита от self-XSS именами файлов).
|
||||||
|
|
||||||
|
export function esc(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 + 'с';
|
||||||
|
}
|
||||||
@@ -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); },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>';
|
||||||
|
}
|
||||||
@@ -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 + ' файлов';
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// setStatus — записать HTML-статус в ячейку таблицы (st-<idx>).
|
||||||
|
|
||||||
|
export function setStatus(idx, html) {
|
||||||
|
const e = document.getElementById('st-' + idx);
|
||||||
|
if (e) e.innerHTML = html;
|
||||||
|
}
|
||||||
@@ -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>'), '<a href="x">&'</a>');
|
||||||
|
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('Все фронт-тесты прошли');
|
||||||
@@ -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 (фронт) прошли');
|
||||||
@@ -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); // сырое тело файла
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user