Compare commits
40
Commits
cee18046c2
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52da9a3923 | ||
|
|
49bb4d70d8 | ||
|
|
3fd6f1cffc | ||
|
|
24bd89b511 | ||
|
|
f90d97b276 | ||
|
|
1ff305f38e | ||
|
|
741edafc4a | ||
|
|
04affca363 | ||
|
|
d0128392e3 | ||
|
|
11716259a1 | ||
|
|
4b33593a82 | ||
|
|
e7d771e00f | ||
|
|
ce234132dd | ||
|
|
3abcc6c1fc | ||
|
|
0ad9e0047f | ||
|
|
52c3eb53f1 | ||
|
|
17e6dbd065 | ||
|
|
53751a1f7b | ||
|
|
a33dff2f47 | ||
|
|
af172115b0 | ||
|
|
5711e97fe5 | ||
|
|
5f5ed000b8 | ||
|
|
a18877415d | ||
|
|
7a4f642452 | ||
|
|
090f5e8993 | ||
|
|
d8dee114a3 | ||
|
|
f2141ec731 | ||
|
|
eaa064a721 | ||
|
|
6c51df96d9 | ||
|
|
ba4546ecc9 | ||
|
|
dd1e285c8c | ||
|
|
ff4895b63b | ||
|
|
4b73657e4c | ||
|
|
82acc84311 | ||
|
|
933ee79e3c | ||
|
|
b0c055f926 | ||
|
|
44e12db886 | ||
|
|
08e4788a35 | ||
|
|
e31476ebab | ||
|
|
0479afb44a |
@@ -3,7 +3,7 @@
|
|||||||
- НЕЛЬЗЯ менять ЧТО-ЛИБО при вопросе (?, почему, как, где, что) — только ответ словами
|
- НЕЛЬЗЯ менять ЧТО-ЛИБО при вопросе (?, почему, как, где, что) — только ответ словами
|
||||||
- НЕЛЬЗЯ спешить — обдумать, проверить, потом отвечать
|
- НЕЛЬЗЯ спешить — обдумать, проверить, потом отвечать
|
||||||
- НЕЛЬЗЯ лезть в /home/naeel/nubes/contracts/ (кроме loadtest)
|
- НЕЛЬЗЯ лезть в /home/naeel/nubes/contracts/ (кроме loadtest)
|
||||||
- НЕЛЬЗЯ предполагать — сомневаешься = спроси
|
- НИКОГДА не руководствоваться догадками. Если что-либо неизвестно точно или недоступно — немедленно остановиться и сообщить об этом; не продолжать действие и не выдавать предположение за факт.
|
||||||
- НЕЛЬЗЯ грузить внешние CDN/библиотеки на фронтенд — всё в коде
|
- НЕЛЬЗЯ грузить внешние CDN/библиотеки на фронтенд — всё в коде
|
||||||
|
|
||||||
# ✅ ПОРЯДОК ПРАВКИ
|
# ✅ ПОРЯДОК ПРАВКИ
|
||||||
|
|||||||
+25
@@ -1,9 +1,34 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.env
|
.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/
|
# TMP/
|
||||||
# about1500.md
|
# about1500.md
|
||||||
*.har
|
*.har
|
||||||
chrome-net-export-log.json
|
chrome-net-export-log.json
|
||||||
|
History/tests/2026-09-15-*.md
|
||||||
|
tests/e2e_mass_processing.py
|
||||||
TSTFILES/
|
TSTFILES/
|
||||||
files/
|
files/
|
||||||
|
upload-platform/
|
||||||
|
.agent-tmp/
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
python3
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/usr/bin/python3
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
python3
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
lib
|
|
||||||
@@ -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
|
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
| [infra/](infra/) | Инфраструктура: кластер, ingress, RST-диагностика, HTTP/2, ВМ-буфер |
|
| [infra/](infra/) | Инфраструктура: кластер, ingress, RST-диагностика, HTTP/2, ВМ-буфер |
|
||||||
| [ux-frontend/](ux-frontend/) | Фичи фронтенда: таймеры, имена файлов, ZIP-раскрытие, лимиты, прерывание/ETA |
|
| [ux-frontend/](ux-frontend/) | Фичи фронтенда: таймеры, имена файлов, ZIP-раскрытие, лимиты, прерывание/ETA |
|
||||||
| [upload/](upload/) | Загрузка файлов: диагностика, лимиты, стресс-тесты |
|
| [upload/](upload/) | Загрузка файлов: диагностика, лимиты, стресс-тесты |
|
||||||
|
| [upload-integration/](upload-integration/) | Интеграция переиспользуемого модуля upload-platform |
|
||||||
| [code-fixes/](code-fixes/) | Исправления кода по версиям (баги, оптимизация, безопасность, логирование) |
|
| [code-fixes/](code-fixes/) | Исправления кода по версиям (баги, оптимизация, безопасность, логирование) |
|
||||||
| [tests/](tests/) | Тесты и бенчмарки: замеры UI, обфускация, нагрузка |
|
| [tests/](tests/) | Тесты и бенчмарки: замеры UI, обфускация, нагрузка |
|
||||||
|
|
||||||
@@ -106,6 +107,12 @@
|
|||||||
- **2026-08-24-time-tickers-estimates.md** — v0.0.64–0.0.65: тикер текущего файла на всех этапах, мгновенные оценки времени по файлам и суммарно.
|
- **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-select-implemented.md** — v0.0.68: кнопка «Выбрать папку» (webkitdirectory, рекурсивно, относительный путь).
|
||||||
- **2026-08-24-folder-zip-not-lost.md** — v0.0.69: архивы из папки без документов не теряются (добавляются как есть); раскрытие zip с документами; склонение счётчика.
|
- **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.
|
||||||
|
- **2026-08-24-upload-logic-schema.md** — подробная схема логики загрузки + найденные баги/несоответствия.
|
||||||
- **2026-08-24-help-button-limits-text.md** — v0.0.70: заметная кнопка HELP (вместо «?») + пояснение статуса «пропущен» в ограничениях.
|
- **2026-08-24-help-button-limits-text.md** — v0.0.70: заметная кнопка HELP (вместо «?») + пояснение статуса «пропущен» в ограничениях.
|
||||||
- **2026-08-24-ui-locks-session-freeze.md** — v0.0.66: блокировки UI (выбор/удаление) на время работы, заморозка сессии + кнопка «Новая сессия».
|
- **2026-08-24-ui-locks-session-freeze.md** — v0.0.66: блокировки UI (выбор/удаление) на время работы, заморозка сессии + кнопка «Новая сессия».
|
||||||
|
|
||||||
@@ -116,8 +123,17 @@
|
|||||||
- **2026-08-23-upload-stress-test.md** — тесты загрузки и стресс (v0.0.59–0.0.60, кластер naeel-test-3).
|
- **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 МБ на сессию.
|
- **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/ — исправления кода (по версиям)
|
## 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-18-fix-review-bugs.md** — v0.0.32: фиксы по ревью обфускатора.
|
||||||
- **2026-08-19-replacer-optimization.md** — v0.0.39: оптимизация apply_replacements (однопроходная замена, один regex).
|
- **2026-08-19-replacer-optimization.md** — v0.0.39: оптимизация apply_replacements (однопроходная замена, один regex).
|
||||||
- **2026-08-20-logging.md** — v0.0.51: максимальное логирование (LOG_LEVEL env) + детальные логи SSE/воркера.
|
- **2026-08-20-logging.md** — v0.0.51: максимальное логирование (LOG_LEVEL env) + детальные логи SSE/воркера.
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# v0.0.72 — Ретраи pull, корректная ошибка лимита, счётчик дедупа (2026-08-24)
|
||||||
|
|
||||||
|
_code-fixes. По итогам код-ревью (см. `infra/2026-08-24-upload-logic-schema.md`)._
|
||||||
|
|
||||||
|
## 1. Ретраи pull (site/routes/api_bp.py, `upload_refs`)
|
||||||
|
- `PULL_RETRIES = 3`, `PULL_RETRY_DELAY = 2` (сек).
|
||||||
|
- Pull из ВМ-буфера теперь в цикле: при любой ошибке (DNS `gaierror -5`, сеть) — до 3 попыток
|
||||||
|
с паузой 2с. После исчерпания — проброс исходной ошибки (502 «Pull failed»).
|
||||||
|
- Закрывает инцидент 18:42 (разовый DNS-сбой ронял всю загрузку).
|
||||||
|
|
||||||
|
## 2. Корректная ошибка лимита сессии (`upload_refs`)
|
||||||
|
- `add_file` возвращает `False` и для «сессия исчезла», и для «превышен лимит 500МБ».
|
||||||
|
- Теперь: при `False` — если `get_files(sid) is None` → 404 «Session not found»;
|
||||||
|
иначе (лимит) → файл пропускается (`delete` с ВМ) и загрузка продолжается.
|
||||||
|
Раньше оба случая давали ложное «Session not found».
|
||||||
|
|
||||||
|
## 3. Счётчик «Добавлено из папки» (site/templates/index.html)
|
||||||
|
- `addFileWithDedup` теперь возвращает `true` (файл добавлен) / `false` (дедуп).
|
||||||
|
- `added++` только при `true` — дубли больше не завышают счётчик.
|
||||||
|
- Проверено: папка [Договор.txt, Договор.txt (дубль), Акт.txt] → «Добавлено из папки: 2 файла».
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
- `node --check` — OK, `py_compile` (app.py, api_bp.py) — OK.
|
||||||
|
- Локально: счётчик дедупа работает (2 файла при одном дубле).
|
||||||
|
- Ретраи/лимит — на проде после деплоя.
|
||||||
|
- Версия 0.0.71 → 0.0.72.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# v0.0.73 — фиксы по код-ревью Соннета (безопасность + явные баги) (2026-08-24)
|
||||||
|
|
||||||
|
_code-fixes. По ревью `History/sonnet/2026-08-24-code-review-sonnet.md` (промпт `docs/code-review-sonnet.md`)._
|
||||||
|
_Критично перепроверены в коде; спорные пункты — отклонены/отложены (см. ниже)._
|
||||||
|
|
||||||
|
## Исправлено (6 фиксов)
|
||||||
|
|
||||||
|
1. **SSRF** (`api_bp.py::upload_refs`) — добавлена валидация `url.startswith(VM_UPLOAD_PREFIX)`;
|
||||||
|
недоверенный URL пропускается. Константа `VM_UPLOAD_PREFIX = "https://contracts.kube5s.ru/drhider-upload/"`.
|
||||||
|
2. **Path traversal / zip slip** (`api_bp.py`) — функция `_safe_name()`: нормализует слэши,
|
||||||
|
отбрасывает `..` и абсолютные пути, сохраняя подпапки (`Подпапка/Акт.txt` → как есть,
|
||||||
|
`../../evil.pdf` → ""). Применена в `upload` и `upload_refs`.
|
||||||
|
Unit-тест 8 кейсов — все OK.
|
||||||
|
3. **Слабый SID** (`session.py`) — `uuid.uuid4().hex[:12]` → `uuid.uuid4().hex` (128 бит).
|
||||||
|
4. **Серверная ошибка не отображалась** — серверное событие `event: error` (конфликт с встроенным
|
||||||
|
EventSource) → `event: proc_error`; добавлен клиентский `addEventListener('proc_error', …)`
|
||||||
|
с показом сообщения.
|
||||||
|
5. **ZIP-бомба: обход через поддельный `file_size`** (`extractor.py`) — добавлена проверка
|
||||||
|
`total_uncompressed > MAX_UNCOMPRESSED` ПОСЛЕ `zf.read()` с `break` (ранний выход).
|
||||||
|
6. **Self-XSS через имя файла** (`index.html`) — добавлена `esc()` и применена к `f.name`
|
||||||
|
в `rr()` и `procRow()`.
|
||||||
|
|
||||||
|
## Отклонено (критично к Соннету)
|
||||||
|
- **«cleanup(sid) после /download»** — НЕ сделано: ZIP и CSV скачиваются РАЗДЕЛЬНЫМИ запросами;
|
||||||
|
удаление сессии после отдачи ZIP сломало бы скачивание CSV. Сессия и так чистится по TTL (30 мин).
|
||||||
|
|
||||||
|
## Отложено (требуют решения/риск)
|
||||||
|
- idx-мисматч при `expand_zips` (фильтр расширений на бэке) — связано с фичей v0.0.69
|
||||||
|
(zip без документов «как есть»), требует решения по поведению.
|
||||||
|
- Отмена не проверяется в extract-фазе (.doc liberta 120с) — отдельный фикс.
|
||||||
|
- LLM-таймаут тихо обнуляет чанк — логирование/статус.
|
||||||
|
- Debug-эндпоинт `session_files` — ограничить/закрыть.
|
||||||
|
- LLM prompt injection — задокументировать (класс риска, не фикс кода).
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
- `py_compile` (app.py, api_bp.py, session.py, extractor.py) — OK; `node --check` — OK.
|
||||||
|
- `_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,29 @@
|
|||||||
|
# Временный сбой сертификата gitea и push (2026-08-24)
|
||||||
|
|
||||||
|
_infra. Прецедент, чтобы не терять время в следующий раз._
|
||||||
|
|
||||||
|
## Симптом
|
||||||
|
`git push origin master` к `gitea.services.ngcloud.ru` падал:
|
||||||
|
```
|
||||||
|
SSL: certificate subject name (*.ngcloud.ru) does not match target host name 'gitea.services.ngcloud.ru'
|
||||||
|
```
|
||||||
|
При этом в браузере `https://gitea.services.ngcloud.ru/Nail/drhider.git` открывался.
|
||||||
|
|
||||||
|
## Диагностика
|
||||||
|
- `gitea.services.ngcloud.ru` → `194.31.9.41` (стабильно, и с машины, и с ВМ).
|
||||||
|
- На `194.31.9.41:443` отдавался **wildcard `*.ngcloud.ru`** (SAN: `*.ngcloud.ru, ngcloud.ru`),
|
||||||
|
который НЕ покрывает `gitea.services.ngcloud.ru` (два уровня: `services.ngcloud.ru`).
|
||||||
|
- Внутренний ClusterIP `10.96.52.11` (gitea.mgmt.nubes.ru, hosts ВМ) недоступен ни с машины, ни с ВМ.
|
||||||
|
- На ВМ git-настроек нет (нет sslVerify=false), `credential.helper=store`.
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
Проблема была **на стороне gitea/ngcloud.ru** — временно отдавался неверный (wildcard) сертификат.
|
||||||
|
Через ~10-20 минут сертификат вернулся корректным (`CN = gitea.services.ngcloud.ru`,
|
||||||
|
SAN `DNS:gitea.services.ngcloud.ru`) → `git push` прошёл (`0479afb..e31476e`).
|
||||||
|
|
||||||
|
## Урок
|
||||||
|
- Если push к gitea падает по SSL «*.ngcloud.ru doesn't match» — это временный сбой сертификата
|
||||||
|
на стороне gitea (не код). Проверить: `echo | openssl s_client -connect gitea.services.ngcloud.ru:443 -servername gitea.services.ngcloud.ru | openssl x509 -noout -subject -ext subjectAltName`.
|
||||||
|
- Не менять git config (`sslVerify=false`) и не синкать на ВМ — подождать и повторить push.
|
||||||
|
- Аналогичный сбой DNS был у `contracts.kube5s.ru` в 18:42 (см. `2026-08-24-pull-dns-transient.md`) —
|
||||||
|
вероятно, общее инфраструктурное происшествие ngcloud.ru в этот день.
|
||||||
@@ -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 — только заглушки «(секрет)».
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Схема логики загрузки + найденные баги/несоответствия (2026-08-24)
|
||||||
|
|
||||||
|
_infra. Подробная схема по коду: index.html (фронт), api_bp.py, session.py, obfuscator.py, extractor.py._
|
||||||
|
|
||||||
|
## Участники
|
||||||
|
- **Браузер** — index.html: выбор файлов/папки, PUT на ВМ, SSE-прогресс, статусы.
|
||||||
|
- **ВМ-буфер** — nginx dav `https://contracts.kube5s.ru/drhider-upload/` (CORS разрешён только для origin `https://drhider.pythonk8s.dev.nubes.ru`).
|
||||||
|
- **Бэк** — Flask (api_bp.py): upload_refs (pull), process_stream (SSE, воркер-поток), session.py (лимиты), drhider (extractor→scanner→replacer→builder).
|
||||||
|
|
||||||
|
## Этап 0 — выбор файлов (браузер)
|
||||||
|
1. `fileInput` (multiple, accept) — для `.zip` → `listZipFiles` (раскрытие в браузере, allowedExt = .pdf .doc .docx .txt .md) → `addFileWithDedup`; иначе — как есть.
|
||||||
|
2. `folderInput` (`webkitdirectory`) — рекурсивно: `webkitRelativePath.slice(1)` (отбрасываем верхнюю папку), zip раскрывается с префиксом пути, документы — с относительным путём, прочее — пропуск.
|
||||||
|
3. `addFileWithDedup`: дедуп (имя+размер → пропуск; коллизия имени → суффикс `_2`); лимиты 50МБ/файл и 500МБ/сессия → `overNames` («🔥 не учитывается» в обычной таблице).
|
||||||
|
|
||||||
|
## Этап 1 — загрузка (uploadFiles, фаза 1)
|
||||||
|
4. `toSend` = файлы БЕЗ `overNames`; over-файлы сразу получают статус «пропущен (лимит)» (v0.0.71).
|
||||||
|
5. Для каждого файла: **PUT** на `VM_UPLOAD_URL + token + '_' + k` (имя в URL не несётся) → прогресс % в ячейке → `✓ N KB/s`. `refs.push({name, size, url})`. Таймаут 300с.
|
||||||
|
6. **POST /api/upload_refs** {session, files: refs}:
|
||||||
|
- бэк: для каждого ref: `size > 50МБ` → delete+skip; `GET url` (pull egress, timeout 120с) → `content`; `content > 50МБ` → delete+skip; `add_file(sid, name, content)` (лимит сессии 500МБ); `delete url` с ВМ.
|
||||||
|
- ошибка сети/DNS → **502 «Pull failed»** (весь запрос падает).
|
||||||
|
7. Тест-режим `?upload-only=1` — стоп после фазы 1.
|
||||||
|
|
||||||
|
## Этап 2 — обработка (SSE, фаза 2)
|
||||||
|
8. `EventSource /api/process_stream/<sid>`; воркер-поток → `obfuscate_files(all_files)`.
|
||||||
|
9. **obfuscator (проход 1)**: `expand_zips` (повторная распаковка zip, basename, защита от бомб) → `_dedupe_file_names` → для каждого: `extract_text` (исключение → `skipped`, событие `done` на этапе извлечения) → `scan_regex` → `extract_done` (total_chars, per_file) → `scan_llm_ner` (чанки: file_start/file_chunk/file_done; отмена → CancelRequested).
|
||||||
|
10. **проход 2**: `apply_replacements` для каждого (битые/скан → пропуск; при отмене — только `llm_done`) → `done` → `build_zip` + `build_mapping_csv` → событие `result`/`cancelled`.
|
||||||
|
11. Фронт: 3-секционная таблица (Обработанные / Текущий / Ожидают); статусы: `done` (до extract_done = битый → **skipped «не извлечён»** v0.0.71), `current`, `analyzed`, `pending`; `complete`/`cancelled` → статистика, кнопки ZIP/CSV, заморозка сессии.
|
||||||
|
|
||||||
|
## Найденные баги и несоответствия
|
||||||
|
1. **[исправлено v0.0.71]** Статус «пропущен» использовался для ДВУХ разных причин: лимит и «не извлёкся». Теперь: «пропущен (лимит)» и «не извлечён» (пустой/битый/скан).
|
||||||
|
2. **[исправлено v0.0.71]** over-файлы во время обработки попадали в группу «Ожидают обработки» (нет procState → pending) с оценкой времени. Теперь — отдельная группа «⛔ Пропущены (сверх лимита)».
|
||||||
|
3. **[открыто]** `upload_refs` неатомарен: при ошибке pull (DNS/сеть) часть файлов уже добавлена в сессию и удалена с ВМ, но фронт получает 502 и бросает — сессия-сирота, файлы на ВМ частично остаются (инцидент 18:42). Нужны ретраи/атомарность.
|
||||||
|
4. **[открыто]** `upload_refs` при превышении суммарного лимита сессии отвечает «Session not found» (404) — add_file возвращает False и для отсутствия сессии, и для лимита; сообщение неверное.
|
||||||
|
5. **[открыто, риск]** Повторный `expand_zips` на бэке (с `os.path.basename` — обрезает пути) при отправке zip как есть (v0.0.69) ломает соответствие idx: количество файлов бэка ≠ фронта → `sendIdx[d.idx]` = undefined. Обычно не срабатывает (фронт сам раскрывает), но риск при расхождении allowedExt.
|
||||||
|
6. **[открыто]** allowedExt не совпадают: фронт раскрывает из zip только документы, бэк `expand_zips` берёт ВСЕ файлы (без фильтра) — расхождение поведения при zip «как есть».
|
||||||
|
7. **[открыто, косметика]** Счётчик «Добавлено из папки: N» завышается при дедупе (`added++` безусловно после `addFileWithDedup`).
|
||||||
|
8. **[открыто]** Три разных отображения одной причины (лимит) до обработки / при старте / в обработке — унифицировано в v0.0.71 (группа over).
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
- `node --check` — OK, `py_compile` — OK.
|
||||||
|
- Локально (v0.0.71): группа «⛔ Пропущены (сверх лимита)» + «пропущен (лимит)» — работает.
|
||||||
|
Полный цикл локально невозможен из-за CORS ВМ-буфера (разрешён только origin прода).
|
||||||
|
- Версия 0.0.70 → 0.0.71.
|
||||||
@@ -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_URL` | `https://api.aillm.ru/v1/chat/completions` |
|
||||||
| `LLM_MODEL` | `gpt-oss-120b` |
|
| `LLM_MODEL` | `gpt-oss-120b` |
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Код-ревью DrHider — ответ Соннета (2026-08-24)
|
||||||
|
|
||||||
|
_sonnet. Промпт: `docs/code-review-sonnet.md` (12 файлов кода, 8 вопросов)._
|
||||||
|
_Ревью по v0.0.72. Ничего не исправлено — только задокументировано._
|
||||||
|
|
||||||
|
## 🔴 Критичные
|
||||||
|
|
||||||
|
| Баг | Файл | ~строка | Суть | Фикс |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **SSRF** | `api_bp.py` `upload_refs` | ~93 | `url` из клиентского JSON без валидации → `httpx.stream("GET", url)` + `follow_redirects=True`. Доступ к metadata/kube-apiserver | валидировать URL-prefix == `VM_UPLOAD_URL` до запроса |
|
||||||
|
| **Path traversal / zip slip** | `api_bp.py` + `builder.py` | ~88, ~55 | `name = ref.get("name")` без санитизации → `../../evil.md` в выходном ZIP | `name = os.path.basename(ref.get("name",""))` |
|
||||||
|
|
||||||
|
## 🟠 Средние
|
||||||
|
|
||||||
|
| Баг | Файл | ~строка | Суть |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Серверная ошибка не отображается | `api_bp.py` + `index.html` | ~388, ~840 | сервер шлёт `event: error` — это встроенное имя EventSource; клиент не слушает кастомное → «SSE connection failed» вместо реального msg. Переименовать в `proc_error` |
|
||||||
|
| idx-мисматч при `expand_zips` на бэке | `obfuscator.py` + `index.html` | ~90, sendIdx | фронт фильтрует zip по `.pdf/.doc/.docx/.txt/.md`, бэк расширяет ВСЕ файлы → число/порядок не совпадает → `sendIdx[idx]=undefined` |
|
||||||
|
| Слабый SID (48 бит) | `session.py` | 85 | `uuid4().hex[:12]`; убрать `[:12]` → 128 бит |
|
||||||
|
| ZIP-бомба: обход через поддельный `file_size` | `extractor.py` | ~240 | лимит проверяется по `info.file_size` (central dir, подделывается) ДО чтения; проверять ПОСЛЕ `zf.read()` |
|
||||||
|
| Неатомарность pull при ошибке | `api_bp.py` `upload_refs` | ~115 | при исчерпании ретраев 502 БЕЗ `session_id`; частичная сессия-сирота до TTL |
|
||||||
|
|
||||||
|
## 🟡 Мелкие
|
||||||
|
|
||||||
|
| Баг | Файл | ~строка | Суть |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Отмена не работает в extract-фазе | `obfuscator.py` | ~130 | в extract-цикле нет `cancel_event.is_set()`; .doc (liberta 120с) → отмена ждёт все извлечения |
|
||||||
|
| TOCTOU: сессия между check и pause_ttl | `api_bp.py` | ~165-171 | `get_files` (lock снят) → `pause_ttl`; маловероятно |
|
||||||
|
| `fileTimers` не заполняется | `index.html` | ~792 | мёртвый код, fallback всегда 0.0с |
|
||||||
|
| Self-XSS: `f.name` в innerHTML | `index.html` | ~280, ~380 | имя файла без escaping → `textContent`/`escapeHtml()` |
|
||||||
|
| ZIP с non-doc расширениями → мусор | `extractor.py` | ~257 | фильтровать расширения в `expand_zips` |
|
||||||
|
| Сессия не чистится после `/download` | `api_bp.py` | ~430 | 500МБ висит 30 мин → OOM; `cleanup(sid)` |
|
||||||
|
| LLM-таймаут тихо обнуляет чанк | `llm_client.py` + `scanner.py` | ~70, ~220 | `except → return []`, PII не найден без предупреждения |
|
||||||
|
| Debug-эндпоинт `session_files` | `api_bp.py` | ~139 | список файлов любой сессии по SID без токена |
|
||||||
|
| LLM prompt injection | `scanner.py` | ~200 | текст документа дословно в prompt → подавление NER |
|
||||||
|
|
||||||
|
## Подтверждено корректным (Соннет)
|
||||||
|
- Различимость «сессия не найдена» vs «лимит» ✓
|
||||||
|
- Гонок на `cancel_event`/`queue` нет (thread-safe) ✓
|
||||||
|
- Утечек httpx/zipfile/EventSource/таймеров нет ✓
|
||||||
|
- Пустой txt, битый pdf, 0 файлов/все сверх лимита — обрабатываются ✓
|
||||||
|
|
||||||
|
## Что уже исправлено ранее (до этого ревью)
|
||||||
|
- Ретраи pull (v0.0.72) — Соннет отметил остаточную неатомарность.
|
||||||
|
- Разделение статусов «пропущен (лимит)» / «не извлечён» (v0.0.71).
|
||||||
|
- Счётчик дедупа (v0.0.72).
|
||||||
|
|
||||||
|
## TODO-связь
|
||||||
|
- Открытый баг кнопки «Обфусцировать» при 0 файлов — `docs/WhatTODO.md` (не покрыт ревью Соннета).
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Массовое тестирование v0.0.72 на проде — 4 блока (2026-08-24)
|
||||||
|
|
||||||
|
_tests. Прод https://drhider.pythonk8s.dev.nubes.ru/, редеплой 19:25 (v0.0.72)._
|
||||||
|
|
||||||
|
## Блок 1 — выбор папки (всё ✓)
|
||||||
|
- Рекурсия + относительный путь: `Подпапка/Акт1.txt`.
|
||||||
|
- Вложенный ZIP с кириллицей: `архивы/док.zip` → `архивы/Отчёт/Справка_Сидорова.txt`, `архивы/Счёт_на_оплату.txt` (путь zip + путь внутри).
|
||||||
|
- Дедуп: дубль `Договор1.txt` отсеян → «Добавлено из папки: 4 файла» (было бы 5).
|
||||||
|
- Не-документ `.bin` (60 МБ) — пропущен.
|
||||||
|
|
||||||
|
## Блок 2 — полный цикл (всё ✓)
|
||||||
|
- 4 файла: «Обработано 4 файлов: 9.9с, ИИ 9.7с, токены 2508».
|
||||||
|
- CSV замен (152 Б): `+7 111. → +7_000_000_0001`, `Ивановым И.И. → Семёнов_0001`,
|
||||||
|
`Петрова Анна → Николаев_0001`, `Сидорова → Семёнов_0002`.
|
||||||
|
- ZIP (775 Б) с путями: `Договор1.md`, `Подпапка/Акт1.md`, `архивы/Отчёт/Справка_Сидорова.md`, `архивы/Счёт_на_оплату.md`.
|
||||||
|
- Содержимое обезличено: «Договор с Семёнов_0001, тел +7_000_000_0001» и т.д.
|
||||||
|
|
||||||
|
## Блок 3 — прерывание (всё ✓)
|
||||||
|
- 9 txt, прервано на 9-й секунде: «⏹ Остановлено. Сохранено 4 из 9 файлов + таблица замен (6.5с, токены 3183)».
|
||||||
|
- Частичный ZIP — только 4 готовых: `файл_3.md, файл_4.md, файл_5.md, файл_7.md`.
|
||||||
|
- CSV замен сохранён (1149 Б). Кнопки скачивания видны.
|
||||||
|
|
||||||
|
## Блок 4 — заморозка/UI (всё ✓)
|
||||||
|
- После прерывания: `fi.disabled`, `ub.disabled`, «Новая сессия» видна (заморозка).
|
||||||
|
- HELP открывает модалку (display:flex).
|
||||||
|
- «Новая сессия»: список очищен, статус пуст, dl-кнопки скрыты.
|
||||||
|
|
||||||
|
## Найденный мелкий баг
|
||||||
|
- После «Новой сессии» кнопка «Обфусцировать» активна при 0 файлов
|
||||||
|
(`resetAll()`: `rr()` ставит `ub.disabled=cntMain===0` (true), затем `ub.disabled=false` перекрывает).
|
||||||
|
Нажатие безопасно (`uploadFiles` → `if(sf.length===0) return`), но кнопка выглядит активной.
|
||||||
|
Фикс — 1 строка (не перекрывать после `rr()`). Открыто, ждёт решения.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Тест v0.0.72 на проде — статусы, счётчик, полный цикл (2026-08-24)
|
||||||
|
|
||||||
|
_tests. Прод https://drhider.pythonk8s.dev.nubes.ru/, редеплой 19:25 (v0.0.72)._
|
||||||
|
|
||||||
|
## Сценарий
|
||||||
|
Папка: битый PDF (scan1.pdf) + 3 txt + файл 51 МБ (Big.txt).
|
||||||
|
|
||||||
|
## Результаты (все ✓)
|
||||||
|
1. **Счётчик дедупа (v0.0.72)** — папка с дублем [Договор.txt, Договор.txt, Акт.txt]
|
||||||
|
→ «✅ Добавлено из папки: 3 файла» (дубль отсеян, раньше было бы 4).
|
||||||
|
2. **Статус «не извлечён» (v0.0.71)** — во время обработки битый `scan1.pdf` в группе
|
||||||
|
«✓ Обработанные (1)» со статусом «не извлечён» (раньше было «пропущен»).
|
||||||
|
3. **Статус «пропущен (лимит)» (v0.0.71)** — `Big.txt 51.0 MB` в отдельной группе
|
||||||
|
«⛔ Пропущены (сверх лимита)» (раньше попадал в «Ожидают обработки»).
|
||||||
|
4. **Полный цикл** — «✅ Обработано 3 файлов: 5.6с, ИИ 5.4с» (3 txt), битый/большой исключены.
|
||||||
|
5. **HELP + текст ограничений** (v0.0.70) — на месте.
|
||||||
|
6. **Ретраи pull (v0.0.72)** — загрузка прошла штатно (код на бэке, косвенная проверка).
|
||||||
|
|
||||||
|
## Замечания
|
||||||
|
- Статусы «не извлечён»/«пропущен (лимит)» видны ТОЛЬКО во время обработки
|
||||||
|
(3-секционная таблица); после завершения `rr()` рисует обычную таблицу.
|
||||||
|
- Страница приведена в чистое состояние («Новая сессия» → «Нет выбранных файлов»).
|
||||||
@@ -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 байт).
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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, не кода слоя).
|
||||||
@@ -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_URL` | `https://api.aillm.ru/v1/chat/completions` |
|
||||||
| `LLM_MODEL` | `gpt-oss-120b` |
|
| `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)
|
## Связь с ВМ contracts (legacy)
|
||||||
|
|
||||||
На ВМ `5.172.178.213` (contracts.kube5s.ru) есть старая версия drhider:
|
На ВМ `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**, заменяет ВМ-версию.
|
Этот проект (`drhider.pythonk8s.dev.nubes.ru`) — **независимый managed Flask**, заменяет ВМ-версию.
|
||||||
|
|
||||||
SSH к ВМ: `ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213`
|
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`, поэтому файлы на ВМ не удаляются.
|
||||||
|
|||||||
Vendored
+1194
File diff suppressed because it is too large
Load Diff
Vendored
+1218
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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_URL` | `https://api.aillm.ru/v1/chat/completions` | URL LLM API |
|
||||||
| `LLM_MODEL` | `gpt-oss-120b` | Модель для NER |
|
| `LLM_MODEL` | `gpt-oss-120b` | Модель для NER |
|
||||||
|
|
||||||
|
|||||||
@@ -3,3 +3,15 @@
|
|||||||
не надо самостоятельно что либо "УЛуЧШАТЬ" - никаких изменений рабочего кода без прямого разрешения
|
не надо самостоятельно что либо "УЛуЧШАТЬ" - никаких изменений рабочего кода без прямого разрешения
|
||||||
есть сомнения - лучше остановись и спроси
|
есть сомнения - лучше остановись и спроси
|
||||||
ВСЁ записывай в хистори ! что планировал что сделал в чём ошибся и тд
|
ВСЁ записывай в хистори ! что планировал что сделал в чём ошибся и тд
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TODO (следующая правка кода)
|
||||||
|
|
||||||
|
1. **Кнопка «Обфусцировать» активна при 0 файлов после «Новой сессии»** (найдено 2026-08-24,
|
||||||
|
массовое тестирование v0.0.72, `History/tests/2026-08-24-v072-many-tests.md`).
|
||||||
|
Причина: `resetAll()` сначала вызывает `rr()` (ставит `ub.disabled = cntMain === 0` → true),
|
||||||
|
затем `ub.disabled = false` перекрывает это. Нажатие безопасно (`uploadFiles` проверяет
|
||||||
|
`sf.length === 0`), но кнопка выглядит активной при пустом списке.
|
||||||
|
Фикс: в `resetAll()` не перекрывать `ub.disabled` после `rr()` (либо перенести `ub.disabled`
|
||||||
|
установку до `rr()`). Бамп до v0.0.73 + редеплой.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Код-ревью DrHider — промпт для Соннета
|
||||||
|
|
||||||
|
Ты — строгий ревьюер. Проведи код-ревью сервиса DrHider (Flask + vanilla JS) —
|
||||||
|
обфускация персональных данных в документах.
|
||||||
|
|
||||||
|
## СТРОГО просматривай ТОЛЬКО эти файлы (больше ничего не читай)
|
||||||
|
|
||||||
|
- `site/app.py`
|
||||||
|
- `site/routes/api_bp.py`
|
||||||
|
- `site/session.py`
|
||||||
|
- `site/templates/index.html`
|
||||||
|
- `drhider/__init__.py`
|
||||||
|
- `drhider/builder.py`
|
||||||
|
- `drhider/config.py`
|
||||||
|
- `drhider/extractor.py`
|
||||||
|
- `drhider/llm_client.py`
|
||||||
|
- `drhider/obfuscator.py`
|
||||||
|
- `drhider/replacer.py`
|
||||||
|
- `drhider/scanner.py`
|
||||||
|
|
||||||
|
НЕ смотри: `History/`, `docs/`, `tests/`, `file_list.csv`, `files/`, `TMP/`, `TSTFILES/`.
|
||||||
|
|
||||||
|
## Контекст (кратко, не лезь в файлы)
|
||||||
|
|
||||||
|
- Загрузка файлов: браузер → PUT на ВМ-буфер (`contracts.kube5s.ru/drhider-upload/`),
|
||||||
|
затем `POST /api/upload_refs` → Flask тянет файлы egress-GET (pull) в сессию.
|
||||||
|
- Обработка: `GET /api/process_stream/<sid>` (SSE) → воркер-поток → `obfuscate_files()`
|
||||||
|
→ извлечение текста → regex+LLM-сканирование → замена → ZIP + mapping.csv.
|
||||||
|
- Лимиты: 50 МБ/файл, 500 МБ/сессия.
|
||||||
|
- Есть мягкая отмена (кнопка «Прервать», `cancel_event`).
|
||||||
|
|
||||||
|
## Вопросы — ответь по каждому, с указанием файла и строки
|
||||||
|
|
||||||
|
1. **Корректность загрузки** (`api_bp.py::upload_refs`, `index.html::uploadFiles`):
|
||||||
|
- атомарность pull при ошибке на середине списка файлов;
|
||||||
|
- ретраи при DNS/сетевых сбоях — покрыты ли все transient-ошибки;
|
||||||
|
- различимы ли «сессия не найдена» vs «превышен лимит сессии».
|
||||||
|
|
||||||
|
2. **SSE/потоки** (`api_bp.py::process_stream`, `obfuscator.py`):
|
||||||
|
- корректна ли обработка disconnect клиента и остановка воркера;
|
||||||
|
- нет ли гонок при `cancel_event`/`queue`/`pause_ttl`/`resume_ttl`;
|
||||||
|
- TTL сессии: не умирает ли сессия во время долгой обработки.
|
||||||
|
|
||||||
|
3. **Соответствие индексов** фронт↔бэк (события `start/done/file_*` с `idx`):
|
||||||
|
- `obfuscator.py::expand_zips` и `_dedupe_file_names` могут изменить число/порядок файлов
|
||||||
|
после того, как фронт уже построил список — не ломается ли `sendIdx[d.idx]`?
|
||||||
|
|
||||||
|
4. **Статусы файлов** (`index.html`): консистентность «пропущен (лимит)» / «не извлечён» /
|
||||||
|
«done» / «analyzed» / «pending»; нет ли путей, где статус зависает или перетирается.
|
||||||
|
|
||||||
|
5. **Безопасность** (`extractor.py`, `session.py`, `api_bp.py`):
|
||||||
|
- path traversal через имена файлов (в т.ч. из ZIP);
|
||||||
|
- zip-бомба (расширение, сжатие, суммарный размер);
|
||||||
|
- инъекции/экранирование имён в HTML (`index.html` — вывод имён файлов).
|
||||||
|
|
||||||
|
6. **Утечки/ресурсы**: закрытие `httpx`-сессий, `zipfile`, `EventSource`/`XHR`, таймеры
|
||||||
|
(`setInterval`) — всё ли очищается в `resetAll`/`finishProcUI`/`finally`.
|
||||||
|
|
||||||
|
7. **Краевые случаи**: пустой файл, файл без текста, битый PDF, дубли имён, отмена
|
||||||
|
на каждом этапе, 0 файлов, все файлы сверх лимита.
|
||||||
|
|
||||||
|
8. **Что я мог пропустить** — любые реальные баги/риски, не перечисленные выше.
|
||||||
|
|
||||||
|
## Правила ответа
|
||||||
|
|
||||||
|
- БЕЗ воды. Только конкретика: файл, функция, строка, суть бага, серьёзность (🔴/🟠/🟡), фикс.
|
||||||
|
- Не предлагай «улучшения» вне рамок ревью (рефакторинг, стиль) — только баги и риски.
|
||||||
|
- Если тебе нужен контекст, которого нет выше (назначение функции, формат данных,
|
||||||
|
ожидаемое поведение), — **задай уточняющий вопрос, не домысливай**.
|
||||||
|
- В конце — итоговая таблица: баг | файл | строка | серьёзность | что делать.
|
||||||
@@ -336,9 +336,16 @@ def expand_zips(files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, s
|
|||||||
name = os.path.basename(name)
|
name = os.path.basename(name)
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
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)
|
inner_data = zf.read(info)
|
||||||
total_uncompressed += len(inner_data)
|
total_uncompressed += len(inner_data)
|
||||||
|
if total_uncompressed > MAX_UNCOMPRESSED:
|
||||||
|
log.warning("ZIP uncompressed limit exceeded (mid-read): %s", fname)
|
||||||
|
break
|
||||||
|
|
||||||
# Вложенные ZIP добавляем в очередь на повторную распаковку
|
# Вложенные ZIP добавляем в очередь на повторную распаковку
|
||||||
queue.append((name, inner_data, ""))
|
queue.append((name, inner_data, ""))
|
||||||
|
|||||||
@@ -140,7 +140,13 @@ class TwoPassObfuscator:
|
|||||||
# Без параллельности: liberta не выдерживает конкурентные вызовы
|
# Без параллельности: liberta не выдерживает конкурентные вызовы
|
||||||
# (libreoffice падает с javaldx).
|
# (libreoffice падает с javaldx).
|
||||||
|
|
||||||
|
cancelled_extract = False
|
||||||
for i, (fname, content, ctype) in enumerate(files):
|
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
|
display_name = os.path.basename(fname) or fname
|
||||||
if progress_cb:
|
if progress_cb:
|
||||||
progress_cb("start", i, total, display_name, 0.0)
|
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()})
|
per_file={fname: len(t) for fname, t in all_texts.items()})
|
||||||
|
|
||||||
# LLM-сканирование (получает уже найденное regex'ом чтобы не дублировать)
|
# LLM-сканирование (получает уже найденное regex'ом чтобы не дублировать)
|
||||||
cancelled_llm = False
|
cancelled_llm = cancelled_extract
|
||||||
if self._llm_client:
|
if not cancelled_llm and self._llm_client:
|
||||||
try:
|
try:
|
||||||
scanner.scan_llm_ner(
|
scanner.scan_llm_ner(
|
||||||
all_texts, self._mapping, self._llm_client, self._counters,
|
all_texts, self._mapping, self._llm_client, self._counters,
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
в другом приложении попросил использовать загрузку через ВМ как тут в хайдере
|
||||||
|
вышло не так как я хотел а именно чтобы 1:1
|
||||||
|
|
||||||
|
давай спланируем сначала
|
||||||
|
думаю что надо 3 независимых слоя
|
||||||
|
|
||||||
|
1. фронт: таблица кнопки подсказки и тд - загрузка путей файлов в таблицу - просто выбором файлов, список файлов из архива, ВСЕ файлы из выбранной папки
|
||||||
|
ограничение по расширениям, рекурсивный обход всех подпапок, файлы не дублировать если одинаковые имя/размер, если размер иной - добавлять суффикс, в таблице виден путь файла - в тч когда из архива
|
||||||
|
|
||||||
|
2. собственно закачка файлов - с отображением процесса закачки в таблице
|
||||||
|
|
||||||
|
3. логика собственно приложения
|
||||||
+1
-1
@@ -21,7 +21,7 @@ if _sys_path_root not in sys.path:
|
|||||||
sys.path.insert(0, _sys_path_root)
|
sys.path.insert(0, _sys_path_root)
|
||||||
|
|
||||||
# Версия приложения (меняется при изменениях)
|
# Версия приложения (меняется при изменениях)
|
||||||
VERSION = "0.0.70"
|
VERSION = "0.0.78"
|
||||||
|
|
||||||
|
|
||||||
def setup_logging():
|
def setup_logging():
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from .main_bp import main_bp
|
from .main_bp import main_bp
|
||||||
from .health_bp import health_bp
|
from .health_bp import health_bp
|
||||||
from .api_bp import api_bp
|
from .api_bp import api_bp
|
||||||
|
from upload.backend.upload_refs import create_upload_refs_blueprint
|
||||||
|
|
||||||
|
|
||||||
def register_routes(app):
|
def register_routes(app):
|
||||||
@@ -12,3 +13,13 @@ def register_routes(app):
|
|||||||
app.register_blueprint(main_bp)
|
app.register_blueprint(main_bp)
|
||||||
app.register_blueprint(health_bp)
|
app.register_blueprint(health_bp)
|
||||||
app.register_blueprint(api_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,
|
||||||
|
}))
|
||||||
|
|||||||
+11
-70
@@ -17,20 +17,19 @@ import time
|
|||||||
import zipfile
|
import zipfile
|
||||||
import traceback
|
import traceback
|
||||||
import logging
|
import logging
|
||||||
import httpx
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from flask import Blueprint, request, send_file, jsonify, Response, stream_with_context
|
from flask import Blueprint, request, send_file, jsonify, Response, stream_with_context
|
||||||
|
|
||||||
from drhider import obfuscate_files, LLMClient
|
from drhider import obfuscate_files, LLMClient
|
||||||
from session import (create_session, add_file, get_files, store_result,
|
from upload.backend.session import (create_session, add_file, get_files, store_result,
|
||||||
get_result, store_csv, get_csv, cleanup, file_count,
|
get_result, store_csv, get_csv, cleanup, file_count,
|
||||||
MAX_FILE_BYTES, pause_ttl, resume_ttl,
|
MAX_FILE_BYTES, pause_ttl, resume_ttl,
|
||||||
request_cancel, get_cancel_event)
|
request_cancel, get_cancel_event)
|
||||||
|
from upload.backend.upload_refs import safe_name
|
||||||
|
|
||||||
api_bp = Blueprint("api", __name__, url_prefix="/api")
|
api_bp = Blueprint("api", __name__, url_prefix="/api")
|
||||||
log = logging.getLogger("routes.api_bp")
|
log = logging.getLogger("routes.api_bp")
|
||||||
|
|
||||||
|
|
||||||
def _disconnect_exceptions():
|
def _disconnect_exceptions():
|
||||||
"""Исключения, означающие отключение клиента SSE."""
|
"""Исключения, означающие отключение клиента SSE."""
|
||||||
return (GeneratorExit, BrokenPipeError, ConnectionResetError)
|
return (GeneratorExit, BrokenPipeError, ConnectionResetError)
|
||||||
@@ -50,16 +49,17 @@ def upload():
|
|||||||
added = 0
|
added = 0
|
||||||
had_unnamed = False
|
had_unnamed = False
|
||||||
for f in uploaded:
|
for f in uploaded:
|
||||||
if not f.filename:
|
name = safe_name(f.filename)
|
||||||
|
if not name:
|
||||||
had_unnamed = True
|
had_unnamed = True
|
||||||
continue
|
continue
|
||||||
data = f.read()
|
data = f.read()
|
||||||
log.info("upload: sid=%s file=%r size=%d", sid, f.filename, len(data))
|
log.info("upload: sid=%s file=%r size=%d", sid, name, len(data))
|
||||||
if len(data) > MAX_FILE_BYTES:
|
if len(data) > MAX_FILE_BYTES:
|
||||||
log.warning("upload: file exceeds %dMB, skipped sid=%s file=%r size=%d",
|
log.warning("upload: file exceeds %dMB, skipped sid=%s file=%r size=%d",
|
||||||
MAX_FILE_BYTES // (1024 * 1024), sid, f.filename, len(data))
|
MAX_FILE_BYTES // (1024 * 1024), sid, name, len(data))
|
||||||
continue
|
continue
|
||||||
if not add_file(sid, f.filename, data):
|
if not add_file(sid, name, data):
|
||||||
log.warning("upload: session not found/limit, sid=%s file=%r", sid, f.filename)
|
log.warning("upload: session not found/limit, sid=%s file=%r", sid, f.filename)
|
||||||
return jsonify({"ok": False, "error": "Session not found"}), 404
|
return jsonify({"ok": False, "error": "Session not found"}), 404
|
||||||
added += 1
|
added += 1
|
||||||
@@ -73,65 +73,6 @@ def upload():
|
|||||||
return jsonify({"ok": True, "session": sid, "count": file_count(sid)})
|
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 = ref.get("name")
|
|
||||||
url = ref.get("url")
|
|
||||||
if not name or not 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
|
|
||||||
with client.stream("GET", url) as resp:
|
|
||||||
resp.raise_for_status()
|
|
||||||
content = b"".join(resp.iter_bytes())
|
|
||||||
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):
|
|
||||||
log.warning("upload_refs: session not found/limit, sid=%s file=%r", sid, name)
|
|
||||||
return jsonify({"ok": False, "error": "Session not found"}), 404
|
|
||||||
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"])
|
@api_bp.route("/session_files/<sid>", methods=["GET"])
|
||||||
def session_files(sid):
|
def session_files(sid):
|
||||||
"""Отладка: список файлов сессии с размерами (для теста загрузки)."""
|
"""Отладка: список файлов сессии с размерами (для теста загрузки)."""
|
||||||
@@ -355,7 +296,7 @@ def process_stream(sid):
|
|||||||
_, msg = evt
|
_, msg = evt
|
||||||
log.error("process_stream: error event sid=%s msg=%r", sid, msg)
|
log.error("process_stream: error event sid=%s msg=%r", sid, msg)
|
||||||
try:
|
try:
|
||||||
yield f"event: error\ndata: {json.dumps({'error': msg})}\n\n"
|
yield f"event: proc_error\ndata: {json.dumps({'error': msg})}\n\n"
|
||||||
except _disconnect_exceptions() as e:
|
except _disconnect_exceptions() as e:
|
||||||
log.warning("process_stream: disconnect on error sid=%s err=%r", sid, e)
|
log.warning("process_stream: disconnect on error sid=%s err=%r", sid, e)
|
||||||
return
|
return
|
||||||
|
|||||||
+26
-1
@@ -4,12 +4,19 @@ Blueprint: главная страница (GET /).
|
|||||||
Отдаёт HTML-интерфейс DrHider.
|
Отдаёт HTML-интерфейс DrHider.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Blueprint, render_template, current_app
|
import os
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, current_app, send_from_directory
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# Blueprint: главная страница
|
# 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__)
|
main_bp = Blueprint("main", __name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -21,3 +28,21 @@ def index():
|
|||||||
"""
|
"""
|
||||||
version = current_app.config.get("VERSION", "0.0.0")
|
version = current_app.config.get("VERSION", "0.0.0")
|
||||||
return render_template("index.html", version=version)
|
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
@@ -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[:12]
|
|
||||||
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
|
|
||||||
Vendored
+1194
File diff suppressed because it is too large
Load Diff
Vendored
+1218
File diff suppressed because it is too large
Load Diff
+869
-925
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
@@ -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) == ""
|
||||||
@@ -75,7 +75,7 @@ def test_single_file():
|
|||||||
assert rv.status_code == 200
|
assert rv.status_code == 200
|
||||||
resp = json.loads(rv.data)
|
resp = json.loads(rv.data)
|
||||||
assert resp["ok"] is True
|
assert resp["ok"] is True
|
||||||
assert len(resp["session"]) == 12
|
assert len(resp["session"]) == 32
|
||||||
assert resp["count"] == 1
|
assert resp["count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
Submodule
+1
Submodule upload-platform added at 1750a8497f
@@ -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)`.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Переиспользуемые слои загрузки файлов.
|
||||||
|
|
||||||
|
Пакет не выполняет импортов и не запускает побочных действий при загрузке.
|
||||||
|
Рабочая picker-only логика находится в подпакетах ``frontend`` и ``zip``.
|
||||||
|
"""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Backend переиспользуемого модуля загрузки: upload_refs + session."""
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""In-memory хранилище сессий с TTL и лимитами.
|
||||||
|
|
||||||
|
Каждая операция — в отдельном файле, общее состояние — в state.py.
|
||||||
|
Импорт как единый пакет:
|
||||||
|
|
||||||
|
from upload.backend.session import create_session, add_file, get_files
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .create_session import create_session
|
||||||
|
from .add_file import add_file
|
||||||
|
from .get_files import get_files, file_count
|
||||||
|
from .store_result import store_result, get_result
|
||||||
|
from .store_csv import store_csv, get_csv
|
||||||
|
from .ttl import touch, pause_ttl, resume_ttl
|
||||||
|
from .cancel import request_cancel, get_cancel_event
|
||||||
|
from .cleanup import cleanup
|
||||||
|
from .state import TTL_SECONDS, MAX_FILE_BYTES, MAX_SESSION_BYTES, configure
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"create_session",
|
||||||
|
"add_file",
|
||||||
|
"get_files",
|
||||||
|
"file_count",
|
||||||
|
"store_result",
|
||||||
|
"get_result",
|
||||||
|
"store_csv",
|
||||||
|
"get_csv",
|
||||||
|
"touch",
|
||||||
|
"pause_ttl",
|
||||||
|
"resume_ttl",
|
||||||
|
"request_cancel",
|
||||||
|
"get_cancel_event",
|
||||||
|
"cleanup",
|
||||||
|
"configure",
|
||||||
|
"TTL_SECONDS",
|
||||||
|
"MAX_FILE_BYTES",
|
||||||
|
"MAX_SESSION_BYTES",
|
||||||
|
]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""add_file — добавить файл в сессию (с проверкой суммарного лимита)."""
|
||||||
|
|
||||||
|
from . import state
|
||||||
|
|
||||||
|
|
||||||
|
def add_file(sid: str, filename: str, content: bytes) -> bool:
|
||||||
|
"""Добавить файл в сессию.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sid: Идентификатор сессии
|
||||||
|
filename: Имя файла
|
||||||
|
content: Бинарное содержимое
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если добавлено; False если сессии нет или превышен лимит сессии.
|
||||||
|
"""
|
||||||
|
with state._lock:
|
||||||
|
s = state._sessions.get(sid)
|
||||||
|
if not s:
|
||||||
|
return False
|
||||||
|
total = sum(len(c) for _, c in s["files"])
|
||||||
|
if total + len(content) > state.MAX_SESSION_BYTES:
|
||||||
|
return False # превышен суммарный лимит сессии
|
||||||
|
s["files"].append((filename, content))
|
||||||
|
return True
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""request_cancel / get_cancel_event — мягкое прерывание обработки сессии."""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .state import _sessions, _lock
|
||||||
|
|
||||||
|
|
||||||
|
def request_cancel(sid: str) -> bool:
|
||||||
|
"""Запросить мягкое прерывание обработки сессии.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если сессия существует и отмена запрошена, False если нет.
|
||||||
|
"""
|
||||||
|
with _lock:
|
||||||
|
s = _sessions.get(sid)
|
||||||
|
if not s:
|
||||||
|
return False
|
||||||
|
s["cancel"].set()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_cancel_event(sid: str) -> Optional[threading.Event]:
|
||||||
|
"""Получить событие отмены сессии (или None, если сессии нет)."""
|
||||||
|
with _lock:
|
||||||
|
s = _sessions.get(sid)
|
||||||
|
return s["cancel"] if s else None
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""cleanup — удалить сессию."""
|
||||||
|
|
||||||
|
from .state import _sessions, _lock
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup(sid: str):
|
||||||
|
"""Удалить сессию и остановить её TTL-таймер."""
|
||||||
|
with _lock:
|
||||||
|
s = _sessions.pop(sid, None)
|
||||||
|
if s and s.get("timer"):
|
||||||
|
s["timer"].cancel()
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""create_session — создать новую сессию."""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from .state import _sessions, _lock, _start_timer
|
||||||
|
|
||||||
|
|
||||||
|
def create_session() -> str:
|
||||||
|
"""Создать новую сессию.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Уникальный идентификатор сессии (UUID).
|
||||||
|
"""
|
||||||
|
sid = uuid.uuid4().hex
|
||||||
|
with _lock:
|
||||||
|
_sessions[sid] = {
|
||||||
|
"files": [],
|
||||||
|
"result": None,
|
||||||
|
"cancel": threading.Event(),
|
||||||
|
"timer": _start_timer(sid),
|
||||||
|
}
|
||||||
|
return sid
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""get_files / file_count — чтение файлов сессии."""
|
||||||
|
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
from .state import _sessions, _lock
|
||||||
|
|
||||||
|
|
||||||
|
def get_files(sid: str) -> Optional[List[Tuple[str, bytes]]]:
|
||||||
|
"""Получить все файлы сессии.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
[(filename, content), ...] или None если сессия не найдена.
|
||||||
|
"""
|
||||||
|
with _lock:
|
||||||
|
s = _sessions.get(sid)
|
||||||
|
return list(s["files"]) if s else None
|
||||||
|
|
||||||
|
|
||||||
|
def file_count(sid: str) -> int:
|
||||||
|
"""Количество файлов в сессии."""
|
||||||
|
with _lock:
|
||||||
|
s = _sessions.get(sid)
|
||||||
|
return len(s["files"]) if s else 0
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Общее состояние сессий: хранилище, блокировка, константы, TTL-таймер.
|
||||||
|
|
||||||
|
Единая точка хранения состояния — все операции импортируют её.
|
||||||
|
Дробить state.py на файл-на-переменную не нужно: это данные, а не функции.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
# TTL сессии: 30 минут
|
||||||
|
TTL_SECONDS = 30 * 60
|
||||||
|
|
||||||
|
# Максимальный объём одного файла и суммарный объём файлов в сессии (защита памяти)
|
||||||
|
MAX_FILE_BYTES = 50 * 1024 * 1024 # 50 MB на один файл
|
||||||
|
MAX_SESSION_BYTES = 500 * 1024 * 1024 # 500 MB суммарно на сессию
|
||||||
|
|
||||||
|
_sessions: dict = {}
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _start_timer(sid: str) -> threading.Timer:
|
||||||
|
"""Запустить таймер автоочистки сессии через TTL."""
|
||||||
|
|
||||||
|
def _clean():
|
||||||
|
with _lock:
|
||||||
|
_sessions.pop(sid, None)
|
||||||
|
|
||||||
|
timer = threading.Timer(TTL_SECONDS, _clean)
|
||||||
|
timer.daemon = True
|
||||||
|
timer.start()
|
||||||
|
return timer
|
||||||
|
|
||||||
|
|
||||||
|
def configure(max_file_bytes: int = None, max_session_bytes: int = None,
|
||||||
|
ttl_seconds: int = None):
|
||||||
|
"""Переопределить лимиты/TTL из конфига приложения (глобально).
|
||||||
|
|
||||||
|
None — оставить текущее значение.
|
||||||
|
"""
|
||||||
|
global MAX_FILE_BYTES, MAX_SESSION_BYTES, TTL_SECONDS
|
||||||
|
if max_file_bytes is not None:
|
||||||
|
MAX_FILE_BYTES = max_file_bytes
|
||||||
|
if max_session_bytes is not None:
|
||||||
|
MAX_SESSION_BYTES = max_session_bytes
|
||||||
|
if ttl_seconds is not None:
|
||||||
|
TTL_SECONDS = ttl_seconds
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""store_csv / get_csv — сохранение и чтение CSV с таблицей замен."""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .state import _sessions, _lock
|
||||||
|
|
||||||
|
|
||||||
|
def store_csv(sid: str, csv_str: str) -> bool:
|
||||||
|
"""Сохранить CSV с таблицей замен.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если сохранено, False если сессии нет.
|
||||||
|
"""
|
||||||
|
with _lock:
|
||||||
|
s = _sessions.get(sid)
|
||||||
|
if not s:
|
||||||
|
return False
|
||||||
|
s["csv"] = csv_str
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_csv(sid: str) -> Optional[str]:
|
||||||
|
"""Получить CSV с таблицей замен.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Строка CSV или None если нет.
|
||||||
|
"""
|
||||||
|
with _lock:
|
||||||
|
s = _sessions.get(sid)
|
||||||
|
return s.get("csv") if s else None
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""store_result / get_result — сохранение и чтение результата обработки."""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .state import _sessions, _lock
|
||||||
|
|
||||||
|
|
||||||
|
def store_result(sid: str, zip_data: bytes) -> bool:
|
||||||
|
"""Сохранить результат обработки (ZIP-архив).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если сохранено, False если сессии нет.
|
||||||
|
"""
|
||||||
|
with _lock:
|
||||||
|
s = _sessions.get(sid)
|
||||||
|
if not s:
|
||||||
|
return False
|
||||||
|
s["result"] = zip_data
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_result(sid: str) -> Optional[bytes]:
|
||||||
|
"""Получить результат обработки.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ZIP-архив или None если сессия не найдена/результат не готов.
|
||||||
|
"""
|
||||||
|
with _lock:
|
||||||
|
s = _sessions.get(sid)
|
||||||
|
return s["result"] if s else None
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""touch / pause_ttl / resume_ttl — управление TTL-таймером сессии."""
|
||||||
|
|
||||||
|
from .state import _sessions, _lock, _start_timer
|
||||||
|
|
||||||
|
|
||||||
|
def touch(sid: str):
|
||||||
|
"""Продлить жизнь сессии: перезапустить TTL-таймер (если сессия существует)."""
|
||||||
|
with _lock:
|
||||||
|
s = _sessions.get(sid)
|
||||||
|
if not s:
|
||||||
|
return
|
||||||
|
if s.get("timer"):
|
||||||
|
s["timer"].cancel()
|
||||||
|
s["timer"] = _start_timer(sid)
|
||||||
|
|
||||||
|
|
||||||
|
def pause_ttl(sid: str):
|
||||||
|
"""Приостановить TTL сессии (во время обработки): сессия живёт, пока идёт воркер."""
|
||||||
|
with _lock:
|
||||||
|
s = _sessions.get(sid)
|
||||||
|
if s and s.get("timer"):
|
||||||
|
s["timer"].cancel()
|
||||||
|
s["timer"] = None
|
||||||
|
|
||||||
|
|
||||||
|
def resume_ttl(sid: str):
|
||||||
|
"""Возобновить TTL сессии (после завершения обработки): результат доступен ещё TTL."""
|
||||||
|
with _lock:
|
||||||
|
s = _sessions.get(sid)
|
||||||
|
if not s:
|
||||||
|
return
|
||||||
|
if s.get("timer"):
|
||||||
|
s["timer"].cancel()
|
||||||
|
s["timer"] = _start_timer(sid)
|
||||||
@@ -0,0 +1,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"]
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""Параметры слоя 2 (бэк) по умолчанию.
|
||||||
|
|
||||||
|
Переопределяются из конфига приложения через create_upload_refs_blueprint(cfg).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Ретраи pull из ВМ-буфера: защита от разовых DNS/сетевых сбоев (gaierror -5 и т.п.)
|
||||||
|
PULL_RETRIES = 3
|
||||||
|
PULL_RETRY_DELAY = 2 # секунды между попытками
|
||||||
|
|
||||||
|
# Доверенный префикс ВМ-буфера по умолчанию — валидация URL при pull (защита от SSRF)
|
||||||
|
VM_UPLOAD_PREFIX = "https://contracts.kube5s.ru/drhider-upload/"
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""pull_file — вытащить файл с ВМ-буфера исходящим GET с ретраями."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from .config import PULL_RETRIES, PULL_RETRY_DELAY
|
||||||
|
|
||||||
|
log = logging.getLogger("upload.upload_refs.pull")
|
||||||
|
|
||||||
|
|
||||||
|
def pull_file(client, url: str, retries: int = PULL_RETRIES,
|
||||||
|
delay: float = PULL_RETRY_DELAY, sid: str = None, name: str = None) -> bytes:
|
||||||
|
"""GET url с ретраями; читает по частям (stream).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: httpx.Client
|
||||||
|
url: URL файла на ВМ-буфере.
|
||||||
|
retries: число попыток.
|
||||||
|
delay: пауза между попытками (сек).
|
||||||
|
sid/name: для логирования (опционально).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Содержимое файла (bytes).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Последнюю ошибку попытки, если все ретраи не удались.
|
||||||
|
"""
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(retries):
|
||||||
|
try:
|
||||||
|
with client.stream("GET", url) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
return b"".join(resp.iter_bytes())
|
||||||
|
except Exception as e:
|
||||||
|
last_err = e
|
||||||
|
log.warning("pull: attempt %d/%d failed sid=%s file=%r: %r",
|
||||||
|
attempt + 1, retries, sid, name, e)
|
||||||
|
time.sleep(delay)
|
||||||
|
raise last_err if last_err else RuntimeError("pull failed")
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""safe_name — санитизация имени файла (защита от path traversal)."""
|
||||||
|
|
||||||
|
|
||||||
|
def safe_name(name: str) -> str:
|
||||||
|
"""Санитизировать имя файла: защита от path traversal, сохраняя подпапки.
|
||||||
|
|
||||||
|
Запрещает '..' и абсолютные пути; нормализует слэши. Возвращает "" если
|
||||||
|
имя пустое или небезопасное.
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
name = name.replace("\\", "/")
|
||||||
|
parts = [p for p in name.split("/") if p and p != "."]
|
||||||
|
if not parts or any(p == ".." for p in parts):
|
||||||
|
return ""
|
||||||
|
return "/".join(parts)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"]
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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) => ({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
}[character]));
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user