31 Commits
Author SHA1 Message Date
Repinoid 52da9a3923 Update upload interface and ignore temporary E2E artifacts
Deploy drhider / validate (push) Canceled after 0s
2026-09-16 07:42:31 +03:00
Repinoid 49bb4d70d8 feat: integrate upload-platform v0.2.2 into drhider (v0.0.78)
Deploy drhider / validate (push) Canceled after 0s
- Update upload/ module to v0.2.2 with modular Layer 1 (FilePicker) and Layer 2 (Streaming transit upload)
- Replace legacy manual file table in site/templates/index.html with FilePicker.initFilePicker
- Wire uploadViaVM with per-file status updates and abort signal support
- Add dist bundles to dist/ and site/static/dist/ with routes in site/routes/main_bp.py
- Add test_hardening.py and test_safe_name.py from upload-platform
- Bump version to 0.0.78 in site/app.py
- Document integration plan and report in History/upload-integration/
2026-09-15 12:23:31 +03:00
Repinoid 3fd6f1cffc 1
Deploy drhider / validate (push) Canceled after 0s
2026-09-14 18:40:13 +03:00
Repinoid 24bd89b511 Merge origin/master into master 2026-09-14 18:33:38 +03:00
Repinoid f90d97b276 chore: ignore agent temporary files 2026-09-14 18:25:27 +03:00
Repinoid 1ff305f38e chore: ignore nested upload-platform repository 2026-09-12 19:53:59 +03:00
Repinoid 741edafc4a chore: sync local VM mirror state 2026-09-12 19:48:37 +03:00
“Naeel” 04affca363 Merge remote-tracking branch 'origin/master'
Deploy drhider / validate (push) Canceled after 0s
2026-09-06 14:09:58 +03:00
“Naeel” d0128392e3 feat: add upload platform 2026-09-06 14:09:22 +03:00
“Naeel” 11716259a1 1
Deploy drhider / validate (push) Canceled after 0s
2026-09-05 13:27:35 +03:00
“Naeel” 4b33593a82 docs: document VM rsync sync
Deploy drhider / validate (push) Canceled after 0s
2026-08-31 20:59:26 +03:00
“Naeel” e7d771e00f fix: remove api blueprint syntax error
Deploy drhider / validate (push) Canceled after 0s
2026-08-31 20:55:26 +03:00
“Naeel” ce234132dd docs: README модуля — раздача в Flask, onXHR, слой 3
Deploy drhider / validate (push) Canceled after 0s
2026-08-26 07:12:27 +03:00
“Naeel” 3abcc6c1fc test: покрыть zip deflate (метод 8), skip в Node 18 (нет deflate-raw)
Deploy drhider / validate (push) Canceled after 0s
2026-08-25 19:17:52 +03:00
“Naeel” 0ad9e0047f fix: вернуть регистрацию activeXHR (onXHR) для отмены загрузки + тесты чистых функций
Deploy drhider / validate (push) Canceled after 0s
2026-08-25 19:14:30 +03:00
“Naeel” 52c3eb53f1 test: добавить нагрузочные/долгие тесты слоёв 1 и 2 (37 тестов всего)
Deploy drhider / validate (push) Canceled after 0s
2026-08-25 19:11:29 +03:00
“Naeel” 17e6dbd065 fix: resetAll — syncProcCtx до table.clear (неверный рендер «Готово 0/0» после новой сессии)
Deploy drhider / validate (push) Canceled after 0s
2026-08-25 17:29:17 +03:00
“Naeel” 53751a1f7b chore: удалить неиспользуемый site/session.py, bump v0.0.76
Deploy drhider / validate (push) Canceled after 0s
2026-08-25 08:56:45 +03:00
“Naeel” a33dff2f47 feat: интегрировать модуль upload в drhider (бэк blueprint + фронт ES-модули) 2026-08-25 08:52:50 +03:00
“Naeel” af172115b0 fix(upload): параметризация pullRetries/pullRetryDelay/pullTimeout, configure лимитов/TTL, onStatus в init, логирование sid/name 2026-08-25 08:37:09 +03:00
“Naeel” 5711e97fe5 feat: модуль upload — вынос слоёв 1 и 2 (выбор файлов + закачка через ВМ), файл-на-функцию, тесты 2026-08-25 08:08:14 +03:00
“Naeel” 5f5ed000b8 1
Deploy drhider / validate (push) Canceled after 0s
2026-08-25 07:58:51 +03:00
“Naeel” a18877415d docs: детализировать PLAN-upload-layers (2 слоя, файл-на-функцию, README для агента) 2026-08-25 07:57:55 +03:00
“Naeel” 7a4f642452 docs: тест стабильности v0.0.75 — состав, цикл, CSV/ZIP, кнопка, фильтр zip OK 2026-08-24 20:40:27 +03:00
“Naeel” 090f5e8993 docs: подтверждение v0.0.75 на проде — кнопка при 0 файлов, фильтр zip, регресс OK 2026-08-24 20:23:46 +03:00
“Naeel” d8dee114a3 docs: решение — историю git не переписываем, ключ убран из файлов, рекомендована ротация 2026-08-24 20:22:40 +03:00
“Naeel” f2141ec731 секрет-гигиена: убран LLM_API_KEY из README/DEPLOY/History (попал в коммите 0707d53 при копировании env из Штурвала)
Deploy drhider / validate (push) Canceled after 0s
2026-08-24 20:21:02 +03:00
“Naeel” eaa064a721 v0.0.75: кнопка disabled при 0 файлов, фильтр документов в expand_zips, README-риски
Deploy drhider / validate (push) Canceled after 0s
2026-08-24 20:18:44 +03:00
“Naeel” 6c51df96d9 docs: подтверждение v0.0.74 на проде 2026-08-24 20:12:49 +03:00
“Naeel” ba4546ecc9 v0.0.74: отмена в extract-фазе — прерывание не ждёт все .doc-конвертации
Deploy drhider / validate (push) Canceled after 0s
2026-08-24 20:07:23 +03:00
“Naeel” dd1e285c8c docs: подтверждение v0.0.73 на проде — SSRF/path traversal защита + регресс подпапок OK 2026-08-24 20:04:10 +03:00
71 changed files with 8497 additions and 1300 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
- НЕЛЬЗЯ менять ЧТО-ЛИБО при вопросе (?, почему, как, где, что) — только ответ словами
- НЕЛЬЗЯ спешить — обдумать, проверить, потом отвечать
- НЕЛЬЗЯ лезть в /home/naeel/nubes/contracts/ (кроме loadtest)
- НЕЛЬЗЯ предполагать — сомневаешься = спроси
- НИКОГДА не руководствоваться догадками. Если что-либо неизвестно точно или недоступно — немедленно остановиться и сообщить об этом; не продолжать действие и не выдавать предположение за факт.
- НЕЛЬЗЯ грузить внешние CDN/библиотеки на фронтенд — всё в коде
# ✅ ПОРЯДОК ПРАВКИ
+25
View File
@@ -1,9 +1,34 @@
__pycache__/
*.pyc
.env
.env.*
!.env.example
venv/
.venv/
env/
ENV/
instance/
.flaskenv
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
coverage.xml
htmlcov/
*.log
build/
dist/
!dist/
!dist/file-picker.esm.js
!dist/file-picker.iife.js
*.egg-info/
# TMP/
# about1500.md
*.har
chrome-net-export-log.json
History/tests/2026-09-15-*.md
tests/e2e_mass_processing.py
TSTFILES/
files/
upload-platform/
.agent-tmp/
-1
View File
@@ -1 +0,0 @@
python3
-1
View File
@@ -1 +0,0 @@
/usr/bin/python3
-1
View File
@@ -1 +0,0 @@
python3
-1
View File
@@ -1 +0,0 @@
lib
-5
View File
@@ -1,5 +0,0 @@
home = /usr/bin
include-system-site-packages = false
version = 3.12.3
executable = /usr/bin/python3.12
command = /usr/bin/python3 -m venv /home/naeel/nubes/drhider/.venv
+12
View File
@@ -18,6 +18,7 @@
| [infra/](infra/) | Инфраструктура: кластер, ingress, RST-диагностика, HTTP/2, ВМ-буфер |
| [ux-frontend/](ux-frontend/) | Фичи фронтенда: таймеры, имена файлов, ZIP-раскрытие, лимиты, прерывание/ETA |
| [upload/](upload/) | Загрузка файлов: диагностика, лимиты, стресс-тесты |
| [upload-integration/](upload-integration/) | Интеграция переиспользуемого модуля upload-platform |
| [code-fixes/](code-fixes/) | Исправления кода по версиям (баги, оптимизация, безопасность, логирование) |
| [tests/](tests/) | Тесты и бенчмарки: замеры UI, обфускация, нагрузка |
@@ -106,6 +107,8 @@
- **2026-08-24-time-tickers-estimates.md** — v0.0.64–0.0.65: тикер текущего файла на всех этапах, мгновенные оценки времени по файлам и суммарно.
- **2026-08-24-folder-select-implemented.md** — v0.0.68: кнопка «Выбрать папку» (webkitdirectory, рекурсивно, относительный путь).
- **2026-08-24-folder-zip-not-lost.md** — v0.0.69: архивы из папки без документов не теряются (добавляются как есть); раскрытие zip с документами; склонение счётчика.
- **2026-08-24-v075-small-fixes.md** — v0.0.75: кнопка при 0 файлов, фильтр zip (idx-мисматч), README-риски; замечен закоммиченный LLM_API_KEY.
- **2026-08-24-v074-cancel-extract.md** — v0.0.74: отмена в extract-фазе (не ждём все .doc).
- **2026-08-24-v073-security-fixes.md** — v0.0.73: 6 фиксов по ревью Соннета (SSRF, path traversal, слабый SID, proc_error, zip-бомба, self-XSS); 1 отклонено (cleanup), 5 отложено.
- **2026-08-24-pull-retry-limit-counter.md** — v0.0.72: ретраи pull из ВМ, корректная ошибка лимита сессии, счётчик дедупа.
- **2026-08-24-code-review-sonnet.md** — код-ревью Соннета: 15 багов (SSRF, path traversal, слабый SID, zip-бомба и др.), промпт в docs/code-review-sonnet.md.
@@ -120,8 +123,17 @@
- **2026-08-23-upload-stress-test.md** — тесты загрузки и стресс (v0.0.59–0.0.60, кластер naeel-test-3).
- **2026-08-24-upload-limits.md** — v0.0.62: лимиты 50 МБ на файл, 500 МБ на сессию.
## upload-integration/ — интеграция платформы загрузки
- [README.md](upload-integration/README.md) — стандарт и журнал процесса интеграции `upload-platform`.
- [2026-09-15-upload-platform-integration-plan.md](upload-integration/2026-09-15-upload-platform-integration-plan.md) — детальный архитектурный план и этапы интеграции версии v0.2.2 в drhider.
## code-fixes/ — исправления кода (по версиям)
- **2026-08-31-vm-sync-readme.md** — документирован `rsync` текущего проекта на ВМ
в `/home/naeel/drhider`; legacy-каталог исключён.
- **2026-08-31-api-bp-syntax-error.md** — v0.0.77: удалена случайная строка из `site/routes/api_bp.py`,
вызывавшая `SyntaxError` при импорте приложения; задокументирован ответ Fable по архитектуре.
- **2026-08-18-fix-review-bugs.md** — v0.0.32: фиксы по ревью обфускатора.
- **2026-08-19-replacer-optimization.md** — v0.0.39: оптимизация apply_replacements (однопроходная замена, один regex).
- **2026-08-20-logging.md** — v0.0.51: максимальное логирование (LOG_LEVEL env) + детальные логи SSE/воркера.
@@ -37,3 +37,10 @@ _Критично перепроверены в коде; спорные пун
- `_safe_name` unit-тест — 8/8 OK.
- `create_app()` стартует (VERSION 0.0.73).
- Версия 0.0.72 → 0.0.73.
## Проверка на проде (редеплой 20:00, v0.0.73)
- SSRF: `POST /api/upload_refs` с `url=http://169.254.169.254/...` → `{"count":0,"ok":true}` (URL отклонён).
- Path traversal: `name="../../evil.txt"` → `{"count":0,"ok":true}` (имя отклонено).
- SID теперь полный 32 hex (128 бит) — виден в ответе.
- Регресс: папка с подпапкой + кириллицей → «Обработано 2 файлов: 4.3с»,
в ZIP `Подпапка/Договор_1.md` + `Акт.md` (пути сохранены, `_safe_name` не сломал подпапки).
@@ -0,0 +1,30 @@
# v0.0.74 — отмена в extract-фазе (2026-08-24)
_code-fixes. Доработка по код-ревью Соннета (пункт «отмена не работает в extract-фазе»)._
## Что сделано (`drhider/obfuscator.py`)
- В проходе 1 (извлечение) добавлена проверка `cancel_event.is_set()` между файлами:
при отмене цикл извлечения прерывается (`break`), LLM-сканирование пропускается,
результат помечается `cancelled` (processed=0, пустой ZIP + mapping.csv только заголовок).
- Раньше отмена ждала завершения ВСЕХ извлечений, включая медленные `.doc` (liberta, до 120с/файл).
## Проверка
- `py_compile` — OK, `import` drhider — OK.
- Unit-тест: `cancel_event` установлен до старта → `meta={'cancelled': True, 'processed': 0, 'total': 2}`,
пустой ZIP (22 Б), CSV только заголовок.
## Критично к Соннету (по итогам проверки)
- **«LLM-таймаут тихо обнуляет чанк»** — ОТКЛОНЕНО: `_call_llm` уже логирует
(`log.warning("LLM NER failed: %s", e)`) в `except Exception`. Таймаут не «тихий».
- **«cleanup после /download»** — ОТКЛОНЕНО ранее (ZIP и CSV скачиваются раздельно, см. v0.0.73).
## Осталось (не трогаем, задокументировано)
- idx-мисматч `expand_zips` (фильтр расширений) — краевой случай, спорное поведение.
- LLM prompt injection — класс риска, не фикс кода.
- debug-эндпоинт `session_files` — мелочь, может сломать тесты.
Версия 0.0.73 → 0.0.74.
## Проверка на проде (редеплой 20:07, v0.0.74)
- Версия v0.0.74 — на проде.
- Регресс: обычный цикл «Обработано 2 файлов: 1.9с» — фикс отмены не сломал обработку.
@@ -0,0 +1,31 @@
# v0.0.75 — кнопка при 0 файлов, фильтр zip, README-риски (2026-08-24)
_code-fixes. Закрытие отложенных пунктов списка «что далее»._
## Что сделано
1. **Кнопка «Обфусцировать» активна при 0 файлов** (`index.html::resetAll`) —
убрано перекрытие `ub.disabled = false` после `rr()` (rr() уже ставит disabled при пустом списке).
Из TODO (`docs/WhatTODO.md`, пункт 1).
2. **idx-мисматч `expand_zips`** (`drhider/extractor.py`) — синхронизирован фильтр расширений
с фронтом `listZipFiles`: из zip берутся ТОЛЬКО документы (`.pdf .doc .docx .txt .md`)
и вложенные `.zip`. Unit-тест: zip [txt,pdf,xlsx,ini] → раскрыты только txt,pdf.
3. **README** — добавлена секция «Известные ограничения / риски»: LLM prompt injection,
кейс «zip без документов».
## Не сделано (решение)
- **`session_files` оставлен** — используется для диагностики (тесты),
SID теперь 128-бит (не угадывается), риск мал.
## ⚠️ Замечено (требует решения)
- В `README.md` в открытом виде закоммичен `LLM_API_KEY` (секрет!). Желательно вынести
в переменные окружения Штурвала и убрать из README.
## Проверка
- `py_compile` (app.py, extractor.py) — OK; `node --check` — OK.
- `expand_zips` unit-тест — OK (только документы).
- `resetAll` больше не содержит `ub.disabled = false` (5 оставшихся мест — нужные ветки).
- Версия 0.0.74 → 0.0.75.
## Проверка на проде (редеплой 20:20, v0.0.75)
- Кнопка «Обфусцировать»: disabled при 0 файлов → активна с файлом → disabled после «Новой сессии».
- Регресс: zip с документом раскрыт (справка.txt), «Обработано 2 файлов: 4.4с» — фильтр zip не сломал.
@@ -0,0 +1,26 @@
# 2026-08-31 — удалена случайная строка из `api_bp.py`
## Ответ Fable: анализ архитектуры DrHider
Сервис предназначен для обезличивания документов `.docx`, `.pdf`, `.doc`, `.txt` и `.zip`.
Основной пайплайн: `extractor` → `scanner` (regex + LLM) → `replacer` → `builder`,
оркестрация выполняется через `TwoPassObfuscator`. Веб-часть использует Flask,
blueprint'ы `main`, `health` и `api`, а загрузка файлов реализована через переиспользуемый
модуль с ВМ-буфером и pull.
## Найденная проблема
В `site/routes/api_bp.py` между инициализацией blueprint и определением первой функции
находилась строка:
```text
TMP/примеры_договоров_для_ИИ
```
Это невалидный Python-код и причина `SyntaxError` при импорте приложения. Строка удалена
без изменения остального API.
## Версия и проверка
- Исправление выпущено как версия `0.0.77` вместо `0.0.76`.
- Рабочая копия до исправления не содержала незакоммиченных изменений.
@@ -0,0 +1,13 @@
# 2026-08-31 — документирован sync проекта на ВМ
В README добавлен рабочий порядок синхронизации текущего checkout на ВМ:
- источник: `/home/naeel/nubes/drhider`;
- цель: `/home/naeel/drhider`;
- инструмент: `rsync` через SSH;
- исключены `.git`, кэши Python, `.venv` и `.pytest_cache`;
- `--delete` не используется;
- после копирования выполняются проверка версии и `py_compile`.
Legacy-каталог `/home/naeel/contracts/drhider` явно отмечен как недопустимая цель:
там работает старый standalone-сервис `contracts-drhider`.
@@ -0,0 +1,20 @@
# Секрет-гигиена: LLM_API_KEY в git (2026-08-24)
_infra/заметка. Решение пользователя: историю git НЕ переписывать._
## Что было
- `LLM_API_KEY` (sk-ucI5Yv…) попал в git при написании документации (коммит `0707d53`,
2026-07-12) — переменные окружения Штурвала скопированы в README/DEPLOY дословно, секрет не вычищен.
## Что сделано
- Ключ убран из текущих файлов: `README.md`, `docs/DEPLOY.md`, `History/plans/2026-07-12-audit-and-plan.md`
(коммит `f2141ec`, запушен).
- Проверено: в текущих файлах ключа нет.
## Решение пользователя
- **Историю git не переписываем** (filter-repo/filter-branch — нет, «хуй с ним»).
- Ключ остаётся в git-истории (коммиты `0707d53`, `a2f754a`, `4c3f9d4` и др.), репозиторий публичный.
## Рекомендация (не выполнено)
- Ротация `LLM_API_KEY` в Штурвале (старый считается скомпрометированным). Решение за пользователем.
- На будущее: секреты в env Штурвала, в git — только заглушки «(секрет)».
+1 -1
View File
@@ -140,6 +140,6 @@ site/ # Flask-приложение
| Переменная | Значение |
|---|---|
| `LLM_API_KEY` | `sk-ucI5YvOticoOQ9Kuj5K9mQ` |
| `LLM_API_KEY` | (секрет — не хранить в git) |
| `LLM_URL` | `https://api.aillm.ru/v1/chat/completions` |
| `LLM_MODEL` | `gpt-oss-120b` |
@@ -0,0 +1,20 @@
# Тест стабильности v0.0.75 на проде (2026-08-24)
_tests. Прод, редеплой 20:20 (v0.0.75). Комплексный сценарий после всех фиксов._
## Сценарий
Папка: битый PDF (scan.pdf) + txt + txt-дубль + Big.bin (51 МБ, не документ) + zip с документом (справка.txt).
## Результаты (все ✓)
1. **Состав**: «Добавлено из папки: 3 файла» — дубль отсеян, `.bin` пропущен,
zip раскрыт (справка.txt). Таблица: scan.pdf, Договор.txt, справка.txt.
2. **Полный цикл**: «Обработано 2 файлов: 4.9с, ИИ 4.7с» — битый PDF исключён.
3. **CSV**: глобально консистентные замены: `+7 916 111-22-33 → +7_000_000_0001`,
`+7 900 111-22-33 → +7_000_000_0002`, `Иванов Иван → Соколов_0001`, `Кузнецов Олег → Михайлов_0001`.
4. **ZIP**: `Договор.md`, `справка.md` (битый не попал).
5. **Кнопка**: disabled при 0 файлов → активна с файлом → disabled после «Новой сессии».
6. **Фильтр zip** (v0.0.75): не-документы не раскрываются, документ проходит.
## Итог
v0.0.75 стабильна: все фиксы (v0.0.70–0.0.75) работают совместно, регрессов нет.
Страница приведена в чистое состояние.
@@ -0,0 +1,102 @@
# План интеграции upload-platform (v0.2.2) в drhider
**Дата:** 2026-09-15
**Статус:** План составлен, ожидает команды на выполнение
**Целевой сервис:** drhider (Managed Flask на платформе Штурвал)
**Источник модуля:** `upload-platform/` (релиз v0.2.2, commit `df0576a`)
---
## 1. Контекст и цели
В `drhider` (v0.0.77) модуль загрузки `upload/` зафиксирован в промежуточном состоянии:
- Фронтенд Слоя 1 использует устаревшую табличную вёрстку с ручным парсингом ZIP без поддержки древовидного сворачивания.
- Слой 2 работает по смешанной схеме, в то время как в `upload-platform` v0.2.2 реализован и протестирован чистый пофайловый транзит (Streaming transit: PUT $\to$ POST `/api/upload_refs` $\to$ исходящий pull в RAM $\to$ немедленный DELETE $\to$ repeat).
**Цель интеграции:**
Заменить устаревшие наработки в `upload/` и `site/templates/index.html` на проверенную автономную версию `upload-platform` v0.2.2 без нарушения бизнес-логики обфускации (Слой 3: LLM, SSE-стриминг, генерация ZIP).
---
## 2. Границы ответственности слоёв
```
┌────────────────────────────────────────────────────────────────────────┐
│ БРАУЗЕР │
│ [ Слой 1: FilePicker ] ──► [ Слой 2: uploadViaVM ] │
└─────────────────────────────────────┬──────────────────────────────────┘
│ 1. PUT файл
▼
┌─────────────────────────────┐
│ ВМ-буфер (RAM WebDAV) │
└──────────────┬──────────────┘
│ 3. Исходящий GET (pull в RAM)
│ 4. DELETE с ВМ
▼
┌────────────────────────────────────────────────────────────────────────┐
│ FLASK BACKEND │
│ [ Слой 2: Blueprint upload_refs ] ──► [ RAM-сессия: add_file ] │
│ │ │
│ [ Слой 3: drhider engine ] ◄─────────────────────┘ get_files(sid) │
│ (SSE-прогресс, обфускация, mapping, download) │
└────────────────────────────────────────────────────────────────────────┘
```
- **Слой 1 (Фронтенд)**: инициализация через `FilePicker.initFilePicker()`. Полностью изолирован от транспорта. Возвращает список объектов `File` через `picker.getFiles()`.
- **Слой 2 (Фронтенд + Бэкенд)**: `uploadViaVM` передаёт файлы по одному через буфер на ВМ в RAM сессии Flask.
- **Слой 3 (drhider)**: логика обфускации забирает готовые файлы из `upload.backend.session.get_files(sid)` и запускает существующий пайплайн (`/api/drhider/<sid>`).
---
## 3. Этапы интеграции
### Этап 1. Синхронизация модуля ядра (`upload/`)
1. Заменить содержимое `upload/` в корне `drhider` на актуальную версию из `upload-platform/upload/`:
- `upload/backend/`: актуальные `upload_refs/` (поддержка `httpxTransport`, безопасный pull, санитизация `safe_name`) и `session/` (хранилище в RAM, TTL, блокировки).
- `upload/frontend/`: чистые ES-модули (`index.js`, `table/`, `upload/`, `zip/` на базе `fflate`).
2. Скопировать готовые бандлы из `upload-platform/dist/` в `site/static/dist/` (или настроить прямую раздачу из `upload/frontend` через `main_bp.py`).
### Этап 2. Проверка и настройка бэкенда (`site/routes/`)
1. Проверить регистрацию Blueprint в [site/routes/__init__.py](site/routes/__init__.py):
```python
app.register_blueprint(create_upload_refs_blueprint({
"apiPrefix": "/api",
"vmUploadPrefix": "https://contracts.kube5s.ru/drhider-upload/",
"maxFileBytes": 50 * 1024 * 1024,
"maxSessionBytes": 500 * 1024 * 1024,
"ttlSeconds": 30 * 60,
"pullRetries": 3,
"pullRetryDelay": 2,
}))
```
2. Убедиться, что [site/routes/api_bp.py](site/routes/api_bp.py) бесшовно использует сессии из обновлённого `upload.backend.session`.
### Этап 3. Обновление пользовательского интерфейса (`site/templates/index.html`)
1. Заменить устаревшую ручную вёрстку таблицы и инпутов на контейнер для монтирования Слой 1:
```html
<div id="file-picker-container"></div>
```
2. Подключить модуль `initFilePicker` и сконфигурировать фильтрацию по `.pdf, .doc, .docx, .txt, .md`.
3. Модифицировать обработчик кнопки «🛡️ Обфусцировать»:
- Вызов `uploadViaVM(files, { vmUploadUrl, backendUploadUrl, onFileStatus, onProgress })`.
- По завершении загрузки всех файлов — автоматический переход к Стек/Фазе 2: запуск SSE-стрима `/api/drhider/<sid>`.
4. Сохранить существующий трёхсекционный рендер обработки (Готово / Обрабатывается / Ожидают) и индикацию ETA/статистики LLM.
### Этап 4. Тестирование и валидация
1. **Автономные тесты модуля `upload`**:
- Запуск pytest для `upload/` внутри `drhider`.
2. **Интеграционные тесты `drhider`**:
- Запуск существующих тестов `tests/test_builder.py`, `tests/test_extractor.py`, `tests/test_replacer.py`, `tests/test_scanner.py`.
3. **Ручная/Smoke проверка сквозного цикла**:
- Выбор одиночного документа, папки и архива ZIP.
- Проверка пофайловой загрузки через буфер и немедленного удаления с ВМ.
- Проверка успешного прохождения двухпроходной обфускации и скачивания результирующего архива.
---
## 4. Контрольные точки (Definition of Done)
- [ ] Все тесты `pytest tests/` завершаются успешно (PASS).
- [ ] Отсутствуют временные файлы на диске (всё в RAM).
- [ ] Не нарушена логика отмены (`cancel`) и работы со стримами SSE.
- [ ] Сборка и синтаксис проверены через `py_compile`.
- [ ] Документ с отчётом о завершении зафиксирован в `History/upload-integration/`.
@@ -0,0 +1,46 @@
# 2026-09-15: Интеграция upload-platform v0.2.2 в drhider
## 1. Цель и контекст
Интеграция протестированного модульного релиза `upload-platform` v0.2.2 в сервис `drhider`.
Версия сервиса повышена: `v0.0.77` $\rightarrow$ `v0.0.78`.
## 2. Выполненные изменения
### Ядро модуля `upload/`
- **Слой 1 (Frontend)**: переход на модульный `initFilePicker` с распаковкой ZIP через `fflate`, древовидным представлением папок и архивов, встроенным сворачиванием/разворачиванием групп и дедупликацией. Устаревшие ручные парсеры удалены.
- **Слой 2 (Transit Upload)**:
- **Frontend**: пофайловый стриминговый транзит `uploadViaVM` (PUT на ВМ $\rightarrow$ POST `/api/upload_refs` $\rightarrow$ исходящий pull $\rightarrow$ DELETE из буфера $\rightarrow$ повтор для следующего файла).
- **Backend**: обновлён Blueprint `create_upload_refs_blueprint`: добавлена поддержка локальных путей/mock-буфера (`is_path_only_prefix`), кастомного `httpxTransport` для безопасного тестирования, возвращение параметра `added`.
- **Session**: in-memory хранилище сессий с потокобезопасным TTL и проверкой лимитов.
### Приложение Flask и маршруты
- В [site/routes/main_bp.py](site/routes/main_bp.py) добавлены маршруты раздачи бандлов: `/file-picker/<path:filename>` и `/dist/<path:filename>`.
- Бандлы `file-picker.iife.js` и `file-picker.esm.js` размещены в `dist/` и `site/static/dist/`.
### Пользовательский интерфейс ([site/templates/index.html](site/templates/index.html))
- Вёрстка выбора файлов заменена контейнером `#filePicker`.
- Инициализирован `FilePicker.initFilePicker` с разрешёнными расширениями (`.pdf`, `.doc`, `.docx`, `.txt`, `.md`).
- Логика кнопки «🛡️ Обфусцировать» переведена на `FilePicker.uploadViaVM` с поддержкой отмены (`AbortController`) и пофайловым отображением статусов.
- Сохранён и адаптирован этап 2 обработки (SSE `/api/process_stream/<sid>`, тикеры времени, статистика LLM, выгрузка ZIP и CSV).
### Тесты
- Скопированы и адаптированы тесты:
- [tests/test_hardening.py](tests/test_hardening.py) (проверка устойчивости, malformed requests, SSRF, traversal).
- [tests/test_safe_name.py](tests/test_safe_name.py) (санитизация путей).
- Обновлён [tests/test_upload.py](tests/test_upload.py) (длина сессии `uuid4().hex` = 32 символа).
## 3. Проверки и верификация
- `pytest tests/`: **39 passed** в 4 тестовых файлах.
- `python3 tests/test_builder.py`: **20/20 PASS**.
- `python3 tests/test_extractor.py`: **10/10 PASS**.
- `python3 tests/test_replacer.py`: **10/10 PASS**.
- `python3 tests/test_scanner.py`: **20/20 PASS**.
- `python3 tests/test_zip.py`: **28/28 PASS**.
- Проверка синтаксиса `py_compile` по всем `.py` файлам проекта: **ALL PY FILES COMPILED OK**.
- Проверка импортов ядра: `from drhider import obfuscate_files, LLMClient` $\rightarrow$ **OK**.
- Проверка HTTP:
- `GET /` $\rightarrow$ **HTTP 200**, HTML отрендерен (28571 байт).
- `GET /health` $\rightarrow$ **HTTP 200**, `{"ok":true,"version":"0.0.78"}`.
- `GET /file-picker/file-picker.iife.js` $\rightarrow$ **HTTP 200** (44465 байт).
+21
View File
@@ -0,0 +1,21 @@
# History / upload-integration — Журнал интеграции upload-platform
В этом каталоге фиксируется полный жизненный цикл интеграции автономного модуля
`upload-platform` (Слой 1: File Picker + Слой 2: пофайловый транзит через ВМ-буфер)
в сервис `drhider`.
## Стандарт ведения документации по интеграции
Каждый этап или значимый результат документируется отдельным файлом по формату:
`YYYY-MM-DD-<краткое-действие>.md`
Обязательные разделы каждого документа:
1. **Цель и контекст**: что делалось, версии компонентов (`drhider`, `upload-platform`).
2. **Выполненные изменения**: затронутые файлы и границы слоёв (Слой 1, Слой 2, Слой 3).
3. **Проверки и тесты**: команды запуска тестов и результаты (PASS/FAIL).
4. **Обнаруженные нюансы/отклонения**: проблемы обратной совместимости, сетевые или платформенные особенности.
## Документы раздела
- [2026-09-15-upload-platform-integration-plan.md](2026-09-15-upload-platform-integration-plan.md) — детальный архитектурный план и этапы интеграции версии v0.2.2 в drhider.
- [2026-09-15-upload-platform-v022-integration-report.md](2026-09-15-upload-platform-v022-integration-report.md) — отчёт об успешной интеграции и верификации v0.2.2 (версия drhider v0.0.78).
+62
View File
@@ -0,0 +1,62 @@
# ПЛАН: компонент выбора файлов/папок/архивов → файловое дерево
> Для Flash. Vanilla JS, БЕЗ React и сборщика (как drhider). Новое — только если проще.
> Переиспользуемый компонент (слой 1 из `PLAN-upload-layers.md`).
## Решения (фиксировано)
| Вопрос | Решение | Почему |
|---|---|---|
| Стек | **vanilla JS**, один модуль | drhider без сборщика; React избыточен |
| ZIP | **`fflate`** (`unzipSync`/`unzip` + `filter`) | заменяет ручной `parseZip` (~200 строк); 8 kB; filter по расширению ДО распаковки |
| Выбор папки | `webkitdirectory` (оставить) | уже в drhider, рекурсивно |
| Дерево | **свой лёгкий** рендер (ленивый + делегирование) | jsTree старые, react — нет |
| Единственная новая зависимость | `fflate` (CDN `unpkg`/`jsdelivr`) | оправдано и проще |
## Модель узла
```
node = { type:'dir'|'zip'|'file', name, path, size, file, children, collapsed }
```
- `path` для file — полный относительный путь: `"Папка/архив.zip/Внутри/файл.txt"`.
- Дубль = `path`+`size` → пропуск; имя то же, размер другой → суффикс `_2`.
## API компонента
```
createFilePicker({ container, allowedExt, maxFileBytes, onSelectionChange }) -> {
pickFiles(), pickFolder(),
getFlatFiles() -> [{path,name,size,file}], // в слой закачки
getOverNames(), clear(), setStatus(path, html)
}
```
## Сборка дерева
1. **Папка**: `webkitRelativePath="Top/…"` → отбросить `Top` → вставить по сегментам.
2. **Файлы**: в корень; `.zip` → `fflate.unzip` (рекурсивно по вложенным `.zip`), узел `zip` + дети.
3. **Фильтр**: лист принимается только если расширение ∈ `allowedExt`; `.zip` — ради раскрытия.
## Рендер (сотни файлов)
- Свёрнутый `dir`/`zip` **не рендерит детей** (ленивый).
- Плоский список «видимых строк» одним проходом (или `DocumentFragment`).
- Клики — **делегирование** (один обработчик на контейнер по `data-path`), не сотни слушателей.
- `getFlatFiles()` — обход дерева, только листья-файлы.
## Шаги (для Flash)
1. Подключить `fflate` (CDN). Написать обёртку `zip.js`: `listZip(f) -> [{path,name,size,file}]` с `filter` по `allowedExt`.
2. Модель узла + вставка по сегментам пути.
3. Сборка из `folderInput` (webkitRelativePath) и `fileInput` (раскрытие zip рекурсивно).
4. Дедуп (`path`+`size`, суффикс `_2`).
5. Рендер дерева: collapse, ленивый рендер, делегирование кликов.
6. Фильтр `allowedExt` + лимит + статус «пропущен (лимит)».
7. `getFlatFiles()` → слой закачки.
## Чек-лист приёмки
- [ ] дерево папок/архивов сворачивается/разворачивается;
- [ ] путь файла из архива виден (`Папка/архив.zip/…`);
- [ ] дубли исключены, коллизия имени → `_2`;
- [ ] `allowedExt` — параметр (фильтр и в zip, и в папке);
- [ ] сотни файлов не тормозят (ленивый рендер + делегирование);
- [ ] `getFlatFiles()` отдаёт только учитываемые файлы.
## Не делать (не оправдано)
- React / сборщик / jQuery-деревья (jsTree, Fancytree).
- Виртуализация `react-arborist` — только если появятся тысячи узлов и будет React.
+287
View File
@@ -0,0 +1,287 @@
# ПЛАН: вынос 2 слоёв загрузки-через-ВМ в переиспользуемый модуль
> Для Flash. Источник-референс: drhider v0.0.75 (`site/templates/index.html`,
> `site/routes/api_bp.py`, `site/session.py`). Цель — **1:1** по поведению, но в виде
> **переиспользуемого программного слоя ВНУТРИ приложения** (не отдельного сервиса):
> его встраивает и drhider, и любые другие проекты.
---
## Требования (согласовано с владельцем)
1. **2 слоя структурно выделены** — их можно взять и перенести в другой проект
**без изменения кода** (только свой конфиг).
2. **Без общей репы** — модуль = подпапка внутри проекта, перенос копированием.
3. **Не мудрить** — главное: агент в новом проекте за 2 минуты понял, как интегрировать
(для этого короткий `README.md` внутри модуля).
4. **НЕ поломать текущий рабочий код drhider** — это рефакторинг 1:1 (extract, не rewrite).
5. **Файлы мелкие** — каждая функция в отдельном файле.
6. **Vanilla JS, без React/сборщика**. ZIP раскрытие — на **`fflate`** (вместо ручного парсера):
проще и надёжнее (`unzip` + `filter` по расширению до распаковки). Дерево файлов
(сворачивание по папкам/архивам) — отдельный план `PLAN-file-picker.md`.
---
## 0. Контекст (что уже проверено в drhider)
Паттерн загрузки (шлюз managed-кластера рвёт тела >64КБ, egress не ограничен):
браузер → `PUT` на ВМ-буфер → Flask `POST /api/upload_refs` → egress `GET` (pull) → сессия.
Что НЕЛЬЗЯ потерять при переносе (накоплено и отлажено):
- дедуп «имя+размер», иначе суффикс `_2`;
- раскрытие ZIP с путями, фильтр расширений (`.pdf .doc .docx .txt .md`);
- рекурсивный обход папки (`webkitdirectory`), путь в таблице;
- **SSRF-валидация** URL (`url.startswith(VM_UPLOAD_PREFIX)`);
- **`_safe_name`** (path traversal, сохраняет подпапки);
- **ретраи pull** (3 попытки, пауза 2 с);
- лимиты 50 МБ/файл, 500 МБ/сессия;
- различение «сессия не найдена» vs «лимит»;
- статусы «пропущен (лимит)» / «не извлечён»;
- `esc()` для имён (self-XSS).
---
## 1. Граница слоёв
| Слой | Где | Ответственность | drhider-источник |
|---|---|---|---|
| **0. Конфиг** | общий | `VM_UPLOAD_URL`, `allowedExt`, лимиты | константы в `index.html`/`api_bp.py` |
| **1. Выбор файлов** | фронт | таблица, дедуп, раскрытие ZIP, путь, статусы, кнопки | `index.html` |
| **2. Закачка через ВМ** | фронт + бэк | PUT на ВМ (фронт) → `upload_refs` pull (бэк) → сессия | `index.html` + `api_bp.py` + `session.py` |
| **3. Логика приложения** | бэк | обфускация/обработка, SSE-прогресс | `drhider/*`, `process_stream` — **НЕ выносим** |
**Слой 3 НЕ выносим** — он у каждого приложения свой. В drhider остаётся: `drhider/*`
(extractor/scanner/replacer/builder/obfuscator), `process_stream`, `process`, `download`,
`csv_download`, `cancel`.
**Всё, что у drhider своё (URL, расширения, лимиты) — в конфиг (слой 0), а не в код слоя.**
---
## 2. Структура модуля (каждая функция — отдельный файл)
```
upload/
README.md # ← главное: как интегрировать (для агента)
config.example.json # слой 0: образец конфига
frontend/
zip/ # распаковка ZIP (чистые функции, без DOM) — на базе fflate
list_zip_files.js # listZipFiles(file, allowedExt) — fflate.unzip + filter + рекурсия
table/ # слой 1: выбор файлов
fs.js # fs(b) — формат размера
fmt_sec.js # fmtSec(s) — формат секунд
esc.js # esc(s) — экранирование HTML
est_for_file.js # estForFile(f) + EST_MB_SEC (из конфига)
add_file_with_dedup.js # addFileWithDedup(file)
render.js # rr() — обычная таблица
proc_row.js # procRow(i, stTxt)
render_proc_table.js # renderProcTable() — 3-секционная таблица
rm_file.js # rm(i) — удаление строки
set_status.js # ss(idx, h) — статус в ячейке
on_files_change.js # обработчик <input type=file> change
on_folder_change.js # обработчик webkitdirectory change
init_upload_table.js # initUploadTable(cfg) — сборка слоя 1 (состояние внутри)
upload/ # слой 2 (фронт): закачка через ВМ
put_to_vm.js # PUT одного файла (XHR, progress → setStatus)
upload_via_vm.js # uploadViaVM(files, vmUploadUrl) — цикл PUT + POST upload_refs
backend/
upload_refs/ # слой 2 (бэк): pull + валидация
__init__.py
config.py # PULL_RETRIES, PULL_RETRY_DELAY, VM_UPLOAD_PREFIX (из конфига)
safe_name.py # _safe_name(name)
pull_file.py # pull с ретраями (3×2с, stream, лимит 50МБ)
blueprint.py # create_upload_refs_blueprint(cfg) → Blueprint
session/
__init__.py
state.py # _sessions, _lock, _start_timer (общее состояние)
create_session.py # create_session()
add_file.py # add_file(sid, name, content)
get_files.py # get_files(sid), file_count(sid)
store_result.py # store_result(sid, zip), get_result(sid)
store_csv.py # store_csv(sid, csv), get_csv(sid)
ttl.py # touch, pause_ttl, resume_ttl
cancel.py # request_cancel, get_cancel_event
cleanup.py # cleanup(sid)
```
> Примечание по `backend/session/`: там **общее глобальное состояние** (`_sessions`, `_lock`).
> Поэтому `state.py` выделен отдельно, а каждая операция — свой файл, импортирующий `state`.
> Дробить `state.py` на файл-на-переменную не нужно — это данные, а не функции.
---
## 3. Маппинг: что откуда взять (v0.0.75, имена функций точные)
Переносится **без изменения логики**. Меняется только способ обращения:
глобальные переменные → параметры/состояние модуля.
### Фронт (`site/templates/index.html`)
| Функция в drhider | → файл модуля | Что меняется при переносе |
|---|---|---|
| `listZipFiles` (+ вложенные `parseZip`/`inflateRaw`/`decodeZipName`/`dosToMs`) | `zip/list_zip_files.js` | **заменяется на `fflate`** (`unzip` + `filter` по `allowedExt` + рекурсия по вложенным zip); ручной парсер НЕ переносим |
| `fs` | `table/fs.js` | ничего |
| `fmtSec` | `table/fmt_sec.js` | ничего |
| `esc` | `table/esc.js` | ничего |
| `estForFile` | `table/est_for_file.js` | `EST_MB_SEC` → конфиг |
| `addFileWithDedup` | `table/add_file_with_dedup.js` | `MAX_FILE_BYTES`/`MAX_SESSION_BYTES` → конфиг; `sf`/`fileMeta`/`overNames` → состояние модуля |
| `rr` | `table/render.js` | доступ к `sf`/`overNames` через состояние |
| `procRow` | `table/proc_row.js` | ничего (чистая отрисовка) |
| `renderProcTable` | `table/render_proc_table.js` | `procState`/`procExtractRate` → параметры/состояние |
| `rm` | `table/rm_file.js` | `busy`, `fi.files` → состояние/колбэк |
| `ss` | `table/set_status.js` | ничего |
| `fi` change handler | `table/on_files_change.js` | вызывается из `initUploadTable` |
| `folderInput` change handler | `table/on_folder_change.js` | вызывается из `initUploadTable` |
| PUT-цикл из `uploadFiles()` | `upload/put_to_vm.js` | `VM_UPLOAD_URL` → параметр; `ss` → колбэк прогресса |
| `uploadFiles()` фазы 1+1b | `upload/upload_via_vm.js` | **только** PUT + `POST upload_refs`; SSE-часть остаётся в drhider |
> **Граница слоя 2/3:** `uploadFiles()` сейчас смешанный — в нём и закачка (слой 2), и SSE (слой 3).
> При выносе разделить: `uploadViaVM(files, vmUploadUrl)` делает PUT + POST `/api/upload_refs`
> и возвращает `{ok, session, count}`; drhider после этого запускает **свою** SSE-обработку.
### Бэк (`site/routes/api_bp.py`, `site/session.py`)
| Сейчас | → модуль | Что меняется |
|---|---|---|
| `PULL_RETRIES`, `PULL_RETRY_DELAY`, `VM_UPLOAD_PREFIX` | `upload_refs/config.py` | читаются из конфига |
| `_safe_name` | `upload_refs/safe_name.py` | ничего |
| тело `upload_refs` (цикл pull) | `upload_refs/pull_file.py` | вынести ретраи/стрим/лимит в `pull_file(client, url)` |
| endpoint `upload_refs` | `upload_refs/blueprint.py` | `create_upload_refs_blueprint(cfg)` — Blueprint с префиксом из конфига |
| `session.py` целиком | `backend/session/*` | дробление на файл-на-функцию (1:1) |
`session.py` переносится **1:1** — это самодостаточный модуль, просто дробится на функции.
---
## 4. Интерфейсы (точные сигнатуры)
### Слой 1 — `initUploadTable(cfg) -> api`
```
cfg = {
allowedExt: ['.pdf','.doc','.docx','.txt','.md'],
maxFileBytes: 50*1024*1024,
maxSessionBytes: 500*1024*1024,
fileInputEl, folderInputEl, tableBodyEl, countEl, uploadBtnEl,
onFilesChanged(list) // колбэк
}
api = {
pickFiles(), pickFolder(), // привязка к <input>
addFiles(File[]), // дедуп+раскрытие zip+фильтр+лимиты
getFiles() -> [{name,size,file}], // ТОЛЬКО учитываемые (без over)
getOverNames() -> Set, // сверх лимита
setStatus(name, html), // статус в ячейке
render(), clear()
}
```
### Слой 2 — фронт `uploadViaVM(files, vmUploadUrl)`
```
uploadViaVM(files, vmUploadUrl) -> Promise<{ok, session, count, error?}>
1. token = crypto.randomUUID()
2. для каждого файла: PUT vmUploadUrl + token + '_' + k (XHR, progress -> setStatus)
refs.push({name, size, url})
3. POST /api/upload_refs {session, files: refs}
-> {ok, session, count} | {ok:false, error}
```
### Слой 2 — бэк `create_upload_refs_blueprint(cfg)`
```
POST /api/upload_refs {session?, files:[{name,size,url}]}
- name = _safe_name(name) (path traversal)
- url.startswith(VM_UPLOAD_PREFIX) (SSRF)
- size > 50МБ -> delete+skip
- pull: 3 ретрая, пауза 2с
- len(content) > 50МБ -> delete+skip
- add_file: нет сессии -> 404; лимит 500МБ -> skip
- delete url (best-effort)
-> {ok, session, count}
```
---
## 5. Конфиг (слой 0)
```json
{
"vmUploadUrl": "https://contracts.kube5s.ru/drhider-upload/",
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"],
"maxFileBytes": 52428800,
"maxSessionBytes": 524288000,
"apiPrefix": "/api",
"pullRetries": 3,
"pullRetryDelay": 2
}
```
Всё, что отличает drhider от другого проекта, задаётся **здесь**. Код слоёв не содержит
ни одного drhider-специфичного значения.
---
## 6. Порядок работы для Flash (по шагам, после каждого — проверка)
Строго последовательно, чтобы не сломать прод:
1. **`zip/*`** — подключить `fflate` (CDN) и написать `listZipFiles(file, allowedExt)` на
`fflate.unzip` с `filter` (не распаковывать не-doc) + рекурсия по вложенным zip.
Юнит-тест: кириллица, вложенный zip, не-doc расширения.
2. **`table/*`** (слой 1) — вынести функции таблицы/выбора, состояние `sf`/`fileMeta`/`overNames`
увести внутрь `init_upload_table.js`.
3. **`upload/*`** (слой 2 фронт) — PUT + `upload_refs`, параметризовать `vmUploadUrl`.
4. **`backend/upload_refs/*`** + **`backend/session/*`** — вынести `_safe_name`, pull, Blueprint, сессию.
5. **Подключить модуль обратно в drhider** — заменить встроенный код вызовами модуля,
**по одному куску**, прогоняя тесты после каждого. Слой 3 не трогать.
6. **`README.md`** модуля — короткая инструкция для агента нового проекта (раздел 8).
---
## 7. Обязательный чек-лист приёмки (без этого «1:1» не достичь)
- [ ] дедуп имя+размер, суффикс `_2` при другом размере;
- [ ] рекурсивный обход папки, путь (в т.ч. из архива) в таблице;
- [ ] раскрытие ZIP только документов, вложенные ZIP;
- [ ] SSRF-валидация URL, `_safe_name`, ретраи pull — на месте;
- [ ] лимиты 50/500 МБ, статусы «пропущен (лимит)» и «не извлечён»;
- [ ] `esc()` имён (нет XSS);
- [ ] кнопка загрузки disabled при 0 файлов;
- [ ] заморозка UI во время загрузки/обработки;
- [ ] прогресс загрузки (PUT) в таблице;
- [ ] поведение drhider на проде не изменилось.
---
## 8. Что писать в `README.md` модуля (главное для агента нового проекта)
1. **Что это** — 2 слоя и их ответственность (таблицей из раздела 1).
2. **Как подключить фронт** — подключить `zip/*`, `table/*`, `upload/*` как ES-модули
(`<script type="module">`), вызвать `initUploadTable(cfg)` и `uploadViaVM(...)`.
3. **Как подключить бэк** — зарегистрировать `create_upload_refs_blueprint(cfg)` в приложении,
импортировать `session` (create/add/get).
4. **Какой конфиг задать** — один блок `config.example.json` с пояснением каждого поля.
5. **Что НЕ трогать** — слой 3 (логика приложения у каждого своя).
6. **Дерево файлов** (сворачивание по папкам/архивам) — см. `PLAN-file-picker.md` (слой 1, vanilla + fflate).
---
## 9. Что НЕ менять в drhider (риск регресса)
- `drhider/*` (extractor/scanner/replacer/builder/obfuscator) — слой 3, не трогать.
- `process_stream` (SSE), `process`, `download`, `csv_download`, `cancel` — слой 3.
- SSE-часть `uploadFiles()`, `downloadZip()`, `downloadCsv()`, `resetAll()`, `setBusy()`,
`finishProcUI()` — приложение drhider.
- Рефакторить drhider под модуль — только после того, как модуль заработает на новом сервисе.
---
## 10. Открытое решение (спросить у владельца)
- **Куда класть модуль в новом проекте**: папка `upload/` внутри проекта (согласовано —
без общей репы). CORS на ВМ уже настроен под существующий origin — менять только если
реально другой домен (это правка nginx, не кода слоя).
+43 -1
View File
@@ -112,12 +112,24 @@ git add -A && git commit -m "подробное описание" && git push
| Переменная | Значение |
|---|---|
| `LLM_API_KEY` | `sk-ucI5YvOticoOQ9Kuj5K9mQ` |
| `LLM_API_KEY` | (секрет — задаётся в переменных окружения Штурвала, не в git) |
| `LLM_URL` | `https://api.aillm.ru/v1/chat/completions` |
| `LLM_MODEL` | `gpt-oss-120b` |
---
## Известные ограничения / риски
- **LLM prompt injection**: текст документа вставляется в LLM-промпт дословно; вредоносный
документ может содержать инструкции, подавляющие обнаружение персональных данных.
Итог — неполная обфускация без явной ошибки. Известный класс риска (не фикс кода),
обнаружен при код-ревью (2026-08-24, `History/sonnet/2026-08-24-code-review-sonnet.md`).
- **ZIP без документов**: фронт добавляет такой ZIP в список как есть (v0.0.69), бэк
раскрывает из него только документы (фильтр расширений, v0.0.75); файлы без документов
в результат не попадают.
---
## Связь с ВМ contracts (legacy)
На ВМ `5.172.178.213` (contracts.kube5s.ru) есть старая версия drhider:
@@ -127,3 +139,33 @@ git add -A && git commit -m "подробное описание" && git push
Этот проект (`drhider.pythonk8s.dev.nubes.ru`) — **независимый managed Flask**, заменяет ВМ-версию.
SSH к ВМ: `ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213`
## Синхронизация проекта на ВМ
Текущий checkout проекта на ВМ находится в `/home/naeel/drhider`. Синхронизировать
проект нужно через `rsync` из локального репозитория:
```bash
cd /home/naeel/nubes/drhider
rsync -av \
--exclude='.git/' \
--exclude='__pycache__/' \
--exclude='*.pyc' \
--exclude='.venv/' \
--exclude='.pytest_cache/' \
-e 'ssh -o ConnectTimeout=10 -o BatchMode=yes -i ~/.ssh/naeel_vm_id_ed25519' \
./ naeel@5.172.178.213:/home/naeel/drhider/
```
Проверка после синхронизации:
```bash
ssh -o ConnectTimeout=10 -o BatchMode=yes \
-i ~/.ssh/naeel_vm_id_ed25519 \
naeel@5.172.178.213 \
'cd /home/naeel/drhider && grep -m1 "^VERSION =" site/app.py && \
python3 -m py_compile site/app.py site/routes/api_bp.py'
```
`/home/naeel/contracts/drhider` — legacy standalone-сервис; его синхронизировать
нельзя. `rsync` выполняется без `--delete`, поэтому файлы на ВМ не удаляются.
+1194
View File
File diff suppressed because it is too large Load Diff
+1218
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -29,7 +29,7 @@ git push origin master
| Переменная | Значение | Для чего |
|---|---|---|
| `LLM_API_KEY` | `sk-ucI5YvOticoOQ9Kuj5K9mQ` | Ключ доступа к LLM API |
| `LLM_API_KEY` | (секрет — задаётся в переменных окружения Штурвала, не в git) | Ключ доступа к LLM API |
| `LLM_URL` | `https://api.aillm.ru/v1/chat/completions` | URL LLM API |
| `LLM_MODEL` | `gpt-oss-120b` | Модель для NER |
+4
View File
@@ -336,6 +336,10 @@ def expand_zips(files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, s
name = os.path.basename(name)
if not name:
continue
# Синхронизация с фронтом (listZipFiles): из zip берём ТОЛЬКО документы
low = name.lower()
if not (low.endswith('.zip') or low.endswith(('.pdf', '.doc', '.docx', '.txt', '.md'))):
continue
inner_data = zf.read(info)
total_uncompressed += len(inner_data)
+8 -2
View File
@@ -140,7 +140,13 @@ class TwoPassObfuscator:
# Без параллельности: liberta не выдерживает конкурентные вызовы
# (libreoffice падает с javaldx).
cancelled_extract = False
for i, (fname, content, ctype) in enumerate(files):
# Мягкая остановка в фазе извлечения (между файлами) — не ждём все .doc
if cancel_event is not None and cancel_event.is_set():
log.info("obfuscate: cancelled during extract, processed=%d/%d", i, total)
cancelled_extract = True
break
display_name = os.path.basename(fname) or fname
if progress_cb:
progress_cb("start", i, total, display_name, 0.0)
@@ -168,8 +174,8 @@ class TwoPassObfuscator:
per_file={fname: len(t) for fname, t in all_texts.items()})
# LLM-сканирование (получает уже найденное regex'ом чтобы не дублировать)
cancelled_llm = False
if self._llm_client:
cancelled_llm = cancelled_extract
if not cancelled_llm and self._llm_client:
try:
scanner.scan_llm_ner(
all_texts, self._mapping, self._llm_client, self._counters,
+12
View File
@@ -0,0 +1,12 @@
в другом приложении попросил использовать загрузку через ВМ как тут в хайдере
вышло не так как я хотел а именно чтобы 1:1
давай спланируем сначала
думаю что надо 3 независимых слоя
1. фронт: таблица кнопки подсказки и тд - загрузка путей файлов в таблицу - просто выбором файлов, список файлов из архива, ВСЕ файлы из выбранной папки
ограничение по расширениям, рекурсивный обход всех подпапок, файлы не дублировать если одинаковые имя/размер, если размер иной - добавлять суффикс, в таблице виден путь файла - в тч когда из архива
2. собственно закачка файлов - с отображением процесса закачки в таблице
3. логика собственно приложения
+1 -1
View File
@@ -21,7 +21,7 @@ if _sys_path_root not in sys.path:
sys.path.insert(0, _sys_path_root)
# Версия приложения (меняется при изменениях)
VERSION = "0.0.73"
VERSION = "0.0.78"
def setup_logging():
+11
View File
@@ -1,6 +1,7 @@
from .main_bp import main_bp
from .health_bp import health_bp
from .api_bp import api_bp
from upload.backend.upload_refs import create_upload_refs_blueprint
def register_routes(app):
@@ -12,3 +13,13 @@ def register_routes(app):
app.register_blueprint(main_bp)
app.register_blueprint(health_bp)
app.register_blueprint(api_bp)
# Слой 2 (закачка через ВМ) — переиспользуемый blueprint из модуля upload
app.register_blueprint(create_upload_refs_blueprint({
"apiPrefix": "/api",
"vmUploadPrefix": "https://contracts.kube5s.ru/drhider-upload/",
"maxFileBytes": 50 * 1024 * 1024,
"maxSessionBytes": 500 * 1024 * 1024,
"ttlSeconds": 30 * 60,
"pullRetries": 3,
"pullRetryDelay": 2,
}))
+6 -114
View File
@@ -17,42 +17,19 @@ import time
import zipfile
import traceback
import logging
import httpx
from datetime import datetime, timedelta
from flask import Blueprint, request, send_file, jsonify, Response, stream_with_context
from drhider import obfuscate_files, LLMClient
from session import (create_session, add_file, get_files, store_result,
get_result, store_csv, get_csv, cleanup, file_count,
MAX_FILE_BYTES, pause_ttl, resume_ttl,
request_cancel, get_cancel_event)
from upload.backend.session import (create_session, add_file, get_files, store_result,
get_result, store_csv, get_csv, cleanup, file_count,
MAX_FILE_BYTES, pause_ttl, resume_ttl,
request_cancel, get_cancel_event)
from upload.backend.upload_refs import safe_name
api_bp = Blueprint("api", __name__, url_prefix="/api")
log = logging.getLogger("routes.api_bp")
# Ретраи pull из ВМ-буфера: защита от разовых DNS/сетевых сбоев (gaierror -5 и т.п.)
PULL_RETRIES = 3
PULL_RETRY_DELAY = 2 # секунды между попытками
# Доверенный префикс ВМ-буфера — валидация URL при pull (защита от SSRF)
VM_UPLOAD_PREFIX = "https://contracts.kube5s.ru/drhider-upload/"
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)
def _disconnect_exceptions():
"""Исключения, означающие отключение клиента SSE."""
return (GeneratorExit, BrokenPipeError, ConnectionResetError)
@@ -72,7 +49,7 @@ def upload():
added = 0
had_unnamed = False
for f in uploaded:
name = _safe_name(f.filename)
name = safe_name(f.filename)
if not name:
had_unnamed = True
continue
@@ -96,91 +73,6 @@ def upload():
return jsonify({"ok": True, "session": sid, "count": file_count(sid)})
@api_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
added = 0
try:
with httpx.Client(timeout=120, 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_UPLOAD_PREFIX):
log.warning("upload_refs: unsafe URL, skip sid=%s url=%r", sid, url)
continue
# Лимит на один файл (50 МБ): сверх лимита — пропускаем (не участвует)
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
continue
# Pull с ретраями: разовые DNS/сетевые сбои не роняют всю загрузку
content = None
last_err = None
for attempt in range(PULL_RETRIES):
try:
with client.stream("GET", url) as resp:
resp.raise_for_status()
content = b"".join(resp.iter_bytes())
last_err = None
break
except Exception as e:
last_err = e
log.warning("upload_refs: pull attempt %d/%d failed sid=%s file=%r: %r",
attempt + 1, PULL_RETRIES, sid, name, e)
time.sleep(PULL_RETRY_DELAY)
if content is None:
raise last_err if last_err else RuntimeError("pull failed")
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
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
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)})
@api_bp.route("/session_files/<sid>", methods=["GET"])
def session_files(sid):
"""Отладка: список файлов сессии с размерами (для теста загрузки)."""
+26 -1
View File
@@ -4,12 +4,19 @@ Blueprint: главная страница (GET /).
Отдаёт HTML-интерфейс DrHider.
"""
from flask import Blueprint, render_template, current_app
import os
from flask import Blueprint, render_template, current_app, send_from_directory
# ═══════════════════════════════════════════════════════════════════════════
# Blueprint: главная страница
# ═══════════════════════════════════════════════════════════════════════════
# Корень модуля upload/frontend — для раздачи ES-модулей браузеру
_ROOT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_UPLOAD_FRONTEND_DIR = os.path.join(_ROOT_DIR, "upload", "frontend")
_DIST_DIR = os.path.join(_ROOT_DIR, "dist")
main_bp = Blueprint("main", __name__)
@@ -21,3 +28,21 @@ def index():
"""
version = current_app.config.get("VERSION", "0.0.0")
return render_template("index.html", version=version)
@main_bp.route("/upload/<path:filename>")
def upload_frontend(filename):
"""Раздаёт ES-модули переиспользуемого слоя загрузки (upload/frontend)."""
return send_from_directory(_UPLOAD_FRONTEND_DIR, filename)
@main_bp.route("/file-picker/<path:filename>")
def file_picker_dist(filename):
"""Отдаёт собранный бандл из каталога dist."""
return send_from_directory(_DIST_DIR, filename)
@main_bp.route("/dist/<path:filename>")
def dist_files(filename):
"""Отдаёт файлы из каталога dist."""
return send_from_directory(_DIST_DIR, filename)
-230
View File
@@ -1,230 +0,0 @@
"""
Хранилище сессий в памяти с автоочисткой по TTL.
Использование:
sid = create_session()
add_file(sid, filename, content_bytes)
files = get_files(sid)
store_result(sid, zip_bytes)
zip_data = get_result(sid)
cleanup(sid)
"""
import uuid
import threading
import time
from typing import Dict, List, Tuple, Optional
# ═══════════════════════════════════════════════════════════════════════════
# Данные сессии
# ═══════════════════════════════════════════════════════════════════════════
TTL_SECONDS = 30 * 60 # 30 минут
# Максимальный объём одного файла и суммарный объём файлов в сессии (защита памяти)
MAX_FILE_BYTES = 50 * 1024 * 1024 # 50 MB на один файл
MAX_SESSION_BYTES = 500 * 1024 * 1024 # 500 MB суммарно на сессию
_sessions: Dict[str, dict] = {}
_lock = threading.Lock()
def _start_timer(sid: str):
"""Запустить таймер автоочистки сессии через TTL."""
def _clean():
with _lock:
_sessions.pop(sid, None)
timer = threading.Timer(TTL_SECONDS, _clean)
timer.daemon = True
timer.start()
return 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)
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
# ═══════════════════════════════════════════════════════════════════════════
# API
# ═══════════════════════════════════════════════════════════════════════════
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
def add_file(sid: str, filename: str, content: bytes) -> bool:
"""Добавить файл в сессию.
Args:
sid: Идентификатор сессии
filename: Имя файла
content: Бинарное содержимое
Returns:
True если сессия существует, False если нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
total = sum(len(c) for _, c in s["files"])
if total + len(content) > MAX_SESSION_BYTES:
return False # превышен суммарный лимит сессии
s["files"].append((filename, content))
return True
def get_files(sid: str) -> Optional[List[Tuple[str, bytes]]]:
"""Получить все файлы сессии.
Args:
sid: Идентификатор сессии
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
def store_result(sid: str, zip_data: bytes) -> bool:
"""Сохранить результат обработки.
Args:
sid: Идентификатор сессии
zip_data: 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]:
"""Получить результат обработки.
Args:
sid: Идентификатор сессии
Returns:
ZIP-архив или None если сессия не найдена/результат не готов.
"""
with _lock:
s = _sessions.get(sid)
return s["result"] if s else None
def cleanup(sid: str):
"""Удалить сессию."""
with _lock:
s = _sessions.pop(sid, None)
if s and s.get("timer"):
s["timer"].cancel()
def store_csv(sid: str, csv_str: str) -> bool:
"""Сохранить CSV с таблицей замен.
Args:
sid: Идентификатор сессии
csv_str: Строка 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 с таблицей замен.
Args:
sid: Идентификатор сессии
Returns:
Строка CSV или None если нет.
"""
with _lock:
s = _sessions.get(sid)
return s.get("csv") if s else None
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+870 -938
View File
File diff suppressed because it is too large Load Diff
+116
View File
@@ -0,0 +1,116 @@
import threading
from time import monotonic
import httpx
from flask import Flask
from upload.backend.session import add_file, cleanup, configure, create_session, get_files
from upload.backend.session import state as session_state
from upload.backend.upload_refs.blueprint import create_upload_refs_blueprint
def build_app(transport, **config):
app = Flask(__name__)
app.config['TESTING'] = True
app.config['UPLOAD_HTTPX_TRANSPORT'] = transport
app.register_blueprint(create_upload_refs_blueprint({
'vmUploadPrefix': 'https://vm.test/buffer/',
'httpxTransport': transport,
'pullRetries': 3,
'pullRetryDelay': 0,
**config,
}))
return app
def test_session_limit_accepts_exact_boundary_and_rejects_overflow():
sid = create_session()
previous = session_state.MAX_SESSION_BYTES
configure(max_session_bytes=10)
try:
assert add_file(sid, 'exact.bin', b'x' * 10) is True
assert add_file(sid, 'overflow.bin', b'x') is False
assert len(get_files(sid)) == 1
finally:
configure(max_session_bytes=previous)
cleanup(sid)
def test_upload_refs_rejects_actual_oversize_and_deletes_buffer():
storage = {'oversize': b'123456789'}
deleted = []
def handler(request):
if request.method == 'GET':
return httpx.Response(200, content=storage['oversize'])
if request.method == 'DELETE':
deleted.append(request.url.path)
storage.clear()
return httpx.Response(204)
return httpx.Response(405)
transport = httpx.MockTransport(handler)
app = build_app(transport, maxFileBytes=8)
sid = create_session()
try:
with app.test_client() as client:
response = client.post('/api/upload_refs', json={
'session': sid,
'files': [{
'name': 'oversize.txt',
'size': 8,
'url': 'https://vm.test/buffer/oversize',
}],
})
assert response.status_code == 200
assert response.get_json()['added'] == 0
assert get_files(sid) == []
assert deleted == ['/buffer/oversize']
assert storage == {}
finally:
cleanup(sid)
def test_upload_refs_retries_transient_pull_and_deletes_after_success():
attempts = []
deleted = []
def handler(request):
if request.method == 'GET':
attempts.append(request.url.path)
if len(attempts) == 1:
return httpx.Response(503)
return httpx.Response(200, content=b'retry-content')
if request.method == 'DELETE':
deleted.append(request.url.path)
return httpx.Response(204)
return httpx.Response(405)
transport = httpx.MockTransport(handler)
app = build_app(transport)
with app.test_client() as client:
response = client.post('/api/upload_refs', json={'files': [{
'name': 'retry.txt',
'size': 13,
'url': 'https://vm.test/buffer/retry',
}]})
assert response.status_code == 200
data = response.get_json()
assert data['added'] == 1
assert len(attempts) == 2
assert deleted == ['/buffer/retry']
cleanup(data['session'])
def test_session_ttl_removes_session():
previous = session_state.TTL_SECONDS
configure(ttl_seconds=0.05)
sid = create_session()
try:
deadline = monotonic() + 1
while monotonic() < deadline and get_files(sid) is not None:
threading.Event().wait(0.01)
assert get_files(sid) is None
finally:
configure(ttl_seconds=previous)
cleanup(sid)
+17
View File
@@ -0,0 +1,17 @@
import pytest
from upload.backend.upload_refs.safe_name import safe_name
def test_safe_name_simple():
assert safe_name("test.txt") == "test.txt"
assert safe_name("folder/subfolder/file.pdf") == "folder/subfolder/file.pdf"
assert safe_name("folder\\subfolder\\file.pdf") == "folder/subfolder/file.pdf"
def test_safe_name_traversal():
assert safe_name("../etc/passwd") == ""
assert safe_name("folder/../../etc/passwd") == ""
assert safe_name("/root/file.txt") == "root/file.txt"
assert safe_name("..") == ""
assert safe_name("") == ""
assert safe_name(None) == ""
+1 -1
View File
@@ -75,7 +75,7 @@ def test_single_file():
assert rv.status_code == 200
resp = json.loads(rv.data)
assert resp["ok"] is True
assert len(resp["session"]) == 12
assert len(resp["session"]) == 32
assert resp["count"] == 1
+345
View File
@@ -0,0 +1,345 @@
"""Тесты переиспользуемого модуля upload (backend): session, safe_name, blueprint.
Запуск:
python3 tests/test_upload_module.py
pytest tests/test_upload_module.py
"""
import os
import sys
import time
# Корень проекта — для импорта пакета upload
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from flask import Flask
from upload.backend.session import (
create_session, add_file, get_files, file_count,
store_result, get_result, store_csv, get_csv,
cleanup, request_cancel, get_cancel_event,
)
from upload.backend.upload_refs import safe_name, create_upload_refs_blueprint
VM_PREFIX = "https://contracts.kube5s.ru/drhider-upload/"
# ═══════════════════════════════════════════════════════════════════════════
# session
# ═══════════════════════════════════════════════════════════════════════════
def test_create_session():
sid = create_session()
assert len(sid) == 32 # uuid4().hex
def test_add_file_and_get():
sid = create_session()
assert add_file(sid, "a.txt", b"hello") is True
assert get_files(sid) == [("a.txt", b"hello")]
assert file_count(sid) == 1
def test_add_file_missing_session():
assert add_file("nonexistent", "a.txt", b"x") is False
assert get_files("nonexistent") is None
def test_session_limit():
from upload.backend.session import configure
configure(max_session_bytes=10)
try:
sid = create_session()
assert add_file(sid, "a", b"12345") is True
# суммарно уже 5; +6 = 11 > 10 → False
assert add_file(sid, "b", b"123456") is False
assert file_count(sid) == 1
finally:
configure(max_session_bytes=500 * 1024 * 1024)
def test_store_result_and_csv():
sid = create_session()
assert store_result(sid, b"zip") is True
assert get_result(sid) == b"zip"
assert store_csv(sid, "a,b") is True
assert get_csv(sid) == "a,b"
def test_cleanup():
sid = create_session()
cleanup(sid)
assert get_files(sid) is None
def test_cancel():
sid = create_session()
assert request_cancel(sid) is True
ev = get_cancel_event(sid)
assert ev is not None and ev.is_set()
assert request_cancel("nonexistent") is False
assert get_cancel_event("nonexistent") is None
# ═══════════════════════════════════════════════════════════════════════════
# safe_name
# ═══════════════════════════════════════════════════════════════════════════
def test_safe_name():
assert safe_name("a.txt") == "a.txt"
assert safe_name("dir/sub/a.txt") == "dir/sub/a.txt"
assert safe_name("dir\\sub\\a.txt") == "dir/sub/a.txt"
assert safe_name("../etc/passwd") == ""
assert safe_name("a/../../b") == ""
assert safe_name("") == ""
assert safe_name("./a.txt") == "a.txt"
assert safe_name("/etc/passwd") == "etc/passwd"
# ═══════════════════════════════════════════════════════════════════════════
# blueprint upload_refs
# ═══════════════════════════════════════════════════════════════════════════
class _FakeStream:
def __init__(self, payload=b"", status=200, exc=None):
self._payload = payload
self._status = status
self._exc = exc
def __enter__(self):
if self._exc:
raise self._exc
return self
def __exit__(self, *a):
return False
def raise_for_status(self):
if self._status >= 400:
raise RuntimeError("HTTP %d" % self._status)
def iter_bytes(self):
yield self._payload
class _FakeClient:
"""Имитация httpx.Client: stream/delete без сети.
behavior: dict url -> "ok" | {"payload": bytes} | {"exc": Exception}
"""
def __init__(self, behavior):
self._behavior = behavior
self.deleted = []
self.streams = []
def __enter__(self):
return self
def __exit__(self, *a):
return False
def stream(self, method, url):
self.streams.append((method, url))
b = self._behavior.get(url)
if isinstance(b, dict) and "payload" in b:
return _FakeStream(payload=b["payload"])
if isinstance(b, dict) and "exc" in b:
return _FakeStream(exc=b["exc"])
if b == "ok":
return _FakeStream()
return _FakeStream(status=500)
def delete(self, url):
self.deleted.append(url)
def _make_client(behavior, cfg=None, monkeypatch=None):
import upload.backend.upload_refs.blueprint as bp_mod
monkeypatch.setattr(bp_mod.httpx, "Client", lambda **kw: _FakeClient(behavior))
app = Flask(__name__)
app.register_blueprint(create_upload_refs_blueprint(cfg or {}))
return app.test_client()
def test_upload_refs_no_files(monkeypatch):
c = _make_client({}, monkeypatch=monkeypatch)
rv = c.post("/api/upload_refs", json={"session": "", "files": []})
assert rv.status_code == 400
def test_upload_refs_ok(monkeypatch):
url = VM_PREFIX + "tok_0"
c = _make_client({url: {"payload": b"hello"}}, monkeypatch=monkeypatch)
rv = c.post("/api/upload_refs", json={
"session": "",
"files": [{"name": "a.txt", "size": 5, "url": url}],
})
assert rv.status_code == 200
data = rv.get_json()
assert data["ok"] is True
assert data["count"] == 1
files = get_files(data["session"])
assert files == [("a.txt", b"hello")]
def test_upload_refs_ssrf(monkeypatch):
c = _make_client({}, monkeypatch=monkeypatch)
rv = c.post("/api/upload_refs", json={
"session": "",
"files": [{"name": "evil.txt", "size": 5, "url": "https://evil.example/x"}],
})
assert rv.status_code == 200
data = rv.get_json()
assert data["ok"] is True
assert data["count"] == 0 # unsafe URL пропущен
def test_upload_refs_too_large(monkeypatch):
url = VM_PREFIX + "tok_0"
c = _make_client({}, monkeypatch=monkeypatch)
rv = c.post("/api/upload_refs", json={
"session": "",
"files": [{"name": "big.bin", "size": 60 * 1024 * 1024, "url": url}],
})
assert rv.status_code == 200
assert rv.get_json()["count"] == 0 # сверх 50МБ → skip+delete
def test_upload_refs_session_not_found(monkeypatch):
url = VM_PREFIX + "tok_0"
c = _make_client({url: {"payload": b"x"}}, monkeypatch=monkeypatch)
rv = c.post("/api/upload_refs", json={
"session": "no-such-session",
"files": [{"name": "a.txt", "size": 1, "url": url}],
})
assert rv.status_code == 404
assert rv.get_json()["error"] == "Session not found"
def test_upload_refs_pull_failed(monkeypatch):
url = VM_PREFIX + "tok_0"
behavior = {url: {"exc": RuntimeError("dns fail")}}
c = _make_client(behavior, cfg={"pullRetryDelay": 0}, monkeypatch=monkeypatch)
rv = c.post("/api/upload_refs", json={
"session": "",
"files": [{"name": "a.txt", "size": 1, "url": url}],
})
assert rv.status_code == 502 # все ретраи провалились
def test_session_many_files():
"""Нагрузка: 1000 файлов в одной сессии."""
sid = create_session()
for i in range(1000):
assert add_file(sid, "f%d.txt" % i, b"x" * 10) is True
assert file_count(sid) == 1000
assert len(get_files(sid)) == 1000
def test_concurrent_add_files():
"""Конкурентность: 4 потока добавляют файлы в общую сессию."""
import threading
sid = create_session()
def worker(prefix):
for i in range(50):
add_file(sid, "%s_%d.txt" % (prefix, i), b"data")
threads = [threading.Thread(target=worker, args=("t%d" % t,)) for t in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
assert file_count(sid) == 200
def test_session_ttl_cleanup():
"""TTL: короткий TTL → сессия очищается фоновым таймером (долгий тест)."""
from upload.backend.session import configure
configure(ttl_seconds=1)
try:
sid = create_session()
assert add_file(sid, "a.txt", b"x") is True
time.sleep(1.5)
assert get_files(sid) is None # таймер удалил сессию
finally:
configure(ttl_seconds=30 * 60)
def test_add_file_session_limit_boundary():
"""Суммарный лимит: граница ровно на MAX_SESSION_BYTES."""
from upload.backend.session import configure
configure(max_session_bytes=1000)
try:
sid = create_session()
assert add_file(sid, "a", b"x" * 500) is True
assert add_file(sid, "b", b"x" * 500) is True # ровно 1000
assert add_file(sid, "c", b"x") is False # 1001 > 1000
assert file_count(sid) == 2
finally:
configure(max_session_bytes=500 * 1024 * 1024)
def test_safe_name_edge_cases():
"""safe_name: граничные случаи."""
assert safe_name("...") == "..."
assert safe_name("a/../b") == ""
assert safe_name("a//b") == "a/b"
assert safe_name(" ") == " "
assert safe_name("договор/файл.pdf") == "договор/файл.pdf"
assert safe_name("C:\\path\\file") == "C:/path/file"
assert safe_name("/abs/path") == "abs/path"
def test_upload_refs_many_files(monkeypatch):
"""Нагрузка: 100 refs → все pull OK, count=100."""
behavior = {}
files = []
for i in range(100):
url = VM_PREFIX + "tok_%d" % i
behavior[url] = {"payload": b"x" * 10}
files.append({"name": "f%d.txt" % i, "size": 10, "url": url})
c = _make_client(behavior, monkeypatch=monkeypatch)
rv = c.post("/api/upload_refs", json={"session": "", "files": files})
assert rv.status_code == 200
assert rv.get_json()["count"] == 100
# ═══════════════════════════════════════════════════════════════════════════
# main (запуск без pytest)
# ═══════════════════════════════════════════════════════════════════════════
def main():
"""Прогнать все test_* функции без pytest."""
import inspect
import traceback
fns = [(k, v) for k, v in sorted(globals().items()) if k.startswith("test_")]
mp = _FakeMonkeyPatch()
failed = 0
for name, fn in fns:
try:
if "monkeypatch" in inspect.signature(fn).parameters:
fn(monkeypatch=mp)
else:
fn()
print("PASS %s" % name)
except Exception:
failed += 1
print("FAIL %s" % name)
traceback.print_exc()
if failed:
print("%d тестов упало" % failed)
sys.exit(1)
print("Все тесты прошли")
class _FakeMonkeyPatch:
"""Мини-monkeypatch: setattr, работает до конца вызова (без undo)."""
def setattr(self, target, name, value):
setattr(target, name, value)
if __name__ == "__main__":
main()
+1
Submodule upload-platform added at 1750a8497f
+31
View File
@@ -0,0 +1,31 @@
# Upload module (v0.2.0)
Переиспользуемый модуль загрузки, состоящий из двух независимых слоёв:
1. **Слой 1 (`upload/frontend/`)** — File Picker:
- IIFE: `FilePicker.initFilePicker(config)`;
- ESM: `import { initFilePicker } from './file-picker.esm.js'` (или из `upload/frontend/index.js`).
- Получение выбранных файлов: `picker.getFiles()` возвращает плоский массив `{ path, name, size, file }`.
- Поддерживает выбор файлов, папок, клиентскую распаковку ZIP (fflate) и проверку лимитов.
2. **Слой 2 (`upload/frontend/upload/` + `upload/backend/`)** — Пофайловый транзит в RAM сессии:
- Фронтенд: `uploadViaVM(files, options)` или `FilePicker.uploadViaVM(files, options)`:
- Пофайловый стриминг: для каждого файла выполняется `PUT` в буфер на ВМ, немедленный `POST /api/upload_refs` в бэкенд, и после подтверждения приёма буфер на ВМ очищается.
- Поддержка `AbortSignal` для отмены загрузки на любом этапе.
- Бэкенд: `create_upload_refs_blueprint(cfg)`:
- Приём ссылок через исходящий потоковый `GET` в память RAM.
- SSRF-валидация префикса буфера (`vmUploadPrefix`).
- Защита от path traversal (`safe_name`).
- Хранилище сессий в оперативной памяти (`upload/backend/session`).
- Никакой записи на диск.
## Интеграция в сторонний проект
1. Скопировать каталог `upload/` в проект.
2. Во Flask подключить Blueprint:
```python
from upload.backend.upload_refs import create_upload_refs_blueprint
app.register_blueprint(create_upload_refs_blueprint({"vmUploadPrefix": "..."}))
```
3. На фронтенде подключить `file-picker.iife.js` или импортировать ESM.
4. Вызвать `initFilePicker`, а при отправке вызвать `uploadViaVM(picker.getFiles(), options)`.
+5
View File
@@ -0,0 +1,5 @@
"""Переиспользуемые слои загрузки файлов.
Пакет не выполняет импортов и не запускает побочных действий при загрузке.
Рабочая picker-only логика находится в подпакетах ``frontend`` и ``zip``.
"""
+1
View File
@@ -0,0 +1 @@
"""Backend переиспользуемого модуля загрузки: upload_refs + session."""
+38
View File
@@ -0,0 +1,38 @@
"""In-memory хранилище сессий с TTL и лимитами.
Каждая операция — в отдельном файле, общее состояние — в state.py.
Импорт как единый пакет:
from upload.backend.session import create_session, add_file, get_files
"""
from .create_session import create_session
from .add_file import add_file
from .get_files import get_files, file_count
from .store_result import store_result, get_result
from .store_csv import store_csv, get_csv
from .ttl import touch, pause_ttl, resume_ttl
from .cancel import request_cancel, get_cancel_event
from .cleanup import cleanup
from .state import TTL_SECONDS, MAX_FILE_BYTES, MAX_SESSION_BYTES, configure
__all__ = [
"create_session",
"add_file",
"get_files",
"file_count",
"store_result",
"get_result",
"store_csv",
"get_csv",
"touch",
"pause_ttl",
"resume_ttl",
"request_cancel",
"get_cancel_event",
"cleanup",
"configure",
"TTL_SECONDS",
"MAX_FILE_BYTES",
"MAX_SESSION_BYTES",
]
+25
View File
@@ -0,0 +1,25 @@
"""add_file — добавить файл в сессию (с проверкой суммарного лимита)."""
from . import state
def add_file(sid: str, filename: str, content: bytes) -> bool:
"""Добавить файл в сессию.
Args:
sid: Идентификатор сессии
filename: Имя файла
content: Бинарное содержимое
Returns:
True если добавлено; False если сессии нет или превышен лимит сессии.
"""
with state._lock:
s = state._sessions.get(sid)
if not s:
return False
total = sum(len(c) for _, c in s["files"])
if total + len(content) > state.MAX_SESSION_BYTES:
return False # превышен суммарный лимит сессии
s["files"].append((filename, content))
return True
+27
View File
@@ -0,0 +1,27 @@
"""request_cancel / get_cancel_event — мягкое прерывание обработки сессии."""
import threading
from typing import Optional
from .state import _sessions, _lock
def request_cancel(sid: str) -> bool:
"""Запросить мягкое прерывание обработки сессии.
Returns:
True если сессия существует и отмена запрошена, False если нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["cancel"].set()
return True
def get_cancel_event(sid: str) -> Optional[threading.Event]:
"""Получить событие отмены сессии (или None, если сессии нет)."""
with _lock:
s = _sessions.get(sid)
return s["cancel"] if s else None
+11
View File
@@ -0,0 +1,11 @@
"""cleanup — удалить сессию."""
from .state import _sessions, _lock
def cleanup(sid: str):
"""Удалить сессию и остановить её TTL-таймер."""
with _lock:
s = _sessions.pop(sid, None)
if s and s.get("timer"):
s["timer"].cancel()
+23
View File
@@ -0,0 +1,23 @@
"""create_session — создать новую сессию."""
import threading
import uuid
from .state import _sessions, _lock, _start_timer
def create_session() -> str:
"""Создать новую сессию.
Returns:
Уникальный идентификатор сессии (UUID).
"""
sid = uuid.uuid4().hex
with _lock:
_sessions[sid] = {
"files": [],
"result": None,
"cancel": threading.Event(),
"timer": _start_timer(sid),
}
return sid
+23
View File
@@ -0,0 +1,23 @@
"""get_files / file_count — чтение файлов сессии."""
from typing import List, Optional, Tuple
from .state import _sessions, _lock
def get_files(sid: str) -> Optional[List[Tuple[str, bytes]]]:
"""Получить все файлы сессии.
Returns:
[(filename, content), ...] или None если сессия не найдена.
"""
with _lock:
s = _sessions.get(sid)
return list(s["files"]) if s else None
def file_count(sid: str) -> int:
"""Количество файлов в сессии."""
with _lock:
s = _sessions.get(sid)
return len(s["files"]) if s else 0
+45
View File
@@ -0,0 +1,45 @@
"""Общее состояние сессий: хранилище, блокировка, константы, TTL-таймер.
Единая точка хранения состояния — все операции импортируют её.
Дробить state.py на файл-на-переменную не нужно: это данные, а не функции.
"""
import threading
# TTL сессии: 30 минут
TTL_SECONDS = 30 * 60
# Максимальный объём одного файла и суммарный объём файлов в сессии (защита памяти)
MAX_FILE_BYTES = 50 * 1024 * 1024 # 50 MB на один файл
MAX_SESSION_BYTES = 500 * 1024 * 1024 # 500 MB суммарно на сессию
_sessions: dict = {}
_lock = threading.Lock()
def _start_timer(sid: str) -> threading.Timer:
"""Запустить таймер автоочистки сессии через TTL."""
def _clean():
with _lock:
_sessions.pop(sid, None)
timer = threading.Timer(TTL_SECONDS, _clean)
timer.daemon = True
timer.start()
return timer
def configure(max_file_bytes: int = None, max_session_bytes: int = None,
ttl_seconds: int = None):
"""Переопределить лимиты/TTL из конфига приложения (глобально).
None — оставить текущее значение.
"""
global MAX_FILE_BYTES, MAX_SESSION_BYTES, TTL_SECONDS
if max_file_bytes is not None:
MAX_FILE_BYTES = max_file_bytes
if max_session_bytes is not None:
MAX_SESSION_BYTES = max_session_bytes
if ttl_seconds is not None:
TTL_SECONDS = ttl_seconds
+30
View File
@@ -0,0 +1,30 @@
"""store_csv / get_csv — сохранение и чтение CSV с таблицей замен."""
from typing import Optional
from .state import _sessions, _lock
def store_csv(sid: str, csv_str: str) -> bool:
"""Сохранить CSV с таблицей замен.
Returns:
True если сохранено, False если сессии нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["csv"] = csv_str
return True
def get_csv(sid: str) -> Optional[str]:
"""Получить CSV с таблицей замен.
Returns:
Строка CSV или None если нет.
"""
with _lock:
s = _sessions.get(sid)
return s.get("csv") if s else None
+30
View File
@@ -0,0 +1,30 @@
"""store_result / get_result — сохранение и чтение результата обработки."""
from typing import Optional
from .state import _sessions, _lock
def store_result(sid: str, zip_data: bytes) -> bool:
"""Сохранить результат обработки (ZIP-архив).
Returns:
True если сохранено, False если сессии нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["result"] = zip_data
return True
def get_result(sid: str) -> Optional[bytes]:
"""Получить результат обработки.
Returns:
ZIP-архив или None если сессия не найдена/результат не готов.
"""
with _lock:
s = _sessions.get(sid)
return s["result"] if s else None
+34
View File
@@ -0,0 +1,34 @@
"""touch / pause_ttl / resume_ttl — управление TTL-таймером сессии."""
from .state import _sessions, _lock, _start_timer
def touch(sid: str):
"""Продлить жизнь сессии: перезапустить TTL-таймер (если сессия существует)."""
with _lock:
s = _sessions.get(sid)
if not s:
return
if s.get("timer"):
s["timer"].cancel()
s["timer"] = _start_timer(sid)
def pause_ttl(sid: str):
"""Приостановить TTL сессии (во время обработки): сессия живёт, пока идёт воркер."""
with _lock:
s = _sessions.get(sid)
if s and s.get("timer"):
s["timer"].cancel()
s["timer"] = None
def resume_ttl(sid: str):
"""Возобновить TTL сессии (после завершения обработки): результат доступен ещё TTL."""
with _lock:
s = _sessions.get(sid)
if not s:
return
if s.get("timer"):
s["timer"].cancel()
s["timer"] = _start_timer(sid)
+7
View File
@@ -0,0 +1,7 @@
"""upload_refs — Flask Blueprint для приёма ссылок и pull с ВМ-буфера."""
from .blueprint import create_upload_refs_blueprint
from .safe_name import safe_name
from .pull_file import pull_file
__all__ = ["create_upload_refs_blueprint", "safe_name", "pull_file"]
+145
View File
@@ -0,0 +1,145 @@
"""Переиспользуемый Blueprint слоя 2: POST /upload_refs (pull с ВМ-буфера в сессию).
Поведение:
- safe_name (защита от path traversal)
- SSRF-валидация url.startswith(vm_prefix)
- лимит на один файл -> delete + skip
- pull с ретраями через httpx stream
- лимит сессии -> skip; отсутствие сессии -> 404
- delete url с ВМ (best-effort)
- поддержка пофайлового транзита и пачек
- опциональный callback для интеграции/эмуляции Слоя 3 (on_file_received)
"""
import httpx
import logging
from urllib.parse import urlsplit
from flask import Blueprint, request, jsonify, current_app
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 = 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
onFileReceived (func) — опциональный callback (sid, name, content) для Слоя 3
"""
cfg = cfg or {}
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)
on_file_received = cfg.get("onFileReceived")
# Применить лимиты сессии/TTL из конфига (глобально для всех сессий)
configure(
max_file_bytes=cfg.get("maxFileBytes"),
max_session_bytes=cfg.get("maxSessionBytes"),
ttl_seconds=cfg.get("ttlSeconds"),
)
is_path_only_prefix = vm_prefix.startswith("/")
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 файл
удаляется с ВМ (DELETE).
"""
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
added = 0
transport = cfg.get("httpxTransport") or current_app.config.get("UPLOAD_HTTPX_TRANSPORT")
try:
with httpx.Client(transport=transport, timeout=pull_timeout, follow_redirects=True) as client:
for ref in refs:
name = safe_name(ref.get("name") or "")
url = ref.get("url") or ""
if not name or not url:
continue
# SSRF-защита: тянуть можно ТОЛЬКО с доверенного ВМ-буфера
if is_path_only_prefix:
if not urlsplit(url).path.startswith(vm_prefix):
log.warning("upload_refs: unsafe URL path, skip sid=%s url=%r", sid, url)
continue
if not url.startswith(("http://", "https://")):
url = request.host_url.rstrip("/") + ("/" if not url.startswith("/") else "") + url
else:
if not url.startswith(vm_prefix):
log.warning("upload_refs: unsafe URL, skip sid=%s url=%r", sid, url)
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
continue
# Pull с ретраями
content = pull_file(client, url, pull_retries, pull_delay, sid=sid, name=name)
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
continue
if not add_file(sid, name, content):
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
# Если передан callback для Слоя 3 (эмуляция или реальный процессинг)
if callable(on_file_received):
try:
on_file_received(sid, name, content)
except Exception as cb_err:
log.warning("upload_refs: on_file_received callback error: %r", cb_err)
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
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), "added": added})
return bp
+11
View File
@@ -0,0 +1,11 @@
"""Параметры слоя 2 (бэк) по умолчанию.
Переопределяются из конфига приложения через create_upload_refs_blueprint(cfg).
"""
# Ретраи pull из ВМ-буфера: защита от разовых DNS/сетевых сбоев (gaierror -5 и т.п.)
PULL_RETRIES = 3
PULL_RETRY_DELAY = 2 # секунды между попытками
# Доверенный префикс ВМ-буфера по умолчанию — валидация URL при pull (защита от SSRF)
VM_UPLOAD_PREFIX = "https://contracts.kube5s.ru/drhider-upload/"
+39
View File
@@ -0,0 +1,39 @@
"""pull_file — вытащить файл с ВМ-буфера исходящим GET с ретраями."""
import logging
import time
from .config import PULL_RETRIES, PULL_RETRY_DELAY
log = logging.getLogger("upload.upload_refs.pull")
def pull_file(client, url: str, retries: int = PULL_RETRIES,
delay: float = PULL_RETRY_DELAY, sid: str = None, name: str = None) -> bytes:
"""GET url с ретраями; читает по частям (stream).
Args:
client: httpx.Client
url: URL файла на ВМ-буфере.
retries: число попыток.
delay: пауза между попытками (сек).
sid/name: для логирования (опционально).
Returns:
Содержимое файла (bytes).
Raises:
Последнюю ошибку попытки, если все ретраи не удались.
"""
last_err = None
for attempt in range(retries):
try:
with client.stream("GET", url) as resp:
resp.raise_for_status()
return b"".join(resp.iter_bytes())
except Exception as e:
last_err = e
log.warning("pull: attempt %d/%d failed sid=%s file=%r: %r",
attempt + 1, retries, sid, name, e)
time.sleep(delay)
raise last_err if last_err else RuntimeError("pull failed")
+16
View File
@@ -0,0 +1,16 @@
"""safe_name — санитизация имени файла (защита от path traversal)."""
def safe_name(name: str) -> str:
"""Санитизировать имя файла: защита от path traversal, сохраняя подпапки.
Запрещает '..' и абсолютные пути; нормализует слэши. Возвращает "" если
имя пустое или небезопасное.
"""
if not name:
return ""
name = name.replace("\\", "/")
parts = [p for p in name.split("/") if p and p != "."]
if not parts or any(p == ".." for p in parts):
return ""
return "/".join(parts)
+3
View File
@@ -0,0 +1,3 @@
{
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"]
}
+193
View File
@@ -0,0 +1,193 @@
import { addFiles, onFilesChange } from './table/on_files_change.js';
import { onFolderChange } from './table/on_folder_change.js';
import { findNode, flattenFiles, render } from './table/render.js';
import { esc } from './table/esc.js';
const DEFAULTS = {
allowedExt: [],
labels: {
pickFiles: 'Выбрать файлы',
pickFolder: 'Выбрать папку',
clear: 'Очистить',
empty: 'Нет выбранных файлов',
statusReady: 'готов',
remove: 'Удалить',
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
count: (count, bytes) => `${count} файлов · ${bytes} B`,
},
layout: { columns: ['path', 'size', 'status'], controls: ['files', 'folder', 'clear'] },
limits: {
maxEntries: 1000,
maxTotalBytes: 100 * 1024 * 1024,
maxEntryBytes: 50 * 1024 * 1024,
maxDepth: 20,
},
};
function resolveMount(mount) {
const element = typeof mount === 'string' ? document.querySelector(mount) : mount;
if (!element) throw new Error('File picker mount element не найден');
return element;
}
function normalizeConfig(config) {
return {
...config,
allowedExt: config.allowedExt || DEFAULTS.allowedExt,
labels: {
...DEFAULTS.labels,
...(config.labels || {}),
columns: { ...DEFAULTS.labels.columns, ...(config.labels?.columns || {}) },
},
layout: { ...DEFAULTS.layout, ...(config.layout || {}) },
limits: { ...DEFAULTS.limits, ...(config.limits || {}) },
};
}
function injectStyles() {
if (document.querySelector('style[data-file-picker]')) return;
const style = document.createElement('style');
style.dataset.filePicker = '';
style.textContent = `
.file-picker { color: #17211b; font-family: Georgia, 'Times New Roman', serif; }
.file-picker .fp-toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; }
.file-picker .fp-toolbar button { border: 0; border-radius: 4px; padding: 12px 17px; color: #f7faf4; background: #1f5a3b; font: 700 13px sans-serif; cursor: pointer; }
.file-picker .fp-toolbar button.secondary { color: #1f5a3b; background: #cbdcc9; }
.file-picker .fp-toolbar button.quiet { color: #5d6a61; background: transparent; }
.file-picker .fp-table-wrap { overflow-x: auto; border-top: 1px solid #b9c7ba; }
.file-picker .fp-table { width: 100%; border-collapse: collapse; font: 14px/1.4 sans-serif; }
.file-picker .fp-table th, .file-picker .fp-table td { padding: 14px 10px; border-bottom: 1px solid #cbd5cc; text-align: left; }
.file-picker .fp-table th { color: #56745f; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
.file-picker .fp-status, .file-picker .fp-count { color: #5d6a61; font: 13px sans-serif; }
.file-picker .fp-count { min-height: 22px; }
.file-picker .tree-name { display: inline-flex; align-items: center; gap: 8px; min-width: 240px; padding-top: 0; padding-bottom: 0; border: 0; background: transparent; color: #17211b; font: inherit; text-align: left; }
.file-picker .tree-group { padding-left: 0; cursor: pointer; }
.file-picker .tree-chevron { display: inline-block; width: 12px; color: #78917d; font-size: 10px; }
.file-picker .remove-btn { padding: 2px 7px; color: #8c5148; background: transparent; font: 20px/1 sans-serif; }
.file-picker .tree-row td:first-child { white-space: nowrap; }
@media (max-width: 600px) { .file-picker .fp-toolbar button { flex: 1 1 42%; } }
`;
document.head.append(style);
}
function buildMarkup(root, cfg) {
const controls = cfg.layout.controls || [];
const control = (name, id, label, className = '') => controls.includes(name)
? `<button id="${id}" type="button" class="${className}">${esc(label)}</button>` : '';
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(cfg.labels.columns);
const headings = columns.map((column) => `<th>${esc(cfg.labels.columns[column] || column)}</th>`).join('');
root.innerHTML = `
<div class="file-picker${cfg.layout.theme ? ` ${esc(cfg.layout.theme)}` : ''}">
<div class="fp-toolbar" aria-label="${esc(cfg.labels.pickFiles)}">
<input class="fp-file-input" type="file" accept="${esc([...cfg.allowedExt, '.zip'].join(','))}" multiple hidden>
<input class="fp-folder-input" type="file" accept="${esc([...cfg.allowedExt, '.zip'].join(','))}" webkitdirectory directory multiple hidden>
${control('files', 'fp-files-btn', cfg.labels.pickFiles)}
${control('folder', 'fp-folder-btn', cfg.labels.pickFolder, 'secondary')}
${control('clear', 'fp-clear-btn', cfg.labels.clear, 'quiet')}
</div>
<p class="fp-status" role="status"></p>
<div class="fp-table-wrap"><table class="fp-table"><thead><tr>${headings}<th></th></tr></thead><tbody class="fp-table-body"></tbody></table></div>
<p class="fp-count"></p>
</div>`;
}
function notify(cfg, state) {
if (typeof cfg.onChange === 'function') cfg.onChange([...flattenFiles(state.nodes)]);
}
function removeMetadata(state, node) {
if (node.kind === 'file') {
state.fileKeys.delete(`${node.path}\u0000${node.file.size}`);
return;
}
node.children.forEach((child) => removeMetadata(state, child));
}
function removeNode(state, id) {
const found = findNode(state.nodes, id);
if (!found) return false;
removeMetadata(state, found.node);
found.nodes.splice(found.nodes.indexOf(found.node), 1);
return true;
}
export function initFilePicker(config) {
const cfg = normalizeConfig(config);
const mount = resolveMount(cfg.mount);
injectStyles();
buildMarkup(mount, cfg);
const root = mount.firstElementChild;
const elements = {
fileInputEl: root.querySelector('.fp-file-input'),
folderInputEl: root.querySelector('.fp-folder-input'),
statusEl: root.querySelector('.fp-status'),
tableBodyEl: root.querySelector('.fp-table-body'),
countEl: root.querySelector('.fp-count'),
};
const state = { nodes: [], fileKeys: new Set(), busy: false };
const listeners = [];
const listen = (target, event, handler) => {
target.addEventListener(event, handler);
listeners.push(() => target.removeEventListener(event, handler));
};
const redraw = () => render(state, elements, cfg);
const filesChange = onFilesChange(state, cfg, elements);
const folderChange = onFolderChange(state, cfg, elements);
listen(elements.fileInputEl, 'change', async () => { await filesChange(); notify(cfg, state); });
listen(elements.folderInputEl, 'change', async () => { await folderChange(); notify(cfg, state); });
listen(elements.tableBodyEl, 'click', (event) => {
const toggle = event.target.closest('[data-toggle]');
if (toggle) {
const found = findNode(state.nodes, toggle.dataset.toggle);
if (found) found.node.expanded = !found.node.expanded;
redraw();
return;
}
const remove = event.target.closest('[data-remove]');
if (remove) {
if (removeNode(state, remove.dataset.remove)) {
redraw();
notify(cfg, state);
}
}
});
if (root.querySelector('#fp-files-btn')) listen(root.querySelector('#fp-files-btn'), 'click', () => elements.fileInputEl.click());
if (root.querySelector('#fp-folder-btn')) listen(root.querySelector('#fp-folder-btn'), 'click', () => elements.folderInputEl.click());
if (root.querySelector('#fp-clear-btn')) listen(root.querySelector('#fp-clear-btn'), 'click', () => {
state.nodes = [];
state.fileKeys.clear();
redraw();
notify(cfg, state);
});
redraw();
return {
pickFiles: () => elements.fileInputEl.click(),
pickFolder: () => elements.folderInputEl.click(),
addFiles: async (files) => { await addFiles(state, cfg, files, elements); notify(cfg, state); },
getFiles: () => [...flattenFiles(state.nodes)],
remove: (id) => {
if (removeNode(state, id)) {
redraw();
notify(cfg, state);
}
},
clear: () => {
state.nodes = [];
state.fileKeys.clear();
redraw();
notify(cfg, state);
},
render: redraw,
destroy: () => {
listeners.forEach((removeListener) => removeListener());
state.nodes = [];
state.fileKeys.clear();
mount.replaceChildren();
},
};
}
export { putToVm } from './upload/put_to_vm.js';
export { uploadViaVM } from './upload/upload_via_vm.js';
export { DEFAULTS };
@@ -0,0 +1,54 @@
/**
* Добавляет узел файла или группы в состояние picker с рекурсивной дедупликацией.
*
* @param {{nodes: Array, fileKeys: Set<string>}} state Внутреннее состояние таблицы.
* @param {{kind: string, path: string, file?: File, children?: Array}} fileNode
* Корневой узел файла, папки или ZIP-архива.
* @returns {boolean} True, если в состояние добавлен хотя бы один узел.
*
* Ключ дедупликации состоит из полного пути и размера файла. Это позволяет
* повторно выбрать файл после удаления и одновременно не скрывает файл с тем
* же путём, но другим размером.
*/
export function addFileWithDedup(state, fileNode) {
// Удаляет дубли и пустые группы снизу вверх, сохраняя исходные File objects.
const accept = (node) => {
if (node.kind === 'file') {
// NUL-разделитель исключает неоднозначность при склейке пути и размера.
const key = `${node.path}\u0000${node.file.size}`;
if (state.fileKeys.has(key)) return null;
state.fileKeys.add(key);
return node;
}
// Группа нужна только пока после фильтрации в ней остался хотя бы один leaf.
node.children = node.children.map(accept).filter(Boolean);
return node.children.length ? node : null;
};
const accepted = accept(fileNode);
// Пустой ZIP/каталог не должен появляться в таблице как пустая строка.
if (!accepted || (accepted.kind !== 'file' && !accepted.children.length)) return false;
if (accepted.kind !== 'file') {
const existing = state.nodes.find((node) => node.kind !== 'file' && node.path === accepted.path);
if (existing) {
mergeChildren(existing, accepted);
return true;
}
}
state.nodes.push(accepted);
return true;
}
function mergeChildren(target, incoming) {
incoming.children.forEach((child) => {
if (child.kind === 'file') {
const duplicate = target.children.some((existing) => existing.kind === 'file'
&& existing.path === child.path && existing.file.size === child.file.size);
if (!duplicate) target.children.push(child);
return;
}
const existing = target.children.find((candidate) => candidate.kind !== 'file'
&& candidate.path === child.path);
if (existing) mergeChildren(existing, child);
else target.children.push(child);
});
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Экранирует текст перед вставкой в HTML-шаблон, собираемый через innerHTML.
*
* @param {*} value Имя файла, путь или сообщение статуса.
* @returns {string} Строка с заменёнными HTML-значимыми символами.
*
* Экранируются и кавычки, потому что значение может попасть не только в текст
* кнопки, но и в HTML-атрибут вроде data-path или aria-label.
*/
export function esc(value) {
return String(value).replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[character]));
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Преобразует размер файла из байтов в короткую строку для таблицы.
*
* @param {number} bytes Размер в байтах.
* @returns {string} Значение в B, KB или MB с одним знаком после запятой.
*/
export function fs(bytes) {
return bytes < 1024 ? `${bytes} B`
: bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB`
: `${(bytes / 1048576).toFixed(1)} MB`;
}
+72
View File
@@ -0,0 +1,72 @@
import { listZipFiles } from '../zip/list_zip_files.js';
import { addFileWithDedup } from './add_file_with_dedup.js';
import { render } from './render.js';
/**
* Обрабатывает список файлов из обычного file input.
*
* @param {{fileKeys: Set<string>, nodes: Array, busy: boolean}} state Состояние picker.
* @param {{allowedExt: string[]}} cfg Конфигурация разрешённых расширений.
* @param {FileList|File[]} files Выбранные browser File objects.
* @param {{tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-элементы вывода.
* @returns {Promise<void>} Завершается после разбора всех ZIP и перерисовки таблицы.
*
* Обычные документы добавляются сразу. ZIP читаются асинхронно в браузере;
* ошибка одного архива не отменяет обработку остальных выбранных файлов.
*/
export async function addFiles(state, cfg, files, elements) {
for (const file of Array.from(files)) {
if (!file.name.toLowerCase().endsWith('.zip')) {
// Фильтр повторяет accept в HTML, потому что accept не является защитой API.
if (!cfg.allowedExt.some((extension) => file.name.toLowerCase().endsWith(extension.toLowerCase()))) {
continue;
}
// Обычный файл получает имя как путь: у file input нет относительного пути.
addFileWithDedup(state, {
id: crypto.randomUUID(), kind: 'file', name: file.name, path: file.name,
file, children: [], expanded: true,
});
continue;
}
try {
// listZipFiles возвращает дерево только с разрешёнными leaf-файлами.
const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits);
if (zipTree) addFileWithDedup(state, zipTree);
} catch (error) {
// Битый или небезопасный ZIP пропускается, чтобы не блокировать picker.
if (typeof cfg.onError === 'function') {
cfg.onError(error, file);
} else if (elements.statusEl) {
elements.statusEl.textContent = `Ошибка чтения архива ${file.name}: ${error.message}`;
}
continue;
}
}
// Единая перерисовка после всей пачки предотвращает промежуточные состояния UI.
render(state, elements, cfg);
}
/**
* Создаёт обработчик change для обычного выбора файлов.
*
* @param {object} state Состояние picker, включая флаг busy.
* @param {object} cfg Конфигурация picker.
* @param {object} elements DOM-элементы picker.
* @returns {Function} Async-обработчик для addEventListener('change', ...).
*
* busy устанавливается синхронно до первого await. Поэтому второе быстрое
* событие выбора не запускает параллельный разбор и не меняет дерево конкурирующе.
*/
export function onFilesChange(state, cfg, elements) {
return async () => {
if (state.busy) return;
state.busy = true;
try {
await addFiles(state, cfg, elements.fileInputEl.files, elements);
} finally {
// Даже исключение вне внутреннего ZIP-catch не оставляет picker заблокированным.
elements.fileInputEl.value = '';
state.busy = false;
}
};
}
+84
View File
@@ -0,0 +1,84 @@
import { listZipFiles } from '../zip/list_zip_files.js';
import { addFileWithDedup } from './add_file_with_dedup.js';
import { rebaseTree } from './rebase_tree.js';
import { render } from './render.js';
/**
* Создаёт async-обработчик выбора каталога через webkitdirectory.
*
* @param {{nodes: Array, fileKeys: Set<string>, busy: boolean}} state Состояние picker.
* @param {{allowedExt: string[]}} cfg Разрешённые расширения.
* @param {{folderInputEl: HTMLInputElement, tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-элементы.
* @returns {Function} Async-обработчик события change.
*
* Браузер отдаёт плоский FileList с webkitRelativePath. Обработчик группирует
* его по первой части пути, создаёт промежуточные папки и затем добавляет каждый
* корень через общую дедупликацию. ZIP внутри каталога раскрывается тем же кодом,
* что и ZIP из обычного file input.
*/
export function onFolderChange(state, cfg, elements) {
return async () => {
if (state.busy) return;
state.busy = true;
try {
const roots = new Map();
for (const file of Array.from(elements.folderInputEl.files)) {
// Для webkitdirectory путь начинается с выбранной корневой папки.
const parts = (file.webkitRelativePath || file.name).split('/');
const relativePath = parts.slice(1).join('/') || file.name;
const lowerPath = relativePath.toLowerCase();
const rootName = parts[0] || file.name;
if (!roots.has(rootName)) {
// Один root на выбранный каталог позволяет сохранить дерево целиком.
roots.set(rootName, { id: crypto.randomUUID(), kind: 'folder', name: rootName,
path: rootName, children: [], expanded: false });
}
const root = roots.get(rootName);
// Вставляет узел по его пути, создавая отсутствующие промежуточные папки.
const addToFolder = (node) => {
let current = root;
const nodeParts = node.path.split('/').slice(1);
nodeParts.forEach((part, index) => {
const last = index === nodeParts.length - 1;
let child = current.children.find((item) => item.name === part);
if (!child) {
child = last ? node : { id: crypto.randomUUID(), kind: 'folder', name: part,
path: `${rootName}/${nodeParts.slice(0, index + 1).join('/')}`,
children: [], expanded: false };
current.children.push(child);
}
current = child;
});
};
if (lowerPath.endsWith('.zip')) {
try {
// ZIP rebased на имя выбранной папки перед вставкой в общий root.
const zip = await listZipFiles(file, cfg.allowedExt, cfg.limits);
if (zip) {
rebaseTree(zip, rootName);
addToFolder(zip);
}
} catch (error) {
// Ошибка одного ZIP не должна терять остальные файлы каталога.
if (typeof cfg.onError === 'function') {
cfg.onError(error, file);
} else if (elements.statusEl) {
elements.statusEl.textContent = `Ошибка чтения архива ${file.name}: ${error.message}`;
}
continue;
}
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
addToFolder({ id: crypto.randomUUID(), kind: 'file', name: parts.at(-1),
path: `${rootName}/${relativePath}`, file, children: [], expanded: true });
}
}
// Дедупликация выполняется после сборки каждого корня каталога.
roots.forEach((root) => addFileWithDedup(state, root));
// Сброс value позволяет выбрать тот же каталог повторно.
elements.folderInputEl.value = '';
render(state, elements, cfg);
} finally {
state.busy = false;
}
};
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Добавляет префикс корневой папки к каждому пути дерева.
*
* @param {{path: string, file?: File, children: Array}} root Узел дерева.
* @param {string} prefix Путь выбранной папки, в которую попал узел.
* @returns {object} Тот же узел после изменения путей.
*
* Для leaf-файлов создаётся новый File с тем же базовым именем; логический путь
* хранится отдельно в узле дерева.
*/
export function rebaseTree(root, prefix) {
root.path = `${prefix}/${root.path}`;
if (root.file) root.file = new File([root.file], root.file.name, { lastModified: root.file.lastModified });
root.children.forEach((child) => rebaseTree(child, prefix));
return root;
}
+124
View File
@@ -0,0 +1,124 @@
import { esc } from './esc.js';
import { fs } from './fs.js';
const DEFAULT_RENDER_CONFIG = {
labels: {
statusReady: 'готов',
remove: 'Удалить',
empty: 'Нет выбранных файлов',
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
count: (count, bytes) => `${count} файлов · ${fs(bytes)}`,
},
layout: { columns: ['path', 'size', 'status'] },
};
function renderConfig(cfg = {}) {
return {
labels: {
...DEFAULT_RENDER_CONFIG.labels,
...(cfg.labels || {}),
columns: {
...DEFAULT_RENDER_CONFIG.labels.columns,
...(cfg.labels?.columns || {}),
},
},
layout: { ...DEFAULT_RENDER_CONFIG.layout, ...(cfg.layout || {}) },
};
}
/**
* Рендерит один узел дерева и его раскрытых потомков в HTML-строки таблицы.
*
* @param {{kind: string, id: string, name: string, path: string, file?: File,
* children: Array, expanded: boolean, error?: string}} node Узел file/folder/zip.
* @param {number} depth Глубина узла; используется для визуального отступа.
* @returns {string} HTML одной строки или поддерева строк.
*
* Имена и пути проходят через esc до вставки в innerHTML. Группа получает
* кнопку раскрытия, leaf-файл — размер, статус и data-path для будущего API статусов.
*/
function renderNode(node, depth, cfg) {
const isGroup = node.kind !== 'file';
const padding = depth * 4;
const name = esc(node.name);
const action = isGroup
? `<button class="tree-name tree-group tree-${node.kind}" style="padding-left:${padding}ch" data-toggle="${node.id}" `
+ `aria-expanded="${node.expanded}"><span class="tree-chevron">${node.expanded ? '▼' : '▶'}</span>${name}</button>`
: `<span class="tree-name" style="padding-left:${padding}ch">${name}</span>`;
const remove = `<button class="remove-btn" type="button" data-remove="${node.id}" aria-label="${esc(cfg.labels.remove)} ${name}">×</button>`;
const pathAttribute = node.kind === 'file' ? ` data-path="${esc(node.path)}"` : '';
const cells = {
path: action,
size: node.kind === 'file' ? fs(node.file.size) : '',
status: node.error ? esc(node.error) : node.kind === 'file' ? esc(cfg.labels.statusReady) : '',
};
const columns = cfg.layout.columns?.length
? cfg.layout.columns
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
const row = `<tr class="tree-row tree-${node.kind}"${pathAttribute}>`
+ columns.map((column) => `<td>${cells[column] || ''}</td>`).join('')
+ `<td>${remove}</td></tr>`;
if (!isGroup || !node.expanded) return row;
// Дочерние строки добавляются только для раскрытой группы.
return row + node.children.map((child) => renderNode(child, depth + 1, cfg)).join('');
}
/**
* Полностью синхронизирует DOM таблицы и счётчик с текущим state.nodes.
*
* @param {{nodes: Array}} state Состояние дерева.
* @param {{tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-вывод.
* @returns {void}
*
* Обход для счётчика независим от раскрытия: свернутая группа всё равно входит
* в количество leaf-файлов и суммарный размер.
*/
export function render(state, elements, cfg) {
const normalized = renderConfig(cfg);
const columns = normalized.layout.columns?.length
? normalized.layout.columns
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
elements.tableBodyEl.innerHTML = state.nodes.length
? state.nodes.map((node) => renderNode(node, 0, normalized)).join('')
: `<tr><td colspan="${columns.length + 1}">${esc(normalized.labels.empty)}</td></tr>`;
const files = [];
// Собирает все leaf-файлы, включая содержимое свернутых групп.
const visit = (node) => {
if (node.kind === 'file') files.push(node);
else node.children.forEach(visit);
};
state.nodes.forEach(visit);
const totalBytes = files.reduce((sum, node) => sum + node.file.size, 0);
elements.countEl.textContent = normalized.labels.count(files.length, totalBytes);
}
/**
* Ищет узел по уникальному id и возвращает также массив его непосредственного родителя.
*
* @param {Array} nodes Массив узлов текущего уровня.
* @param {string} id Идентификатор, созданный через crypto.randomUUID().
* @returns {{node: object, nodes: Array}|null} Узел и контейнер для удаления либо null.
*/
export function findNode(nodes, id) {
for (const node of nodes) {
if (node.id === id) return { node, nodes };
const found = findNode(node.children || [], id);
if (found) return found;
}
return null;
}
/**
* Преобразует иерархическое дерево в плоский список leaf-файлов.
*
* @param {Array} nodes Узлы любого уровня.
* @param {Array} result Внутренний аккумулятор рекурсивного вызова.
* @returns {Array<{path: string, name: string, size: number, file: File}>} Файлы для приложения.
*/
export function flattenFiles(nodes, result = []) {
nodes.forEach((node) => {
if (node.kind === 'file') result.push({ path: node.path, name: node.name, size: node.file.size, file: node.file });
else flattenFiles(node.children, result);
});
return result;
}
+78
View File
@@ -0,0 +1,78 @@
/**
* putToVm — отправка одного файла в буфер методом HTTP PUT (сырое бинарное тело).
*
* @param {Blob|File} file Файл для отправки.
* @param {string} url Полный целевой URL в буфере.
* @param {Object} options Опции отправки:
* @param {function(number, number, number): void} [options.onProgress] Колбэк прогресса (pct, loaded, total).
* @param {function(XMLHttpRequest): void} [options.onXHR] Колбэк регистрации XHR для прямого контроля.
* @param {AbortSignal} [options.signal] Сигнал прерывания запроса (AbortController.signal).
* @param {number} [options.timeoutMs=300000] Таймаут в миллисекундах (по умолчанию 300с).
* @returns {Promise<void>} Разрешается при ответе 2xx, отклоняется при ошибке или прерывании.
*/
export function putToVm(file, url, options = {}) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
if (options.onXHR) {
options.onXHR(xhr);
}
const abortHandler = () => {
xhr.abort();
reject(new DOMException('Upload aborted', 'AbortError'));
};
if (options.signal) {
if (options.signal.aborted) {
reject(new DOMException('Upload aborted', 'AbortError'));
return;
}
options.signal.addEventListener('abort', abortHandler, { once: true });
}
xhr.open('PUT', url);
xhr.timeout = options.timeoutMs || 300000;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && options.onProgress) {
const pct = Math.round((e.loaded / e.total) * 100);
options.onProgress(pct, e.loaded, e.total);
}
};
xhr.onload = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(`Буфер: HTTP ${xhr.status} ${xhr.statusText}`));
}
};
xhr.onerror = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
reject(new Error('Сетевая ошибка при отправке в буфер'));
};
xhr.ontimeout = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
reject(new Error(`Таймаут ожидания ответа буфера (${Math.round(xhr.timeout / 1000)}с)`));
};
xhr.onabort = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
reject(new DOMException('Upload aborted', 'AbortError'));
};
xhr.send(file);
});
}
+188
View File
@@ -0,0 +1,188 @@
/**
* uploadViaVM — Слой 2 (Фронтенд): пофайловый транзит через ВМ-буфер в сессию бэкенда.
*
* Архитектура потокового транзита:
* Для каждого файла k:
* 1. Браузер отправляет файл k методом PUT в буфер на ВМ.
* 2. Браузер сразу делает POST /api/upload_refs для одного файла k.
* 3. Бэкенд забирает файл исходящим GET в память RAM и сразу шлёт DELETE в буфер.
* 4. Файл удалён из буфера ВМ, память чиста.
* 5. Браузер переходит к файлу k+1.
*/
import { putToVm } from './put_to_vm.js';
import { fs } from '../table/fs.js';
function generateUuid() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* Нормализует элемент списка файлов (поддерживает как File, так и объект из FilePicker.getFiles()).
*/
function normalizeFileEntry(item) {
if (item instanceof File || item instanceof Blob) {
return { name: item.name || 'unnamed', size: item.size, file: item, path: item.name || '' };
}
if (item && item.file) {
return {
name: item.name || item.file.name || 'unnamed',
size: item.size ?? item.file.size ?? 0,
file: item.file,
path: item.path || item.name || '',
};
}
throw new Error('Некорректный объект файла');
}
/**
* Выполняет пофайловую транзитную загрузку файлов в сессию бэкенда через буфер.
*
* @param {Array<File|{name: string, size: number, file: File, path?: string}>} files Список файлов.
* @param {Object} options Параметры загрузки:
* @param {string} options.vmUploadUrl Базовый URL буфера (обязателен).
* @param {string} [options.backendUploadUrl='/api/upload_refs'] URL эндпоинта приёма ссылок.
* @param {string} [options.session=''] Идентификатор существующей сессии (если есть).
* @param {AbortSignal} [options.signal] Сигнал отмены загрузки.
* @param {function(string): void} [options.onStatus] Колбэк общего текстового статуса.
* @param {function(number, string): void} [options.onFileStatus] Колбэк обновления статуса конкретного файла (индекс, текст/html).
* @param {function(Object): void} [options.onProgress] Колбэк прогресса текущего файла ({ index, total, pct, speed }).
* @param {function(Object): void} [options.onFileComplete] Колбэк успешной доставки файла ({ index, name, session, totalAdded }).
* @returns {Promise<{ok: boolean, session?: string, count?: number, error?: string, aborted?: boolean}>}
*/
export async function uploadViaVM(files, options = {}) {
if (!options.vmUploadUrl) {
return { ok: false, error: 'Параметр options.vmUploadUrl обязателен' };
}
const normalizedFiles = Array.from(files).map(normalizeFileEntry);
const total = normalizedFiles.length;
if (total === 0) {
return { ok: true, session: options.session || '', count: 0 };
}
const backendUrl = options.backendUploadUrl || (options.apiBase || '') + '/api/upload_refs';
const vmBase = options.vmUploadUrl.endsWith('/') ? options.vmUploadUrl : `${options.vmUploadUrl}/`;
let currentSession = options.session || '';
let totalAdded = 0;
for (let k = 0; k < total; k++) {
if (options.signal?.aborted) {
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
}
const { name, size, file } = normalizedFiles[k];
const fileKey = `${generateUuid()}_${k}`;
const fileUrl = `${vmBase}${fileKey}`;
if (options.onStatus) {
options.onStatus(`Загрузка в буфер (${k + 1}/${total}): ${name}`);
}
if (options.onFileStatus) {
options.onFileStatus(k, '⏳ В буфер...');
}
// Этап 1: Отправка одного файла в буфер
const t0 = performance.now();
try {
await putToVm(file, fileUrl, {
signal: options.signal,
onXHR: options.onXHR,
onProgress(pct, loaded, fileTotal) {
const elapsed = (performance.now() - t0) / 1000;
const speed = elapsed > 0 ? (loaded / elapsed) : 0;
if (options.onFileStatus) {
options.onFileStatus(k, `⏳ ${pct}% (${fs(speed)}/s)`);
}
if (options.onProgress) {
options.onProgress({ index: k, total, pct, speed, loaded, fileTotal, name });
}
},
});
} catch (err) {
if (options.signal?.aborted) {
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
}
if (options.onFileStatus) {
options.onFileStatus(k, '✗ Ошибка буфера');
}
return {
ok: false,
error: `Ошибка отправки файла ${name} в буфер: ${err.message}`,
failedIndex: k,
session: currentSession,
};
}
// Этап 2: Вызов /api/upload_refs для немедленного переноса файла с буфера в RAM сессии
if (options.onStatus) {
options.onStatus(`Приём сервисом (${k + 1}/${total}): ${name}`);
}
if (options.onFileStatus) {
options.onFileStatus(k, '⚡ В сессию...');
}
try {
const resp = await fetch(backendUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session: currentSession,
files: [{ name, size, url: fileUrl }],
}),
signal: options.signal,
});
if (!resp.ok) {
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
}
const data = await resp.json();
if (!data.ok) {
throw new Error(data.error || 'Бэкенд вернул ошибку');
}
currentSession = data.session || currentSession;
totalAdded = data.count ?? (totalAdded + 1);
if (options.onFileStatus) {
options.onFileStatus(k, '✓ Доставлен');
}
if (options.onFileComplete) {
options.onFileComplete({
fileIndex: k,
name,
size,
session: currentSession,
totalAdded,
});
}
} catch (err) {
if (options.signal?.aborted) {
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
}
if (options.onFileStatus) {
options.onFileStatus(k, '✗ Ошибка приёма');
}
return {
ok: false,
error: `Ошибка приёма файла ${name} бэкендом: ${err.message}`,
failedIndex: k,
session: currentSession,
};
}
}
if (options.onStatus) {
options.onStatus(`Завершено. Успешно передано файлов: ${total}`);
}
return { ok: true, session: currentSession, count: totalAdded };
}
+125
View File
@@ -0,0 +1,125 @@
// Построить дерево ZIP с файлами только разрешённых расширений.
import { unzipSync } from 'fflate';
import { rebaseTree } from '../table/rebase_tree.js';
const DEFAULT_LIMITS = {
maxEntries: 1000,
maxTotalBytes: 100 * 1024 * 1024,
maxEntryBytes: 50 * 1024 * 1024,
maxDepth: 20,
};
/** Возвращает true, если имя заканчивается одним из разрешённых расширений. */
function extensionAllowed(name, allowedExt) {
const lowerName = name.toLowerCase();
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
}
/** Создаёт browser File из байтов ZIP entry с базовым именем файла. */
function makeFile(data, name) {
return new File([data], name.split('/').at(-1));
}
/**
* Валидирует путь ZIP entry до его использования в дереве.
*
* @param {string} entryName Сырой путь из центрального каталога ZIP.
* @returns {string[]|null} Безопасные сегменты пути или null для опасной записи.
*
* Отклоняются абсолютные пути, backslash, пустые сегменты, `.` и `..`.
* Это предотвращает traversal и появление ложных групп в UI.
*/
function safeEntryParts(entryName) {
if (!entryName || entryName.startsWith('/') || entryName.includes('\\')) return null;
const parts = entryName.split('/');
if (parts.some((part) => !part || part === '.' || part === '..')) return null;
return parts;
}
/** Создаёт единый узел file, folder или zip с уникальным id и начальным состоянием раскрытия. */
function node(kind, name, path, children = [], file = null) {
return { id: crypto.randomUUID(), kind, name, path, children, file, expanded: kind === 'file' };
}
/** Вставляет leaf или вложенное дерево по сегментам пути, создавая folder-узлы. */
function addPath(root, parts, fileNode) {
let current = root;
parts.forEach((part, index) => {
const last = index === parts.length - 1;
let child = current.children.find((item) => item.name === part);
if (!child) {
child = last
? fileNode
: node('folder', part, `${current.path}/${part}`);
current.children.push(child);
}
current = child;
});
}
/**
* Рекурсивно читает байтовый массив ZIP и строит дерево разрешённых файлов.
*
* @param {Uint8Array} data Распаковываемые байты ZIP.
* @param {string} zipName Имя текущего архива для корневого узла и пути.
* @param {string[]} allowedExt Разрешённые расширения документов.
* @param {number} depth Текущая глубина вложенных ZIP.
* @returns {Promise<object|null>} Дерево или null, если разрешённых leaf нет.
* @throws {Error} При превышении глубины или повреждённом ZIP.
*/
async function listEntries(data, zipName, allowedExt, depth, limits, budget) {
// Ограничение глубины защищает браузер от бесконечной/чрезмерной рекурсии.
if (depth > limits.maxDepth) throw new Error('Слишком глубокая вложенность ZIP');
const entries = unzipSync(data, {
filter: (entry) => {
budget.entries += 1;
if (budget.entries > limits.maxEntries) throw new Error('Слишком много ZIP entries');
const lowerName = entry.name.toLowerCase();
if (!extensionAllowed(lowerName, allowedExt) && !lowerName.endsWith('.zip')) return false;
if (entry.originalSize > limits.maxEntryBytes) return false;
if (budget.totalBytes + entry.originalSize > limits.maxTotalBytes) {
throw new Error('Превышен суммарный размер распакованных ZIP entries');
}
budget.totalBytes += entry.originalSize;
return true;
},
});
const root = node('zip', zipName, zipName);
for (const [entryName, entryData] of Object.entries(entries)) {
// Каталоги ZIP не являются файлами и будут созданы addPath при необходимости.
if (entryName.endsWith('/')) continue;
const parts = safeEntryParts(entryName);
if (!parts) continue;
const normalizedName = parts.join('/');
if (normalizedName.toLowerCase().endsWith('.zip')) {
// Вложенный ZIP раскрывается только если внутри есть разрешённые leaf-файлы.
const nested = await listEntries(entryData, entryName, allowedExt, depth + 1, limits, budget);
if (nested) {
rebaseTree(nested, zipName);
nested.name = parts.at(-1);
addPath(root, parts, nested);
}
} else if (extensionAllowed(normalizedName, allowedExt)) {
// Запрещённые документы отбрасываются до создания browser File.
const path = `${zipName}/${normalizedName}`;
const file = makeFile(entryData, path);
addPath(root, parts, node('file', parts.at(-1), path, [], file));
}
}
return root.children.length ? root : null;
}
/**
* Публично читает выбранный ZIP-файл и возвращает его фильтрованное дерево.
*
* @param {File} file Browser File с ZIP-содержимым.
* @param {string[]} allowedExt Разрешённые расширения.
* @param {object} customLimits Ограничения распаковки.
* @returns {Promise<object|null>} Корневой zip-узел или null для пустого результата.
*/
export async function listZipFiles(file, allowedExt, customLimits = {}) {
const data = new Uint8Array(await file.arrayBuffer());
const limits = { ...DEFAULT_LIMITS, ...customLimits };
return listEntries(data, file.name, allowedExt, 0, limits, { entries: 0, totalBytes: 0 });
}