Files
drhider/site/templates/index.html
T

270 lines
12 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; margin-left: auto; }
.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; }
.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; }
.status.pack { background: #fefce8; border: 1px solid #eab308; color: #a16207; display: 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>
</div>
<div class="content">
<div class="card">
<div class="card-header">Обфускация документов</div>
<div class="card-body">
<p class="sub">
Загрузите документы (.docx, .pdf, .txt, .zip) — получите ZIP с обезличенными копиями.<br>
Персональные данные, реквизиты, телефоны, email заменяются. CSV с таблицей замен сохраняется отдельно.
</p>
<div class="file-input-wrap">
<input type="file" id="fileInput" multiple accept=".docx,.pdf,.txt,.doc,.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>
</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');
let sf = [];
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) => '<tr id="row-' + i + '"><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;">—</td><td><button class="remove-btn" onclick="rm(' + i + ')">✕</button></td></tr>').join(''); }
fc.textContent = sf.length + ' файлов';
ub.disabled = sf.length === 0;
}
function rm(i) { sf.splice(i, 1); const d = new DataTransfer(); sf.forEach(f => d.items.add(f)); fi.files = d.files; rr(); }
window.addEventListener('load', () => { sf = []; fi.value = ''; rr(); });
fi.addEventListener('change', () => { sf = Array.from(fi.files); rr(); });
function ts() {
const d = new Date();
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') + '_' + String(d.getHours()).padStart(2, '0') + '-' + String(d.getMinutes()).padStart(2, '0') + '-' + String(d.getSeconds()).padStart(2, '0');
}
function ss(idx, h) { const e = document.getElementById('st-' + idx); if (e) e.innerHTML = h; }
// ═══════════ Нативный ZIP (без внешних библиотек) ═══════════
async function unzip(zb) {
const buf = await zb.arrayBuffer(); const dv = new DataView(buf);
const files = {}; let pos = 0;
while (pos < buf.byteLength - 4) {
const sig = dv.getUint32(pos, true);
if (sig === 0x04034b50) {
const start = pos; // Начало local file header
pos += 26;
const nl = dv.getUint16(pos, true); pos += 2;
const el = dv.getUint16(pos, true); pos += 2;
const nm = new TextDecoder().decode(new Uint8Array(buf, pos, nl)); pos += nl + el;
const cs = dv.getUint32(start + 18, true); // compressed size at offset 18
files[nm] = new Blob([new Uint8Array(buf, pos, cs)]); pos += cs;
} else break;
}
return files;
}
async function buildZip(entries) {
const parts = []; const cd = []; let off = 0;
for (const e of entries) {
const d = new Uint8Array(await e.data.arrayBuffer());
const nm = new TextEncoder().encode(e.name);
const lh = new Uint8Array(30 + nm.length); const lv = new DataView(lh.buffer);
lv.setUint32(0, 0x04034b50, true); lv.setUint16(6, 0x800, true);
lv.setUint32(18, d.length, true); lv.setUint32(22, d.length, true);
lv.setUint16(26, nm.length, true); lh.set(nm, 30); parts.push(lh, d);
const ce = new Uint8Array(46 + nm.length); const cv = new DataView(ce.buffer);
cv.setUint32(0, 0x02014b50, true); cv.setUint16(8, 0x800, true);
cv.setUint32(20, d.length, true); cv.setUint32(24, d.length, true);
cv.setUint16(28, nm.length, true); cv.setUint32(42, off, true); ce.set(nm, 46); cd.push(ce);
off += 30 + nm.length + d.length;
}
const cs = cd.reduce((s, c) => s + c.length, 0);
const eo = new Uint8Array(22); const ev = new DataView(eo.buffer);
ev.setUint32(0, 0x06054b50, true); ev.setUint16(8, entries.length, true);
ev.setUint16(10, entries.length, true); ev.setUint32(12, cs, true); ev.setUint32(16, off, true);
return new Blob([...parts, ...cd, eo], { type: 'application/zip' });
}
async function uploadFiles() {
if (sf.length === 0) return;
ub.disabled = true;
const total = sf.length;
const tstamp = ts();
const maps = [];
const zEntries = [];
let ok = 0, fail = 0;
for (let i = 0; i < total; i++) {
const f = sf[i], n = i + 1;
const t0 = performance.now();
// Бегущий секундомер в статусе
const timer = setInterval(() => {
const sec = Math.round((performance.now() - t0) / 1000);
ss(i, '<span style="color:#2563eb">⏳ ' + sec + 'с</span>');
}, 200);
st.className = 'status progress';
st.textContent = 'Файл ' + n + '/' + total + ': ' + f.name;
const fd = new FormData();
fd.append('files', f, f.name);
try {
const blob = await new Promise((resolve, reject) => {
const x = new XMLHttpRequest();
x.open('POST', '/api/drhider');
x.responseType = 'blob';
x.timeout = 600000;
x.onload = () => x.status === 200 ? resolve(x.response) : reject(new Error('Сервер ' + x.status));
x.onerror = () => reject(new Error('Сеть'));
x.ontimeout = () => reject(new Error('Таймаут'));
x.send(fd);
});
clearInterval(timer);
const el = ((performance.now() - t0) / 1000).toFixed(1);
const files = await unzip(blob);
for (const [name, data] of Object.entries(files)) {
if (name === 'mapping.csv') {
const t = await data.text();
const ls = t.split('\n');
maps.push(maps.length === 0 ? ls.join('\n') : ls.slice(1).join('\n'));
} else { zEntries.push({ name, data }); }
}
ss(i, '<span style="color:#22c55e">✓ ' + el + 'с</span>');
ok++;
} catch (err) {
clearInterval(timer);
ss(i, '<span style="color:#ef4444">✗</span>');
fail++;
}
if (i < total - 1) await new Promise(r => setTimeout(r, 300));
}
if (ok > 0) {
st.className = 'status pack'; st.textContent = 'Упаковка...';
const zb = await buildZip(zEntries);
const u = URL.createObjectURL(zb);
const a = document.createElement('a'); a.href = u; a.download = 'drhider_' + tstamp + '.zip'; a.click();
URL.revokeObjectURL(u);
}
if (maps.length > 0) {
const cb = new Blob(['\ufeff' + maps.join('\n')], { type: 'text/csv;charset=utf-8' });
const u = URL.createObjectURL(cb);
const a = document.createElement('a'); a.href = u; a.download = 'drhider_' + tstamp + '.csv'; a.click();
URL.revokeObjectURL(u);
}
st.className = fail === 0 ? 'status done' : 'status error';
st.textContent = fail === 0 ? '✅ Готово! ' + ok + '/' + total : 'Готово: ' + ok + ' OK, ' + fail + ' ошибок из ' + total;
ub.disabled = false;
}
</script>
</body>
</html>