Files
drhider/site/templates/index.html
T

996 lines
48 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>DrHider — обфускация документов</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<style>
:root {
--brand-primary: #2563eb;
--brand-gray: #d1d5db;
--brand-grey-light: #f3f4f6;
--bg: #f9fafb;
--card: #ffffff;
--text: #1a1a1a;
--muted: #6b7280;
--red: #ef4444;
--green: #22c55e;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font: 14px system-ui, sans-serif; color: var(--text); background: var(--bg); min-height: 100vh; }
.topbar {
background: var(--card); border-bottom: 1px solid var(--brand-gray);
padding: 0 16px; height: 48px; display: flex; align-items: center; gap: 10px;
}
.topbar img { height: 18px; }
.topbar .title { font-weight: 600; font-size: 14px; }
.topbar .ver { font-weight: 400; color: var(--muted); font-size: 12px; }
.topbar .help { margin-left: auto; font-size: 12px; color: var(--muted); cursor: pointer; text-decoration: underline; }
.topbar .help:hover { color: var(--text); }
.content { max-width: 820px; margin: 20px auto; padding: 0 12px; }
.card {
background: var(--card); border-radius: 12px;
border: 1px solid var(--brand-gray); box-shadow: 0 1px 2px rgba(0,0,0,.04);
}
.card-header {
background: var(--brand-grey-light); padding: 10px 12px;
font-weight: 600; font-size: 14px; border-bottom: 1px solid var(--brand-gray);
}
.card-body { padding: 12px; }
.sub { font-size: 12px; color: var(--muted); margin-bottom: 10px; line-height: 1.5; }
.file-input-wrap { margin-bottom: 10px; }
.file-input-wrap input[type="file"] { width: 100%; font-size: 13px; }
.table-wrap { border: 1px solid var(--brand-gray); border-radius: 8px; overflow: hidden; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th {
background: var(--brand-grey-light); text-transform: uppercase; padding: 6px 8px;
border-right: 1px solid var(--brand-gray); text-align: left;
font-weight: 600; font-size: 10px; color: var(--muted);
}
td {
padding: 5px 8px; border-right: 1px solid var(--brand-gray);
border-bottom: 1px solid var(--brand-gray);
}
tr:hover td { background: rgba(37,99,235,.03); }
.name-cell { max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.num-cell { text-align: right; white-space: nowrap; }
.empty-row td { color: var(--muted); text-align: center; padding: 20px; }
.row-over td { background: rgba(220,38,38,.10) !important; color:#c0392b; }
.row-over .num-cell { color:#c0392b; }
.row-over .name-cell { color:#c0392b; }
.row-current td { background: rgba(37,99,235,.12) !important; color:#1d4ed8; font-weight: 600; }
.grp-row td {
background: var(--brand-gray); font-size: 12px; font-weight: 700;
letter-spacing: .03em; text-transform: uppercase; color: var(--muted);
padding: 4px 8px; border: none;
}
.remove-btn {
cursor: pointer; color: #f87171; background: none; border: none;
padding: 2px 4px; font-size: 14px; line-height: 1;
}
.remove-btn:hover { color: var(--red); }
body.busy .remove-btn { display: none; }
.footer-bar {
margin-top: 10px; display: flex; justify-content: space-between; align-items: center;
font-size: 12px; color: var(--muted);
}
.btn {
height: 32px; border-radius: 6px; padding: 0 12px; font-size: 13px;
font-family: inherit; cursor: pointer; border: 1px solid var(--brand-gray);
background: var(--card); color: var(--text);
display: inline-flex; align-items: center; gap: 5px;
}
.btn-primary { background: var(--brand-primary); border-color: var(--brand-primary); color: #fff; }
.btn-primary:hover { opacity: .9; }
.btn:disabled { opacity: .4; cursor: not-allowed; }
.status { margin-top: 10px; padding: 8px 12px; border-radius: 8px; font-size: 13px; display: none; }
.status.progress { background: #eff6ff; border: 1px solid var(--brand-primary); color: var(--brand-primary); display: block; }
.status.done { background: #f0fdf4; border: 1px solid var(--green); color: #16a34a; display: block; }
.status.error { background: #fef2f2; border: 1px solid var(--red); color: #991b1b; display: block; }
.dl-btns { display: none; gap: 8px; margin-top: 8px; }
.dl-btns.show { display: flex; }
.stats-block {
display: none; margin-top: 12px; padding: 16px;
background: linear-gradient(135deg, #f0fdf4, #ecfdf5);
border: 2px solid var(--green); border-radius: 12px;
}
.stats-block.show { display: block; }
.stats-title { font-size: 16px; font-weight: 700; color: #14532d; margin-bottom: 10px; }
.stats-row { display: flex; flex-wrap: wrap; gap: 10px; }
.stat-item {
flex: 1; min-width: 130px; background: #fff; border: 1px solid #bbf7d0;
border-radius: 10px; padding: 10px 12px; text-align: center;
}
.stat-label { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 4px; }
.stat-value { font-size: 24px; font-weight: 800; color: #166534; }
.live-block {
display: none; margin-top: 12px; padding: 16px;
background: linear-gradient(135deg, #eff6ff, #dbeafe);
border: 2px solid var(--brand-primary); border-radius: 12px;
}
.live-block.show { display: block; }
.live-title { font-size: 18px; font-weight: 800; color: #1e40af; margin-bottom: 6px; }
.live-file { font-size: 15px; color: #1e3a8a; margin-bottom: 10px; word-break: break-word; }
.live-timer { font-size: 40px; font-weight: 800; color: #1d4ed8; line-height: 1.1; }
.live-llm {
display: none; margin-top: 8px; padding: 8px 12px; border-radius: 8px;
background: #fef3c7; border: 1px solid #f59e0b; color: #92400e;
font-size: 18px; font-weight: 700; width: fit-content;
}
.live-llm.show { display: inline-block; }
.live-eta { margin-top: 8px; font-size: 14px; color: #1d4ed8; font-weight: 600; }
</style>
</head>
<body>
<div class="topbar">
<img src="/static/logo.svg" alt="Nubes">
<span class="title">DrHider</span>
<span class="ver">v{{ version }}</span>
<span class="help" onclick="document.getElementById('helpModal').style.display='flex'">?</span>
</div>
<div class="content">
<div class="card">
<div class="card-header">Обфускация документов</div>
<div class="card-body">
<p class="sub">
Загрузите документы (.docx, .doc, .pdf, .txt, .md, .zip) — получите ZIP с обезличенными копиями.<br>
CSV с таблицей замен скачивается отдельно.<br>
<span style="color:#c0392b;">Ограничения: один файл не более 50 МБ, суммарно не более 500 МБ. Файлы сверх лимитов помечаются красным и не участвуют в обфускации.</span><br>
<span style="color:#7d3c98;">⏱ Время обработки каждого файла — ориентировочное (обновляется по факту).</span>
</p>
<div class="file-input-wrap">
<input type="file" id="fileInput" multiple accept=".doc,.docx,.pdf,.txt,.zip">
<button type="button" class="btn" id="folderBtn" onclick="document.getElementById('folderInput').click()">📁 Выбрать папку</button>
<input type="file" id="folderInput" webkitdirectory multiple style="display:none">
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Имя</th>
<th style="width:80px;">Размер</th>
<th style="width:80px;">Статус</th>
<th style="width:32px;"></th>
</tr>
</thead>
<tbody id="fileList">
<tr class="empty-row"><td colspan="4">Нет выбранных файлов</td></tr>
</tbody>
</table>
</div>
<div class="footer-bar">
<span id="fileCount">0 файлов</span>
<button class="btn" id="cancelBtn" style="display:none" onclick="confirmCancel()">⏹ Прервать</button>
<button class="btn" id="newSessionBtn" style="display:none" onclick="resetAll()">🔄 Новая сессия</button>
<button class="btn btn-primary" id="uploadBtn" disabled onclick="uploadFiles()">🛡️ Обфусцировать</button>
</div>
<div class="status" id="status"></div>
<div class="dl-btns" id="dlBtns">
<button class="btn btn-primary" id="dlZip" onclick="downloadZip()">📦 Скачать ZIP</button>
<button class="btn" id="dlCsv" onclick="downloadCsv()">📋 Скачать CSV</button>
</div>
<div class="live-block" id="liveBlock">
<div class="live-title">⏱ Обработка…</div>
<div class="live-file" id="liveFile"></div>
<div class="live-timer" id="liveTimer">0.0 с</div>
<div class="live-llm" id="liveLlm">🤖 ИИ обрабатывает… <span id="liveLlmTime">0.0 с</span></div>
<div class="live-eta" id="liveEta"></div>
</div>
<div class="stats-block" id="statsBlock">
<div class="stats-title">📊 Итоги обработки</div>
<div class="stats-row">
<div class="stat-item">
<div class="stat-label">Общее время</div>
<div class="stat-value" id="stTotalTime"></div>
</div>
<div class="stat-item">
<div class="stat-label">Из них ИИ</div>
<div class="stat-value" id="stLlmTime"></div>
</div>
<div class="stat-item">
<div class="stat-label">Токены ИИ</div>
<div class="stat-value" id="stLlmTokens"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const fi = document.getElementById('fileInput');
const folderInput = document.getElementById('folderInput');
const DOC_EXTS = ['.pdf', '.doc', '.docx', '.txt', '.md']; // документы (из папки/архивов)
const fl = document.getElementById('fileList');
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 больших файлов (шлюз кластера их рвёт)
// Тест-режим: открыть страницу с ?upload-only=1 → только загрузка, без обработки
const TEST_UPLOAD_ONLY = new URLSearchParams(location.search).get('upload-only') === '1';
let currentSid = '';
let activeES = null; // активный EventSource
let activeXHR = null; // активный XHR
function resetAll() {
if (activeES) { activeES.close(); activeES = null; }
if (activeXHR) { activeXHR.abort(); activeXHR = null; }
if (ptimer) { clearInterval(ptimer); ptimer = null; }
if (liveRefresh) { clearInterval(liveRefresh); liveRefresh = null; }
if (procRefresh) { clearInterval(procRefresh); procRefresh = null; }
currentSid = '';
sf = [];
fileMeta = new Map();
overNames = new Set();
procPhase = 'idle';
procState = {};
procNameIdx = {};
procExtractDone = false;
procExtractRate = null;
sessionDone = false;
setBusy(false);
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');
ub.disabled = false;
}
// При 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 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">' + 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">' + 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: [] };
for (let i = 0; i < sf.length; i++) {
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;">пропущен</span>'
: '<span style="color:#22c55e;">✓ ' + (p.elapsed ? p.elapsed.toFixed(1) : '0.0') + 'с</span>';
rows.push(procRow(i, txt));
}
}
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 (overNames.has(sf[i].name)) txt = '<span style="color:#c0392b;">🔥 не учитывается</span>';
else 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';
}
function doCancel() {
document.getElementById('cancelModal').style.display = 'none';
if (!currentSid) return;
fetch('/api/cancel/' + currentSid, { method: 'POST' }).catch(() => {});
st.textContent = '⏹ Остановка… завершаем текущий файл';
}
function finishProcUI() {
if (activeES) { activeES.close(); activeES = null; }
if (ptimer) { clearInterval(ptimer); ptimer = null; }
if (liveRefresh) { clearInterval(liveRefresh); liveRefresh = null; }
if (procRefresh) { clearInterval(procRefresh); procRefresh = null; }
document.getElementById('liveBlock').classList.remove('show');
document.getElementById('cancelBtn').style.display = 'none';
procPhase = 'idle';
rr();
}
function setBusy(b) {
busy = b;
fi.disabled = 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;
}
// Имя то же, размер другой — добавить с суффиксом _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;
}
sf.push(new File([file], name, { lastModified: mtime }));
}
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);
nested.forEach(x => addFileWithDedup(x));
} 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);
for (const nf of nested) {
const nm = relDir ? relDir + '/' + nf.name : nf.name;
addFileWithDedup(new File([nf], nm, { lastModified: nf.lastModified }));
added++;
}
} catch (err) {
addFileWithDedup(new File([f], rel, { lastModified: f.lastModified })); // zip как есть
added++;
}
} else if (DOC_EXTS.some(e => low.endsWith(e))) {
addFileWithDedup(new File([f], rel, { lastModified: f.lastModified }));
added++;
}
// иначе — не документ, пропускаем
}
} finally {
document.body.style.cursor = '';
}
folderInput.value = ''; // чтобы повторный выбор той же папки сработал
rr();
if (added) { st.className = 'status done'; st.textContent = '✅ Добавлено из папки: ' + added + ' файлов'; }
});
function ss(idx, h) { const e = document.getElementById('st-' + idx); if (e) e.innerHTML = h; }
async function uploadFiles() {
if (busy) return; // уже идёт загрузка/обработка
if (sf.length === 0) return;
setBusy(true);
sessionDone = false;
document.getElementById('newSessionBtn').style.display = 'none';
// Сохранить список до очистки
const files = sf.slice();
// Только учитываемые файлы идут на загрузку/обфускацию (переборные пропускаем)
const toSend = [];
const sendIdx = []; // sendIdx[k] = индекс в sf для отправленного файла
for (let i = 0; i < files.length; i++) {
if (!overNames.has(files[i].name)) { toSend.push(files[i]); sendIdx.push(i); }
else { ss(i, '<span style="color:#c0392b;">пропущен</span>'); }
}
// Обрубить всё что могло остаться от предыдущего раза
if (activeES) { activeES.close(); activeES = null; }
if (activeXHR) { activeXHR.abort(); activeXHR = null; }
currentSid = '';
st.className = '';
st.textContent = '';
db.classList.remove('show');
document.getElementById('statsBlock').classList.remove('show');
document.getElementById('liveBlock').classList.remove('show');
ub.disabled = true;
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) {
st.className = 'status error';
st.textContent = 'Ошибка передачи ссылок: ' + err.message;
setBusy(false);
ub.disabled = false;
return;
}
// Тест-режим (?upload-only=1): только загрузка, без обработки
if (TEST_UPLOAD_ONLY) {
st.className = 'status done';
st.textContent = '✅ Загружено ' + uploadedCount + ' файлов (тест: обработка пропущена). Сессия: ' + currentSid;
setBusy(false);
ub.disabled = false;
return;
}
// Фаза 2: обработка (SSE — прогресс по каждому файлу)
// Сброс загрузочных статусов — теперь этап обработки (только отправленные)
for (let k = 0; k < total; k++) ss(sendIdx[k], '<span style="color:#2563eb">⏳</span>');
// Инициализация 3-секционной таблицы (готово / текущий / ожидают)
procPhase = 'processing';
procState = {};
procNameIdx = {};
procExtractDone = false;
procExtractRate = null;
for (let k = 0; k < total; k++) {
procState[sendIdx[k]] = { st: 'pending', elapsed: 0, eta: null, est: null, chars: 0, t0: 0 };
}
document.getElementById('cancelBtn').style.display = 'inline-block';
if (procRefresh) clearInterval(procRefresh);
procRefresh = setInterval(rr, 1000); // тикающий рендер (elapsed текущего файла)
rr();
const fileTimers = {}; // idx -> performance.now()
const fileIntervals = {}; // idx -> setInterval id
st.className = 'status progress';
st.textContent = 'Обработка (этап 2/2)…';
const t0 = performance.now();
ptimer = setInterval(() => {
const sec = Math.round((performance.now() - t0) / 1000);
st.textContent = 'Обработка (этап 2/2)… ' + sec + 'с';
}, 1000);
// Крупный live-блок «Идёт обработка» с тикающим таймером
const liveBlock = document.getElementById('liveBlock');
const liveTimerEl = document.getElementById('liveTimer');
const liveFileEl = document.getElementById('liveFile');
const liveLlm = document.getElementById('liveLlm');
const liveLlmTime = document.getElementById('liveLlmTime');
const liveEta = document.getElementById('liveEta');
let currentLiveFile = ''; // последнее имя файла из start
liveLlm.classList.remove('show');
liveEta.textContent = '';
liveTimerEl.textContent = '0.0 с';
liveFileEl.textContent = 'Подготовка…';
liveBlock.classList.add('show');
liveRefresh = setInterval(() => {
const sec = ((performance.now() - t0) / 1000).toFixed(1);
liveTimerEl.textContent = sec + ' с';
}, 200);
try {
await new Promise((resolve, reject) => {
activeES = new EventSource('/api/process_stream/' + currentSid);
activeES.addEventListener('start', function(e) {
const d = JSON.parse(e.data);
const idx = sendIdx[d.idx]; // индекс в sf для вывода прогресса
procNameIdx[d.name] = idx;
const p = procState[idx];
if (p) { p.st = 'pending'; p.elapsed = 0; }
// Этап извлечения: текущий файл = обрабатываемый сейчас (тикер в таблице).
// Предыдущий «текущий» демотируем и замеряем его скорость (сек/МБ) для оценок.
if (!procExtractDone) {
let prevIdx = null;
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) {
const el = (performance.now() - procState[prevIdx].t0) / 1000;
const szMB = sf[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];
const sz = f ? fs(f.size) : '';
currentLiveFile = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name + (sz ? ' (' + sz + ')' : '');
liveFileEl.textContent = currentLiveFile;
});
activeES.addEventListener('extract_done', function(e) {
const d = JSON.parse(e.data);
procExtractDone = true;
// Этап извлечения завершён — снимаем подсветку «текущего» извлечения
for (const i in procState) {
if (procState[i].st === 'current') procState[i].st = 'pending';
}
// per-file символы -> оценка времени для ожидающих файлов
if (d.per_file) {
for (const nm in d.per_file) {
const idx = procNameIdx[nm];
if (idx != null && procState[idx]) procState[idx].chars = d.per_file[nm];
}
}
});
activeES.addEventListener('file_start', function(e) {
const d = JSON.parse(e.data);
const idx = sendIdx[d.idx];
const p = procState[idx];
if (p) { p.st = 'current'; p.t0 = performance.now(); p.eta = null; }
});
activeES.addEventListener('file_chunk', function(e) {
const d = JSON.parse(e.data);
const idx = sendIdx[d.idx];
const p = procState[idx];
if (p && typeof d.eta_sec === 'number') p.eta = d.eta_sec;
liveFileEl.textContent = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name +
(typeof d.eta_sec === 'number' ? ' — осталось ~' + fmtSec(d.eta_sec) : '');
});
activeES.addEventListener('file_done', function(e) {
const d = JSON.parse(e.data);
const idx = sendIdx[d.idx];
const p = procState[idx];
if (p) { p.st = 'analyzed'; }
});
activeES.addEventListener('llm', function(e) {
const d = JSON.parse(e.data);
if (d.active) {
liveLlmTime.textContent = d.elapsed + ' с';
liveLlm.classList.add('show');
liveFileEl.textContent = '🤖 ИИ анализирует все файлы…';
if (typeof d.eta_sec === 'number' && d.eta_sec >= 0) liveEta.textContent = '⏳ осталось ~' + fmtSec(d.eta_sec);
else liveEta.textContent = '';
} else {
liveLlm.classList.remove('show');
if (currentLiveFile) liveFileEl.textContent = currentLiveFile;
}
});
activeES.addEventListener('done', function(e) {
const d = JSON.parse(e.data);
const idx = sendIdx[d.idx];
if (!procExtractDone) {
// done на этапе извлечения = битый/пропущенный файл
const p0 = procState[idx];
if (p0) p0.st = 'skipped';
return;
}
// Время на КОНКРЕТНЫЙ файл приходит с бэка (extract_text + замена, без общего LLM)
const sec = (typeof d.elapsed === 'number' && d.elapsed > 0)
? d.elapsed.toFixed(1)
: ((performance.now() - (fileTimers[idx] || performance.now())) / 1000).toFixed(1);
const p = procState[idx];
if (p) { p.st = 'done'; p.elapsed = d.elapsed > 0 ? d.elapsed : parseFloat(sec); }
else procState[idx] = { st: 'done', elapsed: parseFloat(sec), eta: null, est: null, chars: 0, t0: 0 };
});
activeES.addEventListener('cancelled', function(e) {
const d = JSON.parse(e.data);
finishProcUI();
const totalSec = ((performance.now() - t0) / 1000).toFixed(1);
st.className = 'status done';
st.textContent = '⏹ Остановлено. Сохранено ' + d.saved + ' из ' + d.total + ' файлов + таблица замен (общее ' + totalSec + 'с)';
const sb = document.getElementById('statsBlock');
document.getElementById('stTotalTime').textContent = totalSec + ' с';
if (d.llm_sec > 0) {
document.getElementById('stLlmTime').textContent = d.llm_sec + ' с';
document.getElementById('stLlmTokens').textContent = d.tokens > 0 ? d.tokens : '—';
} else {
document.getElementById('stLlmTime').textContent = '—';
document.getElementById('stLlmTokens').textContent = '—';
}
sb.classList.add('show');
db.classList.add('show');
// Заморозка сессии: результат готов (частичный), список файлов больше не меняем
sessionDone = true;
ub.disabled = true;
fi.disabled = true;
document.getElementById('newSessionBtn').style.display = 'inline-block';
resolve();
});
activeES.addEventListener('complete', function(e) {
finishProcUI();
const d = JSON.parse(e.data);
const totalSec = ((performance.now() - t0) / 1000).toFixed(1);
st.className = 'status done';
st.textContent = '✅ Обработано ' + d.total + ' файлов: общее ' + totalSec + 'с, из них ИИ ' + (d.llm_sec > 0 ? d.llm_sec + 'с' : '0с');
// Крупный блок статистики
const sb = document.getElementById('statsBlock');
document.getElementById('stTotalTime').textContent = totalSec + ' с';
if (d.llm_sec > 0) {
document.getElementById('stLlmTime').textContent = d.llm_sec + ' с';
document.getElementById('stLlmTokens').textContent = d.tokens > 0 ? d.tokens : '—';
} else {
document.getElementById('stLlmTime').textContent = '—';
document.getElementById('stLlmTokens').textContent = '—';
}
sb.classList.add('show');
db.classList.add('show');
// Заморозка сессии: результат готов, список файлов больше не меняем
sessionDone = true;
ub.disabled = true;
fi.disabled = true;
document.getElementById('newSessionBtn').style.display = 'inline-block';
resolve();
});
activeES.onerror = function() {
finishProcUI();
reject(new Error('SSE connection failed'));
};
});
} catch (err) {
finishProcUI();
setBusy(false);
st.className = 'status error';
st.textContent = 'Ошибка: ' + err.message;
}
if (!sessionDone) ub.disabled = false;
}
function downloadZip() {
if (!currentSid) return;
fetch('/api/download/' + currentSid)
.then(r => {
const disp = r.headers.get('Content-Disposition');
const m = disp && disp.match(/filename="?(.+?)"?$/);
return Promise.all([r.blob(), m ? m[1] : 'drhider.zip']);
})
.then(([blob, fname]) => {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = fname;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
});
}
function downloadCsv() {
if (!currentSid) return;
fetch('/api/csv/' + currentSid)
.then(r => {
const disp = r.headers.get('Content-Disposition');
const m = disp && disp.match(/filename="?(.+?)"?$/);
return Promise.all([r.blob(), m ? m[1] : 'mapping.csv']);
})
.then(([blob, fname]) => {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = fname;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
});
}
</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()">
<h3 style="margin-bottom:12px">DrHider — обфускация документов</h3>
<p style="font-size:13px;color:var(--muted);line-height:1.6">
Загрузите документы (.docx, .doc, .pdf, .txt, .md, .zip) — получите ZIP-архив с обезличенными копиями.
Персональные данные, реквизиты компаний, телефоны, email и другие приватные сведения заменяются на обезличенные коды.
CSV-файл с таблицей соответствия оригинал→замена скачивается отдельно.
</p>
<button class="btn" style="margin-top:12px;width:100%" onclick="document.getElementById('helpModal').style.display='none'">Закрыть</button>
</div>
</div>
<div id="cancelModal" 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:440px;box-shadow:0 4px 24px rgba(0,0,0,.15)" onclick="event.stopPropagation()">
<h3 style="margin-bottom:12px">⏹ Остановить обработку?</h3>
<p style="font-size:13px;color:var(--muted);line-height:1.6" id="cancelModalText">
Будут сохранены: полностью обработанные файлы и таблица замен.
Необработанные файлы в результат не попадут.
Текущий анализ будет доведён до конца.
</p>
<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end">
<button class="btn" onclick="document.getElementById('cancelModal').style.display='none'">Отмена</button>
<button class="btn btn-primary" onclick="doCancel()">Остановить</button>
</div>
</div>
</div>
</body>
</html>