49 Commits
Author SHA1 Message Date
Repinoid 52da9a3923 Update upload interface and ignore temporary E2E artifacts
Deploy drhider / validate (push) Canceled after 0s
2026-09-16 07:42:31 +03:00
Repinoid 49bb4d70d8 feat: integrate upload-platform v0.2.2 into drhider (v0.0.78)
Deploy drhider / validate (push) Canceled after 0s
- Update upload/ module to v0.2.2 with modular Layer 1 (FilePicker) and Layer 2 (Streaming transit upload)
- Replace legacy manual file table in site/templates/index.html with FilePicker.initFilePicker
- Wire uploadViaVM with per-file status updates and abort signal support
- Add dist bundles to dist/ and site/static/dist/ with routes in site/routes/main_bp.py
- Add test_hardening.py and test_safe_name.py from upload-platform
- Bump version to 0.0.78 in site/app.py
- Document integration plan and report in History/upload-integration/
2026-09-15 12:23:31 +03:00
Repinoid 3fd6f1cffc 1
Deploy drhider / validate (push) Canceled after 0s
2026-09-14 18:40:13 +03:00
Repinoid 24bd89b511 Merge origin/master into master 2026-09-14 18:33:38 +03:00
Repinoid f90d97b276 chore: ignore agent temporary files 2026-09-14 18:25:27 +03:00
Repinoid 1ff305f38e chore: ignore nested upload-platform repository 2026-09-12 19:53:59 +03:00
Repinoid 741edafc4a chore: sync local VM mirror state 2026-09-12 19:48:37 +03:00
“Naeel” 04affca363 Merge remote-tracking branch 'origin/master'
Deploy drhider / validate (push) Canceled after 0s
2026-09-06 14:09:58 +03:00
“Naeel” d0128392e3 feat: add upload platform 2026-09-06 14:09:22 +03:00
“Naeel” 11716259a1 1
Deploy drhider / validate (push) Canceled after 0s
2026-09-05 13:27:35 +03:00
“Naeel” 4b33593a82 docs: document VM rsync sync
Deploy drhider / validate (push) Canceled after 0s
2026-08-31 20:59:26 +03:00
“Naeel” e7d771e00f fix: remove api blueprint syntax error
Deploy drhider / validate (push) Canceled after 0s
2026-08-31 20:55:26 +03:00
“Naeel” ce234132dd docs: README модуля — раздача в Flask, onXHR, слой 3
Deploy drhider / validate (push) Canceled after 0s
2026-08-26 07:12:27 +03:00
“Naeel” 3abcc6c1fc test: покрыть zip deflate (метод 8), skip в Node 18 (нет deflate-raw)
Deploy drhider / validate (push) Canceled after 0s
2026-08-25 19:17:52 +03:00
“Naeel” 0ad9e0047f fix: вернуть регистрацию activeXHR (onXHR) для отмены загрузки + тесты чистых функций
Deploy drhider / validate (push) Canceled after 0s
2026-08-25 19:14:30 +03:00
“Naeel” 52c3eb53f1 test: добавить нагрузочные/долгие тесты слоёв 1 и 2 (37 тестов всего)
Deploy drhider / validate (push) Canceled after 0s
2026-08-25 19:11:29 +03:00
“Naeel” 17e6dbd065 fix: resetAll — syncProcCtx до table.clear (неверный рендер «Готово 0/0» после новой сессии)
Deploy drhider / validate (push) Canceled after 0s
2026-08-25 17:29:17 +03:00
“Naeel” 53751a1f7b chore: удалить неиспользуемый site/session.py, bump v0.0.76
Deploy drhider / validate (push) Canceled after 0s
2026-08-25 08:56:45 +03:00
“Naeel” a33dff2f47 feat: интегрировать модуль upload в drhider (бэк blueprint + фронт ES-модули) 2026-08-25 08:52:50 +03:00
“Naeel” af172115b0 fix(upload): параметризация pullRetries/pullRetryDelay/pullTimeout, configure лимитов/TTL, onStatus в init, логирование sid/name 2026-08-25 08:37:09 +03:00
“Naeel” 5711e97fe5 feat: модуль upload — вынос слоёв 1 и 2 (выбор файлов + закачка через ВМ), файл-на-функцию, тесты 2026-08-25 08:08:14 +03:00
“Naeel” 5f5ed000b8 1
Deploy drhider / validate (push) Canceled after 0s
2026-08-25 07:58:51 +03:00
“Naeel” a18877415d docs: детализировать PLAN-upload-layers (2 слоя, файл-на-функцию, README для агента) 2026-08-25 07:57:55 +03:00
“Naeel” 7a4f642452 docs: тест стабильности v0.0.75 — состав, цикл, CSV/ZIP, кнопка, фильтр zip OK 2026-08-24 20:40:27 +03:00
“Naeel” 090f5e8993 docs: подтверждение v0.0.75 на проде — кнопка при 0 файлов, фильтр zip, регресс OK 2026-08-24 20:23:46 +03:00
“Naeel” d8dee114a3 docs: решение — историю git не переписываем, ключ убран из файлов, рекомендована ротация 2026-08-24 20:22:40 +03:00
“Naeel” f2141ec731 секрет-гигиена: убран LLM_API_KEY из README/DEPLOY/History (попал в коммите 0707d53 при копировании env из Штурвала)
Deploy drhider / validate (push) Canceled after 0s
2026-08-24 20:21:02 +03:00
“Naeel” eaa064a721 v0.0.75: кнопка disabled при 0 файлов, фильтр документов в expand_zips, README-риски
Deploy drhider / validate (push) Canceled after 0s
2026-08-24 20:18:44 +03:00
“Naeel” 6c51df96d9 docs: подтверждение v0.0.74 на проде 2026-08-24 20:12:49 +03:00
“Naeel” ba4546ecc9 v0.0.74: отмена в extract-фазе — прерывание не ждёт все .doc-конвертации
Deploy drhider / validate (push) Canceled after 0s
2026-08-24 20:07:23 +03:00
“Naeel” dd1e285c8c docs: подтверждение v0.0.73 на проде — SSRF/path traversal защита + регресс подпапок OK 2026-08-24 20:04:10 +03:00
“Naeel” ff4895b63b v0.0.73: фиксы по код-ревью Соннета — SSRF, path traversal, слабый SID, proc_error, zip-бомба, self-XSS
Deploy drhider / validate (push) Canceled after 0s
2026-08-24 19:58:27 +03:00
“Naeel” 4b73657e4c docs: код-ревью Соннета — 15 находок (SSRF, path traversal, слабый SID, zip-бомба, idx-мисматч и др.) 2026-08-24 19:50:50 +03:00
“Naeel” 82acc84311 docs: промпт для код-ревью Соннетом — строго ограниченный список файлов + 8 вопросов 2026-08-24 19:37:36 +03:00
“Naeel” 933ee79e3c docs: TODO — баг кнопки «Обфусцировать» активна при 0 файлов после «Новой сессии» (исправить при следующей правке, v0.0.73) 2026-08-24 19:35:42 +03:00
“Naeel” b0c055f926 docs: массовое тестирование v0.0.72 — 4 блока (папка/цикл/прерывание/UI) все OK; найден мелкий баг кнопки при 0 файлов 2026-08-24 19:34:49 +03:00
“Naeel” 44e12db886 docs: тест v0.0.72 на проде — статусы «не извлечён»/«пропущен (лимит)», счётчик дедупа, полный цикл OK 2026-08-24 19:28:05 +03:00
“Naeel” 08e4788a35 docs: временный сбой сертификата gitea — push починился сам, диагностика 2026-08-24 19:23:37 +03:00
“Naeel” e31476ebab v0.0.72: ретраи pull (3 попытки), корректная ошибка лимита сессии, счётчик дедупа
Deploy drhider / validate (push) Canceled after 0s
2026-08-24 19:14:53 +03:00
“Naeel” 0479afb44a v0.0.71: разделение статусов — «пропущен (лимит)» vs «не извлечён»; группа «Пропущены (сверх лимита)»; схема логики загрузки + найденные баги
Deploy drhider / validate (push) Canceled after 0s
2026-08-24 19:04:55 +03:00
“Naeel” cee18046c2 v0.0.70: заметная кнопка HELP в шапке (вместо «?») + пояснение статуса «пропущен» в ограничениях
Deploy drhider / validate (push) Canceled after 0s
2026-08-24 18:53:11 +03:00
“Naeel” ec660ddd1f docs: диагноз разового DNS-сбоя pull (gaierror -5) — единичная ошибка, работает 2026-08-24 18:47:28 +03:00
“Naeel” a901c4f023 docs: полный тест выбора папки v0.0.69 на проде — все сценарии OK, найден баг счётчика при дедупе 2026-08-24 18:42:21 +03:00
“Naeel” 89bb8a3442 docs: подтверждение v0.0.69 на проде — оба сценария папки с архивами работают 2026-08-24 18:39:28 +03:00
“Naeel” 2a49155b05 v0.0.69: архивы из папки не теряются — zip без документов добавляется как есть; раскрытие zip с документами; склонение счётчика
Deploy drhider / validate (push) Canceled after 0s
2026-08-24 17:10:21 +03:00
“Naeel” f45ecb603d v0.0.68: кнопка «Выбрать папку» — рекурсивный выбор папки с подпапками (webkitdirectory), относительный путь сохраняется
Deploy drhider / validate (push) Canceled after 0s
2026-08-24 16:47:48 +03:00
“Naeel” cfe6b9e2bf docs: план для Flash — кнопка «Выбрать папку» (webkitdirectory, рекурсивно, относительный путь) 2026-08-24 16:45:56 +03:00
“Naeel” d5b3ed3f68 docs: тесты v0.0.67 (загрузка/лимиты/ZIP-кириллица/ETA/прерывание) + разбор двухкластерного деплоя 2026-08-24 16:38:32 +03:00
“Naeel” 45c6f3b7f0 docs: v0.0.64-0.0.67 (тикеры/оценки, блокировки UI, фикс имён ZIP) + README History 2026-08-24 15:55:55 +03:00
90 changed files with 9200 additions and 1187 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
- НЕЛЬЗЯ менять ЧТО-ЛИБО при вопросе (?, почему, как, где, что) — только ответ словами - НЕЛЬЗЯ менять ЧТО-ЛИБО при вопросе (?, почему, как, где, что) — только ответ словами
- НЕЛЬЗЯ спешить — обдумать, проверить, потом отвечать - НЕЛЬЗЯ спешить — обдумать, проверить, потом отвечать
- НЕЛЬЗЯ лезть в /home/naeel/nubes/contracts/ (кроме loadtest) - НЕЛЬЗЯ лезть в /home/naeel/nubes/contracts/ (кроме loadtest)
- НЕЛЬЗЯ предполагать — сомневаешься = спроси - НИКОГДА не руководствоваться догадками. Если что-либо неизвестно точно или недоступно — немедленно остановиться и сообщить об этом; не продолжать действие и не выдавать предположение за факт.
- НЕЛЬЗЯ грузить внешние CDN/библиотеки на фронтенд — всё в коде - НЕЛЬЗЯ грузить внешние CDN/библиотеки на фронтенд — всё в коде
# ✅ ПОРЯДОК ПРАВКИ # ✅ ПОРЯДОК ПРАВКИ
+25
View File
@@ -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
View File
@@ -1 +0,0 @@
python3
-1
View File
@@ -1 +0,0 @@
/usr/bin/python3
-1
View File
@@ -1 +0,0 @@
python3
-1
View File
@@ -1 +0,0 @@
lib
-5
View File
@@ -1,5 +0,0 @@
home = /usr/bin
include-system-site-packages = false
version = 3.12.3
executable = /usr/bin/python3.12
command = /usr/bin/python3 -m venv /home/naeel/nubes/drhider/.venv
+25
View File
@@ -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, обфускация, нагрузка |
@@ -35,6 +36,7 @@
- **2026-07-12-v3-done.md** — итоги v3: переход на Markdown-пайплайн + универсальное LLM-обнаружение. - **2026-07-12-v3-done.md** — итоги v3: переход на Markdown-пайплайн + универсальное LLM-обнаружение.
- **2026-07-12-v3.1-plan.md** — план v3.1: пофайловая обработка + прогресс в таблице (решение таймаута). - **2026-07-12-v3.1-plan.md** — план v3.1: пофайловая обработка + прогресс в таблице (решение таймаута).
- **2026-08-24-implementation-plan-for-flash.md** — план для агента-кодера (Flash): TTL-фикс, трекинг файлов, прерывание, ETA, UI. - **2026-08-24-implementation-plan-for-flash.md** — план для агента-кодера (Flash): TTL-фикс, трекинг файлов, прерывание, ETA, UI.
- **2026-08-24-folder-select-plan.md** — план для Flash: кнопка «Выбрать папку» (webkitdirectory, рекурсивно, относительный путь сохраняется).
## sonnet/ — обсуждения с Sonnet (вопросы и ответы) ## sonnet/ — обсуждения с Sonnet (вопросы и ответы)
@@ -89,6 +91,7 @@
- **2026-08-21-pattern-vm-buffer-pull-plan.md** — план: перенос паттерна «ВМ-буфер + pull» в drhider. - **2026-08-21-pattern-vm-buffer-pull-plan.md** — план: перенос паттерна «ВМ-буфер + pull» в drhider.
- **2026-08-21-vm-files-api-plan.md** — план: универсальный файловый сервис на ВМ (API). - **2026-08-21-vm-files-api-plan.md** — план: универсальный файловый сервис на ВМ (API).
- **2026-08-21-vm-upload-implemented.md** — v0.0.58: ВМ-загрузка реализована (паттерн «ВМ-буфер + pull»). - **2026-08-21-vm-upload-implemented.md** — v0.0.58: ВМ-загрузка реализована (паттерн «ВМ-буфер + pull»).
- **2026-08-24-two-clusters-deploy.md** — деплой на двух кластерах: iot-naeel (актуальный, DNS → .151) и naeel-test-3 (не погашен).
## ux-frontend/ — фичи фронтенда ## ux-frontend/ — фичи фронтенда
@@ -101,6 +104,17 @@
- **2026-08-20-upload-limits.md** — v0.0.50: лимиты загрузки (>100МБ/файл, >1ГБ сумма) + красная подсветка в таблице. - **2026-08-20-upload-limits.md** — v0.0.50: лимиты загрузки (>100МБ/файл, >1ГБ сумма) + красная подсветка в таблице.
- **2026-08-24-interrupt-eta-design.md** — дизайн: TTL-фикс + прерывание с сохранением + оценка времени + UI. - **2026-08-24-interrupt-eta-design.md** — дизайн: TTL-фикс + прерывание с сохранением + оценка времени + UI.
- **2026-08-24-interrupt-eta-implemented.md** — v0.0.63: реализовано прерывание с сохранением + ETA + UI-таблица. - **2026-08-24-interrupt-eta-implemented.md** — v0.0.63: реализовано прерывание с сохранением + ETA + UI-таблица.
- **2026-08-24-time-tickers-estimates.md** — v0.0.64–0.0.65: тикер текущего файла на всех этапах, мгновенные оценки времени по файлам и суммарно.
- **2026-08-24-folder-select-implemented.md** — v0.0.68: кнопка «Выбрать папку» (webkitdirectory, рекурсивно, относительный путь).
- **2026-08-24-folder-zip-not-lost.md** — v0.0.69: архивы из папки без документов не теряются (добавляются как есть); раскрытие zip с документами; склонение счётчика.
- **2026-08-24-v075-small-fixes.md** — v0.0.75: кнопка при 0 файлов, фильтр zip (idx-мисматч), README-риски; замечен закоммиченный LLM_API_KEY.
- **2026-08-24-v074-cancel-extract.md** — v0.0.74: отмена в extract-фазе (не ждём все .doc).
- **2026-08-24-v073-security-fixes.md** — v0.0.73: 6 фиксов по ревью Соннета (SSRF, path traversal, слабый SID, proc_error, zip-бомба, self-XSS); 1 отклонено (cleanup), 5 отложено.
- **2026-08-24-pull-retry-limit-counter.md** — v0.0.72: ретраи pull из ВМ, корректная ошибка лимита сессии, счётчик дедупа.
- **2026-08-24-code-review-sonnet.md** — код-ревью Соннета: 15 багов (SSRF, path traversal, слабый SID, zip-бомба и др.), промпт в docs/code-review-sonnet.md.
- **2026-08-24-upload-logic-schema.md** — подробная схема логики загрузки + найденные баги/несоответствия.
- **2026-08-24-help-button-limits-text.md** — v0.0.70: заметная кнопка HELP (вместо «?») + пояснение статуса «пропущен» в ограничениях.
- **2026-08-24-ui-locks-session-freeze.md** — v0.0.66: блокировки UI (выбор/удаление) на время работы, заморозка сессии + кнопка «Новая сессия».
## upload/ — загрузка файлов ## upload/ — загрузка файлов
@@ -109,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/воркера.
@@ -119,12 +142,14 @@
- **2026-08-20-v1-v2-implemented.md** — v0.0.54: В2 (фикс кракозябр имён zip) + В1 (фильтр таблиц, эффект≈0). - **2026-08-20-v1-v2-implemented.md** — v0.0.54: В2 (фикс кракозябр имён zip) + В1 (фильтр таблиц, эффект≈0).
- **2026-08-20-honest-answer-and-A-implemented.md** — v0.0.55: честный ответ Sonnet + реализация А (threading .doc, кэш regex). - **2026-08-20-honest-answer-and-A-implemented.md** — v0.0.55: честный ответ Sonnet + реализация А (threading .doc, кэш regex).
- **2026-08-20-csv-out-of-zip.md** — v0.0.56: КРИТИЧНО — mapping.csv (ключ расшифровки) убран из ZIP. - **2026-08-20-csv-out-of-zip.md** — v0.0.56: КРИТИЧНО — mapping.csv (ключ расшифровки) убран из ZIP.
- **2026-08-24-zip-name-cyrillic-frontend.md** — v0.0.67: фикс кириллических имён из ZIP на фронте (UTF-8 без флага → CP866-мусор), согласовано с бэком.
## tests/ — тесты и бенчмарки ## tests/ — тесты и бенчмарки
- **2026-08-19-ui-5runs-benchmark.md** — v0.0.49: бенчмарк UI, 5 прогонов DownLoads.zip (18 файлов, замеры времени). - **2026-08-19-ui-5runs-benchmark.md** — v0.0.49: бенчмарк UI, 5 прогонов DownLoads.zip (18 файлов, замеры времени).
- **2026-08-23-obfuscation-tests.md** — тесты реальной обфускации и фикс .doc (v0.0.60–0.0.61, корпус /mnt/y/T). - **2026-08-23-obfuscation-tests.md** — тесты реальной обфускации и фикс .doc (v0.0.60–0.0.61, корпус /mnt/y/T).
- **2026-08-24-tests-upload-and-obfuscation.md** — тесты загрузки и обфускации (v0.0.62, лимиты, ВМ-буфер). - **2026-08-24-tests-upload-and-obfuscation.md** — тесты загрузки и обфускации (v0.0.62, лимиты, ВМ-буфер).
- **2026-08-24-test-batch-v067.md** — тесты v0.0.67: загрузка, лимит 50МБ, ZIP-кириллица, ETA, прерывание с частичным сохранением.
--- ---
@@ -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,29 @@
# v0.0.67 — Фикс кириллических имён из ZIP на фронтенде (2026-08-24)
_code-fixes. Имя `TKM_6й_Семестр_ЭКЗАМЕН.docx` из архива отображалось как
`TKM_6╨╣_╨б╨╡╨╝╨╡╤Б╤В╤А_...` (мусор)._
## Причина
Архиватор записал имя **в UTF-8, но без UTF-8-флага** (bit 11). Фронт (`decodeZipName`)
видел «флага нет», шёл в legacy-ветку и декодировал **UTF-8-байты как CP866** →
`D0 99` («й») → `╨╣` (псевдографика CP866).
## Фикс (index.html, `decodeZipName`)
- Первым делом — **строгая проверка UTF-8** (`TextDecoder('utf-8', {fatal:true})`):
если байты — валидный UTF-8 с кириллицей или печатаемым текстом → берём как есть.
- Только если строгий UTF-8 не проходит (реальные CP437/CP866 из 1С) — legacy-путь
(CP437 → CP866) не меняется.
- CP866-кириллица (0x80–0xAF) — это продолжения UTF-8 без ведущих байтов, поэтому
строгий UTF-8 для них честно падает и legacy-путь работает как раньше.
## Согласованность с бэком
Бэк (`extractor.py`, `_decode_name`) это уже умел (v0.0.54, «В2 — фикс кракозябр имён zip»).
Теперь фронт и бэк обрабатывают имена ZIP одинаково.
## Проверка (node)
- `TKM_6й_Семестр_ЭКЗАМЕН.docx` (UTF-8 без флага) — декодируется верно ✅.
- ASCII — ✅. Legacy CP437/CP866-путь не тронут.
- `node --check` — OK.
## Версия
- 0.0.66 → 0.0.67.
@@ -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,28 @@
# Разовый DNS-сбой pull: «Pull failed: [Errno -5] No address associated with hostname» (2026-08-24)
_infra. Вопрос пользователя «ЭТО ЧТО ????» при загрузке папки CNC (220 файлов, отобрано 8)._
## Симптом
Фронт: «Ошибка передачи ссылок: Pull failed: [Errno -5] No address associated with hostname»
при нажатии «Обфусцировать».
## Диагностика (факты)
- ВМ-буфер: `https://contracts.kube5s.ru/drhider-upload/` (nginx dav), host → 5.172.178.213.
- DNS снаружи (8.8.8.8) и на ВМ: `contracts.kube5s.ru` → 5.172.178.213 — ок; буфер отвечает 403.
- Под drhider: ns `20a75175-a58c-49cb-b8fa-e86367b1a8dc`, pod `pythonk8s-85d8f4dcc8-vjjts`
(создан ~18:36 МСК, редеплой 18:31-18:33). `getent`/`socket` из пода → 5.172.178.213 — ок.
- Логи пода: ЕДИНСТВЕННАЯ ошибка `upload_refs: pull error sid=66ce16c1cda9` в **15:42:43 UTC (18:42 МСК)**:
`connect_tcp.started host='contracts.kube5s.ru'` → `gaierror(-5)`.
- До неё (15:41:41 UTC / 18:41 МСК) сессия пользователя `3ec718dafa5b` с теми же 8 файлами
(info.txt, out1.txt, fatD.pdf, fatihaDownEdited.pdf, fatihaUPEdited.pdf, fatU.pdf, FT0.pdf, sha0.pdf)
УСПЕШНО обработана (worker done, 29.8с, tokens 2138).
- CoreDNS: 2 пода, 0 рестартов, 44d — стабильны.
- Живой тест сейчас: папка 2 txt → «✅ Обработано 2 файлов: 6.6с» — pull работает.
## Вывод
**Разовый DNS-сбой** CoreDNS/upstream на `contracts.kube5s.ru` в узкий момент 18:42 МСК
(одна ошибка за весь лог). К выбору папки отношения нет; повторный запуск работает.
## Рекомендация (опция)
Добавить ретраи pull в `upload_refs` (2-3 повтора с паузой при `ConnectError`/DNS-ошибке) —
v0.0.70. Решение за пользователем.
@@ -0,0 +1,27 @@
# Деплой: два кластера, iot-naeel актуальный, naeel-test-3 — не погашен (2026-08-24)
## Симптом
- health/HTML внешнего URL отдавали **0.0.67**, а под в ns 20a75175 на naeel-test-3 был **0.0.61**
(внутренний health 0.0.61, нет MAX_FILE_BYTES/cancel/ETA/UI).
- Часть запросов (upload_refs) попадала на старый под → тесты вели себя непредсказуемо
(60МБ «принимался»).
## Причина (подтверждена kubeconfig'ами обоих кластеров)
- drhider развёрнут на **двух кластерах** в одном namespace-ID `20a75175-...`:
- **iot-naeel**: под `pythonk8s-cf686f894-96l7m` (возраст ~42м — свежий редеплой), **v0.0.67**;
ingress `drhider.pythonk8s.dev.nubes.ru` → **185.247.187.151**.
- **naeel-test-3**: под `pythonk8s-797ddd69df-dmdg6` — **старый, не погашенный** (был 0.0.61);
ingress → 185.247.187.147.
- **DNS `drhider.pythonk8s.dev.nubes.ru` → 185.247.187.151 = iot-naeel** → внешний URL обслуживает
iot-naeel (0.0.67). naeel-test-3 в DNS не участвует, но остаётся живым инстансом.
## Что сделано
- kubeconfig'и на ВМ обновлены на оба кластера (`~/.kube/config` iot-naeel, `~/.kube/config-naeel-test-3`).
- Проверены оба пода: и iot-naeel, и naeel-test-3 теперь **v0.0.67** (naeel-test-3 обновлён
пересозданием пода — `scale 0 → 1` заставил git-clone master@0.0.67).
- Внешний бэкенд — iot-naeel (0.0.67), все тесты прогнаны на нём (см. tests/2026-08-24-test-batch-v067.md).
## Осталось (рекомендация)
- В Штурвале **погасить/удалить инстанс drhider на naeel-test-3** (ns 20a75175) — он не нужен,
DNS ведёт на iot-naeel. Оставить один актуальный (iot-naeel, 0.0.67).
- Проверить прочие `pythonk8s` на iot-naeel (contractor/loadtest/polygon/atest) — это другие приложения, не drhider.
@@ -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.
+1 -1
View File
@@ -140,6 +140,6 @@ site/ # Flask-приложение
| Переменная | Значение | | Переменная | Значение |
|---|---| |---|---|
| `LLM_API_KEY` | `sk-ucI5YvOticoOQ9Kuj5K9mQ` | | `LLM_API_KEY` | (секрет — не хранить в git) |
| `LLM_URL` | `https://api.aillm.ru/v1/chat/completions` | | `LLM_URL` | `https://api.aillm.ru/v1/chat/completions` |
| `LLM_MODEL` | `gpt-oss-120b` | | `LLM_MODEL` | `gpt-oss-120b` |
@@ -0,0 +1,63 @@
# План для Flash: выбор целой папки рекурсивно (v0.0.68)
> Самодостаточный план. Файл: `site/templates/index.html` (весь фронт — ванильный JS, без сборки).
> Текущая версия 0.0.67 → поднять до **0.0.68** (в `site/app.py` `VERSION`).
## Задача
Кнопка «📁 Выбрать папку»: юзер выбирает папку → в таблицу рекурсивно добавляются ВСЕ файлы
(включая вложенные подпапки), как сейчас раскрываются ZIP. **Относительный путь сохраняется**
(без имени верхней выбранной папки): `Подпапка/Внутри/report.pdf`.
## Что уже есть (ориентиры в index.html)
- `<input type="file" id="fileInput" multiple accept=".doc,.docx,.pdf,.txt,.zip">` в `.file-input-wrap`.
- `const fi = document.getElementById('fileInput');` (в начале `<script>`).
- `fi.addEventListener('change', async () => {...})` — текущий обработчик: для `.zip` → `listZipFiles(f)` (раскрывает архив, возвращает `File[]`), иначе `addFileWithDedup(f)`; в конце пересобирает `DataTransfer`, `rr()`.
- `addFileWithDedup(file)` — дедуп по `file.name` + `file.size`, лимиты `MAX_FILE_BYTES`/`MAX_SESSION_BYTES`, `overNames`.
- `listZipFiles(file)` — рекурсивно раскрывает `.zip` (в т.ч. вложенные), фильтрует только документы
`['.pdf','.doc','.docx','.txt','.md']`, возвращает `File[]` (имена = пути внутри архива).
- `busy` — флаг «идёт загрузка/обработка»; есть `setBusy(b)`, guard'ы в `change` и `rm`.
- Footer: `<div class="footer-bar">` содержит `fileCount`, `cancelBtn`, `newSessionBtn`, `uploadBtn`.
## Реализация
### 1. HTML — кнопка и скрытый input
- В `.file-input-wrap` (рядом с кнопкой «Choose File», т.е. `#fileInput`) добавить кнопку:
`<button type="button" class="btn" id="folderBtn">📁 Выбрать папку</button>`
- Добавить скрытый input (вне/внутри формы без разницы):
`<input type="file" id="folderInput" webkitdirectory style="display:none">`
- `folderBtn.onclick = () => folderInput.click();`
### 2. JS — ссылки и обработчик
- `const folderInput = document.getElementById('folderInput');`
- `const DOC_EXTS = ['.pdf', '.doc', '.docx', '.txt', '.md'];` (можно переиспользовать из listZipFiles — там это локальная константа `allowedExt`; продублировать на верхнем уровне или вынести).
- `folderInput.addEventListener('change', async () => { ... })`:
1. `if (busy) return;`
2. `const files = Array.from(folderInput.files);` — у каждого есть `file.webkitRelativePath` (вида `TopFolder/Подпапка/report.pdf`).
3. Для каждого файла:
- Определить `rel = file.webkitRelativePath.split('/')` и **отбросить первый сегмент** (верхняя папка): `rel = rel.slice(1).join('/')`.
- `const low = rel.toLowerCase();`
- Если `.zip` → `const nested = await listZipFiles(file);` затем для каждого `nf` создать
`new File([nf], rel_dir + '/' + nf.name, {lastModified: nf.lastModified})`, где
`rel_dir = rel.slice(0, rel.lastIndexOf('/'))` (папка, в которой лежал zip; если в корне — пусто),
и `addFileWithDedup(that)`. Обернуть в try/catch: при ошибке раскрытия добавить сам `.zip` как есть (по аналогии с текущим обработчиком).
- Иначе если `DOC_EXTS.some(e => low.endsWith(e))` → `addFileWithDedup(new File([file], rel, {lastModified: file.lastModified}))`.
- Иначе — пропустить (не документ).
4. В конце — показать индикатор «Разбираю папку…» на время обработки (как с ZIP: `document.body.style.cursor='wait'` + `st`), затем `rr()`.
- Показать индикатор до цикла, сбросить в `finally` (как текущий change-обработчик).
### 3. Важные детали
- **Дедуп/лимиты**: `addFileWithDedup` уже работает по `file.name`; так как имя = относительный путь,
файлы из разных подпапок не сливаются. Лимиты 50МБ/500МБ применяются автоматически.
- **Имя в таблице и в выходном ZIP** = относительный путь (с `/`). Это ожидаемо.
- **`webkitdirectory`**: поддерживается Chrome/Edge (полно), Firefox (частично), Safari — нет.
Best-effort, ничего не ломается в неподдерживаемых браузерах (кнопка просто не откроет выбор папки).
- **Guard `busy`**: во время загрузки/обработки выбор папки не должен работать (как и обычный input).
### 4. Версия и проверка
- `site/app.py`: `VERSION = "0.0.68"`.
- Проверки: `python3 -m py_compile site/app.py`; `node --check` на извлечённом `<script>` (как обычно).
- Тест вручную/браузером: папка с подпапками + вложенный zip + кириллические имена → все файлы в таблице
с относительными путями, дедуп/лимиты работают.
## Не трогать
- Логику обфускации, SSE, сессии, лимиты — только добавить кнопку/input/обработчик выбора папки.
@@ -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,31 @@
# Тест выбора папки v0.0.69 на проде — полный цикл (2026-08-24)
_tests. Прод https://drhider.pythonk8s.dev.nubes.ru/, после редеплоя 18:31 (v0.0.69)._
## Что проверено (все ✓)
1. **Выбор папки рекурсивно с подпапками** — папка `Клиенты/` с `Подпапка/Акт_работ.txt`.
2. **Вложенный ZIP** — `Клиенты/архивы/документы.zip` раскрыт, файлы добавлены с префиксом пути:
`архивы/Отчёт/Справка_Сидоров.txt`, `архивы/Счёт_на_оплату.txt`.
3. **Кириллические имена** — сохраняются корректно.
4. **Относительный путь** — в таблице и в результирующем ZIP.
5. **Дедуп** — повторный `Договор_Ромашка.txt` добавлен один раз.
6. **Полный цикл обфускации** (3 txt, 7.3с, ИИ 7с, токены 1858):
- CSV-таблица замен: `+7 916 123-45-67 → +7_000_000_0001`, `Петрова Анна Сергеевна → Николаев_0001`,
`Сидоров Пётр Николаевич → Павлов_0001`, `Козлова Мария Викторовна → Новиков_0001`, `Счёт 89 → Договор_0002`, `№45-А → Договор_0001`.
- ZIP: имена с путями (`Подпапка/Акт_работ.md`, `архивы/Отчёт/Справка_Сидоров.md`, `архивы/Счёт_на_оплату.md`);
содержимое обезличено («…Ответственный: Николаев_0001.»).
7. **Лимиты** — файл 51 МБ помечен «🔥 не учитывается», сводка «1 учитываются + 1 свыше лимита».
8. **Заморозка сессии** после обработки — выбор файлов/папок заблокирован до «Новая сессия».
## Найденный баг (косметика)
Счётчик «Добавлено из папки: N» завышается на число дублей: `added++` выполняется безусловно
после `addFileWithDedup`, даже когда файл не добавлен (дедуп). Пример: папка с одним дублем
→ «5 файлов» при 4 реально добавленных. На список файлов не влияет.
## Примечания
- Битый pdf-плейсхолдер (1 Б) не обработался (пропущен) — ожидаемо.
- В ZIP текстовые `.txt` выходят как `.md` — поведение бэкенда, не связано с выбором папки.
- Скачивание через событие `download` в Playwright не генерируется — данные получены прямым
`fetch /api/download/<sid>` и `/api/csv/<sid>` (sid из перехвата URL).
- Клик по «✕» в одном тике по двум кнопкам → `TypeError … 'name'` в `rm` — артефакт теста,
в реальном использовании не воспроизводится (rr() пере-рендерит DOM синхронно).
@@ -0,0 +1,30 @@
# Тесты v0.0.67: загрузка, лимиты, ZIP-кириллица, ETA, прерывание (2026-08-24)
_Фон: найден и разобран неконсистентный деплой (см. infra/2026-08-24-two-clusters-deploy.md).
Все тесты — на реальном внешнем бэкенде **iot-naeel (185.247.187.151), под v0.0.67**._
## 1. Загрузка: много + больших ✅
- 119 файлов / **480.5 МБ** (8×45МБ + 10×10МБ + 100×200КБ).
- PUT (P=8) 44.8с, `upload_refs` 51.9с, сессия: 119 файлов, 480.5МБ, 0 ошибок.
## 2. Лимит 50 МБ/файл ✅
- big60 (60МБ) + small1 (1МБ) → `upload_refs` `{count:1}`; в сессии только `small1.bin`; big60 удалён с ВМ.
- Раньше (старый под 0.0.61 в балансировке) big60 принимался — после чистки работает.
## 3. ZIP: кириллица без UTF-8-флага (v0.0.67) ✅
- ZIP с `TKM_6й_Семестр_ЭКЗАМЕН.docx`, `Договор_ООО_Ромашка.pdf` (UTF-8, флаг сброшен) → в браузере имена декодированы **верно** (было `╨╣…`-мусор). Оценки времени видны.
## 4. Обфускация 20 файлов: ETA и SSE-события ✅
- 21 файл (вкл. plan.txt): SSE 255с, `complete {total:21, tokens:104128, llm_sec:254.0}`.
- События: `start=21, extract_done=1, file_start=21, file_chunk=21, file_done=21, done=21, complete=1`.
- `llm`-heartbeat с `eta_sec`: 230 примеров, убывает 237→0 (адаптивная ETA работает).
- ZIP: 21 файл (29.4КБ), CSV: 295 строк.
## 5. Прерывание с частичным сохранением ✅
- 22 файла, через 25с `POST /api/cancel/<sid>` → `{"ok":true}`.
- SSE `cancelled: {saved:3, total:22, tokens:11640, llm_sec:30.2}`.
- **Частичный ZIP: 3 файла (4.4КБ), частичный CSV: 188 строк** — скачаны (HTTP 200).
## Вывод
На консистентном v0.0.67 все новые фичи работают: лимиты, декодирование имён ZIP,
ETA (global+per-file в SSE), прерывание с частичным результатом. Регрессий нет.
@@ -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 байт).
+21
View File
@@ -0,0 +1,21 @@
# History / upload-integration — Журнал интеграции upload-platform
В этом каталоге фиксируется полный жизненный цикл интеграции автономного модуля
`upload-platform` (Слой 1: File Picker + Слой 2: пофайловый транзит через ВМ-буфер)
в сервис `drhider`.
## Стандарт ведения документации по интеграции
Каждый этап или значимый результат документируется отдельным файлом по формату:
`YYYY-MM-DD-<краткое-действие>.md`
Обязательные разделы каждого документа:
1. **Цель и контекст**: что делалось, версии компонентов (`drhider`, `upload-platform`).
2. **Выполненные изменения**: затронутые файлы и границы слоёв (Слой 1, Слой 2, Слой 3).
3. **Проверки и тесты**: команды запуска тестов и результаты (PASS/FAIL).
4. **Обнаруженные нюансы/отклонения**: проблемы обратной совместимости, сетевые или платформенные особенности.
## Документы раздела
- [2026-09-15-upload-platform-integration-plan.md](2026-09-15-upload-platform-integration-plan.md) — детальный архитектурный план и этапы интеграции версии v0.2.2 в drhider.
- [2026-09-15-upload-platform-v022-integration-report.md](2026-09-15-upload-platform-v022-integration-report.md) — отчёт об успешной интеграции и верификации v0.2.2 (версия drhider v0.0.78).
@@ -0,0 +1,18 @@
# v0.0.68 — Кнопка «Выбрать папку» (webkitdirectory, рекурсивно) (2026-08-24)
_ux-frontend. По плану `plans/2026-08-24-folder-select-plan.md`. Код: `site/templates/index.html`._
## Что сделано
- Кнопка «📁 Выбрать папку» (`#folderBtn`) + скрытый `<input type="file" id="folderInput" webkitdirectory multiple>`.
- Обработчик `change` `#folderInput`:
- `if (busy) return` — во время загрузки/обработки выбор папки заблокирован.
- Берёт `webkitRelativePath` (`TopFolder/Подпапка/file.pdf`), **отбрасывает верхнюю папку** → `rel`.
- `.zip` → раскрывает `listZipFiles`, префикс пути папки к именам; при ошибке — zip как есть.
- Документы (`.pdf .doc .docx .txt .md`) → `addFileWithDedup(new File([...], rel))`.
- Прочее — пропускается. Индикатор «Разбираю папку…», после — «✅ Добавлено N файлов».
- Дедуп/лимиты работают штатно (имя = относительный путь → подпапки не сливаются).
- Поддержка: Chrome/Edge — полно, Firefox — частично, Safari — нет (best-effort).
## Проверка
- `node --check` — OK; `py_compile` — OK.
- Версия 0.0.67 → 0.0.68.
@@ -0,0 +1,42 @@
# v0.0.69 — Архивы из папки: не терять, раскрывать документы (2026-08-24)
_ux-frontend. После ретеста v0.0.68 пользователем на проде._
## Ситуация
Пользователь выбрал папку, в которой лежали `iviconfig8257.zip` (17.1 KB) и
`Nail_Nevrolog_20250602.PDF`. Добавился только PDF («Добавлено из папки: 1 файлов»),
zip не раскрылся. Требование: «если в папке архив — его надо раскрывать!».
## Диагностика (Playwright на проде, v0.0.68)
- Сервер отдавал v0.0.68 с кнопкой — деплой прошёл; первая загрузка страницы
показывала старый кэш v0.0.67 (нужен reload).
- Собранный в браузере валидный zip с документами в подпапке — обработчик папки
РАСКРЫВАЕТ корректно: файлы добавляются с префиксом пути (`Подпапка/Договор…`),
статус «Добавлено из папки: 4 файлов».
- Вывод: `iviconfig8257.zip` не раскрылся, потому что ВНУТРИ нет документов
(конфиги/прочее) — `listZipFiles` вернул пусто. Оба обработчика (fileInput и
folderInput) молча ТЕРЯЛИ такой архив.
## Фикс (site/templates/index.html)
1. `fi`-обработчик: `nested.length` → добавлять файлы; иначе — архив как есть.
2. `folder`-обработчик: то же для zip из папки (если документов нет — архив как есть).
3. Склонение счётчика: «1 файл / 2 файла / 5 файлов».
Поведение после фикса:
- В папке zip с документами → раскрывается (файлы с путём в таблицу).
- В папке zip без документов / нераспакованный → добавляется как zip, не теряется.
## Проверка
- `node --check` — OK, `py_compile` — OK.
- Локальный запуск на 127.0.0.1:5010 (v0.0.69), сценарий папки
[zip-конфиг + PDF] → «✅ Добавлено из папки: 2 файла», в таблице оба файла.
- Версия 0.0.68 → 0.0.69.
## Проверка на проде (после редеплоя 18:31, v0.0.69)
- Сценарий A — папка [zip БЕЗ документов + PDF] → «✅ Добавлено из папки: 2 файла»,
в таблице `iviconfig8257.zip` (как есть) + `Nail_Nevrolog_20250602.PDF`. Архив не теряется.
- Сценарий B — папка [zip С документами в подпапке + PDF] → «✅ Добавлено из папки: 3 файла»,
в таблице `Подпапка/Договор_ООО_Ромашка.pdf` (раскрыт с путём), `Акт_выполненных_работ.docx`, `Отчёт.pdf`.
- Примечание: `curl` к проду отдаёт ОБРЕЗАННЫЙ HTML (рвётся соединение, ~15-18КБ из 49.6КБ
Content-Length) — это проблема шлюза для curl, НЕ деплоя. Браузер докачивает полностью.
@@ -0,0 +1,19 @@
# v0.0.70 — Заметная кнопка «?» + пояснение «пропущен» (2026-08-24)
_ux-frontend. По замечаниям пользователя после v0.0.69._
## Что сделано (site/templates/index.html)
1. **Кнопка «HELP» в правом верхнем углу** (вместо значка «?»):
- было: «?» 12px muted — почти не видно;
- стало: синяя кнопка-пилюля с надписью **HELP** (фон `#1d4ed8`, белый жирный текст 13px/700,
padding 5×14px, radius 6px, тень; hover темнее). Размер 62.6×23px.
- Клик открывает helpModal (проверено: display flex).
2. **Текст ограничений** — добавлено пояснение статуса «пропущен»:
«…помечаются красным и не участвуют в обфускации: при запуске обработки они
получают статус «пропущен» и не попадают в результат.»
## Проверка
- `node --check` — OK, `py_compile` — OK.
- Локально (127.0.0.1:5010, v0.0.70): кнопка HELP — синяя пилюля 62.6×23px, клик открывает справку;
текст ограничений обновлён.
- Версия 0.0.69 → 0.0.70.
@@ -0,0 +1,30 @@
# v0.0.64–0.0.65 — Тикеры времени и оценки обработки по файлам (2026-08-24)
_ux-frontend. Продолжение v0.0.63 (прерывание/ETA). По итогам замечаний: тикеры/оценки
должны быть видны СРАЗУ и на ВСЕХ этапах, а не только в LLM-фазе._
## Проблема (v0.0.63)
- Подсветка «▶ Текущий файл» и оценки появлялись только в LLM-фазе.
- На этапе извлечения (долгие PDF, .doc через liberta) таблица показывала всё
«○ Ожидают —», без текущего файла и оценок — юзер не видел никакого прогресса.
## v0.0.64 — тикер текущего файла на всех этапах + самокалибровка
- `start` (этап извлечения): файл, который обрабатывается сейчас, помечается `current`
(предыдущий демотируется), тикер `прошло/осталось` идёт через `procRefresh` (1с).
- Замер скорости извлечения: по предыдущему «текущему» файлу считается `procExtractRate`
(сек/МБ) — оценки «ожидающих» калибруются по факту, а не по константе.
- `extract_done`: снимает подсветку извлечения (LLM-фаза назначает текущий через `file_start`).
- Оценки у «ожидающих»: LLM (`chars × rate`) либо грубая по размеру (сек/МБ).
## v0.0.65 — мгновенные оценки (ещё до старта)
- При выборе файлов (простой режим) у каждого файла в «Статусе» — `~X` (по размеру,
`EST_MB_SEC = 12` с/МБ).
- В шапке таблицы — суммарная оценка: `N файлов · S МБ · ~T`.
- Подсказка: «⏱ Время обработки каждого файла — ориентировочное (обновляется по факту)».
## Проверка
- `node --check` — OK; `py_compile` — OK.
- Локально: имя из ZIP декодируется корректно (см. v0.0.67).
## Версии
- 0.0.63 → 0.0.64 (тикеры на всех этапах) → 0.0.65 (мгновенные оценки).
@@ -0,0 +1,34 @@
# v0.0.66 — Блокировки UI: предсказуемое поведение во всех состояниях (2026-08-24)
_ux-frontend. По замечанию: во время обработки «Выбрать файлы» оставалась активной —
юзер мог менять список и ломать состояние/индексы. Требование: ВСЁ предсказуемо,
учтены ВСЕ действия юзера._
## Состояния и доступные действия
| Состояние | Выбрать файлы | ✕ удалить | Обфусцировать | Прервать | Скачать | Новая сессия |
|---|---|---|---|---|---|---|
| Простой | ✅ | ✅ | ✅ | — | — | — |
| Загрузка (1/2) | 🔒 | 🔒 | 🔒 | — | — | — |
| Обработка (2/2) | 🔒 | 🔒 | 🔒 | ✅ | — | — |
| Результат готов | 🔒 | 🔒 | 🔒 | — | ✅ | ✅ |
| Ошибка | ✅ | ✅ | ✅ | — | — | — |
## Реализация
- Глобальный флаг `busy` + `setBusy(b)`: блокирует `fileInput` и скрывает кнопки «✕»
(CSS `body.busy .remove-btn { display:none }`).
- Двойная защита: обработчики `change` (выбор файлов) и `rm` (удаление) игнорируют
вызовы при `busy`.
- `uploadFiles()`: guard `if (busy) return` + `setBusy(true)`; ранние выходы (ошибки
фазы 1, test-режим) — `setBusy(false)`.
- **Заморозка сессии после результата**: `complete`/`cancelled` → `sessionDone=true`,
`fi`/`uploadBtn` выключены, показаны «Скачать» и новая кнопка **«🔄 Новая сессия»**
(`resetAll`). Ошибки — снимают блокировку (можно повторить).
- При закрытии вкладки (beforeunload) — `resetAll` сбрасывает всё.
## Найденный и исправленный баг при правке
- При добавлении заморозки случайно затёрлась строка `activeES.addEventListener('complete', ...)`
— JS-синтаксис сломался («Missing catch»). Восстановлено ДО пуша; `node --check` — OK.
## Проверка
- `node --check` (извлечённый <script>) — OK; `py_compile` — OK.
+62
View File
@@ -0,0 +1,62 @@
# ПЛАН: компонент выбора файлов/папок/архивов → файловое дерево
> Для Flash. Vanilla JS, БЕЗ React и сборщика (как drhider). Новое — только если проще.
> Переиспользуемый компонент (слой 1 из `PLAN-upload-layers.md`).
## Решения (фиксировано)
| Вопрос | Решение | Почему |
|---|---|---|
| Стек | **vanilla JS**, один модуль | drhider без сборщика; React избыточен |
| ZIP | **`fflate`** (`unzipSync`/`unzip` + `filter`) | заменяет ручной `parseZip` (~200 строк); 8 kB; filter по расширению ДО распаковки |
| Выбор папки | `webkitdirectory` (оставить) | уже в drhider, рекурсивно |
| Дерево | **свой лёгкий** рендер (ленивый + делегирование) | jsTree старые, react — нет |
| Единственная новая зависимость | `fflate` (CDN `unpkg`/`jsdelivr`) | оправдано и проще |
## Модель узла
```
node = { type:'dir'|'zip'|'file', name, path, size, file, children, collapsed }
```
- `path` для file — полный относительный путь: `"Папка/архив.zip/Внутри/файл.txt"`.
- Дубль = `path`+`size` → пропуск; имя то же, размер другой → суффикс `_2`.
## API компонента
```
createFilePicker({ container, allowedExt, maxFileBytes, onSelectionChange }) -> {
pickFiles(), pickFolder(),
getFlatFiles() -> [{path,name,size,file}], // в слой закачки
getOverNames(), clear(), setStatus(path, html)
}
```
## Сборка дерева
1. **Папка**: `webkitRelativePath="Top/…"` → отбросить `Top` → вставить по сегментам.
2. **Файлы**: в корень; `.zip` → `fflate.unzip` (рекурсивно по вложенным `.zip`), узел `zip` + дети.
3. **Фильтр**: лист принимается только если расширение ∈ `allowedExt`; `.zip` — ради раскрытия.
## Рендер (сотни файлов)
- Свёрнутый `dir`/`zip` **не рендерит детей** (ленивый).
- Плоский список «видимых строк» одним проходом (или `DocumentFragment`).
- Клики — **делегирование** (один обработчик на контейнер по `data-path`), не сотни слушателей.
- `getFlatFiles()` — обход дерева, только листья-файлы.
## Шаги (для Flash)
1. Подключить `fflate` (CDN). Написать обёртку `zip.js`: `listZip(f) -> [{path,name,size,file}]` с `filter` по `allowedExt`.
2. Модель узла + вставка по сегментам пути.
3. Сборка из `folderInput` (webkitRelativePath) и `fileInput` (раскрытие zip рекурсивно).
4. Дедуп (`path`+`size`, суффикс `_2`).
5. Рендер дерева: collapse, ленивый рендер, делегирование кликов.
6. Фильтр `allowedExt` + лимит + статус «пропущен (лимит)».
7. `getFlatFiles()` → слой закачки.
## Чек-лист приёмки
- [ ] дерево папок/архивов сворачивается/разворачивается;
- [ ] путь файла из архива виден (`Папка/архив.zip/…`);
- [ ] дубли исключены, коллизия имени → `_2`;
- [ ] `allowedExt` — параметр (фильтр и в zip, и в папке);
- [ ] сотни файлов не тормозят (ленивый рендер + делегирование);
- [ ] `getFlatFiles()` отдаёт только учитываемые файлы.
## Не делать (не оправдано)
- React / сборщик / jQuery-деревья (jsTree, Fancytree).
- Виртуализация `react-arborist` — только если появятся тысячи узлов и будет React.
+287
View File
@@ -0,0 +1,287 @@
# ПЛАН: вынос 2 слоёв загрузки-через-ВМ в переиспользуемый модуль
> Для Flash. Источник-референс: drhider v0.0.75 (`site/templates/index.html`,
> `site/routes/api_bp.py`, `site/session.py`). Цель — **1:1** по поведению, но в виде
> **переиспользуемого программного слоя ВНУТРИ приложения** (не отдельного сервиса):
> его встраивает и drhider, и любые другие проекты.
---
## Требования (согласовано с владельцем)
1. **2 слоя структурно выделены** — их можно взять и перенести в другой проект
**без изменения кода** (только свой конфиг).
2. **Без общей репы** — модуль = подпапка внутри проекта, перенос копированием.
3. **Не мудрить** — главное: агент в новом проекте за 2 минуты понял, как интегрировать
(для этого короткий `README.md` внутри модуля).
4. **НЕ поломать текущий рабочий код drhider** — это рефакторинг 1:1 (extract, не rewrite).
5. **Файлы мелкие** — каждая функция в отдельном файле.
6. **Vanilla JS, без React/сборщика**. ZIP раскрытие — на **`fflate`** (вместо ручного парсера):
проще и надёжнее (`unzip` + `filter` по расширению до распаковки). Дерево файлов
(сворачивание по папкам/архивам) — отдельный план `PLAN-file-picker.md`.
---
## 0. Контекст (что уже проверено в drhider)
Паттерн загрузки (шлюз managed-кластера рвёт тела >64КБ, egress не ограничен):
браузер → `PUT` на ВМ-буфер → Flask `POST /api/upload_refs` → egress `GET` (pull) → сессия.
Что НЕЛЬЗЯ потерять при переносе (накоплено и отлажено):
- дедуп «имя+размер», иначе суффикс `_2`;
- раскрытие ZIP с путями, фильтр расширений (`.pdf .doc .docx .txt .md`);
- рекурсивный обход папки (`webkitdirectory`), путь в таблице;
- **SSRF-валидация** URL (`url.startswith(VM_UPLOAD_PREFIX)`);
- **`_safe_name`** (path traversal, сохраняет подпапки);
- **ретраи pull** (3 попытки, пауза 2 с);
- лимиты 50 МБ/файл, 500 МБ/сессия;
- различение «сессия не найдена» vs «лимит»;
- статусы «пропущен (лимит)» / «не извлечён»;
- `esc()` для имён (self-XSS).
---
## 1. Граница слоёв
| Слой | Где | Ответственность | drhider-источник |
|---|---|---|---|
| **0. Конфиг** | общий | `VM_UPLOAD_URL`, `allowedExt`, лимиты | константы в `index.html`/`api_bp.py` |
| **1. Выбор файлов** | фронт | таблица, дедуп, раскрытие ZIP, путь, статусы, кнопки | `index.html` |
| **2. Закачка через ВМ** | фронт + бэк | PUT на ВМ (фронт) → `upload_refs` pull (бэк) → сессия | `index.html` + `api_bp.py` + `session.py` |
| **3. Логика приложения** | бэк | обфускация/обработка, SSE-прогресс | `drhider/*`, `process_stream` — **НЕ выносим** |
**Слой 3 НЕ выносим** — он у каждого приложения свой. В drhider остаётся: `drhider/*`
(extractor/scanner/replacer/builder/obfuscator), `process_stream`, `process`, `download`,
`csv_download`, `cancel`.
**Всё, что у drhider своё (URL, расширения, лимиты) — в конфиг (слой 0), а не в код слоя.**
---
## 2. Структура модуля (каждая функция — отдельный файл)
```
upload/
README.md # ← главное: как интегрировать (для агента)
config.example.json # слой 0: образец конфига
frontend/
zip/ # распаковка ZIP (чистые функции, без DOM) — на базе fflate
list_zip_files.js # listZipFiles(file, allowedExt) — fflate.unzip + filter + рекурсия
table/ # слой 1: выбор файлов
fs.js # fs(b) — формат размера
fmt_sec.js # fmtSec(s) — формат секунд
esc.js # esc(s) — экранирование HTML
est_for_file.js # estForFile(f) + EST_MB_SEC (из конфига)
add_file_with_dedup.js # addFileWithDedup(file)
render.js # rr() — обычная таблица
proc_row.js # procRow(i, stTxt)
render_proc_table.js # renderProcTable() — 3-секционная таблица
rm_file.js # rm(i) — удаление строки
set_status.js # ss(idx, h) — статус в ячейке
on_files_change.js # обработчик <input type=file> change
on_folder_change.js # обработчик webkitdirectory change
init_upload_table.js # initUploadTable(cfg) — сборка слоя 1 (состояние внутри)
upload/ # слой 2 (фронт): закачка через ВМ
put_to_vm.js # PUT одного файла (XHR, progress → setStatus)
upload_via_vm.js # uploadViaVM(files, vmUploadUrl) — цикл PUT + POST upload_refs
backend/
upload_refs/ # слой 2 (бэк): pull + валидация
__init__.py
config.py # PULL_RETRIES, PULL_RETRY_DELAY, VM_UPLOAD_PREFIX (из конфига)
safe_name.py # _safe_name(name)
pull_file.py # pull с ретраями (3×2с, stream, лимит 50МБ)
blueprint.py # create_upload_refs_blueprint(cfg) → Blueprint
session/
__init__.py
state.py # _sessions, _lock, _start_timer (общее состояние)
create_session.py # create_session()
add_file.py # add_file(sid, name, content)
get_files.py # get_files(sid), file_count(sid)
store_result.py # store_result(sid, zip), get_result(sid)
store_csv.py # store_csv(sid, csv), get_csv(sid)
ttl.py # touch, pause_ttl, resume_ttl
cancel.py # request_cancel, get_cancel_event
cleanup.py # cleanup(sid)
```
> Примечание по `backend/session/`: там **общее глобальное состояние** (`_sessions`, `_lock`).
> Поэтому `state.py` выделен отдельно, а каждая операция — свой файл, импортирующий `state`.
> Дробить `state.py` на файл-на-переменную не нужно — это данные, а не функции.
---
## 3. Маппинг: что откуда взять (v0.0.75, имена функций точные)
Переносится **без изменения логики**. Меняется только способ обращения:
глобальные переменные → параметры/состояние модуля.
### Фронт (`site/templates/index.html`)
| Функция в drhider | → файл модуля | Что меняется при переносе |
|---|---|---|
| `listZipFiles` (+ вложенные `parseZip`/`inflateRaw`/`decodeZipName`/`dosToMs`) | `zip/list_zip_files.js` | **заменяется на `fflate`** (`unzip` + `filter` по `allowedExt` + рекурсия по вложенным zip); ручной парсер НЕ переносим |
| `fs` | `table/fs.js` | ничего |
| `fmtSec` | `table/fmt_sec.js` | ничего |
| `esc` | `table/esc.js` | ничего |
| `estForFile` | `table/est_for_file.js` | `EST_MB_SEC` → конфиг |
| `addFileWithDedup` | `table/add_file_with_dedup.js` | `MAX_FILE_BYTES`/`MAX_SESSION_BYTES` → конфиг; `sf`/`fileMeta`/`overNames` → состояние модуля |
| `rr` | `table/render.js` | доступ к `sf`/`overNames` через состояние |
| `procRow` | `table/proc_row.js` | ничего (чистая отрисовка) |
| `renderProcTable` | `table/render_proc_table.js` | `procState`/`procExtractRate` → параметры/состояние |
| `rm` | `table/rm_file.js` | `busy`, `fi.files` → состояние/колбэк |
| `ss` | `table/set_status.js` | ничего |
| `fi` change handler | `table/on_files_change.js` | вызывается из `initUploadTable` |
| `folderInput` change handler | `table/on_folder_change.js` | вызывается из `initUploadTable` |
| PUT-цикл из `uploadFiles()` | `upload/put_to_vm.js` | `VM_UPLOAD_URL` → параметр; `ss` → колбэк прогресса |
| `uploadFiles()` фазы 1+1b | `upload/upload_via_vm.js` | **только** PUT + `POST upload_refs`; SSE-часть остаётся в drhider |
> **Граница слоя 2/3:** `uploadFiles()` сейчас смешанный — в нём и закачка (слой 2), и SSE (слой 3).
> При выносе разделить: `uploadViaVM(files, vmUploadUrl)` делает PUT + POST `/api/upload_refs`
> и возвращает `{ok, session, count}`; drhider после этого запускает **свою** SSE-обработку.
### Бэк (`site/routes/api_bp.py`, `site/session.py`)
| Сейчас | → модуль | Что меняется |
|---|---|---|
| `PULL_RETRIES`, `PULL_RETRY_DELAY`, `VM_UPLOAD_PREFIX` | `upload_refs/config.py` | читаются из конфига |
| `_safe_name` | `upload_refs/safe_name.py` | ничего |
| тело `upload_refs` (цикл pull) | `upload_refs/pull_file.py` | вынести ретраи/стрим/лимит в `pull_file(client, url)` |
| endpoint `upload_refs` | `upload_refs/blueprint.py` | `create_upload_refs_blueprint(cfg)` — Blueprint с префиксом из конфига |
| `session.py` целиком | `backend/session/*` | дробление на файл-на-функцию (1:1) |
`session.py` переносится **1:1** — это самодостаточный модуль, просто дробится на функции.
---
## 4. Интерфейсы (точные сигнатуры)
### Слой 1 — `initUploadTable(cfg) -> api`
```
cfg = {
allowedExt: ['.pdf','.doc','.docx','.txt','.md'],
maxFileBytes: 50*1024*1024,
maxSessionBytes: 500*1024*1024,
fileInputEl, folderInputEl, tableBodyEl, countEl, uploadBtnEl,
onFilesChanged(list) // колбэк
}
api = {
pickFiles(), pickFolder(), // привязка к <input>
addFiles(File[]), // дедуп+раскрытие zip+фильтр+лимиты
getFiles() -> [{name,size,file}], // ТОЛЬКО учитываемые (без over)
getOverNames() -> Set, // сверх лимита
setStatus(name, html), // статус в ячейке
render(), clear()
}
```
### Слой 2 — фронт `uploadViaVM(files, vmUploadUrl)`
```
uploadViaVM(files, vmUploadUrl) -> Promise<{ok, session, count, error?}>
1. token = crypto.randomUUID()
2. для каждого файла: PUT vmUploadUrl + token + '_' + k (XHR, progress -> setStatus)
refs.push({name, size, url})
3. POST /api/upload_refs {session, files: refs}
-> {ok, session, count} | {ok:false, error}
```
### Слой 2 — бэк `create_upload_refs_blueprint(cfg)`
```
POST /api/upload_refs {session?, files:[{name,size,url}]}
- name = _safe_name(name) (path traversal)
- url.startswith(VM_UPLOAD_PREFIX) (SSRF)
- size > 50МБ -> delete+skip
- pull: 3 ретрая, пауза 2с
- len(content) > 50МБ -> delete+skip
- add_file: нет сессии -> 404; лимит 500МБ -> skip
- delete url (best-effort)
-> {ok, session, count}
```
---
## 5. Конфиг (слой 0)
```json
{
"vmUploadUrl": "https://contracts.kube5s.ru/drhider-upload/",
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"],
"maxFileBytes": 52428800,
"maxSessionBytes": 524288000,
"apiPrefix": "/api",
"pullRetries": 3,
"pullRetryDelay": 2
}
```
Всё, что отличает drhider от другого проекта, задаётся **здесь**. Код слоёв не содержит
ни одного drhider-специфичного значения.
---
## 6. Порядок работы для Flash (по шагам, после каждого — проверка)
Строго последовательно, чтобы не сломать прод:
1. **`zip/*`** — подключить `fflate` (CDN) и написать `listZipFiles(file, allowedExt)` на
`fflate.unzip` с `filter` (не распаковывать не-doc) + рекурсия по вложенным zip.
Юнит-тест: кириллица, вложенный zip, не-doc расширения.
2. **`table/*`** (слой 1) — вынести функции таблицы/выбора, состояние `sf`/`fileMeta`/`overNames`
увести внутрь `init_upload_table.js`.
3. **`upload/*`** (слой 2 фронт) — PUT + `upload_refs`, параметризовать `vmUploadUrl`.
4. **`backend/upload_refs/*`** + **`backend/session/*`** — вынести `_safe_name`, pull, Blueprint, сессию.
5. **Подключить модуль обратно в drhider** — заменить встроенный код вызовами модуля,
**по одному куску**, прогоняя тесты после каждого. Слой 3 не трогать.
6. **`README.md`** модуля — короткая инструкция для агента нового проекта (раздел 8).
---
## 7. Обязательный чек-лист приёмки (без этого «1:1» не достичь)
- [ ] дедуп имя+размер, суффикс `_2` при другом размере;
- [ ] рекурсивный обход папки, путь (в т.ч. из архива) в таблице;
- [ ] раскрытие ZIP только документов, вложенные ZIP;
- [ ] SSRF-валидация URL, `_safe_name`, ретраи pull — на месте;
- [ ] лимиты 50/500 МБ, статусы «пропущен (лимит)» и «не извлечён»;
- [ ] `esc()` имён (нет XSS);
- [ ] кнопка загрузки disabled при 0 файлов;
- [ ] заморозка UI во время загрузки/обработки;
- [ ] прогресс загрузки (PUT) в таблице;
- [ ] поведение drhider на проде не изменилось.
---
## 8. Что писать в `README.md` модуля (главное для агента нового проекта)
1. **Что это** — 2 слоя и их ответственность (таблицей из раздела 1).
2. **Как подключить фронт** — подключить `zip/*`, `table/*`, `upload/*` как ES-модули
(`<script type="module">`), вызвать `initUploadTable(cfg)` и `uploadViaVM(...)`.
3. **Как подключить бэк** — зарегистрировать `create_upload_refs_blueprint(cfg)` в приложении,
импортировать `session` (create/add/get).
4. **Какой конфиг задать** — один блок `config.example.json` с пояснением каждого поля.
5. **Что НЕ трогать** — слой 3 (логика приложения у каждого своя).
6. **Дерево файлов** (сворачивание по папкам/архивам) — см. `PLAN-file-picker.md` (слой 1, vanilla + fflate).
---
## 9. Что НЕ менять в drhider (риск регресса)
- `drhider/*` (extractor/scanner/replacer/builder/obfuscator) — слой 3, не трогать.
- `process_stream` (SSE), `process`, `download`, `csv_download`, `cancel` — слой 3.
- SSE-часть `uploadFiles()`, `downloadZip()`, `downloadCsv()`, `resetAll()`, `setBusy()`,
`finishProcUI()` — приложение drhider.
- Рефакторить drhider под модуль — только после того, как модуль заработает на новом сервисе.
---
## 10. Открытое решение (спросить у владельца)
- **Куда класть модуль в новом проекте**: папка `upload/` внутри проекта (согласовано —
без общей репы). CORS на ВМ уже настроен под существующий origin — менять только если
реально другой домен (это правка nginx, не кода слоя).
+43 -1
View File
@@ -112,12 +112,24 @@ git add -A && git commit -m "подробное описание" && git push
| Переменная | Значение | | Переменная | Значение |
|---|---| |---|---|
| `LLM_API_KEY` | `sk-ucI5YvOticoOQ9Kuj5K9mQ` | | `LLM_API_KEY` | (секрет — задаётся в переменных окружения Штурвала, не в git) |
| `LLM_URL` | `https://api.aillm.ru/v1/chat/completions` | | `LLM_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`, поэтому файлы на ВМ не удаляются.
+1194
View File
File diff suppressed because it is too large Load Diff
+1218
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -29,7 +29,7 @@ git push origin master
| Переменная | Значение | Для чего | | Переменная | Значение | Для чего |
|---|---|---| |---|---|---|
| `LLM_API_KEY` | `sk-ucI5YvOticoOQ9Kuj5K9mQ` | Ключ доступа к LLM API | | `LLM_API_KEY` | (секрет — задаётся в переменных окружения Штурвала, не в git) | Ключ доступа к LLM API |
| `LLM_URL` | `https://api.aillm.ru/v1/chat/completions` | URL LLM API | | `LLM_URL` | `https://api.aillm.ru/v1/chat/completions` | URL LLM API |
| `LLM_MODEL` | `gpt-oss-120b` | Модель для NER | | `LLM_MODEL` | `gpt-oss-120b` | Модель для NER |
+13 -1
View File
@@ -2,4 +2,16 @@
не надо ДОГАДОК ! не надо ДОГАДОК !
не надо самостоятельно что либо "УЛуЧШАТЬ" - никаких изменений рабочего кода без прямого разрешения не надо самостоятельно что либо "УЛуЧШАТЬ" - никаких изменений рабочего кода без прямого разрешения
есть сомнения - лучше остановись и спроси есть сомнения - лучше остановись и спроси
ВСЁ записывай в хистори ! что планировал что сделал в чём ошибся и тд ВСЁ записывай в хистори ! что планировал что сделал в чём ошибся и тд
---
## 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 + редеплой.
+70
View File
@@ -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. **Что я мог пропустить** — любые реальные баги/риски, не перечисленные выше.
## Правила ответа
- БЕЗ воды. Только конкретика: файл, функция, строка, суть бага, серьёзность (🔴/🟠/🟡), фикс.
- Не предлагай «улучшения» вне рамок ревью (рефакторинг, стиль) — только баги и риски.
- Если тебе нужен контекст, которого нет выше (назначение функции, формат данных,
ожидаемое поведение), — **задай уточняющий вопрос, не домысливай**.
- В конце — итоговая таблица: баг | файл | строка | серьёзность | что делать.
+7
View File
@@ -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, ""))
+8 -2
View File
@@ -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,
+12
View File
@@ -0,0 +1,12 @@
в другом приложении попросил использовать загрузку через ВМ как тут в хайдере
вышло не так как я хотел а именно чтобы 1:1
давай спланируем сначала
думаю что надо 3 независимых слоя
1. фронт: таблица кнопки подсказки и тд - загрузка путей файлов в таблицу - просто выбором файлов, список файлов из архива, ВСЕ файлы из выбранной папки
ограничение по расширениям, рекурсивный обход всех подпапок, файлы не дублировать если одинаковые имя/размер, если размер иной - добавлять суффикс, в таблице виден путь файла - в тч когда из архива
2. собственно закачка файлов - с отображением процесса закачки в таблице
3. логика собственно приложения
+1 -1
View File
@@ -21,7 +21,7 @@ if _sys_path_root not in sys.path:
sys.path.insert(0, _sys_path_root) sys.path.insert(0, _sys_path_root)
# Версия приложения (меняется при изменениях) # Версия приложения (меняется при изменениях)
VERSION = "0.0.67" VERSION = "0.0.78"
def setup_logging(): def setup_logging():
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1,230 +0,0 @@
"""
Хранилище сессий в памяти с автоочисткой по TTL.
Использование:
sid = create_session()
add_file(sid, filename, content_bytes)
files = get_files(sid)
store_result(sid, zip_bytes)
zip_data = get_result(sid)
cleanup(sid)
"""
import uuid
import threading
import time
from typing import Dict, List, Tuple, Optional
# ═══════════════════════════════════════════════════════════════════════════
# Данные сессии
# ═══════════════════════════════════════════════════════════════════════════
TTL_SECONDS = 30 * 60 # 30 минут
# Максимальный объём одного файла и суммарный объём файлов в сессии (защита памяти)
MAX_FILE_BYTES = 50 * 1024 * 1024 # 50 MB на один файл
MAX_SESSION_BYTES = 500 * 1024 * 1024 # 500 MB суммарно на сессию
_sessions: Dict[str, dict] = {}
_lock = threading.Lock()
def _start_timer(sid: str):
"""Запустить таймер автоочистки сессии через TTL."""
def _clean():
with _lock:
_sessions.pop(sid, None)
timer = threading.Timer(TTL_SECONDS, _clean)
timer.daemon = True
timer.start()
return timer
def touch(sid: str):
"""Продлить жизнь сессии: перезапустить TTL-таймер (если сессия существует)."""
with _lock:
s = _sessions.get(sid)
if not s:
return
if s.get("timer"):
s["timer"].cancel()
s["timer"] = _start_timer(sid)
def pause_ttl(sid: str):
"""Приостановить TTL сессии (во время обработки): сессия живёт, пока идёт воркер."""
with _lock:
s = _sessions.get(sid)
if s and s.get("timer"):
s["timer"].cancel()
s["timer"] = None
def resume_ttl(sid: str):
"""Возобновить TTL сессии (после завершения обработки): результат доступен ещё TTL."""
with _lock:
s = _sessions.get(sid)
if not s:
return
if s.get("timer"):
s["timer"].cancel()
s["timer"] = _start_timer(sid)
def request_cancel(sid: str) -> bool:
"""Запросить мягкое прерывание обработки сессии.
Returns:
True если сессия существует и отмена запрошена, False если нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["cancel"].set()
return True
def get_cancel_event(sid: str) -> Optional[threading.Event]:
"""Получить событие отмены сессии (или None, если сессии нет)."""
with _lock:
s = _sessions.get(sid)
return s["cancel"] if s else None
# ═══════════════════════════════════════════════════════════════════════════
# API
# ═══════════════════════════════════════════════════════════════════════════
def create_session() -> str:
"""Создать новую сессию.
Returns:
Уникальный идентификатор сессии (UUID).
"""
sid = uuid.uuid4().hex[: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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+878 -868
View File
File diff suppressed because it is too large Load Diff
+116
View File
@@ -0,0 +1,116 @@
import threading
from time import monotonic
import httpx
from flask import Flask
from upload.backend.session import add_file, cleanup, configure, create_session, get_files
from upload.backend.session import state as session_state
from upload.backend.upload_refs.blueprint import create_upload_refs_blueprint
def build_app(transport, **config):
app = Flask(__name__)
app.config['TESTING'] = True
app.config['UPLOAD_HTTPX_TRANSPORT'] = transport
app.register_blueprint(create_upload_refs_blueprint({
'vmUploadPrefix': 'https://vm.test/buffer/',
'httpxTransport': transport,
'pullRetries': 3,
'pullRetryDelay': 0,
**config,
}))
return app
def test_session_limit_accepts_exact_boundary_and_rejects_overflow():
sid = create_session()
previous = session_state.MAX_SESSION_BYTES
configure(max_session_bytes=10)
try:
assert add_file(sid, 'exact.bin', b'x' * 10) is True
assert add_file(sid, 'overflow.bin', b'x') is False
assert len(get_files(sid)) == 1
finally:
configure(max_session_bytes=previous)
cleanup(sid)
def test_upload_refs_rejects_actual_oversize_and_deletes_buffer():
storage = {'oversize': b'123456789'}
deleted = []
def handler(request):
if request.method == 'GET':
return httpx.Response(200, content=storage['oversize'])
if request.method == 'DELETE':
deleted.append(request.url.path)
storage.clear()
return httpx.Response(204)
return httpx.Response(405)
transport = httpx.MockTransport(handler)
app = build_app(transport, maxFileBytes=8)
sid = create_session()
try:
with app.test_client() as client:
response = client.post('/api/upload_refs', json={
'session': sid,
'files': [{
'name': 'oversize.txt',
'size': 8,
'url': 'https://vm.test/buffer/oversize',
}],
})
assert response.status_code == 200
assert response.get_json()['added'] == 0
assert get_files(sid) == []
assert deleted == ['/buffer/oversize']
assert storage == {}
finally:
cleanup(sid)
def test_upload_refs_retries_transient_pull_and_deletes_after_success():
attempts = []
deleted = []
def handler(request):
if request.method == 'GET':
attempts.append(request.url.path)
if len(attempts) == 1:
return httpx.Response(503)
return httpx.Response(200, content=b'retry-content')
if request.method == 'DELETE':
deleted.append(request.url.path)
return httpx.Response(204)
return httpx.Response(405)
transport = httpx.MockTransport(handler)
app = build_app(transport)
with app.test_client() as client:
response = client.post('/api/upload_refs', json={'files': [{
'name': 'retry.txt',
'size': 13,
'url': 'https://vm.test/buffer/retry',
}]})
assert response.status_code == 200
data = response.get_json()
assert data['added'] == 1
assert len(attempts) == 2
assert deleted == ['/buffer/retry']
cleanup(data['session'])
def test_session_ttl_removes_session():
previous = session_state.TTL_SECONDS
configure(ttl_seconds=0.05)
sid = create_session()
try:
deadline = monotonic() + 1
while monotonic() < deadline and get_files(sid) is not None:
threading.Event().wait(0.01)
assert get_files(sid) is None
finally:
configure(ttl_seconds=previous)
cleanup(sid)
+17
View File
@@ -0,0 +1,17 @@
import pytest
from upload.backend.upload_refs.safe_name import safe_name
def test_safe_name_simple():
assert safe_name("test.txt") == "test.txt"
assert safe_name("folder/subfolder/file.pdf") == "folder/subfolder/file.pdf"
assert safe_name("folder\\subfolder\\file.pdf") == "folder/subfolder/file.pdf"
def test_safe_name_traversal():
assert safe_name("../etc/passwd") == ""
assert safe_name("folder/../../etc/passwd") == ""
assert safe_name("/root/file.txt") == "root/file.txt"
assert safe_name("..") == ""
assert safe_name("") == ""
assert safe_name(None) == ""
+1 -1
View File
@@ -75,7 +75,7 @@ def test_single_file():
assert rv.status_code == 200 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
+345
View File
@@ -0,0 +1,345 @@
"""Тесты переиспользуемого модуля upload (backend): session, safe_name, blueprint.
Запуск:
python3 tests/test_upload_module.py
pytest tests/test_upload_module.py
"""
import os
import sys
import time
# Корень проекта — для импорта пакета upload
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from flask import Flask
from upload.backend.session import (
create_session, add_file, get_files, file_count,
store_result, get_result, store_csv, get_csv,
cleanup, request_cancel, get_cancel_event,
)
from upload.backend.upload_refs import safe_name, create_upload_refs_blueprint
VM_PREFIX = "https://contracts.kube5s.ru/drhider-upload/"
# ═══════════════════════════════════════════════════════════════════════════
# session
# ═══════════════════════════════════════════════════════════════════════════
def test_create_session():
sid = create_session()
assert len(sid) == 32 # uuid4().hex
def test_add_file_and_get():
sid = create_session()
assert add_file(sid, "a.txt", b"hello") is True
assert get_files(sid) == [("a.txt", b"hello")]
assert file_count(sid) == 1
def test_add_file_missing_session():
assert add_file("nonexistent", "a.txt", b"x") is False
assert get_files("nonexistent") is None
def test_session_limit():
from upload.backend.session import configure
configure(max_session_bytes=10)
try:
sid = create_session()
assert add_file(sid, "a", b"12345") is True
# суммарно уже 5; +6 = 11 > 10 → False
assert add_file(sid, "b", b"123456") is False
assert file_count(sid) == 1
finally:
configure(max_session_bytes=500 * 1024 * 1024)
def test_store_result_and_csv():
sid = create_session()
assert store_result(sid, b"zip") is True
assert get_result(sid) == b"zip"
assert store_csv(sid, "a,b") is True
assert get_csv(sid) == "a,b"
def test_cleanup():
sid = create_session()
cleanup(sid)
assert get_files(sid) is None
def test_cancel():
sid = create_session()
assert request_cancel(sid) is True
ev = get_cancel_event(sid)
assert ev is not None and ev.is_set()
assert request_cancel("nonexistent") is False
assert get_cancel_event("nonexistent") is None
# ═══════════════════════════════════════════════════════════════════════════
# safe_name
# ═══════════════════════════════════════════════════════════════════════════
def test_safe_name():
assert safe_name("a.txt") == "a.txt"
assert safe_name("dir/sub/a.txt") == "dir/sub/a.txt"
assert safe_name("dir\\sub\\a.txt") == "dir/sub/a.txt"
assert safe_name("../etc/passwd") == ""
assert safe_name("a/../../b") == ""
assert safe_name("") == ""
assert safe_name("./a.txt") == "a.txt"
assert safe_name("/etc/passwd") == "etc/passwd"
# ═══════════════════════════════════════════════════════════════════════════
# blueprint upload_refs
# ═══════════════════════════════════════════════════════════════════════════
class _FakeStream:
def __init__(self, payload=b"", status=200, exc=None):
self._payload = payload
self._status = status
self._exc = exc
def __enter__(self):
if self._exc:
raise self._exc
return self
def __exit__(self, *a):
return False
def raise_for_status(self):
if self._status >= 400:
raise RuntimeError("HTTP %d" % self._status)
def iter_bytes(self):
yield self._payload
class _FakeClient:
"""Имитация httpx.Client: stream/delete без сети.
behavior: dict url -> "ok" | {"payload": bytes} | {"exc": Exception}
"""
def __init__(self, behavior):
self._behavior = behavior
self.deleted = []
self.streams = []
def __enter__(self):
return self
def __exit__(self, *a):
return False
def stream(self, method, url):
self.streams.append((method, url))
b = self._behavior.get(url)
if isinstance(b, dict) and "payload" in b:
return _FakeStream(payload=b["payload"])
if isinstance(b, dict) and "exc" in b:
return _FakeStream(exc=b["exc"])
if b == "ok":
return _FakeStream()
return _FakeStream(status=500)
def delete(self, url):
self.deleted.append(url)
def _make_client(behavior, cfg=None, monkeypatch=None):
import upload.backend.upload_refs.blueprint as bp_mod
monkeypatch.setattr(bp_mod.httpx, "Client", lambda **kw: _FakeClient(behavior))
app = Flask(__name__)
app.register_blueprint(create_upload_refs_blueprint(cfg or {}))
return app.test_client()
def test_upload_refs_no_files(monkeypatch):
c = _make_client({}, monkeypatch=monkeypatch)
rv = c.post("/api/upload_refs", json={"session": "", "files": []})
assert rv.status_code == 400
def test_upload_refs_ok(monkeypatch):
url = VM_PREFIX + "tok_0"
c = _make_client({url: {"payload": b"hello"}}, monkeypatch=monkeypatch)
rv = c.post("/api/upload_refs", json={
"session": "",
"files": [{"name": "a.txt", "size": 5, "url": url}],
})
assert rv.status_code == 200
data = rv.get_json()
assert data["ok"] is True
assert data["count"] == 1
files = get_files(data["session"])
assert files == [("a.txt", b"hello")]
def test_upload_refs_ssrf(monkeypatch):
c = _make_client({}, monkeypatch=monkeypatch)
rv = c.post("/api/upload_refs", json={
"session": "",
"files": [{"name": "evil.txt", "size": 5, "url": "https://evil.example/x"}],
})
assert rv.status_code == 200
data = rv.get_json()
assert data["ok"] is True
assert data["count"] == 0 # unsafe URL пропущен
def test_upload_refs_too_large(monkeypatch):
url = VM_PREFIX + "tok_0"
c = _make_client({}, monkeypatch=monkeypatch)
rv = c.post("/api/upload_refs", json={
"session": "",
"files": [{"name": "big.bin", "size": 60 * 1024 * 1024, "url": url}],
})
assert rv.status_code == 200
assert rv.get_json()["count"] == 0 # сверх 50МБ → skip+delete
def test_upload_refs_session_not_found(monkeypatch):
url = VM_PREFIX + "tok_0"
c = _make_client({url: {"payload": b"x"}}, monkeypatch=monkeypatch)
rv = c.post("/api/upload_refs", json={
"session": "no-such-session",
"files": [{"name": "a.txt", "size": 1, "url": url}],
})
assert rv.status_code == 404
assert rv.get_json()["error"] == "Session not found"
def test_upload_refs_pull_failed(monkeypatch):
url = VM_PREFIX + "tok_0"
behavior = {url: {"exc": RuntimeError("dns fail")}}
c = _make_client(behavior, cfg={"pullRetryDelay": 0}, monkeypatch=monkeypatch)
rv = c.post("/api/upload_refs", json={
"session": "",
"files": [{"name": "a.txt", "size": 1, "url": url}],
})
assert rv.status_code == 502 # все ретраи провалились
def test_session_many_files():
"""Нагрузка: 1000 файлов в одной сессии."""
sid = create_session()
for i in range(1000):
assert add_file(sid, "f%d.txt" % i, b"x" * 10) is True
assert file_count(sid) == 1000
assert len(get_files(sid)) == 1000
def test_concurrent_add_files():
"""Конкурентность: 4 потока добавляют файлы в общую сессию."""
import threading
sid = create_session()
def worker(prefix):
for i in range(50):
add_file(sid, "%s_%d.txt" % (prefix, i), b"data")
threads = [threading.Thread(target=worker, args=("t%d" % t,)) for t in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
assert file_count(sid) == 200
def test_session_ttl_cleanup():
"""TTL: короткий TTL → сессия очищается фоновым таймером (долгий тест)."""
from upload.backend.session import configure
configure(ttl_seconds=1)
try:
sid = create_session()
assert add_file(sid, "a.txt", b"x") is True
time.sleep(1.5)
assert get_files(sid) is None # таймер удалил сессию
finally:
configure(ttl_seconds=30 * 60)
def test_add_file_session_limit_boundary():
"""Суммарный лимит: граница ровно на MAX_SESSION_BYTES."""
from upload.backend.session import configure
configure(max_session_bytes=1000)
try:
sid = create_session()
assert add_file(sid, "a", b"x" * 500) is True
assert add_file(sid, "b", b"x" * 500) is True # ровно 1000
assert add_file(sid, "c", b"x") is False # 1001 > 1000
assert file_count(sid) == 2
finally:
configure(max_session_bytes=500 * 1024 * 1024)
def test_safe_name_edge_cases():
"""safe_name: граничные случаи."""
assert safe_name("...") == "..."
assert safe_name("a/../b") == ""
assert safe_name("a//b") == "a/b"
assert safe_name(" ") == " "
assert safe_name("договор/файл.pdf") == "договор/файл.pdf"
assert safe_name("C:\\path\\file") == "C:/path/file"
assert safe_name("/abs/path") == "abs/path"
def test_upload_refs_many_files(monkeypatch):
"""Нагрузка: 100 refs → все pull OK, count=100."""
behavior = {}
files = []
for i in range(100):
url = VM_PREFIX + "tok_%d" % i
behavior[url] = {"payload": b"x" * 10}
files.append({"name": "f%d.txt" % i, "size": 10, "url": url})
c = _make_client(behavior, monkeypatch=monkeypatch)
rv = c.post("/api/upload_refs", json={"session": "", "files": files})
assert rv.status_code == 200
assert rv.get_json()["count"] == 100
# ═══════════════════════════════════════════════════════════════════════════
# main (запуск без pytest)
# ═══════════════════════════════════════════════════════════════════════════
def main():
"""Прогнать все test_* функции без pytest."""
import inspect
import traceback
fns = [(k, v) for k, v in sorted(globals().items()) if k.startswith("test_")]
mp = _FakeMonkeyPatch()
failed = 0
for name, fn in fns:
try:
if "monkeypatch" in inspect.signature(fn).parameters:
fn(monkeypatch=mp)
else:
fn()
print("PASS %s" % name)
except Exception:
failed += 1
print("FAIL %s" % name)
traceback.print_exc()
if failed:
print("%d тестов упало" % failed)
sys.exit(1)
print("Все тесты прошли")
class _FakeMonkeyPatch:
"""Мини-monkeypatch: setattr, работает до конца вызова (без undo)."""
def setattr(self, target, name, value):
setattr(target, name, value)
if __name__ == "__main__":
main()
+1
Submodule upload-platform added at 1750a8497f
+31
View File
@@ -0,0 +1,31 @@
# Upload module (v0.2.0)
Переиспользуемый модуль загрузки, состоящий из двух независимых слоёв:
1. **Слой 1 (`upload/frontend/`)** — File Picker:
- IIFE: `FilePicker.initFilePicker(config)`;
- ESM: `import { initFilePicker } from './file-picker.esm.js'` (или из `upload/frontend/index.js`).
- Получение выбранных файлов: `picker.getFiles()` возвращает плоский массив `{ path, name, size, file }`.
- Поддерживает выбор файлов, папок, клиентскую распаковку ZIP (fflate) и проверку лимитов.
2. **Слой 2 (`upload/frontend/upload/` + `upload/backend/`)** — Пофайловый транзит в RAM сессии:
- Фронтенд: `uploadViaVM(files, options)` или `FilePicker.uploadViaVM(files, options)`:
- Пофайловый стриминг: для каждого файла выполняется `PUT` в буфер на ВМ, немедленный `POST /api/upload_refs` в бэкенд, и после подтверждения приёма буфер на ВМ очищается.
- Поддержка `AbortSignal` для отмены загрузки на любом этапе.
- Бэкенд: `create_upload_refs_blueprint(cfg)`:
- Приём ссылок через исходящий потоковый `GET` в память RAM.
- SSRF-валидация префикса буфера (`vmUploadPrefix`).
- Защита от path traversal (`safe_name`).
- Хранилище сессий в оперативной памяти (`upload/backend/session`).
- Никакой записи на диск.
## Интеграция в сторонний проект
1. Скопировать каталог `upload/` в проект.
2. Во Flask подключить Blueprint:
```python
from upload.backend.upload_refs import create_upload_refs_blueprint
app.register_blueprint(create_upload_refs_blueprint({"vmUploadPrefix": "..."}))
```
3. На фронтенде подключить `file-picker.iife.js` или импортировать ESM.
4. Вызвать `initFilePicker`, а при отправке вызвать `uploadViaVM(picker.getFiles(), options)`.
+5
View File
@@ -0,0 +1,5 @@
"""Переиспользуемые слои загрузки файлов.
Пакет не выполняет импортов и не запускает побочных действий при загрузке.
Рабочая picker-only логика находится в подпакетах ``frontend`` и ``zip``.
"""
+1
View File
@@ -0,0 +1 @@
"""Backend переиспользуемого модуля загрузки: upload_refs + session."""
+38
View File
@@ -0,0 +1,38 @@
"""In-memory хранилище сессий с TTL и лимитами.
Каждая операция — в отдельном файле, общее состояние — в state.py.
Импорт как единый пакет:
from upload.backend.session import create_session, add_file, get_files
"""
from .create_session import create_session
from .add_file import add_file
from .get_files import get_files, file_count
from .store_result import store_result, get_result
from .store_csv import store_csv, get_csv
from .ttl import touch, pause_ttl, resume_ttl
from .cancel import request_cancel, get_cancel_event
from .cleanup import cleanup
from .state import TTL_SECONDS, MAX_FILE_BYTES, MAX_SESSION_BYTES, configure
__all__ = [
"create_session",
"add_file",
"get_files",
"file_count",
"store_result",
"get_result",
"store_csv",
"get_csv",
"touch",
"pause_ttl",
"resume_ttl",
"request_cancel",
"get_cancel_event",
"cleanup",
"configure",
"TTL_SECONDS",
"MAX_FILE_BYTES",
"MAX_SESSION_BYTES",
]
+25
View File
@@ -0,0 +1,25 @@
"""add_file — добавить файл в сессию (с проверкой суммарного лимита)."""
from . import state
def add_file(sid: str, filename: str, content: bytes) -> bool:
"""Добавить файл в сессию.
Args:
sid: Идентификатор сессии
filename: Имя файла
content: Бинарное содержимое
Returns:
True если добавлено; False если сессии нет или превышен лимит сессии.
"""
with state._lock:
s = state._sessions.get(sid)
if not s:
return False
total = sum(len(c) for _, c in s["files"])
if total + len(content) > state.MAX_SESSION_BYTES:
return False # превышен суммарный лимит сессии
s["files"].append((filename, content))
return True
+27
View File
@@ -0,0 +1,27 @@
"""request_cancel / get_cancel_event — мягкое прерывание обработки сессии."""
import threading
from typing import Optional
from .state import _sessions, _lock
def request_cancel(sid: str) -> bool:
"""Запросить мягкое прерывание обработки сессии.
Returns:
True если сессия существует и отмена запрошена, False если нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["cancel"].set()
return True
def get_cancel_event(sid: str) -> Optional[threading.Event]:
"""Получить событие отмены сессии (или None, если сессии нет)."""
with _lock:
s = _sessions.get(sid)
return s["cancel"] if s else None
+11
View File
@@ -0,0 +1,11 @@
"""cleanup — удалить сессию."""
from .state import _sessions, _lock
def cleanup(sid: str):
"""Удалить сессию и остановить её TTL-таймер."""
with _lock:
s = _sessions.pop(sid, None)
if s and s.get("timer"):
s["timer"].cancel()
+23
View File
@@ -0,0 +1,23 @@
"""create_session — создать новую сессию."""
import threading
import uuid
from .state import _sessions, _lock, _start_timer
def create_session() -> str:
"""Создать новую сессию.
Returns:
Уникальный идентификатор сессии (UUID).
"""
sid = uuid.uuid4().hex
with _lock:
_sessions[sid] = {
"files": [],
"result": None,
"cancel": threading.Event(),
"timer": _start_timer(sid),
}
return sid
+23
View File
@@ -0,0 +1,23 @@
"""get_files / file_count — чтение файлов сессии."""
from typing import List, Optional, Tuple
from .state import _sessions, _lock
def get_files(sid: str) -> Optional[List[Tuple[str, bytes]]]:
"""Получить все файлы сессии.
Returns:
[(filename, content), ...] или None если сессия не найдена.
"""
with _lock:
s = _sessions.get(sid)
return list(s["files"]) if s else None
def file_count(sid: str) -> int:
"""Количество файлов в сессии."""
with _lock:
s = _sessions.get(sid)
return len(s["files"]) if s else 0
+45
View File
@@ -0,0 +1,45 @@
"""Общее состояние сессий: хранилище, блокировка, константы, TTL-таймер.
Единая точка хранения состояния — все операции импортируют её.
Дробить state.py на файл-на-переменную не нужно: это данные, а не функции.
"""
import threading
# TTL сессии: 30 минут
TTL_SECONDS = 30 * 60
# Максимальный объём одного файла и суммарный объём файлов в сессии (защита памяти)
MAX_FILE_BYTES = 50 * 1024 * 1024 # 50 MB на один файл
MAX_SESSION_BYTES = 500 * 1024 * 1024 # 500 MB суммарно на сессию
_sessions: dict = {}
_lock = threading.Lock()
def _start_timer(sid: str) -> threading.Timer:
"""Запустить таймер автоочистки сессии через TTL."""
def _clean():
with _lock:
_sessions.pop(sid, None)
timer = threading.Timer(TTL_SECONDS, _clean)
timer.daemon = True
timer.start()
return timer
def configure(max_file_bytes: int = None, max_session_bytes: int = None,
ttl_seconds: int = None):
"""Переопределить лимиты/TTL из конфига приложения (глобально).
None — оставить текущее значение.
"""
global MAX_FILE_BYTES, MAX_SESSION_BYTES, TTL_SECONDS
if max_file_bytes is not None:
MAX_FILE_BYTES = max_file_bytes
if max_session_bytes is not None:
MAX_SESSION_BYTES = max_session_bytes
if ttl_seconds is not None:
TTL_SECONDS = ttl_seconds
+30
View File
@@ -0,0 +1,30 @@
"""store_csv / get_csv — сохранение и чтение CSV с таблицей замен."""
from typing import Optional
from .state import _sessions, _lock
def store_csv(sid: str, csv_str: str) -> bool:
"""Сохранить CSV с таблицей замен.
Returns:
True если сохранено, False если сессии нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["csv"] = csv_str
return True
def get_csv(sid: str) -> Optional[str]:
"""Получить CSV с таблицей замен.
Returns:
Строка CSV или None если нет.
"""
with _lock:
s = _sessions.get(sid)
return s.get("csv") if s else None
+30
View File
@@ -0,0 +1,30 @@
"""store_result / get_result — сохранение и чтение результата обработки."""
from typing import Optional
from .state import _sessions, _lock
def store_result(sid: str, zip_data: bytes) -> bool:
"""Сохранить результат обработки (ZIP-архив).
Returns:
True если сохранено, False если сессии нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["result"] = zip_data
return True
def get_result(sid: str) -> Optional[bytes]:
"""Получить результат обработки.
Returns:
ZIP-архив или None если сессия не найдена/результат не готов.
"""
with _lock:
s = _sessions.get(sid)
return s["result"] if s else None
+34
View File
@@ -0,0 +1,34 @@
"""touch / pause_ttl / resume_ttl — управление TTL-таймером сессии."""
from .state import _sessions, _lock, _start_timer
def touch(sid: str):
"""Продлить жизнь сессии: перезапустить TTL-таймер (если сессия существует)."""
with _lock:
s = _sessions.get(sid)
if not s:
return
if s.get("timer"):
s["timer"].cancel()
s["timer"] = _start_timer(sid)
def pause_ttl(sid: str):
"""Приостановить TTL сессии (во время обработки): сессия живёт, пока идёт воркер."""
with _lock:
s = _sessions.get(sid)
if s and s.get("timer"):
s["timer"].cancel()
s["timer"] = None
def resume_ttl(sid: str):
"""Возобновить TTL сессии (после завершения обработки): результат доступен ещё TTL."""
with _lock:
s = _sessions.get(sid)
if not s:
return
if s.get("timer"):
s["timer"].cancel()
s["timer"] = _start_timer(sid)
+7
View File
@@ -0,0 +1,7 @@
"""upload_refs — Flask Blueprint для приёма ссылок и pull с ВМ-буфера."""
from .blueprint import create_upload_refs_blueprint
from .safe_name import safe_name
from .pull_file import pull_file
__all__ = ["create_upload_refs_blueprint", "safe_name", "pull_file"]
+145
View File
@@ -0,0 +1,145 @@
"""Переиспользуемый Blueprint слоя 2: POST /upload_refs (pull с ВМ-буфера в сессию).
Поведение:
- safe_name (защита от path traversal)
- SSRF-валидация url.startswith(vm_prefix)
- лимит на один файл -> delete + skip
- pull с ретраями через httpx stream
- лимит сессии -> skip; отсутствие сессии -> 404
- delete url с ВМ (best-effort)
- поддержка пофайлового транзита и пачек
- опциональный callback для интеграции/эмуляции Слоя 3 (on_file_received)
"""
import httpx
import logging
from urllib.parse import urlsplit
from flask import Blueprint, request, jsonify, current_app
from ..session import (create_session, add_file, get_files, file_count,
MAX_FILE_BYTES, configure)
from .config import PULL_RETRIES, PULL_RETRY_DELAY, VM_UPLOAD_PREFIX
from .safe_name import safe_name
from .pull_file import pull_file
log = logging.getLogger("upload.upload_refs")
def create_upload_refs_blueprint(cfg: dict = None) -> Blueprint:
"""Создать Blueprint с эндпоинтом upload_refs.
cfg (все ключи опциональны, есть дефолты):
apiPrefix (str) — префикс Blueprint, по умолчанию "/api"
vmUploadPrefix (str) — доверенный префикс ВМ-буфера (SSRF-валидация)
maxFileBytes (int) — лимит на один файл
maxSessionBytes (int) — суммарный лимит сессии (применяется к сессиям)
ttlSeconds (int) — TTL сессии
pullRetries (int) — ретраи pull
pullRetryDelay (int) — пауза между ретраями (сек)
pullTimeout (int) — таймаут одного GET pull
onFileReceived (func) — опциональный callback (sid, name, content) для Слоя 3
"""
cfg = cfg or {}
prefix = cfg.get("apiPrefix", "/api")
vm_prefix = cfg.get("vmUploadPrefix", VM_UPLOAD_PREFIX)
max_file_bytes = cfg.get("maxFileBytes", MAX_FILE_BYTES)
pull_retries = cfg.get("pullRetries", PULL_RETRIES)
pull_delay = cfg.get("pullRetryDelay", PULL_RETRY_DELAY)
pull_timeout = cfg.get("pullTimeout", 120)
on_file_received = cfg.get("onFileReceived")
# Применить лимиты сессии/TTL из конфига (глобально для всех сессий)
configure(
max_file_bytes=cfg.get("maxFileBytes"),
max_session_bytes=cfg.get("maxSessionBytes"),
ttl_seconds=cfg.get("ttlSeconds"),
)
is_path_only_prefix = vm_prefix.startswith("/")
bp = Blueprint("upload_refs", __name__, url_prefix=prefix)
@bp.route("/upload_refs", methods=["POST"])
def upload_refs():
"""Принять ссылки на файлы (загружены на ВМ-буфер), забрать по egress.
Вход: JSON {"session": "...", "files": [{"name": str, "size": int, "url": str}]}.
Каждый файл тянется исходящим GET с ВМ (egress не ограничен шлюзом),
читается по частям (stream), кладётся в сессию. После успешного pull файл
удаляется с ВМ (DELETE).
"""
data = request.get_json(silent=True) or {}
sid = data.get("session") or create_session()
refs = data.get("files") or []
if not refs:
log.warning("upload_refs: no files, sid=%s", sid)
return jsonify({"ok": False, "error": "No files"}), 400
added = 0
transport = cfg.get("httpxTransport") or current_app.config.get("UPLOAD_HTTPX_TRANSPORT")
try:
with httpx.Client(transport=transport, timeout=pull_timeout, follow_redirects=True) as client:
for ref in refs:
name = safe_name(ref.get("name") or "")
url = ref.get("url") or ""
if not name or not url:
continue
# SSRF-защита: тянуть можно ТОЛЬКО с доверенного ВМ-буфера
if is_path_only_prefix:
if not urlsplit(url).path.startswith(vm_prefix):
log.warning("upload_refs: unsafe URL path, skip sid=%s url=%r", sid, url)
continue
if not url.startswith(("http://", "https://")):
url = request.host_url.rstrip("/") + ("/" if not url.startswith("/") else "") + url
else:
if not url.startswith(vm_prefix):
log.warning("upload_refs: unsafe URL, skip sid=%s url=%r", sid, url)
continue
# Лимит на один файл: сверх лимита — пропускаем
if (ref.get("size") or 0) > max_file_bytes:
log.warning("upload_refs: file exceeds %dMB, skip sid=%s file=%r size=%s",
max_file_bytes // (1024 * 1024), sid, name, ref.get("size"))
try:
client.delete(url)
except Exception:
pass
continue
# Pull с ретраями
content = pull_file(client, url, pull_retries, pull_delay, sid=sid, name=name)
log.info("upload_refs: pulled sid=%s file=%r size=%d", sid, name, len(content))
if len(content) > max_file_bytes:
log.warning("upload_refs: pulled file exceeds %dMB, skip sid=%s file=%r size=%d",
max_file_bytes // (1024 * 1024), sid, name, len(content))
try:
client.delete(url)
except Exception:
pass
continue
if not add_file(sid, name, content):
if get_files(sid) is None:
log.warning("upload_refs: session not found, sid=%s file=%r", sid, name)
return jsonify({"ok": False, "error": "Session not found"}), 404
log.warning("upload_refs: session limit exceeded, skip sid=%s file=%r", sid, name)
try:
client.delete(url)
except Exception:
pass
continue
# Успешно добавлен в сессию — удаляем с ВМ-буфера
try:
client.delete(url)
except Exception:
pass
# Если передан callback для Слоя 3 (эмуляция или реальный процессинг)
if callable(on_file_received):
try:
on_file_received(sid, name, content)
except Exception as cb_err:
log.warning("upload_refs: on_file_received callback error: %r", cb_err)
added += 1
except Exception as e:
log.error("upload_refs: pull error sid=%s: %r", sid, e)
return jsonify({"ok": False, "error": "Pull failed: %s" % e}), 502
log.info("upload_refs: done sid=%s added=%d total=%d", sid, added, file_count(sid))
return jsonify({"ok": True, "session": sid, "count": file_count(sid), "added": added})
return bp
+11
View File
@@ -0,0 +1,11 @@
"""Параметры слоя 2 (бэк) по умолчанию.
Переопределяются из конфига приложения через create_upload_refs_blueprint(cfg).
"""
# Ретраи pull из ВМ-буфера: защита от разовых DNS/сетевых сбоев (gaierror -5 и т.п.)
PULL_RETRIES = 3
PULL_RETRY_DELAY = 2 # секунды между попытками
# Доверенный префикс ВМ-буфера по умолчанию — валидация URL при pull (защита от SSRF)
VM_UPLOAD_PREFIX = "https://contracts.kube5s.ru/drhider-upload/"
+39
View File
@@ -0,0 +1,39 @@
"""pull_file — вытащить файл с ВМ-буфера исходящим GET с ретраями."""
import logging
import time
from .config import PULL_RETRIES, PULL_RETRY_DELAY
log = logging.getLogger("upload.upload_refs.pull")
def pull_file(client, url: str, retries: int = PULL_RETRIES,
delay: float = PULL_RETRY_DELAY, sid: str = None, name: str = None) -> bytes:
"""GET url с ретраями; читает по частям (stream).
Args:
client: httpx.Client
url: URL файла на ВМ-буфере.
retries: число попыток.
delay: пауза между попытками (сек).
sid/name: для логирования (опционально).
Returns:
Содержимое файла (bytes).
Raises:
Последнюю ошибку попытки, если все ретраи не удались.
"""
last_err = None
for attempt in range(retries):
try:
with client.stream("GET", url) as resp:
resp.raise_for_status()
return b"".join(resp.iter_bytes())
except Exception as e:
last_err = e
log.warning("pull: attempt %d/%d failed sid=%s file=%r: %r",
attempt + 1, retries, sid, name, e)
time.sleep(delay)
raise last_err if last_err else RuntimeError("pull failed")
+16
View File
@@ -0,0 +1,16 @@
"""safe_name — санитизация имени файла (защита от path traversal)."""
def safe_name(name: str) -> str:
"""Санитизировать имя файла: защита от path traversal, сохраняя подпапки.
Запрещает '..' и абсолютные пути; нормализует слэши. Возвращает "" если
имя пустое или небезопасное.
"""
if not name:
return ""
name = name.replace("\\", "/")
parts = [p for p in name.split("/") if p and p != "."]
if not parts or any(p == ".." for p in parts):
return ""
return "/".join(parts)
+3
View File
@@ -0,0 +1,3 @@
{
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"]
}
+193
View File
@@ -0,0 +1,193 @@
import { addFiles, onFilesChange } from './table/on_files_change.js';
import { onFolderChange } from './table/on_folder_change.js';
import { findNode, flattenFiles, render } from './table/render.js';
import { esc } from './table/esc.js';
const DEFAULTS = {
allowedExt: [],
labels: {
pickFiles: 'Выбрать файлы',
pickFolder: 'Выбрать папку',
clear: 'Очистить',
empty: 'Нет выбранных файлов',
statusReady: 'готов',
remove: 'Удалить',
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
count: (count, bytes) => `${count} файлов · ${bytes} B`,
},
layout: { columns: ['path', 'size', 'status'], controls: ['files', 'folder', 'clear'] },
limits: {
maxEntries: 1000,
maxTotalBytes: 100 * 1024 * 1024,
maxEntryBytes: 50 * 1024 * 1024,
maxDepth: 20,
},
};
function resolveMount(mount) {
const element = typeof mount === 'string' ? document.querySelector(mount) : mount;
if (!element) throw new Error('File picker mount element не найден');
return element;
}
function normalizeConfig(config) {
return {
...config,
allowedExt: config.allowedExt || DEFAULTS.allowedExt,
labels: {
...DEFAULTS.labels,
...(config.labels || {}),
columns: { ...DEFAULTS.labels.columns, ...(config.labels?.columns || {}) },
},
layout: { ...DEFAULTS.layout, ...(config.layout || {}) },
limits: { ...DEFAULTS.limits, ...(config.limits || {}) },
};
}
function injectStyles() {
if (document.querySelector('style[data-file-picker]')) return;
const style = document.createElement('style');
style.dataset.filePicker = '';
style.textContent = `
.file-picker { color: #17211b; font-family: Georgia, 'Times New Roman', serif; }
.file-picker .fp-toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; }
.file-picker .fp-toolbar button { border: 0; border-radius: 4px; padding: 12px 17px; color: #f7faf4; background: #1f5a3b; font: 700 13px sans-serif; cursor: pointer; }
.file-picker .fp-toolbar button.secondary { color: #1f5a3b; background: #cbdcc9; }
.file-picker .fp-toolbar button.quiet { color: #5d6a61; background: transparent; }
.file-picker .fp-table-wrap { overflow-x: auto; border-top: 1px solid #b9c7ba; }
.file-picker .fp-table { width: 100%; border-collapse: collapse; font: 14px/1.4 sans-serif; }
.file-picker .fp-table th, .file-picker .fp-table td { padding: 14px 10px; border-bottom: 1px solid #cbd5cc; text-align: left; }
.file-picker .fp-table th { color: #56745f; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
.file-picker .fp-status, .file-picker .fp-count { color: #5d6a61; font: 13px sans-serif; }
.file-picker .fp-count { min-height: 22px; }
.file-picker .tree-name { display: inline-flex; align-items: center; gap: 8px; min-width: 240px; padding-top: 0; padding-bottom: 0; border: 0; background: transparent; color: #17211b; font: inherit; text-align: left; }
.file-picker .tree-group { padding-left: 0; cursor: pointer; }
.file-picker .tree-chevron { display: inline-block; width: 12px; color: #78917d; font-size: 10px; }
.file-picker .remove-btn { padding: 2px 7px; color: #8c5148; background: transparent; font: 20px/1 sans-serif; }
.file-picker .tree-row td:first-child { white-space: nowrap; }
@media (max-width: 600px) { .file-picker .fp-toolbar button { flex: 1 1 42%; } }
`;
document.head.append(style);
}
function buildMarkup(root, cfg) {
const controls = cfg.layout.controls || [];
const control = (name, id, label, className = '') => controls.includes(name)
? `<button id="${id}" type="button" class="${className}">${esc(label)}</button>` : '';
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(cfg.labels.columns);
const headings = columns.map((column) => `<th>${esc(cfg.labels.columns[column] || column)}</th>`).join('');
root.innerHTML = `
<div class="file-picker${cfg.layout.theme ? ` ${esc(cfg.layout.theme)}` : ''}">
<div class="fp-toolbar" aria-label="${esc(cfg.labels.pickFiles)}">
<input class="fp-file-input" type="file" accept="${esc([...cfg.allowedExt, '.zip'].join(','))}" multiple hidden>
<input class="fp-folder-input" type="file" accept="${esc([...cfg.allowedExt, '.zip'].join(','))}" webkitdirectory directory multiple hidden>
${control('files', 'fp-files-btn', cfg.labels.pickFiles)}
${control('folder', 'fp-folder-btn', cfg.labels.pickFolder, 'secondary')}
${control('clear', 'fp-clear-btn', cfg.labels.clear, 'quiet')}
</div>
<p class="fp-status" role="status"></p>
<div class="fp-table-wrap"><table class="fp-table"><thead><tr>${headings}<th></th></tr></thead><tbody class="fp-table-body"></tbody></table></div>
<p class="fp-count"></p>
</div>`;
}
function notify(cfg, state) {
if (typeof cfg.onChange === 'function') cfg.onChange([...flattenFiles(state.nodes)]);
}
function removeMetadata(state, node) {
if (node.kind === 'file') {
state.fileKeys.delete(`${node.path}\u0000${node.file.size}`);
return;
}
node.children.forEach((child) => removeMetadata(state, child));
}
function removeNode(state, id) {
const found = findNode(state.nodes, id);
if (!found) return false;
removeMetadata(state, found.node);
found.nodes.splice(found.nodes.indexOf(found.node), 1);
return true;
}
export function initFilePicker(config) {
const cfg = normalizeConfig(config);
const mount = resolveMount(cfg.mount);
injectStyles();
buildMarkup(mount, cfg);
const root = mount.firstElementChild;
const elements = {
fileInputEl: root.querySelector('.fp-file-input'),
folderInputEl: root.querySelector('.fp-folder-input'),
statusEl: root.querySelector('.fp-status'),
tableBodyEl: root.querySelector('.fp-table-body'),
countEl: root.querySelector('.fp-count'),
};
const state = { nodes: [], fileKeys: new Set(), busy: false };
const listeners = [];
const listen = (target, event, handler) => {
target.addEventListener(event, handler);
listeners.push(() => target.removeEventListener(event, handler));
};
const redraw = () => render(state, elements, cfg);
const filesChange = onFilesChange(state, cfg, elements);
const folderChange = onFolderChange(state, cfg, elements);
listen(elements.fileInputEl, 'change', async () => { await filesChange(); notify(cfg, state); });
listen(elements.folderInputEl, 'change', async () => { await folderChange(); notify(cfg, state); });
listen(elements.tableBodyEl, 'click', (event) => {
const toggle = event.target.closest('[data-toggle]');
if (toggle) {
const found = findNode(state.nodes, toggle.dataset.toggle);
if (found) found.node.expanded = !found.node.expanded;
redraw();
return;
}
const remove = event.target.closest('[data-remove]');
if (remove) {
if (removeNode(state, remove.dataset.remove)) {
redraw();
notify(cfg, state);
}
}
});
if (root.querySelector('#fp-files-btn')) listen(root.querySelector('#fp-files-btn'), 'click', () => elements.fileInputEl.click());
if (root.querySelector('#fp-folder-btn')) listen(root.querySelector('#fp-folder-btn'), 'click', () => elements.folderInputEl.click());
if (root.querySelector('#fp-clear-btn')) listen(root.querySelector('#fp-clear-btn'), 'click', () => {
state.nodes = [];
state.fileKeys.clear();
redraw();
notify(cfg, state);
});
redraw();
return {
pickFiles: () => elements.fileInputEl.click(),
pickFolder: () => elements.folderInputEl.click(),
addFiles: async (files) => { await addFiles(state, cfg, files, elements); notify(cfg, state); },
getFiles: () => [...flattenFiles(state.nodes)],
remove: (id) => {
if (removeNode(state, id)) {
redraw();
notify(cfg, state);
}
},
clear: () => {
state.nodes = [];
state.fileKeys.clear();
redraw();
notify(cfg, state);
},
render: redraw,
destroy: () => {
listeners.forEach((removeListener) => removeListener());
state.nodes = [];
state.fileKeys.clear();
mount.replaceChildren();
},
};
}
export { putToVm } from './upload/put_to_vm.js';
export { uploadViaVM } from './upload/upload_via_vm.js';
export { DEFAULTS };
@@ -0,0 +1,54 @@
/**
* Добавляет узел файла или группы в состояние picker с рекурсивной дедупликацией.
*
* @param {{nodes: Array, fileKeys: Set<string>}} state Внутреннее состояние таблицы.
* @param {{kind: string, path: string, file?: File, children?: Array}} fileNode
* Корневой узел файла, папки или ZIP-архива.
* @returns {boolean} True, если в состояние добавлен хотя бы один узел.
*
* Ключ дедупликации состоит из полного пути и размера файла. Это позволяет
* повторно выбрать файл после удаления и одновременно не скрывает файл с тем
* же путём, но другим размером.
*/
export function addFileWithDedup(state, fileNode) {
// Удаляет дубли и пустые группы снизу вверх, сохраняя исходные File objects.
const accept = (node) => {
if (node.kind === 'file') {
// NUL-разделитель исключает неоднозначность при склейке пути и размера.
const key = `${node.path}\u0000${node.file.size}`;
if (state.fileKeys.has(key)) return null;
state.fileKeys.add(key);
return node;
}
// Группа нужна только пока после фильтрации в ней остался хотя бы один leaf.
node.children = node.children.map(accept).filter(Boolean);
return node.children.length ? node : null;
};
const accepted = accept(fileNode);
// Пустой ZIP/каталог не должен появляться в таблице как пустая строка.
if (!accepted || (accepted.kind !== 'file' && !accepted.children.length)) return false;
if (accepted.kind !== 'file') {
const existing = state.nodes.find((node) => node.kind !== 'file' && node.path === accepted.path);
if (existing) {
mergeChildren(existing, accepted);
return true;
}
}
state.nodes.push(accepted);
return true;
}
function mergeChildren(target, incoming) {
incoming.children.forEach((child) => {
if (child.kind === 'file') {
const duplicate = target.children.some((existing) => existing.kind === 'file'
&& existing.path === child.path && existing.file.size === child.file.size);
if (!duplicate) target.children.push(child);
return;
}
const existing = target.children.find((candidate) => candidate.kind !== 'file'
&& candidate.path === child.path);
if (existing) mergeChildren(existing, child);
else target.children.push(child);
});
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Экранирует текст перед вставкой в HTML-шаблон, собираемый через innerHTML.
*
* @param {*} value Имя файла, путь или сообщение статуса.
* @returns {string} Строка с заменёнными HTML-значимыми символами.
*
* Экранируются и кавычки, потому что значение может попасть не только в текст
* кнопки, но и в HTML-атрибут вроде data-path или aria-label.
*/
export function esc(value) {
return String(value).replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[character]));
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Преобразует размер файла из байтов в короткую строку для таблицы.
*
* @param {number} bytes Размер в байтах.
* @returns {string} Значение в B, KB или MB с одним знаком после запятой.
*/
export function fs(bytes) {
return bytes < 1024 ? `${bytes} B`
: bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB`
: `${(bytes / 1048576).toFixed(1)} MB`;
}
+72
View File
@@ -0,0 +1,72 @@
import { listZipFiles } from '../zip/list_zip_files.js';
import { addFileWithDedup } from './add_file_with_dedup.js';
import { render } from './render.js';
/**
* Обрабатывает список файлов из обычного file input.
*
* @param {{fileKeys: Set<string>, nodes: Array, busy: boolean}} state Состояние picker.
* @param {{allowedExt: string[]}} cfg Конфигурация разрешённых расширений.
* @param {FileList|File[]} files Выбранные browser File objects.
* @param {{tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-элементы вывода.
* @returns {Promise<void>} Завершается после разбора всех ZIP и перерисовки таблицы.
*
* Обычные документы добавляются сразу. ZIP читаются асинхронно в браузере;
* ошибка одного архива не отменяет обработку остальных выбранных файлов.
*/
export async function addFiles(state, cfg, files, elements) {
for (const file of Array.from(files)) {
if (!file.name.toLowerCase().endsWith('.zip')) {
// Фильтр повторяет accept в HTML, потому что accept не является защитой API.
if (!cfg.allowedExt.some((extension) => file.name.toLowerCase().endsWith(extension.toLowerCase()))) {
continue;
}
// Обычный файл получает имя как путь: у file input нет относительного пути.
addFileWithDedup(state, {
id: crypto.randomUUID(), kind: 'file', name: file.name, path: file.name,
file, children: [], expanded: true,
});
continue;
}
try {
// listZipFiles возвращает дерево только с разрешёнными leaf-файлами.
const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits);
if (zipTree) addFileWithDedup(state, zipTree);
} catch (error) {
// Битый или небезопасный ZIP пропускается, чтобы не блокировать picker.
if (typeof cfg.onError === 'function') {
cfg.onError(error, file);
} else if (elements.statusEl) {
elements.statusEl.textContent = `Ошибка чтения архива ${file.name}: ${error.message}`;
}
continue;
}
}
// Единая перерисовка после всей пачки предотвращает промежуточные состояния UI.
render(state, elements, cfg);
}
/**
* Создаёт обработчик change для обычного выбора файлов.
*
* @param {object} state Состояние picker, включая флаг busy.
* @param {object} cfg Конфигурация picker.
* @param {object} elements DOM-элементы picker.
* @returns {Function} Async-обработчик для addEventListener('change', ...).
*
* busy устанавливается синхронно до первого await. Поэтому второе быстрое
* событие выбора не запускает параллельный разбор и не меняет дерево конкурирующе.
*/
export function onFilesChange(state, cfg, elements) {
return async () => {
if (state.busy) return;
state.busy = true;
try {
await addFiles(state, cfg, elements.fileInputEl.files, elements);
} finally {
// Даже исключение вне внутреннего ZIP-catch не оставляет picker заблокированным.
elements.fileInputEl.value = '';
state.busy = false;
}
};
}
+84
View File
@@ -0,0 +1,84 @@
import { listZipFiles } from '../zip/list_zip_files.js';
import { addFileWithDedup } from './add_file_with_dedup.js';
import { rebaseTree } from './rebase_tree.js';
import { render } from './render.js';
/**
* Создаёт async-обработчик выбора каталога через webkitdirectory.
*
* @param {{nodes: Array, fileKeys: Set<string>, busy: boolean}} state Состояние picker.
* @param {{allowedExt: string[]}} cfg Разрешённые расширения.
* @param {{folderInputEl: HTMLInputElement, tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-элементы.
* @returns {Function} Async-обработчик события change.
*
* Браузер отдаёт плоский FileList с webkitRelativePath. Обработчик группирует
* его по первой части пути, создаёт промежуточные папки и затем добавляет каждый
* корень через общую дедупликацию. ZIP внутри каталога раскрывается тем же кодом,
* что и ZIP из обычного file input.
*/
export function onFolderChange(state, cfg, elements) {
return async () => {
if (state.busy) return;
state.busy = true;
try {
const roots = new Map();
for (const file of Array.from(elements.folderInputEl.files)) {
// Для webkitdirectory путь начинается с выбранной корневой папки.
const parts = (file.webkitRelativePath || file.name).split('/');
const relativePath = parts.slice(1).join('/') || file.name;
const lowerPath = relativePath.toLowerCase();
const rootName = parts[0] || file.name;
if (!roots.has(rootName)) {
// Один root на выбранный каталог позволяет сохранить дерево целиком.
roots.set(rootName, { id: crypto.randomUUID(), kind: 'folder', name: rootName,
path: rootName, children: [], expanded: false });
}
const root = roots.get(rootName);
// Вставляет узел по его пути, создавая отсутствующие промежуточные папки.
const addToFolder = (node) => {
let current = root;
const nodeParts = node.path.split('/').slice(1);
nodeParts.forEach((part, index) => {
const last = index === nodeParts.length - 1;
let child = current.children.find((item) => item.name === part);
if (!child) {
child = last ? node : { id: crypto.randomUUID(), kind: 'folder', name: part,
path: `${rootName}/${nodeParts.slice(0, index + 1).join('/')}`,
children: [], expanded: false };
current.children.push(child);
}
current = child;
});
};
if (lowerPath.endsWith('.zip')) {
try {
// ZIP rebased на имя выбранной папки перед вставкой в общий root.
const zip = await listZipFiles(file, cfg.allowedExt, cfg.limits);
if (zip) {
rebaseTree(zip, rootName);
addToFolder(zip);
}
} catch (error) {
// Ошибка одного ZIP не должна терять остальные файлы каталога.
if (typeof cfg.onError === 'function') {
cfg.onError(error, file);
} else if (elements.statusEl) {
elements.statusEl.textContent = `Ошибка чтения архива ${file.name}: ${error.message}`;
}
continue;
}
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
addToFolder({ id: crypto.randomUUID(), kind: 'file', name: parts.at(-1),
path: `${rootName}/${relativePath}`, file, children: [], expanded: true });
}
}
// Дедупликация выполняется после сборки каждого корня каталога.
roots.forEach((root) => addFileWithDedup(state, root));
// Сброс value позволяет выбрать тот же каталог повторно.
elements.folderInputEl.value = '';
render(state, elements, cfg);
} finally {
state.busy = false;
}
};
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Добавляет префикс корневой папки к каждому пути дерева.
*
* @param {{path: string, file?: File, children: Array}} root Узел дерева.
* @param {string} prefix Путь выбранной папки, в которую попал узел.
* @returns {object} Тот же узел после изменения путей.
*
* Для leaf-файлов создаётся новый File с тем же базовым именем; логический путь
* хранится отдельно в узле дерева.
*/
export function rebaseTree(root, prefix) {
root.path = `${prefix}/${root.path}`;
if (root.file) root.file = new File([root.file], root.file.name, { lastModified: root.file.lastModified });
root.children.forEach((child) => rebaseTree(child, prefix));
return root;
}
+124
View File
@@ -0,0 +1,124 @@
import { esc } from './esc.js';
import { fs } from './fs.js';
const DEFAULT_RENDER_CONFIG = {
labels: {
statusReady: 'готов',
remove: 'Удалить',
empty: 'Нет выбранных файлов',
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
count: (count, bytes) => `${count} файлов · ${fs(bytes)}`,
},
layout: { columns: ['path', 'size', 'status'] },
};
function renderConfig(cfg = {}) {
return {
labels: {
...DEFAULT_RENDER_CONFIG.labels,
...(cfg.labels || {}),
columns: {
...DEFAULT_RENDER_CONFIG.labels.columns,
...(cfg.labels?.columns || {}),
},
},
layout: { ...DEFAULT_RENDER_CONFIG.layout, ...(cfg.layout || {}) },
};
}
/**
* Рендерит один узел дерева и его раскрытых потомков в HTML-строки таблицы.
*
* @param {{kind: string, id: string, name: string, path: string, file?: File,
* children: Array, expanded: boolean, error?: string}} node Узел file/folder/zip.
* @param {number} depth Глубина узла; используется для визуального отступа.
* @returns {string} HTML одной строки или поддерева строк.
*
* Имена и пути проходят через esc до вставки в innerHTML. Группа получает
* кнопку раскрытия, leaf-файл — размер, статус и data-path для будущего API статусов.
*/
function renderNode(node, depth, cfg) {
const isGroup = node.kind !== 'file';
const padding = depth * 4;
const name = esc(node.name);
const action = isGroup
? `<button class="tree-name tree-group tree-${node.kind}" style="padding-left:${padding}ch" data-toggle="${node.id}" `
+ `aria-expanded="${node.expanded}"><span class="tree-chevron">${node.expanded ? '▼' : '▶'}</span>${name}</button>`
: `<span class="tree-name" style="padding-left:${padding}ch">${name}</span>`;
const remove = `<button class="remove-btn" type="button" data-remove="${node.id}" aria-label="${esc(cfg.labels.remove)} ${name}">×</button>`;
const pathAttribute = node.kind === 'file' ? ` data-path="${esc(node.path)}"` : '';
const cells = {
path: action,
size: node.kind === 'file' ? fs(node.file.size) : '',
status: node.error ? esc(node.error) : node.kind === 'file' ? esc(cfg.labels.statusReady) : '',
};
const columns = cfg.layout.columns?.length
? cfg.layout.columns
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
const row = `<tr class="tree-row tree-${node.kind}"${pathAttribute}>`
+ columns.map((column) => `<td>${cells[column] || ''}</td>`).join('')
+ `<td>${remove}</td></tr>`;
if (!isGroup || !node.expanded) return row;
// Дочерние строки добавляются только для раскрытой группы.
return row + node.children.map((child) => renderNode(child, depth + 1, cfg)).join('');
}
/**
* Полностью синхронизирует DOM таблицы и счётчик с текущим state.nodes.
*
* @param {{nodes: Array}} state Состояние дерева.
* @param {{tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-вывод.
* @returns {void}
*
* Обход для счётчика независим от раскрытия: свернутая группа всё равно входит
* в количество leaf-файлов и суммарный размер.
*/
export function render(state, elements, cfg) {
const normalized = renderConfig(cfg);
const columns = normalized.layout.columns?.length
? normalized.layout.columns
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
elements.tableBodyEl.innerHTML = state.nodes.length
? state.nodes.map((node) => renderNode(node, 0, normalized)).join('')
: `<tr><td colspan="${columns.length + 1}">${esc(normalized.labels.empty)}</td></tr>`;
const files = [];
// Собирает все leaf-файлы, включая содержимое свернутых групп.
const visit = (node) => {
if (node.kind === 'file') files.push(node);
else node.children.forEach(visit);
};
state.nodes.forEach(visit);
const totalBytes = files.reduce((sum, node) => sum + node.file.size, 0);
elements.countEl.textContent = normalized.labels.count(files.length, totalBytes);
}
/**
* Ищет узел по уникальному id и возвращает также массив его непосредственного родителя.
*
* @param {Array} nodes Массив узлов текущего уровня.
* @param {string} id Идентификатор, созданный через crypto.randomUUID().
* @returns {{node: object, nodes: Array}|null} Узел и контейнер для удаления либо null.
*/
export function findNode(nodes, id) {
for (const node of nodes) {
if (node.id === id) return { node, nodes };
const found = findNode(node.children || [], id);
if (found) return found;
}
return null;
}
/**
* Преобразует иерархическое дерево в плоский список leaf-файлов.
*
* @param {Array} nodes Узлы любого уровня.
* @param {Array} result Внутренний аккумулятор рекурсивного вызова.
* @returns {Array<{path: string, name: string, size: number, file: File}>} Файлы для приложения.
*/
export function flattenFiles(nodes, result = []) {
nodes.forEach((node) => {
if (node.kind === 'file') result.push({ path: node.path, name: node.name, size: node.file.size, file: node.file });
else flattenFiles(node.children, result);
});
return result;
}
+78
View File
@@ -0,0 +1,78 @@
/**
* putToVm — отправка одного файла в буфер методом HTTP PUT (сырое бинарное тело).
*
* @param {Blob|File} file Файл для отправки.
* @param {string} url Полный целевой URL в буфере.
* @param {Object} options Опции отправки:
* @param {function(number, number, number): void} [options.onProgress] Колбэк прогресса (pct, loaded, total).
* @param {function(XMLHttpRequest): void} [options.onXHR] Колбэк регистрации XHR для прямого контроля.
* @param {AbortSignal} [options.signal] Сигнал прерывания запроса (AbortController.signal).
* @param {number} [options.timeoutMs=300000] Таймаут в миллисекундах (по умолчанию 300с).
* @returns {Promise<void>} Разрешается при ответе 2xx, отклоняется при ошибке или прерывании.
*/
export function putToVm(file, url, options = {}) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
if (options.onXHR) {
options.onXHR(xhr);
}
const abortHandler = () => {
xhr.abort();
reject(new DOMException('Upload aborted', 'AbortError'));
};
if (options.signal) {
if (options.signal.aborted) {
reject(new DOMException('Upload aborted', 'AbortError'));
return;
}
options.signal.addEventListener('abort', abortHandler, { once: true });
}
xhr.open('PUT', url);
xhr.timeout = options.timeoutMs || 300000;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && options.onProgress) {
const pct = Math.round((e.loaded / e.total) * 100);
options.onProgress(pct, e.loaded, e.total);
}
};
xhr.onload = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(`Буфер: HTTP ${xhr.status} ${xhr.statusText}`));
}
};
xhr.onerror = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
reject(new Error('Сетевая ошибка при отправке в буфер'));
};
xhr.ontimeout = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
reject(new Error(`Таймаут ожидания ответа буфера (${Math.round(xhr.timeout / 1000)}с)`));
};
xhr.onabort = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
reject(new DOMException('Upload aborted', 'AbortError'));
};
xhr.send(file);
});
}
+188
View File
@@ -0,0 +1,188 @@
/**
* uploadViaVM — Слой 2 (Фронтенд): пофайловый транзит через ВМ-буфер в сессию бэкенда.
*
* Архитектура потокового транзита:
* Для каждого файла k:
* 1. Браузер отправляет файл k методом PUT в буфер на ВМ.
* 2. Браузер сразу делает POST /api/upload_refs для одного файла k.
* 3. Бэкенд забирает файл исходящим GET в память RAM и сразу шлёт DELETE в буфер.
* 4. Файл удалён из буфера ВМ, память чиста.
* 5. Браузер переходит к файлу k+1.
*/
import { putToVm } from './put_to_vm.js';
import { fs } from '../table/fs.js';
function generateUuid() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* Нормализует элемент списка файлов (поддерживает как File, так и объект из FilePicker.getFiles()).
*/
function normalizeFileEntry(item) {
if (item instanceof File || item instanceof Blob) {
return { name: item.name || 'unnamed', size: item.size, file: item, path: item.name || '' };
}
if (item && item.file) {
return {
name: item.name || item.file.name || 'unnamed',
size: item.size ?? item.file.size ?? 0,
file: item.file,
path: item.path || item.name || '',
};
}
throw new Error('Некорректный объект файла');
}
/**
* Выполняет пофайловую транзитную загрузку файлов в сессию бэкенда через буфер.
*
* @param {Array<File|{name: string, size: number, file: File, path?: string}>} files Список файлов.
* @param {Object} options Параметры загрузки:
* @param {string} options.vmUploadUrl Базовый URL буфера (обязателен).
* @param {string} [options.backendUploadUrl='/api/upload_refs'] URL эндпоинта приёма ссылок.
* @param {string} [options.session=''] Идентификатор существующей сессии (если есть).
* @param {AbortSignal} [options.signal] Сигнал отмены загрузки.
* @param {function(string): void} [options.onStatus] Колбэк общего текстового статуса.
* @param {function(number, string): void} [options.onFileStatus] Колбэк обновления статуса конкретного файла (индекс, текст/html).
* @param {function(Object): void} [options.onProgress] Колбэк прогресса текущего файла ({ index, total, pct, speed }).
* @param {function(Object): void} [options.onFileComplete] Колбэк успешной доставки файла ({ index, name, session, totalAdded }).
* @returns {Promise<{ok: boolean, session?: string, count?: number, error?: string, aborted?: boolean}>}
*/
export async function uploadViaVM(files, options = {}) {
if (!options.vmUploadUrl) {
return { ok: false, error: 'Параметр options.vmUploadUrl обязателен' };
}
const normalizedFiles = Array.from(files).map(normalizeFileEntry);
const total = normalizedFiles.length;
if (total === 0) {
return { ok: true, session: options.session || '', count: 0 };
}
const backendUrl = options.backendUploadUrl || (options.apiBase || '') + '/api/upload_refs';
const vmBase = options.vmUploadUrl.endsWith('/') ? options.vmUploadUrl : `${options.vmUploadUrl}/`;
let currentSession = options.session || '';
let totalAdded = 0;
for (let k = 0; k < total; k++) {
if (options.signal?.aborted) {
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
}
const { name, size, file } = normalizedFiles[k];
const fileKey = `${generateUuid()}_${k}`;
const fileUrl = `${vmBase}${fileKey}`;
if (options.onStatus) {
options.onStatus(`Загрузка в буфер (${k + 1}/${total}): ${name}`);
}
if (options.onFileStatus) {
options.onFileStatus(k, '⏳ В буфер...');
}
// Этап 1: Отправка одного файла в буфер
const t0 = performance.now();
try {
await putToVm(file, fileUrl, {
signal: options.signal,
onXHR: options.onXHR,
onProgress(pct, loaded, fileTotal) {
const elapsed = (performance.now() - t0) / 1000;
const speed = elapsed > 0 ? (loaded / elapsed) : 0;
if (options.onFileStatus) {
options.onFileStatus(k, `⏳ ${pct}% (${fs(speed)}/s)`);
}
if (options.onProgress) {
options.onProgress({ index: k, total, pct, speed, loaded, fileTotal, name });
}
},
});
} catch (err) {
if (options.signal?.aborted) {
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
}
if (options.onFileStatus) {
options.onFileStatus(k, '✗ Ошибка буфера');
}
return {
ok: false,
error: `Ошибка отправки файла ${name} в буфер: ${err.message}`,
failedIndex: k,
session: currentSession,
};
}
// Этап 2: Вызов /api/upload_refs для немедленного переноса файла с буфера в RAM сессии
if (options.onStatus) {
options.onStatus(`Приём сервисом (${k + 1}/${total}): ${name}`);
}
if (options.onFileStatus) {
options.onFileStatus(k, '⚡ В сессию...');
}
try {
const resp = await fetch(backendUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session: currentSession,
files: [{ name, size, url: fileUrl }],
}),
signal: options.signal,
});
if (!resp.ok) {
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
}
const data = await resp.json();
if (!data.ok) {
throw new Error(data.error || 'Бэкенд вернул ошибку');
}
currentSession = data.session || currentSession;
totalAdded = data.count ?? (totalAdded + 1);
if (options.onFileStatus) {
options.onFileStatus(k, '✓ Доставлен');
}
if (options.onFileComplete) {
options.onFileComplete({
fileIndex: k,
name,
size,
session: currentSession,
totalAdded,
});
}
} catch (err) {
if (options.signal?.aborted) {
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
}
if (options.onFileStatus) {
options.onFileStatus(k, '✗ Ошибка приёма');
}
return {
ok: false,
error: `Ошибка приёма файла ${name} бэкендом: ${err.message}`,
failedIndex: k,
session: currentSession,
};
}
}
if (options.onStatus) {
options.onStatus(`Завершено. Успешно передано файлов: ${total}`);
}
return { ok: true, session: currentSession, count: totalAdded };
}
+125
View File
@@ -0,0 +1,125 @@
// Построить дерево ZIP с файлами только разрешённых расширений.
import { unzipSync } from 'fflate';
import { rebaseTree } from '../table/rebase_tree.js';
const DEFAULT_LIMITS = {
maxEntries: 1000,
maxTotalBytes: 100 * 1024 * 1024,
maxEntryBytes: 50 * 1024 * 1024,
maxDepth: 20,
};
/** Возвращает true, если имя заканчивается одним из разрешённых расширений. */
function extensionAllowed(name, allowedExt) {
const lowerName = name.toLowerCase();
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
}
/** Создаёт browser File из байтов ZIP entry с базовым именем файла. */
function makeFile(data, name) {
return new File([data], name.split('/').at(-1));
}
/**
* Валидирует путь ZIP entry до его использования в дереве.
*
* @param {string} entryName Сырой путь из центрального каталога ZIP.
* @returns {string[]|null} Безопасные сегменты пути или null для опасной записи.
*
* Отклоняются абсолютные пути, backslash, пустые сегменты, `.` и `..`.
* Это предотвращает traversal и появление ложных групп в UI.
*/
function safeEntryParts(entryName) {
if (!entryName || entryName.startsWith('/') || entryName.includes('\\')) return null;
const parts = entryName.split('/');
if (parts.some((part) => !part || part === '.' || part === '..')) return null;
return parts;
}
/** Создаёт единый узел file, folder или zip с уникальным id и начальным состоянием раскрытия. */
function node(kind, name, path, children = [], file = null) {
return { id: crypto.randomUUID(), kind, name, path, children, file, expanded: kind === 'file' };
}
/** Вставляет leaf или вложенное дерево по сегментам пути, создавая folder-узлы. */
function addPath(root, parts, fileNode) {
let current = root;
parts.forEach((part, index) => {
const last = index === parts.length - 1;
let child = current.children.find((item) => item.name === part);
if (!child) {
child = last
? fileNode
: node('folder', part, `${current.path}/${part}`);
current.children.push(child);
}
current = child;
});
}
/**
* Рекурсивно читает байтовый массив ZIP и строит дерево разрешённых файлов.
*
* @param {Uint8Array} data Распаковываемые байты ZIP.
* @param {string} zipName Имя текущего архива для корневого узла и пути.
* @param {string[]} allowedExt Разрешённые расширения документов.
* @param {number} depth Текущая глубина вложенных ZIP.
* @returns {Promise<object|null>} Дерево или null, если разрешённых leaf нет.
* @throws {Error} При превышении глубины или повреждённом ZIP.
*/
async function listEntries(data, zipName, allowedExt, depth, limits, budget) {
// Ограничение глубины защищает браузер от бесконечной/чрезмерной рекурсии.
if (depth > limits.maxDepth) throw new Error('Слишком глубокая вложенность ZIP');
const entries = unzipSync(data, {
filter: (entry) => {
budget.entries += 1;
if (budget.entries > limits.maxEntries) throw new Error('Слишком много ZIP entries');
const lowerName = entry.name.toLowerCase();
if (!extensionAllowed(lowerName, allowedExt) && !lowerName.endsWith('.zip')) return false;
if (entry.originalSize > limits.maxEntryBytes) return false;
if (budget.totalBytes + entry.originalSize > limits.maxTotalBytes) {
throw new Error('Превышен суммарный размер распакованных ZIP entries');
}
budget.totalBytes += entry.originalSize;
return true;
},
});
const root = node('zip', zipName, zipName);
for (const [entryName, entryData] of Object.entries(entries)) {
// Каталоги ZIP не являются файлами и будут созданы addPath при необходимости.
if (entryName.endsWith('/')) continue;
const parts = safeEntryParts(entryName);
if (!parts) continue;
const normalizedName = parts.join('/');
if (normalizedName.toLowerCase().endsWith('.zip')) {
// Вложенный ZIP раскрывается только если внутри есть разрешённые leaf-файлы.
const nested = await listEntries(entryData, entryName, allowedExt, depth + 1, limits, budget);
if (nested) {
rebaseTree(nested, zipName);
nested.name = parts.at(-1);
addPath(root, parts, nested);
}
} else if (extensionAllowed(normalizedName, allowedExt)) {
// Запрещённые документы отбрасываются до создания browser File.
const path = `${zipName}/${normalizedName}`;
const file = makeFile(entryData, path);
addPath(root, parts, node('file', parts.at(-1), path, [], file));
}
}
return root.children.length ? root : null;
}
/**
* Публично читает выбранный ZIP-файл и возвращает его фильтрованное дерево.
*
* @param {File} file Browser File с ZIP-содержимым.
* @param {string[]} allowedExt Разрешённые расширения.
* @param {object} customLimits Ограничения распаковки.
* @returns {Promise<object|null>} Корневой zip-узел или null для пустого результата.
*/
export async function listZipFiles(file, allowedExt, customLimits = {}) {
const data = new Uint8Array(await file.arrayBuffer());
const limits = { ...DEFAULT_LIMITS, ...customLimits };
return listEntries(data, file.name, allowedExt, 0, limits, { entries: 0, totalBytes: 0 });
}