1 Commits
Author SHA1 Message Date
Repinoid ff4cc3ccbc chore: update ignore rules 2026-10-27 14:08:56 +03:00
75 changed files with 2211 additions and 6200 deletions
+48
View File
@@ -0,0 +1,48 @@
# ⛔ ПРАВИЛА ДЛЯ COPILOT — DRHIDER
Читать перед ЛЮБЫМ действием.
---
## ⛔⛔⛔ НИЧЕГО НЕ ДЕЛАТЬ БЕЗ «ДЕЛАЙ»
Любой вопрос, обсуждение, анализ — **НЕ повод менять код.**
Только после явного: «делай», «делайте», «давай», «приступай», «пушить», «commit», «push».
## ⛔⛔⛔ НЕ МЕНЯТЬ КОД БЕЗ РАЗРЕШЕНИЯ
Даже если ошибка очевидна. **Показать → описать → ЖДАТЬ.**
## ⛔⛔⛔ НЕ СПЕШИТЬ
Обдумать. Проверить. Перепроверить. Только потом отвечать.
## ⛔⛔⛔ НЕ ПРЕДПОЛАГАТЬ, НЕ ДОГАДЫВАТЬСЯ
Сомневаешься — **ОСТАНОВИСЬ И СПРОСИ.**
## ⛔⛔⛔ НЕ ЛЕЗТЬ В ЛОКАЛЬНЫЕ ПАПКИ CONTRACTS
Код drhider — на ВМ (`5.172.178.213` через SSH). Исключение: `/home/naeel/nubes/loadtest/`.
---
## 📋 Что есть в проекте
| Что нужно | Читай |
|---|---|
| Меняешь код? | [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) — полный порядок действий |
| Архитектура? | [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — схема, модули, поток данных |
| Деплой/ВМ/env? | [`docs/DEPLOY.md`](docs/DEPLOY.md) — всё про окружение |
| Обзор проекта? | [`README.md`](README.md) — структура, API, правила |
| История изменений? | [`History/`](History/) — что когда и зачем делалось |
---
## 📋 Быстрая справка
- Платформа: **Штурвал** (Managed Flask, без Dockerfile/gunicorn)
- URL: https://drhider.pythonk8s.dev.nubes.ru/
- БД: **нет**, всё в памяти
- Дизайн фронтенда: `/home/naeel/nubes/design/`
- ВМ (legacy): `ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213`
-32
View File
@@ -1,36 +1,4 @@
__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/
OBFUSCATOR/
.agent-tmp/
/chrome-net-export-log.json
-11
View File
@@ -1,11 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libreoffice-writer \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY site /app/site
COPY drhider /app/drhider
EXPOSE 5000
CMD ["python", "site/app.py"]
+43
View File
@@ -0,0 +1,43 @@
# DrHider — Managed Flask на pythonk8s
**Дата:** 2026-07-11
**URL:** https://drhider.pythonk8s.dev.nubes.ru/
**Репозиторий:** https://gitea.services.ngcloud.ru/Nail/drhider
## Структура проекта
```
drhider/
├── Dockerfile # python:3.12-slim, gunicorn + gevent :5000
├── requirements.txt # flask, gunicorn, gevent, python-docx, pdfplumber, httpx
├── .gitignore
├── .gitea/workflows/deploy.yaml # CI (linux_amd64)
├── drhider.py # ядро обфускации (скопировано с ВМ contracts)
├── drhider_server.py # HTTP-сервер (скопировано с ВМ contracts)
├── site/
│ ├── app.py # Flask: GET /, POST /api/drhider, /health
│ ├── templates/index.html
│ └── static/favicon.svg
└── History/
```
## API
| Метод | Путь | Описание |
|---|---|---|
| GET | / | Веб-интерфейс |
| GET | /health | `{"ok": true, "version": "..."}` |
| POST | /api/drhider | multipart files → ZIP с обфусцированными документами |
## Переменные окружения
| Переменная | Назначение | По умолчанию |
|---|---|---|
| `LLM_API_KEY` / `LLM_KEY` | Ключ API для LLM | — |
| `LLM_URL` | URL LLM API | `https://api.aillm.ru/v1/chat/completions` |
| `LLM_MODEL` | Модель | `gpt-oss-120b` |
## Коммиты
- `a455f9d` — Начальная структура managed Flask + drhider
- `a89df86` — Managed Flask: app.py, index.html, CI workflow
+145
View File
@@ -0,0 +1,145 @@
# DrHider — Полный аудит и план рефакторинга
**Дата:** 2026-07-12
**Платформа:** Штурвал (Managed Flask) — запускает Flask сам, без Dockerfile/gunicorn
**URL:** https://drhider.pythonk8s.dev.nubes.ru/
**Репозиторий:** https://gitea.services.ngcloud.ru/Nail/drhider
---
## Текущее состояние (аудит каждого файла)
| Файл | Статус | Решение |
|---|---|---|
| `Dockerfile` | ❌ НЕ НУЖЕН | Штурвал сам запускает Flask. Удалить. |
| `drhider_server.py` | ❌ НЕ НУЖЕН | Standalone-сервер с ВМ (:8767). Flask app.py уже обрабатывает /api/drhider. Удалить. |
| `requirements.txt` | ⚠️ Нужны правки | Убрать `gunicorn` и `gevent`. Оставить `flask`, `python-docx`, `pdfplumber`, `httpx`. |
| `.gitignore` | ✅ OK | Без изменений. |
| `.gitea/workflows/deploy.yaml` | ✅ OK | CI для Штурвала. |
| `site/app.py` | ⚠️ Нужны правки | Удалить блок `if __name__ == "__main__"` (gunicorn). При рефакторинге — вынести LLMClient. |
| `site/templates/index.html` | ✅ OK | UI. |
| `site/static/favicon.svg` | ✅ OK | Иконка (из loadtest). |
| `drhider.py` | ⚠️ МОНОЛИТ | 560 строк. Разбить на ~20 модулей. |
| `History/` | ✅ OK | Документация. |
---
## План рефакторинга (Фаза 1: удаление лишнего)
### Шаг 1.1 — Удалить `Dockerfile`
### Шаг 1.2 — Удалить `drhider_server.py`
### Шаг 1.3 — Исправить `requirements.txt` (убрать gunicorn, gevent)
### Шаг 1.4 — Исправить `site/app.py` (убрать блок `if __name__ == "__main__"`)
### Шаг 1.5 — Проверить синтаксис → commit → push
---
## План рефакторинга (Фаза 2: модульная структура)
### Новая структура `drhider/` пакета (вместо `drhider.py` 560 строк):
```
drhider/ # Пакет ядра обфускации
├── __init__.py # re-export: obfuscate_files()
├── config.py # Константы: ENTITY_PATTERNS, COMPANY_PATTERN, PERSON_PATTERN,
│ # RU_SURNAMES, RU_NAMES, RU_PATRONYMICS,
│ # RU_CITIES, RU_STREETS, FAKE_DOMAINS
├── checksum.py # _checksum_inn10, _checksum_inn12, _checksum_ogrn
├── random_utils.py # _random_digits, _random_letters
├── generators/ # Генераторы фиктивных значений
│ ├── __init__.py # ENTITY_GENERATORS маппинг
│ ├── phone.py # generate_phone
│ ├── email.py # generate_email
│ ├── inn.py # generate_inn10, generate_inn12
│ ├── ogrn.py # generate_ogrn
│ ├── kpp.py # generate_kpp
│ ├── bik.py # generate_bik
│ ├── accounts.py # generate_rs, generate_ks
│ ├── passport.py # generate_passport
│ ├── company.py # generate_company
│ ├── person.py # generate_person
│ └── address.py # generate_address
├── extractor.py # _extract_text, _expand_zips, _convert_pdfs_to_docx
├── scanner.py # Проход 1: _scan_regex, _scan_llm_ner
├── replacer.py # Проход 2: _replace_in_docx, _replace_in_text, _apply_replacements
├── builder.py # Сборка: _build_zip, _build_mapping_csv
├── obfuscator.py # TwoPassObfuscator — оркестратор
└── llm_client.py # LLMClient (из app.py → сюда)
site/ # Flask-приложение
├── __init__.py
├── app.py # Только create_app() + регистрация blueprint'ов
├── routes/
│ ├── __init__.py # Регистрация всех blueprint'ов
│ ├── main_bp.py # GET /
│ ├── health_bp.py # GET /health
│ └── api_bp.py # POST /api/drhider
├── templates/index.html
└── static/favicon.svg
```
### Принципы:
- Каждый файл ≤ 50 строк (где возможно)
- Каждый файл — одна ответственность
- Максимум docstring и инлайн-комментариев
- Все импорты явные, никаких `import *`
---
## Результат рефакторинга (2026-07-12)
### Итоговая структура
```
drhider/ # Пакет ядра обфускации
├── __init__.py # re-export: obfuscate_files, LLMClient, TwoPassObfuscator
├── config.py # Константы: ENTITY_PATTERNS, словари имён/городов/улиц
├── checksum.py # Контрольные суммы: ИНН10, ИНН12, ОГРН
├── random_utils.py # Утилиты: random_digits, random_letters
├── llm_client.py # LLMClient — HTTP-клиент к LLM API
├── extractor.py # Извлечение текста + expand_zips + convert_pdfs_to_docx
├── scanner.py # Проход 1: scan_regex, scan_llm_ner
├── replacer.py # Проход 2: apply_replacements, replace_in_docx, replace_in_text
├── builder.py # Сборка: build_zip, build_mapping_csv
├── obfuscator.py # TwoPassObfuscator — оркестратор
└── generators/ # Генераторы фиктивных значений
├── __init__.py # ENTITY_GENERATORS маппинг
├── phone.py, email.py # Телефон, email
├── inn.py, ogrn.py, kpp.py # ИНН, ОГРН, КПП
├── bik.py, accounts.py # БИК, счета (р/с, к/с)
├── passport.py # Паспорт
├── company.py, person.py # Компания, ФИО
└── address.py # Адрес
site/ # Flask-приложение
├── app.py # create_app() + VERSION (20 строк)
├── routes/
│ ├── __init__.py # register_routes()
│ ├── main_bp.py # GET /
│ ├── health_bp.py # GET /health
│ └── api_bp.py # POST /api/drhider
├── templates/index.html
└── static/favicon.svg
```
### Что изменилось
| Было | Стало |
|---|---|
| `drhider.py` — 560 строк монолит | Пакет `drhider/` — 22 модуля по 20–100 строк |
| `site/app.py` — 100 строк (всё в одном файле) | `app.py` 33 строки + 3 blueprint'а по 20–50 строк |
| `Dockerfile` | Удалён (Штурвал сам) |
| `drhider_server.py` | Удалён (Flask заменяет) |
### Коммиты
- `a2f754a` — Фаза 1: удалён Dockerfile, drhider_server.py, gunicorn
- `37581eb` — Фаза 2: config.py, checksum.py, random_utils.py, generators/
- `2cf4e08` — Фаза 2: llm_client.py, extractor.py, scanner.py, replacer.py, builder.py, obfuscator.py
- `51bc1a7` — Фаза 2: site/app.py → blueprint'ы, старый drhider.py удалён
| Переменная | Значение |
|---|---|
| `LLM_API_KEY` | `sk-ucI5YvOticoOQ9Kuj5K9mQ` |
| `LLM_URL` | `https://api.aillm.ru/v1/chat/completions` |
| `LLM_MODEL` | `gpt-oss-120b` |
+24
View File
@@ -0,0 +1,24 @@
# Документация — 2026-07-12
**После рефакторинга** создана полная документация:
### Созданные файлы
| Файл | Назначение |
|---|---|
| `README.md` | Обзор проекта, правила для агентов, структура, API, env vars |
| `ARCHITECTURE.md` | Архитектура: схема, двухпроходная обфускация, поток данных, описание каждого модуля |
| `DEPLOY.md` | Деплой, ВМ, переменные окружения, локальная разработка, как добавить генератор |
| `.github/copilot-instructions.md` | Правила для Copilot (не менять без «делай», не смотреть локальные папки, коммитить после каждой правки) |
### Ключевые факты зафиксированы
- Платформа: Штурвал (Managed Flask), без Dockerfile/gunicorn
- URL: https://drhider.pythonk8s.dev.nubes.ru/
- Дизайн фронтенда: `/home/naeel/nubes/design/`
- ВМ (legacy): `5.172.178.213`, ssh-ключ `~/.ssh/naeel_vm_id_ed25519`
- Переменные окружения: LLM_API_KEY, LLM_URL, LLM_MODEL
### Коммит
`0707d53` — Документация: README.md, ARCHITECTURE.md, DEPLOY.md, .github/copilot-instructions.md
+76
View File
@@ -0,0 +1,76 @@
# Запрос к Соннету — универсальная архитектура DrHider
## Контекст
Проект DrHider — обфускация документов (замена персональных данных на фиктивные). Сейчас:
- Python/Flask, без БД, Managed Flask на платформе Штурвал
- Двухпроходная архитектура: сбор сущностей → замена
- Проход 1: regex-паттерны (телефон, email, ИНН, ОГРН, КПП, БИК, счета, паспорт) + LLM NER (ФИО, компании, адреса, паспорта)
- Проход 2: замена по словарю mapping
## Проблема
Архитектура **в корне неверна** — жёстко привязана к фиксированному списку типов сущностей:
```python
# config.py — жёстко зашитые паттерны
ENTITY_PATTERNS = {
"phone": r'...',
"email": r'...',
"inn_fl": r'ИНН\s*\d{12}',
...
}
```
```python
# scanner.py — жёстко зашитый промпт
prompt = (
"Найди ВСЕ следующие сущности:\n"
"1. ФИО\n2. Компании\n3. Адреса\n4. Паспортные данные\n"
)
```
Если новый документ содержит СНИЛС, водительское удостоверение, номер договора, ИНН без префикса «ИНН» — система их НЕ обнаружит. Надо править config, generators, scanner, маппинги.
## Что нужно
**Универсальная архитектура**, где система САМА определяет что скрывать в любом документе:
1. Не привязана к списку типов
2. Не требует добавления паттернов под каждый новый вид данных
3. LLM сама решает что является персональными/конфиденциальными данными
4. Генерация фиктивных значений — тоже универсальная (не 11 отдельных функций)
## Текущая структура (полная)
```
drhider/
├── config.py # ENTITY_PATTERNS (10 regex), словари имён/городов
├── checksum.py # Контрольные суммы ИНН/ОГРН
├── random_utils.py # random_digits, random_letters
├── generators/ # 11 файлов: phone, email, inn, ogrn, kpp, bik, accounts, passport, company, person, address
├── llm_client.py # HTTP-клиент к LLM API
├── extractor.py # Извлечение текста + expand_zips + convert_pdfs_to_docx
├── scanner.py # scan_regex (regex) + scan_llm_ner (LLM NER с жёстким промптом)
├── replacer.py # apply_replacements, replace_in_docx, replace_in_text
├── builder.py # build_zip, build_mapping_csv
├── obfuscator.py # TwoPassObfuscator — оркестратор
├── site/app.py # Flask (3 blueprint'а)
└── site/templates/ # HTML-интерфейс
```
## Вопросы к Соннету
1. Как перестроить архитектуру чтобы обнаружение было универсальным (LLM сама решает что скрывать)?
2. Нужен ли regex вообще или достаточно одного LLM с правильным промптом?
3. Как сделать генерацию фиктивных значений универсальной? (Не 11 функций под каждый тип, а что-то общее)
4. Двухпроходная схема (сбор→замена) — сохранять или перейти на однопроходную (LLM сразу возвращает обфусцированный текст)?
5. Как должен выглядеть промпт чтобы LLM возвращала структурированный результат (что найдено + на что заменить)?
6. Стоит ли сохранять DOCX-форматирование (сейчас runs склеиваются-разделяются) или проще отдать LLM plain text?
## Ограничения
- Документы до 200 MB
- Форматы: .docx, .pdf, .txt, .zip
- LLM: OpenAI-совместимое API (aillm.ru, модель gpt-oss-120b, 8000 токенов)
- Без БД, всё в памяти
- Платформа: Managed Flask на Kubernetes
+78
View File
@@ -0,0 +1,78 @@
# Ответ Соннета — универсальная архитектура DrHider (2026-07-12)
Кратко: 6 вопросов → 6 ответов → карта изменений.
---
## Q1. Как сделать обнаружение универсальным?
Промпт должен звучать не «найди вот эти типы», а «найди ВСЁ, что выглядит как приватная информация, и сам назови тип».
Затрагивает: `scanner.py` (промпт), `builder.py` (тип для mapping.csv).
---
## Q2. Regex или LLM?
**Гибрид — правильный выбор:**
- **Regex = pre-pass** для структурированных данных (телефон, email, ИНН, БИК). Экономит токены LLM.
- **LLM = post-pass** для неструктурированного (имена, адреса, нестандартные ID).
- Дублирования не страшны — mapping dict сам отсеет.
---
## Q3. Генерация фиктивных значений — универсальная?
Два уровня:
1. **Известные типы** — специализированные генераторы (оставить как есть).
2. **Неизвестные типы** — fallback-генератор: character-class preserving замена (цифры→цифры, буквы→буквы той же длины).
Дополнительно: в промпт добавить `"category": "person|org|contact|id|financial|other"` — 6 категорий вместо 10+ типов.
---
## Q4. Двухпроходная или однопроходная?
**Двухпроходная — обязательно.** Причина: «Иванов» в 5 документах должен заменяться одинаково. Однопроход не может этого гарантировать.
Текущий `TwoPassObfuscator` — правильный, не трогать.
---
## Q5. Новый промпт
```
You are a PII detector. Find ALL sensitive or private information.
Return JSON array: [{"type": "short_label", "value": "exact_string"}]
Rules:
- Copy "value" VERBATIM from text
- "type" is snake_case label you invent
- Same value → include once
- Return ONLY JSON
```
Ключевое: нет ограничения на типы, value verbatim.
---
## Q6. DOCX или Markdown?
- **Внутренняя обработка:** Markdown проще парсить, LLM понимает лучше.
- **На выходе:** опция `output_format: "docx" | "md"`. По умолчанию `"docx"`.
---
## Итоговая карта изменений
```
scanner.py — новый промпт (убрать список типов, LLM сама называет типы)
generators/ — добавить fallback-генератор для неизвестных типов
obfuscator.py — вызывать fallback если тип неизвестен
api_bp.py — принять параметр output_format
replacer.py — добавить replace_to_markdown()
builder.py — упаковывать .md если output_format=md
```
**Не трогать:** двухпроходная схема, checksum-генераторы, ZIP-безопасность.
+51
View File
@@ -0,0 +1,51 @@
# План v3 — универсальная архитектура (2026-07-12)
## Цель
Убрать жёсткую привязку к фиксированному списку типов сущностей.
Перейти на Markdown как внутренний формат.
## Изменения по модулям
### 1. `scanner.py` — универсальный LLM-промпт
- Убрать список типов из промпта
- LLM сама называет типы (snake_case)
- `value` — verbatim из текста
### 2. `generators/` — fallback-генератор
- Новый `fallback.py`: character-class preserving замена
- `__init__.py`: добавить в ENTITY_GENERATORS
- `obfuscator.py`: использовать fallback для неизвестных типов
### 3. `extractor.py` — единый MD-пайплайн
- DOCX → MD (python-docx: стили, форматирование)
- PDF → MD (pdfplumber: текст + таблицы)
- TXT → как есть
- Убрать convert_pdfs_to_docx (не нужен)
- Убрать хранение docx-объектов
### 4. `replacer.py` — упростить
- `apply_replacements(text)` — уже есть
- `replace_in_docx(doc, mapping)` — сохранить как утилиту для DOCX-выхода
- Удалить `replace_in_text()` (заменяется на apply_replacements)
### 5. `obfuscator.py` — обновить пайплайн
- Pass 1: extract MD → scan_regex + scan_llm_ner
- Pass 2: apply_replacements к MD
- Опционально: replace_in_docx к оригинальному DOCX
### 6. `api_bp.py` — output_format
- Параметр `output_format: "md" | "docx"` (по умолчанию md)
### 7. `.doc` — потом
- Добавим convert_doc_to_docx через LibreOffice отдельно
## Порядок реализации
1. `generators/fallback.py` + обновить `__init__.py`
2. `scanner.py` — новый промпт
3. `extractor.py` — MD-конвертация
4. `replacer.py` — упростить
5. `obfuscator.py` — новый пайплайн
6. `api_bp.py` — output_format
7. Проверить всё → commit
+142
View File
@@ -0,0 +1,142 @@
# DrHider — обфускация документов (Managed Flask)
**URL:** https://drhider.pythonk8s.dev.nubes.ru/
**Репозиторий:** https://gitea.services.ngcloud.ru/Nail/drhider
**Платформа:** Штурвал (Managed Flask на pythonk8s)
**Дизайн фронтенда:** `/home/naeel/nubes/design/`
---
## Что делает
Загружаешь документы (.docx, .pdf, .txt, .zip) — получаешь ZIP с обфусцированными копиями.
Все персональные данные, реквизиты, телефоны, email заменяются на фиктивные.
В архиве — mapping.csv с таблицей замен (оригинал → фиктивное).
Обфускация двухпроходная:
1. **Проход 1 (сбор):** regex + LLM находят все сущности во всех файлах → глобальный словарь замен
2. **Проход 2 (замена):** применяем замены ко всем файлам → ZIP
Согласованность: одна и та же сущность во всех файлах → одно и то же фиктивное значение.
---
## ⛔ ПРАВИЛА ДЛЯ АГЕНТОВ (читать перед ЛЮБЫМ действием)
### 1. НЕ смотреть локальные папки contracts/contracts-flask
Весь старый код drhider — ТОЛЬКО на ВМ (`5.172.178.213`). НЕ локально.
Единственное исключение: `/home/naeel/nubes/loadtest/` (образец структуры managed Flask).
### 2. Штурвал запускает Flask САМ
- БЕЗ Dockerfile (удалён)
- БЕЗ gunicorn (нет в requirements.txt)
- Точка входа: `site/app.py` → `app = create_app()`
- Деплой: `git push origin master` → авто-деплой в UI Штурвала
### 3. НЕ менять код без «делай»
Даже если ошибка очевидна. Показать → описать → ЖДАТЬ.
### 4. После ЛЮБОЙ правки:
```bash
python3 -c "import py_compile; py_compile.compile('файл.py', doraise=True)"
git add -A && git commit -m "подробное описание" && git push
```
### 5. Документировать ВСЁ в History/
Каждое изменение, план, ошибку — в отдельный .md файл.
---
## Структура проекта
### `drhider/` — пакет ядра обфускации
| Файл | Назначение | Строк |
|---|---|---|
| `__init__.py` | re-export: `obfuscate_files`, `LLMClient`, `TwoPassObfuscator` | 10 |
| `config.py` | Константы: regex-паттерны (ENTITY_PATTERNS, COMPANY_PATTERN, PERSON_PATTERN), словари имён/городов/улиц | 80 |
| `checksum.py` | Контрольные суммы: ИНН10, ИНН12, ОГРН | 60 |
| `random_utils.py` | Утилиты: `random_digits`, `random_letters` | 20 |
| `llm_client.py` | LLMClient — HTTP-клиент к LLM API (aillm.ru) | 50 |
| `extractor.py` | Извлечение текста из .docx/.pdf/.txt + `expand_zips` + `convert_pdfs_to_docx` | 180 |
| `scanner.py` | Проход 1: `scan_regex` (regex) + `scan_llm_ner` (LLM NER) | 110 |
| `replacer.py` | Проход 2: `apply_replacements`, `replace_in_docx`, `replace_in_text` | 100 |
| `builder.py` | Сборка: `build_zip`, `build_mapping_csv` | 65 |
| `obfuscator.py` | `TwoPassObfuscator` — оркестратор + `obfuscate_files()` | 100 |
### `drhider/generators/` — генераторы фиктивных значений
Каждый файл — один генератор (10–40 строк). Интерфейс: `generate_xxx(original: str) -> str`.
| Файл | Что генерирует |
|---|---|
| `phone.py` | +7 (XXX) XXX-XX-XX |
| `email.py` | user@fake-domain |
| `inn.py` | ИНН 10 и 12 знаков (с контрольной суммой) |
| `ogrn.py` | ОГРН 13 знаков |
| `kpp.py` | КПП 9 знаков |
| `bik.py` | БИК 9 знаков (04XXXXXXX) |
| `accounts.py` | Расчётный счёт (40702...) + корр. счёт (30101...) |
| `passport.py` | Паспорт (XX XX XXXXXX) |
| `company.py` | ООО/ЗАО/АО «СлучайноеНазвание» |
| `person.py` | Фамилия И.О. |
| `address.py` | Город, ул. Улица, д. N |
`__init__.py` содержит `ENTITY_GENERATORS` — маппинг `entity_type → генератор`.
### `site/` — Flask-приложение
| Файл | Назначение |
|---|---|
| `app.py` | `create_app()` — создание Flask, регистрация blueprint'ов, VERSION |
| `routes/__init__.py` | `register_routes(app)` |
| `routes/main_bp.py` | `GET /` — HTML-интерфейс |
| `routes/health_bp.py` | `GET /health` — `{"ok": true, "version": "..."}` |
| `routes/api_bp.py` | `POST /api/drhider` — multipart files → ZIP |
| `templates/index.html` | UI (выбор файлов, прогресс, скачивание) |
| `static/favicon.svg` | Иконка |
### Корневые файлы
| Файл | Назначение |
|---|---|
| `requirements.txt` | `flask`, `python-docx`, `pdfplumber`, `httpx` |
| `.gitignore` | `__pycache__/`, `*.pyc`, `.env` |
| `.gitea/workflows/deploy.yaml` | CI: валидация Python |
| `History/` | Документация всех изменений |
| `README.md` | Этот файл |
| `docs/ARCHITECTURE.md` | Архитектура |
| `docs/DEPLOY.md` | Деплой, ВМ, переменные окружения |
| `docs/DEVELOPMENT.md` | Порядок действий при изменении кода |
---
## API
| Метод | Путь | Что делает |
|---|---|---|
| `GET` | `/` | HTML-интерфейс |
| `GET` | `/health` | `{"ok": true, "version": "2.0.0"}` |
| `POST` | `/api/drhider` | multipart/form-data (поле `files`) → `application/zip` |
---
## Переменные окружения (Штурвал)
| Переменная | Значение |
|---|---|
| `LLM_API_KEY` | `sk-ucI5YvOticoOQ9Kuj5K9mQ` |
| `LLM_URL` | `https://api.aillm.ru/v1/chat/completions` |
| `LLM_MODEL` | `gpt-oss-120b` |
---
## Связь с ВМ contracts (legacy)
На ВМ `5.172.178.213` (contracts.kube5s.ru) есть старая версия drhider:
- `drhider_server.py` на порту 8767 — standalone HTTP-сервер
- `drhider.py` — та же логика, что и в этом проекте (скопирована оттуда)
Этот проект (`drhider.pythonk8s.dev.nubes.ru`) — **независимый managed Flask**, заменяет ВМ-версию.
SSH к ВМ: `ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213`
+191
View File
@@ -0,0 +1,191 @@
# Архитектура DrHider
## Общая схема
```
Пользователь (браузер)
│
▼
https://drhider.pythonk8s.dev.nubes.ru/
│
▼
Штурвал (Managed Flask на pythonk8s)
│
▼
site/app.py ──→ Flask (create_app)
│
├── GET / → main_bp → templates/index.html
├── GET /health → health_bp → {"ok": true}
└── POST /api/drhider → api_bp → drhider.obfuscate_files()
│
▼
TwoPassObfuscator
│
┌────────────┼────────────┐
│ │ │
extractor scanner replacer
(текст) (regex+LLM) (замена)
│ │ │
└────────────┼────────────┘
│
builder
(ZIP+CSV)
│
▼
application/zip
```
---
## Двухпроходная обфускация
### Проход 1: сбор сущностей
```
Файлы (.docx, .pdf, .txt, .zip)
│
▼
extractor.expand_zips() ← распаковать ZIP
│
▼
extractor.convert_pdfs_to_docx() ← PDF → DOCX
│
▼
extractor.extract_text() ← извлечь текст из каждого файла
│
├──→ scanner.scan_regex() ← regex: телефоны, email, ИНН, ОГРН, КПП,
│ БИК, счета, паспорта, компании, ФИО
│
└──→ scanner.scan_llm_ner() ← LLM: имена, адреса, паспорта
(только если llm_client передан)
│
▼
mapping = {оригинал → фиктивное} ← глобальный словарь замен
```
### Проход 2: замена
```
mapping + sorted_keys (по убыванию длины)
│
▼
Для каждого файла:
│
├── .docx → replacer.replace_in_docx() ← склеить runs → заменить → сохранить
├── .doc → без изменений (бинарный)
└── .txt/.pdf → replacer.replace_in_text() ← простая строковая замена
│
▼
builder.build_mapping_csv() ← mapping.csv
builder.build_zip() ← ZIP со всеми файлами + mapping.csv
```
---
## Модули
### `drhider/config.py`
Константы уровня всего пакета. НЕ содержит логики — только данные.
- `ENTITY_PATTERNS` — 10 regex-паттернов
- `COMPANY_PATTERN`, `PERSON_PATTERN` — скомпилированные regex
- Словари: `RU_SURNAMES`, `RU_NAMES`, `RU_PATRONYMICS`, `RU_CITIES`, `RU_STREETS`, `FAKE_DOMAINS`
### `drhider/checksum.py`
Чистые функции для расчёта контрольных сумм. Без побочных эффектов.
- `checksum_inn10(inn)` → 1 цифра
- `checksum_inn12(inn)` → 2 цифры
- `checksum_ogrn(ogrn)` → 1 цифра
### `drhider/random_utils.py`
Утилиты генерации случайных строк.
- `random_digits(n)` → строка из n цифр
- `random_letters(n)` → строка из n строчных латинских букв
### `drhider/generators/`
Каждый генератор — независимая функция с сигнатурой `generate_xxx(original: str) -> str`.
Параметр `original` нужен для извлечения префикса (например, «ИНН » из «ИНН 1234567890»).
Генераторы НЕ общаются друг с другом — только импортируют из `config`, `checksum`, `random_utils`.
`__init__.py` содержит `ENTITY_GENERATORS` — словарь, связывающий `entity_type` из `ENTITY_PATTERNS` с функцией-генератором. Используется в `scanner.scan_regex()`.
### `drhider/llm_client.py`
HTTP-клиент к OpenAI-совместимому API. Читает переменные окружения.
Интерфейс: `.complete(prompt: str) -> str`.
### `drhider/extractor.py`
Три независимые функции (не класс):
- `extract_text(fname, content, ctype)` → `(text, docx_document_or_None)`
- `expand_zips(files)` → распакованный список
- `convert_pdfs_to_docx(files)` → PDF заменены на DOCX
### `drhider/scanner.py`
Две функции (не класс), мутируют переданный `mapping: Dict[str, str]`:
- `scan_regex(text, mapping)` — regex-поиск
- `scan_llm_ner(all_texts, mapping, llm_client)` — LLM NER
### `drhider/replacer.py`
Три чистые функции:
- `apply_replacements(text, mapping, sorted_keys)` → строка с заменами
- `replace_in_docx(doc, mapping, sorted_keys)` → bytes (изменённый DOCX)
- `replace_in_text(text, fname, mapping, sorted_keys)` → bytes
### `drhider/builder.py`
Две функции сборки результата:
- `build_zip(files, mapping_csv)` → bytes (ZIP-архив)
- `build_mapping_csv(mapping)` → str (CSV)
### `drhider/obfuscator.py`
`TwoPassObfuscator` — оркестратор. Единственный класс, который знает о顺序е вызовов.
Метод `.obfuscate(files)` вызывает модули в правильном порядке.
`obfuscate_files()` — удобная функция-обёртка.
### `site/app.py`
Точка входа для Штурвала. `create_app()` создаёт Flask, регистрирует blueprint'ы.
`app = create_app()` — глобальный экземпляр.
### `site/routes/`
Blueprint'ы — каждый в своём файле:
- `main_bp` — только `GET /`
- `health_bp` — только `GET /health`
- `api_bp` — только `POST /api/drhider`
`__init__.py` содержит `register_routes(app)` — единая точка регистрации.
---
## Поток данных
```
HTTP POST /api/drhider (multipart/form-data)
│
▼
api_bp.drhider()
│ request.files.getlist("files")
│ → [(filename, bytes, mimetype), ...]
▼
obfuscate_files(files, llm_client=LLMClient())
│
▼
TwoPassObfuscator.obfuscate(files)
│
├── expand_zips → convert_pdfs_to_docx → extract_text
├── scan_regex + scan_llm_ner → mapping
├── replace_in_docx / replace_in_text → обфусцированные файлы
└── build_mapping_csv + build_zip → (zip_bytes, csv_str)
│
▼
send_file(BytesIO(zip_data), mimetype="application/zip")
│
▼
HTTP 200 + ZIP-архив
```
---
## Принципы
1. **Модули — чистые функции.** Где возможно — без классов, без состояния.
2. **Единственный класс — TwoPassObfuscator.** Только он управляет состоянием (mapping, sorted_keys).
3. **Генераторы — изолированы.** Каждый в своём файле, не зависят друг от друга.
4. **Flask — тонкая прослойка.** Только принимает запрос, вызывает `obfuscate_files()`, отдаёт ответ.
5. **Без БД.** Всё в памяти. Никаких внешних зависимостей кроме LLM API.
+139
View File
@@ -0,0 +1,139 @@
# Деплой, ВМ и переменные окружения
---
## Платформа
**Штурвал** — Managed Flask на pythonk8s (Kubernetes).
- Запускает Flask **сам** — без Dockerfile, без gunicorn
- Точка входа: `site/app.py` → глобальная переменная `app`
- Деплой: `git push origin master` → авто-деплой через UI Штурвала
---
## Деплой
```bash
cd /home/naeel/nubes/drhider
git add -A
git commit -m "описание изменений"
git push origin master
```
После пуша — зайти в UI Штурвала и нажать ** Redeploy** для `drhider`.
---
## Переменные окружения (настраиваются в UI Штурвала)
| Переменная | Значение | Для чего |
|---|---|---|
| `LLM_API_KEY` | `sk-ucI5YvOticoOQ9Kuj5K9mQ` | Ключ доступа к LLM API |
| `LLM_URL` | `https://api.aillm.ru/v1/chat/completions` | URL LLM API |
| `LLM_MODEL` | `gpt-oss-120b` | Модель для NER |
Без `LLM_API_KEY` LLM-сканирование не работает (только regex).
---
## ВМ contracts (legacy)
Старый сервер contracts.kube5s.ru. DrHider там — standalone HTTP-сервер на порту 8767.
### SSH
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213
```
### Где лежит старый drhider
```
/home/naeel/contracts/drhider/
├── drhider.py # Ядро обфускации (отсюда скопировано в этот проект)
├── drhider_server.py # HTTP-сервер :8767 (НЕ ИСПОЛЬЗУЕТСЯ в новом проекте)
└── __init__.py
```
### Сервисы на ВМ
| Сервис | Порт | systemd unit |
|---|---|---|
| drhider | 8767 | `contracts-drhider` |
| convert_server | 8766 | `contracts` |
| Flask UI | 5001 | — (start.sh) |
| nginx | 443 | `nginx` |
| PostgreSQL | 5432 | — |
### Управление drhider на ВМ
```bash
# Статус
systemctl status contracts-drhider
# Логи
journalctl -u contracts-drhider -f
# Перезапуск
sudo systemctl restart contracts-drhider
```
---
## Локальная разработка
```bash
cd /home/naeel/nubes/drhider
# Установка зависимостей
pip install -r requirements.txt
# Запуск Flask (dev-сервер)
cd site && python3 app.py
# Проверка синтаксиса ВСЕХ файлов
python3 -c "
import py_compile, os
for root, dirs, files in os.walk('.'):
for f in files:
if f.endswith('.py'):
path = os.path.join(root, f)
py_compile.compile(path, doraise=True)
print(f'OK: {path}')
"
# Проверка импорта
python3 -c "from drhider import obfuscate_files, LLMClient; print('OK')"
```
---
## Как править код
1. Понять что меняем → **описать план**
2. Получить **«делай»**
3. Внести изменения
4. `python3 -c "import py_compile; py_compile.compile('файл.py', doraise=True)"` — КАЖДЫЙ изменённый .py
5. `python3 -c "from drhider import obfuscate_files, LLMClient"` — проверка импорта
6. `git add -A && git commit -m "подробно что и зачем" && git push`
7. Записать в `History/` что сделано
8. Зайти в UI Штурвала → Redeploy
---
## Как добавить новый генератор
1. Создать `drhider/generators/новый_тип.py` с функцией `generate_xxx(original: str) -> str`
2. Добавить regex-паттерн в `drhider/config.py` → `ENTITY_PATTERNS`
3. Добавить импорт и маппинг в `drhider/generators/__init__.py` → `ENTITY_GENERATORS`
4. Проверить синтаксис → commit → push → Redeploy
---
## История версий
| Версия | Дата | Что |
|---|---|---|
| 2.0.0 | 2026-07-12 | Модульный рефакторинг (22 модуля вместо монолита) |
| 1.0.0 | 2026-07-11 | Начальная версия (monolith drhider.py + Flask) |
+104
View File
@@ -0,0 +1,104 @@
# Порядок действий при изменении кода
**Читать перед ЛЮБОЙ правкой.**
---
## ⛔ ЗАПРЕЩЕНО
1. Менять код без «делай»
2. Действовать по догадкам («я бы сделал так», «можно попробовать»)
3. Удалять файлы/папки без разрешения
4. «Улучшать» рабочий код без прямого разрешения
5. Использовать `sed`
6. Откладывать commit/push «на потом»
7. Игнорировать проверку синтаксиса перед push
---
## ✅ ПОРЯДОК ПРИ ЛЮБОМ ИЗМЕНЕНИИ
### Шаг 1 — План
```
Описать что меняем → получить «делай»
```
### Шаг 2 — Правка
Вносим изменения. Только то, что обговорено. Никакой самодеятельности.
### Шаг 3 — Проверка синтаксиса
```bash
python3 -c "import py_compile; py_compile.compile('путь/к/файлу.py', doraise=True)"
```
**КАЖДЫЙ изменённый .py файл.**
### Шаг 4 — Проверка импорта
```bash
cd /home/naeel/nubes/drhider && python3 -c "from drhider import obfuscate_files, LLMClient; print('OK')"
```
### Шаг 5 — Проверить соседей
После `replace_string_in_file` — прочитать соседние строки, убедиться что не затёр соседнюю функцию/класс.
### Шаг 6 — commit + push (СРАЗУ)
```bash
cd /home/naeel/nubes/drhider
git add -A
git commit -m "подробное описание что и зачем"
git push
```
**Без исключений. Никаких накоплений.**
### Шаг 7 — Документировать
Записать в `History/` что сделано:
- Отдельный `.md` файл
- Что планировалось → что сделано → в чём ошибся
### Шаг 8 — Redeploy
Зайти в UI Штурвала → Redeploy `drhider`.
---
## 🔧 Команды в терминале — всегда с таймаутами
```bash
curl --max-time 30 ...
ssh -o ConnectTimeout=10 ...
timeout 10 grep ... file.txt
```
---
## 📦 Проверка ВСЕХ файлов разом
```bash
cd /home/naeel/nubes/drhider
python3 -c "
import py_compile, os
for root, dirs, files in os.walk('.'):
for f in files:
if f.endswith('.py'):
py_compile.compile(os.path.join(root, f), doraise=True)
print(f'OK: {os.path.join(root, f)}')
"
```
---
## 🏷️ Версионирование
Версия в `site/app.py` (переменная `VERSION`).
Менять при любом изменении кода, влияющем на поведение.
---
## 📝 Пример хорошего коммита
```
git commit -m "generators: добавил generate_snils — генератор фиктивных СНИЛС
- Новый файл drhider/generators/snils.py
- Добавлен паттерн в config.py → ENTITY_PATTERNS
- Добавлен в generators/__init__.py → ENTITY_GENERATORS
- bump VERSION 2.0.0 → 2.0.1"
```
+5
View File
@@ -0,0 +1,5 @@
НЕ СПЕШИ ! не ошибайся. НЕ ПРЕДПОЛАГАЙ !
не надо ДОГАДОК !
не надо самостоятельно что либо "УЛуЧШАТЬ" - никаких изменений рабочего кода без прямого разрешения
есть сомнения - лучше остановись и спроси
ВСЁ записывай в хистори ! что планировал что сделал в чём ошибся и тд
+12 -29
View File
@@ -8,10 +8,8 @@
import io
import csv
import os
import zipfile
import re
import time
from typing import Dict, List, Tuple
from .config import ENTITY_PATTERNS, COMPANY_PATTERN
@@ -20,50 +18,35 @@ from .config import ENTITY_PATTERNS, COMPANY_PATTERN
def build_zip(files: List[Tuple[str, bytes]], mapping_csv: str = "") -> bytes:
"""Собрать ZIP-архив с обфусцированными файлами.
В архив добавляются ТОЛЬКО обфусцированные файлы (с оригинальными именами).
mapping.csv в архив НЕ кладётся — это таблица соответствия оригинал→замена,
т.е. ключ расшифровки (секретные данные). Он хранится/скачивается отдельно
(/api/csv), чтобы нельзя было расшифровать архив, получив один ZIP.
В архив добавляются:
- Все обфусцированные файлы (с оригинальными именами)
- mapping.csv — таблица соответствия оригинал→замена (если не пустая)
Имена файлов в архиве — UTF-8 (бит 11 в flag_bits).
Args:
files: [(filename, content_bytes), ...]
mapping_csv: Строка CSV с таблицей замен (опционально, НЕ добавляется в архив)
mapping_csv: Строка CSV с таблицей замен (опционально)
Returns:
Бинарное содержимое ZIP-архива
"""
buf = io.BytesIO()
# Дедупликация имён: имя+одинаковый контент → пропуск; имя+разный контент → суффикс
seen: Dict[str, bytes] = {}
def _resolve_name(name: str, content: bytes) -> str:
if name not in seen:
seen[name] = content
return name
if seen[name] == content:
return None # точный дубль — пропускаем
base, ext = os.path.splitext(name)
n = 2
while f"{base}_{n}{ext}" in seen:
n += 1
resolved = f"{base}_{n}{ext}"
seen[resolved] = content
return resolved
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
# Добавляем обфусцированные файлы
for fname, content in files:
out_name = _resolve_name(fname, content)
if out_name is None:
continue
info = zipfile.ZipInfo(out_name)
info.date_time = time.localtime(time.time() + 3 * 3600)[:6] # MSK (UTC+3)
info = zipfile.ZipInfo(fname)
info.flag_bits |= 0x800 # Флаг: имя файла в UTF-8
zf.writestr(info, content)
# Добавляем mapping.csv с BOM (для корректного открытия в Excel)
if mapping_csv:
zf.writestr(
"mapping.csv",
'\ufeff'.encode('utf-8') + mapping_csv.encode('utf-8'),
)
return buf.getvalue()
+69
View File
@@ -0,0 +1,69 @@
"""
Контрольные суммы для ИНН (10 и 12 знаков) и ОГРН.
Используются генераторами фиктивных значений для создания
синтаксически корректных номеров.
"""
def checksum_inn10(inn: str) -> str:
"""Контрольная сумма для 10-значного ИНН (юридические лица).
Алгоритм:
1. Умножаем первые 9 цифр на коэффициенты [2,4,10,3,5,9,4,6,8]
2. Суммируем, берём (сумма % 11) % 10
Args:
inn: 9 цифр ИНН (без контрольной суммы)
Returns:
Одна цифра контрольной суммы
"""
coeffs = [2, 4, 10, 3, 5, 9, 4, 6, 8]
s = sum(int(inn[i]) * coeffs[i] for i in range(9))
return str((s % 11) % 10)
def checksum_inn12(inn: str) -> str:
"""Контрольные суммы для 12-значного ИНН (физические лица).
Алгоритм (две контрольные цифры):
1. n1: умножаем первые 10 цифр на [7,2,4,10,3,5,9,4,6,8],
берём (сумма % 11) % 10
2. n2: добавляем n1 к числу, умножаем 11 цифр на
[3,7,2,4,10,3,5,9,4,6,8], берём (сумма % 11) % 10
Args:
inn: 10 цифр ИНН (без контрольных сумм)
Returns:
Две цифры контрольных сумм (n1 + n2)
"""
c1 = [7, 2, 4, 10, 3, 5, 9, 4, 6, 8]
c2 = [3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8]
# Первая контрольная цифра
s1 = sum(int(inn[i]) * c1[i] for i in range(10))
n1 = (s1 % 11) % 10
# Вторая контрольная цифра (с учётом первой)
inn2 = inn + str(n1)
s2 = sum(int(inn2[i]) * c2[i] for i in range(11))
n2 = (s2 % 11) % 10
return str(n1) + str(n2)
def checksum_ogrn(ogrn: str) -> str:
"""Контрольная сумма для ОГРН (13 знаков).
Алгоритм: первые 12 цифр как число % 11, затем % 10.
Args:
ogrn: 12 цифр ОГРН (без контрольной суммы)
Returns:
Одна цифра контрольной суммы
"""
s = int(ogrn) % 11
return str(s % 10)
+2 -30
View File
@@ -41,8 +41,8 @@ COMPANY_PATTERN = re.compile(
# ФИО: Фамилия + инициалы (Иванов И.И.) или полное имя (Иванов Иван Иванович)
PERSON_PATTERN = re.compile(
r'\b[А-Я][а-я]+[\s\xa0]+[А-Я]\.[А-Я]\.'
r'|\b[А-Я][а-я]+[\s\xa0]+[А-Я][а-я]+[\s\xa0]+[А-Я][а-я]+',
r'\b[А-Я][а-я]+\s+[А-Я]\.[А-Я]\.'
r'|\b[А-Я][а-я]+\s+[А-Я][а-я]+\s+[А-Я][а-я]+',
)
# ═══════════════════════════════════════════════════════════════════════════
@@ -86,31 +86,3 @@ RU_STREETS = [
# Фиктивные домены для генерации email
FAKE_DOMAINS = ["example.ru", "mail.test", "company.local", "org.example.ru"]
# ═══════════════════════════════════════════════════════════════════════════
# Пулы для гибридных токенов (шаблон + номер)
# ═══════════════════════════════════════════════════════════════════════════
# Существительные для названий компаний (20)
COMPANY_NOUNS = [
"Технология", "Прогресс", "Гарант", "Стандарт", "Импульс",
"Вектор", "Сфера", "Альянс", "Синтез", "Меридиан",
"Спектр", "Формат", "Модуль", "Ресурс", "Платформа",
"Система", "Комплекс", "Проект", "Решение", "Сервис",
]
# Города для адресов (20)
CITY_POOL = [
"Москва", "Санкт-Петербург", "Новосибирск", "Екатеринбург",
"Казань", "Нижний_Новгород", "Челябинск", "Самара", "Омск",
"Ростов-на-Дону", "Уфа", "Красноярск", "Воронеж", "Пермь",
"Волгоград", "Краснодар", "Саратов", "Тюмень", "Иркутск", "Хабаровск",
]
# Улицы для адресов (20)
STREET_POOL = [
"Ленина", "Мира", "Пушкина", "Гагарина", "Советская",
"Кирова", "Октябрьская", "Молодёжная", "Садовая", "Центральная",
"Школьная", "Лесная", "Полевая", "Строителей", "Набережная",
"Парковая", "Солнечная", "Зелёная", "Юбилейная", "Весенняя",
]
+177 -274
View File
@@ -1,243 +1,95 @@
"""
Извлечение текста из документов — все форматы → Markdown.
Извлечение текста из документов разных форматов.
Поддерживает:
- .docx → MD (python-docx: стили, жирный/курсив, таблицы)
- .pdf → MD (pdfplumber: текст + таблицы, без форматирования)
- .doc → MD (LibreOffice headless: .doc → .docx → штатный docx_to_markdown)
- .txt и прочие → как есть
- .docx (через python-docx)
- .pdf (через pdfplumber)
- .doc (бинарный — не парсится)
- .txt и прочие (как UTF-8)
Также содержит:
- expand_zips — распаковка ZIP с защитой от ZIP-бомб
- _expand_zips — распаковка ZIP с защитой от ZIP-бомб
- _convert_pdfs_to_docx — конвертация PDF → DOCX
"""
import io
import os
import zipfile
import tempfile
import shutil
import subprocess
import logging
from typing import List, Tuple
from typing import Dict, List, Tuple, Optional
log = logging.getLogger("drhider")
# ═══════════════════════════════════════════════════════════════════════════
# DOCX → Markdown
# ═══════════════════════════════════════════════════════════════════════════
def docx_to_markdown(content: bytes) -> str:
"""Конвертировать DOCX в Markdown с сохранением базового форматирования.
Маппинг стилей:
- Heading 1/2/3 → # / ## / ###
- Bold → **текст**
- Italic → *текст*
- Таблицы → | col1 | col2 | (с разделителем)
Args:
content: Бинарное содержимое .docx файла
Returns:
Строка в формате Markdown
"""
from docx import Document
doc = Document(io.BytesIO(content))
lines = []
for para in doc.paragraphs:
if not para.text.strip():
lines.append("") # Пустая строка — разделитель абзацев
continue
text = para.text
# Применяем форматирование из runs (bold, italic)
if para.runs:
formatted_parts = []
for run in para.runs:
part = run.text
if run.bold:
part = f"**{part}**"
if run.italic:
part = f"*{part}*"
formatted_parts.append(part)
text = "".join(formatted_parts)
# Стиль абзаца
style_name = para.style.name if para.style else ""
if style_name.startswith("Heading"):
# Heading 1 → #, Heading 2 → ##, ...
try:
level = int(style_name.split()[-1])
except (ValueError, IndexError):
level = 1
prefix = "#" * min(level, 6)
lines.append(f"{prefix} {text}")
else:
lines.append(text)
# ── Таблицы ──
for table in doc.tables:
lines.append("") # Отступ перед таблицей
for ri, row in enumerate(table.rows):
cells = [cell.text.replace("\n", " ").strip() for cell in row.cells]
lines.append("| " + " | ".join(cells) + " |")
# Разделитель после первой строки (заголовка таблицы)
if ri == 0:
lines.append("|" + "|".join(["---"] * len(cells)) + "|")
lines.append("") # Отступ после таблицы
return "\n".join(lines)
# ═══════════════════════════════════════════════════════════════════════════
# PDF → Markdown
# ═══════════════════════════════════════════════════════════════════════════
def pdf_to_markdown(content: bytes) -> str:
"""Конвертировать PDF в Markdown (только текст + таблицы, без форматирования).
pdfplumber не различает стили (bold/italic/heading), поэтому
всё извлекается как plain text.
Args:
content: Бинарное содержимое .pdf файла
Returns:
Строка в формате Markdown (plain text + таблицы)
"""
import pdfplumber
lines = []
with pdfplumber.open(io.BytesIO(content)) as pdf:
for page in pdf.pages:
# ── Текст страницы ──
text = page.extract_text()
if text:
lines.append(text)
lines.append("")
# ── Таблицы ──
# Быстрый фильтр: если на странице нет линий/кривых/прямоугольников,
# таблиц нет (pdfplumber ищет таблицы только по векторным линиям).
# Включая page.rects — таблицы на цветном фоне (заливка).
# Пропускаем extract_tables() на «чистых» страницах (скан/текст) — это
# основная стоимость на сканах, где линий нет.
if page.lines or page.curves or page.rects:
tables = page.extract_tables()
else:
tables = []
for table in tables:
if not table:
continue
# Чистим строки таблицы
clean_rows = [
[str(c or "").strip() for c in (row or [])]
for row in table
]
clean_rows = [r for r in clean_rows if any(r)]
if not clean_rows:
continue
# Выводим таблицу в MD
for ri, row in enumerate(clean_rows):
lines.append("| " + " | ".join(row) + " |")
if ri == 0:
lines.append("|" + "|".join(["---"] * len(row)) + "|")
lines.append("")
return "\n".join(lines)
# ═══════════════════════════════════════════════════════════════════════════
# DOC → Markdown (через сервис liberta)
# ═══════════════════════════════════════════════════════════════════════════
def doc_to_markdown(content: bytes) -> str:
"""Конвертировать бинарный .doc → .docx (сервис liberta) → Markdown.
Отправляет .doc на HTTP-сервис конвертации, получает .docx,
затем прогоняет через штатный docx_to_markdown().
Args:
content: Бинарное содержимое .doc файла
Returns:
Строка в формате Markdown (или сообщение об ошибке)
"""
import httpx
url = os.environ.get(
"CONVERT_SERVICE_URL",
"https://liberta.containerk8s.dev.nubes.ru/convert"
)
try:
with httpx.Client(timeout=120) as client:
resp = client.post(
url,
files={"file": ("input.doc", content, "application/msword")}
)
if resp.status_code == 200:
return docx_to_markdown(resp.content)
err = resp.json().get("error", f"HTTP {resp.status_code}")
log.warning("liberta conversion failed: %s", err)
return f"[DOC — conversion failed: {err}]"
except httpx.TimeoutException:
return "[DOC — conversion timed out (>120s)]"
except Exception as e:
log.warning("liberta error: %s", e)
return f"[DOC — conversion error: {e}]"
# ═══════════════════════════════════════════════════════════════════════════
# Единая точка входа
# ═══════════════════════════════════════════════════════════════════════════
def extract_text(fname: str, content: bytes, ctype: str = "") -> str:
"""Извлечь текст из одного файла → Markdown.
def extract_text(fname: str, content: bytes, ctype: str) -> Tuple[str, Optional[object]]:
"""Извлечь текст из одного файла.
Args:
fname: Имя файла (с расширением)
content: Бинарное содержимое файла
ctype: MIME-тип (зарезервировано, не используется)
ctype: MIME-тип (не используется в текущей версии)
Returns:
Строка в формате Markdown
(text, docx_document_or_None):
text — извлечённый текст (строка)
doc — объект python-docx Document или None
"""
doc = None
text = ""
ext = os.path.splitext(fname)[1].lower()
# ── .docx: полный Markdown с форматированием ──
# ── .docx: извлекаем текст + сохраняем Document для замен с форматированием ──
if ext == '.docx':
try:
return docx_to_markdown(content)
from docx import Document
except ImportError:
return content.decode('utf-8', errors='replace')
# Если python-docx не установлен — читаем как plain text
text = content.decode('utf-8', errors='replace')
return text, None
# ── .pdf: текст + таблицы ──
doc = Document(io.BytesIO(content))
# Собираем текст из параграфов
paragraphs = [p.text for p in doc.paragraphs]
# Добавляем текст из таблиц
for table in doc.tables:
for row in table.rows:
row_text = " | ".join(cell.text for cell in row.cells)
paragraphs.append(row_text)
text = "\n".join(paragraphs)
# ── .pdf: извлекаем текст через pdfplumber ──
elif ext == '.pdf':
try:
return pdf_to_markdown(content)
import pdfplumber
except ImportError:
return content.decode('utf-8', errors='replace')
text = content.decode('utf-8', errors='replace')
return text, None
# ── .doc: бинарный → LibreOffice → DOCX → Markdown ──
parts = []
with pdfplumber.open(io.BytesIO(content)) as pdf:
for page in pdf.pages:
# Текст страницы
t = page.extract_text()
if t:
parts.append(t)
# Текст из таблиц
for table in page.extract_tables():
for row in table:
row_str = " | ".join(str(c) if c else "" for c in row)
parts.append(row_str)
text = "\n".join(parts)
# ── .doc: бинарный формат — без libreoffice не парсим ──
elif ext == '.doc':
return doc_to_markdown(content)
text = "[DOC binary — not parsed]"
return text, None
# ── .txt и прочие ──
# ── .txt и прочие: читаем как UTF-8 ──
else:
return content.decode('utf-8', errors='replace')
text = content.decode('utf-8', errors='replace')
return text, doc
# ═══════════════════════════════════════════════════════════════════════════
# ZIP-распаковка (без изменений)
# ═══════════════════════════════════════════════════════════════════════════
def expand_zips(files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]:
"""Распаковать ZIP-файлы в списке, заменив их содержимым.
@@ -255,103 +107,154 @@ def expand_zips(files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, s
Returns:
Новый список файлов (ZIP раскрыты, остальные как есть)
"""
# Лимиты защиты от ZIP-бомб (глобально на весь вызов)
MAX_FILES_IN_ARCHIVE = 500
MAX_UNCOMPRESSED = 500 * 1024 * 1024 # 500 MB
MAX_RATIO = 100
result: List[Tuple[str, bytes, str]] = []
queue: List[Tuple[str, bytes, str]] = list(files)
total_uncompressed = 0
def _decode_name(name: str, info) -> str:
"""Декодировать имя из ZIP.
Если флаг UTF-8 (bit 11) выставлен — имя уже корректное (берём как есть).
Иначе zipfile декодировал имя как CP437. Пытаемся восстановить исходные
байты (encode cp437) и декодировать:
1) как UTF-8 — если результат в кириллице/ASCII (Info-ZIP/Linux-зипы
пишут UTF-8 без флага; случайная коллизия CP866→UTF-8 отсекается
проверкой диапазона);
2) как CP866 — реальные 1С-выгрузки.
"""
def _cyr_ok(s: str) -> bool:
# Все символы — ASCII или кириллица (U+0400–U+04FF)
return all(ord(c) < 128 or 0x0400 <= ord(c) <= 0x04FF for c in s)
if not (info.flag_bits & 0x800) and any(ord(c) > 127 for c in name):
try:
raw = name.encode("cp437")
except UnicodeEncodeError:
return name
# 1) UTF-8 (Info-ZIP/Linux без флага)
try:
dec = raw.decode("utf-8")
if _cyr_ok(dec):
return dec
except UnicodeDecodeError:
pass
# 2) CP866 (1С)
try:
return raw.decode("cp866")
except UnicodeDecodeError:
pass
return name
while queue:
fname, content, ctype = queue.pop(0)
for fname, content, ctype in files:
# Пропускаем не-ZIP
if not fname.lower().endswith('.zip'):
result.append((fname, content, ctype))
continue
try:
with zipfile.ZipFile(io.BytesIO(content)) as zf:
infos = [i for i in zf.infolist() if not i.is_dir()]
if len(infos) > MAX_FILES_IN_ARCHIVE:
# Проверка: не более 500 файлов в архиве
if len(zf.infolist()) > 500:
log.warning("ZIP too many files, skipping: %s", fname)
result.append((fname, content, ctype))
continue
# Проверяем лимиты ДО чтения содержимого (защита от бомб)
bomb = False
for info in infos:
total_uncompressed = 0
for info in zf.infolist():
# Пропускаем директории
if info.is_dir():
continue
# Проверка на ZIP-бомбу: ratio
if info.compress_size > 0:
ratio = info.file_size / info.compress_size
if ratio > MAX_RATIO:
bomb = True
break
if total_uncompressed + info.file_size > MAX_UNCOMPRESSED:
bomb = True
break
if ratio > 100: # Файл сжимается более чем в 100 раз
log.warning(
"ZIP bomb ratio %.0f:1, skipping: %s", ratio, fname
)
result.append((fname, content, ctype))
break # Пропускаем весь архив
if bomb:
log.warning("ZIP bomb/limit, skipping: %s", fname)
result.append((fname, content, ctype))
continue
# Декодируем имя файла: cp437 → utf-8
name = info.filename
try:
name = name.encode("cp437").decode("utf-8", errors="replace")
except (UnicodeDecodeError, UnicodeEncodeError):
pass
for info in infos:
name = _decode_name(info.filename, info)
# Защита от path traversal
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'))):
if (
not name
or name.endswith("/")
or ".." in name
or "/" in name
or "\\" in name
):
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)
if total_uncompressed > 500 * 1024 * 1024: # 500 MB
log.warning(
"ZIP uncompressed limit exceeded, stopping: %s", fname
)
break
# Вложенные ZIP добавляем в очередь на повторную распаковку
queue.append((name, inner_data, ""))
result.append((name, inner_data, ""))
except Exception as e:
log.warning("Failed to expand ZIP %s: %s", fname, e)
result.append((fname, content, ctype))
return result
def convert_pdfs_to_docx(
files: List[Tuple[str, bytes, str]],
) -> List[Tuple[str, bytes, str]]:
"""Конвертировать PDF-файлы в DOCX через pdfplumber.
Не-PDF файлы проходят без изменений.
При совпадении имён к имени добавляется суффикс '_из_pdf'.
Конвертация:
- Текст страницы → параграфы DOCX
- Таблицы → таблицы DOCX со стилем 'Table Grid'
Args:
files: [(filename, content_bytes, content_type), ...]
Returns:
Новый список файлов (PDF заменены на DOCX)
"""
import pdfplumber
from docx import Document as DocxDocument
result: List[Tuple[str, bytes, str]] = []
existing_names = {f[0] for f in files}
for fname, content, ctype in files:
# Пропускаем не-PDF
if not fname.lower().endswith('.pdf'):
result.append((fname, content, ctype))
continue
try:
doc = DocxDocument()
with pdfplumber.open(io.BytesIO(content)) as pdf:
for page in pdf.pages:
# ── Таблицы ──
tables = page.extract_tables()
for table in tables:
if not table:
continue
# Чистим строки: убираем полностью пустые
rows = [
[str(c or "").strip() for c in (row or [])]
for row in table
]
rows = [r for r in rows if any(r)]
if rows:
t = doc.add_table(rows=len(rows), cols=len(rows[0]))
t.style = 'Table Grid'
for ri, row in enumerate(rows):
for ci, cell_text in enumerate(row):
t.rows[ri].cells[ci].text = cell_text
# ── Текст ──
text = page.extract_text()
if text:
for line in text.split('\n'):
line = line.strip()
if line:
doc.add_paragraph(line)
# Сохраняем DOCX в буфер
buf = io.BytesIO()
doc.save(buf)
# Формируем новое имя
new_name = fname[:-4] + '.docx'
if new_name in existing_names:
new_name = fname[:-4] + '_из_pdf.docx'
existing_names.add(new_name)
result.append((new_name, buf.getvalue(), ctype))
except Exception as e:
log.warning("PDF→DOCX error for %s: %s", fname, e)
# При ошибке — оставляем оригинальный PDF
result.append((fname, content, ctype))
return result
+37
View File
@@ -0,0 +1,37 @@
"""
Маппинг типов сущностей на генераторы фиктивных значений.
ENTITY_GENERATORS используется в TwoPassObfuscator._scan_regex()
для автоматического выбора генератора по типу найденной сущности.
"""
from .phone import generate_phone
from .email import generate_email
from .inn import generate_inn10, generate_inn12
from .ogrn import generate_ogrn
from .kpp import generate_kpp
from .bik import generate_bik
from .accounts import generate_rs, generate_ks
from .passport import generate_passport
from .fallback import generate_fallback
# ═══════════════════════════════════════════════════════════════════════════
# Маппинг: entity_type → функция-генератор
# Ключи соответствуют ключам в config.ENTITY_PATTERNS
# ═══════════════════════════════════════════════════════════════════════════
ENTITY_GENERATORS = {
"phone": generate_phone,
"email": generate_email,
"inn_ul": generate_inn10, # 10-значный ИНН → юрлица
"inn_fl": generate_inn12, # 12-значный ИНН → физлица
"ogrn": generate_ogrn,
"kpp": generate_kpp,
"bik": generate_bik,
"rs": generate_rs, # расчётный счёт
"ks": generate_ks, # корреспондентский счёт
"passport": generate_passport,
}
# Fallback-генератор — экспортируется отдельно для использования в scanner/obfuscator
FALLBACK_GENERATOR = generate_fallback
+47
View File
@@ -0,0 +1,47 @@
"""
Генераторы фиктивных банковских счетов.
- generate_rs: расчётный счёт (20 знаков)
- generate_ks: корреспондентский счёт (20 знаков)
Счета начинаются с реальных префиксов: 40702 (расчётный), 30101 (корреспондентский).
"""
import re
from ..random_utils import random_digits
def generate_rs(original: str) -> str:
"""Сгенерировать фиктивный 20-значный расчётный счёт.
Сохраняет оригинальный префикс (р/с, расчётный счёт и т.д.).
Начинается с 40702 (реальный префикс расчётного счёта).
Args:
original: Оригинальная строка с расчётным счётом
Returns:
Строка вида «р/с 40702XXXXXXXXXXXXXXX»
"""
prefix = re.match(r'(?:р/с|расч[её]тный\s*сч[её]т)\s*', original, re.IGNORECASE).group(0)
return prefix + "40702" + random_digits(15)
def generate_ks(original: str) -> str:
"""Сгенерировать фиктивный 20-значный корреспондентский счёт.
Сохраняет оригинальный префикс (к/с, кор/счёт и т.д.).
Начинается с 30101 (реальный префикс корр. счёта).
Args:
original: Оригинальная строка с корр. счётом
Returns:
Строка вида «к/с 30101XXXXXXXXXXXXXXX»
"""
prefix = re.match(
r'(?:к/с|кор/сч|корр?[еи]?спондентский\s*сч[её]т)\s*',
original,
re.IGNORECASE,
).group(0)
return prefix + "30101" + random_digits(15)
+25
View File
@@ -0,0 +1,25 @@
"""
Генератор фиктивных почтовых адресов.
Формат: Город, ул. Улица, д. Номер
"""
import random
from ..config import RU_CITIES, RU_STREETS
def generate_address(_: str) -> str:
"""Сгенерировать фиктивный почтовый адрес.
Выбирает случайный город, улицу и номер дома из словарей.
Args:
_: Оригинальный адрес (игнорируется)
Returns:
Строка вида «Москва, ул. Ленина, д. 42»
"""
city = random.choice(RU_CITIES)
street = random.choice(RU_STREETS)
house = random.randint(1, 200)
return f"{city}, ул. {street}, д. {house}"
+25
View File
@@ -0,0 +1,25 @@
"""
Генератор фиктивного БИК (9 знаков).
Формат: «БИК » + 9 цифр.
Первые 2 цифры — 04 (код РФ).
"""
import re
from ..random_utils import random_digits
def generate_bik(original: str) -> str:
"""Сгенерировать фиктивный 9-значный БИК.
Сохраняет оригинальный префикс (например, «БИК »).
Начинается с 04 (код Российской Федерации).
Args:
original: Оригинальная строка с БИК
Returns:
Строка вида «БИК 04XXXXXXX»
"""
prefix = re.match(r'БИК\s*', original, re.IGNORECASE).group(0)
return prefix + "04" + random_digits(7)
+27
View File
@@ -0,0 +1,27 @@
"""
Генератор фиктивных названий компаний.
Формат: ООО/ЗАО/АО «СлучайноеСуществительное»
"""
import random
def generate_company(_: str) -> str:
"""Сгенерировать фиктивное название компании.
Выбирает случайную организационно-правовую форму (ООО, ЗАО, АО)
и случайное существительное из словаря.
Args:
_: Оригинальное название (игнорируется)
Returns:
Строка вида «ООО «Технология»»
"""
forms = ["ООО", "ЗАО", "АО"]
nouns = [
"Технология", "Прогресс", "Гарант", "Стандарт", "Импульс",
"Вектор", "Сфера", "Альянс", "Синтез", "Меридиан", "Спектр", "Формат",
]
return f'{random.choice(forms)} «{random.choice(nouns)}»'
+23
View File
@@ -0,0 +1,23 @@
"""
Генератор фиктивного email-адреса.
Сохраняет структуру оригинального email: локальная_часть@домен.
"""
import random
from ..config import FAKE_DOMAINS
from ..random_utils import random_letters
def generate_email(original: str) -> str:
"""Сгенерировать фиктивный email с тем же форматом.
Args:
original: Оригинальный email (для сохранения структуры, не используется)
Returns:
Фиктивный email вида случайные_буквы@фиктивный_домен
"""
domain = random.choice(FAKE_DOMAINS)
local = random_letters(random.randint(5, 10))
return f"{local}@{domain}"
+64
View File
@@ -0,0 +1,64 @@
"""
Fallback-генератор фиктивных значений для неизвестных типов сущностей.
Используется когда LLM находит сущность с типом, которого нет
в специализированных генераторах (например, "contract_number", "employee_id").
Принцип: character-class preserving замена.
- Цифры → случайные цифры
- Буквы → случайные буквы (того же алфавита)
- Пробелы/знаки препинания → сохраняются
- Длина строки сохраняется
"""
import random
import string
def generate_fallback(original: str) -> str:
"""Сгенерировать фиктивное значение, сохраняя структуру оригинала.
Правила замены:
- [0-9] → случайная цифра
- [A-Za-z] → случайная буква того же регистра
- [А-Яа-я] → случайная кириллическая буква того же регистра
- Все остальные символы (пробелы, дефисы, спецсимволы) → без изменений
Args:
original: Оригинальное значение сущности
Returns:
Фиктивное значение той же длины и структуры
Example:
"Договор №123/2024" → "Фтщшлпь №847/5921"
"EMP-0042" → "XQJ-8193"
"""
result = []
for ch in original:
if ch.isdigit():
# Цифра → случайная цифра
result.append(random.choice(string.digits))
elif 'A' <= ch <= 'Z':
# Заглавная латиница → случайная заглавная латиница
result.append(random.choice(string.ascii_uppercase))
elif 'a' <= ch <= 'z':
# Строчная латиница → случайная строчная латиница
result.append(random.choice(string.ascii_lowercase))
elif 'А' <= ch <= 'Я':
# Заглавная кириллица → случайная заглавная кириллица
result.append(chr(random.randint(0x0410, 0x042F)))
elif 'а' <= ch <= 'я' or ch == 'ё':
# Строчная кириллица → случайная строчная кириллица
result.append(chr(random.randint(0x0430, 0x044F)))
else:
# Всё остальное (пробелы, дефисы, слэши, точки) → сохранить
result.append(ch)
return ''.join(result)
+45
View File
@@ -0,0 +1,45 @@
"""
Генераторы фиктивных ИНН.
- generate_inn10: 10-значный ИНН (юридические лица)
- generate_inn12: 12-значный ИНН (физические лица)
Оба сохраняют префикс «ИНН » и генерируют корректную контрольную сумму.
"""
import random
import re
from ..random_utils import random_digits
from ..checksum import checksum_inn10, checksum_inn12
def generate_inn10(original: str) -> str:
"""Сгенерировать фиктивный 10-значный ИНН (для юридических лиц).
Сохраняет оригинальный префикс (например, «ИНН »).
Args:
original: Оригинальная строка с ИНН
Returns:
Строка вида «ИНН XXXXXXXXX-C» с корректной контрольной суммой
"""
prefix = re.match(r'ИНН\s*', original, re.IGNORECASE).group(0)
base = f"{random.randint(1,9)}{random_digits(8)}"
return prefix + base + checksum_inn10(base)
def generate_inn12(original: str) -> str:
"""Сгенерировать фиктивный 12-значный ИНН (для физических лиц).
Сохраняет оригинальный префикс (например, «ИНН »).
Args:
original: Оригинальная строка с ИНН
Returns:
Строка вида «ИНН XXXXXXXXXX-CC» с двумя корректными контрольными суммами
"""
prefix = re.match(r'ИНН\s*', original, re.IGNORECASE).group(0)
base = f"{random.randint(1,9)}{random_digits(9)}"
return prefix + base + checksum_inn12(base)
+26
View File
@@ -0,0 +1,26 @@
"""
Генератор фиктивного КПП (9 знаков).
Формат: «КПП » + 9 цифр.
Последние 3 цифры — код причины постановки (01, 43 или 77).
"""
import random
import re
from ..random_utils import random_digits
def generate_kpp(original: str) -> str:
"""Сгенерировать фиктивный 9-значный КПП.
Сохраняет оригинальный префикс (например, «КПП »).
Последние 3 цифры — один из реальных кодов причины постановки.
Args:
original: Оригинальная строка с КПП
Returns:
Строка вида «КПП XXXX-код-XXX»
"""
prefix = re.match(r'КПП\s*', original, re.IGNORECASE).group(0)
return prefix + random_digits(4) + random.choice(["01", "43", "77"]) + random_digits(3)
+26
View File
@@ -0,0 +1,26 @@
"""
Генератор фиктивного ОГРН (13 знаков).
Формат: «ОГРН » + 13 цифр с корректной контрольной суммой.
"""
import re
from ..random_utils import random_digits
from ..checksum import checksum_ogrn
def generate_ogrn(original: str) -> str:
"""Сгенерировать фиктивный 13-значный ОГРН.
Сохраняет оригинальный префикс (например, «ОГРН »).
Первая цифра всегда 1 (признак юридического лица).
Args:
original: Оригинальная строка с ОГРН
Returns:
Строка вида «ОГРН 1XXXXXXXXXXX-C» с корректной контрольной суммой
"""
prefix = re.match(r'ОГРН\s*', original, re.IGNORECASE).group(0)
base = "1" + random_digits(11)
return prefix + base + checksum_ogrn(base)
+26
View File
@@ -0,0 +1,26 @@
"""
Генератор фиктивных паспортных данных.
Формат: «паспорт » / «серия номер » + XX XX XXXXXX
"""
import random
import re
from ..random_utils import random_digits
def generate_passport(original: str) -> str:
"""Сгенерировать фиктивные паспортные данные.
Сохраняет оригинальный префикс (паспорт, серия номер и т.д.).
Формат: 2 цифры серии + 2 цифры + 6 цифр номера.
Args:
original: Оригинальная строка с паспортными данными
Returns:
Строка вида «паспорт XX XX XXXXXX»
"""
m = re.match(r'(?:паспорт|серия\s+номер)[\s:№]*', original, re.IGNORECASE)
prefix = m.group(0) if m else ""
return prefix + f"{random.randint(10, 99)} {random.randint(10, 99)} {random_digits(6)}"
+26
View File
@@ -0,0 +1,26 @@
"""
Генератор фиктивных ФИО.
Формат: Фамилия И.О. (фамилия полностью, имя и отчество — инициалы).
"""
import random
from ..config import RU_SURNAMES, RU_NAMES, RU_PATRONYMICS
def generate_person(_: str) -> str:
"""Сгенерировать фиктивное ФИО в формате «Фамилия И.О.».
Выбирает случайные фамилию, имя и отчество из словарей.
Имя и отчество сокращаются до инициалов.
Args:
_: Оригинальное ФИО (игнорируется)
Returns:
Строка вида «Иванов А.И.»
"""
s = random.choice(RU_SURNAMES)
n = random.choice(RU_NAMES)
p = random.choice(RU_PATRONYMICS)
return f"{s} {n[0]}.{p[0]}."
+22
View File
@@ -0,0 +1,22 @@
"""
Генератор фиктивного телефонного номера.
Формат: +7 (код) XXX-XX-XX
Код выбирается случайно из списка реальных российских кодов.
"""
import random
from ..random_utils import random_digits
def generate_phone(_: str) -> str:
"""Сгенерировать фиктивный российский телефонный номер.
Args:
_: Оригинальный номер (игнорируется, нужен для единого интерфейса)
Returns:
Строка в формате +7 (XXX) XXX-XX-XX
"""
code = random.choice(["495", "499", "812", "383", "343"])
return f"+7 ({code}) {random_digits(3)}-{random_digits(2)}-{random_digits(2)}"
+17 -59
View File
@@ -10,7 +10,6 @@ LLM-клиент для DrHider.
"""
import os
import time
import httpx
@@ -24,33 +23,8 @@ class LLMClient:
"""
def __init__(self):
"""Инициализировать клиент. httpx импортируется лениво.
Ведёт кумулятивные счётчики за всё время жизни клиента:
tokens_prompt — суммарные входные токены
tokens_completion — суммарные выходные токены
llm_sec — суммарное время LLM-запросов (сек)
Плюс флаг текущей активности (для live-таймера в UI):
llm_active — True пока идёт LLM-вызов
llm_started — time.time() начала текущего LLM-вызова
"""
"""Инициализировать клиент. httpx импортируется лениво."""
self._httpx = httpx
self.tokens_prompt = 0
self.tokens_completion = 0
self.llm_sec = 0.0
self.llm_active = False
self.llm_started = 0.0
@property
def tokens_total(self) -> int:
return self.tokens_prompt + self.tokens_completion
def llm_elapsed_now(self) -> float:
"""Секунд с начала текущего LLM-вызова (0, если LLM не активен)."""
if self.llm_active:
return time.time() - self.llm_started
return 0.0
def complete(self, prompt: str) -> str:
"""Отправить промпт в LLM и вернуть текст ответа.
@@ -72,35 +46,19 @@ class LLMClient:
url = os.environ.get("LLM_URL", "https://api.aillm.ru/v1/chat/completions")
model = os.environ.get("LLM_MODEL", "gpt-oss-120b")
# Пометим LLM-вызов как активный (для live-таймера в UI)
self.llm_active = True
self.llm_started = time.time()
try:
r = self._httpx.post(
url,
json={
"model": model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 8000,
"temperature": 0.1,
},
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
},
timeout=120,
)
r.raise_for_status()
# Время запроса (реальное время HTTP-вызова LLM)
self.llm_sec += r.elapsed.total_seconds()
data = r.json()
# Счётчики токенов из usage (могут отсутствовать у некоторых прокси)
usage = data.get("usage", {}) or {}
self.tokens_prompt += int(usage.get("prompt_tokens", 0) or 0)
self.tokens_completion += int(usage.get("completion_tokens", 0) or 0)
return data["choices"][0]["message"]["content"]
finally:
self.llm_active = False
r = self._httpx.post(
url,
json={
"model": model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 8000,
"temperature": 0.1,
},
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
},
timeout=120,
)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"]
+33 -176
View File
@@ -8,8 +8,6 @@
"""
import io
import os
import time
import logging
from typing import Dict, List, Tuple, Callable, Optional
@@ -21,39 +19,6 @@ from . import builder
log = logging.getLogger("drhider")
def _dedupe_file_names(
files: List[Tuple[str, bytes, str]]
) -> List[Tuple[str, bytes, str]]:
"""Уникализировать имена файлов перед обработкой.
Точный дубль (одинаковое имя + одинаковое содержимое) пропускается.
При коллизии имени с ДРУГИМ содержимым добавляется суффикс _2, _3, ...
Args:
files: [(filename, content_bytes, content_type), ...]
Returns:
Новый список с уникальными именами (порядок сохранён).
"""
seen: Dict[str, bytes] = {}
out: List[Tuple[str, bytes, str]] = []
for fname, content, ctype in files:
candidate = fname
if candidate in seen:
if seen[candidate] == content:
continue # точный дубль — пропускаем
base, ext = os.path.splitext(fname)
n = 2
while f"{base}_{n}{ext}" in seen:
n += 1
candidate = f"{base}_{n}{ext}"
seen[candidate] = content
out.append((candidate, content, ctype))
return out
class TwoPassObfuscator:
"""Двухпроходный обфускатор документов.
@@ -78,8 +43,6 @@ class TwoPassObfuscator:
"""
self._mapping: Dict[str, str] = {}
self._sorted_keys: List[str] = []
self._compiled_re = None # кэш скомпилированного regex (A2)
self._counters: Dict[str, int] = {} # Глобальные счётчики токенов
self._llm_client = llm_client
# ═══════════════════════════════════════════════════════════════════
@@ -87,180 +50,80 @@ class TwoPassObfuscator:
# ═══════════════════════════════════════════════════════════════════
def obfuscate(
self, files: List[Tuple[str, bytes, str]],
progress_cb: Optional[Callable[[str, int, int, str], None]] = None,
cancel_event=None, file_progress=None
) -> Tuple[bytes, str, Optional[dict]]:
self, files: List[Tuple[str, bytes, str]]
) -> Tuple[bytes, str]:
"""Обфусцировать список файлов.
Все форматы → Markdown → замена → результат.
Args:
files: [(filename, content_bytes, content_type), ...]
progress_cb: Опциональный коллбек (phase, idx, total, fname, elapsed),
где phase ∈ {"start", "done"}, idx — 0-based индекс,
elapsed — время обработки конкретного файла (сек).
cancel_event: threading.Event — мягкая остановка. Проверяется между
файлами LLM и между файлами замены; текущий файл добирается.
file_progress: callable(event, fname, **fields) — прогресс по файлам/чанкам.
События: extract_done{total_chars}, file_start{chars,chunks,idx},
file_chunk{chunks_done,chunks_total,idx}, file_done{elapsed,idx}.
content_type — MIME-тип (может быть пустой строкой)
Returns:
(zip_bytes, csv_string, meta):
zip_bytes — ZIP-архив с обфусцированными .md файлами (без mapping.csv)
(zip_bytes, csv_string):
zip_bytes — ZIP-архив с обфусцированными файлами + mapping.csv
csv_string — содержимое mapping.csv как строка
meta — None при штатном завершении; {"cancelled": True, "processed", "total"}
при прерывании (частичный результат)
"""
# ── Предобработка: распаковать ZIP + уникализировать имена ──
# ── Предобработка: распаковать ZIP, конвертировать PDF ──
files = extractor.expand_zips(files)
files = _dedupe_file_names(files)
# имя файла (внутр. ключ all_texts) -> idx в files (для событий file_progress)
name_to_idx = {fname: i for i, (fname, _, _) in enumerate(files)}
llm_done: set = set() # файлы, чей LLM-анализ ПОЛНОСТЬЮ завершён
def _fp(event, fname, **fields):
if event == "file_done":
llm_done.add(fname)
if file_progress:
fields["idx"] = name_to_idx.get(fname)
file_progress(event, fname, **fields)
files = extractor.convert_pdfs_to_docx(files)
try:
# ── Проход 1: сбор сущностей ──
# Извлекаем Markdown из каждого файла
all_texts: Dict[str, str] = {}
total = len(files)
file_times: List[float] = [0.0] * total
skipped: set = set() # имена файлов, которые не удалось обработать (битые)
all_docx: Dict[str, object] = {}
# A1: .doc конвертируются через HTTP-сервис liberta ПОСЛЕДОВАТЕЛЬНО.
# Без параллельности: 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)
t0 = time.time()
try:
text = extractor.extract_text(fname, content, ctype)
except Exception as e:
log.warning("obfuscate: skip file %r: %r", fname, e)
skipped.add(fname)
if progress_cb:
progress_cb("done", i, total, display_name, 0.0)
continue
for fname, content, ctype in files:
text, doc = extractor.extract_text(fname, content, ctype)
all_texts[fname] = text
if doc is not None:
all_docx[fname] = doc
# Regex-сканирование (быстрое, локальное)
if text and not text.startswith("[DOC binary"):
scanner.scan_regex(text, self._mapping, self._counters)
file_times[i] += time.time() - t0
if text and text != "[DOC binary — not parsed]":
scanner.scan_regex(text, self._mapping)
# Объём извлечённого текста — для оценки времени (глобальной ETA)
total_chars = sum(len(t) for t in all_texts.values())
if file_progress:
file_progress("extract_done", None, total_chars=total_chars,
per_file={fname: len(t) for fname, t in all_texts.items()})
# LLM-сканирование (получает уже найденное regex'ом чтобы не дублировать)
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,
cancel_event=cancel_event, file_progress=_fp,
)
except scanner.CancelRequested:
log.info("obfuscate: LLM cancelled, llm_done=%d", len(llm_done))
cancelled_llm = True
# LLM-сканирование (медленное, сетевое — только если есть клиент)
if self._llm_client:
scanner.scan_llm_ner(all_texts, self._mapping, self._llm_client)
# Предсортировать ключи один раз (по убыванию длины)
self._sorted_keys = sorted(
self._mapping.keys(), key=len, reverse=True
)
# A2: кэш скомпилированного regex — один на все файлы прохода 2
self._compiled_re = replacer._build_combined_re(self._sorted_keys)
# ── Проход 2: замена сущностей ──
cancelled = cancelled_llm
results: List[Tuple[str, bytes]] = []
for i, (in_fname, content, ctype) in enumerate(files):
fname = in_fname
display_name = os.path.basename(in_fname) or in_fname
# Битый файл (не удалось извлечь текст) — пропускаем
if fname in skipped:
if progress_cb:
progress_cb("done", i, total, display_name, 0.0)
continue
# При прерывании в LLM — в результат идут ТОЛЬКО файлы с завершённым LLM
if cancelled_llm and fname not in llm_done:
continue
# Мягкая остановка в фазе замены (между файлами).
# НЕ проверяем, если LLM уже прерван: файлы из llm_done добираем (сохраняем всё готовое).
if not cancelled_llm and cancel_event is not None and cancel_event.is_set():
log.info("obfuscate: cancelled in replace phase, processed=%d", len(results))
cancelled = True
break
t0 = time.time()
for fname, content, ctype in files:
obf_content = content # По умолчанию — без изменений
if fname.endswith('.doc'):
# .doc — конвертируется через liberta, обрабатывается как .docx
md_text = all_texts.get(fname, '')
replaced = replacer.apply_replacements(
md_text, self._mapping, self._sorted_keys, self._compiled_re
# .doc — бинарный формат, оставляем как есть
pass
elif fname in all_docx:
# DOCX: замена с сохранением форматирования
obf_content = replacer.replace_in_docx(
all_docx[fname], self._mapping, self._sorted_keys
)
md_name = os.path.splitext(fname)[0] + '.md'
obf_content = replaced.encode('utf-8')
fname = md_name
else:
# Все форматы → Markdown → замена в тексте
md_text = all_texts.get(fname, '')
replaced = replacer.apply_replacements(
md_text, self._mapping, self._sorted_keys, self._compiled_re
# Plain text / PDF-текст: простая замена
txt = all_texts.get(fname, '')
obf_content = replacer.replace_in_text(
txt, fname, self._mapping, self._sorted_keys
)
# Имя файла: заменить расширение на .md
md_name = os.path.splitext(fname)[0] + '.md'
obf_content = replaced.encode('utf-8')
fname = md_name
file_times[i] += time.time() - t0
results.append((fname, obf_content))
if progress_cb:
progress_cb("done", i, total, display_name, round(file_times[i], 2))
# ── Сборка результата ──
csv_str = builder.build_mapping_csv(self._mapping)
zip_data = builder.build_zip(results, csv_str)
meta = None
if cancelled:
expected_total = total - len(skipped)
meta = {"cancelled": True, "processed": len(results), "total": expected_total}
return zip_data, csv_str, meta
return zip_data, csv_str
finally:
# Очистка состояния (обфускатор может использоваться повторно)
self._mapping.clear()
self._sorted_keys.clear()
self._compiled_re = None
self._counters.clear()
# ═══════════════════════════════════════════════════════════════════════
@@ -268,10 +131,8 @@ class TwoPassObfuscator:
# ═══════════════════════════════════════════════════════════════════════
def obfuscate_files(
files: List[Tuple[str, bytes, str]], llm_client=None,
progress_cb: Optional[Callable[[str, int, int, str], None]] = None,
cancel_event=None, file_progress=None
) -> Tuple[bytes, str, Optional[dict]]:
files: List[Tuple[str, bytes, str]], llm_client=None
) -> Tuple[bytes, str]:
"""Обфусцировать список файлов — удобная функция.
Создаёт экземпляр TwoPassObfuscator и вызывает .obfuscate().
@@ -279,13 +140,9 @@ def obfuscate_files(
Args:
files: [(filename, content_bytes, content_type), ...]
llm_client: Опциональный LLM-клиент
progress_cb: Опциональный коллбек (phase, idx, total, fname)
cancel_event: threading.Event — мягкая остановка (см. TwoPassObfuscator.obfuscate)
file_progress: callable(event, fname, **fields) — прогресс по файлам/чанкам
Returns:
(zip_bytes, csv_string, meta)
(zip_bytes, csv_string)
"""
obf = TwoPassObfuscator(llm_client=llm_client)
return obf.obfuscate(files, progress_cb=progress_cb,
cancel_event=cancel_event, file_progress=file_progress)
return obf.obfuscate(files)
+33
View File
@@ -0,0 +1,33 @@
"""
Утилиты для генерации случайных строк.
Используются генераторами фиктивных значений для создания
случайных цифр и букв в номерах/реквизитах.
"""
import random
import string
def random_digits(n: int) -> str:
"""Сгенерировать строку из n случайных цифр.
Args:
n: Количество цифр
Returns:
Строка из n случайных цифр (0-9)
"""
return ''.join(random.choice(string.digits) for _ in range(n))
def random_letters(n: int) -> str:
"""Сгенерировать строку из n случайных строчных латинских букв.
Args:
n: Количество букв
Returns:
Строка из n случайных букв (a-z)
"""
return ''.join(random.choice(string.ascii_lowercase) for _ in range(n))
+33 -79
View File
@@ -1,10 +1,10 @@
"""
Проход 2: замена сущностей в документах.
Два метода:
1. apply_replacements — замена в Markdown/plain-тексте (основной)
2. replace_in_docx — замена в DOCX с сохранением форматирования
(для опционального DOCX-выхода, будет добавлен позже)
Три метода:
1. _apply_replacements — замена в plain-тексте (ядро)
2. _replace_in_docx — замена в DOCX с сохранением форматирования
3. _replace_in_text — замена в plain-тексте → bytes
"""
import io
@@ -12,52 +12,7 @@ import re
from typing import Dict, List
def _md_tolerant_pattern(original: str) -> str:
r"""Паттерн, допускающий ** между частями строки (Markdown bold).
DOCX → Markdown: жирность добавляет ** вокруг слов.
Если ** попадает внутрь сущности (например, **ООО **"**НУБЕС**"),
точное совпадение не срабатывает.
Этот паттерн вставляет (?:\*{1,2})? между каждым словом/символом,
позволяя найти сущность даже с ** внутри.
Args:
original: Исходная строка сущности (например, 'ООО "НУБЕС"')
Returns:
Regex-паттерн, находящий ту же строку с ** между частями
"""
MD = r'(?:\*{1,2})?'
parts = re.findall(r'[А-ЯЁа-яёA-Za-z0-9]+|[^А-ЯЁа-яёA-Za-z0-9]', original)
if len(parts) <= 1:
return None # Одно слово — ** не может разорвать изнутри
return MD + MD.join(re.escape(p) for p in parts) + MD
def _build_combined_re(sorted_keys: List[str]):
"""Построить единый regex из всех ключей (убывание длины, границы слова).
Возвращает None, если нет ни одного применимого ключа.
"""
alternatives = []
for original in sorted_keys:
if not original:
continue
esc = re.escape(original)
# Границы слова — только если сущность обрамлена буквами/цифрами
if original[0].isalnum() and original[-1].isalnum():
alternatives.append(r'(?<!\w)' + esc + r'(?!\w)')
else:
alternatives.append(esc)
if not alternatives:
return None
return re.compile('|'.join(alternatives))
def apply_replacements(text: str, mapping: Dict[str, str], sorted_keys: List[str],
compiled_re=None) -> str:
def apply_replacements(text: str, mapping: Dict[str, str], sorted_keys: List[str]) -> str:
"""Применить все замены из словаря mapping к строке.
Ключи применяются в порядке убывания длины (sorted_keys).
@@ -71,42 +26,22 @@ def apply_replacements(text: str, mapping: Dict[str, str], sorted_keys: List[str
text: Исходный текст
mapping: Словарь {оригинал: замена}
sorted_keys: Ключи mapping, отсортированные по длине (убывание)
compiled_re: Опционально, заранее скомпилированный regex из _build_combined_re
(кэш между файлами). Если None — компилируется здесь.
Returns:
Текст с заменами
"""
result = text
# ── Однопроходная замена: единый regex из всех ключей ──
# Вместо N отдельных re.sub по всему тексту (O(N×text)) собираем
# один паттерн из всех ключей и делаем один re.sub с callback (O(text)).
if not sorted_keys:
return result
combined = compiled_re if compiled_re is not None else _build_combined_re(sorted_keys)
if combined is None:
return result
matched_keys = set()
def _replace(match):
m = match.group(0)
matched_keys.add(m)
return mapping.get(m, m)
result = combined.sub(_replace, result)
# ── Фоллбэк: Markdown-жирность могла разорвать строку ──
# Применяется только к ключам, которые основная замена НЕ нашла
# (например, «ООО **"**НУБЕС**"» — ** внутри сущности).
for original in sorted_keys:
if not original or original in matched_keys:
continue
md_pattern = _md_tolerant_pattern(original)
if md_pattern is not None:
result = re.sub(md_pattern, mapping[original], result)
replacement = mapping[original]
# Если сущность обрамлена буквами/цифрами — используем границы слова
if original and original[0].isalnum() and original[-1].isalnum():
pattern = r'(?<!\w)' + re.escape(original) + r'(?!\w)'
else:
pattern = re.escape(original)
result = re.sub(pattern, replacement, result)
return result
@@ -164,3 +99,22 @@ def replace_in_docx(doc, mapping: Dict[str, str], sorted_keys: List[str]) -> byt
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()
def replace_in_text(text: str, fname: str, mapping: Dict[str, str], sorted_keys: List[str]) -> bytes:
"""Заменить сущности в plain-тексте.
Для PDF и прочих нетекстовых форматов — отдаём текст.
(MVP: без сохранения форматирования PDF).
Args:
text: Исходный текст
fname: Имя файла (для будущего использования — разные форматы)
mapping: Словарь замен
sorted_keys: Ключи mapping по убыванию длины
Returns:
Бинарное содержимое с заменами (UTF-8)
"""
replaced = apply_replacements(text, mapping, sorted_keys)
return replaced.encode('utf-8')
+79 -227
View File
@@ -5,290 +5,142 @@
1. scan_regex — быстрое regex-обнаружение (телефоны, email, ИНН, счета, компании, ФИО)
2. scan_llm_ner — универсальное LLM-обнаружение (любые приватные данные)
Замена: гибридные токены — читаемый шаблон + уникальный номер.
Пример: Иванов_0001, ООО_Технология_0034, ул_Ленина_0015
LLM САМА определяет какие типы сущностей присутствуют в документе.
Никакого фиксированного списка типов.
"""
import re
import json
import random
import time
import logging
from concurrent.futures import ThreadPoolExecutor
from typing import Dict, List, Optional
from typing import Dict
from .config import (
ENTITY_PATTERNS, COMPANY_PATTERN, PERSON_PATTERN,
RU_SURNAMES, COMPANY_NOUNS, CITY_POOL, STREET_POOL,
)
from .config import ENTITY_PATTERNS, COMPANY_PATTERN, PERSON_PATTERN
from .generators import ENTITY_GENERATORS, FALLBACK_GENERATOR
from .generators.company import generate_company
from .generators.person import generate_person
log = logging.getLogger("drhider")
# ═══════════════════════════════════════════════════════════════════════════
# Гибридные токены: тип → (пул_шаблонов, префикс_для_счётчика)
# ═══════════════════════════════════════════════════════════════════════════
TYPE_POOLS = {
"person_name": (RU_SURNAMES, "person"),
"person": (RU_SURNAMES, "person"),
"company": (COMPANY_NOUNS, "company"),
"phone": (["+7_000_000"], "phone"),
"email": (["email"], "email"),
"address": (CITY_POOL, "address"),
"inn": (["ИНН"], "inn"),
"inn_ul": (["ИНН"], "inn"),
"inn_fl": (["ИНН"], "inn"),
"kpp": (["КПП"], "kpp"),
"ogrn": (["ОГРН"], "ogrn"),
"bik": (["БИК"], "bik"),
"passport": (["Паспорт"], "passport"),
"bank_account": (["Счёт"], "bank"),
"rs": (["РС"], "bank"),
"ks": (["КС"], "bank"),
"contract_number": (["Договор"], "contract"),
"other_id": (["ID"], "other"),
}
_FALLBACK_POOL = (["Данные"], "data")
def _next_token(entity_type: str, counters: Dict[str, int]) -> str:
"""Сгенерировать токен замены: случайный шаблон из пула + номер.
Одна и та же сущность в mapping получает один и тот же токен
(mapping dict гарантирует согласованность).
Разные сущности одного типа получают РАЗНЫЕ шаблоны из пула.
def _get_generator(entity_type: str):
"""Получить генератор по типу сущности. Если тип неизвестен — fallback.
Args:
entity_type: Тип сущности (person_name, company, phone...)
counters: Глобальные счётчики {prefix_key: count}
entity_type: Строковый идентификатор типа (например, "phone", "person_name")
Returns:
Строка вида "Иванов_0001", "ООО_Технология_0034", "[Адрес_0015]"
Функция-генератор: (str) -> str
"""
pool, prefix_key = TYPE_POOLS.get(entity_type, _FALLBACK_POOL)
template = random.choice(pool)
key = f"{prefix_key}_{template}"
counters[key] = counters.get(key, 0) + 1
return f"{template}_{counters[key]:04d}"
log = logging.getLogger("drhider")
return ENTITY_GENERATORS.get(entity_type, FALLBACK_GENERATOR)
def scan_regex(text: str, mapping: Dict[str, str], counters: Dict[str, int]) -> None:
def scan_regex(text: str, mapping: Dict[str, str]) -> None:
"""Сканировать текст regex-паттернами, заполнить словарь замен.
Замена: гибридные токены (шаблон + номер).
Ищет в тексте:
- Сущности из ENTITY_PATTERNS (телефоны, email, ИНН, ОГРН, КПП, БИК, счета, паспорта)
- Названия компаний (кроме «НУБЕС» — это Исполнитель, не заменяем)
- ФИО в формате «Фамилия И.О.»
Найденные значения добавляются в mapping: оригинал → фиктивное значение.
Если сущность уже есть в mapping — не перезаписываем (согласованность).
Args:
text: Текст документа для сканирования
mapping: Словарь замен (мутабельный, пополняется)
counters: Глобальные счётчики токенов (мутабельный)
"""
# ── Сущности по regex-паттернам ──
# ── Сущности по regex-паттернам (телефоны, email, реквизиты) ──
for entity_type, pattern in ENTITY_PATTERNS.items():
for match in re.finditer(pattern, text, re.IGNORECASE | re.MULTILINE):
original = match.group(0).strip()
if not original or original in mapping:
continue
mapping[original] = _next_token(entity_type, counters)
# Генератор: специализированный (если есть) или fallback
generator = _get_generator(entity_type)
mapping[original] = generator(original)
# ── Названия компаний ──
for match in COMPANY_PATTERN.finditer(text):
original = match.group(0).strip()
if not original or original in mapping:
continue
mapping[original] = _next_token("company", counters)
# ── ФИО ──
# «НУБЕС» — Исполнитель, НЕ заменяем
if re.search(r'НУБЕС|NUBES', original, re.IGNORECASE):
continue
mapping[original] = generate_company(original)
# ── ФИО (Фамилия И.О.) ──
for match in PERSON_PATTERN.finditer(text):
original = match.group(0).strip()
if not original or original in mapping:
continue
mapping[original] = _next_token("person_name", counters)
mapping[original] = generate_person(original)
# ═══════════════════════════════════════════════════════════════════════════
# LLM-NER: пофайловый чанкинг (Sonnet-схема)
# ═══════════════════════════════════════════════════════════════════════════
def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str], llm_client) -> None:
"""LLM NER для обнаружения имён, адресов, паспортных данных.
class CancelRequested(Exception):
"""Обработка прервана пользователем (мягкая остановка между файлами LLM)."""
Отправляет объединённый текст всех файлов в LLM, получает JSON-список
найденных сущностей, добавляет их в словарь замен.
# Приоритет границ для чанков (от предпочтительных к жёстким)
_CHUNK_BOUNDARIES = ['\n\n', '\n', '. ', '? ', '! ', '; ', ', ', ' ']
_CHUNK_SIZE = 6000 # символов на чанк (≈1500-2000 токенов RU — NER-качество)
_CHUNK_OVERLAP = 500 # перекрытие чанков (сущности на стыке)
_LLM_CONCURRENCY = 1 # параллельность убрана — всё последовательно (liberta/LLM не тянет конкурентность)
def split_into_chunks(text: str, size: int = _CHUNK_SIZE,
overlap: int = _CHUNK_OVERLAP) -> List[str]:
"""Разбить текст на чанки ~size символов с перекрытием overlap.
Режем по границам (\n\n, \n, ..., пробел) — чтобы не разрывать слова/сущности.
"""
chunks: List[str] = []
start = 0
tl = len(text)
while start < tl:
end = min(start + size, tl)
if end == tl:
chunks.append(text[start:])
break
cut = None
for boundary in _CHUNK_BOUNDARIES:
pos = text.rfind(boundary, start + size // 2, end)
if pos != -1:
cut = pos + len(boundary)
break
if cut is None:
cut = end # жёсткий разрез — нет ни одного пробела/границы
chunks.append(text[start:cut])
# overlap: отступить назад, найти начало слова
ov_start = max(start, cut - overlap)
space = text.find(' ', ov_start)
start = (space + 1) if (space != -1 and space < cut) else ov_start
return chunks
def normalize_entity(s: str) -> str:
"""Нормализовать строку сущности для надёжного дедупа (регистр/пробелы/тире)."""
s = s.strip()
s = re.sub(r'\s+', ' ', s)
s = s.lower()
s = re.sub(r'[«»\u201c\u201d\u2018\u2019"\u0027]', '"', s)
s = re.sub(r'[\u2014\u2013\u2212-]', '-', s)
s = s.replace('\u00ad', '') # мягкий перенос
s = s.replace('ё', 'е') # Ё→Е (OCR/PDF)
return s
def _verify_entity(value: str, full_text: str) -> bool:
"""Верификация: сущность реально есть в полном тексте файла (отсекает галлюцинации)."""
if value in full_text:
return True
return normalize_entity(value) in normalize_entity(full_text)
def _build_llm_prompt(text: str, already_found: List[str]) -> str:
"""Собрать промпт для одного текстового фрагмента (чанк/файл)."""
af = "\n".join(f"- {k}" for k in already_found) if already_found else "(none)"
return (
"You are a PII (Personally Identifiable Information) detector for Russian legal documents.\n\n"
"Find ALL sensitive or private information that is NOT already captured below.\n\n"
"Already captured by regex (skip these exact strings):\n"
f"{af}\n\n"
"Return a JSON array. Each item must have:\n"
' - "type": MUST be one of: person_name, company, phone, email, address,\n'
' inn, kpp, ogrn, bik, passport, bank_account, contract_number, other_id\n'
' Use "other_id" only if nothing else fits. If unsure — do NOT include.\n'
' - "value": the EXACT string from the text (copy verbatim)\n\n'
"NOT PII — do NOT include these (legal terms, not private data):\n"
"- Roles: Исполнитель, Заказчик, Стороны, Сторона, Покупатель, Поставщик\n"
"- Titles: Генеральный директор, Директор, действующего на основании\n"
"- Legal terms: Услуги, Договор, Приложение, Спецификация, НДС, Устава\n\n"
"Rules:\n"
"- Copy value VERBATIM — same spaces, punctuation, case as in text\n"
"- Same value → include only once\n"
"- Return ONLY the JSON array, no other text, no markdown wrapping\n\n"
f"Text:\n{text}"
)
def _call_llm(text: str, llm_client) -> List[dict]:
"""Один вызов LLM по фрагменту → список сущностей (пустой при ошибке)."""
try:
raw = llm_client.complete(text)
raw = raw.strip()
if raw.startswith("```"):
raw = raw.split("\n", 1)[1]
if raw.endswith("```"):
raw = raw[:-3]
entities = json.loads(raw)
if not isinstance(entities, list):
return []
return entities
except Exception as e:
log.warning("LLM NER failed: %s", e)
return []
def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
llm_client, counters: Dict[str, int],
cancel_event=None, file_progress=None) -> None:
"""Универсальное LLM-обнаружение приватных данных (пофайлово, целиком).
Каждый файл обрабатывается ПОЛНОСТЬЮ: текст разбивается на чанки
(_CHUNK_SIZE=6000, overlap=_CHUNK_OVERLAP), чанки обрабатываются последовательно
(_LLM_CONCURRENCY), сущности дедуплицируются и верифицируются против полного
текста файла (отсечка галлюцинаций). regex-найденное не дублируется.
Отправляются первые 3000 символов каждого файла (экономия токенов).
Общий размер промпта ограничен 8000 символами.
Args:
all_texts: {filename: text_content} — тексты всех файлов
mapping: Словарь замен (мутабельный, пополняется)
llm_client: Объект с методом .complete(prompt) -> str
counters: Глобальные счётчики токенов (мутабельный)
cancel_event: threading.Event — мягкая остановка между файлами (текущий добирается до конца)
file_progress: callable(event, fname, **fields) — прогресс по файлам/чанкам
"""
already_found = list(mapping.keys())
# Порядок файлов: крупные вперёд (приоритет на долгую обработку)
file_items = sorted(
all_texts.items(), key=lambda kv: len(kv[1]), reverse=True
# Формируем комбинированный текст: имя файла + первые 3000 символов
combined = "\n\n---FILE---\n\n".join(
f"FILE: {fname}\n{t[:3000]}" for fname, t in all_texts.items()
)
for fname, full_text in file_items:
# Мягкая остановка ТОЛЬКО между файлами — текущий файл добирается до конца
if cancel_event is not None and cancel_event.is_set():
raise CancelRequested()
# ── Универсальный промпт — без фиксированного списка типов ──
prompt = (
"You are a PII (Personally Identifiable Information) detector. "
"Find ALL sensitive or private information in the text below.\n\n"
"Return a JSON array. Each item must have:\n"
' - "type": short snake_case label describing the entity\n'
' (e.g. person_name, phone, email, address, inn, company, passport,\n'
' contract_number, employee_id, bank_account — WHATEVER you see)\n'
' - "value": the EXACT string from the text (copy verbatim)\n\n'
"Rules:\n"
"- Copy value VERBATIM — same spaces, punctuation, case as in text\n"
"- If same value appears multiple times — include only once\n"
"- Invent the type label yourself based on what you see\n"
"- Return ONLY the JSON array, no other text, no markdown wrapping\n\n"
f"Text:\n{combined[:8000]}"
)
if not full_text or full_text.startswith("[DOC binary"):
continue
chunks = split_into_chunks(full_text)
try:
# Отправляем запрос в LLM
raw = llm_client.complete(prompt)
if file_progress:
file_progress("file_start", fname, chars=len(full_text), chunks=len(chunks))
# Чистим markdown-обёртку (если LLM вернула ```json ... ```)
raw = raw.strip()
if raw.startswith("```"):
raw = raw.split("\n", 1)[1]
if raw.endswith("```"):
raw = raw[:-3]
t0 = time.time()
entities = json.loads(raw)
# Параллельные вызовы LLM по чанкам этого файла
if len(chunks) > 1 and _LLM_CONCURRENCY > 1:
with ThreadPoolExecutor(max_workers=_LLM_CONCURRENCY) as _ex:
futures = [_ex.submit(_call_llm, _build_llm_prompt(c, mapping.keys()), llm_client)
for c in chunks]
all_ent = []
for fut in futures:
all_ent.extend(fut.result())
else:
all_ent = []
for k, c in enumerate(chunks):
all_ent.extend(_call_llm(_build_llm_prompt(c, mapping.keys()), llm_client))
if file_progress:
file_progress("file_chunk", fname,
chunks_done=k + 1, chunks_total=len(chunks))
# Дедуп + верификация + добавление в mapping
seen: set = set()
for ent in all_ent:
# Добавляем найденные сущности в mapping
for ent in entities:
val = ent.get("value", "").strip()
if not val or val in mapping:
continue
nk = normalize_entity(val)
if nk in seen:
continue
seen.add(nk)
if not _verify_entity(val, full_text):
log.debug("LLM hallucination dropped: %r", val)
continue
ent_type = ent.get("type", "").strip().lower().replace(" ", "_")
mapping[val] = _next_token(ent_type, counters)
if file_progress:
file_progress("file_done", fname, elapsed=round(time.time() - t0, 2))
# Тип — любой (LLM сама придумала)
ent_type = ent.get("type", "").strip().lower().replace(" ", "_")
# Генератор: специализированный (если тип совпал) или fallback
generator = _get_generator(ent_type)
mapping[val] = generator(val)
except Exception as e:
log.warning("LLM NER failed: %s", e)
-1
View File
@@ -1,5 +1,4 @@
flask
waitress
python-docx
pdfplumber
httpx
+2 -53
View File
@@ -12,7 +12,6 @@ DrHider — Managed Flask приложение на платформе Штур
import os
import sys
import logging
from flask import Flask
# Добавляем корень проекта в sys.path для импорта пакета drhider
@@ -21,81 +20,31 @@ if _sys_path_root not in sys.path:
sys.path.insert(0, _sys_path_root)
# Версия приложения (меняется при изменениях)
VERSION = "0.0.80"
def setup_logging():
"""Настроить логирование. Уровень из env LOG_LEVEL (DEBUG/INFO/WARNING).
Логи пишутся в stderr (для локального запуска) И в файл /tmp/drhider.log
(для кубера, где stdout/stderr контейнера может не попадать в kubectl logs).
Путь файла можно переопределить env LOG_FILE.
"""
level_name = os.environ.get("LOG_LEVEL", "INFO").upper()
level = getattr(logging, level_name, logging.INFO)
log_file = os.environ.get("LOG_FILE", "/tmp/drhider.log")
fmt = logging.Formatter("%(asctime)s %(levelname)s [%(name)s] %(message)s", "%Y-%m-%d %H:%M:%S")
root = logging.getLogger()
root.setLevel(level)
root.handlers.clear()
sh = logging.StreamHandler(sys.stderr)
sh.setLevel(level)
sh.setFormatter(fmt)
root.addHandler(sh)
try:
fh = logging.FileHandler(log_file, encoding="utf-8")
fh.setLevel(level)
fh.setFormatter(fmt)
root.addHandler(fh)
except Exception as e: # файл не открылся — не падаем
sys.stderr.write(f"[app] cannot open log file {log_file}: {e}\n")
for name in ("drhider", "app", "routes", "session", __name__):
logging.getLogger(name).setLevel(level)
log = logging.getLogger("app")
log.info("Logging configured, level=%s, version=%s, file=%s", level_name, VERSION, log_file)
VERSION = "2.0.0"
def create_app():
"""Создать и настроить Flask-приложение.
Штурвал запускает из директории site/:
from app import app
Поэтому routes/ доступен как обычный подпакет.
Returns:
Экземпляр Flask с зарегистрированными blueprint'ами.
"""
setup_logging()
app = Flask(__name__)
# Конфигурация
app.config["VERSION"] = VERSION
app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 # 50 MB (лимит на один файл)
app.config["MAX_CONTENT_LENGTH"] = 200 * 1024 * 1024 # 200 MB
# Регистрируем blueprint'ы
from routes import register_routes
register_routes(app)
# Запрещаем кэширование — F5 всегда грузит свежую страницу
@app.after_request
def no_cache(response):
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
return app
# Экземпляр приложения — точка входа для Штурвала
app = create_app()
# Штурвал запускает: cd site && python app.py
if __name__ == "__main__":
from waitress import serve
serve(app, host="0.0.0.0", port=5000, threads=8)
+6 -11
View File
@@ -1,7 +1,12 @@
"""
Регистрация всех blueprint'ов приложения.
Импортируется из site/app.py при создании Flask-приложения.
"""
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):
@@ -13,13 +18,3 @@ 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://upload.sinefo.ru/drhider-upload/",
"maxFileBytes": 50 * 1024 * 1024,
"maxSessionBytes": 500 * 1024 * 1024,
"ttlSeconds": 30 * 60,
"pullRetries": 3,
"pullRetryDelay": 2,
}))
+48 -337
View File
@@ -1,362 +1,73 @@
"""
Blueprint: API DrHider.
Blueprint: API обфускации (POST /api/drhider).
Five endpoints:
- POST /api/upload — upload one file -> {session_id}
- GET /api/process_stream/<sid> — SSE: process files, per-file progress
- POST /api/process/<sid> — process all session files -> {status:"done"} (legacy)
- GET /api/download/<sid> — download ZIP (with timestamp name)
- GET /api/csv/<sid> — download CSV separately
Принимает multipart/form-data с файлами, возвращает ZIP-архив
с обфусцированными документами.
"""
import io
import json
import queue
import threading
import time
import zipfile
import traceback
import logging
from datetime import datetime, timedelta
from flask import Blueprint, request, send_file, jsonify, Response, stream_with_context
from flask import Blueprint, request, send_file, jsonify
from drhider import obfuscate_files, LLMClient
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
# ═══════════════════════════════════════════════════════════════════════════
# Blueprint: API DrHider
# ═══════════════════════════════════════════════════════════════════════════
api_bp = Blueprint("api", __name__, url_prefix="/api")
log = logging.getLogger("routes.api_bp")
def _disconnect_exceptions():
"""Исключения, означающие отключение клиента SSE."""
return (GeneratorExit, BrokenPipeError, ConnectionResetError)
# Максимальный размер тела запроса: 200 MB
MAX_BODY = 200 * 1024 * 1024
@api_bp.route("/upload", methods=["POST"])
def upload():
"""Upload files to session (один или несколько)."""
sid = request.form.get("session", "")
if not sid:
sid = create_session()
@api_bp.route("/drhider", methods=["POST"])
def drhider():
"""Обфускация документов.
Принимает multipart/form-data с полем 'files' (один или несколько файлов).
Возвращает ZIP-архив с обфусцированными документами + mapping.csv.
Поддерживаемые форматы:
.docx, .pdf, .txt, .doc (бинарный — без изменений)
.zip (распаковывается, содержимое обфусцируется)
Returns:
200: ZIP-архив (application/zip)
400: {"ok": false, "error": "..."}
500: {"ok": false, "error": "..."}
"""
# ── Получаем файлы из запроса ──
uploaded = request.files.getlist("files")
if not uploaded:
log.warning("upload: no files, sid=%s", sid)
return jsonify({"ok": False, "error": "No file"}), 400
return jsonify({"ok": False, "error": "Нет файлов"}), 400
added = 0
had_unnamed = False
# Формируем список для obfuscate_files: [(name, bytes, mimetype), ...]
files = []
for f in uploaded:
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, 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, name, len(data))
continue
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
if f.filename:
files.append((f.filename, f.read(), f.mimetype or ""))
if added == 0:
err = "No filename" if had_unnamed else "No file"
log.warning("upload: %s, sid=%s", err, sid)
return jsonify({"ok": False, "error": err}), 400
log.info("upload: 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):
"""Отладка: список файлов сессии с размерами (для теста загрузки)."""
files = get_files(sid)
if files is None:
return jsonify({"ok": False, "error": "Session not found"}), 404
return jsonify({
"ok": True, "count": len(files),
"files": [{"name": n, "size": len(b)} for n, b in files],
})
@api_bp.route("/cancel/<sid>", methods=["POST"])
def cancel(sid):
"""Запросить мягкое прерывание обработки сессии.
Воркер останавливается на ближайшей границе файла (текущий добирается),
собирает частичный результат (готовые файлы + mapping) и шлёт SSE-событие `cancelled`.
"""
if request_cancel(sid):
log.info("cancel: requested sid=%s", sid)
return jsonify({"ok": True}), 200
log.warning("cancel: session not found sid=%s", sid)
return jsonify({"ok": False, "error": "Session not found"}), 404
@api_bp.route("/process_stream/<sid>", methods=["GET"])
def process_stream(sid):
"""SSE: process all session files, streaming per-file progress.
Все файлы обрабатываются ЕДИНЫМ вызовом obfuscate_files (общий mapping,
согласованные токены). Обработка идёт в отдельном потоке; прогресс
передаётся через очередь. Разрыв соединения клиента корректно
перехватывается и останавливает генератор (и воркер — через cancel_event).
"""
files = get_files(sid)
if files is None:
return jsonify({"ok": False, "error": "Session not found"}), 404
if not files:
return jsonify({"ok": False, "error": "No files"}), 400
return jsonify({"ok": False, "error": "Нет файлов"}), 400
all_files = [(fname, content, "") for fname, content in files]
log.info("process_stream: start sid=%s files=%d", sid, len(all_files))
# Сессия живёт, пока идёт обработка (TTL возобновляется в finally генератора)
pause_ttl(sid)
def generate():
llm = LLMClient()
q = queue.Queue()
cancel = threading.Event() # локальный: разрыв клиента (стоп heartbeat)
cancel_event = get_cancel_event(sid) # из сессии: мягкая отмена (кнопка «Прервать»)
# Состояние для глобальной ETA (символы)
eta = {"total_chars": 0, "done_chars": 0, "cur_chars": 0, "cur_total": 0, "cur_done": 0}
_TOKENS_PER_CHAR = 8.0 # эмпирический коэффициент символов -> токенов LLM
def progress(phase, idx, total_, name, elapsed):
q.put(("progress", phase, idx, name, total_, elapsed))
# Состояние для per-file ETA (чанки)
fstate = {"t0": 0.0, "total": 0, "done": 0}
def file_progress(event, fname, **fields):
if event == "file_start":
fstate["t0"] = time.time()
fstate["total"] = fields.get("chunks", 0)
fstate["done"] = 0
elif event == "file_chunk":
fstate["done"] = fields.get("chunks_done", 0)
elapsed = time.time() - fstate["t0"]
rate = fstate["done"] / elapsed if elapsed > 0 else 0
rem = fstate["total"] - fstate["done"]
if rate > 0:
fields["eta_sec"] = max(0, round(rem / rate))
q.put(("file", event, fname, fields))
def worker():
log.info("worker: start sid=%s files=%d", sid, len(all_files))
t0 = datetime.utcnow()
try:
zip_data, csv_str, meta = obfuscate_files(
all_files, llm_client=llm, progress_cb=progress,
cancel_event=cancel_event, file_progress=file_progress,
)
stats = {
"tokens": llm.tokens_total,
"llm_sec": round(llm.llm_sec, 1),
}
dt = (datetime.utcnow() - t0).total_seconds()
if meta and meta.get("cancelled"):
log.info("worker: cancelled sid=%s in %.1fs processed=%d/%d",
sid, dt, meta.get("processed", 0), meta.get("total", 0))
q.put(("cancelled", zip_data, csv_str, stats, meta))
else:
log.info("worker: done sid=%s in %.1fs tokens=%d llm_sec=%.1f zip_len=%d",
sid, dt, llm.tokens_total, llm.llm_sec, len(zip_data))
q.put(("result", zip_data, csv_str, stats))
except Exception as e:
log.error("worker: exception sid=%s: %r\n%s", sid, e, traceback.format_exc())
q.put(("error", repr(e)))
threading.Thread(target=worker, daemon=True).start()
log.debug("process_stream: worker thread started sid=%s", sid)
try:
while True:
try:
evt = q.get(timeout=1)
except queue.Empty:
if cancel.is_set():
log.info("process_stream: cancelled sid=%s (generator exit)", sid)
return
# Heartbeat: живая статистика LLM + глобальная ETA
tokens = llm.tokens_total
elapsed = llm.llm_elapsed_now()
eta_sec = None
if eta["total_chars"] > 0 and tokens > 0 and elapsed > 0:
est_total_tokens = eta["total_chars"] * _TOKENS_PER_CHAR
rate = tokens / elapsed
if rate > 0:
eta_sec = max(0, int((est_total_tokens - tokens) / rate))
done_chars = eta["done_chars"]
if eta["cur_total"] > 0:
done_chars += int(eta["cur_chars"] * (eta["cur_done"] / eta["cur_total"]))
try:
yield (
f"event: llm\n"
f"data: {json.dumps({'active': llm.llm_active, 'elapsed': round(elapsed, 1), 'tokens': tokens, 'eta_sec': eta_sec, 'done_chars': done_chars, 'total_chars': eta['total_chars']})}\n\n"
)
except _disconnect_exceptions() as e:
log.warning("process_stream: disconnect during heartbeat sid=%s err=%r", sid, e)
cancel.set()
if cancel_event:
cancel_event.set()
return
continue
kind = evt[0]
if kind == "progress":
_, phase, idx, name, total_, elapsed = evt
log.debug("process_stream: event=%s idx=%d name=%r elapsed=%s sid=%s",
phase, idx, name, elapsed, sid)
try:
yield (
f"event: {phase}\n"
f"data: {json.dumps({'idx': idx, 'name': name, 'total': total_, 'elapsed': elapsed})}\n\n"
)
except _disconnect_exceptions() as e:
log.warning("process_stream: disconnect on progress sid=%s phase=%s err=%r", sid, phase, e)
cancel.set()
if cancel_event:
cancel_event.set()
return
elif kind == "file":
_, event, fname, fields = evt
# Обновляем состояние для глобальной ETA
if event == "extract_done":
eta["total_chars"] = fields.get("total_chars", 0)
elif event == "file_start":
eta["cur_chars"] = fields.get("chars", 0)
eta["cur_total"] = fields.get("chunks", 0)
eta["cur_done"] = 0
elif event == "file_chunk":
eta["cur_done"] = fields.get("chunks_done", 0)
elif event == "file_done":
eta["done_chars"] += eta["cur_chars"]
eta["cur_chars"] = 0
eta["cur_total"] = 0
eta["cur_done"] = 0
try:
yield (
f"event: {event}\n"
f"data: {json.dumps({'name': fname, **fields})}\n\n"
)
except _disconnect_exceptions() as e:
log.warning("process_stream: disconnect on file event sid=%s err=%r", sid, e)
cancel.set()
if cancel_event:
cancel_event.set()
return
elif kind == "result":
_, zip_data, csv_str, stats = evt
log.info("process_stream: result sid=%s, storing result", sid)
store_result(sid, zip_data)
if csv_str:
store_csv(sid, csv_str)
count = 0
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
count = len([n for n in zf.namelist() if n != "mapping.csv"])
log.info("process_stream: complete sid=%s count=%d stats=%r", sid, count, stats)
try:
yield (
f"event: complete\n"
f"data: {json.dumps({'total': count, **stats})}\n\n"
)
except _disconnect_exceptions() as e:
log.warning("process_stream: disconnect on complete sid=%s err=%r", sid, e)
return
return
elif kind == "cancelled":
_, zip_data, csv_str, stats, meta = evt
log.info("process_stream: cancelled sid=%s, storing partial result", sid)
store_result(sid, zip_data)
if csv_str:
store_csv(sid, csv_str)
try:
yield (
f"event: cancelled\n"
f"data: {json.dumps({'saved': meta.get('processed', 0), 'total': meta.get('total', 0), **stats})}\n\n"
)
except _disconnect_exceptions() as e:
log.warning("process_stream: disconnect on cancelled sid=%s err=%r", sid, e)
return
return
elif kind == "error":
_, msg = evt
log.error("process_stream: error event sid=%s msg=%r", sid, msg)
try:
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
return
finally:
resume_ttl(sid)
log.debug("process_stream: generator exit sid=%s, TTL resumed", sid)
return Response(
stream_with_context(generate()),
content_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
)
@api_bp.route("/process/<sid>", methods=["POST"])
def process(sid):
"""Process all session files -> ZIP (единым вызовом, общий mapping)."""
files = get_files(sid)
if files is None:
return jsonify({"ok": False, "error": "Session not found"}), 404
if not files:
return jsonify({"ok": False, "error": "No files"}), 400
# ── Обфускация ──
try:
# Создаём LLM-клиент для NER-сканирования
llm = LLMClient()
all_files = [(fname, content, "") for fname, content in files]
pause_ttl(sid)
try:
zip_data, csv_str, _ = obfuscate_files(all_files, llm_client=llm)
finally:
resume_ttl(sid)
store_result(sid, zip_data)
if csv_str:
store_csv(sid, csv_str)
return jsonify({"ok": True, "status": "done"})
# Вызываем ядро обфускации
zip_data, _csv = obfuscate_files(files, llm_client=llm)
# Отдаём ZIP-архив
return send_file(
io.BytesIO(zip_data),
mimetype="application/zip",
as_attachment=True,
download_name="drhider_output.zip",
)
except Exception as e:
# Логируем полный трейсбек на сервере
traceback.print_exc()
return jsonify({"ok": False, "error": str(e)}), 500
@api_bp.route("/download/<sid>", methods=["GET"])
def download(sid):
"""Download result and cleanup session."""
zip_data = get_result(sid)
if zip_data is None:
return jsonify({"ok": False, "error": "Not found"}), 404
ts = (datetime.now() + timedelta(hours=3)).strftime("%Y-%m-%d_%H-%M-%S")
return send_file(io.BytesIO(zip_data), mimetype="application/zip",
as_attachment=True, download_name=f"drhider_{ts}.zip")
@api_bp.route("/csv/<sid>", methods=["GET"])
def csv_download(sid):
"""Download CSV separately."""
csv_str = get_csv(sid)
if csv_str is None:
return jsonify({"ok": False, "error": "Not found"}), 404
ts = (datetime.now() + timedelta(hours=3)).strftime("%Y-%m-%d_%H-%M-%S")
buf = io.BytesIO()
buf.write('\ufeff'.encode('utf-8') + csv_str.encode('utf-8'))
buf.seek(0)
return send_file(buf, mimetype="text/csv",
as_attachment=True, download_name=f"mapping_{ts}.csv")
+1 -26
View File
@@ -4,19 +4,12 @@ Blueprint: главная страница (GET /).
Отдаёт HTML-интерфейс DrHider.
"""
import os
from flask import Blueprint, render_template, current_app, send_from_directory
from flask import Blueprint, render_template, current_app
# ═══════════════════════════════════════════════════════════════════════════
# 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, "site", "static", "dist")
main_bp = Blueprint("main", __name__)
@@ -28,21 +21,3 @@ 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)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+66 -25
View File
@@ -1,25 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="82.3711mm" height="18.2443mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
viewBox="0 0 8221.93 1821.07"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
<defs>
<style type="text/css">
<![CDATA[
.fil0 {fill:#001C34}
]]>
</style>
</defs>
<g id="Слой_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<g id="_2283355517888">
<path class="fil0" d="M477.49 479.69l-413.51 0 -63.98 276.48 178.44 0 1.17 1028.71 0 26.75 0.03 0 276.39 0 0 -871.72c0,-101.28 82.43,-183.69 183.75,-183.69l589.25 3.44c101.34,0 183.76,82.46 183.76,183.74l0 871.72 276.44 0 0 -871.72c0,-253.77 -206.46,-460.15 -460.05,-460.15l-589.52 -3.53 -162.17 0.45 0 -0.48z"/>
<path class="fil0" d="M6664.65 1813.37l1181.04 0c212.14,0 376.24,-175.01 376.24,-387.08 0,-212.13 -172.55,-384.68 -384.69,-384.68l-653.66 0c-60.22,0 -109.16,-48.94 -109.16,-109.18l0 -66.18c0,-60.19 48.94,-109.14 109.16,-109.14l695.76 0 63.74 -275.49 -759.5 0c-212.13,0 -384.66,172.53 -384.66,384.63l0 66.18c0,212.13 172.53,384.67 384.66,384.67l653.66 0c60.2,0 109.2,49 109.2,109.19 0,60.13 -49,116.1 -109.2,116.1l-1118.9 0 -53.68 270.98z"/>
<path class="fil0" d="M6200.79 483.5l-723.21 0c-215.88,0 -391.47,175.6 -391.47,391.51l0 485.5c0,248.38 202.05,450.4 450.4,450.4l989.38 0 62.13 -268.51 -1051.51 0c-96.41,0 -174.95,-85.37 -174.95,-181.88l-1.5 -39.46 923.62 0 308.51 -1.84 0 -237.95 0 -206.26c0,-215.91 -175.59,-391.51 -391.41,-391.51zm115.96 560.28l-955.19 0 0 -168.77c0,-63.96 52.07,-116.05 116.02,-116.05l723.21 0c63.91,0 115.96,52.08 115.96,116.05l0 168.77z"/>
<path class="fil0" d="M4683.49 1194.24l0 168.28c0,100.92 -81.7,178.37 -182.67,178.37l-589.87 1.23c-93.18,0 -170.28,-69.96 -181.63,-160.09l0 -458.13c11.36,-90.1 88.46,-160.07 181.63,-160.07l589.44 3.5c100.97,0 183.1,82.18 183.1,183.1l0 30.42 0 213.4zm-1230.2 -345.53l-0.89 -795.03 276.91 -53.68 0 526.07c55.74,-24.16 117.03,-37.74 181.5,-37.74l589.71 3.5c252.69,0 458.42,205.72 458.42,458.59l0 30.42 0 213.4 0 168.28c0,252.86 -205.73,458.54 -458.42,458.54l-589.71 -3.5c-252.7,0 -458.41,-205.67 -458.41,-458.56l0 -171.61 0 -240.48 0.89 -98.2z"/>
<path class="fil0" d="M3041.25 1150.84l0 204.28c0,100.93 -82.15,183.05 -183.11,183.05l-582.11 -3.46c-100.96,0 -183.11,-82.16 -183.11,-183.08l0 -67.42 1.48 0.24 0 -862.65 -276.91 53.68 0.12 103.41 -0.12 0.04 0 772.69c0,252.87 205.72,458.54 458.39,458.54l582.4 3.5c252.67,0 458.4,-205.68 458.4,-458.55l0 -70.79 0.71 0.12 0 -862.65 -276.91 53.68 0.76 675.35z"/>
</g>
</g>
</svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="120"
height="120"
id="svg2"
version="1.1"
inkscape:version="1.1 (c68e22c387, 2021-05-23)"
sodipodi:docname="favicon.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="5.6"
inkscape:cx="91.428571"
inkscape:cy="54.910714"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:window-width="1920"
inkscape:window-height="1027"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:pagecheckerboard="0"
width="120px" />
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Calque 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-80.000001,-1018.2599)">
<path
style="fill:#004d00;stroke-width:0.100353;fill-opacity:1"
d="m 144.12346,1127.3527 c -14.7384,-15.4935 -17.30029,-23.203 -14.91709,-44.8912 1.78418,-16.2372 1.43961,-18.7434 -3.2949,-23.9623 -4.60141,-5.0725 -4.72472,-5.8285 -0.95017,-5.8285 5.32989,0 5.67331,-6.0284 0.58406,-10.2519 -4.3155,-3.5819 -10.08626,-19.468 -8.64375,-23.7956 1.09412,-3.2824 21.29169,27.0158 23.93661,35.9068 0.94001,3.1601 8.83542,12.1943 17.54536,20.0767 11.55761,10.4589 14.47966,14.331 10.81518,14.331 -2.7616,0 -9.48553,-3.0338 -14.94204,-6.7417 -6.36215,-4.323 -10.81609,-5.8461 -12.41642,-4.2461 -1.60033,1.6005 3.29311,7.7342 13.64167,17.0989 8.87547,8.0322 15.48529,15.2553 14.68853,16.052 -0.79676,0.7967 -7.93147,-2.6984 -15.85488,-7.7667 -16.57177,-10.6009 -17.2489,-10.7849 -17.23587,-4.6824 0.0132,5.7346 7.77953,18.5635 16.5486,27.3327 3.71587,3.7157 6.75613,7.9746 6.75613,9.4643 0,5.5255 -6.27497,2.4016 -16.26102,-8.096 z"
id="path3974"
inkscape:connector-curvature="0" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

-25
View File
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="82.3711mm" height="18.2443mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
viewBox="0 0 8221.93 1821.07"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
<defs>
<style type="text/css">
<![CDATA[
.fil0 {fill:#001C34}
]]>
</style>
</defs>
<g id="Слой_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<g id="_2283355517888">
<path class="fil0" d="M477.49 479.69l-413.51 0 -63.98 276.48 178.44 0 1.17 1028.71 0 26.75 0.03 0 276.39 0 0 -871.72c0,-101.28 82.43,-183.69 183.75,-183.69l589.25 3.44c101.34,0 183.76,82.46 183.76,183.74l0 871.72 276.44 0 0 -871.72c0,-253.77 -206.46,-460.15 -460.05,-460.15l-589.52 -3.53 -162.17 0.45 0 -0.48z"/>
<path class="fil0" d="M6664.65 1813.37l1181.04 0c212.14,0 376.24,-175.01 376.24,-387.08 0,-212.13 -172.55,-384.68 -384.69,-384.68l-653.66 0c-60.22,0 -109.16,-48.94 -109.16,-109.18l0 -66.18c0,-60.19 48.94,-109.14 109.16,-109.14l695.76 0 63.74 -275.49 -759.5 0c-212.13,0 -384.66,172.53 -384.66,384.63l0 66.18c0,212.13 172.53,384.67 384.66,384.67l653.66 0c60.2,0 109.2,49 109.2,109.19 0,60.13 -49,116.1 -109.2,116.1l-1118.9 0 -53.68 270.98z"/>
<path class="fil0" d="M6200.79 483.5l-723.21 0c-215.88,0 -391.47,175.6 -391.47,391.51l0 485.5c0,248.38 202.05,450.4 450.4,450.4l989.38 0 62.13 -268.51 -1051.51 0c-96.41,0 -174.95,-85.37 -174.95,-181.88l-1.5 -39.46 923.62 0 308.51 -1.84 0 -237.95 0 -206.26c0,-215.91 -175.59,-391.51 -391.41,-391.51zm115.96 560.28l-955.19 0 0 -168.77c0,-63.96 52.07,-116.05 116.02,-116.05l723.21 0c63.91,0 115.96,52.08 115.96,116.05l0 168.77z"/>
<path class="fil0" d="M4683.49 1194.24l0 168.28c0,100.92 -81.7,178.37 -182.67,178.37l-589.87 1.23c-93.18,0 -170.28,-69.96 -181.63,-160.09l0 -458.13c11.36,-90.1 88.46,-160.07 181.63,-160.07l589.44 3.5c100.97,0 183.1,82.18 183.1,183.1l0 30.42 0 213.4zm-1230.2 -345.53l-0.89 -795.03 276.91 -53.68 0 526.07c55.74,-24.16 117.03,-37.74 181.5,-37.74l589.71 3.5c252.69,0 458.42,205.72 458.42,458.59l0 30.42 0 213.4 0 168.28c0,252.86 -205.73,458.54 -458.42,458.54l-589.71 -3.5c-252.7,0 -458.41,-205.67 -458.41,-458.56l0 -171.61 0 -240.48 0.89 -98.2z"/>
<path class="fil0" d="M3041.25 1150.84l0 204.28c0,100.93 -82.15,183.05 -183.11,183.05l-582.11 -3.46c-100.96,0 -183.11,-82.16 -183.11,-183.08l0 -67.42 1.48 0.24 0 -862.65 -276.91 53.68 0.12 103.41 -0.12 0.04 0 772.69c0,252.87 205.72,458.54 458.39,458.54l582.4 3.5c252.67,0 458.4,-205.68 458.4,-458.55l0 -70.79 0.71 0.12 0 -862.65 -276.91 53.68 0.76 675.35z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

+168 -917
View File
File diff suppressed because it is too large Load Diff
-5
View File
@@ -1,5 +0,0 @@
"""Переиспользуемые слои загрузки файлов.
Пакет не выполняет импортов и не запускает побочных действий при загрузке.
Рабочая picker-only логика находится в подпакетах ``frontend`` и ``zip``.
"""
-1
View File
@@ -1 +0,0 @@
"""Backend переиспользуемого модуля загрузки: upload_refs + session."""
-38
View File
@@ -1,38 +0,0 @@
"""In-memory хранилище сессий с TTL и лимитами.
Каждая операция — в отдельном файле, общее состояние — в state.py.
Импорт как единый пакет:
from upload.backend.session import create_session, add_file, get_files
"""
from .create_session import create_session
from .add_file import add_file
from .get_files import get_files, file_count
from .store_result import store_result, get_result
from .store_csv import store_csv, get_csv
from .ttl import touch, pause_ttl, resume_ttl
from .cancel import request_cancel, get_cancel_event
from .cleanup import cleanup
from .state import TTL_SECONDS, MAX_FILE_BYTES, MAX_SESSION_BYTES, configure
__all__ = [
"create_session",
"add_file",
"get_files",
"file_count",
"store_result",
"get_result",
"store_csv",
"get_csv",
"touch",
"pause_ttl",
"resume_ttl",
"request_cancel",
"get_cancel_event",
"cleanup",
"configure",
"TTL_SECONDS",
"MAX_FILE_BYTES",
"MAX_SESSION_BYTES",
]
-25
View File
@@ -1,25 +0,0 @@
"""add_file — добавить файл в сессию (с проверкой суммарного лимита)."""
from . import state
def add_file(sid: str, filename: str, content: bytes) -> bool:
"""Добавить файл в сессию.
Args:
sid: Идентификатор сессии
filename: Имя файла
content: Бинарное содержимое
Returns:
True если добавлено; False если сессии нет или превышен лимит сессии.
"""
with state._lock:
s = state._sessions.get(sid)
if not s:
return False
total = sum(len(c) for _, c in s["files"])
if total + len(content) > state.MAX_SESSION_BYTES:
return False # превышен суммарный лимит сессии
s["files"].append((filename, content))
return True
-27
View File
@@ -1,27 +0,0 @@
"""request_cancel / get_cancel_event — мягкое прерывание обработки сессии."""
import threading
from typing import Optional
from .state import _sessions, _lock
def request_cancel(sid: str) -> bool:
"""Запросить мягкое прерывание обработки сессии.
Returns:
True если сессия существует и отмена запрошена, False если нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["cancel"].set()
return True
def get_cancel_event(sid: str) -> Optional[threading.Event]:
"""Получить событие отмены сессии (или None, если сессии нет)."""
with _lock:
s = _sessions.get(sid)
return s["cancel"] if s else None
-11
View File
@@ -1,11 +0,0 @@
"""cleanup — удалить сессию."""
from .state import _sessions, _lock
def cleanup(sid: str):
"""Удалить сессию и остановить её TTL-таймер."""
with _lock:
s = _sessions.pop(sid, None)
if s and s.get("timer"):
s["timer"].cancel()
-23
View File
@@ -1,23 +0,0 @@
"""create_session — создать новую сессию."""
import threading
import uuid
from .state import _sessions, _lock, _start_timer
def create_session() -> str:
"""Создать новую сессию.
Returns:
Уникальный идентификатор сессии (UUID).
"""
sid = uuid.uuid4().hex
with _lock:
_sessions[sid] = {
"files": [],
"result": None,
"cancel": threading.Event(),
"timer": _start_timer(sid),
}
return sid
-23
View File
@@ -1,23 +0,0 @@
"""get_files / file_count — чтение файлов сессии."""
from typing import List, Optional, Tuple
from .state import _sessions, _lock
def get_files(sid: str) -> Optional[List[Tuple[str, bytes]]]:
"""Получить все файлы сессии.
Returns:
[(filename, content), ...] или None если сессия не найдена.
"""
with _lock:
s = _sessions.get(sid)
return list(s["files"]) if s else None
def file_count(sid: str) -> int:
"""Количество файлов в сессии."""
with _lock:
s = _sessions.get(sid)
return len(s["files"]) if s else 0
-45
View File
@@ -1,45 +0,0 @@
"""Общее состояние сессий: хранилище, блокировка, константы, TTL-таймер.
Единая точка хранения состояния — все операции импортируют её.
Дробить state.py на файл-на-переменную не нужно: это данные, а не функции.
"""
import threading
# TTL сессии: 30 минут
TTL_SECONDS = 30 * 60
# Максимальный объём одного файла и суммарный объём файлов в сессии (защита памяти)
MAX_FILE_BYTES = 50 * 1024 * 1024 # 50 MB на один файл
MAX_SESSION_BYTES = 500 * 1024 * 1024 # 500 MB суммарно на сессию
_sessions: dict = {}
_lock = threading.Lock()
def _start_timer(sid: str) -> threading.Timer:
"""Запустить таймер автоочистки сессии через TTL."""
def _clean():
with _lock:
_sessions.pop(sid, None)
timer = threading.Timer(TTL_SECONDS, _clean)
timer.daemon = True
timer.start()
return timer
def configure(max_file_bytes: int = None, max_session_bytes: int = None,
ttl_seconds: int = None):
"""Переопределить лимиты/TTL из конфига приложения (глобально).
None — оставить текущее значение.
"""
global MAX_FILE_BYTES, MAX_SESSION_BYTES, TTL_SECONDS
if max_file_bytes is not None:
MAX_FILE_BYTES = max_file_bytes
if max_session_bytes is not None:
MAX_SESSION_BYTES = max_session_bytes
if ttl_seconds is not None:
TTL_SECONDS = ttl_seconds
-30
View File
@@ -1,30 +0,0 @@
"""store_csv / get_csv — сохранение и чтение CSV с таблицей замен."""
from typing import Optional
from .state import _sessions, _lock
def store_csv(sid: str, csv_str: str) -> bool:
"""Сохранить CSV с таблицей замен.
Returns:
True если сохранено, False если сессии нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["csv"] = csv_str
return True
def get_csv(sid: str) -> Optional[str]:
"""Получить CSV с таблицей замен.
Returns:
Строка CSV или None если нет.
"""
with _lock:
s = _sessions.get(sid)
return s.get("csv") if s else None
-30
View File
@@ -1,30 +0,0 @@
"""store_result / get_result — сохранение и чтение результата обработки."""
from typing import Optional
from .state import _sessions, _lock
def store_result(sid: str, zip_data: bytes) -> bool:
"""Сохранить результат обработки (ZIP-архив).
Returns:
True если сохранено, False если сессии нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["result"] = zip_data
return True
def get_result(sid: str) -> Optional[bytes]:
"""Получить результат обработки.
Returns:
ZIP-архив или None если сессия не найдена/результат не готов.
"""
with _lock:
s = _sessions.get(sid)
return s["result"] if s else None
-34
View File
@@ -1,34 +0,0 @@
"""touch / pause_ttl / resume_ttl — управление TTL-таймером сессии."""
from .state import _sessions, _lock, _start_timer
def touch(sid: str):
"""Продлить жизнь сессии: перезапустить TTL-таймер (если сессия существует)."""
with _lock:
s = _sessions.get(sid)
if not s:
return
if s.get("timer"):
s["timer"].cancel()
s["timer"] = _start_timer(sid)
def pause_ttl(sid: str):
"""Приостановить TTL сессии (во время обработки): сессия живёт, пока идёт воркер."""
with _lock:
s = _sessions.get(sid)
if s and s.get("timer"):
s["timer"].cancel()
s["timer"] = None
def resume_ttl(sid: str):
"""Возобновить TTL сессии (после завершения обработки): результат доступен ещё TTL."""
with _lock:
s = _sessions.get(sid)
if not s:
return
if s.get("timer"):
s["timer"].cancel()
s["timer"] = _start_timer(sid)
-7
View File
@@ -1,7 +0,0 @@
"""upload_refs — Flask Blueprint для приёма ссылок и pull с ВМ-буфера."""
from .blueprint import create_upload_refs_blueprint
from .safe_name import safe_name
from .pull_file import pull_file
__all__ = ["create_upload_refs_blueprint", "safe_name", "pull_file"]
-145
View File
@@ -1,145 +0,0 @@
"""Переиспользуемый Blueprint слоя 2: POST /upload_refs (pull с ВМ-буфера в сессию).
Поведение:
- safe_name (защита от path traversal)
- SSRF-валидация url.startswith(vm_prefix)
- лимит на один файл -> delete + skip
- pull с ретраями через httpx stream
- лимит сессии -> skip; отсутствие сессии -> 404
- delete url с ВМ (best-effort)
- поддержка пофайлового транзита и пачек
- опциональный callback для интеграции/эмуляции Слоя 3 (on_file_received)
"""
import httpx
import logging
from urllib.parse import urlsplit
from flask import Blueprint, request, jsonify, current_app
from ..session import (create_session, add_file, get_files, file_count,
MAX_FILE_BYTES, configure)
from .config import PULL_RETRIES, PULL_RETRY_DELAY, VM_UPLOAD_PREFIX
from .safe_name import safe_name
from .pull_file import pull_file
log = logging.getLogger("upload.upload_refs")
def create_upload_refs_blueprint(cfg: dict = None) -> Blueprint:
"""Создать Blueprint с эндпоинтом upload_refs.
cfg (все ключи опциональны, есть дефолты):
apiPrefix (str) — префикс Blueprint, по умолчанию "/api"
vmUploadPrefix (str) — доверенный префикс ВМ-буфера (SSRF-валидация)
maxFileBytes (int) — лимит на один файл
maxSessionBytes (int) — суммарный лимит сессии (применяется к сессиям)
ttlSeconds (int) — TTL сессии
pullRetries (int) — ретраи pull
pullRetryDelay (int) — пауза между ретраями (сек)
pullTimeout (int) — таймаут одного GET pull
onFileReceived (func) — опциональный callback (sid, name, content) для Слоя 3
"""
cfg = cfg or {}
prefix = cfg.get("apiPrefix", "/api")
vm_prefix = cfg.get("vmUploadPrefix", VM_UPLOAD_PREFIX)
max_file_bytes = cfg.get("maxFileBytes", MAX_FILE_BYTES)
pull_retries = cfg.get("pullRetries", PULL_RETRIES)
pull_delay = cfg.get("pullRetryDelay", PULL_RETRY_DELAY)
pull_timeout = cfg.get("pullTimeout", 120)
on_file_received = cfg.get("onFileReceived")
# Применить лимиты сессии/TTL из конфига (глобально для всех сессий)
configure(
max_file_bytes=cfg.get("maxFileBytes"),
max_session_bytes=cfg.get("maxSessionBytes"),
ttl_seconds=cfg.get("ttlSeconds"),
)
is_path_only_prefix = vm_prefix.startswith("/")
bp = Blueprint("upload_refs", __name__, url_prefix=prefix)
@bp.route("/upload_refs", methods=["POST"])
def upload_refs():
"""Принять ссылки на файлы (загружены на ВМ-буфер), забрать по egress.
Вход: JSON {"session": "...", "files": [{"name": str, "size": int, "url": str}]}.
Каждый файл тянется исходящим GET с ВМ (egress не ограничен шлюзом),
читается по частям (stream), кладётся в сессию. После успешного pull файл
удаляется с ВМ (DELETE).
"""
data = request.get_json(silent=True) or {}
sid = data.get("session") or create_session()
refs = data.get("files") or []
if not refs:
log.warning("upload_refs: no files, sid=%s", sid)
return jsonify({"ok": False, "error": "No files"}), 400
added = 0
transport = cfg.get("httpxTransport") or current_app.config.get("UPLOAD_HTTPX_TRANSPORT")
try:
with httpx.Client(transport=transport, timeout=pull_timeout, follow_redirects=True) as client:
for ref in refs:
name = safe_name(ref.get("name") or "")
url = ref.get("url") or ""
if not name or not url:
continue
# SSRF-защита: тянуть можно ТОЛЬКО с доверенного ВМ-буфера
if is_path_only_prefix:
if not urlsplit(url).path.startswith(vm_prefix):
log.warning("upload_refs: unsafe URL path, skip sid=%s url=%r", sid, url)
continue
if not url.startswith(("http://", "https://")):
url = request.host_url.rstrip("/") + ("/" if not url.startswith("/") else "") + url
else:
if not url.startswith(vm_prefix):
log.warning("upload_refs: unsafe URL, skip sid=%s url=%r", sid, url)
continue
# Лимит на один файл: сверх лимита — пропускаем
if (ref.get("size") or 0) > max_file_bytes:
log.warning("upload_refs: file exceeds %dMB, skip sid=%s file=%r size=%s",
max_file_bytes // (1024 * 1024), sid, name, ref.get("size"))
try:
client.delete(url)
except Exception:
pass
continue
# Pull с ретраями
content = pull_file(client, url, pull_retries, pull_delay, sid=sid, name=name)
log.info("upload_refs: pulled sid=%s file=%r size=%d", sid, name, len(content))
if len(content) > max_file_bytes:
log.warning("upload_refs: pulled file exceeds %dMB, skip sid=%s file=%r size=%d",
max_file_bytes // (1024 * 1024), sid, name, len(content))
try:
client.delete(url)
except Exception:
pass
continue
if not add_file(sid, name, content):
if get_files(sid) is None:
log.warning("upload_refs: session not found, sid=%s file=%r", sid, name)
return jsonify({"ok": False, "error": "Session not found"}), 404
log.warning("upload_refs: session limit exceeded, skip sid=%s file=%r", sid, name)
try:
client.delete(url)
except Exception:
pass
continue
# Успешно добавлен в сессию — удаляем с ВМ-буфера
try:
client.delete(url)
except Exception:
pass
# Если передан callback для Слоя 3 (эмуляция или реальный процессинг)
if callable(on_file_received):
try:
on_file_received(sid, name, content)
except Exception as cb_err:
log.warning("upload_refs: on_file_received callback error: %r", cb_err)
added += 1
except Exception as e:
log.error("upload_refs: pull error sid=%s: %r", sid, e)
return jsonify({"ok": False, "error": "Pull failed: %s" % e}), 502
log.info("upload_refs: done sid=%s added=%d total=%d", sid, added, file_count(sid))
return jsonify({"ok": True, "session": sid, "count": file_count(sid), "added": added})
return bp
-11
View File
@@ -1,11 +0,0 @@
"""Параметры слоя 2 (бэк) по умолчанию.
Переопределяются из конфига приложения через create_upload_refs_blueprint(cfg).
"""
# Ретраи pull из ВМ-буфера: защита от разовых DNS/сетевых сбоев (gaierror -5 и т.п.)
PULL_RETRIES = 3
PULL_RETRY_DELAY = 2 # секунды между попытками
# Доверенный префикс ВМ-буфера по умолчанию — валидация URL при pull (защита от SSRF)
VM_UPLOAD_PREFIX = "https://upload.sinefo.ru/drhider-upload/"
-39
View File
@@ -1,39 +0,0 @@
"""pull_file — вытащить файл с ВМ-буфера исходящим GET с ретраями."""
import logging
import time
from .config import PULL_RETRIES, PULL_RETRY_DELAY
log = logging.getLogger("upload.upload_refs.pull")
def pull_file(client, url: str, retries: int = PULL_RETRIES,
delay: float = PULL_RETRY_DELAY, sid: str = None, name: str = None) -> bytes:
"""GET url с ретраями; читает по частям (stream).
Args:
client: httpx.Client
url: URL файла на ВМ-буфере.
retries: число попыток.
delay: пауза между попытками (сек).
sid/name: для логирования (опционально).
Returns:
Содержимое файла (bytes).
Raises:
Последнюю ошибку попытки, если все ретраи не удались.
"""
last_err = None
for attempt in range(retries):
try:
with client.stream("GET", url) as resp:
resp.raise_for_status()
return b"".join(resp.iter_bytes())
except Exception as e:
last_err = e
log.warning("pull: attempt %d/%d failed sid=%s file=%r: %r",
attempt + 1, retries, sid, name, e)
time.sleep(delay)
raise last_err if last_err else RuntimeError("pull failed")
-16
View File
@@ -1,16 +0,0 @@
"""safe_name — санитизация имени файла (защита от path traversal)."""
def safe_name(name: str) -> str:
"""Санитизировать имя файла: защита от path traversal, сохраняя подпапки.
Запрещает '..' и абсолютные пути; нормализует слэши. Возвращает "" если
имя пустое или небезопасное.
"""
if not name:
return ""
name = name.replace("\\", "/")
parts = [p for p in name.split("/") if p and p != "."]
if not parts or any(p == ".." for p in parts):
return ""
return "/".join(parts)
-3
View File
@@ -1,3 +0,0 @@
{
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"]
}
-193
View File
@@ -1,193 +0,0 @@
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 };
@@ -1,54 +0,0 @@
/**
* Добавляет узел файла или группы в состояние picker с рекурсивной дедупликацией.
*
* @param {{nodes: Array, fileKeys: Set<string>}} state Внутреннее состояние таблицы.
* @param {{kind: string, path: string, file?: File, children?: Array}} fileNode
* Корневой узел файла, папки или ZIP-архива.
* @returns {boolean} True, если в состояние добавлен хотя бы один узел.
*
* Ключ дедупликации состоит из полного пути и размера файла. Это позволяет
* повторно выбрать файл после удаления и одновременно не скрывает файл с тем
* же путём, но другим размером.
*/
export function addFileWithDedup(state, fileNode) {
// Удаляет дубли и пустые группы снизу вверх, сохраняя исходные File objects.
const accept = (node) => {
if (node.kind === 'file') {
// NUL-разделитель исключает неоднозначность при склейке пути и размера.
const key = `${node.path}\u0000${node.file.size}`;
if (state.fileKeys.has(key)) return null;
state.fileKeys.add(key);
return node;
}
// Группа нужна только пока после фильтрации в ней остался хотя бы один leaf.
node.children = node.children.map(accept).filter(Boolean);
return node.children.length ? node : null;
};
const accepted = accept(fileNode);
// Пустой ZIP/каталог не должен появляться в таблице как пустая строка.
if (!accepted || (accepted.kind !== 'file' && !accepted.children.length)) return false;
if (accepted.kind !== 'file') {
const existing = state.nodes.find((node) => node.kind !== 'file' && node.path === accepted.path);
if (existing) {
mergeChildren(existing, accepted);
return true;
}
}
state.nodes.push(accepted);
return true;
}
function mergeChildren(target, incoming) {
incoming.children.forEach((child) => {
if (child.kind === 'file') {
const duplicate = target.children.some((existing) => existing.kind === 'file'
&& existing.path === child.path && existing.file.size === child.file.size);
if (!duplicate) target.children.push(child);
return;
}
const existing = target.children.find((candidate) => candidate.kind !== 'file'
&& candidate.path === child.path);
if (existing) mergeChildren(existing, child);
else target.children.push(child);
});
}
-18
View File
@@ -1,18 +0,0 @@
/**
* Экранирует текст перед вставкой в HTML-шаблон, собираемый через innerHTML.
*
* @param {*} value Имя файла, путь или сообщение статуса.
* @returns {string} Строка с заменёнными HTML-значимыми символами.
*
* Экранируются и кавычки, потому что значение может попасть не только в текст
* кнопки, но и в HTML-атрибут вроде data-path или aria-label.
*/
export function esc(value) {
return String(value).replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[character]));
}
-11
View File
@@ -1,11 +0,0 @@
/**
* Преобразует размер файла из байтов в короткую строку для таблицы.
*
* @param {number} bytes Размер в байтах.
* @returns {string} Значение в B, KB или MB с одним знаком после запятой.
*/
export function fs(bytes) {
return bytes < 1024 ? `${bytes} B`
: bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB`
: `${(bytes / 1048576).toFixed(1)} MB`;
}
-72
View File
@@ -1,72 +0,0 @@
import { listZipFiles } from '../zip/list_zip_files.js';
import { addFileWithDedup } from './add_file_with_dedup.js';
import { render } from './render.js';
/**
* Обрабатывает список файлов из обычного file input.
*
* @param {{fileKeys: Set<string>, nodes: Array, busy: boolean}} state Состояние picker.
* @param {{allowedExt: string[]}} cfg Конфигурация разрешённых расширений.
* @param {FileList|File[]} files Выбранные browser File objects.
* @param {{tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-элементы вывода.
* @returns {Promise<void>} Завершается после разбора всех ZIP и перерисовки таблицы.
*
* Обычные документы добавляются сразу. ZIP читаются асинхронно в браузере;
* ошибка одного архива не отменяет обработку остальных выбранных файлов.
*/
export async function addFiles(state, cfg, files, elements) {
for (const file of Array.from(files)) {
if (!file.name.toLowerCase().endsWith('.zip')) {
// Фильтр повторяет accept в HTML, потому что accept не является защитой API.
if (!cfg.allowedExt.some((extension) => file.name.toLowerCase().endsWith(extension.toLowerCase()))) {
continue;
}
// Обычный файл получает имя как путь: у file input нет относительного пути.
addFileWithDedup(state, {
id: crypto.randomUUID(), kind: 'file', name: file.name, path: file.name,
file, children: [], expanded: true,
});
continue;
}
try {
// listZipFiles возвращает дерево только с разрешёнными leaf-файлами.
const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits);
if (zipTree) addFileWithDedup(state, zipTree);
} catch (error) {
// Битый или небезопасный ZIP пропускается, чтобы не блокировать picker.
if (typeof cfg.onError === 'function') {
cfg.onError(error, file);
} else if (elements.statusEl) {
elements.statusEl.textContent = `Ошибка чтения архива ${file.name}: ${error.message}`;
}
continue;
}
}
// Единая перерисовка после всей пачки предотвращает промежуточные состояния UI.
render(state, elements, cfg);
}
/**
* Создаёт обработчик change для обычного выбора файлов.
*
* @param {object} state Состояние picker, включая флаг busy.
* @param {object} cfg Конфигурация picker.
* @param {object} elements DOM-элементы picker.
* @returns {Function} Async-обработчик для addEventListener('change', ...).
*
* busy устанавливается синхронно до первого await. Поэтому второе быстрое
* событие выбора не запускает параллельный разбор и не меняет дерево конкурирующе.
*/
export function onFilesChange(state, cfg, elements) {
return async () => {
if (state.busy) return;
state.busy = true;
try {
await addFiles(state, cfg, elements.fileInputEl.files, elements);
} finally {
// Даже исключение вне внутреннего ZIP-catch не оставляет picker заблокированным.
elements.fileInputEl.value = '';
state.busy = false;
}
};
}
-84
View File
@@ -1,84 +0,0 @@
import { listZipFiles } from '../zip/list_zip_files.js';
import { addFileWithDedup } from './add_file_with_dedup.js';
import { rebaseTree } from './rebase_tree.js';
import { render } from './render.js';
/**
* Создаёт async-обработчик выбора каталога через webkitdirectory.
*
* @param {{nodes: Array, fileKeys: Set<string>, busy: boolean}} state Состояние picker.
* @param {{allowedExt: string[]}} cfg Разрешённые расширения.
* @param {{folderInputEl: HTMLInputElement, tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-элементы.
* @returns {Function} Async-обработчик события change.
*
* Браузер отдаёт плоский FileList с webkitRelativePath. Обработчик группирует
* его по первой части пути, создаёт промежуточные папки и затем добавляет каждый
* корень через общую дедупликацию. ZIP внутри каталога раскрывается тем же кодом,
* что и ZIP из обычного file input.
*/
export function onFolderChange(state, cfg, elements) {
return async () => {
if (state.busy) return;
state.busy = true;
try {
const roots = new Map();
for (const file of Array.from(elements.folderInputEl.files)) {
// Для webkitdirectory путь начинается с выбранной корневой папки.
const parts = (file.webkitRelativePath || file.name).split('/');
const relativePath = parts.slice(1).join('/') || file.name;
const lowerPath = relativePath.toLowerCase();
const rootName = parts[0] || file.name;
if (!roots.has(rootName)) {
// Один root на выбранный каталог позволяет сохранить дерево целиком.
roots.set(rootName, { id: crypto.randomUUID(), kind: 'folder', name: rootName,
path: rootName, children: [], expanded: false });
}
const root = roots.get(rootName);
// Вставляет узел по его пути, создавая отсутствующие промежуточные папки.
const addToFolder = (node) => {
let current = root;
const nodeParts = node.path.split('/').slice(1);
nodeParts.forEach((part, index) => {
const last = index === nodeParts.length - 1;
let child = current.children.find((item) => item.name === part);
if (!child) {
child = last ? node : { id: crypto.randomUUID(), kind: 'folder', name: part,
path: `${rootName}/${nodeParts.slice(0, index + 1).join('/')}`,
children: [], expanded: false };
current.children.push(child);
}
current = child;
});
};
if (lowerPath.endsWith('.zip')) {
try {
// ZIP rebased на имя выбранной папки перед вставкой в общий root.
const zip = await listZipFiles(file, cfg.allowedExt, cfg.limits);
if (zip) {
rebaseTree(zip, rootName);
addToFolder(zip);
}
} catch (error) {
// Ошибка одного ZIP не должна терять остальные файлы каталога.
if (typeof cfg.onError === 'function') {
cfg.onError(error, file);
} else if (elements.statusEl) {
elements.statusEl.textContent = `Ошибка чтения архива ${file.name}: ${error.message}`;
}
continue;
}
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
addToFolder({ id: crypto.randomUUID(), kind: 'file', name: parts.at(-1),
path: `${rootName}/${relativePath}`, file, children: [], expanded: true });
}
}
// Дедупликация выполняется после сборки каждого корня каталога.
roots.forEach((root) => addFileWithDedup(state, root));
// Сброс value позволяет выбрать тот же каталог повторно.
elements.folderInputEl.value = '';
render(state, elements, cfg);
} finally {
state.busy = false;
}
};
}
-16
View File
@@ -1,16 +0,0 @@
/**
* Добавляет префикс корневой папки к каждому пути дерева.
*
* @param {{path: string, file?: File, children: Array}} root Узел дерева.
* @param {string} prefix Путь выбранной папки, в которую попал узел.
* @returns {object} Тот же узел после изменения путей.
*
* Для leaf-файлов создаётся новый File с тем же базовым именем; логический путь
* хранится отдельно в узле дерева.
*/
export function rebaseTree(root, prefix) {
root.path = `${prefix}/${root.path}`;
if (root.file) root.file = new File([root.file], root.file.name, { lastModified: root.file.lastModified });
root.children.forEach((child) => rebaseTree(child, prefix));
return root;
}
-124
View File
@@ -1,124 +0,0 @@
import { esc } from './esc.js';
import { fs } from './fs.js';
const DEFAULT_RENDER_CONFIG = {
labels: {
statusReady: 'готов',
remove: 'Удалить',
empty: 'Нет выбранных файлов',
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
count: (count, bytes) => `${count} файлов · ${fs(bytes)}`,
},
layout: { columns: ['path', 'size', 'status'] },
};
function renderConfig(cfg = {}) {
return {
labels: {
...DEFAULT_RENDER_CONFIG.labels,
...(cfg.labels || {}),
columns: {
...DEFAULT_RENDER_CONFIG.labels.columns,
...(cfg.labels?.columns || {}),
},
},
layout: { ...DEFAULT_RENDER_CONFIG.layout, ...(cfg.layout || {}) },
};
}
/**
* Рендерит один узел дерева и его раскрытых потомков в HTML-строки таблицы.
*
* @param {{kind: string, id: string, name: string, path: string, file?: File,
* children: Array, expanded: boolean, error?: string}} node Узел file/folder/zip.
* @param {number} depth Глубина узла; используется для визуального отступа.
* @returns {string} HTML одной строки или поддерева строк.
*
* Имена и пути проходят через esc до вставки в innerHTML. Группа получает
* кнопку раскрытия, leaf-файл — размер, статус и data-path для будущего API статусов.
*/
function renderNode(node, depth, cfg) {
const isGroup = node.kind !== 'file';
const padding = depth * 4;
const name = esc(node.name);
const action = isGroup
? `<button class="tree-name tree-group tree-${node.kind}" style="padding-left:${padding}ch" data-toggle="${node.id}" `
+ `aria-expanded="${node.expanded}"><span class="tree-chevron">${node.expanded ? '▼' : '▶'}</span>${name}</button>`
: `<span class="tree-name" style="padding-left:${padding}ch">${name}</span>`;
const remove = `<button class="remove-btn" type="button" data-remove="${node.id}" aria-label="${esc(cfg.labels.remove)} ${name}">×</button>`;
const pathAttribute = node.kind === 'file' ? ` data-path="${esc(node.path)}"` : '';
const cells = {
path: action,
size: node.kind === 'file' ? fs(node.file.size) : '',
status: node.error ? esc(node.error) : node.kind === 'file' ? esc(cfg.labels.statusReady) : '',
};
const columns = cfg.layout.columns?.length
? cfg.layout.columns
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
const row = `<tr class="tree-row tree-${node.kind}"${pathAttribute}>`
+ columns.map((column) => `<td>${cells[column] || ''}</td>`).join('')
+ `<td>${remove}</td></tr>`;
if (!isGroup || !node.expanded) return row;
// Дочерние строки добавляются только для раскрытой группы.
return row + node.children.map((child) => renderNode(child, depth + 1, cfg)).join('');
}
/**
* Полностью синхронизирует DOM таблицы и счётчик с текущим state.nodes.
*
* @param {{nodes: Array}} state Состояние дерева.
* @param {{tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-вывод.
* @returns {void}
*
* Обход для счётчика независим от раскрытия: свернутая группа всё равно входит
* в количество leaf-файлов и суммарный размер.
*/
export function render(state, elements, cfg) {
const normalized = renderConfig(cfg);
const columns = normalized.layout.columns?.length
? normalized.layout.columns
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
elements.tableBodyEl.innerHTML = state.nodes.length
? state.nodes.map((node) => renderNode(node, 0, normalized)).join('')
: `<tr><td colspan="${columns.length + 1}">${esc(normalized.labels.empty)}</td></tr>`;
const files = [];
// Собирает все leaf-файлы, включая содержимое свернутых групп.
const visit = (node) => {
if (node.kind === 'file') files.push(node);
else node.children.forEach(visit);
};
state.nodes.forEach(visit);
const totalBytes = files.reduce((sum, node) => sum + node.file.size, 0);
elements.countEl.textContent = normalized.labels.count(files.length, totalBytes);
}
/**
* Ищет узел по уникальному id и возвращает также массив его непосредственного родителя.
*
* @param {Array} nodes Массив узлов текущего уровня.
* @param {string} id Идентификатор, созданный через crypto.randomUUID().
* @returns {{node: object, nodes: Array}|null} Узел и контейнер для удаления либо null.
*/
export function findNode(nodes, id) {
for (const node of nodes) {
if (node.id === id) return { node, nodes };
const found = findNode(node.children || [], id);
if (found) return found;
}
return null;
}
/**
* Преобразует иерархическое дерево в плоский список leaf-файлов.
*
* @param {Array} nodes Узлы любого уровня.
* @param {Array} result Внутренний аккумулятор рекурсивного вызова.
* @returns {Array<{path: string, name: string, size: number, file: File}>} Файлы для приложения.
*/
export function flattenFiles(nodes, result = []) {
nodes.forEach((node) => {
if (node.kind === 'file') result.push({ path: node.path, name: node.name, size: node.file.size, file: node.file });
else flattenFiles(node.children, result);
});
return result;
}
-78
View File
@@ -1,78 +0,0 @@
/**
* putToVm — отправка одного файла в буфер методом HTTP PUT (сырое бинарное тело).
*
* @param {Blob|File} file Файл для отправки.
* @param {string} url Полный целевой URL в буфере.
* @param {Object} options Опции отправки:
* @param {function(number, number, number): void} [options.onProgress] Колбэк прогресса (pct, loaded, total).
* @param {function(XMLHttpRequest): void} [options.onXHR] Колбэк регистрации XHR для прямого контроля.
* @param {AbortSignal} [options.signal] Сигнал прерывания запроса (AbortController.signal).
* @param {number} [options.timeoutMs=300000] Таймаут в миллисекундах (по умолчанию 300с).
* @returns {Promise<void>} Разрешается при ответе 2xx, отклоняется при ошибке или прерывании.
*/
export function putToVm(file, url, options = {}) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
if (options.onXHR) {
options.onXHR(xhr);
}
const abortHandler = () => {
xhr.abort();
reject(new DOMException('Upload aborted', 'AbortError'));
};
if (options.signal) {
if (options.signal.aborted) {
reject(new DOMException('Upload aborted', 'AbortError'));
return;
}
options.signal.addEventListener('abort', abortHandler, { once: true });
}
xhr.open('PUT', url);
xhr.timeout = options.timeoutMs || 300000;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && options.onProgress) {
const pct = Math.round((e.loaded / e.total) * 100);
options.onProgress(pct, e.loaded, e.total);
}
};
xhr.onload = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(`Буфер: HTTP ${xhr.status} ${xhr.statusText}`));
}
};
xhr.onerror = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
reject(new Error('Сетевая ошибка при отправке в буфер'));
};
xhr.ontimeout = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
reject(new Error(`Таймаут ожидания ответа буфера (${Math.round(xhr.timeout / 1000)}с)`));
};
xhr.onabort = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
reject(new DOMException('Upload aborted', 'AbortError'));
};
xhr.send(file);
});
}
-188
View File
@@ -1,188 +0,0 @@
/**
* uploadViaVM — Слой 2 (Фронтенд): пофайловый транзит через ВМ-буфер в сессию бэкенда.
*
* Архитектура потокового транзита:
* Для каждого файла k:
* 1. Браузер отправляет файл k методом PUT в буфер на ВМ.
* 2. Браузер сразу делает POST /api/upload_refs для одного файла k.
* 3. Бэкенд забирает файл исходящим GET в память RAM и сразу шлёт DELETE в буфер.
* 4. Файл удалён из буфера ВМ, память чиста.
* 5. Браузер переходит к файлу k+1.
*/
import { putToVm } from './put_to_vm.js';
import { fs } from '../table/fs.js';
function generateUuid() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* Нормализует элемент списка файлов (поддерживает как File, так и объект из FilePicker.getFiles()).
*/
function normalizeFileEntry(item) {
if (item instanceof File || item instanceof Blob) {
return { name: item.name || 'unnamed', size: item.size, file: item, path: item.name || '' };
}
if (item && item.file) {
return {
name: item.name || item.file.name || 'unnamed',
size: item.size ?? item.file.size ?? 0,
file: item.file,
path: item.path || item.name || '',
};
}
throw new Error('Некорректный объект файла');
}
/**
* Выполняет пофайловую транзитную загрузку файлов в сессию бэкенда через буфер.
*
* @param {Array<File|{name: string, size: number, file: File, path?: string}>} files Список файлов.
* @param {Object} options Параметры загрузки:
* @param {string} options.vmUploadUrl Базовый URL буфера (обязателен).
* @param {string} [options.backendUploadUrl='/api/upload_refs'] URL эндпоинта приёма ссылок.
* @param {string} [options.session=''] Идентификатор существующей сессии (если есть).
* @param {AbortSignal} [options.signal] Сигнал отмены загрузки.
* @param {function(string): void} [options.onStatus] Колбэк общего текстового статуса.
* @param {function(number, string): void} [options.onFileStatus] Колбэк обновления статуса конкретного файла (индекс, текст/html).
* @param {function(Object): void} [options.onProgress] Колбэк прогресса текущего файла ({ index, total, pct, speed }).
* @param {function(Object): void} [options.onFileComplete] Колбэк успешной доставки файла ({ index, name, session, totalAdded }).
* @returns {Promise<{ok: boolean, session?: string, count?: number, error?: string, aborted?: boolean}>}
*/
export async function uploadViaVM(files, options = {}) {
if (!options.vmUploadUrl) {
return { ok: false, error: 'Параметр options.vmUploadUrl обязателен' };
}
const normalizedFiles = Array.from(files).map(normalizeFileEntry);
const total = normalizedFiles.length;
if (total === 0) {
return { ok: true, session: options.session || '', count: 0 };
}
const backendUrl = options.backendUploadUrl || (options.apiBase || '') + '/api/upload_refs';
const vmBase = options.vmUploadUrl.endsWith('/') ? options.vmUploadUrl : `${options.vmUploadUrl}/`;
let currentSession = options.session || '';
let totalAdded = 0;
for (let k = 0; k < total; k++) {
if (options.signal?.aborted) {
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
}
const { name, size, file } = normalizedFiles[k];
const fileKey = `${generateUuid()}_${k}`;
const fileUrl = `${vmBase}${fileKey}`;
if (options.onStatus) {
options.onStatus(`Загрузка в буфер (${k + 1}/${total}): ${name}`);
}
if (options.onFileStatus) {
options.onFileStatus(k, '⏳ В буфер...');
}
// Этап 1: Отправка одного файла в буфер
const t0 = performance.now();
try {
await putToVm(file, fileUrl, {
signal: options.signal,
onXHR: options.onXHR,
onProgress(pct, loaded, fileTotal) {
const elapsed = (performance.now() - t0) / 1000;
const speed = elapsed > 0 ? (loaded / elapsed) : 0;
if (options.onFileStatus) {
options.onFileStatus(k, `⏳ ${pct}% (${fs(speed)}/s)`);
}
if (options.onProgress) {
options.onProgress({ index: k, total, pct, speed, loaded, fileTotal, name });
}
},
});
} catch (err) {
if (options.signal?.aborted) {
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
}
if (options.onFileStatus) {
options.onFileStatus(k, '✗ Ошибка буфера');
}
return {
ok: false,
error: `Ошибка отправки файла ${name} в буфер: ${err.message}`,
failedIndex: k,
session: currentSession,
};
}
// Этап 2: Вызов /api/upload_refs для немедленного переноса файла с буфера в RAM сессии
if (options.onStatus) {
options.onStatus(`Приём сервисом (${k + 1}/${total}): ${name}`);
}
if (options.onFileStatus) {
options.onFileStatus(k, '⚡ В сессию...');
}
try {
const resp = await fetch(backendUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session: currentSession,
files: [{ name, size, url: fileUrl }],
}),
signal: options.signal,
});
if (!resp.ok) {
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
}
const data = await resp.json();
if (!data.ok) {
throw new Error(data.error || 'Бэкенд вернул ошибку');
}
currentSession = data.session || currentSession;
totalAdded = data.count ?? (totalAdded + 1);
if (options.onFileStatus) {
options.onFileStatus(k, '✓ Доставлен');
}
if (options.onFileComplete) {
options.onFileComplete({
fileIndex: k,
name,
size,
session: currentSession,
totalAdded,
});
}
} catch (err) {
if (options.signal?.aborted) {
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
}
if (options.onFileStatus) {
options.onFileStatus(k, '✗ Ошибка приёма');
}
return {
ok: false,
error: `Ошибка приёма файла ${name} бэкендом: ${err.message}`,
failedIndex: k,
session: currentSession,
};
}
}
if (options.onStatus) {
options.onStatus(`Завершено. Успешно передано файлов: ${total}`);
}
return { ok: true, session: currentSession, count: totalAdded };
}
-125
View File
@@ -1,125 +0,0 @@
// Построить дерево 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 });
}