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