690 lines
31 KiB
HTML
690 lines
31 KiB
HTML
<!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; }
|
||
.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); }
|
||
.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; }
|
||
</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;">Ограничения: суммарно не более 1 ГБ. Файлы сверх суммарного лимита помечаются красным и не участвуют в обфускации.</span>
|
||
</p>
|
||
<div class="file-input-wrap">
|
||
<input type="file" id="fileInput" multiple accept=".doc,.docx,.pdf,.txt,.zip">
|
||
</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 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>
|
||
<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 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(); // имена файлов сверх лимита (не участвуют в обфускации)
|
||
const MAX_SESSION_BYTES = 1024 * 1024 * 1024; // 1 ГБ суммарно (лимит 100МБ/файл убран)
|
||
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; }
|
||
currentSid = '';
|
||
sf = [];
|
||
fileMeta = new Map();
|
||
overNames = new Set();
|
||
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 rr() {
|
||
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:#999;">—</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;
|
||
fc.textContent = (overCount ? cntMain + ' учитываются + ' + overCount + ' свыше лимита' : sf.length) + ' файлов · ' + fs(totalSize);
|
||
ub.disabled = cntMain === 0;
|
||
}
|
||
|
||
function rm(i) { 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);
|
||
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 });
|
||
|
||
// Определяем, превышает ли файл суммарный лимит (не участвует в обфускации)
|
||
// Лимит 100МБ/файл убран — учитываются все файлы по размеру.
|
||
let over = false;
|
||
const sum = sf.reduce((s, f) => s + (overNames.has(f.name) ? 0 : f.size), 0);
|
||
if (sum + size > MAX_SESSION_BYTES) over = true;
|
||
|
||
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 () => {
|
||
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();
|
||
});
|
||
|
||
function ss(idx, h) { const e = document.getElementById('st-' + idx); if (e) e.innerHTML = h; }
|
||
|
||
async function uploadFiles() {
|
||
if (sf.length === 0) return;
|
||
// Сохранить список до очистки
|
||
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 = 'Нет файлов для обфускации (все превышают лимит).'; 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;
|
||
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;
|
||
ub.disabled = false;
|
||
return;
|
||
}
|
||
|
||
// Тест-режим (?upload-only=1): только загрузка, без обработки
|
||
if (TEST_UPLOAD_ONLY) {
|
||
st.className = 'status done';
|
||
st.textContent = '✅ Загружено ' + uploadedCount + ' файлов (тест: обработка пропущена). Сессия: ' + currentSid;
|
||
ub.disabled = false;
|
||
return;
|
||
}
|
||
|
||
// Фаза 2: обработка (SSE — прогресс по каждому файлу)
|
||
// Сброс загрузочных статусов — теперь этап обработки (только отправленные)
|
||
for (let k = 0; k < total; k++) ss(sendIdx[k], '<span style="color:#2563eb">⏳</span>');
|
||
const fileTimers = {}; // idx -> performance.now()
|
||
const fileIntervals = {}; // idx -> setInterval id
|
||
st.className = 'status progress';
|
||
st.textContent = 'Обработка (этап 2/2)…';
|
||
const t0 = performance.now();
|
||
const 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');
|
||
let currentLiveFile = ''; // последнее имя файла из start
|
||
liveLlm.classList.remove('show');
|
||
liveTimerEl.textContent = '0.0 с';
|
||
liveFileEl.textContent = 'Подготовка…';
|
||
liveBlock.classList.add('show');
|
||
const 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 для вывода прогресса
|
||
// Таймер тикает ТОЛЬКО у текущего файла; таймеры остальных останавливаем
|
||
for (const k in fileIntervals) {
|
||
clearInterval(fileIntervals[k]);
|
||
delete fileIntervals[k];
|
||
}
|
||
fileTimers[idx] = performance.now();
|
||
fileIntervals[idx] = setInterval(function() {
|
||
const elapsed = ((performance.now() - fileTimers[idx]) / 1000).toFixed(1);
|
||
ss(idx, '<span style="color:#2563eb">⏳ ' + elapsed + 'с</span>');
|
||
}, 200);
|
||
// Показываем текущий файл и его размер в live-блоке
|
||
const f = sf[idx];
|
||
const sz = f ? fs(f.size) : '';
|
||
currentLiveFile = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name + (sz ? ' (' + sz + ')' : '');
|
||
liveFileEl.textContent = currentLiveFile;
|
||
});
|
||
activeES.addEventListener('llm', function(e) {
|
||
const d = JSON.parse(e.data);
|
||
if (d.active) {
|
||
liveLlmTime.textContent = d.elapsed + ' с';
|
||
liveLlm.classList.add('show');
|
||
// LLM анализирует ВСЕ файлы вместе — не показываем имя конкретного
|
||
liveFileEl.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];
|
||
clearInterval(fileIntervals[idx]);
|
||
// Время на КОНКРЕТНЫЙ файл приходит с бэка (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);
|
||
ss(idx, '<span style="color:#22c55e">✓ ' + sec + 'с</span>');
|
||
});
|
||
activeES.addEventListener('complete', function(e) {
|
||
activeES.close();
|
||
activeES = null;
|
||
clearInterval(ptimer);
|
||
clearInterval(liveRefresh);
|
||
liveBlock.classList.remove('show');
|
||
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');
|
||
resolve();
|
||
});
|
||
activeES.onerror = function() {
|
||
activeES.close();
|
||
activeES = null;
|
||
clearInterval(ptimer);
|
||
clearInterval(liveRefresh);
|
||
liveBlock.classList.remove('show');
|
||
reject(new Error('SSE connection failed'));
|
||
};
|
||
});
|
||
} catch (err) {
|
||
clearInterval(ptimer);
|
||
clearInterval(liveRefresh);
|
||
liveBlock.classList.remove('show');
|
||
st.className = 'status error';
|
||
st.textContent = 'Ошибка: ' + err.message;
|
||
}
|
||
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>
|
||
</body>
|
||
</html>
|