feat: DrHider MVP — two-pass document obfuscation service
Deploy contracts-flask / validate (push) Successful in 0s
Deploy contracts-flask / validate (push) Successful in 0s
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
# Запрос Opus — ревью реализации DrHider
|
||||
|
||||
Дата: 29.06.2026
|
||||
|
||||
## Контекст
|
||||
|
||||
Написан MVP сервиса обфускации документов. Код в трёх файлах. Нужно ревью: что упущено, что сломается, что улучшить.
|
||||
|
||||
## Файлы (все в contracts-flask)
|
||||
|
||||
1. **`deploy/services/drhider.py`** — ядро (≈280 строк)
|
||||
2. **`site/templates/drhider.html`** — страница загрузки
|
||||
3. **`site/app.py`** — добавлены роуты `/DrHider` и `POST /api/drhider`
|
||||
|
||||
## Что реализовано
|
||||
|
||||
### Двухпроходная обфускация
|
||||
- **Проход 1 (сбор сущностей):** regex-паттерны (телефон, email, ИНН/ОГРН/КПП, БИК, р/с, к/с, паспорт) + COMPANY_PATTERN (ООО/ЗАО/АО/ИП) + опционально LLM-NER для имён/адресов.
|
||||
- Глобальный словарь `_mapping[оригинал] = замена` обеспечивает согласованность: одна сущность → одна замена во всех файлах.
|
||||
- **Проход 2 (замена):** docx — замена в `paragraphs.runs` и `tables.cells` (python-docx). PDF — read-only через pdfplumber, замена по тексту (без сохранения форматирования — осознанное ограничение MVP).
|
||||
|
||||
### Генераторы фиктивных значений
|
||||
- Телефон: `+7 (XXX) XXX-XX-XX` с реальными кодами городов.
|
||||
- Email: `<random>@example.ru` (фиктивные домены).
|
||||
- ИНН/ОГРН: валидные контрольные суммы.
|
||||
- Компании: ООО «<существительное>» из словаря.
|
||||
- ФИО: русские фамилия + инициалы из словарей.
|
||||
- Адреса: город + улица + дом из словарей.
|
||||
- Паспорт: `XX XX XXXXXX`.
|
||||
|
||||
### Безопасность
|
||||
- Вся обработка в памяти.
|
||||
- Temp-директория для конвертации `.doc` → `.docx` — удаляется в `finally`.
|
||||
- Никакой БД, никакого логирования реальных данных.
|
||||
- `mapping.csv` — внутри ZIP, уходит пользователю.
|
||||
|
||||
### UI
|
||||
- Drag&drop, выбор файлов, прогресс.
|
||||
- Кнопка «Обфусцировать» → POST /api/drhider → скачивание ZIP.
|
||||
|
||||
## Вопросы к ревью
|
||||
|
||||
### 1. Согласованность замены
|
||||
Сейчас `_mapping` — плоский словарь `строка → строка`. Если в одном файле «Иванов И.И.», а в другом «Иванов Иван Иванович» — это разные ключи, получат разные замены. Как правильно решить?
|
||||
|
||||
### 2. PDF
|
||||
MVP: текст из PDF извлекается, заменяется, отдаётся как `.txt` внутри ZIP. Реальное решение — pymupdf (AGPL) или reportlab? Что посоветуешь?
|
||||
|
||||
### 3. .doc → .docx конвертация
|
||||
Не реализована в этом MVP — файлы .doc просто читаются как текст. Нужно libreoffice (как в `convert_doc.py`) или есть способ проще?
|
||||
|
||||
### 4. LLM-NER
|
||||
Сейчас опционален. Промпт просит JSON-массив сущностей. Не слишком ли медленно для 100+ файлов? Как оптимизировать?
|
||||
|
||||
### 5. Замена в docx через runs
|
||||
python-docx разбивает текст на runs. Слово может быть разбито на несколько runs — замена ломается. Как обойти?
|
||||
|
||||
### 6. Номера договоров
|
||||
Сейчас НЕ заменяются (нужны для группировки). Но это реальные данные — утечка. Как сделать чтобы были уникальные псевдонимы, но группировка не ломалась?
|
||||
|
||||
### 7. Упущенные типы данных
|
||||
Что ещё нужно заменять, чего нет в текущих паттернах? (Колонтитулы? Сноски? Изображения с текстом? Подписи?)
|
||||
|
||||
### 8. Архитектура
|
||||
Код в одном файле drhider.py — нормально для MVP или уже пора разбивать?
|
||||
|
||||
### 9. Edge cases
|
||||
- Пустой файл?
|
||||
- ZIP с вложенным ZIP?
|
||||
- PDF без текста (сканированный)?
|
||||
- Файл с паролем?
|
||||
|
||||
## Ожидаемый ответ
|
||||
По каждому вопросу: **что не так → как исправить → приоритет (MVP/позже/опционально)**.
|
||||
@@ -0,0 +1,412 @@
|
||||
"""
|
||||
DrHider — обфускация документов (двухпроходная, в памяти, без БД).
|
||||
|
||||
Проход 1: собрать все сущности из всех файлов → глобальный словарь замен.
|
||||
Проход 2: применить замены → собрать ZIP с обфусцированными файлами + mapping.csv.
|
||||
|
||||
Согласованность: одна и та же сущность во всех файлах → одно и то же фиктивное значение.
|
||||
"""
|
||||
import io
|
||||
import csv
|
||||
import re
|
||||
import hashlib
|
||||
import random
|
||||
import string
|
||||
import zipfile
|
||||
import logging
|
||||
import tempfile
|
||||
import os
|
||||
import shutil
|
||||
from typing import Dict, List, Tuple, Callable, Optional
|
||||
|
||||
log = logging.getLogger("drhider")
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# Regex-паттерны для обнаружения сущностей
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
ENTITY_PATTERNS: Dict[str, str] = {
|
||||
"phone": r'(?:\+7|8)[\s\-]?\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}',
|
||||
"email": r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}',
|
||||
"inn_ul": r'ИНН\s*\d{10}',
|
||||
"inn_fl": r'ИНН\s*\d{12}',
|
||||
"ogrn": r'ОГРН\s*\d{13}',
|
||||
"kpp": r'КПП\s*\d{9}',
|
||||
"bik": r'БИК\s*\d{9}',
|
||||
"rs": r'(?:р/с|расч[её]тный\s*сч[её]т)\s*\d{20}',
|
||||
"ks": r'(?:к/с|корр?[еи]?спондентский\s*сч[её]т)\s*\d{20}',
|
||||
"passport": r'\d{2}\s*\d{2}\s*\d{6,7}',
|
||||
}
|
||||
|
||||
# Фирмы — обнаружение по шаблону
|
||||
COMPANY_PATTERN = re.compile(
|
||||
r'(?:ООО|ЗАО|ОАО|АО|ПАО|ИП|ТОО)\s+(?:«[^»]+»|"[^"]+"|[А-ЯA-Z][\w\s\-\.]+)',
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# Генераторы фиктивных значений
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
# Словари русских имён
|
||||
RU_SURNAMES = ["Иванов", "Смирнов", "Кузнецов", "Попов", "Васильев", "Петров",
|
||||
"Соколов", "Михайлов", "Новиков", "Фёдоров", "Морозов", "Волков", "Алексеев",
|
||||
"Лебедев", "Семёнов", "Егоров", "Павлов", "Козлов", "Степанов", "Николаев"]
|
||||
RU_NAMES = ["Александр", "Дмитрий", "Сергей", "Андрей", "Алексей", "Максим",
|
||||
"Евгений", "Иван", "Михаил", "Николай", "Владимир", "Павел", "Виктор", "Олег"]
|
||||
RU_PATRONYMICS = ["Александрович", "Дмитриевич", "Сергеевич", "Андреевич",
|
||||
"Алексеевич", "Иванович", "Михайлович", "Николаевич", "Владимирович",
|
||||
"Павлович", "Викторович", "Олегович", "Евгеньевич", "Максимович"]
|
||||
|
||||
RU_CITIES = ["Москва", "Санкт-Петербург", "Новосибирск", "Екатеринбург",
|
||||
"Казань", "Нижний Новгород", "Челябинск", "Самара", "Омск", "Ростов-на-Дону",
|
||||
"Уфа", "Красноярск", "Воронеж", "Пермь", "Волгоград"]
|
||||
RU_STREETS = ["Ленина", "Мира", "Пушкина", "Гагарина", "Советская",
|
||||
"Кирова", "Октябрьская", "Молодёжная", "Садовая", "Центральная"]
|
||||
|
||||
FAKE_DOMAINS = ["example.ru", "mail.test", "company.local", "org.example.ru"]
|
||||
|
||||
|
||||
def _checksum_inn10(inn: str) -> str:
|
||||
"""Контрольная сумма для 10-значного ИНН."""
|
||||
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-значного ИНН."""
|
||||
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))
|
||||
s2 = sum(int(inn[i]) * c2[i] for i in range(11))
|
||||
return str((s1 % 11) % 10) + str((s2 % 11) % 10)
|
||||
|
||||
|
||||
def _checksum_ogrn(ogrn: str) -> str:
|
||||
"""Контрольная сумма для ОГРН (12 цифр → остаток от деления на 11)."""
|
||||
s = int(ogrn) % 11
|
||||
return str(s % 10)
|
||||
|
||||
|
||||
def _random_digits(n: int) -> str:
|
||||
return ''.join(random.choice(string.digits) for _ in range(n))
|
||||
|
||||
|
||||
def _random_letters(n: int) -> str:
|
||||
return ''.join(random.choice(string.ascii_lowercase) for _ in range(n))
|
||||
|
||||
|
||||
def generate_phone(_: str) -> str:
|
||||
code = random.choice(["495", "499", "812", "383", "343"])
|
||||
return f"+7 ({code}) {_random_digits(3)}-{_random_digits(2)}-{_random_digits(2)}"
|
||||
|
||||
|
||||
def generate_email(original: str) -> str:
|
||||
"""Генерирует email с тем же форматом."""
|
||||
domain = random.choice(FAKE_DOMAINS)
|
||||
local = _random_letters(random.randint(5, 10))
|
||||
return f"{local}@{domain}"
|
||||
|
||||
|
||||
def generate_inn10(_: str) -> str:
|
||||
base = f"{random.randint(1,9)}{_random_digits(8)}"
|
||||
return base + _checksum_inn10(base)
|
||||
|
||||
|
||||
def generate_inn12(_: str) -> str:
|
||||
base = f"{random.randint(1,9)}{_random_digits(9)}"
|
||||
return base + _checksum_inn12(base)
|
||||
|
||||
|
||||
def generate_ogrn(_: str) -> str:
|
||||
base = "1" + _random_digits(11)
|
||||
return base + _checksum_ogrn(base)
|
||||
|
||||
|
||||
def generate_kpp(_: str) -> str:
|
||||
return _random_digits(4) + random.choice(["01", "43", "77"]) + _random_digits(3)
|
||||
|
||||
|
||||
def generate_bik(_: str) -> str:
|
||||
return "04" + _random_digits(7)
|
||||
|
||||
|
||||
def generate_rs(_: str) -> str:
|
||||
return "40702" + _random_digits(15)
|
||||
|
||||
|
||||
def generate_ks(_: str) -> str:
|
||||
return "30101" + _random_digits(15)
|
||||
|
||||
|
||||
def generate_passport(_: str) -> str:
|
||||
return f"{random.randint(10,99)} {random.randint(10,99)} {_random_digits(6)}"
|
||||
|
||||
|
||||
def generate_company(_: str) -> str:
|
||||
forms = ["ООО", "ЗАО", "АО"]
|
||||
nouns = ["Технология", "Прогресс", "Гарант", "Стандарт", "Импульс",
|
||||
"Вектор", "Сфера", "Альянс", "Синтез", "Меридиан", "Спектр", "Формат"]
|
||||
return f'{random.choice(forms)} «{random.choice(nouns)}»'
|
||||
|
||||
|
||||
def generate_person(_: str) -> str:
|
||||
s = random.choice(RU_SURNAMES)
|
||||
n = random.choice(RU_NAMES)
|
||||
p = random.choice(RU_PATRONYMICS)
|
||||
return f"{s} {n[0]}.{p[0]}."
|
||||
|
||||
|
||||
def generate_address(_: str) -> str:
|
||||
city = random.choice(RU_CITIES)
|
||||
street = random.choice(RU_STREETS)
|
||||
house = random.randint(1, 200)
|
||||
return f"{city}, ул. {street}, д. {house}"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# Основной класс
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class TwoPassObfuscator:
|
||||
"""Двухпроходный обфускатор: сбор сущностей → замена."""
|
||||
|
||||
def __init__(self, llm_client=None):
|
||||
self._mapping: Dict[str, str] = {} # оригинал → замена (только для точных текстовых совпадений)
|
||||
self._regex_replacements: List[Tuple[str, str, Callable]] = [] # (pattern, type, generator)
|
||||
self._llm_client = llm_client
|
||||
self._workdir: Optional[str] = None
|
||||
|
||||
def obfuscate(self, files: List[Tuple[str, bytes, str]]) -> Tuple[bytes, str]:
|
||||
"""
|
||||
Главная точка входа.
|
||||
|
||||
Args:
|
||||
files: [(original_filename, content_bytes, content_type), ...]
|
||||
|
||||
Returns:
|
||||
(zip_bytes, mapping_csv_string)
|
||||
"""
|
||||
self._workdir = tempfile.mkdtemp(prefix="drhider_")
|
||||
try:
|
||||
# --- Проход 1: сбор сущностей ---
|
||||
all_texts: Dict[str, str] = {} # filename → text for LLM NER
|
||||
all_docx: Dict[str, object] = {} # filename → docx Document for replacement
|
||||
|
||||
for fname, content, ctype in files:
|
||||
text, doc = self._extract_text(fname, content, ctype)
|
||||
all_texts[fname] = text
|
||||
if doc is not None:
|
||||
all_docx[fname] = doc
|
||||
self._scan_regex(text)
|
||||
|
||||
# LLM NER для имён/адресов (если есть клиент)
|
||||
if self._llm_client:
|
||||
self._scan_llm_ner(all_texts)
|
||||
|
||||
# --- Проход 2: замена ---
|
||||
results = []
|
||||
for fname, content, ctype in files:
|
||||
obf_content = content
|
||||
if fname in all_docx:
|
||||
# Замена внутри docx
|
||||
obf_content = self._replace_in_docx(all_docx[fname])
|
||||
elif fname.endswith('.pdf'):
|
||||
# PDF: read-only, создаём новый
|
||||
obf_content = self._replace_in_text(all_texts.get(fname, ''), fname)
|
||||
else:
|
||||
# .doc или другой — замена по тексту
|
||||
txt = all_texts.get(fname, '')
|
||||
obf_content = self._replace_in_text(txt, fname).encode('utf-8')
|
||||
|
||||
results.append((fname, obf_content))
|
||||
|
||||
# Собираем ZIP + CSV
|
||||
return self._build_zip(results), self._build_mapping_csv()
|
||||
|
||||
finally:
|
||||
shutil.rmtree(self._workdir, ignore_errors=True)
|
||||
self._mapping.clear()
|
||||
self._regex_replacements.clear()
|
||||
|
||||
# --- Проход 1: обнаружение ---
|
||||
|
||||
def _extract_text(self, fname: str, content: bytes, ctype: str) -> Tuple[str, Optional[object]]:
|
||||
"""Извлечь текст из файла. Возвращает (text, docx_document_or_None)."""
|
||||
doc = None
|
||||
text = ""
|
||||
|
||||
ext = os.path.splitext(fname)[1].lower()
|
||||
|
||||
if ext == '.docx':
|
||||
from docx import Document
|
||||
doc = Document(io.BytesIO(content))
|
||||
text = "\n".join(p.text for p in doc.paragraphs)
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
text += "\n" + " | ".join(cell.text for cell in row.cells)
|
||||
|
||||
elif ext == '.pdf':
|
||||
import pdfplumber
|
||||
with pdfplumber.open(io.BytesIO(content)) as pdf:
|
||||
for page in pdf.pages:
|
||||
t = page.extract_text()
|
||||
if t:
|
||||
text += t + "\n"
|
||||
for table in page.extract_tables():
|
||||
for row in table:
|
||||
text += "\n" + " | ".join(str(c) if c else "" for c in row)
|
||||
|
||||
elif ext == '.doc':
|
||||
# .doc конвертируется в .docx ДО вызова — здесь уже docx
|
||||
pass
|
||||
|
||||
else:
|
||||
text = content.decode('utf-8', errors='replace')
|
||||
|
||||
return text, doc
|
||||
|
||||
def _scan_regex(self, text: str):
|
||||
"""Сканировать текст regex-паттернами, заполнить словарь замен."""
|
||||
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 original and original not in self._mapping:
|
||||
generator = ENTITY_GENERATORS.get(entity_type, lambda x: "XXX")
|
||||
self._mapping[original] = generator(original)
|
||||
|
||||
# Компании
|
||||
for match in COMPANY_PATTERN.finditer(text):
|
||||
original = match.group(0).strip()
|
||||
if original and original not in self._mapping:
|
||||
self._mapping[original] = generate_company(original)
|
||||
|
||||
def _scan_llm_ner(self, all_texts: Dict[str, str]):
|
||||
"""LLM NER для обнаружения имён и адресов во всех файлах."""
|
||||
combined = "\n\n---FILE---\n\n".join(
|
||||
f"FILE: {fname}\n{t[:3000]}" for fname, t in all_texts.items()
|
||||
)
|
||||
prompt = (
|
||||
"Ты — система обнаружения персональных данных в документах. "
|
||||
"Найди ВСЕ следующие сущности в тексте ниже:\n\n"
|
||||
"1. ФИО (полные и сокращённые — 'Иванов И.И.', 'Петров А.С.')\n"
|
||||
"2. Названия компаний-контрагентов (не 'НУБЕС')\n"
|
||||
"3. Почтовые адреса\n"
|
||||
"4. Паспортные данные\n\n"
|
||||
"Формат ответа — JSON-массив:\n"
|
||||
'[{"type": "person"|"company"|"address"|"passport", "value": "найденный текст"}]\n\n'
|
||||
f"Текст:\n{combined[:8000]}"
|
||||
)
|
||||
try:
|
||||
raw = self._llm_client.complete(prompt)
|
||||
import json
|
||||
# Игнорируем markdown-обёртку
|
||||
raw = raw.strip()
|
||||
if raw.startswith("```"):
|
||||
raw = raw.split("\n", 1)[1]
|
||||
if raw.endswith("```"):
|
||||
raw = raw[:-3]
|
||||
entities = json.loads(raw)
|
||||
for ent in entities:
|
||||
val = ent.get("value", "").strip()
|
||||
if val and val not in self._mapping:
|
||||
if ent.get("type") == "person":
|
||||
self._mapping[val] = generate_person(val)
|
||||
elif ent.get("type") == "company":
|
||||
self._mapping[val] = generate_company(val)
|
||||
elif ent.get("type") == "address":
|
||||
self._mapping[val] = generate_address(val)
|
||||
elif ent.get("type") == "passport":
|
||||
self._mapping[val] = generate_passport(val)
|
||||
except Exception as e:
|
||||
log.warning("LLM NER failed: %s", e)
|
||||
|
||||
# --- Проход 2: замена ---
|
||||
|
||||
def _replace_in_docx(self, doc) -> bytes:
|
||||
"""Заменить сущности в docx-документе (in-place)."""
|
||||
# Замена в параграфах
|
||||
for para in doc.paragraphs:
|
||||
for run in para.runs:
|
||||
run.text = self._apply_replacements(run.text)
|
||||
|
||||
# Замена в таблицах
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
for para in cell.paragraphs:
|
||||
for run in para.runs:
|
||||
run.text = self._apply_replacements(run.text)
|
||||
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
def _replace_in_text(self, text: str, fname: str) -> bytes:
|
||||
"""Заменить сущности в plain text (для PDF и прочих)."""
|
||||
replaced = self._apply_replacements(text)
|
||||
# Для PDF пока отдаём текст (MVP — без сохранения форматирования PDF)
|
||||
return replaced.encode('utf-8')
|
||||
|
||||
def _apply_replacements(self, text: str) -> str:
|
||||
"""Применить все замены из словаря mapping к строке. Сначала длинные, потом короткие."""
|
||||
# Сортируем по длине оригинала (убывание) чтобы избежать частичных замен
|
||||
sorted_keys = sorted(self._mapping.keys(), key=len, reverse=True)
|
||||
result = text
|
||||
for original in sorted_keys:
|
||||
replacement = self._mapping[original]
|
||||
# Только точное совпадение (не подстрока)
|
||||
result = re.sub(re.escape(original), replacement, result)
|
||||
return result
|
||||
|
||||
# --- Сборка выдачи ---
|
||||
|
||||
def _build_zip(self, files: List[Tuple[str, bytes]]) -> bytes:
|
||||
"""Собрать ZIP с обфусцированными файлами."""
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for fname, content in files:
|
||||
zf.writestr(fname, content)
|
||||
return buf.getvalue()
|
||||
|
||||
def _build_mapping_csv(self) -> str:
|
||||
"""Собрать mapping.csv."""
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(["тип_данных", "оригинал", "замена"])
|
||||
for original, replacement in sorted(self._mapping.items()):
|
||||
# Определяем тип по паттерну
|
||||
etype = "text"
|
||||
for t, pat in ENTITY_PATTERNS.items():
|
||||
if re.match(pat, original, re.IGNORECASE):
|
||||
etype = t
|
||||
break
|
||||
if COMPANY_PATTERN.match(original):
|
||||
etype = "company"
|
||||
writer.writerow([etype, original, replacement])
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# Маппинг entity_type → генератор
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
ENTITY_GENERATORS: Dict[str, Callable] = {
|
||||
"phone": generate_phone,
|
||||
"email": generate_email,
|
||||
"inn_ul": generate_inn10,
|
||||
"inn_fl": generate_inn12,
|
||||
"ogrn": generate_ogrn,
|
||||
"kpp": generate_kpp,
|
||||
"bik": generate_bik,
|
||||
"rs": generate_rs,
|
||||
"ks": generate_ks,
|
||||
"passport": generate_passport,
|
||||
}
|
||||
|
||||
|
||||
def obfuscate_files(files: List[Tuple[str, bytes, str]], llm_client=None) -> Tuple[bytes, str]:
|
||||
"""Удобная функция: обфусцировать список файлов → (zip_bytes, csv_string)."""
|
||||
obf = TwoPassObfuscator(llm_client=llm_client)
|
||||
return obf.obfuscate(files)
|
||||
+49
@@ -103,6 +103,55 @@ def ci_cd():
|
||||
return render_template("ci-cd.html")
|
||||
|
||||
|
||||
@app.route("/DrHider")
|
||||
def drhider():
|
||||
return render_template("drhider.html")
|
||||
|
||||
|
||||
@app.route("/api/drhider", methods=["POST"])
|
||||
def api_drhider():
|
||||
"""Обфускация: файлы → ZIP с обезличенными копиями + mapping.csv."""
|
||||
import io
|
||||
import zipfile
|
||||
from flask import send_file
|
||||
|
||||
uploaded = request.files.getlist("files")
|
||||
if not uploaded:
|
||||
return jsonify({"ok": False, "error": "Нет файлов"}), 400
|
||||
|
||||
files = []
|
||||
for f in uploaded:
|
||||
if not f.filename:
|
||||
continue
|
||||
content = f.read()
|
||||
files.append((f.filename, content, f.content_type or ""))
|
||||
|
||||
if not files:
|
||||
return jsonify({"ok": False, "error": "Нет файлов"}), 400
|
||||
|
||||
try:
|
||||
from services.drhider import obfuscate_files
|
||||
from services.llm_client import HttpxLLMClient
|
||||
|
||||
# LLM-клиент для NER (если настроен)
|
||||
llm = HttpxLLMClient(LLM_URL, LLM_KEY, LLM_MODEL) if LLM_KEY else None
|
||||
|
||||
zip_data, csv_data = obfuscate_files(files, llm_client=llm)
|
||||
|
||||
buf = io.BytesIO()
|
||||
buf.write(zip_data)
|
||||
buf.seek(0)
|
||||
|
||||
return send_file(
|
||||
buf,
|
||||
mimetype="application/zip",
|
||||
as_attachment=True,
|
||||
download_name="drhider_output.zip"
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
return {"ok": True, "service": "contracts-flask"}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DrHider — обфускация документов</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117; --fg: #c9d1d9; --muted: #8b949e;
|
||||
--accent: #58a6ff; --green: #3fb950; --red: #f85149; --border: #30363d;
|
||||
--card-bg: #161b22; --orange: #d2991d;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font: 15px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--fg); max-width: 700px; margin: 0 auto; padding: 32px 20px 60px; }
|
||||
h1 { font-size: 22px; margin-bottom: 4px; }
|
||||
.sub { color: var(--muted); font-size: 14px; margin-bottom: 24px; }
|
||||
.nav { margin-bottom: 20px; }
|
||||
.nav a { color: var(--accent); text-decoration: none; font-size: 13px; }
|
||||
.nav a:hover { text-decoration: underline; }
|
||||
|
||||
#dropZone {
|
||||
border: 2px dashed var(--border); border-radius: 12px; padding: 40px 20px;
|
||||
text-align: center; cursor: pointer; transition: all .2s;
|
||||
background: var(--card-bg); margin-bottom: 16px;
|
||||
}
|
||||
#dropZone:hover, #dropZone.active { border-color: var(--accent); background: #0d1a28; }
|
||||
#dropZone p { color: var(--muted); margin: 4px 0; font-size: 14px; }
|
||||
#dropZone .big { font-size: 36px; }
|
||||
|
||||
#fileList { display: none; margin: 16px 0; }
|
||||
#fileList.show { display: block; }
|
||||
.file-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; margin-bottom: 6px; font-size: 13px; }
|
||||
.file-row .name { flex: 1; }
|
||||
.file-row .size { color: var(--muted); }
|
||||
.file-row .remove { cursor: pointer; color: var(--red); font-weight: bold; }
|
||||
|
||||
#status { display: none; margin: 16px 0; padding: 12px; border-radius: 8px; font-size: 13px; }
|
||||
#status.show { display: block; }
|
||||
#status.progress { background: #1a2a3a; border: 1px solid var(--accent); color: var(--accent); }
|
||||
#status.done { background: #1a2a1a; border: 1px solid var(--green); color: var(--green); }
|
||||
#status.error { background: #2a1a1a; border: 1px solid var(--red); color: var(--red); }
|
||||
|
||||
button {
|
||||
padding: 10px 24px; border: none; border-radius: 8px; cursor: pointer;
|
||||
font-size: 14px; font-weight: 600; transition: all .2s;
|
||||
}
|
||||
.btn-go { background: var(--green); color: #000; width: 100%; margin-top: 12px; }
|
||||
.btn-go:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-go:hover:not(:disabled) { background: #4cd964; }
|
||||
|
||||
.note { margin-top: 24px; padding: 12px 16px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; font-size: 12px; color: var(--muted); }
|
||||
.note strong { color: var(--orange); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="nav"><a href="/">← Основной сервис</a></div>
|
||||
|
||||
<h1>🛡️ DrHider — обфускация документов</h1>
|
||||
<p class="sub">Загрузите документы — получите ZIP с обезличенными копиями и таблицей соответствия. Без сохранения на сервере.</p>
|
||||
|
||||
<div id="dropZone">
|
||||
<div class="big">📁</div>
|
||||
<p>Выберите файлы или перетащите сюда</p>
|
||||
<p style="font-size:12px;">.docx .pdf .doc .zip</p>
|
||||
<input type="file" id="fileInput" multiple accept=".docx,.pdf,.doc,.zip" style="display:none">
|
||||
</div>
|
||||
|
||||
<div id="fileList"></div>
|
||||
|
||||
<button class="btn-go" id="btnGo" disabled>Обфусцировать</button>
|
||||
|
||||
<div id="status"></div>
|
||||
|
||||
<div class="note">
|
||||
<strong>⚡ Всё в памяти.</strong> Файлы не сохраняются на сервер. После обработки данные удаляются.<br>
|
||||
<strong>📋 mapping.csv</strong> — таблица «оригинал → замена» внутри ZIP.<br>
|
||||
<strong>🔒 Результат</strong> — обезличенные файлы можно загружать в основной сервис сверки.
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const DZ = document.getElementById('dropZone');
|
||||
const INPUT = document.getElementById('fileInput');
|
||||
const LIST = document.getElementById('fileList');
|
||||
const BTN = document.getElementById('btnGo');
|
||||
const STATUS = document.getElementById('status');
|
||||
let files = [];
|
||||
|
||||
DZ.onclick = () => INPUT.click();
|
||||
INPUT.onchange = () => { addFiles(INPUT.files); INPUT.value = ''; };
|
||||
|
||||
DZ.ondragover = e => { e.preventDefault(); DZ.classList.add('active'); };
|
||||
DZ.ondragleave = () => DZ.classList.remove('active');
|
||||
DZ.ondrop = e => { e.preventDefault(); DZ.classList.remove('active'); addFiles(e.dataTransfer.files); };
|
||||
|
||||
function addFiles(fl) {
|
||||
for (let f of fl) {
|
||||
if (files.find(x => x.name === f.name && x.size === f.size)) continue;
|
||||
files.push(f);
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function remove(i) { files.splice(i, 1); render(); }
|
||||
|
||||
function render() {
|
||||
LIST.innerHTML = files.map((f, i) =>
|
||||
`<div class="file-row"><span class="name">${esc(f.name)}</span><span class="size">${fmt(f.size)}</span><span class="remove" onclick="remove(${i})">✕</span></div>`
|
||||
).join('');
|
||||
LIST.className = files.length ? 'show' : '';
|
||||
BTN.disabled = files.length === 0;
|
||||
}
|
||||
|
||||
BTN.onclick = async () => {
|
||||
if (!files.length) return;
|
||||
BTN.disabled = true;
|
||||
setStatus('progress', '⏳ Обработка...');
|
||||
|
||||
const fd = new FormData();
|
||||
files.forEach(f => fd.append('files', f));
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/drhider', { method: 'POST', body: fd });
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'drhider_output.zip';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
setStatus('done', '✅ Готово! ZIP скачан. Файлы обезличены, mapping.csv внутри.');
|
||||
} catch (e) {
|
||||
setStatus('error', '❌ Ошибка: ' + e.message);
|
||||
}
|
||||
BTN.disabled = false;
|
||||
};
|
||||
|
||||
function setStatus(cls, msg) { STATUS.className = 'show ' + cls; STATUS.textContent = msg; }
|
||||
function esc(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function fmt(n) { return n > 1e6 ? (n/1e6).toFixed(1)+' MB' : n > 1e3 ? (n/1e3).toFixed(0)+' KB' : n+' B'; }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user