feat: интегрировать модуль upload в drhider (бэк blueprint + фронт ES-модули)

This commit is contained in:
“Naeel”
2026-08-25 08:52:50 +03:00
parent af172115b0
commit a33dff2f47
4 changed files with 117 additions and 554 deletions
+11
View File
@@ -1,6 +1,7 @@
from .main_bp import main_bp from .main_bp import main_bp
from .health_bp import health_bp from .health_bp import health_bp
from .api_bp import api_bp from .api_bp import api_bp
from upload.backend.upload_refs import create_upload_refs_blueprint
def register_routes(app): def register_routes(app):
@@ -12,3 +13,13 @@ def register_routes(app):
app.register_blueprint(main_bp) app.register_blueprint(main_bp)
app.register_blueprint(health_bp) app.register_blueprint(health_bp)
app.register_blueprint(api_bp) app.register_blueprint(api_bp)
# Слой 2 (закачка через ВМ) — переиспользуемый blueprint из модуля upload
app.register_blueprint(create_upload_refs_blueprint({
"apiPrefix": "/api",
"vmUploadPrefix": "https://contracts.kube5s.ru/drhider-upload/",
"maxFileBytes": 50 * 1024 * 1024,
"maxSessionBytes": 500 * 1024 * 1024,
"ttlSeconds": 30 * 60,
"pullRetries": 3,
"pullRetryDelay": 2,
}))
+6 -113
View File
@@ -17,41 +17,19 @@ import time
import zipfile import zipfile
import traceback import traceback
import logging import logging
import httpx
from datetime import datetime, timedelta from datetime import datetime, timedelta
from flask import Blueprint, request, send_file, jsonify, Response, stream_with_context from flask import Blueprint, request, send_file, jsonify, Response, stream_with_context
from drhider import obfuscate_files, LLMClient from drhider import obfuscate_files, LLMClient
from session import (create_session, add_file, get_files, store_result, from upload.backend.session import (create_session, add_file, get_files, store_result,
get_result, store_csv, get_csv, cleanup, file_count, get_result, store_csv, get_csv, cleanup, file_count,
MAX_FILE_BYTES, pause_ttl, resume_ttl, MAX_FILE_BYTES, pause_ttl, resume_ttl,
request_cancel, get_cancel_event) request_cancel, get_cancel_event)
from upload.backend.upload_refs import safe_name
api_bp = Blueprint("api", __name__, url_prefix="/api") api_bp = Blueprint("api", __name__, url_prefix="/api")
log = logging.getLogger("routes.api_bp") log = logging.getLogger("routes.api_bp")
# Ретраи pull из ВМ-буфера: защита от разовых DNS/сетевых сбоев (gaierror -5 и т.п.)
PULL_RETRIES = 3
PULL_RETRY_DELAY = 2 # секунды между попытками
# Доверенный префикс ВМ-буфера — валидация URL при pull (защита от SSRF)
VM_UPLOAD_PREFIX = "https://contracts.kube5s.ru/drhider-upload/"
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)
def _disconnect_exceptions(): def _disconnect_exceptions():
"""Исключения, означающие отключение клиента SSE.""" """Исключения, означающие отключение клиента SSE."""
@@ -72,7 +50,7 @@ def upload():
added = 0 added = 0
had_unnamed = False had_unnamed = False
for f in uploaded: for f in uploaded:
name = _safe_name(f.filename) name = safe_name(f.filename)
if not name: if not name:
had_unnamed = True had_unnamed = True
continue continue
@@ -96,91 +74,6 @@ def upload():
return jsonify({"ok": True, "session": sid, "count": file_count(sid)}) return jsonify({"ok": True, "session": sid, "count": file_count(sid)})
@api_bp.route("/upload_refs", methods=["POST"])
def upload_refs():
"""Принять ссылки на файлы (загружены на ВМ-буфер), забрать по egress.
Вход: JSON {"session": "...", "files": [{"name": str, "size": int, "url": str}]}.
Каждый файл тянется ИСХОДЯЩИМ GET'ом с ВМ (egress не ограничен шлюзом),
читается по частям (stream), кладётся в сессию. После успешного pull файл
удаляется с ВМ (best-effort; TTL-чистка на ВМ тоже есть).
"""
data = request.get_json(silent=True) or {}
sid = data.get("session") or create_session()
refs = data.get("files") or []
if not refs:
log.warning("upload_refs: no files, sid=%s", sid)
return jsonify({"ok": False, "error": "No files"}), 400
added = 0
try:
with httpx.Client(timeout=120, follow_redirects=True) as client:
for ref in refs:
name = _safe_name(ref.get("name") or "")
url = ref.get("url")
if not name or not url:
continue
# SSRF-защита: тянуть можно ТОЛЬКО с доверенного ВМ-буфера
if not url.startswith(VM_UPLOAD_PREFIX):
log.warning("upload_refs: unsafe URL, skip sid=%s url=%r", sid, url)
continue
# Лимит на один файл (50 МБ): сверх лимита — пропускаем (не участвует)
if (ref.get("size") or 0) > MAX_FILE_BYTES:
log.warning("upload_refs: file exceeds %dMB, skip sid=%s file=%r size=%s",
MAX_FILE_BYTES // (1024 * 1024), sid, name, ref.get("size"))
try:
client.delete(url)
except Exception:
pass
continue
# Pull с ретраями: разовые DNS/сетевые сбои не роняют всю загрузку
content = None
last_err = None
for attempt in range(PULL_RETRIES):
try:
with client.stream("GET", url) as resp:
resp.raise_for_status()
content = b"".join(resp.iter_bytes())
last_err = None
break
except Exception as e:
last_err = e
log.warning("upload_refs: pull attempt %d/%d failed sid=%s file=%r: %r",
attempt + 1, PULL_RETRIES, sid, name, e)
time.sleep(PULL_RETRY_DELAY)
if content is None:
raise last_err if last_err else RuntimeError("pull failed")
log.info("upload_refs: pulled sid=%s file=%r size=%d", sid, name, len(content))
if len(content) > MAX_FILE_BYTES:
log.warning("upload_refs: pulled file exceeds %dMB, skip sid=%s file=%r size=%d",
MAX_FILE_BYTES // (1024 * 1024), sid, name, len(content))
try:
client.delete(url)
except Exception:
pass
continue
if not add_file(sid, name, content):
# Различить: сессия исчезла vs превышен суммарный лимит сессии
if get_files(sid) is None:
log.warning("upload_refs: session not found, sid=%s file=%r", sid, name)
return jsonify({"ok": False, "error": "Session not found"}), 404
log.warning("upload_refs: session limit exceeded, skip sid=%s file=%r", sid, name)
try:
client.delete(url)
except Exception:
pass
continue
try:
client.delete(url) # убрать файл с ВМ после загрузки
except Exception:
pass
added += 1
except Exception as e:
log.error("upload_refs: pull error sid=%s: %r", sid, e)
return jsonify({"ok": False, "error": "Pull failed: %s" % e}), 502
log.info("upload_refs: done sid=%s added=%d total=%d", sid, added, file_count(sid))
return jsonify({"ok": True, "session": sid, "count": file_count(sid)})
@api_bp.route("/session_files/<sid>", methods=["GET"]) @api_bp.route("/session_files/<sid>", methods=["GET"])
def session_files(sid): def session_files(sid):
"""Отладка: список файлов сессии с размерами (для теста загрузки).""" """Отладка: список файлов сессии с размерами (для теста загрузки)."""
+14 -1
View File
@@ -4,12 +4,19 @@ Blueprint: главная страница (GET /).
Отдаёт HTML-интерфейс DrHider. Отдаёт HTML-интерфейс DrHider.
""" """
from flask import Blueprint, render_template, current_app import os
from flask import Blueprint, render_template, current_app, send_from_directory
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
# Blueprint: главная страница # Blueprint: главная страница
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
# Корень модуля upload/frontend — для раздачи ES-модулей браузеру
_UPLOAD_FRONTEND_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"upload", "frontend")
main_bp = Blueprint("main", __name__) main_bp = Blueprint("main", __name__)
@@ -21,3 +28,9 @@ def index():
""" """
version = current_app.config.get("VERSION", "0.0.0") version = current_app.config.get("VERSION", "0.0.0")
return render_template("index.html", version=version) return render_template("index.html", version=version)
@main_bp.route("/upload/<path:filename>")
def upload_frontend(filename):
"""Раздаёт ES-модули переиспользуемого слоя загрузки (upload/frontend)."""
return send_from_directory(_UPLOAD_FRONTEND_DIR, filename)
+86 -440
View File
@@ -207,7 +207,11 @@
</div> </div>
</div> </div>
</div> </div>
<script> <script type="module">
import { initUploadTable } from '/upload/table/init_upload_table.js';
import { uploadViaVM } from '/upload/upload/upload_via_vm.js';
import { fs } from '/upload/table/fs.js';
const fi = document.getElementById('fileInput'); const fi = document.getElementById('fileInput');
const folderInput = document.getElementById('folderInput'); const folderInput = document.getElementById('folderInput');
const DOC_EXTS = ['.pdf', '.doc', '.docx', '.txt', '.md']; // документы (из папки/архивов) const DOC_EXTS = ['.pdf', '.doc', '.docx', '.txt', '.md']; // документы (из папки/архивов)
@@ -216,19 +220,6 @@ const fc = document.getElementById('fileCount');
const ub = document.getElementById('uploadBtn'); const ub = document.getElementById('uploadBtn');
const st = document.getElementById('status'); const st = document.getElementById('status');
const db = document.getElementById('dlBtns'); const db = document.getElementById('dlBtns');
let sf = [];
let fileMeta = new Map(); // имя -> {size, mtime} для дедупа/суффиксов
let overNames = new Set(); // имена файлов сверх лимита (не участвуют в обфускации)
// ── Состояние обработки (3-секционная таблица: готово / текущий / ожидают) ──
let procPhase = 'idle'; // 'idle' | 'processing'
let procState = {}; // sfIdx -> {st, elapsed, eta, est, chars, t0}
let procNameIdx = {}; // имя (с бэка) -> sfIdx
let procExtractDone = false; // true после extract_done (далее done = реальная готовность)
let procExtractRate = null; // измеренная скорость извлечения (сек/МБ) для оценок ожидающих
let procRefresh = null; // setInterval перерисовки таблицы
let ptimer = null, liveRefresh = null; // таймеры статуса и live-блока
let busy = false; // идёт загрузка/обработка — список файлов заблокирован
let sessionDone = false; // результат готов — сессия заморожена до «Новая сессия»
const MAX_FILE_BYTES = 50 * 1024 * 1024; // 50 МБ на один файл const MAX_FILE_BYTES = 50 * 1024 * 1024; // 50 МБ на один файл
const MAX_SESSION_BYTES = 500 * 1024 * 1024; // 500 МБ суммарно на сессию const MAX_SESSION_BYTES = 500 * 1024 * 1024; // 500 МБ суммарно на сессию
const VM_UPLOAD_URL = 'https://contracts.kube5s.ru/drhider-upload/'; // ВМ-буфер: PUT больших файлов (шлюз кластера их рвёт) const VM_UPLOAD_URL = 'https://contracts.kube5s.ru/drhider-upload/'; // ВМ-буфер: PUT больших файлов (шлюз кластера их рвёт)
@@ -238,6 +229,43 @@ let currentSid = '';
let activeES = null; // активный EventSource let activeES = null; // активный EventSource
let activeXHR = null; // активный XHR let activeXHR = null; // активный XHR
// ── Состояние обработки (3-секционная таблица: готово / текущий / ожидают) ──
let procPhase = 'idle'; // 'idle' | 'processing'
let procState = {}; // sfIdx -> {st, elapsed, eta, est, chars, t0}
let procNameIdx = {}; // имя (с бэка) -> sfIdx
let procExtractDone = false; // true после extract_done (далее done = реальная готовность)
let procExtractRate = null; // измеренная скорость извлечения (сек/МБ) для оценок ожидающих
let procRefresh = null; // setInterval перерисовки таблицы
let ptimer = null, liveRefresh = null; // таймеры статуса и live-блока
let sessionDone = false; // результат готов — сессия заморожена до «Новая сессия»
// Слой 1: выбор файлов/папки/архива (состояние внутри модуля)
const table = initUploadTable({
allowedExt: DOC_EXTS,
maxFileBytes: MAX_FILE_BYTES,
maxSessionBytes: MAX_SESSION_BYTES,
estMbSec: 12,
}, {
fileInput: fi,
folderInput: folderInput,
tableBody: fl,
countEl: fc,
uploadBtnEl: ub,
onStatus(cls, text) {
st.className = cls ? 'status ' + cls : '';
st.textContent = text;
},
});
// Связать состояние обработки с рендером модуля (renderProcTable читает state.proc)
table.state.proc = {};
function syncProcCtx() {
table.state.proc.phase = procPhase;
table.state.proc.procState = procState;
table.state.proc.procExtractRate = procExtractRate;
}
syncProcCtx();
function resetAll() { function resetAll() {
if (activeES) { activeES.close(); activeES = null; } if (activeES) { activeES.close(); activeES = null; }
if (activeXHR) { activeXHR.abort(); activeXHR = null; } if (activeXHR) { activeXHR.abort(); activeXHR = null; }
@@ -245,9 +273,6 @@ function resetAll() {
if (liveRefresh) { clearInterval(liveRefresh); liveRefresh = null; } if (liveRefresh) { clearInterval(liveRefresh); liveRefresh = null; }
if (procRefresh) { clearInterval(procRefresh); procRefresh = null; } if (procRefresh) { clearInterval(procRefresh); procRefresh = null; }
currentSid = ''; currentSid = '';
sf = [];
fileMeta = new Map();
overNames = new Set();
procPhase = 'idle'; procPhase = 'idle';
procState = {}; procState = {};
procNameIdx = {}; procNameIdx = {};
@@ -255,129 +280,26 @@ function resetAll() {
procExtractRate = null; procExtractRate = null;
sessionDone = false; sessionDone = false;
setBusy(false); setBusy(false);
table.clear();
syncProcCtx();
document.getElementById('newSessionBtn').style.display = 'none'; document.getElementById('newSessionBtn').style.display = 'none';
document.getElementById('cancelBtn').style.display = 'none'; document.getElementById('cancelBtn').style.display = 'none';
fi.value = '';
rr();
st.className = ''; st.className = '';
st.textContent = ''; st.textContent = '';
db.classList.remove('show'); db.classList.remove('show');
document.getElementById('statsBlock').classList.remove('show'); document.getElementById('statsBlock').classList.remove('show');
document.getElementById('liveBlock').classList.remove('show'); document.getElementById('liveBlock').classList.remove('show');
// Кнопку «Обфусцировать» НЕ перекрываем: rr() уже поставил disabled при пустом списке // Кнопку «Обфусцировать» НЕ перекрываем: table.clear() уже поставил disabled при пустом списке
} }
// При F5 / закрытии вкладки — обрубить всё // При F5 / закрытии вкладки — обрубить всё
window.addEventListener('beforeunload', () => resetAll()); window.addEventListener('beforeunload', () => resetAll());
function fs(b) { return b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(1) + ' KB' : (b / 1048576).toFixed(1) + ' MB'; }
function fmtSec(s) { function fmtSec(s) {
s = Math.max(0, Math.round(s)); s = Math.max(0, Math.round(s));
return s >= 60 ? Math.floor(s / 60) + 'м ' + (s % 60) + 'с' : s + 'с'; return s >= 60 ? Math.floor(s / 60) + 'м ' + (s % 60) + 'с' : s + 'с';
} }
// Эмпирическая оценка времени обработки файла: сек/МБ (ориентировочно, до старта)
const EST_MB_SEC = 12;
function estForFile(f) { return f ? Math.max(1, Math.round(f.size / 1048576 * EST_MB_SEC)) : 0; }
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function rr() {
if (procPhase === 'processing') { renderProcTable(); return; }
if (sf.length === 0) { fl.innerHTML = '<tr class="empty-row"><td colspan="4">Нет выбранных файлов</td></tr>'; }
else {
fl.innerHTML = sf.map((f, i) => {
const over = overNames.has(f.name);
const rowCls = over ? ' class="row-over"' : '';
const stTxt = over ? '<span style="color:#c0392b;">🔥 не учитывается</span>'
: '<span style="color:#7d3c98;">~' + fmtSec(estForFile(f)) + '</span>';
return '<tr id="row-' + i + '"' + rowCls + '><td class="name-cell">' + esc(f.name) + '</td><td class="num-cell">' + fs(f.size) + '</td><td class="num-cell" id="st-' + i + '" style="font-size:12px;">' + stTxt + '</td><td><button class="remove-btn" onclick="rm(' + i + ')">✕</button></td></tr>';
}).join('');
}
const overCount = sf.filter(f => overNames.has(f.name)).length;
const totalSize = sf.reduce((s, f) => s + (overNames.has(f.name) ? 0 : f.size), 0);
const cntMain = sf.length - overCount;
const totEst = sf.reduce((s, f) => s + (overNames.has(f.name) ? 0 : estForFile(f)), 0);
fc.textContent = (overCount ? cntMain + ' учитываются + ' + overCount + ' свыше лимита' : sf.length) + ' файлов · ' + fs(totalSize) + ' · ~' + fmtSec(totEst);
ub.disabled = cntMain === 0;
}
// ═══ 3-секционная таблица во время обработки (готово / текущий / ожидают) ═══
function procRow(i, stTxt) {
const f = sf[i];
const over = overNames.has(f.name);
const cls = (procState[i] && procState[i].st === 'current') ? ' class="row-current"'
: (over ? ' class="row-over"' : '');
return '<tr' + cls + '><td class="name-cell">' + esc(f.name) + '</td><td class="num-cell">' + fs(f.size) + '</td><td class="num-cell" style="font-size:12px;">' + stTxt + '</td><td></td></tr>';
}
function renderProcTable() {
const groups = { done: [], current: [], pending: [], over: [] };
for (let i = 0; i < sf.length; i++) {
if (overNames.has(sf[i].name)) { groups.over.push(i); continue; }
const st = procState[i] ? procState[i].st : 'pending';
if (st === 'done' || st === 'skipped') groups.done.push(i);
else if (st === 'current') groups.current.push(i);
else groups.pending.push(i);
}
// Оценка скорости из текущего файла (сек/символ) — для «ожидающих»
let rate = null;
for (const i of groups.current) {
const p = procState[i];
const cur = (performance.now() - p.t0) / 1000;
const total = cur + (p.eta != null ? p.eta : 0);
if (p.chars > 0 && total > 0) rate = total / p.chars;
}
const rows = [];
if (groups.done.length) {
rows.push('<tr class="grp-row"><td colspan="4">✓ Обработанные (' + groups.done.length + ')</td></tr>');
for (const i of groups.done) {
const p = procState[i];
const txt = p.st === 'skipped'
? '<span style="color:#c0392b;" title="Не удалось прочитать файл: пустой, повреждённый или скан без текста">не извлечён</span>'
: '<span style="color:#22c55e;">✓ ' + (p.elapsed ? p.elapsed.toFixed(1) : '0.0') + 'с</span>';
rows.push(procRow(i, txt));
}
}
if (groups.over.length) {
rows.push('<tr class="grp-row"><td colspan="4">⛔ Пропущены (сверх лимита) (' + groups.over.length + ')</td></tr>');
for (const i of groups.over) {
rows.push(procRow(i, '<span style="color:#c0392b;">пропущен (лимит)</span>'));
}
}
if (groups.current.length) {
rows.push('<tr class="grp-row"><td colspan="4">▶ Текущий файл</td></tr>');
for (const i of groups.current) {
const p = procState[i];
const cur = ((performance.now() - p.t0) / 1000).toFixed(1);
const eta = (p.eta != null) ? ' / ~' + fmtSec(p.eta) : '';
rows.push(procRow(i, '<span style="color:#2563eb;">⏳ ' + cur + 'с' + eta + '</span>'));
}
}
if (groups.pending.length) {
rows.push('<tr class="grp-row"><td colspan="4">○ Ожидают обработки (' + groups.pending.length + ')</td></tr>');
for (const i of groups.pending) {
const p = procState[i] || {};
// Оценка: LLM (chars x rate) если известна; иначе грубая по размеру/скорости извлечения
if (rate && !p.est && p.chars > 0) p.est = p.chars * rate;
if (!p.est) {
const szMB = (sf[i] ? sf[i].size : 0) / 1048576;
const k = procExtractRate || EST_MB_SEC; // сек/МБ: замеренная или эмпирическая
p.est = Math.max(1, Math.round(szMB * k));
}
let txt;
if (p.st === 'analyzed') txt = '<span style="color:#7d3c98;">анализ ✓</span>';
else if (p.st === 'current') txt = '<span style="color:#2563eb;">⏳</span>';
else if (p.est != null) txt = '<span style="color:#999;">~' + fmtSec(p.est) + '</span>';
else txt = '<span style="color:#999;">—</span>';
rows.push(procRow(i, txt));
}
}
fl.innerHTML = rows.join('');
const cntDone = groups.done.length;
fc.textContent = 'Готово ' + cntDone + ' / ' + sf.length + ' файлов';
}
function confirmCancel() { function confirmCancel() {
document.getElementById('cancelModal').style.display = 'flex'; document.getElementById('cancelModal').style.display = 'flex';
} }
@@ -397,266 +319,29 @@ function finishProcUI() {
document.getElementById('liveBlock').classList.remove('show'); document.getElementById('liveBlock').classList.remove('show');
document.getElementById('cancelBtn').style.display = 'none'; document.getElementById('cancelBtn').style.display = 'none';
procPhase = 'idle'; procPhase = 'idle';
rr(); syncProcCtx();
table.render();
} }
function setBusy(b) { function setBusy(b) {
busy = b; table.setBusy(b);
fi.disabled = b; // «Выбрать файлы» — блокируется на время загрузки/обработки и в замороженной сессии
document.body.classList.toggle('busy', b); // скрывает кнопки «✕» в таблице document.body.classList.toggle('busy', b); // скрывает кнопки «✕» в таблице
} }
function rm(i) { if (busy) return; const nm = sf[i].name; overNames.delete(nm); fileMeta.delete(nm); sf.splice(i, 1); const d = new DataTransfer(); sf.forEach(f => d.items.add(f)); fi.files = d.files; rr(); } function ss(idx, h) { table.setStatus(idx, h); }
window.addEventListener('load', () => { sf = []; fileMeta = new Map(); fi.value = ''; rr(); /* прогрев upstream-соединения */ fetch('/health').catch(() => {}); });
// ═══════════ Нативная распаковка ZIP (без внешних библиотек) ═══════════
function dosToMs(date, time) {
const year = 1980 + ((date >> 9) & 0x7f);
const month = (date >> 5) & 0x0f;
const day = date & 0x1f;
const hour = (time >> 11) & 0x1f;
const min = (time >> 5) & 0x3f;
const sec = (time & 0x1f) * 2;
return new Date(year, month - 1, day, hour, min, sec).getTime();
}
function decodeZipName(bytes, isUtf8) {
if (isUtf8) return new TextDecoder('utf-8').decode(bytes);
// Многие архиваторы пишут имя в UTF-8, но НЕ выставляют UTF-8-флаг (bit 11).
// Сначала строго пробуем UTF-8: если байты — валидный UTF-8 с кириллицей/текстом,
// берём их как есть (иначе декодирование CP437→CP866 превратит их в «╨╣…»-мусор).
try {
const s = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
// Кириллица — точно UTF-8; либо чистый печатаемый текст без управляющих символов.
if (/[\u0400-\u04FF]/.test(s) || !/[^\u0020-\u007e]/.test(s)) return s;
} catch (e) { /* не UTF-8 — legacy (CP437/CP866) */ }
let name;
try { name = new TextDecoder('ibm437').decode(bytes); }
catch (e) { name = new TextDecoder('utf-8').decode(bytes); }
// Кириллица из 1С (CP866) — перекодировать, если имя пришло как CP437-мусор
if (/[^\x00-\x7f]/.test(name)) {
try { name = new TextDecoder('ibm866').decode(bytes); }
catch (e) { /* оставить как есть */ }
}
return name;
}
async function inflateRaw(bytes) {
const ds = new DecompressionStream('deflate-raw');
const stream = new Blob([bytes]).stream().pipeThrough(ds);
const ab = await new Response(stream).arrayBuffer();
return new Uint8Array(ab);
}
async function parseZip(buf) {
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
let eocd = -1;
for (let i = buf.length - 22; i >= 0; i--) {
if (dv.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
}
if (eocd < 0) throw new Error('Не ZIP');
const cdSize = dv.getUint32(eocd + 12, true);
const cdOffset = dv.getUint32(eocd + 16, true);
const entries = [];
let pos = cdOffset;
const cdEnd = cdOffset + cdSize;
while (pos < cdEnd) {
if (dv.getUint32(pos, true) !== 0x02014b50) break;
const flags = dv.getUint16(pos + 8, true);
const method = dv.getUint16(pos + 10, true);
const modTime = dv.getUint16(pos + 12, true);
const modDate = dv.getUint16(pos + 14, true);
const compSize = dv.getUint32(pos + 20, true);
const nameLen = dv.getUint16(pos + 28, true);
const extraLen = dv.getUint16(pos + 30, true);
const commentLen = dv.getUint16(pos + 32, true);
const localOffset = dv.getUint32(pos + 42, true);
const nameBytes = buf.slice(pos + 46, pos + 46 + nameLen);
const name = decodeZipName(nameBytes, (flags & 0x800) !== 0);
const lhNameLen = dv.getUint16(localOffset + 26, true);
const lhExtraLen = dv.getUint16(localOffset + 28, true);
const dataStart = localOffset + 30 + lhNameLen + lhExtraLen;
const comp = buf.slice(dataStart, dataStart + compSize);
let data;
if (method === 0) data = comp;
else if (method === 8) data = await inflateRaw(comp);
else throw new Error('Метод сжатия ' + method + ' не поддерживается');
entries.push({ name, data, dosMs: dosToMs(modDate, modTime), isDir: name.endsWith('/') });
pos += 46 + nameLen + extraLen + commentLen;
}
return entries;
}
async function listZipFiles(file) {
const buf = new Uint8Array(await file.arrayBuffer());
const entries = await parseZip(buf);
const out = [];
// Из ZIP вытаскиваем только документы. Всё прочее (изображения и т.п.) пропускаем.
const allowedExt = ['.pdf', '.doc', '.docx', '.txt', '.md'];
for (const e of entries) {
if (e.isDir) continue;
const low = e.name.toLowerCase();
if (low.endsWith('.zip')) {
const sub = new File([e.data], e.name, { lastModified: e.dosMs });
out.push(...(await listZipFiles(sub)));
} else if (allowedExt.some(ext => low.endsWith(ext))) {
out.push(new File([e.data], e.name, { lastModified: e.dosMs }));
}
// иначе — не документ, пропускаем
}
return out;
}
function addFileWithDedup(file) {
const size = file.size;
const mtime = file.lastModified;
let name = file.name;
if (fileMeta.has(name)) {
const e = fileMeta.get(name);
if (e.size === size) {
// Тот же файл (имя+размер) — дедуп. Дату не сравниваем: файл могли пересохранить
// с тем же содержимым. Оставляем более свежий по дате.
if (mtime > e.mtime) {
e.mtime = mtime;
const idx = sf.findIndex(f => f.name === name);
if (idx >= 0) sf[idx] = new File([file], name, { lastModified: mtime });
}
return false; // дедуп: файл не добавлен (обновлена только дата)
}
// Имя то же, размер другой — добавить с суффиксом _2, _3...
const dot = name.lastIndexOf('.');
const base = dot > 0 ? name.slice(0, dot) : name;
const ext = dot > 0 ? name.slice(dot) : '';
let n = 2;
while (fileMeta.has(base + '_' + n + ext)) n++;
name = base + '_' + n + ext;
}
fileMeta.set(name, { size, mtime });
// Определяем, превышает ли файл лимит (по размеру файла или суммарный) — не участвует в обфускации
let over = false;
if (size > MAX_FILE_BYTES) over = true; // лимит на один файл — 50 МБ
const sum = sf.reduce((s, f) => s + (overNames.has(f.name) ? 0 : f.size), 0);
if (sum + size > MAX_SESSION_BYTES) over = true; // суммарный лимит сессии — 500 МБ
if (over) {
overNames.add(name);
sf.push(new File([file], name, { lastModified: mtime }));
return true;
}
sf.push(new File([file], name, { lastModified: mtime }));
return true;
}
fi.addEventListener('change', async () => {
if (busy) return; // во время загрузки/обработки менять список нельзя
const incoming = Array.from(fi.files);
const hasZip = incoming.some(f => f.name.toLowerCase().endsWith('.zip'));
if (hasZip) {
// Распаковка архивов может занять время — показать индикатор
document.body.style.cursor = 'wait';
st.className = 'status progress';
st.textContent = 'Разбираю архивы…';
}
try {
for (const f of incoming) {
if (f.name.toLowerCase().endsWith('.zip')) {
try {
const nested = await listZipFiles(f);
if (nested.length) nested.forEach(x => addFileWithDedup(x));
else addFileWithDedup(f); // в архиве нет документов — добавить архив как есть
} catch (err) {
addFileWithDedup(f); // не удалось распаковать — добавить zip как есть
}
} else {
addFileWithDedup(f);
}
}
} finally {
if (hasZip) {
document.body.style.cursor = '';
st.className = '';
st.textContent = '';
}
}
const d = new DataTransfer();
sf.forEach(f => d.items.add(f));
fi.files = d.files;
rr();
});
// ═══ Выбор целой папки (webkitdirectory): рекурсивно, относительный путь сохраняется ═══
folderInput.addEventListener('change', async () => {
if (busy) return; // во время загрузки/обработки менять список нельзя
const incoming = Array.from(folderInput.files);
if (!incoming.length) return;
document.body.style.cursor = 'wait';
st.className = 'status progress';
st.textContent = 'Разбираю папку…';
let added = 0;
try {
for (const f of incoming) {
// webkitRelativePath: "TopFolder/Подпапка/file.pdf" — отбрасываем верхнюю папку
const parts = (f.webkitRelativePath || f.name).split('/');
const rel = parts.slice(1).join('/') || f.name;
const low = rel.toLowerCase();
const slashIdx = rel.lastIndexOf('/');
const relDir = slashIdx >= 0 ? rel.slice(0, slashIdx) : '';
if (low.endsWith('.zip')) {
try {
const nested = await listZipFiles(f);
if (nested.length) {
for (const nf of nested) {
const nm = relDir ? relDir + '/' + nf.name : nf.name;
if (addFileWithDedup(new File([nf], nm, { lastModified: nf.lastModified }))) added++;
}
} else {
// в архиве нет документов — добавить архив как есть, чтобы не терялся
if (addFileWithDedup(new File([f], rel, { lastModified: f.lastModified }))) added++;
}
} catch (err) {
if (addFileWithDedup(new File([f], rel, { lastModified: f.lastModified }))) added++; // zip как есть
}
} else if (DOC_EXTS.some(e => low.endsWith(e))) {
if (addFileWithDedup(new File([f], rel, { lastModified: f.lastModified }))) added++;
}
// иначе — не документ, пропускаем
}
} finally {
document.body.style.cursor = '';
}
folderInput.value = ''; // чтобы повторный выбор той же папки сработал
rr();
if (added) {
st.className = 'status done';
const n = added;
const w = (n % 10 === 1 && n % 100 !== 11) ? 'файл' : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 'файла' : 'файлов';
st.textContent = '✅ Добавлено из папки: ' + n + ' ' + w;
}
});
function ss(idx, h) { const e = document.getElementById('st-' + idx); if (e) e.innerHTML = h; }
async function uploadFiles() { async function uploadFiles() {
if (busy) return; // уже идёт загрузка/обработка if (table.state.busy) return; // уже идёт загрузка/обработка
if (sf.length === 0) return; if (table.state.files.length === 0) return;
setBusy(true); setBusy(true);
sessionDone = false; sessionDone = false;
document.getElementById('newSessionBtn').style.display = 'none'; document.getElementById('newSessionBtn').style.display = 'none';
// Сохранить список до очистки // Только учитываемые файлы идут на загрузку/обфускацию (сверхлимитные пропускаем)
const files = sf.slice(); const files = table.state.files.slice();
// Только учитываемые файлы идут на загрузку/обфускацию (переборные пропускаем)
const toSend = []; const toSend = [];
const sendIdx = []; // sendIdx[k] = индекс в sf для отправленного файла const sendIdx = []; // sendIdx[k] = индекс в files для отправленного файла
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
if (!overNames.has(files[i].name)) { toSend.push(files[i]); sendIdx.push(i); } if (!table.state.overNames.has(files[i].name)) { toSend.push(files[i]); sendIdx.push(i); }
else { ss(i, '<span style="color:#c0392b;">пропущен (лимит)</span>'); } else { ss(i, '<span style="color:#c0392b;">пропущен (лимит)</span>'); }
} }
// Обрубить всё что могло остаться от предыдущего раза // Обрубить всё что могло остаться от предыдущего раза
@@ -672,75 +357,22 @@ async function uploadFiles() {
const total = toSend.length; const total = toSend.length;
if (total === 0) { st.className = 'status error'; st.textContent = 'Нет файлов для обфускации (все превышают лимит).'; setBusy(false); ub.disabled = false; return; } if (total === 0) { st.className = 'status error'; st.textContent = 'Нет файлов для обфускации (все превышают лимит).'; setBusy(false); ub.disabled = false; return; }
// Фаза 1: загрузка на ВМ-буфер (PUT напрямую на nginx ВМ, минуя шлюз кластера) // Фаза 1+1b: загрузка через ВМ-буфер (слой 2 модуля: PUT + POST /api/upload_refs)
const token = crypto.randomUUID(); let uploadedCount = 0;
const refs = []; // {name, size, url} — для POST /api/upload_refs const res = await uploadViaVM(toSend, VM_UPLOAD_URL, {
let uploadedCount = 0; // сколько файлов реально легло в сессию (для тест-режима) session: currentSid,
for (let k = 0; k < total; k++) { onStatus(k, html) { ss(sendIdx[k], html); },
const f = toSend[k]; onUploadStatus(text) { st.className = 'status progress'; st.textContent = text; },
const i = sendIdx[k]; // индекс в sf для вывода прогресса });
const n = k + 1; if (!res.ok) {
st.className = 'status progress';
st.textContent = 'Загрузка на ВМ (этап 1/2) ' + n + '/' + total + ': ' + f.name;
try {
const t0 = performance.now();
const vmUrl = VM_UPLOAD_URL + token + '_' + k; // имя файла в URL не несём (токен-ключ)
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', vmUrl);
xhr.timeout = 300000; // 300с: большие файлы
xhr.upload.onprogress = function(e) {
if (e.lengthComputable) {
const pct = Math.round(e.loaded / e.total * 100);
ss(i, '<span style="color:#2563eb">⏳ ' + pct + '%</span>');
}
};
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
const elapsed = (performance.now() - t0) / 1000;
const speed = f.size / elapsed;
ss(i, '<span style="color:#22c55e">✓ ' + fs(speed) + '/s</span>');
refs.push({ name: f.name, size: f.size, url: vmUrl });
resolve();
} else {
reject(new Error('ВМ: HTTP ' + xhr.status));
}
};
xhr.onerror = function() { reject(new Error('Сеть (ВМ)')); };
xhr.ontimeout = function() { reject(new Error('Таймаут 300с (ВМ)')); };
activeXHR = xhr;
xhr.send(f); // сырое тело файла
});
} catch (err) {
ss(i, '<span style="color:#ef4444">✗</span>');
st.className = 'status error';
st.textContent = 'Ошибка загрузки на ВМ: ' + err.message;
setBusy(false);
ub.disabled = false;
return;
}
}
// Шаг 1b: один маленький POST во Flask со ссылками на файлы ВМ (<64КБ)
st.className = 'status progress';
st.textContent = 'Передача ссылок в сервис…';
try {
const resp = await fetch('/api/upload_refs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: currentSid, files: refs })
});
const data = await resp.json();
if (!data.ok) throw new Error(data.error || 'HTTP ' + resp.status);
currentSid = data.session;
uploadedCount = data.count || refs.length;
} catch (err) {
st.className = 'status error'; st.className = 'status error';
st.textContent = 'Ошибка передачи ссылок: ' + err.message; st.textContent = res.error;
setBusy(false); setBusy(false);
ub.disabled = false; ub.disabled = false;
return; return;
} }
currentSid = res.session;
uploadedCount = res.count;
// Тест-режим (?upload-only=1): только загрузка, без обработки // Тест-режим (?upload-only=1): только загрузка, без обработки
if (TEST_UPLOAD_ONLY) { if (TEST_UPLOAD_ONLY) {
@@ -763,10 +395,11 @@ async function uploadFiles() {
for (let k = 0; k < total; k++) { for (let k = 0; k < total; k++) {
procState[sendIdx[k]] = { st: 'pending', elapsed: 0, eta: null, est: null, chars: 0, t0: 0 }; procState[sendIdx[k]] = { st: 'pending', elapsed: 0, eta: null, est: null, chars: 0, t0: 0 };
} }
syncProcCtx();
document.getElementById('cancelBtn').style.display = 'inline-block'; document.getElementById('cancelBtn').style.display = 'inline-block';
if (procRefresh) clearInterval(procRefresh); if (procRefresh) clearInterval(procRefresh);
procRefresh = setInterval(rr, 1000); // тикающий рендер (elapsed текущего файла) procRefresh = setInterval(() => table.render(), 1000); // тикающий рендер (elapsed текущего файла)
rr(); table.render();
const fileTimers = {}; // idx -> performance.now() const fileTimers = {}; // idx -> performance.now()
const fileIntervals = {}; // idx -> setInterval id const fileIntervals = {}; // idx -> setInterval id
st.className = 'status progress'; st.className = 'status progress';
@@ -800,7 +433,7 @@ async function uploadFiles() {
activeES = new EventSource('/api/process_stream/' + currentSid); activeES = new EventSource('/api/process_stream/' + currentSid);
activeES.addEventListener('start', function(e) { activeES.addEventListener('start', function(e) {
const d = JSON.parse(e.data); const d = JSON.parse(e.data);
const idx = sendIdx[d.idx]; // индекс в sf для вывода прогресса const idx = sendIdx[d.idx]; // индекс в files для вывода прогресса
procNameIdx[d.name] = idx; procNameIdx[d.name] = idx;
const p = procState[idx]; const p = procState[idx];
if (p) { p.st = 'pending'; p.elapsed = 0; } if (p) { p.st = 'pending'; p.elapsed = 0; }
@@ -811,14 +444,15 @@ async function uploadFiles() {
for (const i in procState) { for (const i in procState) {
if (procState[i].st === 'current') { prevIdx = Number(i); procState[i].st = 'pending'; } if (procState[i].st === 'current') { prevIdx = Number(i); procState[i].st = 'pending'; }
} }
if (prevIdx != null && sf[prevIdx] && procState[prevIdx].t0) { if (prevIdx != null && table.state.files[prevIdx] && procState[prevIdx].t0) {
const el = (performance.now() - procState[prevIdx].t0) / 1000; const el = (performance.now() - procState[prevIdx].t0) / 1000;
const szMB = sf[prevIdx].size / 1048576; const szMB = table.state.files[prevIdx].size / 1048576;
if (el > 0 && szMB > 0) procExtractRate = el / szMB; if (el > 0 && szMB > 0) procExtractRate = el / szMB;
} }
if (p) { p.st = 'current'; p.t0 = performance.now(); p.eta = null; } if (p) { p.st = 'current'; p.t0 = performance.now(); p.eta = null; }
} }
const f = sf[idx]; syncProcCtx();
const f = table.state.files[idx];
const sz = f ? fs(f.size) : ''; const sz = f ? fs(f.size) : '';
currentLiveFile = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name + (sz ? ' (' + sz + ')' : ''); currentLiveFile = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name + (sz ? ' (' + sz + ')' : '');
liveFileEl.textContent = currentLiveFile; liveFileEl.textContent = currentLiveFile;
@@ -996,6 +630,18 @@ function downloadCsv() {
URL.revokeObjectURL(a.href); URL.revokeObjectURL(a.href);
}); });
} }
// Экспонировать в window для onclick-атрибутов в HTML
window.uploadFiles = uploadFiles;
window.resetAll = resetAll;
window.confirmCancel = confirmCancel;
window.doCancel = doCancel;
window.downloadZip = downloadZip;
window.downloadCsv = downloadCsv;
// Инициализация: пустой список + прогрев upstream-соединения
table.render();
fetch('/health').catch(() => {});
</script> </script>
<div id="helpModal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);z-index:999;justify-content:center;align-items:center" onclick="this.style.display='none'"> <div id="helpModal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);z-index:999;justify-content:center;align-items:center" onclick="this.style.display='none'">
<div style="background:var(--card);border-radius:12px;padding:24px;max-width:420px;box-shadow:0 4px 24px rgba(0,0,0,.15)" onclick="event.stopPropagation()"> <div style="background:var(--card);border-radius:12px;padding:24px;max-width:420px;box-shadow:0 4px 24px rgba(0,0,0,.15)" onclick="event.stopPropagation()">