328 lines
13 KiB
JavaScript
328 lines
13 KiB
JavaScript
'use strict';
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
let statusTimer = null;
|
||
let lastData = null;
|
||
let knownPeersKey = '';
|
||
let rangeInitialized = false;
|
||
let pendingTrustId = null;
|
||
|
||
function esc(v) {
|
||
return String(v).replace(/[&<>"']/g, (s) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[s]));
|
||
}
|
||
|
||
function showStatus(msg, isError = false) {
|
||
const el = $('status');
|
||
clearTimeout(statusTimer);
|
||
el.style.display = 'block';
|
||
el.style.background = isError ? 'rgba(239,68,68,.2)' : 'rgba(16,185,129,.2)';
|
||
el.style.border = `1px solid ${isError ? '#ef4444' : '#10b981'}`;
|
||
el.textContent = msg;
|
||
statusTimer = setTimeout(() => { el.style.display = 'none'; }, 4500);
|
||
}
|
||
|
||
function formatBytes(b) {
|
||
if (b < 1024) return `${b} Б`;
|
||
if (b < 1024 ** 2) return `${(b / 1024).toFixed(1)} КБ`;
|
||
if (b < 1024 ** 3) return `${(b / 1024 ** 2).toFixed(2)} МБ`;
|
||
return `${(b / 1024 ** 3).toFixed(2)} ГБ`;
|
||
}
|
||
|
||
// Обновляем DOM только если разметка реально изменилась (не сбрасывает скролл/hover/картинки)
|
||
function setHtml(el, html) {
|
||
if (el._h !== html) { el._h = html; el.innerHTML = html; }
|
||
}
|
||
|
||
async function api(path, opts = {}) {
|
||
const res = await fetch(path, { credentials: 'same-origin', ...opts });
|
||
if (!res.ok) throw new Error((await res.text()) || `Ошибка ${res.status}`);
|
||
return res;
|
||
}
|
||
|
||
function postJson(path, body) {
|
||
return api(path, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
}
|
||
|
||
// ───────────── рендер ─────────────
|
||
function renderHeader(d) {
|
||
$('my-name').textContent = `Устройство: ${d.device_name}`;
|
||
$('my-ip').textContent = `Наш адрес: ${d.my_ip}:${d.p2p_port} | Отпечаток: ${d.my_fingerprint.slice(0, 16)}…`;
|
||
$('my-pin').textContent = d.my_pin;
|
||
$('media-dir').textContent = d.media_dir;
|
||
if (!rangeInitialized && d.my_ip && d.my_ip.includes('.') && !d.my_ip.startsWith('127.')) {
|
||
const p = d.my_ip.split('.');
|
||
$('scan-range-input').value = `${p[0]}.${p[1]}.${p[2]}.1-254`;
|
||
rangeInitialized = true;
|
||
}
|
||
}
|
||
|
||
function renderScan(scan) {
|
||
const box = $('scan-progress-box');
|
||
if (scan && (scan.active || scan.total > 0)) {
|
||
box.style.display = 'block';
|
||
const pct = scan.total > 0 ? Math.round((scan.scanned / scan.total) * 100) : 0;
|
||
$('scan-label').textContent = scan.active
|
||
? `Сканирование ${scan.range}… (найдено: ${scan.found})`
|
||
: `Сканирование ${scan.range} завершено (найдено: ${scan.found})`;
|
||
$('scan-numbers').textContent = `${scan.scanned} / ${scan.total} (${pct}%)`;
|
||
$('scan-bar-fill').style.width = `${pct}%`;
|
||
$('btn-scan-range').disabled = scan.active;
|
||
$('btn-scan-all').disabled = scan.active;
|
||
}
|
||
}
|
||
|
||
function renderTransfers(list) {
|
||
setHtml($('transfers-list'), (list || []).map((t) => {
|
||
const dir = t.direction === 'out' ? '⬆️ Отправка на' : '⬇️ Приём от';
|
||
const status = t.error ? '✗ Ошибка' : t.done ? '✓ Завершено' : `${t.percent}%`;
|
||
const note = t.error
|
||
? `<span style="color:#f87171;">${esc(t.error)}</span>`
|
||
: (t.done ? 'Готово' : 'Передача по TLS…');
|
||
return `
|
||
<div class="progress-wrap">
|
||
<div style="display:flex;justify-content:space-between;font-size:.83rem;font-weight:600;gap:8px;">
|
||
<span>${dir} ${esc(t.peer_name)}: <span style="color:#60a5fa;">${esc(t.filename)}</span></span>
|
||
<span>${esc(status)}</span>
|
||
</div>
|
||
<div class="progress-bar-bg"><div class="progress-bar-fill" style="width:${Number(t.percent)}%;"></div></div>
|
||
<div class="small muted" style="display:flex;justify-content:space-between;gap:8px;">
|
||
<span>${formatBytes(t.transferred_bytes)} из ${formatBytes(t.total_bytes)}</span>
|
||
<span>${note}</span>
|
||
</div>
|
||
</div>`;
|
||
}).join(''));
|
||
}
|
||
|
||
function renderPeers(peers) {
|
||
const box = $('peers-list');
|
||
if (!peers.length) {
|
||
setHtml(box, '<div class="muted" style="font-size:.9rem;">Устройства пока не найдены. Нажмите «Вся подсеть» или введите IP вручную.</div>');
|
||
} else {
|
||
setHtml(box, peers.map((p) => `
|
||
<div class="peer-item">
|
||
<div style="min-width:0;">
|
||
<div style="font-weight:600;">
|
||
<span class="dot ${p.online ? 'dot-on' : 'dot-off'}"></span>${esc(p.name)}
|
||
<span class="muted small">(${esc(p.ips.join(', ') || 'адрес неизвестен')}:${esc(p.port)})</span>
|
||
</div>
|
||
<div class="small muted mono" style="margin-top:3px;word-break:break-all;">
|
||
Код сверки: <b style="color:#fff;">${esc(p.pin_code)}</b><br>
|
||
Статус: ${p.verified ? '<span style="color:#34d399;">Заверен</span>' : '<span style="color:#f87171;">Не заверен</span>'}
|
||
</div>
|
||
</div>
|
||
<button class="btn-sm ${p.verified ? 'btn-red' : 'btn-green'}"
|
||
data-action="trust" data-id="${esc(p.id)}" data-trust="${p.verified ? '0' : '1'}">
|
||
${p.verified ? 'Отозвать' : 'Заверить'}
|
||
</button>
|
||
</div>`).join(''));
|
||
}
|
||
|
||
const key = peers.map((p) => `${p.id}:${p.verified}:${p.ips[0] || ''}:${p.name}`).join('|');
|
||
if (key !== knownPeersKey) {
|
||
knownPeersKey = key;
|
||
const sel = $('target-peer');
|
||
const prev = sel.value;
|
||
sel.innerHTML = '<option value="">-- Выберите устройство --</option>' + peers.map((p) =>
|
||
`<option value="${esc(p.id)}">${esc(p.name)} (${esc(p.ips[0] || '—')}) ${p.verified ? '✓' : '🔒 не заверен'}</option>`).join('');
|
||
if (prev) sel.value = prev;
|
||
}
|
||
}
|
||
|
||
function renderTexts(texts) {
|
||
const box = $('ram-texts');
|
||
if (!texts.length) return setHtml(box, '<div class="muted" style="font-size:.9rem;">Буфер ОЗУ пуст</div>');
|
||
setHtml(box, texts.map((t, i) => `
|
||
<div class="msg-item">
|
||
<div class="msg-meta">
|
||
<span>От: <b>${esc(t.sender)}</b></span>
|
||
<button class="btn-sm" data-action="copy" data-idx="${i}">Скопировать</button>
|
||
</div>
|
||
<div class="msg-text">${esc(t.text)}</div>
|
||
</div>`).join(''));
|
||
}
|
||
|
||
function renderMedia(media) {
|
||
const box = $('disk-media');
|
||
if (!media.length) return setHtml(box, '<div class="muted" style="font-size:.9rem;">Нет сохранённых файлов</div>');
|
||
setHtml(box, media.map((m) => {
|
||
const url = `/ui/files/${encodeURIComponent(m.filename)}`;
|
||
return `
|
||
<div class="msg-item">
|
||
<div class="msg-meta"><span>От: <b>${esc(m.sender)}</b></span><span>${formatBytes(m.size)}</span></div>
|
||
<div style="font-weight:600;margin:2px 0;word-break:break-all;">
|
||
<a href="${url}" target="_blank" rel="noopener" style="color:#60a5fa;text-decoration:none;">${esc(m.filename)} ↗</a>
|
||
</div>
|
||
${m.risky ? '<div class="warn">⚠ Потенциально опасный тип файла — не открывайте, если не ждали его.</div>' : ''}
|
||
${m.previewable ? `<img src="${url}" class="preview-img" alt="">` : ''}
|
||
</div>`;
|
||
}).join(''));
|
||
}
|
||
|
||
async function fetchState() {
|
||
const d = await (await api('/ui/state')).json();
|
||
lastData = d;
|
||
renderHeader(d);
|
||
renderScan(d.scan);
|
||
renderTransfers(d.transfers);
|
||
renderPeers(d.peers);
|
||
renderTexts(d.texts);
|
||
renderMedia(d.media);
|
||
}
|
||
|
||
// ───────────── действия ─────────────
|
||
async function guarded(fn) {
|
||
try { await fn(); } catch (e) { showStatus(e.message || String(e), true); }
|
||
}
|
||
|
||
function scanIpRange(full) {
|
||
return guarded(async () => {
|
||
const range = full ? 'all' : $('scan-range-input').value.trim();
|
||
if (!full && !range) return showStatus('Введите диапазон IP (например 192.168.1.1-254)', true);
|
||
await postJson('/ui/scan-range', { range });
|
||
showStatus('Сканирование запущено…');
|
||
fetchState().catch(() => {});
|
||
});
|
||
}
|
||
|
||
function addManualPeer() {
|
||
return guarded(async () => {
|
||
const raw = $('manual-ip-input').value.trim();
|
||
if (!raw) return showStatus('Введите IP-адрес устройства', true);
|
||
let ip = raw, port = null;
|
||
const i = raw.lastIndexOf(':');
|
||
if (i > 0) { ip = raw.slice(0, i); port = parseInt(raw.slice(i + 1), 10) || null; }
|
||
showStatus(`Связь с узлом ${ip}…`);
|
||
await postJson('/ui/manual-peer', port ? { ip, port } : { ip });
|
||
$('manual-ip-input').value = '';
|
||
showStatus(`Узел ${ip} найден. Сверьте код и заверьте устройство.`);
|
||
fetchState().catch(() => {});
|
||
});
|
||
}
|
||
|
||
function pingPeers() {
|
||
return guarded(async () => {
|
||
await api('/ui/ping', { method: 'POST' });
|
||
showStatus('Запущен опрос известных узлов');
|
||
setTimeout(() => fetchState().catch(() => {}), 800);
|
||
});
|
||
}
|
||
|
||
function setTrust(id, trust) {
|
||
return guarded(async () => {
|
||
await postJson('/ui/trust', { peer_id: id, trust });
|
||
showStatus(trust ? 'Устройство заверено' : 'Доверие отозвано');
|
||
fetchState().catch(() => {});
|
||
});
|
||
}
|
||
|
||
function sendText() {
|
||
return guarded(async () => {
|
||
const peer_id = $('target-peer').value;
|
||
const input = $('text-input');
|
||
const text = input.value.trim();
|
||
if (!peer_id) return showStatus('Выберите устройство-получатель!', true);
|
||
if (!text) return showStatus('Введите текст для отправки!', true);
|
||
if (new TextEncoder().encode(text).length > 64 * 1024) return showStatus('Текст больше 64 КБ', true);
|
||
await postJson('/ui/send-text', { peer_id, text });
|
||
input.value = '';
|
||
showStatus('Текст доставлен в ОЗУ получателя');
|
||
});
|
||
}
|
||
|
||
async function sendMedia() {
|
||
const peer_id = $('target-peer').value;
|
||
const fileInput = $('media-input');
|
||
const btn = $('send-media-btn');
|
||
if (!peer_id) return showStatus('Выберите устройство-получатель!', true);
|
||
if (!fileInput.files.length) return showStatus('Выберите файл!', true);
|
||
const file = fileInput.files[0];
|
||
btn.disabled = true;
|
||
try {
|
||
await api('/ui/send-media', {
|
||
method: 'POST',
|
||
headers: {
|
||
'X-Target-Id': peer_id,
|
||
'X-Filename': encodeURIComponent(file.name),
|
||
'Content-Type': 'application/octet-stream',
|
||
},
|
||
body: file,
|
||
});
|
||
fileInput.value = '';
|
||
showStatus(`Файл «${file.name}» передан`);
|
||
} catch (e) {
|
||
showStatus(e.message || String(e), true);
|
||
} finally {
|
||
btn.disabled = false;
|
||
fetchState().catch(() => {});
|
||
}
|
||
}
|
||
|
||
function clearRam() {
|
||
return guarded(async () => {
|
||
await api('/ui/clear-ram', { method: 'POST' });
|
||
showStatus('Буфер текстов в ОЗУ очищен');
|
||
fetchState().catch(() => {});
|
||
});
|
||
}
|
||
|
||
// ───────────── события (без inline-обработчиков: CSP script-src 'self') ─────────────
|
||
$('btn-ping').addEventListener('click', pingPeers);
|
||
$('btn-manual').addEventListener('click', addManualPeer);
|
||
$('btn-scan-range').addEventListener('click', () => scanIpRange(false));
|
||
$('btn-scan-all').addEventListener('click', () => scanIpRange(true));
|
||
$('btn-send-text').addEventListener('click', sendText);
|
||
$('send-media-btn').addEventListener('click', sendMedia);
|
||
$('btn-clear-ram').addEventListener('click', clearRam);
|
||
|
||
$('text-input').addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendText(); }
|
||
});
|
||
|
||
$('peers-list').addEventListener('click', (e) => {
|
||
const btn = e.target.closest('button[data-action="trust"]');
|
||
if (!btn || !lastData) return;
|
||
const id = btn.dataset.id;
|
||
if (btn.dataset.trust === '0') return setTrust(id, false);
|
||
const peer = lastData.peers.find((p) => p.id === id);
|
||
if (!peer) return;
|
||
pendingTrustId = id;
|
||
$('td-name').textContent = peer.name;
|
||
$('td-code').textContent = peer.pin_code;
|
||
$('trust-dialog').showModal();
|
||
});
|
||
|
||
$('td-cancel').addEventListener('click', () => { pendingTrustId = null; $('trust-dialog').close(); });
|
||
$('td-ok').addEventListener('click', () => {
|
||
const id = pendingTrustId;
|
||
pendingTrustId = null;
|
||
$('trust-dialog').close();
|
||
if (id) setTrust(id, true);
|
||
});
|
||
|
||
$('ram-texts').addEventListener('click', (e) => {
|
||
const btn = e.target.closest('button[data-action="copy"]');
|
||
if (!btn || !lastData) return;
|
||
const item = lastData.texts[Number(btn.dataset.idx)];
|
||
if (!item) return;
|
||
navigator.clipboard.writeText(item.text).then(
|
||
() => showStatus('Текст скопирован в буфер обмена'),
|
||
() => showStatus('Не удалось скопировать', true),
|
||
);
|
||
});
|
||
|
||
// ───────────── опрос состояния: следующий запрос только после завершения предыдущего ─────────────
|
||
async function poll() {
|
||
try {
|
||
if (!document.hidden) await fetchState();
|
||
$('offline').style.display = 'none';
|
||
} catch (_) {
|
||
$('offline').style.display = 'block';
|
||
}
|
||
setTimeout(poll, document.hidden ? 2500 : 700);
|
||
}
|
||
poll();
|