From 2450c0c9a17885bae8dcc600a120511079a9aace Mon Sep 17 00:00:00 2001 From: Repinoid Date: Wed, 21 Oct 2026 12:00:44 +0300 Subject: [PATCH] Initial deployable Flask application --- .gitignore | 34 + drhider/__init__.py | 13 + drhider/builder.py | 113 ++ drhider/config.py | 116 ++ drhider/extractor.py | 357 +++++ drhider/llm_client.py | 106 ++ drhider/obfuscator.py | 291 +++++ drhider/replacer.py | 166 +++ drhider/scanner.py | 294 +++++ requirements.txt | 5 + site/app.py | 101 ++ site/routes/__init__.py | 25 + site/routes/api_bp.py | 362 ++++++ site/routes/health_bp.py | 27 + site/routes/main_bp.py | 48 + site/static/dist/file-picker.esm.js | 1194 +++++++++++++++++ site/static/dist/file-picker.iife.js | 1218 ++++++++++++++++++ site/static/favicon.svg | 25 + site/static/logo.svg | 25 + site/templates/index.html | 958 ++++++++++++++ upload/__init__.py | 5 + upload/backend/__init__.py | 1 + upload/backend/session/__init__.py | 38 + upload/backend/session/add_file.py | 25 + upload/backend/session/cancel.py | 27 + upload/backend/session/cleanup.py | 11 + upload/backend/session/create_session.py | 23 + upload/backend/session/get_files.py | 23 + upload/backend/session/state.py | 45 + upload/backend/session/store_csv.py | 30 + upload/backend/session/store_result.py | 30 + upload/backend/session/ttl.py | 34 + upload/backend/upload_refs/__init__.py | 7 + upload/backend/upload_refs/blueprint.py | 145 +++ upload/backend/upload_refs/config.py | 11 + upload/backend/upload_refs/pull_file.py | 39 + upload/backend/upload_refs/safe_name.py | 16 + upload/frontend/index.js | 193 +++ upload/frontend/table/add_file_with_dedup.js | 54 + upload/frontend/table/esc.js | 18 + upload/frontend/table/fs.js | 11 + upload/frontend/table/on_files_change.js | 72 ++ upload/frontend/table/on_folder_change.js | 84 ++ upload/frontend/table/rebase_tree.js | 16 + upload/frontend/table/render.js | 124 ++ upload/frontend/upload/put_to_vm.js | 78 ++ upload/frontend/upload/upload_via_vm.js | 188 +++ upload/frontend/zip/list_zip_files.js | 125 ++ 48 files changed, 6951 insertions(+) create mode 100644 .gitignore create mode 100644 drhider/__init__.py create mode 100644 drhider/builder.py create mode 100644 drhider/config.py create mode 100644 drhider/extractor.py create mode 100644 drhider/llm_client.py create mode 100644 drhider/obfuscator.py create mode 100644 drhider/replacer.py create mode 100644 drhider/scanner.py create mode 100644 requirements.txt create mode 100644 site/app.py create mode 100644 site/routes/__init__.py create mode 100644 site/routes/api_bp.py create mode 100644 site/routes/health_bp.py create mode 100644 site/routes/main_bp.py create mode 100644 site/static/dist/file-picker.esm.js create mode 100644 site/static/dist/file-picker.iife.js create mode 100644 site/static/favicon.svg create mode 100755 site/static/logo.svg create mode 100644 site/templates/index.html create mode 100644 upload/__init__.py create mode 100644 upload/backend/__init__.py create mode 100644 upload/backend/session/__init__.py create mode 100644 upload/backend/session/add_file.py create mode 100644 upload/backend/session/cancel.py create mode 100644 upload/backend/session/cleanup.py create mode 100644 upload/backend/session/create_session.py create mode 100644 upload/backend/session/get_files.py create mode 100644 upload/backend/session/state.py create mode 100644 upload/backend/session/store_csv.py create mode 100644 upload/backend/session/store_result.py create mode 100644 upload/backend/session/ttl.py create mode 100644 upload/backend/upload_refs/__init__.py create mode 100644 upload/backend/upload_refs/blueprint.py create mode 100644 upload/backend/upload_refs/config.py create mode 100644 upload/backend/upload_refs/pull_file.py create mode 100644 upload/backend/upload_refs/safe_name.py create mode 100644 upload/frontend/index.js create mode 100644 upload/frontend/table/add_file_with_dedup.js create mode 100644 upload/frontend/table/esc.js create mode 100644 upload/frontend/table/fs.js create mode 100644 upload/frontend/table/on_files_change.js create mode 100644 upload/frontend/table/on_folder_change.js create mode 100644 upload/frontend/table/rebase_tree.js create mode 100644 upload/frontend/table/render.js create mode 100644 upload/frontend/upload/put_to_vm.js create mode 100644 upload/frontend/upload/upload_via_vm.js create mode 100644 upload/frontend/zip/list_zip_files.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b36f1f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +__pycache__/ +*.pyc +.env +.env.* +!.env.example +venv/ +.venv/ +env/ +ENV/ +instance/ +.flaskenv +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +coverage.xml +htmlcov/ +*.log +build/ +dist/ +!dist/ +!dist/file-picker.esm.js +!dist/file-picker.iife.js +*.egg-info/ +# TMP/ +# about1500.md +*.har +chrome-net-export-log.json +History/tests/2026-09-15-*.md +tests/e2e_mass_processing.py +TSTFILES/ +files/ +upload-platform/ +.agent-tmp/ diff --git a/drhider/__init__.py b/drhider/__init__.py new file mode 100644 index 0000000..64ace7c --- /dev/null +++ b/drhider/__init__.py @@ -0,0 +1,13 @@ +""" +DrHider — обфускация документов (двухпроходная, в памяти, без БД). + +Проход 1: собрать все сущности из всех файлов → глобальный словарь замен. +Проход 2: применить замены → собрать ZIP с обфусцированными файлами + mapping.csv. + +Согласованность: одна и та же сущность во всех файлах → одно и то же фиктивное значение. +""" + +from .obfuscator import TwoPassObfuscator, obfuscate_files +from .llm_client import LLMClient + +__all__ = ["TwoPassObfuscator", "obfuscate_files", "LLMClient"] diff --git a/drhider/builder.py b/drhider/builder.py new file mode 100644 index 0000000..7566b6c --- /dev/null +++ b/drhider/builder.py @@ -0,0 +1,113 @@ +""" +Сборка результата обфускации. + +Два метода: +1. _build_zip — упаковка обфусцированных файлов в ZIP +2. _build_mapping_csv — генерация mapping.csv с таблицей замен +""" + +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 + + +def build_zip(files: List[Tuple[str, bytes]], mapping_csv: str = "") -> bytes: + """Собрать ZIP-архив с обфусцированными файлами. + + В архив добавляются ТОЛЬКО обфусцированные файлы (с оригинальными именами). + mapping.csv в архив НЕ кладётся — это таблица соответствия оригинал→замена, + т.е. ключ расшифровки (секретные данные). Он хранится/скачивается отдельно + (/api/csv), чтобы нельзя было расшифровать архив, получив один ZIP. + + Имена файлов в архиве — UTF-8 (бит 11 в flag_bits). + + Args: + files: [(filename, content_bytes), ...] + 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.flag_bits |= 0x800 # Флаг: имя файла в UTF-8 + zf.writestr(info, content) + + return buf.getvalue() + + +def build_mapping_csv(mapping: Dict[str, str]) -> str: + """Собрать mapping.csv — таблицу соответствия оригинал → замена. + + Колонки: + - тип_данных: тип сущности (phone, email, inn_ul, company, ...) + - оригинал: исходное значение из документа + - замена: фиктивное значение + + Тип определяется проверкой каждого значения через ENTITY_PATTERNS + и COMPANY_PATTERN. Если ни один паттерн не подошёл — тип "text". + + Args: + mapping: Словарь {оригинал: замена} + + Returns: + Строка в формате CSV + """ + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(["тип_данных", "оригинал", "замена"]) + + # Порядок проверки типов: inn_fl ДО inn_ul (12 цифр vs 10) + type_order = [ + "phone", "email", "inn_fl", "inn_ul", "ogrn", + "kpp", "bik", "rs", "ks", "passport", + ] + + for original, replacement in sorted(mapping.items()): + # Определяем тип сущности + etype = "text" # По умолчанию + + for t in type_order: + pat = ENTITY_PATTERNS.get(t, "") + if pat and re.match(pat, original, re.IGNORECASE): + etype = t + break + + # Компании проверяем отдельно (COMPANY_PATTERN не в ENTITY_PATTERNS) + if COMPANY_PATTERN.match(original): + etype = "company" + + writer.writerow([etype, original, replacement]) + + return buf.getvalue() diff --git a/drhider/config.py b/drhider/config.py new file mode 100644 index 0000000..90d097b --- /dev/null +++ b/drhider/config.py @@ -0,0 +1,116 @@ +""" +Константы и словари для DrHider. + +Содержит: +- ENTITY_PATTERNS — regex-паттерны для обнаружения сущностей +- COMPANY_PATTERN — паттерн для обнаружения названий компаний +- PERSON_PATTERN — паттерн для обнаружения ФИО +- Словари русских имён, городов, улиц +- Список фиктивных доменов для email +""" + +import re + +# ═══════════════════════════════════════════════════════════════════════════ +# Regex-паттерны для обнаружения сущностей в тексте документов +# ═══════════════════════════════════════════════════════════════════════════ + +ENTITY_PATTERNS = { + "phone": r'(? 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. + + Args: + fname: Имя файла (с расширением) + content: Бинарное содержимое файла + ctype: MIME-тип (зарезервировано, не используется) + + Returns: + Строка в формате Markdown + """ + ext = os.path.splitext(fname)[1].lower() + + # ── .docx: полный Markdown с форматированием ── + if ext == '.docx': + try: + return docx_to_markdown(content) + except ImportError: + return content.decode('utf-8', errors='replace') + + # ── .pdf: текст + таблицы ── + elif ext == '.pdf': + try: + return pdf_to_markdown(content) + except ImportError: + return content.decode('utf-8', errors='replace') + + # ── .doc: бинарный → LibreOffice → DOCX → Markdown ── + elif ext == '.doc': + return doc_to_markdown(content) + + # ── .txt и прочие ── + else: + return content.decode('utf-8', errors='replace') + + +# ═══════════════════════════════════════════════════════════════════════════ +# ZIP-распаковка (без изменений) +# ═══════════════════════════════════════════════════════════════════════════ + +def expand_zips(files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]: + """Распаковать ZIP-файлы в списке, заменив их содержимым. + + Не-ZIP файлы проходят без изменений. + + Защита от ZIP-бомб: + - Максимум 500 файлов в архиве + - Ratio file_size/compress_size не более 100:1 + - Накопительный размер распакованных данных не более 500 MB + + Args: + files: [(filename, content_bytes, content_type), ...] + + 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) + + 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: + log.warning("ZIP too many files, skipping: %s", fname) + result.append((fname, content, ctype)) + continue + + # Проверяем лимиты ДО чтения содержимого (защита от бомб) + bomb = False + for info in infos: + 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 bomb: + log.warning("ZIP bomb/limit, skipping: %s", fname) + result.append((fname, content, ctype)) + continue + + for info in infos: + name = _decode_name(info.filename, info) + name = os.path.basename(name) + if not name: + continue + # Синхронизация с фронтом (listZipFiles): из zip берём ТОЛЬКО документы + low = name.lower() + if not (low.endswith('.zip') or low.endswith(('.pdf', '.doc', '.docx', '.txt', '.md'))): + continue + + inner_data = zf.read(info) + total_uncompressed += len(inner_data) + if total_uncompressed > MAX_UNCOMPRESSED: + log.warning("ZIP uncompressed limit exceeded (mid-read): %s", fname) + break + + # Вложенные ZIP добавляем в очередь на повторную распаковку + queue.append((name, inner_data, "")) + + except Exception as e: + log.warning("Failed to expand ZIP %s: %s", fname, e) + result.append((fname, content, ctype)) + + return result diff --git a/drhider/llm_client.py b/drhider/llm_client.py new file mode 100644 index 0000000..c40e4c3 --- /dev/null +++ b/drhider/llm_client.py @@ -0,0 +1,106 @@ +""" +LLM-клиент для DrHider. + +Используется в TwoPassObfuscator._scan_llm_ner() для обнаружения +имён, компаний, адресов и паспортных данных через LLM API. + +Интерфейс: + client = LLMClient() + result = client.complete(prompt) # str +""" + +import os +import time +import httpx + + +class LLMClient: + """Клиент для вызова LLM API (aillm.ru / OpenAI-совместимый). + + Использует переменные окружения: + LLM_API_KEY или LLM_KEY — ключ API + LLM_URL — URL эндпоинта (по умолчанию https://api.aillm.ru/v1/chat/completions) + LLM_MODEL — модель (по умолчанию gpt-oss-120b) + """ + + def __init__(self): + """Инициализировать клиент. httpx импортируется лениво. + + Ведёт кумулятивные счётчики за всё время жизни клиента: + tokens_prompt — суммарные входные токены + tokens_completion — суммарные выходные токены + llm_sec — суммарное время LLM-запросов (сек) + + Плюс флаг текущей активности (для live-таймера в UI): + llm_active — True пока идёт LLM-вызов + llm_started — time.time() начала текущего LLM-вызова + """ + 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 и вернуть текст ответа. + + Args: + prompt: Текст промпта (инструкция + данные для анализа) + + Returns: + Текстовый ответ модели (обычно JSON-строка) + + Raises: + httpx.HTTPError: при ошибке HTTP + KeyError: при неожиданном формате ответа + """ + # Ключ API: сначала LLM_KEY, затем LLM_API_KEY (для совместимости) + key = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "") + + # URL и модель с значениями по умолчанию + 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 diff --git a/drhider/obfuscator.py b/drhider/obfuscator.py new file mode 100644 index 0000000..476a937 --- /dev/null +++ b/drhider/obfuscator.py @@ -0,0 +1,291 @@ +""" +Оркестратор двухпроходной обфускации — класс TwoPassObfuscator. + +Процесс: +1. Проход 1 (сбор): извлечь текст → regex-сканирование → LLM-сканирование +2. Проход 2 (замена): применить mapping ко всем файлам +3. Сборка: ZIP + mapping.csv +""" + +import io +import os +import time +import logging +from typing import Dict, List, Tuple, Callable, Optional + +from . import extractor +from . import scanner +from . import replacer +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: + """Двухпроходный обфускатор документов. + + Проход 1: собрать все сущности из всех файлов → глобальный словарь замен. + Проход 2: применить замены ко всем файлам → ZIP с результатом. + + Согласованность: одна и та же сущность во всех файлах получает + одно и то же фиктивное значение. + + Attributes: + _mapping: {оригинал: замена} — глобальный словарь + _sorted_keys: ключи mapping, отсортированные по длине (убывание) + _llm_client: опциональный LLM-клиент для NER + """ + + def __init__(self, llm_client=None): + """Инициализировать обфускатор. + + Args: + llm_client: Объект с методом .complete(prompt) -> str. + Если None — LLM-сканирование не выполняется. + """ + 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 + + # ═══════════════════════════════════════════════════════════════════ + # Главная точка входа + # ═══════════════════════════════════════════════════════════════════ + + 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]]: + """Обфусцировать список файлов. + + Все форматы → 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}. + + Returns: + (zip_bytes, csv_string, meta): + zip_bytes — ZIP-архив с обфусцированными .md файлами (без mapping.csv) + csv_string — содержимое mapping.csv как строка + meta — None при штатном завершении; {"cancelled": True, "processed", "total"} + при прерывании (частичный результат) + """ + # ── Предобработка: распаковать ZIP + уникализировать имена ── + 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) + + try: + # ── Проход 1: сбор сущностей ── + # Извлекаем Markdown из каждого файла + all_texts: Dict[str, str] = {} + total = len(files) + file_times: List[float] = [0.0] * total + skipped: set = set() # имена файлов, которые не удалось обработать (битые) + + # 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 + all_texts[fname] = text + + # Regex-сканирование (быстрое, локальное) + if text and not text.startswith("[DOC binary"): + scanner.scan_regex(text, self._mapping, self._counters) + file_times[i] += time.time() - t0 + + # Объём извлечённого текста — для оценки времени (глобальной 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 + + # Предсортировать ключи один раз (по убыванию длины) + 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() + 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 + ) + 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 + ) + # Имя файла: заменить расширение на .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 + + finally: + # Очистка состояния (обфускатор может использоваться повторно) + self._mapping.clear() + self._sorted_keys.clear() + self._compiled_re = None + self._counters.clear() + + +# ═══════════════════════════════════════════════════════════════════════ +# Удобная функция для быстрого вызова +# ═══════════════════════════════════════════════════════════════════════ + +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]]: + """Обфусцировать список файлов — удобная функция. + + Создаёт экземпляр TwoPassObfuscator и вызывает .obfuscate(). + + 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) + """ + obf = TwoPassObfuscator(llm_client=llm_client) + return obf.obfuscate(files, progress_cb=progress_cb, + cancel_event=cancel_event, file_progress=file_progress) diff --git a/drhider/replacer.py b/drhider/replacer.py new file mode 100644 index 0000000..8e445e8 --- /dev/null +++ b/drhider/replacer.py @@ -0,0 +1,166 @@ +""" +Проход 2: замена сущностей в документах. + +Два метода: +1. apply_replacements — замена в Markdown/plain-тексте (основной) +2. replace_in_docx — замена в DOCX с сохранением форматирования + (для опционального DOCX-выхода, будет добавлен позже) +""" + +import io +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'(? str: + """Применить все замены из словаря mapping к строке. + + Ключи применяются в порядке убывания длины (sorted_keys). + Это гарантирует, что более длинные совпадения заменяются раньше + коротких (например, «ИНН 123456789012» до «ИНН 1234567890»). + + Для сущностей, начинающихся и заканчивающихся на букву/цифру, + используются границы слова (\\b), чтобы избежать частичных замен. + + Args: + 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) + + return result + + +def replace_in_docx(doc, mapping: Dict[str, str], sorted_keys: List[str]) -> bytes: + """Заменить сущности в DOCX-документе с сохранением форматирования. + + Алгоритм для каждого параграфа: + 1. Склеиваем текст всех runs в одну строку + 2. Применяем замены + 3. Пишем результат в первый run, очищаем остальные + + Это нужно потому что python-docx разбивает текст на runs + (например, mid-docx форматирование), и сущность может быть + разорвана между несколькими runs. + + Args: + doc: Объект python-docx Document + mapping: Словарь замен + sorted_keys: Ключи mapping по убыванию длины + + Returns: + Бинарное содержимое изменённого DOCX-файла + """ + # ── Обработка параграфов ── + for para in doc.paragraphs: + if not para.runs: + continue + # Склеиваем все runs в одну строку + full_text = "".join(run.text for run in para.runs) + replaced = apply_replacements(full_text, mapping, sorted_keys) + + # Если были замены — пишем в первый run, очищаем остальные + if replaced != full_text: + para.runs[0].text = replaced + for run in para.runs[1:]: + run.text = "" + + # ── Обработка таблиц ── + for table in doc.tables: + for row in table.rows: + for cell in row.cells: + for para in cell.paragraphs: + if not para.runs: + continue + full_text = "".join(run.text for run in para.runs) + replaced = apply_replacements(full_text, mapping, sorted_keys) + + if replaced != full_text: + para.runs[0].text = replaced + for run in para.runs[1:]: + run.text = "" + + # Сохраняем в буфер + buf = io.BytesIO() + doc.save(buf) + return buf.getvalue() diff --git a/drhider/scanner.py b/drhider/scanner.py new file mode 100644 index 0000000..283b005 --- /dev/null +++ b/drhider/scanner.py @@ -0,0 +1,294 @@ +""" +Проход 1: обнаружение сущностей в тексте документов. + +Два метода сканирования: +1. scan_regex — быстрое regex-обнаружение (телефоны, email, ИНН, счета, компании, ФИО) +2. scan_llm_ner — универсальное LLM-обнаружение (любые приватные данные) + +Замена: гибридные токены — читаемый шаблон + уникальный номер. +Пример: Иванов_0001, ООО_Технология_0034, ул_Ленина_0015 +""" + +import re +import json +import random +import time +import logging +from concurrent.futures import ThreadPoolExecutor +from typing import Dict, List, Optional + +from .config import ( + ENTITY_PATTERNS, COMPANY_PATTERN, PERSON_PATTERN, + RU_SURNAMES, COMPANY_NOUNS, CITY_POOL, STREET_POOL, +) + +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 гарантирует согласованность). + Разные сущности одного типа получают РАЗНЫЕ шаблоны из пула. + + Args: + entity_type: Тип сущности (person_name, company, phone...) + counters: Глобальные счётчики {prefix_key: count} + + Returns: + Строка вида "Иванов_0001", "ООО_Технология_0034", "[Адрес_0015]" + """ + 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") + + +def scan_regex(text: str, mapping: Dict[str, str], counters: Dict[str, int]) -> None: + """Сканировать текст regex-паттернами, заполнить словарь замен. + + Замена: гибридные токены (шаблон + номер). + + Args: + text: Текст документа для сканирования + mapping: Словарь замен (мутабельный, пополняется) + counters: Глобальные счётчики токенов (мутабельный) + """ + # ── Сущности по 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 not original or original in mapping: + continue + mapping[original] = _next_token(entity_type, counters) + + # ── Названия компаний ── + 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) + + # ── ФИО ── + 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) + + +# ═══════════════════════════════════════════════════════════════════════════ +# LLM-NER: пофайловый чанкинг (Sonnet-схема) +# ═══════════════════════════════════════════════════════════════════════════ + +class CancelRequested(Exception): + """Обработка прервана пользователем (мягкая остановка между файлами LLM).""" + +# Приоритет границ для чанков (от предпочтительных к жёстким) +_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-найденное не дублируется. + + 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 + ) + + for fname, full_text in file_items: + # Мягкая остановка ТОЛЬКО между файлами — текущий файл добирается до конца + if cancel_event is not None and cancel_event.is_set(): + raise CancelRequested() + + if not full_text or full_text.startswith("[DOC binary"): + continue + chunks = split_into_chunks(full_text) + + if file_progress: + file_progress("file_start", fname, chars=len(full_text), chunks=len(chunks)) + + t0 = time.time() + + # Параллельные вызовы 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: + 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)) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b6541a0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +flask +waitress +python-docx +pdfplumber +httpx diff --git a/site/app.py b/site/app.py new file mode 100644 index 0000000..9d4aef4 --- /dev/null +++ b/site/app.py @@ -0,0 +1,101 @@ +""" +DrHider — Managed Flask приложение на платформе Штурвал. + +Приложение создаётся фабрикой create_app(). +Штурвал запускает Flask самостоятельно (без Dockerfile/gunicorn). + +Роуты разнесены по blueprint'ам: +- main_bp: GET / — веб-интерфейс +- health_bp: GET /health — проверка живости +- api_bp: POST /api/drhider — обфускация документов +""" + +import os +import sys +import logging +from flask import Flask + +# Добавляем корень проекта в sys.path для импорта пакета drhider +_sys_path_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _sys_path_root not in sys.path: + sys.path.insert(0, _sys_path_root) + +# Версия приложения (меняется при изменениях) +VERSION = "0.0.79" + + +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) + + +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 (лимит на один файл) + + # Регистрируем 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) + + + diff --git a/site/routes/__init__.py b/site/routes/__init__.py new file mode 100644 index 0000000..2ae6f41 --- /dev/null +++ b/site/routes/__init__.py @@ -0,0 +1,25 @@ +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): + """Зарегистрировать все blueprint'ы на Flask-приложении. + + Args: + app: Экземпляр Flask-приложения + """ + 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, + })) diff --git a/site/routes/api_bp.py b/site/routes/api_bp.py new file mode 100644 index 0000000..9fba0d9 --- /dev/null +++ b/site/routes/api_bp.py @@ -0,0 +1,362 @@ +""" +Blueprint: API DrHider. + +Five endpoints: +- POST /api/upload — upload one file -> {session_id} +- GET /api/process_stream/ — SSE: process files, per-file progress +- POST /api/process/ — process all session files -> {status:"done"} (legacy) +- GET /api/download/ — download ZIP (with timestamp name) +- GET /api/csv/ — download CSV separately +""" + +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 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 + +api_bp = Blueprint("api", __name__, url_prefix="/api") +log = logging.getLogger("routes.api_bp") + +def _disconnect_exceptions(): + """Исключения, означающие отключение клиента SSE.""" + return (GeneratorExit, BrokenPipeError, ConnectionResetError) + + +@api_bp.route("/upload", methods=["POST"]) +def upload(): + """Upload files to session (один или несколько).""" + sid = request.form.get("session", "") + if not sid: + sid = create_session() + uploaded = request.files.getlist("files") + if not uploaded: + log.warning("upload: no files, sid=%s", sid) + return jsonify({"ok": False, "error": "No file"}), 400 + + added = 0 + had_unnamed = False + 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 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/", 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/", 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/", 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 + + 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/", 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 = 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"}) + except Exception as e: + traceback.print_exc() + return jsonify({"ok": False, "error": str(e)}), 500 + + +@api_bp.route("/download/", 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/", 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") diff --git a/site/routes/health_bp.py b/site/routes/health_bp.py new file mode 100644 index 0000000..c1eacc4 --- /dev/null +++ b/site/routes/health_bp.py @@ -0,0 +1,27 @@ +""" +Blueprint: health-check (GET /health). + +Используется платформой Штурвал для проверки живости приложения. +""" + +from flask import Blueprint, jsonify, current_app + +# ═══════════════════════════════════════════════════════════════════════════ +# Blueprint: health check +# ═══════════════════════════════════════════════════════════════════════════ + +health_bp = Blueprint("health", __name__) + + +@health_bp.route("/health") +def health(): + """Эндпоинт проверки живости. + + Возвращает JSON с версией приложения. + Используется платформой для readiness/liveness probes. + + Returns: + {"ok": true, "version": "X.Y.Z"} + """ + version = current_app.config.get("VERSION", "0.0.0") + return jsonify({"ok": True, "version": version}) diff --git a/site/routes/main_bp.py b/site/routes/main_bp.py new file mode 100644 index 0000000..a8e01f2 --- /dev/null +++ b/site/routes/main_bp.py @@ -0,0 +1,48 @@ +""" +Blueprint: главная страница (GET /). + +Отдаёт HTML-интерфейс DrHider. +""" + +import os + +from flask import Blueprint, render_template, current_app, send_from_directory + +# ═══════════════════════════════════════════════════════════════════════════ +# Blueprint: главная страница +# ═══════════════════════════════════════════════════════════════════════════ + +# Корень модуля upload/frontend — для раздачи ES-модулей браузеру +_ROOT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +_UPLOAD_FRONTEND_DIR = os.path.join(_ROOT_DIR, "upload", "frontend") +_DIST_DIR = os.path.join(_ROOT_DIR, "dist") + +main_bp = Blueprint("main", __name__) + + +@main_bp.route("/") +def index(): + """Главная страница — веб-интерфейс DrHider. + + Передаёт в шаблон текущую версию приложения. + """ + version = current_app.config.get("VERSION", "0.0.0") + return render_template("index.html", version=version) + + +@main_bp.route("/upload/") +def upload_frontend(filename): + """Раздаёт ES-модули переиспользуемого слоя загрузки (upload/frontend).""" + return send_from_directory(_UPLOAD_FRONTEND_DIR, filename) + + +@main_bp.route("/file-picker/") +def file_picker_dist(filename): + """Отдаёт собранный бандл из каталога dist.""" + return send_from_directory(_DIST_DIR, filename) + + +@main_bp.route("/dist/") +def dist_files(filename): + """Отдаёт файлы из каталога dist.""" + return send_from_directory(_DIST_DIR, filename) diff --git a/site/static/dist/file-picker.esm.js b/site/static/dist/file-picker.esm.js new file mode 100644 index 0000000..d9f7ac1 --- /dev/null +++ b/site/static/dist/file-picker.esm.js @@ -0,0 +1,1194 @@ +// node_modules/fflate/esm/browser.js +var u8 = Uint8Array; +var u16 = Uint16Array; +var i32 = Int32Array; +var fleb = new u8([ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 2, + 2, + 2, + 2, + 3, + 3, + 3, + 3, + 4, + 4, + 4, + 4, + 5, + 5, + 5, + 5, + 0, + /* unused */ + 0, + 0, + /* impossible */ + 0 +]); +var fdeb = new u8([ + 0, + 0, + 0, + 0, + 1, + 1, + 2, + 2, + 3, + 3, + 4, + 4, + 5, + 5, + 6, + 6, + 7, + 7, + 8, + 8, + 9, + 9, + 10, + 10, + 11, + 11, + 12, + 12, + 13, + 13, + /* unused */ + 0, + 0 +]); +var clim = new u8([16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]); +var freb = function(eb, start) { + var b = new u16(31); + for (var i = 0; i < 31; ++i) { + b[i] = start += 1 << eb[i - 1]; + } + var r = new i32(b[30]); + for (var i = 1; i < 30; ++i) { + for (var j = b[i]; j < b[i + 1]; ++j) { + r[j] = j - b[i] << 5 | i; + } + } + return { b, r }; +}; +var _a = freb(fleb, 2); +var fl = _a.b; +var revfl = _a.r; +fl[28] = 258, revfl[258] = 28; +var _b = freb(fdeb, 0); +var fd = _b.b; +var revfd = _b.r; +var rev = new u16(32768); +for (i = 0; i < 32768; ++i) { + x = (i & 43690) >> 1 | (i & 21845) << 1; + x = (x & 52428) >> 2 | (x & 13107) << 2; + x = (x & 61680) >> 4 | (x & 3855) << 4; + rev[i] = ((x & 65280) >> 8 | (x & 255) << 8) >> 1; +} +var x; +var i; +var hMap = (function(cd, mb, r) { + var s = cd.length; + var i = 0; + var l = new u16(mb); + for (; i < s; ++i) { + if (cd[i]) + ++l[cd[i] - 1]; + } + var le = new u16(mb); + for (i = 1; i < mb; ++i) { + le[i] = le[i - 1] + l[i - 1] << 1; + } + var co; + if (r) { + co = new u16(1 << mb); + var rvb = 15 - mb; + for (i = 0; i < s; ++i) { + if (cd[i]) { + var sv = i << 4 | cd[i]; + var r_1 = mb - cd[i]; + var v = le[cd[i] - 1]++ << r_1; + for (var m = v | (1 << r_1) - 1; v <= m; ++v) { + co[rev[v] >> rvb] = sv; + } + } + } + } else { + co = new u16(s); + for (i = 0; i < s; ++i) { + if (cd[i]) { + co[i] = rev[le[cd[i] - 1]++] >> 15 - cd[i]; + } + } + } + return co; +}); +var flt = new u8(288); +for (i = 0; i < 144; ++i) + flt[i] = 8; +var i; +for (i = 144; i < 256; ++i) + flt[i] = 9; +var i; +for (i = 256; i < 280; ++i) + flt[i] = 7; +var i; +for (i = 280; i < 288; ++i) + flt[i] = 8; +var i; +var fdt = new u8(32); +for (i = 0; i < 32; ++i) + fdt[i] = 5; +var i; +var flrm = /* @__PURE__ */ hMap(flt, 9, 1); +var fdrm = /* @__PURE__ */ hMap(fdt, 5, 1); +var max = function(a) { + var m = a[0]; + for (var i = 1; i < a.length; ++i) { + if (a[i] > m) + m = a[i]; + } + return m; +}; +var bits = function(d, p, m) { + var o = p / 8 | 0; + return (d[o] | d[o + 1] << 8) >> (p & 7) & m; +}; +var bits16 = function(d, p) { + var o = p / 8 | 0; + return (d[o] | d[o + 1] << 8 | d[o + 2] << 16) >> (p & 7); +}; +var shft = function(p) { + return (p + 7) / 8 | 0; +}; +var slc = function(v, s, e) { + if (s == null || s < 0) + s = 0; + if (e == null || e > v.length) + e = v.length; + return new u8(v.subarray(s, e)); +}; +var ec = [ + "unexpected EOF", + "invalid block type", + "invalid length/literal", + "invalid distance", + "stream finished", + "no stream handler", + , + // determined by compression function + "no callback", + "invalid UTF-8 data", + "extra field too long", + "date not in range 1980-2099", + "filename too long", + "stream finishing", + "invalid zip data" + // determined by unknown compression method +]; +var err = function(ind, msg, nt) { + var e = new Error(msg || ec[ind]); + e.code = ind; + if (Error.captureStackTrace) + Error.captureStackTrace(e, err); + if (!nt) + throw e; + return e; +}; +var inflt = function(dat, st, buf, dict) { + var sl = dat.length, dl = dict ? dict.length : 0; + if (!sl || st.f && !st.l) + return buf || new u8(0); + var noBuf = !buf; + var resize = noBuf || st.i != 2; + var noSt = st.i; + if (noBuf) + buf = new u8(sl * 3); + var cbuf = function(l2) { + var bl = buf.length; + if (l2 > bl) { + var nbuf = new u8(Math.max(bl * 2, l2)); + nbuf.set(buf); + buf = nbuf; + } + }; + var final = st.f || 0, pos = st.p || 0, bt = st.b || 0, lm = st.l, dm = st.d, lbt = st.m, dbt = st.n; + var tbts = sl * 8; + do { + if (!lm) { + final = bits(dat, pos, 1); + var type = bits(dat, pos + 1, 3); + pos += 3; + if (!type) { + var s = shft(pos) + 4, l = dat[s - 4] | dat[s - 3] << 8, t = s + l; + if (t > sl) { + if (noSt) + err(0); + break; + } + if (resize) + cbuf(bt + l); + buf.set(dat.subarray(s, t), bt); + st.b = bt += l, st.p = pos = t * 8, st.f = final; + continue; + } else if (type == 1) + lm = flrm, dm = fdrm, lbt = 9, dbt = 5; + else if (type == 2) { + var hLit = bits(dat, pos, 31) + 257, hcLen = bits(dat, pos + 10, 15) + 4; + var tl = hLit + bits(dat, pos + 5, 31) + 1; + pos += 14; + var ldt = new u8(tl); + var clt = new u8(19); + for (var i = 0; i < hcLen; ++i) { + clt[clim[i]] = bits(dat, pos + i * 3, 7); + } + pos += hcLen * 3; + var clb = max(clt), clbmsk = (1 << clb) - 1; + var clm = hMap(clt, clb, 1); + for (var i = 0; i < tl; ) { + var r = clm[bits(dat, pos, clbmsk)]; + pos += r & 15; + var s = r >> 4; + if (s < 16) { + ldt[i++] = s; + } else { + var c = 0, n = 0; + if (s == 16) + n = 3 + bits(dat, pos, 3), pos += 2, c = ldt[i - 1]; + else if (s == 17) + n = 3 + bits(dat, pos, 7), pos += 3; + else if (s == 18) + n = 11 + bits(dat, pos, 127), pos += 7; + while (n--) + ldt[i++] = c; + } + } + var lt = ldt.subarray(0, hLit), dt = ldt.subarray(hLit); + lbt = max(lt); + dbt = max(dt); + lm = hMap(lt, lbt, 1); + dm = hMap(dt, dbt, 1); + } else + err(1); + if (pos > tbts) { + if (noSt) + err(0); + break; + } + } + if (resize) + cbuf(bt + 131072); + var lms = (1 << lbt) - 1, dms = (1 << dbt) - 1; + var lpos = pos; + for (; ; lpos = pos) { + var c = lm[bits16(dat, pos) & lms], sym = c >> 4; + pos += c & 15; + if (pos > tbts) { + if (noSt) + err(0); + break; + } + if (!c) + err(2); + if (sym < 256) + buf[bt++] = sym; + else if (sym == 256) { + lpos = pos, lm = null; + break; + } else { + var add = sym - 254; + if (sym > 264) { + var i = sym - 257, b = fleb[i]; + add = bits(dat, pos, (1 << b) - 1) + fl[i]; + pos += b; + } + var d = dm[bits16(dat, pos) & dms], dsym = d >> 4; + if (!d) + err(3); + pos += d & 15; + var dt = fd[dsym]; + if (dsym > 3) { + var b = fdeb[dsym]; + dt += bits16(dat, pos) & (1 << b) - 1, pos += b; + } + if (pos > tbts) { + if (noSt) + err(0); + break; + } + if (resize) + cbuf(bt + 131072); + var end = bt + add; + if (bt < dt) { + var shift = dl - dt, dend = Math.min(dt, end); + if (shift + bt < 0) + err(3); + for (; bt < dend; ++bt) + buf[bt] = dict[shift + bt]; + } + for (; bt < end; ++bt) + buf[bt] = buf[bt - dt]; + } + } + st.l = lm, st.p = lpos, st.b = bt, st.f = final; + if (lm) + final = 1, st.m = lbt, st.d = dm, st.n = dbt; + } while (!final); + return bt != buf.length && noBuf ? slc(buf, 0, bt) : buf.subarray(0, bt); +}; +var et = /* @__PURE__ */ new u8(0); +var b2 = function(d, b) { + return d[b] | d[b + 1] << 8; +}; +var b4 = function(d, b) { + return (d[b] | d[b + 1] << 8 | d[b + 2] << 16 | d[b + 3] << 24) >>> 0; +}; +var b8 = function(d, b) { + return b4(d, b) + b4(d, b + 4) * 4294967296; +}; +function inflateSync(data, opts) { + return inflt(data, { i: 2 }, opts && opts.out, opts && opts.dictionary); +} +var td = typeof TextDecoder != "undefined" && /* @__PURE__ */ new TextDecoder(); +var tds = 0; +try { + td.decode(et, { stream: true }); + tds = 1; +} catch (e) { +} +var dutf8 = function(d) { + for (var r = "", i = 0; ; ) { + var c = d[i++]; + var eb = (c > 127) + (c > 223) + (c > 239); + if (i + eb > d.length) + return { s: r, r: slc(d, i - 1) }; + if (!eb) + r += String.fromCharCode(c); + else if (eb == 3) { + c = ((c & 15) << 18 | (d[i++] & 63) << 12 | (d[i++] & 63) << 6 | d[i++] & 63) - 65536, r += String.fromCharCode(55296 | c >> 10, 56320 | c & 1023); + } else if (eb & 1) + r += String.fromCharCode((c & 31) << 6 | d[i++] & 63); + else + r += String.fromCharCode((c & 15) << 12 | (d[i++] & 63) << 6 | d[i++] & 63); + } +}; +function strFromU8(dat, latin1) { + if (latin1) { + var r = ""; + for (var i = 0; i < dat.length; i += 16384) + r += String.fromCharCode.apply(null, dat.subarray(i, i + 16384)); + return r; + } else if (td) { + return td.decode(dat); + } else { + var _a2 = dutf8(dat), s = _a2.s, r = _a2.r; + if (r.length) + err(8); + return s; + } +} +var slzh = function(d, b) { + return b + 30 + b2(d, b + 26) + b2(d, b + 28); +}; +var zh = function(d, b, z) { + var fnl = b2(d, b + 28), efl = b2(d, b + 30), fn = strFromU8(d.subarray(b + 46, b + 46 + fnl), !(b2(d, b + 8) & 2048)), es = b + 46 + fnl; + var _a2 = z64hs(d, es, efl, z, b4(d, b + 20), b4(d, b + 24), b4(d, b + 42)), sc = _a2[0], su = _a2[1], off = _a2[2]; + return [b2(d, b + 10), sc, su, fn, es + efl + b2(d, b + 32), off]; +}; +var z64hs = function(d, b, l, z, sc, su, off) { + var nsc = sc == 4294967295, nsu = su == 4294967295, noff = off == 4294967295, e = b + l; + var nf = nsc + nsu + noff; + if (z && nf) { + for (; b + 4 < e; b += 4 + b2(d, b + 2)) { + if (b2(d, b) == 1) { + return [ + nsc ? b8(d, b + 4 + 8 * nsu) : sc, + nsu ? b8(d, b + 4) : su, + noff ? b8(d, b + 4 + 8 * (nsu + nsc)) : off, + 1 + ]; + } + } + if (z < 2) + err(13); + } + return [sc, su, off, 0]; +}; +function unzipSync(data, opts) { + var files = {}; + var e = data.length - 22; + for (; b4(data, e) != 101010256; --e) { + if (!e || data.length - e > 65558) + err(13); + } + ; + var c = b2(data, e + 8); + if (!c) + return {}; + var o = b4(data, e + 16); + var z = b4(data, e - 20) == 117853008; + if (z) { + var ze = b4(data, e - 12); + z = b4(data, ze) == 101075792; + if (z) { + c = b4(data, ze + 32); + o = b4(data, ze + 48); + } + } + var fltr = opts && opts.filter; + for (var i = 0; i < c; ++i) { + var _a2 = zh(data, o, z), c_2 = _a2[0], sc = _a2[1], su = _a2[2], fn = _a2[3], no = _a2[4], off = _a2[5], b = slzh(data, off); + o = no; + if (!fltr || fltr({ + name: fn, + size: sc, + originalSize: su, + compression: c_2 + })) { + if (!c_2) + files[fn] = slc(data, b, b + sc); + else if (c_2 == 8) + files[fn] = inflateSync(data.subarray(b, b + sc), { out: new u8(su) }); + else + err(14, "unknown compression type " + c_2); + } + } + return files; +} + +// upload/frontend/table/rebase_tree.js +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; +} + +// upload/frontend/zip/list_zip_files.js +var DEFAULT_LIMITS = { + maxEntries: 1e3, + maxTotalBytes: 100 * 1024 * 1024, + maxEntryBytes: 50 * 1024 * 1024, + maxDepth: 20 +}; +function extensionAllowed(name, allowedExt) { + const lowerName = name.toLowerCase(); + return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase())); +} +function makeFile(data, name) { + return new File([data], name.split("/").at(-1)); +} +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; +} +function node(kind, name, path, children = [], file = null) { + return { id: crypto.randomUUID(), kind, name, path, children, file, expanded: kind === "file" }; +} +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; + }); +} +async function listEntries(data, zipName, allowedExt, depth, limits, budget) { + if (depth > limits.maxDepth) throw new Error("\u0421\u043B\u0438\u0448\u043A\u043E\u043C \u0433\u043B\u0443\u0431\u043E\u043A\u0430\u044F \u0432\u043B\u043E\u0436\u0435\u043D\u043D\u043E\u0441\u0442\u044C ZIP"); + const entries = unzipSync(data, { + filter: (entry) => { + budget.entries += 1; + if (budget.entries > limits.maxEntries) throw new Error("\u0421\u043B\u0438\u0448\u043A\u043E\u043C \u043C\u043D\u043E\u0433\u043E 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("\u041F\u0440\u0435\u0432\u044B\u0448\u0435\u043D \u0441\u0443\u043C\u043C\u0430\u0440\u043D\u044B\u0439 \u0440\u0430\u0437\u043C\u0435\u0440 \u0440\u0430\u0441\u043F\u0430\u043A\u043E\u0432\u0430\u043D\u043D\u044B\u0445 ZIP entries"); + } + budget.totalBytes += entry.originalSize; + return true; + } + }); + const root = node("zip", zipName, zipName); + for (const [entryName, entryData] of Object.entries(entries)) { + if (entryName.endsWith("/")) continue; + const parts = safeEntryParts(entryName); + if (!parts) continue; + const normalizedName = parts.join("/"); + if (normalizedName.toLowerCase().endsWith(".zip")) { + 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)) { + 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; +} +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 }); +} + +// upload/frontend/table/add_file_with_dedup.js +function addFileWithDedup(state, fileNode) { + const accept = (node2) => { + if (node2.kind === "file") { + const key = `${node2.path}\0${node2.file.size}`; + if (state.fileKeys.has(key)) return null; + state.fileKeys.add(key); + return node2; + } + node2.children = node2.children.map(accept).filter(Boolean); + return node2.children.length ? node2 : null; + }; + const accepted = accept(fileNode); + if (!accepted || accepted.kind !== "file" && !accepted.children.length) return false; + if (accepted.kind !== "file") { + const existing = state.nodes.find((node2) => node2.kind !== "file" && node2.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((existing2) => existing2.kind === "file" && existing2.path === child.path && existing2.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); + }); +} + +// upload/frontend/table/esc.js +function esc(value) { + return String(value).replace(/[&<>"']/g, (character) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + })[character]); +} + +// upload/frontend/table/fs.js +function fs(bytes) { + return bytes < 1024 ? `${bytes} B` : bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1048576).toFixed(1)} MB`; +} + +// upload/frontend/table/render.js +var DEFAULT_RENDER_CONFIG = { + labels: { + statusReady: "\u0433\u043E\u0442\u043E\u0432", + remove: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C", + empty: "\u041D\u0435\u0442 \u0432\u044B\u0431\u0440\u0430\u043D\u043D\u044B\u0445 \u0444\u0430\u0439\u043B\u043E\u0432", + columns: { path: "\u041F\u0443\u0442\u044C", size: "\u0420\u0430\u0437\u043C\u0435\u0440", status: "\u0421\u0442\u0430\u0442\u0443\u0441" }, + count: (count, bytes) => `${count} \u0444\u0430\u0439\u043B\u043E\u0432 \xB7 ${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 || {} } + }; +} +function renderNode(node2, depth, cfg) { + const isGroup = node2.kind !== "file"; + const padding = depth * 4; + const name = esc(node2.name); + const action = isGroup ? `` : `${name}`; + const remove = ``; + const pathAttribute = node2.kind === "file" ? ` data-path="${esc(node2.path)}"` : ""; + const cells = { + path: action, + size: node2.kind === "file" ? fs(node2.file.size) : "", + status: node2.error ? esc(node2.error) : node2.kind === "file" ? esc(cfg.labels.statusReady) : "" + }; + const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(DEFAULT_RENDER_CONFIG.labels.columns); + const row = `` + columns.map((column) => `${cells[column] || ""}`).join("") + `${remove}`; + if (!isGroup || !node2.expanded) return row; + return row + node2.children.map((child) => renderNode(child, depth + 1, cfg)).join(""); +} +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((node2) => renderNode(node2, 0, normalized)).join("") : `${esc(normalized.labels.empty)}`; + const files = []; + const visit = (node2) => { + if (node2.kind === "file") files.push(node2); + else node2.children.forEach(visit); + }; + state.nodes.forEach(visit); + const totalBytes = files.reduce((sum, node2) => sum + node2.file.size, 0); + elements.countEl.textContent = normalized.labels.count(files.length, totalBytes); +} +function findNode(nodes, id) { + for (const node2 of nodes) { + if (node2.id === id) return { node: node2, nodes }; + const found = findNode(node2.children || [], id); + if (found) return found; + } + return null; +} +function flattenFiles(nodes, result = []) { + nodes.forEach((node2) => { + if (node2.kind === "file") result.push({ path: node2.path, name: node2.name, size: node2.file.size, file: node2.file }); + else flattenFiles(node2.children, result); + }); + return result; +} + +// upload/frontend/table/on_files_change.js +async function addFiles(state, cfg, files, elements) { + for (const file of Array.from(files)) { + if (!file.name.toLowerCase().endsWith(".zip")) { + if (!cfg.allowedExt.some((extension) => file.name.toLowerCase().endsWith(extension.toLowerCase()))) { + continue; + } + addFileWithDedup(state, { + id: crypto.randomUUID(), + kind: "file", + name: file.name, + path: file.name, + file, + children: [], + expanded: true + }); + continue; + } + try { + const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits); + if (zipTree) addFileWithDedup(state, zipTree); + } catch (error) { + if (typeof cfg.onError === "function") { + cfg.onError(error, file); + } else if (elements.statusEl) { + elements.statusEl.textContent = `\u041E\u0448\u0438\u0431\u043A\u0430 \u0447\u0442\u0435\u043D\u0438\u044F \u0430\u0440\u0445\u0438\u0432\u0430 ${file.name}: ${error.message}`; + } + continue; + } + } + render(state, elements, cfg); +} +function onFilesChange(state, cfg, elements) { + return async () => { + if (state.busy) return; + state.busy = true; + try { + await addFiles(state, cfg, elements.fileInputEl.files, elements); + } finally { + elements.fileInputEl.value = ""; + state.busy = false; + } + }; +} + +// upload/frontend/table/on_folder_change.js +function onFolderChange(state, cfg, elements) { + return async () => { + if (state.busy) return; + state.busy = true; + try { + const roots = /* @__PURE__ */ new Map(); + for (const file of Array.from(elements.folderInputEl.files)) { + 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)) { + roots.set(rootName, { + id: crypto.randomUUID(), + kind: "folder", + name: rootName, + path: rootName, + children: [], + expanded: false + }); + } + const root = roots.get(rootName); + const addToFolder = (node2) => { + let current = root; + const nodeParts = node2.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 ? node2 : { + 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 { + const zip = await listZipFiles(file, cfg.allowedExt, cfg.limits); + if (zip) { + rebaseTree(zip, rootName); + addToFolder(zip); + } + } catch (error) { + if (typeof cfg.onError === "function") { + cfg.onError(error, file); + } else if (elements.statusEl) { + elements.statusEl.textContent = `\u041E\u0448\u0438\u0431\u043A\u0430 \u0447\u0442\u0435\u043D\u0438\u044F \u0430\u0440\u0445\u0438\u0432\u0430 ${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)); + elements.folderInputEl.value = ""; + render(state, elements, cfg); + } finally { + state.busy = false; + } + }; +} + +// upload/frontend/upload/put_to_vm.js +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 || 3e5; + 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(`\u0411\u0443\u0444\u0435\u0440: HTTP ${xhr.status} ${xhr.statusText}`)); + } + }; + xhr.onerror = () => { + if (options.signal) { + options.signal.removeEventListener("abort", abortHandler); + } + reject(new Error("\u0421\u0435\u0442\u0435\u0432\u0430\u044F \u043E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u0438 \u043E\u0442\u043F\u0440\u0430\u0432\u043A\u0435 \u0432 \u0431\u0443\u0444\u0435\u0440")); + }; + xhr.ontimeout = () => { + if (options.signal) { + options.signal.removeEventListener("abort", abortHandler); + } + reject(new Error(`\u0422\u0430\u0439\u043C\u0430\u0443\u0442 \u043E\u0436\u0438\u0434\u0430\u043D\u0438\u044F \u043E\u0442\u0432\u0435\u0442\u0430 \u0431\u0443\u0444\u0435\u0440\u0430 (${Math.round(xhr.timeout / 1e3)}\u0441)`)); + }; + xhr.onabort = () => { + if (options.signal) { + options.signal.removeEventListener("abort", abortHandler); + } + reject(new DOMException("Upload aborted", "AbortError")); + }; + xhr.send(file); + }); +} + +// upload/frontend/upload/upload_via_vm.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 & 3 | 8; + return v.toString(16); + }); +} +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("\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 \u043E\u0431\u044A\u0435\u043A\u0442 \u0444\u0430\u0439\u043B\u0430"); +} +async function uploadViaVM(files, options = {}) { + if (!options.vmUploadUrl) { + return { ok: false, error: "\u041F\u0430\u0440\u0430\u043C\u0435\u0442\u0440 options.vmUploadUrl \u043E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u0435\u043D" }; + } + 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: "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u043E\u0442\u043C\u0435\u043D\u0435\u043D\u0430", aborted: true, session: currentSession }; + } + const { name, size, file } = normalizedFiles[k]; + const fileKey = `${generateUuid()}_${k}`; + const fileUrl = `${vmBase}${fileKey}`; + if (options.onStatus) { + options.onStatus(`\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u0432 \u0431\u0443\u0444\u0435\u0440 (${k + 1}/${total}): ${name}`); + } + if (options.onFileStatus) { + options.onFileStatus(k, "\u23F3 \u0412 \u0431\u0443\u0444\u0435\u0440..."); + } + const t0 = performance.now(); + try { + await putToVm(file, fileUrl, { + signal: options.signal, + onXHR: options.onXHR, + onProgress(pct, loaded, fileTotal) { + const elapsed = (performance.now() - t0) / 1e3; + const speed = elapsed > 0 ? loaded / elapsed : 0; + if (options.onFileStatus) { + options.onFileStatus(k, `\u23F3 ${pct}% (${fs(speed)}/s)`); + } + if (options.onProgress) { + options.onProgress({ index: k, total, pct, speed, loaded, fileTotal, name }); + } + } + }); + } catch (err2) { + if (options.signal?.aborted) { + return { ok: false, error: "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u043E\u0442\u043C\u0435\u043D\u0435\u043D\u0430", aborted: true, session: currentSession }; + } + if (options.onFileStatus) { + options.onFileStatus(k, "\u2717 \u041E\u0448\u0438\u0431\u043A\u0430 \u0431\u0443\u0444\u0435\u0440\u0430"); + } + return { + ok: false, + error: `\u041E\u0448\u0438\u0431\u043A\u0430 \u043E\u0442\u043F\u0440\u0430\u0432\u043A\u0438 \u0444\u0430\u0439\u043B\u0430 ${name} \u0432 \u0431\u0443\u0444\u0435\u0440: ${err2.message}`, + failedIndex: k, + session: currentSession + }; + } + if (options.onStatus) { + options.onStatus(`\u041F\u0440\u0438\u0451\u043C \u0441\u0435\u0440\u0432\u0438\u0441\u043E\u043C (${k + 1}/${total}): ${name}`); + } + if (options.onFileStatus) { + options.onFileStatus(k, "\u26A1 \u0412 \u0441\u0435\u0441\u0441\u0438\u044E..."); + } + 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 || "\u0411\u044D\u043A\u0435\u043D\u0434 \u0432\u0435\u0440\u043D\u0443\u043B \u043E\u0448\u0438\u0431\u043A\u0443"); + } + currentSession = data.session || currentSession; + totalAdded = data.count ?? totalAdded + 1; + if (options.onFileStatus) { + options.onFileStatus(k, "\u2713 \u0414\u043E\u0441\u0442\u0430\u0432\u043B\u0435\u043D"); + } + if (options.onFileComplete) { + options.onFileComplete({ + fileIndex: k, + name, + size, + session: currentSession, + totalAdded + }); + } + } catch (err2) { + if (options.signal?.aborted) { + return { ok: false, error: "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u043E\u0442\u043C\u0435\u043D\u0435\u043D\u0430", aborted: true, session: currentSession }; + } + if (options.onFileStatus) { + options.onFileStatus(k, "\u2717 \u041E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u0438\u0451\u043C\u0430"); + } + return { + ok: false, + error: `\u041E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u0438\u0451\u043C\u0430 \u0444\u0430\u0439\u043B\u0430 ${name} \u0431\u044D\u043A\u0435\u043D\u0434\u043E\u043C: ${err2.message}`, + failedIndex: k, + session: currentSession + }; + } + } + if (options.onStatus) { + options.onStatus(`\u0417\u0430\u0432\u0435\u0440\u0448\u0435\u043D\u043E. \u0423\u0441\u043F\u0435\u0448\u043D\u043E \u043F\u0435\u0440\u0435\u0434\u0430\u043D\u043E \u0444\u0430\u0439\u043B\u043E\u0432: ${total}`); + } + return { ok: true, session: currentSession, count: totalAdded }; +} + +// upload/frontend/index.js +var DEFAULTS = { + allowedExt: [], + labels: { + pickFiles: "\u0412\u044B\u0431\u0440\u0430\u0442\u044C \u0444\u0430\u0439\u043B\u044B", + pickFolder: "\u0412\u044B\u0431\u0440\u0430\u0442\u044C \u043F\u0430\u043F\u043A\u0443", + clear: "\u041E\u0447\u0438\u0441\u0442\u0438\u0442\u044C", + empty: "\u041D\u0435\u0442 \u0432\u044B\u0431\u0440\u0430\u043D\u043D\u044B\u0445 \u0444\u0430\u0439\u043B\u043E\u0432", + statusReady: "\u0433\u043E\u0442\u043E\u0432", + remove: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C", + columns: { path: "\u041F\u0443\u0442\u044C", size: "\u0420\u0430\u0437\u043C\u0435\u0440", status: "\u0421\u0442\u0430\u0442\u0443\u0441" }, + count: (count, bytes) => `${count} \u0444\u0430\u0439\u043B\u043E\u0432 \xB7 ${bytes} B` + }, + layout: { columns: ["path", "size", "status"], controls: ["files", "folder", "clear"] }, + limits: { + maxEntries: 1e3, + 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 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D"); + 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) ? `` : ""; + const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(cfg.labels.columns); + const headings = columns.map((column) => `${esc(cfg.labels.columns[column] || column)}`).join(""); + root.innerHTML = ` +
+
+ + + ${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")} +
+

+
${headings}
+

+
`; +} +function notify(cfg, state) { + if (typeof cfg.onChange === "function") cfg.onChange([...flattenFiles(state.nodes)]); +} +function removeMetadata(state, node2) { + if (node2.kind === "file") { + state.fileKeys.delete(`${node2.path}\0${node2.file.size}`); + return; + } + node2.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; +} +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: /* @__PURE__ */ 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 { + DEFAULTS, + initFilePicker, + putToVm, + uploadViaVM +}; diff --git a/site/static/dist/file-picker.iife.js b/site/static/dist/file-picker.iife.js new file mode 100644 index 0000000..0659d05 --- /dev/null +++ b/site/static/dist/file-picker.iife.js @@ -0,0 +1,1218 @@ +var FilePicker = (() => { + var __defProp = Object.defineProperty; + var __getOwnPropDesc = Object.getOwnPropertyDescriptor; + var __getOwnPropNames = Object.getOwnPropertyNames; + var __hasOwnProp = Object.prototype.hasOwnProperty; + var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); + }; + var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; + }; + var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + + // upload/frontend/index.js + var index_exports = {}; + __export(index_exports, { + DEFAULTS: () => DEFAULTS, + initFilePicker: () => initFilePicker, + putToVm: () => putToVm, + uploadViaVM: () => uploadViaVM + }); + + // node_modules/fflate/esm/browser.js + var u8 = Uint8Array; + var u16 = Uint16Array; + var i32 = Int32Array; + var fleb = new u8([ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 2, + 2, + 2, + 2, + 3, + 3, + 3, + 3, + 4, + 4, + 4, + 4, + 5, + 5, + 5, + 5, + 0, + /* unused */ + 0, + 0, + /* impossible */ + 0 + ]); + var fdeb = new u8([ + 0, + 0, + 0, + 0, + 1, + 1, + 2, + 2, + 3, + 3, + 4, + 4, + 5, + 5, + 6, + 6, + 7, + 7, + 8, + 8, + 9, + 9, + 10, + 10, + 11, + 11, + 12, + 12, + 13, + 13, + /* unused */ + 0, + 0 + ]); + var clim = new u8([16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]); + var freb = function(eb, start) { + var b = new u16(31); + for (var i = 0; i < 31; ++i) { + b[i] = start += 1 << eb[i - 1]; + } + var r = new i32(b[30]); + for (var i = 1; i < 30; ++i) { + for (var j = b[i]; j < b[i + 1]; ++j) { + r[j] = j - b[i] << 5 | i; + } + } + return { b, r }; + }; + var _a = freb(fleb, 2); + var fl = _a.b; + var revfl = _a.r; + fl[28] = 258, revfl[258] = 28; + var _b = freb(fdeb, 0); + var fd = _b.b; + var revfd = _b.r; + var rev = new u16(32768); + for (i = 0; i < 32768; ++i) { + x = (i & 43690) >> 1 | (i & 21845) << 1; + x = (x & 52428) >> 2 | (x & 13107) << 2; + x = (x & 61680) >> 4 | (x & 3855) << 4; + rev[i] = ((x & 65280) >> 8 | (x & 255) << 8) >> 1; + } + var x; + var i; + var hMap = (function(cd, mb, r) { + var s = cd.length; + var i = 0; + var l = new u16(mb); + for (; i < s; ++i) { + if (cd[i]) + ++l[cd[i] - 1]; + } + var le = new u16(mb); + for (i = 1; i < mb; ++i) { + le[i] = le[i - 1] + l[i - 1] << 1; + } + var co; + if (r) { + co = new u16(1 << mb); + var rvb = 15 - mb; + for (i = 0; i < s; ++i) { + if (cd[i]) { + var sv = i << 4 | cd[i]; + var r_1 = mb - cd[i]; + var v = le[cd[i] - 1]++ << r_1; + for (var m = v | (1 << r_1) - 1; v <= m; ++v) { + co[rev[v] >> rvb] = sv; + } + } + } + } else { + co = new u16(s); + for (i = 0; i < s; ++i) { + if (cd[i]) { + co[i] = rev[le[cd[i] - 1]++] >> 15 - cd[i]; + } + } + } + return co; + }); + var flt = new u8(288); + for (i = 0; i < 144; ++i) + flt[i] = 8; + var i; + for (i = 144; i < 256; ++i) + flt[i] = 9; + var i; + for (i = 256; i < 280; ++i) + flt[i] = 7; + var i; + for (i = 280; i < 288; ++i) + flt[i] = 8; + var i; + var fdt = new u8(32); + for (i = 0; i < 32; ++i) + fdt[i] = 5; + var i; + var flrm = /* @__PURE__ */ hMap(flt, 9, 1); + var fdrm = /* @__PURE__ */ hMap(fdt, 5, 1); + var max = function(a) { + var m = a[0]; + for (var i = 1; i < a.length; ++i) { + if (a[i] > m) + m = a[i]; + } + return m; + }; + var bits = function(d, p, m) { + var o = p / 8 | 0; + return (d[o] | d[o + 1] << 8) >> (p & 7) & m; + }; + var bits16 = function(d, p) { + var o = p / 8 | 0; + return (d[o] | d[o + 1] << 8 | d[o + 2] << 16) >> (p & 7); + }; + var shft = function(p) { + return (p + 7) / 8 | 0; + }; + var slc = function(v, s, e) { + if (s == null || s < 0) + s = 0; + if (e == null || e > v.length) + e = v.length; + return new u8(v.subarray(s, e)); + }; + var ec = [ + "unexpected EOF", + "invalid block type", + "invalid length/literal", + "invalid distance", + "stream finished", + "no stream handler", + , + // determined by compression function + "no callback", + "invalid UTF-8 data", + "extra field too long", + "date not in range 1980-2099", + "filename too long", + "stream finishing", + "invalid zip data" + // determined by unknown compression method + ]; + var err = function(ind, msg, nt) { + var e = new Error(msg || ec[ind]); + e.code = ind; + if (Error.captureStackTrace) + Error.captureStackTrace(e, err); + if (!nt) + throw e; + return e; + }; + var inflt = function(dat, st, buf, dict) { + var sl = dat.length, dl = dict ? dict.length : 0; + if (!sl || st.f && !st.l) + return buf || new u8(0); + var noBuf = !buf; + var resize = noBuf || st.i != 2; + var noSt = st.i; + if (noBuf) + buf = new u8(sl * 3); + var cbuf = function(l2) { + var bl = buf.length; + if (l2 > bl) { + var nbuf = new u8(Math.max(bl * 2, l2)); + nbuf.set(buf); + buf = nbuf; + } + }; + var final = st.f || 0, pos = st.p || 0, bt = st.b || 0, lm = st.l, dm = st.d, lbt = st.m, dbt = st.n; + var tbts = sl * 8; + do { + if (!lm) { + final = bits(dat, pos, 1); + var type = bits(dat, pos + 1, 3); + pos += 3; + if (!type) { + var s = shft(pos) + 4, l = dat[s - 4] | dat[s - 3] << 8, t = s + l; + if (t > sl) { + if (noSt) + err(0); + break; + } + if (resize) + cbuf(bt + l); + buf.set(dat.subarray(s, t), bt); + st.b = bt += l, st.p = pos = t * 8, st.f = final; + continue; + } else if (type == 1) + lm = flrm, dm = fdrm, lbt = 9, dbt = 5; + else if (type == 2) { + var hLit = bits(dat, pos, 31) + 257, hcLen = bits(dat, pos + 10, 15) + 4; + var tl = hLit + bits(dat, pos + 5, 31) + 1; + pos += 14; + var ldt = new u8(tl); + var clt = new u8(19); + for (var i = 0; i < hcLen; ++i) { + clt[clim[i]] = bits(dat, pos + i * 3, 7); + } + pos += hcLen * 3; + var clb = max(clt), clbmsk = (1 << clb) - 1; + var clm = hMap(clt, clb, 1); + for (var i = 0; i < tl; ) { + var r = clm[bits(dat, pos, clbmsk)]; + pos += r & 15; + var s = r >> 4; + if (s < 16) { + ldt[i++] = s; + } else { + var c = 0, n = 0; + if (s == 16) + n = 3 + bits(dat, pos, 3), pos += 2, c = ldt[i - 1]; + else if (s == 17) + n = 3 + bits(dat, pos, 7), pos += 3; + else if (s == 18) + n = 11 + bits(dat, pos, 127), pos += 7; + while (n--) + ldt[i++] = c; + } + } + var lt = ldt.subarray(0, hLit), dt = ldt.subarray(hLit); + lbt = max(lt); + dbt = max(dt); + lm = hMap(lt, lbt, 1); + dm = hMap(dt, dbt, 1); + } else + err(1); + if (pos > tbts) { + if (noSt) + err(0); + break; + } + } + if (resize) + cbuf(bt + 131072); + var lms = (1 << lbt) - 1, dms = (1 << dbt) - 1; + var lpos = pos; + for (; ; lpos = pos) { + var c = lm[bits16(dat, pos) & lms], sym = c >> 4; + pos += c & 15; + if (pos > tbts) { + if (noSt) + err(0); + break; + } + if (!c) + err(2); + if (sym < 256) + buf[bt++] = sym; + else if (sym == 256) { + lpos = pos, lm = null; + break; + } else { + var add = sym - 254; + if (sym > 264) { + var i = sym - 257, b = fleb[i]; + add = bits(dat, pos, (1 << b) - 1) + fl[i]; + pos += b; + } + var d = dm[bits16(dat, pos) & dms], dsym = d >> 4; + if (!d) + err(3); + pos += d & 15; + var dt = fd[dsym]; + if (dsym > 3) { + var b = fdeb[dsym]; + dt += bits16(dat, pos) & (1 << b) - 1, pos += b; + } + if (pos > tbts) { + if (noSt) + err(0); + break; + } + if (resize) + cbuf(bt + 131072); + var end = bt + add; + if (bt < dt) { + var shift = dl - dt, dend = Math.min(dt, end); + if (shift + bt < 0) + err(3); + for (; bt < dend; ++bt) + buf[bt] = dict[shift + bt]; + } + for (; bt < end; ++bt) + buf[bt] = buf[bt - dt]; + } + } + st.l = lm, st.p = lpos, st.b = bt, st.f = final; + if (lm) + final = 1, st.m = lbt, st.d = dm, st.n = dbt; + } while (!final); + return bt != buf.length && noBuf ? slc(buf, 0, bt) : buf.subarray(0, bt); + }; + var et = /* @__PURE__ */ new u8(0); + var b2 = function(d, b) { + return d[b] | d[b + 1] << 8; + }; + var b4 = function(d, b) { + return (d[b] | d[b + 1] << 8 | d[b + 2] << 16 | d[b + 3] << 24) >>> 0; + }; + var b8 = function(d, b) { + return b4(d, b) + b4(d, b + 4) * 4294967296; + }; + function inflateSync(data, opts) { + return inflt(data, { i: 2 }, opts && opts.out, opts && opts.dictionary); + } + var td = typeof TextDecoder != "undefined" && /* @__PURE__ */ new TextDecoder(); + var tds = 0; + try { + td.decode(et, { stream: true }); + tds = 1; + } catch (e) { + } + var dutf8 = function(d) { + for (var r = "", i = 0; ; ) { + var c = d[i++]; + var eb = (c > 127) + (c > 223) + (c > 239); + if (i + eb > d.length) + return { s: r, r: slc(d, i - 1) }; + if (!eb) + r += String.fromCharCode(c); + else if (eb == 3) { + c = ((c & 15) << 18 | (d[i++] & 63) << 12 | (d[i++] & 63) << 6 | d[i++] & 63) - 65536, r += String.fromCharCode(55296 | c >> 10, 56320 | c & 1023); + } else if (eb & 1) + r += String.fromCharCode((c & 31) << 6 | d[i++] & 63); + else + r += String.fromCharCode((c & 15) << 12 | (d[i++] & 63) << 6 | d[i++] & 63); + } + }; + function strFromU8(dat, latin1) { + if (latin1) { + var r = ""; + for (var i = 0; i < dat.length; i += 16384) + r += String.fromCharCode.apply(null, dat.subarray(i, i + 16384)); + return r; + } else if (td) { + return td.decode(dat); + } else { + var _a2 = dutf8(dat), s = _a2.s, r = _a2.r; + if (r.length) + err(8); + return s; + } + } + var slzh = function(d, b) { + return b + 30 + b2(d, b + 26) + b2(d, b + 28); + }; + var zh = function(d, b, z) { + var fnl = b2(d, b + 28), efl = b2(d, b + 30), fn = strFromU8(d.subarray(b + 46, b + 46 + fnl), !(b2(d, b + 8) & 2048)), es = b + 46 + fnl; + var _a2 = z64hs(d, es, efl, z, b4(d, b + 20), b4(d, b + 24), b4(d, b + 42)), sc = _a2[0], su = _a2[1], off = _a2[2]; + return [b2(d, b + 10), sc, su, fn, es + efl + b2(d, b + 32), off]; + }; + var z64hs = function(d, b, l, z, sc, su, off) { + var nsc = sc == 4294967295, nsu = su == 4294967295, noff = off == 4294967295, e = b + l; + var nf = nsc + nsu + noff; + if (z && nf) { + for (; b + 4 < e; b += 4 + b2(d, b + 2)) { + if (b2(d, b) == 1) { + return [ + nsc ? b8(d, b + 4 + 8 * nsu) : sc, + nsu ? b8(d, b + 4) : su, + noff ? b8(d, b + 4 + 8 * (nsu + nsc)) : off, + 1 + ]; + } + } + if (z < 2) + err(13); + } + return [sc, su, off, 0]; + }; + function unzipSync(data, opts) { + var files = {}; + var e = data.length - 22; + for (; b4(data, e) != 101010256; --e) { + if (!e || data.length - e > 65558) + err(13); + } + ; + var c = b2(data, e + 8); + if (!c) + return {}; + var o = b4(data, e + 16); + var z = b4(data, e - 20) == 117853008; + if (z) { + var ze = b4(data, e - 12); + z = b4(data, ze) == 101075792; + if (z) { + c = b4(data, ze + 32); + o = b4(data, ze + 48); + } + } + var fltr = opts && opts.filter; + for (var i = 0; i < c; ++i) { + var _a2 = zh(data, o, z), c_2 = _a2[0], sc = _a2[1], su = _a2[2], fn = _a2[3], no = _a2[4], off = _a2[5], b = slzh(data, off); + o = no; + if (!fltr || fltr({ + name: fn, + size: sc, + originalSize: su, + compression: c_2 + })) { + if (!c_2) + files[fn] = slc(data, b, b + sc); + else if (c_2 == 8) + files[fn] = inflateSync(data.subarray(b, b + sc), { out: new u8(su) }); + else + err(14, "unknown compression type " + c_2); + } + } + return files; + } + + // upload/frontend/table/rebase_tree.js + 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; + } + + // upload/frontend/zip/list_zip_files.js + var DEFAULT_LIMITS = { + maxEntries: 1e3, + maxTotalBytes: 100 * 1024 * 1024, + maxEntryBytes: 50 * 1024 * 1024, + maxDepth: 20 + }; + function extensionAllowed(name, allowedExt) { + const lowerName = name.toLowerCase(); + return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase())); + } + function makeFile(data, name) { + return new File([data], name.split("/").at(-1)); + } + 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; + } + function node(kind, name, path, children = [], file = null) { + return { id: crypto.randomUUID(), kind, name, path, children, file, expanded: kind === "file" }; + } + 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; + }); + } + async function listEntries(data, zipName, allowedExt, depth, limits, budget) { + if (depth > limits.maxDepth) throw new Error("\u0421\u043B\u0438\u0448\u043A\u043E\u043C \u0433\u043B\u0443\u0431\u043E\u043A\u0430\u044F \u0432\u043B\u043E\u0436\u0435\u043D\u043D\u043E\u0441\u0442\u044C ZIP"); + const entries = unzipSync(data, { + filter: (entry) => { + budget.entries += 1; + if (budget.entries > limits.maxEntries) throw new Error("\u0421\u043B\u0438\u0448\u043A\u043E\u043C \u043C\u043D\u043E\u0433\u043E 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("\u041F\u0440\u0435\u0432\u044B\u0448\u0435\u043D \u0441\u0443\u043C\u043C\u0430\u0440\u043D\u044B\u0439 \u0440\u0430\u0437\u043C\u0435\u0440 \u0440\u0430\u0441\u043F\u0430\u043A\u043E\u0432\u0430\u043D\u043D\u044B\u0445 ZIP entries"); + } + budget.totalBytes += entry.originalSize; + return true; + } + }); + const root = node("zip", zipName, zipName); + for (const [entryName, entryData] of Object.entries(entries)) { + if (entryName.endsWith("/")) continue; + const parts = safeEntryParts(entryName); + if (!parts) continue; + const normalizedName = parts.join("/"); + if (normalizedName.toLowerCase().endsWith(".zip")) { + 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)) { + 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; + } + 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 }); + } + + // upload/frontend/table/add_file_with_dedup.js + function addFileWithDedup(state, fileNode) { + const accept = (node2) => { + if (node2.kind === "file") { + const key = `${node2.path}\0${node2.file.size}`; + if (state.fileKeys.has(key)) return null; + state.fileKeys.add(key); + return node2; + } + node2.children = node2.children.map(accept).filter(Boolean); + return node2.children.length ? node2 : null; + }; + const accepted = accept(fileNode); + if (!accepted || accepted.kind !== "file" && !accepted.children.length) return false; + if (accepted.kind !== "file") { + const existing = state.nodes.find((node2) => node2.kind !== "file" && node2.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((existing2) => existing2.kind === "file" && existing2.path === child.path && existing2.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); + }); + } + + // upload/frontend/table/esc.js + function esc(value) { + return String(value).replace(/[&<>"']/g, (character) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + })[character]); + } + + // upload/frontend/table/fs.js + function fs(bytes) { + return bytes < 1024 ? `${bytes} B` : bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1048576).toFixed(1)} MB`; + } + + // upload/frontend/table/render.js + var DEFAULT_RENDER_CONFIG = { + labels: { + statusReady: "\u0433\u043E\u0442\u043E\u0432", + remove: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C", + empty: "\u041D\u0435\u0442 \u0432\u044B\u0431\u0440\u0430\u043D\u043D\u044B\u0445 \u0444\u0430\u0439\u043B\u043E\u0432", + columns: { path: "\u041F\u0443\u0442\u044C", size: "\u0420\u0430\u0437\u043C\u0435\u0440", status: "\u0421\u0442\u0430\u0442\u0443\u0441" }, + count: (count, bytes) => `${count} \u0444\u0430\u0439\u043B\u043E\u0432 \xB7 ${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 || {} } + }; + } + function renderNode(node2, depth, cfg) { + const isGroup = node2.kind !== "file"; + const padding = depth * 4; + const name = esc(node2.name); + const action = isGroup ? `` : `${name}`; + const remove = ``; + const pathAttribute = node2.kind === "file" ? ` data-path="${esc(node2.path)}"` : ""; + const cells = { + path: action, + size: node2.kind === "file" ? fs(node2.file.size) : "", + status: node2.error ? esc(node2.error) : node2.kind === "file" ? esc(cfg.labels.statusReady) : "" + }; + const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(DEFAULT_RENDER_CONFIG.labels.columns); + const row = `` + columns.map((column) => `${cells[column] || ""}`).join("") + `${remove}`; + if (!isGroup || !node2.expanded) return row; + return row + node2.children.map((child) => renderNode(child, depth + 1, cfg)).join(""); + } + 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((node2) => renderNode(node2, 0, normalized)).join("") : `${esc(normalized.labels.empty)}`; + const files = []; + const visit = (node2) => { + if (node2.kind === "file") files.push(node2); + else node2.children.forEach(visit); + }; + state.nodes.forEach(visit); + const totalBytes = files.reduce((sum, node2) => sum + node2.file.size, 0); + elements.countEl.textContent = normalized.labels.count(files.length, totalBytes); + } + function findNode(nodes, id) { + for (const node2 of nodes) { + if (node2.id === id) return { node: node2, nodes }; + const found = findNode(node2.children || [], id); + if (found) return found; + } + return null; + } + function flattenFiles(nodes, result = []) { + nodes.forEach((node2) => { + if (node2.kind === "file") result.push({ path: node2.path, name: node2.name, size: node2.file.size, file: node2.file }); + else flattenFiles(node2.children, result); + }); + return result; + } + + // upload/frontend/table/on_files_change.js + async function addFiles(state, cfg, files, elements) { + for (const file of Array.from(files)) { + if (!file.name.toLowerCase().endsWith(".zip")) { + if (!cfg.allowedExt.some((extension) => file.name.toLowerCase().endsWith(extension.toLowerCase()))) { + continue; + } + addFileWithDedup(state, { + id: crypto.randomUUID(), + kind: "file", + name: file.name, + path: file.name, + file, + children: [], + expanded: true + }); + continue; + } + try { + const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits); + if (zipTree) addFileWithDedup(state, zipTree); + } catch (error) { + if (typeof cfg.onError === "function") { + cfg.onError(error, file); + } else if (elements.statusEl) { + elements.statusEl.textContent = `\u041E\u0448\u0438\u0431\u043A\u0430 \u0447\u0442\u0435\u043D\u0438\u044F \u0430\u0440\u0445\u0438\u0432\u0430 ${file.name}: ${error.message}`; + } + continue; + } + } + render(state, elements, cfg); + } + function onFilesChange(state, cfg, elements) { + return async () => { + if (state.busy) return; + state.busy = true; + try { + await addFiles(state, cfg, elements.fileInputEl.files, elements); + } finally { + elements.fileInputEl.value = ""; + state.busy = false; + } + }; + } + + // upload/frontend/table/on_folder_change.js + function onFolderChange(state, cfg, elements) { + return async () => { + if (state.busy) return; + state.busy = true; + try { + const roots = /* @__PURE__ */ new Map(); + for (const file of Array.from(elements.folderInputEl.files)) { + 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)) { + roots.set(rootName, { + id: crypto.randomUUID(), + kind: "folder", + name: rootName, + path: rootName, + children: [], + expanded: false + }); + } + const root = roots.get(rootName); + const addToFolder = (node2) => { + let current = root; + const nodeParts = node2.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 ? node2 : { + 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 { + const zip = await listZipFiles(file, cfg.allowedExt, cfg.limits); + if (zip) { + rebaseTree(zip, rootName); + addToFolder(zip); + } + } catch (error) { + if (typeof cfg.onError === "function") { + cfg.onError(error, file); + } else if (elements.statusEl) { + elements.statusEl.textContent = `\u041E\u0448\u0438\u0431\u043A\u0430 \u0447\u0442\u0435\u043D\u0438\u044F \u0430\u0440\u0445\u0438\u0432\u0430 ${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)); + elements.folderInputEl.value = ""; + render(state, elements, cfg); + } finally { + state.busy = false; + } + }; + } + + // upload/frontend/upload/put_to_vm.js + 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 || 3e5; + 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(`\u0411\u0443\u0444\u0435\u0440: HTTP ${xhr.status} ${xhr.statusText}`)); + } + }; + xhr.onerror = () => { + if (options.signal) { + options.signal.removeEventListener("abort", abortHandler); + } + reject(new Error("\u0421\u0435\u0442\u0435\u0432\u0430\u044F \u043E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u0438 \u043E\u0442\u043F\u0440\u0430\u0432\u043A\u0435 \u0432 \u0431\u0443\u0444\u0435\u0440")); + }; + xhr.ontimeout = () => { + if (options.signal) { + options.signal.removeEventListener("abort", abortHandler); + } + reject(new Error(`\u0422\u0430\u0439\u043C\u0430\u0443\u0442 \u043E\u0436\u0438\u0434\u0430\u043D\u0438\u044F \u043E\u0442\u0432\u0435\u0442\u0430 \u0431\u0443\u0444\u0435\u0440\u0430 (${Math.round(xhr.timeout / 1e3)}\u0441)`)); + }; + xhr.onabort = () => { + if (options.signal) { + options.signal.removeEventListener("abort", abortHandler); + } + reject(new DOMException("Upload aborted", "AbortError")); + }; + xhr.send(file); + }); + } + + // upload/frontend/upload/upload_via_vm.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 & 3 | 8; + return v.toString(16); + }); + } + 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("\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 \u043E\u0431\u044A\u0435\u043A\u0442 \u0444\u0430\u0439\u043B\u0430"); + } + async function uploadViaVM(files, options = {}) { + if (!options.vmUploadUrl) { + return { ok: false, error: "\u041F\u0430\u0440\u0430\u043C\u0435\u0442\u0440 options.vmUploadUrl \u043E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u0435\u043D" }; + } + 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: "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u043E\u0442\u043C\u0435\u043D\u0435\u043D\u0430", aborted: true, session: currentSession }; + } + const { name, size, file } = normalizedFiles[k]; + const fileKey = `${generateUuid()}_${k}`; + const fileUrl = `${vmBase}${fileKey}`; + if (options.onStatus) { + options.onStatus(`\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u0432 \u0431\u0443\u0444\u0435\u0440 (${k + 1}/${total}): ${name}`); + } + if (options.onFileStatus) { + options.onFileStatus(k, "\u23F3 \u0412 \u0431\u0443\u0444\u0435\u0440..."); + } + const t0 = performance.now(); + try { + await putToVm(file, fileUrl, { + signal: options.signal, + onXHR: options.onXHR, + onProgress(pct, loaded, fileTotal) { + const elapsed = (performance.now() - t0) / 1e3; + const speed = elapsed > 0 ? loaded / elapsed : 0; + if (options.onFileStatus) { + options.onFileStatus(k, `\u23F3 ${pct}% (${fs(speed)}/s)`); + } + if (options.onProgress) { + options.onProgress({ index: k, total, pct, speed, loaded, fileTotal, name }); + } + } + }); + } catch (err2) { + if (options.signal?.aborted) { + return { ok: false, error: "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u043E\u0442\u043C\u0435\u043D\u0435\u043D\u0430", aborted: true, session: currentSession }; + } + if (options.onFileStatus) { + options.onFileStatus(k, "\u2717 \u041E\u0448\u0438\u0431\u043A\u0430 \u0431\u0443\u0444\u0435\u0440\u0430"); + } + return { + ok: false, + error: `\u041E\u0448\u0438\u0431\u043A\u0430 \u043E\u0442\u043F\u0440\u0430\u0432\u043A\u0438 \u0444\u0430\u0439\u043B\u0430 ${name} \u0432 \u0431\u0443\u0444\u0435\u0440: ${err2.message}`, + failedIndex: k, + session: currentSession + }; + } + if (options.onStatus) { + options.onStatus(`\u041F\u0440\u0438\u0451\u043C \u0441\u0435\u0440\u0432\u0438\u0441\u043E\u043C (${k + 1}/${total}): ${name}`); + } + if (options.onFileStatus) { + options.onFileStatus(k, "\u26A1 \u0412 \u0441\u0435\u0441\u0441\u0438\u044E..."); + } + 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 || "\u0411\u044D\u043A\u0435\u043D\u0434 \u0432\u0435\u0440\u043D\u0443\u043B \u043E\u0448\u0438\u0431\u043A\u0443"); + } + currentSession = data.session || currentSession; + totalAdded = data.count ?? totalAdded + 1; + if (options.onFileStatus) { + options.onFileStatus(k, "\u2713 \u0414\u043E\u0441\u0442\u0430\u0432\u043B\u0435\u043D"); + } + if (options.onFileComplete) { + options.onFileComplete({ + fileIndex: k, + name, + size, + session: currentSession, + totalAdded + }); + } + } catch (err2) { + if (options.signal?.aborted) { + return { ok: false, error: "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u043E\u0442\u043C\u0435\u043D\u0435\u043D\u0430", aborted: true, session: currentSession }; + } + if (options.onFileStatus) { + options.onFileStatus(k, "\u2717 \u041E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u0438\u0451\u043C\u0430"); + } + return { + ok: false, + error: `\u041E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u0438\u0451\u043C\u0430 \u0444\u0430\u0439\u043B\u0430 ${name} \u0431\u044D\u043A\u0435\u043D\u0434\u043E\u043C: ${err2.message}`, + failedIndex: k, + session: currentSession + }; + } + } + if (options.onStatus) { + options.onStatus(`\u0417\u0430\u0432\u0435\u0440\u0448\u0435\u043D\u043E. \u0423\u0441\u043F\u0435\u0448\u043D\u043E \u043F\u0435\u0440\u0435\u0434\u0430\u043D\u043E \u0444\u0430\u0439\u043B\u043E\u0432: ${total}`); + } + return { ok: true, session: currentSession, count: totalAdded }; + } + + // upload/frontend/index.js + var DEFAULTS = { + allowedExt: [], + labels: { + pickFiles: "\u0412\u044B\u0431\u0440\u0430\u0442\u044C \u0444\u0430\u0439\u043B\u044B", + pickFolder: "\u0412\u044B\u0431\u0440\u0430\u0442\u044C \u043F\u0430\u043F\u043A\u0443", + clear: "\u041E\u0447\u0438\u0441\u0442\u0438\u0442\u044C", + empty: "\u041D\u0435\u0442 \u0432\u044B\u0431\u0440\u0430\u043D\u043D\u044B\u0445 \u0444\u0430\u0439\u043B\u043E\u0432", + statusReady: "\u0433\u043E\u0442\u043E\u0432", + remove: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C", + columns: { path: "\u041F\u0443\u0442\u044C", size: "\u0420\u0430\u0437\u043C\u0435\u0440", status: "\u0421\u0442\u0430\u0442\u0443\u0441" }, + count: (count, bytes) => `${count} \u0444\u0430\u0439\u043B\u043E\u0432 \xB7 ${bytes} B` + }, + layout: { columns: ["path", "size", "status"], controls: ["files", "folder", "clear"] }, + limits: { + maxEntries: 1e3, + 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 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D"); + 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) ? `` : ""; + const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(cfg.labels.columns); + const headings = columns.map((column) => `${esc(cfg.labels.columns[column] || column)}`).join(""); + root.innerHTML = ` +
+
+ + + ${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")} +
+

+
${headings}
+

+
`; + } + function notify(cfg, state) { + if (typeof cfg.onChange === "function") cfg.onChange([...flattenFiles(state.nodes)]); + } + function removeMetadata(state, node2) { + if (node2.kind === "file") { + state.fileKeys.delete(`${node2.path}\0${node2.file.size}`); + return; + } + node2.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; + } + 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: /* @__PURE__ */ 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(); + } + }; + } + return __toCommonJS(index_exports); +})(); diff --git a/site/static/favicon.svg b/site/static/favicon.svg new file mode 100644 index 0000000..d76eef6 --- /dev/null +++ b/site/static/favicon.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + diff --git a/site/static/logo.svg b/site/static/logo.svg new file mode 100755 index 0000000..d76eef6 --- /dev/null +++ b/site/static/logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + diff --git a/site/templates/index.html b/site/templates/index.html new file mode 100644 index 0000000..71ceb0d --- /dev/null +++ b/site/templates/index.html @@ -0,0 +1,958 @@ + + + + + + + + + + DrHider — обфускация документов + + + + + +
+ Nubes + DrHider + v{{ version }} + HELP +
+
+
+
Обфускация документов
+
+

+ Загрузите документы (.docx, .doc, .pdf, .txt, .md, .zip) — получите ZIP с обезличенными копиями.
+ CSV с таблицей замен скачивается отдельно.
+ Ограничения: один файл не более 50 МБ, суммарно не более 500 МБ. Файлы сверх + лимитов помечаются статусом «пропущен» и не участвуют в обфускации. Пустые, повреждённые файлы и сканы без + текстового слоя получают статус «не извлечён» — они тоже не попадают в результат.
+ ⏱ Время обработки каждого файла — ориентировочное (обновляется по факту). +

+ +
+ + +
+
+ + +
+
+
⏱ Обработка…
+
—
+
0.0 с
+
🤖 ИИ обрабатывает… 0.0 с
+
+
+
+
📊 Итоги обработки
+
+
+
Общее время
+
—
+
+
+
Из них ИИ
+
—
+
+
+
Токены ИИ
+
—
+
+
+
+
+
+
+ + + + + + + \ No newline at end of file diff --git a/upload/__init__.py b/upload/__init__.py new file mode 100644 index 0000000..c04fe25 --- /dev/null +++ b/upload/__init__.py @@ -0,0 +1,5 @@ +"""Переиспользуемые слои загрузки файлов. + +Пакет не выполняет импортов и не запускает побочных действий при загрузке. +Рабочая picker-only логика находится в подпакетах ``frontend`` и ``zip``. +""" \ No newline at end of file diff --git a/upload/backend/__init__.py b/upload/backend/__init__.py new file mode 100644 index 0000000..0cdaa2f --- /dev/null +++ b/upload/backend/__init__.py @@ -0,0 +1 @@ +"""Backend переиспользуемого модуля загрузки: upload_refs + session.""" diff --git a/upload/backend/session/__init__.py b/upload/backend/session/__init__.py new file mode 100644 index 0000000..1b8b0f4 --- /dev/null +++ b/upload/backend/session/__init__.py @@ -0,0 +1,38 @@ +"""In-memory хранилище сессий с TTL и лимитами. + +Каждая операция — в отдельном файле, общее состояние — в state.py. +Импорт как единый пакет: + + from upload.backend.session import create_session, add_file, get_files +""" + +from .create_session import create_session +from .add_file import add_file +from .get_files import get_files, file_count +from .store_result import store_result, get_result +from .store_csv import store_csv, get_csv +from .ttl import touch, pause_ttl, resume_ttl +from .cancel import request_cancel, get_cancel_event +from .cleanup import cleanup +from .state import TTL_SECONDS, MAX_FILE_BYTES, MAX_SESSION_BYTES, configure + +__all__ = [ + "create_session", + "add_file", + "get_files", + "file_count", + "store_result", + "get_result", + "store_csv", + "get_csv", + "touch", + "pause_ttl", + "resume_ttl", + "request_cancel", + "get_cancel_event", + "cleanup", + "configure", + "TTL_SECONDS", + "MAX_FILE_BYTES", + "MAX_SESSION_BYTES", +] diff --git a/upload/backend/session/add_file.py b/upload/backend/session/add_file.py new file mode 100644 index 0000000..f598d63 --- /dev/null +++ b/upload/backend/session/add_file.py @@ -0,0 +1,25 @@ +"""add_file — добавить файл в сессию (с проверкой суммарного лимита).""" + +from . import state + + +def add_file(sid: str, filename: str, content: bytes) -> bool: + """Добавить файл в сессию. + + Args: + sid: Идентификатор сессии + filename: Имя файла + content: Бинарное содержимое + + Returns: + True если добавлено; False если сессии нет или превышен лимит сессии. + """ + with state._lock: + s = state._sessions.get(sid) + if not s: + return False + total = sum(len(c) for _, c in s["files"]) + if total + len(content) > state.MAX_SESSION_BYTES: + return False # превышен суммарный лимит сессии + s["files"].append((filename, content)) + return True diff --git a/upload/backend/session/cancel.py b/upload/backend/session/cancel.py new file mode 100644 index 0000000..ad39d8a --- /dev/null +++ b/upload/backend/session/cancel.py @@ -0,0 +1,27 @@ +"""request_cancel / get_cancel_event — мягкое прерывание обработки сессии.""" + +import threading +from typing import Optional + +from .state import _sessions, _lock + + +def request_cancel(sid: str) -> bool: + """Запросить мягкое прерывание обработки сессии. + + Returns: + True если сессия существует и отмена запрошена, False если нет. + """ + with _lock: + s = _sessions.get(sid) + if not s: + return False + s["cancel"].set() + return True + + +def get_cancel_event(sid: str) -> Optional[threading.Event]: + """Получить событие отмены сессии (или None, если сессии нет).""" + with _lock: + s = _sessions.get(sid) + return s["cancel"] if s else None diff --git a/upload/backend/session/cleanup.py b/upload/backend/session/cleanup.py new file mode 100644 index 0000000..be0d8f2 --- /dev/null +++ b/upload/backend/session/cleanup.py @@ -0,0 +1,11 @@ +"""cleanup — удалить сессию.""" + +from .state import _sessions, _lock + + +def cleanup(sid: str): + """Удалить сессию и остановить её TTL-таймер.""" + with _lock: + s = _sessions.pop(sid, None) + if s and s.get("timer"): + s["timer"].cancel() diff --git a/upload/backend/session/create_session.py b/upload/backend/session/create_session.py new file mode 100644 index 0000000..6c29d8a --- /dev/null +++ b/upload/backend/session/create_session.py @@ -0,0 +1,23 @@ +"""create_session — создать новую сессию.""" + +import threading +import uuid + +from .state import _sessions, _lock, _start_timer + + +def create_session() -> str: + """Создать новую сессию. + + Returns: + Уникальный идентификатор сессии (UUID). + """ + sid = uuid.uuid4().hex + with _lock: + _sessions[sid] = { + "files": [], + "result": None, + "cancel": threading.Event(), + "timer": _start_timer(sid), + } + return sid diff --git a/upload/backend/session/get_files.py b/upload/backend/session/get_files.py new file mode 100644 index 0000000..74a6bca --- /dev/null +++ b/upload/backend/session/get_files.py @@ -0,0 +1,23 @@ +"""get_files / file_count — чтение файлов сессии.""" + +from typing import List, Optional, Tuple + +from .state import _sessions, _lock + + +def get_files(sid: str) -> Optional[List[Tuple[str, bytes]]]: + """Получить все файлы сессии. + + Returns: + [(filename, content), ...] или None если сессия не найдена. + """ + with _lock: + s = _sessions.get(sid) + return list(s["files"]) if s else None + + +def file_count(sid: str) -> int: + """Количество файлов в сессии.""" + with _lock: + s = _sessions.get(sid) + return len(s["files"]) if s else 0 diff --git a/upload/backend/session/state.py b/upload/backend/session/state.py new file mode 100644 index 0000000..dc70a72 --- /dev/null +++ b/upload/backend/session/state.py @@ -0,0 +1,45 @@ +"""Общее состояние сессий: хранилище, блокировка, константы, TTL-таймер. + +Единая точка хранения состояния — все операции импортируют её. +Дробить state.py на файл-на-переменную не нужно: это данные, а не функции. +""" + +import threading + +# TTL сессии: 30 минут +TTL_SECONDS = 30 * 60 + +# Максимальный объём одного файла и суммарный объём файлов в сессии (защита памяти) +MAX_FILE_BYTES = 50 * 1024 * 1024 # 50 MB на один файл +MAX_SESSION_BYTES = 500 * 1024 * 1024 # 500 MB суммарно на сессию + +_sessions: dict = {} +_lock = threading.Lock() + + +def _start_timer(sid: str) -> threading.Timer: + """Запустить таймер автоочистки сессии через TTL.""" + + def _clean(): + with _lock: + _sessions.pop(sid, None) + + timer = threading.Timer(TTL_SECONDS, _clean) + timer.daemon = True + timer.start() + return timer + + +def configure(max_file_bytes: int = None, max_session_bytes: int = None, + ttl_seconds: int = None): + """Переопределить лимиты/TTL из конфига приложения (глобально). + + None — оставить текущее значение. + """ + global MAX_FILE_BYTES, MAX_SESSION_BYTES, TTL_SECONDS + if max_file_bytes is not None: + MAX_FILE_BYTES = max_file_bytes + if max_session_bytes is not None: + MAX_SESSION_BYTES = max_session_bytes + if ttl_seconds is not None: + TTL_SECONDS = ttl_seconds diff --git a/upload/backend/session/store_csv.py b/upload/backend/session/store_csv.py new file mode 100644 index 0000000..f7b6d4a --- /dev/null +++ b/upload/backend/session/store_csv.py @@ -0,0 +1,30 @@ +"""store_csv / get_csv — сохранение и чтение CSV с таблицей замен.""" + +from typing import Optional + +from .state import _sessions, _lock + + +def store_csv(sid: str, csv_str: str) -> bool: + """Сохранить CSV с таблицей замен. + + Returns: + True если сохранено, False если сессии нет. + """ + with _lock: + s = _sessions.get(sid) + if not s: + return False + s["csv"] = csv_str + return True + + +def get_csv(sid: str) -> Optional[str]: + """Получить CSV с таблицей замен. + + Returns: + Строка CSV или None если нет. + """ + with _lock: + s = _sessions.get(sid) + return s.get("csv") if s else None diff --git a/upload/backend/session/store_result.py b/upload/backend/session/store_result.py new file mode 100644 index 0000000..e39542a --- /dev/null +++ b/upload/backend/session/store_result.py @@ -0,0 +1,30 @@ +"""store_result / get_result — сохранение и чтение результата обработки.""" + +from typing import Optional + +from .state import _sessions, _lock + + +def store_result(sid: str, zip_data: bytes) -> bool: + """Сохранить результат обработки (ZIP-архив). + + Returns: + True если сохранено, False если сессии нет. + """ + with _lock: + s = _sessions.get(sid) + if not s: + return False + s["result"] = zip_data + return True + + +def get_result(sid: str) -> Optional[bytes]: + """Получить результат обработки. + + Returns: + ZIP-архив или None если сессия не найдена/результат не готов. + """ + with _lock: + s = _sessions.get(sid) + return s["result"] if s else None diff --git a/upload/backend/session/ttl.py b/upload/backend/session/ttl.py new file mode 100644 index 0000000..06abb9a --- /dev/null +++ b/upload/backend/session/ttl.py @@ -0,0 +1,34 @@ +"""touch / pause_ttl / resume_ttl — управление TTL-таймером сессии.""" + +from .state import _sessions, _lock, _start_timer + + +def touch(sid: str): + """Продлить жизнь сессии: перезапустить TTL-таймер (если сессия существует).""" + with _lock: + s = _sessions.get(sid) + if not s: + return + if s.get("timer"): + s["timer"].cancel() + s["timer"] = _start_timer(sid) + + +def pause_ttl(sid: str): + """Приостановить TTL сессии (во время обработки): сессия живёт, пока идёт воркер.""" + with _lock: + s = _sessions.get(sid) + if s and s.get("timer"): + s["timer"].cancel() + s["timer"] = None + + +def resume_ttl(sid: str): + """Возобновить TTL сессии (после завершения обработки): результат доступен ещё TTL.""" + with _lock: + s = _sessions.get(sid) + if not s: + return + if s.get("timer"): + s["timer"].cancel() + s["timer"] = _start_timer(sid) diff --git a/upload/backend/upload_refs/__init__.py b/upload/backend/upload_refs/__init__.py new file mode 100644 index 0000000..71d986b --- /dev/null +++ b/upload/backend/upload_refs/__init__.py @@ -0,0 +1,7 @@ +"""upload_refs — Flask Blueprint для приёма ссылок и pull с ВМ-буфера.""" + +from .blueprint import create_upload_refs_blueprint +from .safe_name import safe_name +from .pull_file import pull_file + +__all__ = ["create_upload_refs_blueprint", "safe_name", "pull_file"] diff --git a/upload/backend/upload_refs/blueprint.py b/upload/backend/upload_refs/blueprint.py new file mode 100644 index 0000000..e1c53f9 --- /dev/null +++ b/upload/backend/upload_refs/blueprint.py @@ -0,0 +1,145 @@ +"""Переиспользуемый Blueprint слоя 2: POST /upload_refs (pull с ВМ-буфера в сессию). + +Поведение: +- safe_name (защита от path traversal) +- SSRF-валидация url.startswith(vm_prefix) +- лимит на один файл -> delete + skip +- pull с ретраями через httpx stream +- лимит сессии -> skip; отсутствие сессии -> 404 +- delete url с ВМ (best-effort) +- поддержка пофайлового транзита и пачек +- опциональный callback для интеграции/эмуляции Слоя 3 (on_file_received) +""" + +import httpx +import logging +from urllib.parse import urlsplit +from flask import Blueprint, request, jsonify, current_app + +from ..session import (create_session, add_file, get_files, file_count, + MAX_FILE_BYTES, configure) +from .config import PULL_RETRIES, PULL_RETRY_DELAY, VM_UPLOAD_PREFIX +from .safe_name import safe_name +from .pull_file import pull_file + +log = logging.getLogger("upload.upload_refs") + + +def create_upload_refs_blueprint(cfg: dict = None) -> Blueprint: + """Создать Blueprint с эндпоинтом upload_refs. + + cfg (все ключи опциональны, есть дефолты): + apiPrefix (str) — префикс Blueprint, по умолчанию "/api" + vmUploadPrefix (str) — доверенный префикс ВМ-буфера (SSRF-валидация) + maxFileBytes (int) — лимит на один файл + maxSessionBytes (int) — суммарный лимит сессии (применяется к сессиям) + ttlSeconds (int) — TTL сессии + pullRetries (int) — ретраи pull + pullRetryDelay (int) — пауза между ретраями (сек) + pullTimeout (int) — таймаут одного GET pull + onFileReceived (func) — опциональный callback (sid, name, content) для Слоя 3 + """ + cfg = cfg or {} + prefix = cfg.get("apiPrefix", "/api") + vm_prefix = cfg.get("vmUploadPrefix", VM_UPLOAD_PREFIX) + max_file_bytes = cfg.get("maxFileBytes", MAX_FILE_BYTES) + pull_retries = cfg.get("pullRetries", PULL_RETRIES) + pull_delay = cfg.get("pullRetryDelay", PULL_RETRY_DELAY) + pull_timeout = cfg.get("pullTimeout", 120) + on_file_received = cfg.get("onFileReceived") + + # Применить лимиты сессии/TTL из конфига (глобально для всех сессий) + configure( + max_file_bytes=cfg.get("maxFileBytes"), + max_session_bytes=cfg.get("maxSessionBytes"), + ttl_seconds=cfg.get("ttlSeconds"), + ) + + is_path_only_prefix = vm_prefix.startswith("/") + + bp = Blueprint("upload_refs", __name__, url_prefix=prefix) + + @bp.route("/upload_refs", methods=["POST"]) + def upload_refs(): + """Принять ссылки на файлы (загружены на ВМ-буфер), забрать по egress. + + Вход: JSON {"session": "...", "files": [{"name": str, "size": int, "url": str}]}. + Каждый файл тянется исходящим GET с ВМ (egress не ограничен шлюзом), + читается по частям (stream), кладётся в сессию. После успешного pull файл + удаляется с ВМ (DELETE). + """ + data = request.get_json(silent=True) or {} + sid = data.get("session") or create_session() + refs = data.get("files") or [] + if not refs: + log.warning("upload_refs: no files, sid=%s", sid) + return jsonify({"ok": False, "error": "No files"}), 400 + added = 0 + transport = cfg.get("httpxTransport") or current_app.config.get("UPLOAD_HTTPX_TRANSPORT") + try: + with httpx.Client(transport=transport, timeout=pull_timeout, follow_redirects=True) as client: + for ref in refs: + name = safe_name(ref.get("name") or "") + url = ref.get("url") or "" + if not name or not url: + continue + # SSRF-защита: тянуть можно ТОЛЬКО с доверенного ВМ-буфера + if is_path_only_prefix: + if not urlsplit(url).path.startswith(vm_prefix): + log.warning("upload_refs: unsafe URL path, skip sid=%s url=%r", sid, url) + continue + if not url.startswith(("http://", "https://")): + url = request.host_url.rstrip("/") + ("/" if not url.startswith("/") else "") + url + else: + if not url.startswith(vm_prefix): + log.warning("upload_refs: unsafe URL, skip sid=%s url=%r", sid, url) + continue + # Лимит на один файл: сверх лимита — пропускаем + if (ref.get("size") or 0) > max_file_bytes: + log.warning("upload_refs: file exceeds %dMB, skip sid=%s file=%r size=%s", + max_file_bytes // (1024 * 1024), sid, name, ref.get("size")) + try: + client.delete(url) + except Exception: + pass + continue + # Pull с ретраями + content = pull_file(client, url, pull_retries, pull_delay, sid=sid, name=name) + log.info("upload_refs: pulled sid=%s file=%r size=%d", sid, name, len(content)) + if len(content) > max_file_bytes: + log.warning("upload_refs: pulled file exceeds %dMB, skip sid=%s file=%r size=%d", + max_file_bytes // (1024 * 1024), sid, name, len(content)) + try: + client.delete(url) + except Exception: + pass + continue + if not add_file(sid, name, content): + if get_files(sid) is None: + log.warning("upload_refs: session not found, sid=%s file=%r", sid, name) + return jsonify({"ok": False, "error": "Session not found"}), 404 + log.warning("upload_refs: session limit exceeded, skip sid=%s file=%r", sid, name) + try: + client.delete(url) + except Exception: + pass + continue + # Успешно добавлен в сессию — удаляем с ВМ-буфера + try: + client.delete(url) + except Exception: + pass + # Если передан callback для Слоя 3 (эмуляция или реальный процессинг) + if callable(on_file_received): + try: + on_file_received(sid, name, content) + except Exception as cb_err: + log.warning("upload_refs: on_file_received callback error: %r", cb_err) + added += 1 + except Exception as e: + log.error("upload_refs: pull error sid=%s: %r", sid, e) + return jsonify({"ok": False, "error": "Pull failed: %s" % e}), 502 + log.info("upload_refs: done sid=%s added=%d total=%d", sid, added, file_count(sid)) + return jsonify({"ok": True, "session": sid, "count": file_count(sid), "added": added}) + + return bp diff --git a/upload/backend/upload_refs/config.py b/upload/backend/upload_refs/config.py new file mode 100644 index 0000000..ff77692 --- /dev/null +++ b/upload/backend/upload_refs/config.py @@ -0,0 +1,11 @@ +"""Параметры слоя 2 (бэк) по умолчанию. + +Переопределяются из конфига приложения через create_upload_refs_blueprint(cfg). +""" + +# Ретраи pull из ВМ-буфера: защита от разовых DNS/сетевых сбоев (gaierror -5 и т.п.) +PULL_RETRIES = 3 +PULL_RETRY_DELAY = 2 # секунды между попытками + +# Доверенный префикс ВМ-буфера по умолчанию — валидация URL при pull (защита от SSRF) +VM_UPLOAD_PREFIX = "https://upload.sinefo.ru/drhider-upload/" diff --git a/upload/backend/upload_refs/pull_file.py b/upload/backend/upload_refs/pull_file.py new file mode 100644 index 0000000..9fab75c --- /dev/null +++ b/upload/backend/upload_refs/pull_file.py @@ -0,0 +1,39 @@ +"""pull_file — вытащить файл с ВМ-буфера исходящим GET с ретраями.""" + +import logging +import time + +from .config import PULL_RETRIES, PULL_RETRY_DELAY + +log = logging.getLogger("upload.upload_refs.pull") + + +def pull_file(client, url: str, retries: int = PULL_RETRIES, + delay: float = PULL_RETRY_DELAY, sid: str = None, name: str = None) -> bytes: + """GET url с ретраями; читает по частям (stream). + + Args: + client: httpx.Client + url: URL файла на ВМ-буфере. + retries: число попыток. + delay: пауза между попытками (сек). + sid/name: для логирования (опционально). + + Returns: + Содержимое файла (bytes). + + Raises: + Последнюю ошибку попытки, если все ретраи не удались. + """ + last_err = None + for attempt in range(retries): + try: + with client.stream("GET", url) as resp: + resp.raise_for_status() + return b"".join(resp.iter_bytes()) + except Exception as e: + last_err = e + log.warning("pull: attempt %d/%d failed sid=%s file=%r: %r", + attempt + 1, retries, sid, name, e) + time.sleep(delay) + raise last_err if last_err else RuntimeError("pull failed") diff --git a/upload/backend/upload_refs/safe_name.py b/upload/backend/upload_refs/safe_name.py new file mode 100644 index 0000000..8eca581 --- /dev/null +++ b/upload/backend/upload_refs/safe_name.py @@ -0,0 +1,16 @@ +"""safe_name — санитизация имени файла (защита от path traversal).""" + + +def safe_name(name: str) -> str: + """Санитизировать имя файла: защита от path traversal, сохраняя подпапки. + + Запрещает '..' и абсолютные пути; нормализует слэши. Возвращает "" если + имя пустое или небезопасное. + """ + if not name: + return "" + name = name.replace("\\", "/") + parts = [p for p in name.split("/") if p and p != "."] + if not parts or any(p == ".." for p in parts): + return "" + return "/".join(parts) diff --git a/upload/frontend/index.js b/upload/frontend/index.js new file mode 100644 index 0000000..f360dd7 --- /dev/null +++ b/upload/frontend/index.js @@ -0,0 +1,193 @@ +import { addFiles, onFilesChange } from './table/on_files_change.js'; +import { onFolderChange } from './table/on_folder_change.js'; +import { findNode, flattenFiles, render } from './table/render.js'; +import { esc } from './table/esc.js'; + +const DEFAULTS = { + allowedExt: [], + labels: { + pickFiles: 'Выбрать файлы', + pickFolder: 'Выбрать папку', + clear: 'Очистить', + empty: 'Нет выбранных файлов', + statusReady: 'готов', + remove: 'Удалить', + columns: { path: 'Путь', size: 'Размер', status: 'Статус' }, + count: (count, bytes) => `${count} файлов · ${bytes} B`, + }, + layout: { columns: ['path', 'size', 'status'], controls: ['files', 'folder', 'clear'] }, + limits: { + maxEntries: 1000, + maxTotalBytes: 100 * 1024 * 1024, + maxEntryBytes: 50 * 1024 * 1024, + maxDepth: 20, + }, +}; + +function resolveMount(mount) { + const element = typeof mount === 'string' ? document.querySelector(mount) : mount; + if (!element) throw new Error('File picker mount element не найден'); + return element; +} + +function normalizeConfig(config) { + return { + ...config, + allowedExt: config.allowedExt || DEFAULTS.allowedExt, + labels: { + ...DEFAULTS.labels, + ...(config.labels || {}), + columns: { ...DEFAULTS.labels.columns, ...(config.labels?.columns || {}) }, + }, + layout: { ...DEFAULTS.layout, ...(config.layout || {}) }, + limits: { ...DEFAULTS.limits, ...(config.limits || {}) }, + }; +} + +function injectStyles() { + if (document.querySelector('style[data-file-picker]')) return; + const style = document.createElement('style'); + style.dataset.filePicker = ''; + style.textContent = ` +.file-picker { color: #17211b; font-family: Georgia, 'Times New Roman', serif; } +.file-picker .fp-toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; } +.file-picker .fp-toolbar button { border: 0; border-radius: 4px; padding: 12px 17px; color: #f7faf4; background: #1f5a3b; font: 700 13px sans-serif; cursor: pointer; } +.file-picker .fp-toolbar button.secondary { color: #1f5a3b; background: #cbdcc9; } +.file-picker .fp-toolbar button.quiet { color: #5d6a61; background: transparent; } +.file-picker .fp-table-wrap { overflow-x: auto; border-top: 1px solid #b9c7ba; } +.file-picker .fp-table { width: 100%; border-collapse: collapse; font: 14px/1.4 sans-serif; } +.file-picker .fp-table th, .file-picker .fp-table td { padding: 14px 10px; border-bottom: 1px solid #cbd5cc; text-align: left; } +.file-picker .fp-table th { color: #56745f; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; } +.file-picker .fp-status, .file-picker .fp-count { color: #5d6a61; font: 13px sans-serif; } +.file-picker .fp-count { min-height: 22px; } +.file-picker .tree-name { display: inline-flex; align-items: center; gap: 8px; min-width: 240px; padding-top: 0; padding-bottom: 0; border: 0; background: transparent; color: #17211b; font: inherit; text-align: left; } +.file-picker .tree-group { padding-left: 0; cursor: pointer; } +.file-picker .tree-chevron { display: inline-block; width: 12px; color: #78917d; font-size: 10px; } +.file-picker .remove-btn { padding: 2px 7px; color: #8c5148; background: transparent; font: 20px/1 sans-serif; } +.file-picker .tree-row td:first-child { white-space: nowrap; } +@media (max-width: 600px) { .file-picker .fp-toolbar button { flex: 1 1 42%; } } +`; + document.head.append(style); +} + +function buildMarkup(root, cfg) { + const controls = cfg.layout.controls || []; + const control = (name, id, label, className = '') => controls.includes(name) + ? `` : ''; + const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(cfg.labels.columns); + const headings = columns.map((column) => `${esc(cfg.labels.columns[column] || column)}`).join(''); + root.innerHTML = ` +
+
+ + + ${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')} +
+

+
${headings}
+

+
`; +} + +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 }; + diff --git a/upload/frontend/table/add_file_with_dedup.js b/upload/frontend/table/add_file_with_dedup.js new file mode 100644 index 0000000..9ef48b8 --- /dev/null +++ b/upload/frontend/table/add_file_with_dedup.js @@ -0,0 +1,54 @@ +/** + * Добавляет узел файла или группы в состояние picker с рекурсивной дедупликацией. + * + * @param {{nodes: Array, fileKeys: Set}} 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); + }); +} \ No newline at end of file diff --git a/upload/frontend/table/esc.js b/upload/frontend/table/esc.js new file mode 100644 index 0000000..1244483 --- /dev/null +++ b/upload/frontend/table/esc.js @@ -0,0 +1,18 @@ +/** + * Экранирует текст перед вставкой в HTML-шаблон, собираемый через innerHTML. + * + * @param {*} value Имя файла, путь или сообщение статуса. + * @returns {string} Строка с заменёнными HTML-значимыми символами. + * + * Экранируются и кавычки, потому что значение может попасть не только в текст + * кнопки, но и в HTML-атрибут вроде data-path или aria-label. + */ +export function esc(value) { + return String(value).replace(/[&<>"']/g, (character) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[character])); +} \ No newline at end of file diff --git a/upload/frontend/table/fs.js b/upload/frontend/table/fs.js new file mode 100644 index 0000000..0feaa7b --- /dev/null +++ b/upload/frontend/table/fs.js @@ -0,0 +1,11 @@ +/** + * Преобразует размер файла из байтов в короткую строку для таблицы. + * + * @param {number} bytes Размер в байтах. + * @returns {string} Значение в B, KB или MB с одним знаком после запятой. + */ +export function fs(bytes) { + return bytes < 1024 ? `${bytes} B` + : bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB` + : `${(bytes / 1048576).toFixed(1)} MB`; +} \ No newline at end of file diff --git a/upload/frontend/table/on_files_change.js b/upload/frontend/table/on_files_change.js new file mode 100644 index 0000000..6e9ee0b --- /dev/null +++ b/upload/frontend/table/on_files_change.js @@ -0,0 +1,72 @@ +import { listZipFiles } from '../zip/list_zip_files.js'; +import { addFileWithDedup } from './add_file_with_dedup.js'; +import { render } from './render.js'; + +/** + * Обрабатывает список файлов из обычного file input. + * + * @param {{fileKeys: Set, 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} Завершается после разбора всех 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; + } + }; +} \ No newline at end of file diff --git a/upload/frontend/table/on_folder_change.js b/upload/frontend/table/on_folder_change.js new file mode 100644 index 0000000..5ffed6c --- /dev/null +++ b/upload/frontend/table/on_folder_change.js @@ -0,0 +1,84 @@ +import { listZipFiles } from '../zip/list_zip_files.js'; +import { addFileWithDedup } from './add_file_with_dedup.js'; +import { rebaseTree } from './rebase_tree.js'; +import { render } from './render.js'; + +/** + * Создаёт async-обработчик выбора каталога через webkitdirectory. + * + * @param {{nodes: Array, fileKeys: Set, 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; + } + }; +} \ No newline at end of file diff --git a/upload/frontend/table/rebase_tree.js b/upload/frontend/table/rebase_tree.js new file mode 100644 index 0000000..af78db6 --- /dev/null +++ b/upload/frontend/table/rebase_tree.js @@ -0,0 +1,16 @@ +/** + * Добавляет префикс корневой папки к каждому пути дерева. + * + * @param {{path: string, file?: File, children: Array}} root Узел дерева. + * @param {string} prefix Путь выбранной папки, в которую попал узел. + * @returns {object} Тот же узел после изменения путей. + * + * Для leaf-файлов создаётся новый File с тем же базовым именем; логический путь + * хранится отдельно в узле дерева. + */ +export function rebaseTree(root, prefix) { + root.path = `${prefix}/${root.path}`; + if (root.file) root.file = new File([root.file], root.file.name, { lastModified: root.file.lastModified }); + root.children.forEach((child) => rebaseTree(child, prefix)); + return root; +} diff --git a/upload/frontend/table/render.js b/upload/frontend/table/render.js new file mode 100644 index 0000000..76b8967 --- /dev/null +++ b/upload/frontend/table/render.js @@ -0,0 +1,124 @@ +import { esc } from './esc.js'; +import { fs } from './fs.js'; + +const DEFAULT_RENDER_CONFIG = { + labels: { + statusReady: 'готов', + remove: 'Удалить', + empty: 'Нет выбранных файлов', + columns: { path: 'Путь', size: 'Размер', status: 'Статус' }, + count: (count, bytes) => `${count} файлов · ${fs(bytes)}`, + }, + layout: { columns: ['path', 'size', 'status'] }, +}; + +function renderConfig(cfg = {}) { + return { + labels: { + ...DEFAULT_RENDER_CONFIG.labels, + ...(cfg.labels || {}), + columns: { + ...DEFAULT_RENDER_CONFIG.labels.columns, + ...(cfg.labels?.columns || {}), + }, + }, + layout: { ...DEFAULT_RENDER_CONFIG.layout, ...(cfg.layout || {}) }, + }; +} + +/** + * Рендерит один узел дерева и его раскрытых потомков в HTML-строки таблицы. + * + * @param {{kind: string, id: string, name: string, path: string, file?: File, + * children: Array, expanded: boolean, error?: string}} node Узел file/folder/zip. + * @param {number} depth Глубина узла; используется для визуального отступа. + * @returns {string} HTML одной строки или поддерева строк. + * + * Имена и пути проходят через esc до вставки в innerHTML. Группа получает + * кнопку раскрытия, leaf-файл — размер, статус и data-path для будущего API статусов. + */ +function renderNode(node, depth, cfg) { + const isGroup = node.kind !== 'file'; + const padding = depth * 4; + const name = esc(node.name); + const action = isGroup + ? `` + : `${name}`; + const remove = ``; + 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 = `` + + columns.map((column) => `${cells[column] || ''}`).join('') + + `${remove}`; + 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('') + : `${esc(normalized.labels.empty)}`; + 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; +} \ No newline at end of file diff --git a/upload/frontend/upload/put_to_vm.js b/upload/frontend/upload/put_to_vm.js new file mode 100644 index 0000000..b2e6fa4 --- /dev/null +++ b/upload/frontend/upload/put_to_vm.js @@ -0,0 +1,78 @@ +/** + * putToVm — отправка одного файла в буфер методом HTTP PUT (сырое бинарное тело). + * + * @param {Blob|File} file Файл для отправки. + * @param {string} url Полный целевой URL в буфере. + * @param {Object} options Опции отправки: + * @param {function(number, number, number): void} [options.onProgress] Колбэк прогресса (pct, loaded, total). + * @param {function(XMLHttpRequest): void} [options.onXHR] Колбэк регистрации XHR для прямого контроля. + * @param {AbortSignal} [options.signal] Сигнал прерывания запроса (AbortController.signal). + * @param {number} [options.timeoutMs=300000] Таймаут в миллисекундах (по умолчанию 300с). + * @returns {Promise} Разрешается при ответе 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); + }); +} diff --git a/upload/frontend/upload/upload_via_vm.js b/upload/frontend/upload/upload_via_vm.js new file mode 100644 index 0000000..8386e58 --- /dev/null +++ b/upload/frontend/upload/upload_via_vm.js @@ -0,0 +1,188 @@ +/** + * uploadViaVM — Слой 2 (Фронтенд): пофайловый транзит через ВМ-буфер в сессию бэкенда. + * + * Архитектура потокового транзита: + * Для каждого файла k: + * 1. Браузер отправляет файл k методом PUT в буфер на ВМ. + * 2. Браузер сразу делает POST /api/upload_refs для одного файла k. + * 3. Бэкенд забирает файл исходящим GET в память RAM и сразу шлёт DELETE в буфер. + * 4. Файл удалён из буфера ВМ, память чиста. + * 5. Браузер переходит к файлу k+1. + */ + +import { putToVm } from './put_to_vm.js'; +import { fs } from '../table/fs.js'; + +function generateUuid() { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +/** + * Нормализует элемент списка файлов (поддерживает как File, так и объект из FilePicker.getFiles()). + */ +function normalizeFileEntry(item) { + if (item instanceof File || item instanceof Blob) { + return { name: item.name || 'unnamed', size: item.size, file: item, path: item.name || '' }; + } + if (item && item.file) { + return { + name: item.name || item.file.name || 'unnamed', + size: item.size ?? item.file.size ?? 0, + file: item.file, + path: item.path || item.name || '', + }; + } + throw new Error('Некорректный объект файла'); +} + +/** + * Выполняет пофайловую транзитную загрузку файлов в сессию бэкенда через буфер. + * + * @param {Array} 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 }; +} diff --git a/upload/frontend/zip/list_zip_files.js b/upload/frontend/zip/list_zip_files.js new file mode 100644 index 0000000..0a9e4d4 --- /dev/null +++ b/upload/frontend/zip/list_zip_files.js @@ -0,0 +1,125 @@ +// Построить дерево ZIP с файлами только разрешённых расширений. +import { unzipSync } from 'fflate'; +import { rebaseTree } from '../table/rebase_tree.js'; + +const DEFAULT_LIMITS = { + maxEntries: 1000, + maxTotalBytes: 100 * 1024 * 1024, + maxEntryBytes: 50 * 1024 * 1024, + maxDepth: 20, +}; + +/** Возвращает true, если имя заканчивается одним из разрешённых расширений. */ +function extensionAllowed(name, allowedExt) { + const lowerName = name.toLowerCase(); + return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase())); +} + +/** Создаёт browser File из байтов ZIP entry с базовым именем файла. */ +function makeFile(data, name) { + return new File([data], name.split('/').at(-1)); +} + +/** + * Валидирует путь ZIP entry до его использования в дереве. + * + * @param {string} entryName Сырой путь из центрального каталога ZIP. + * @returns {string[]|null} Безопасные сегменты пути или null для опасной записи. + * + * Отклоняются абсолютные пути, backslash, пустые сегменты, `.` и `..`. + * Это предотвращает traversal и появление ложных групп в UI. + */ +function safeEntryParts(entryName) { + if (!entryName || entryName.startsWith('/') || entryName.includes('\\')) return null; + const parts = entryName.split('/'); + if (parts.some((part) => !part || part === '.' || part === '..')) return null; + return parts; +} + +/** Создаёт единый узел file, folder или zip с уникальным id и начальным состоянием раскрытия. */ +function node(kind, name, path, children = [], file = null) { + return { id: crypto.randomUUID(), kind, name, path, children, file, expanded: kind === 'file' }; +} + +/** Вставляет leaf или вложенное дерево по сегментам пути, создавая folder-узлы. */ +function addPath(root, parts, fileNode) { + let current = root; + parts.forEach((part, index) => { + const last = index === parts.length - 1; + let child = current.children.find((item) => item.name === part); + if (!child) { + child = last + ? fileNode + : node('folder', part, `${current.path}/${part}`); + current.children.push(child); + } + current = child; + }); +} + +/** + * Рекурсивно читает байтовый массив ZIP и строит дерево разрешённых файлов. + * + * @param {Uint8Array} data Распаковываемые байты ZIP. + * @param {string} zipName Имя текущего архива для корневого узла и пути. + * @param {string[]} allowedExt Разрешённые расширения документов. + * @param {number} depth Текущая глубина вложенных ZIP. + * @returns {Promise} Дерево или 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} Корневой 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 }); +} \ No newline at end of file