feat: интегрировать модуль upload в drhider (бэк blueprint + фронт ES-модули)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from .main_bp import main_bp
|
||||
from .health_bp import health_bp
|
||||
from .api_bp import api_bp
|
||||
from upload.backend.upload_refs import create_upload_refs_blueprint
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
@@ -12,3 +13,13 @@ def register_routes(app):
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(api_bp)
|
||||
# Слой 2 (закачка через ВМ) — переиспользуемый blueprint из модуля upload
|
||||
app.register_blueprint(create_upload_refs_blueprint({
|
||||
"apiPrefix": "/api",
|
||||
"vmUploadPrefix": "https://contracts.kube5s.ru/drhider-upload/",
|
||||
"maxFileBytes": 50 * 1024 * 1024,
|
||||
"maxSessionBytes": 500 * 1024 * 1024,
|
||||
"ttlSeconds": 30 * 60,
|
||||
"pullRetries": 3,
|
||||
"pullRetryDelay": 2,
|
||||
}))
|
||||
|
||||
+6
-113
@@ -17,41 +17,19 @@ import time
|
||||
import zipfile
|
||||
import traceback
|
||||
import logging
|
||||
import httpx
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, request, send_file, jsonify, Response, stream_with_context
|
||||
|
||||
from drhider import obfuscate_files, LLMClient
|
||||
from session import (create_session, add_file, get_files, store_result,
|
||||
get_result, store_csv, get_csv, cleanup, file_count,
|
||||
MAX_FILE_BYTES, pause_ttl, resume_ttl,
|
||||
request_cancel, get_cancel_event)
|
||||
from upload.backend.session import (create_session, add_file, get_files, store_result,
|
||||
get_result, store_csv, get_csv, cleanup, file_count,
|
||||
MAX_FILE_BYTES, pause_ttl, resume_ttl,
|
||||
request_cancel, get_cancel_event)
|
||||
from upload.backend.upload_refs import safe_name
|
||||
|
||||
api_bp = Blueprint("api", __name__, url_prefix="/api")
|
||||
log = logging.getLogger("routes.api_bp")
|
||||
|
||||
# Ретраи pull из ВМ-буфера: защита от разовых DNS/сетевых сбоев (gaierror -5 и т.п.)
|
||||
PULL_RETRIES = 3
|
||||
PULL_RETRY_DELAY = 2 # секунды между попытками
|
||||
|
||||
# Доверенный префикс ВМ-буфера — валидация 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():
|
||||
"""Исключения, означающие отключение клиента SSE."""
|
||||
@@ -72,7 +50,7 @@ def upload():
|
||||
added = 0
|
||||
had_unnamed = False
|
||||
for f in uploaded:
|
||||
name = _safe_name(f.filename)
|
||||
name = safe_name(f.filename)
|
||||
if not name:
|
||||
had_unnamed = True
|
||||
continue
|
||||
@@ -96,91 +74,6 @@ def upload():
|
||||
return jsonify({"ok": True, "session": sid, "count": file_count(sid)})
|
||||
|
||||
|
||||
@api_bp.route("/upload_refs", methods=["POST"])
|
||||
def upload_refs():
|
||||
"""Принять ссылки на файлы (загружены на ВМ-буфер), забрать по egress.
|
||||
|
||||
Вход: JSON {"session": "...", "files": [{"name": str, "size": int, "url": str}]}.
|
||||
Каждый файл тянется ИСХОДЯЩИМ GET'ом с ВМ (egress не ограничен шлюзом),
|
||||
читается по частям (stream), кладётся в сессию. После успешного pull файл
|
||||
удаляется с ВМ (best-effort; TTL-чистка на ВМ тоже есть).
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
sid = data.get("session") or create_session()
|
||||
refs = data.get("files") or []
|
||||
if not refs:
|
||||
log.warning("upload_refs: no files, sid=%s", sid)
|
||||
return jsonify({"ok": False, "error": "No files"}), 400
|
||||
added = 0
|
||||
try:
|
||||
with httpx.Client(timeout=120, follow_redirects=True) as client:
|
||||
for ref in refs:
|
||||
name = _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"])
|
||||
def session_files(sid):
|
||||
"""Отладка: список файлов сессии с размерами (для теста загрузки)."""
|
||||
|
||||
+14
-1
@@ -4,12 +4,19 @@ Blueprint: главная страница (GET /).
|
||||
Отдаёт HTML-интерфейс DrHider.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, render_template, current_app
|
||||
import os
|
||||
|
||||
from flask import Blueprint, render_template, current_app, send_from_directory
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Blueprint: главная страница
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Корень модуля upload/frontend — для раздачи ES-модулей браузеру
|
||||
_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__)
|
||||
|
||||
|
||||
@@ -21,3 +28,9 @@ def index():
|
||||
"""
|
||||
version = current_app.config.get("VERSION", "0.0.0")
|
||||
return render_template("index.html", version=version)
|
||||
|
||||
|
||||
@main_bp.route("/upload/<path:filename>")
|
||||
def upload_frontend(filename):
|
||||
"""Раздаёт ES-модули переиспользуемого слоя загрузки (upload/frontend)."""
|
||||
return send_from_directory(_UPLOAD_FRONTEND_DIR, filename)
|
||||
|
||||
+86
-440
@@ -207,7 +207,11 @@
|
||||
</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 folderInput = document.getElementById('folderInput');
|
||||
const DOC_EXTS = ['.pdf', '.doc', '.docx', '.txt', '.md']; // документы (из папки/архивов)
|
||||
@@ -216,19 +220,6 @@ const fc = document.getElementById('fileCount');
|
||||
const ub = document.getElementById('uploadBtn');
|
||||
const st = document.getElementById('status');
|
||||
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_SESSION_BYTES = 500 * 1024 * 1024; // 500 МБ суммарно на сессию
|
||||
const VM_UPLOAD_URL = 'https://contracts.kube5s.ru/drhider-upload/'; // ВМ-буфер: PUT больших файлов (шлюз кластера их рвёт)
|
||||
@@ -238,6 +229,43 @@ let currentSid = '';
|
||||
let activeES = null; // активный EventSource
|
||||
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() {
|
||||
if (activeES) { activeES.close(); activeES = null; }
|
||||
if (activeXHR) { activeXHR.abort(); activeXHR = null; }
|
||||
@@ -245,9 +273,6 @@ function resetAll() {
|
||||
if (liveRefresh) { clearInterval(liveRefresh); liveRefresh = null; }
|
||||
if (procRefresh) { clearInterval(procRefresh); procRefresh = null; }
|
||||
currentSid = '';
|
||||
sf = [];
|
||||
fileMeta = new Map();
|
||||
overNames = new Set();
|
||||
procPhase = 'idle';
|
||||
procState = {};
|
||||
procNameIdx = {};
|
||||
@@ -255,129 +280,26 @@ function resetAll() {
|
||||
procExtractRate = null;
|
||||
sessionDone = false;
|
||||
setBusy(false);
|
||||
table.clear();
|
||||
syncProcCtx();
|
||||
document.getElementById('newSessionBtn').style.display = 'none';
|
||||
document.getElementById('cancelBtn').style.display = 'none';
|
||||
fi.value = '';
|
||||
rr();
|
||||
st.className = '';
|
||||
st.textContent = '';
|
||||
db.classList.remove('show');
|
||||
document.getElementById('statsBlock').classList.remove('show');
|
||||
document.getElementById('liveBlock').classList.remove('show');
|
||||
// Кнопку «Обфусцировать» НЕ перекрываем: rr() уже поставил disabled при пустом списке
|
||||
// Кнопку «Обфусцировать» НЕ перекрываем: table.clear() уже поставил disabled при пустом списке
|
||||
}
|
||||
|
||||
// При F5 / закрытии вкладки — обрубить всё
|
||||
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) {
|
||||
s = Math.max(0, Math.round(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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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() {
|
||||
document.getElementById('cancelModal').style.display = 'flex';
|
||||
}
|
||||
@@ -397,266 +319,29 @@ function finishProcUI() {
|
||||
document.getElementById('liveBlock').classList.remove('show');
|
||||
document.getElementById('cancelBtn').style.display = 'none';
|
||||
procPhase = 'idle';
|
||||
rr();
|
||||
syncProcCtx();
|
||||
table.render();
|
||||
}
|
||||
|
||||
function setBusy(b) {
|
||||
busy = b;
|
||||
fi.disabled = b; // «Выбрать файлы» — блокируется на время загрузки/обработки и в замороженной сессии
|
||||
table.setBusy(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(); }
|
||||
|
||||
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; }
|
||||
function ss(idx, h) { table.setStatus(idx, h); }
|
||||
|
||||
async function uploadFiles() {
|
||||
if (busy) return; // уже идёт загрузка/обработка
|
||||
if (sf.length === 0) return;
|
||||
if (table.state.busy) return; // уже идёт загрузка/обработка
|
||||
if (table.state.files.length === 0) return;
|
||||
setBusy(true);
|
||||
sessionDone = false;
|
||||
document.getElementById('newSessionBtn').style.display = 'none';
|
||||
// Сохранить список до очистки
|
||||
const files = sf.slice();
|
||||
// Только учитываемые файлы идут на загрузку/обфускацию (переборные пропускаем)
|
||||
// Только учитываемые файлы идут на загрузку/обфускацию (сверхлимитные пропускаем)
|
||||
const files = table.state.files.slice();
|
||||
const toSend = [];
|
||||
const sendIdx = []; // sendIdx[k] = индекс в sf для отправленного файла
|
||||
const sendIdx = []; // sendIdx[k] = индекс в files для отправленного файла
|
||||
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>'); }
|
||||
}
|
||||
// Обрубить всё что могло остаться от предыдущего раза
|
||||
@@ -672,75 +357,22 @@ async function uploadFiles() {
|
||||
const total = toSend.length;
|
||||
if (total === 0) { st.className = 'status error'; st.textContent = 'Нет файлов для обфускации (все превышают лимит).'; setBusy(false); ub.disabled = false; return; }
|
||||
|
||||
// Фаза 1: загрузка на ВМ-буфер (PUT напрямую на nginx ВМ, минуя шлюз кластера)
|
||||
const token = crypto.randomUUID();
|
||||
const refs = []; // {name, size, url} — для POST /api/upload_refs
|
||||
let uploadedCount = 0; // сколько файлов реально легло в сессию (для тест-режима)
|
||||
for (let k = 0; k < total; k++) {
|
||||
const f = toSend[k];
|
||||
const i = sendIdx[k]; // индекс в sf для вывода прогресса
|
||||
const n = k + 1;
|
||||
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) {
|
||||
// Фаза 1+1b: загрузка через ВМ-буфер (слой 2 модуля: PUT + POST /api/upload_refs)
|
||||
let uploadedCount = 0;
|
||||
const res = await uploadViaVM(toSend, VM_UPLOAD_URL, {
|
||||
session: currentSid,
|
||||
onStatus(k, html) { ss(sendIdx[k], html); },
|
||||
onUploadStatus(text) { st.className = 'status progress'; st.textContent = text; },
|
||||
});
|
||||
if (!res.ok) {
|
||||
st.className = 'status error';
|
||||
st.textContent = 'Ошибка передачи ссылок: ' + err.message;
|
||||
st.textContent = res.error;
|
||||
setBusy(false);
|
||||
ub.disabled = false;
|
||||
return;
|
||||
}
|
||||
currentSid = res.session;
|
||||
uploadedCount = res.count;
|
||||
|
||||
// Тест-режим (?upload-only=1): только загрузка, без обработки
|
||||
if (TEST_UPLOAD_ONLY) {
|
||||
@@ -763,10 +395,11 @@ async function uploadFiles() {
|
||||
for (let k = 0; k < total; k++) {
|
||||
procState[sendIdx[k]] = { st: 'pending', elapsed: 0, eta: null, est: null, chars: 0, t0: 0 };
|
||||
}
|
||||
syncProcCtx();
|
||||
document.getElementById('cancelBtn').style.display = 'inline-block';
|
||||
if (procRefresh) clearInterval(procRefresh);
|
||||
procRefresh = setInterval(rr, 1000); // тикающий рендер (elapsed текущего файла)
|
||||
rr();
|
||||
procRefresh = setInterval(() => table.render(), 1000); // тикающий рендер (elapsed текущего файла)
|
||||
table.render();
|
||||
const fileTimers = {}; // idx -> performance.now()
|
||||
const fileIntervals = {}; // idx -> setInterval id
|
||||
st.className = 'status progress';
|
||||
@@ -800,7 +433,7 @@ async function uploadFiles() {
|
||||
activeES = new EventSource('/api/process_stream/' + currentSid);
|
||||
activeES.addEventListener('start', function(e) {
|
||||
const d = JSON.parse(e.data);
|
||||
const idx = sendIdx[d.idx]; // индекс в sf для вывода прогресса
|
||||
const idx = sendIdx[d.idx]; // индекс в files для вывода прогресса
|
||||
procNameIdx[d.name] = idx;
|
||||
const p = procState[idx];
|
||||
if (p) { p.st = 'pending'; p.elapsed = 0; }
|
||||
@@ -811,14 +444,15 @@ async function uploadFiles() {
|
||||
for (const i in procState) {
|
||||
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 szMB = sf[prevIdx].size / 1048576;
|
||||
const szMB = table.state.files[prevIdx].size / 1048576;
|
||||
if (el > 0 && szMB > 0) procExtractRate = el / szMB;
|
||||
}
|
||||
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) : '';
|
||||
currentLiveFile = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name + (sz ? ' (' + sz + ')' : '');
|
||||
liveFileEl.textContent = currentLiveFile;
|
||||
@@ -996,6 +630,18 @@ function downloadCsv() {
|
||||
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>
|
||||
<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()">
|
||||
|
||||
Reference in New Issue
Block a user