diff --git a/public/bans.html b/public/bans.html index d91f0a6..55c394f 100644 --- a/public/bans.html +++ b/public/bans.html @@ -12,24 +12,55 @@

Блокировки IP

-

IP автоматически блокируется на 24 часа: за заполнение скрытого антиспам-поля, за 10 неудачных попыток входа или 10 неверных паролей на защищённой ссылке

+

Автобан на 24 часа за антиспам и брутфорс. Здесь можно разбанить или добавить вручную

-
- - + +
+ +
+ +
+ + + + + + + + + + + +
IPПричинаЗаблокированРазблокируется
+ +
+
+
+
+ + @@ -37,5 +68,14 @@ + + diff --git a/public/js/bans.js b/public/js/bans.js index 95e661a..64e68c4 100644 --- a/public/js/bans.js +++ b/public/js/bans.js @@ -1,3 +1,4 @@ +const PAGE_SIZE = 15; const REASONS = { 'honeypot': 'Антиспам-поле заполнено', 'login-bruteforce': 'Подбор пароля (10 неудачных входов)', @@ -5,49 +6,95 @@ const REASONS = { 'manual': 'Вручную', }; +let allBans = []; +let page = 1; + function reasonLabel(r) { return REASONS[r] || r || '—'; } async function loadBans() { - const tbody = document.getElementById('bansBody'); - const info = document.getElementById('bansInfo'); const res = await fetch(`${API}/api/bans`, { headers: hdr() }); + const tbody = document.getElementById('bansBody'); if (!res.ok) { - tbody.innerHTML = `Ошибка загрузки (${res.status})`; - info.textContent = ''; + tbody.innerHTML = `Ошибка загрузки (${res.status})`; + allBans = []; + renderPager(document.getElementById('pager'), 1, 1, () => {}); return; } - const bans = await res.json(); - info.textContent = `Активных блокировок: ${bans.length}`; - if (!bans.length) { - tbody.innerHTML = 'Активных блокировок нет'; - return; - } - tbody.innerHTML = bans.map(b => ` + allBans = await res.json(); + const maxPage = Math.max(1, Math.ceil(allBans.length / PAGE_SIZE)); + if (page > maxPage) page = maxPage; + renderPage(); +} + +function renderPage() { + const tbody = document.getElementById('bansBody'); + const empty = document.getElementById('bansEmpty'); + const slice = allBans.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + empty.style.display = allBans.length ? 'none' : 'block'; + tbody.innerHTML = slice.map(b => { + const reason = reasonLabel(b.reason); + const rcls = ['honeypot', 'login-bruteforce', 'share-password-bruteforce', 'manual'].includes(b.reason) + ? `reason-${b.reason}` : 'reason-manual'; + return ` ${esc(b.ip)} - ${esc(reasonLabel(b.reason))} + ${esc(reason)} ${new Date(b.created_at).toLocaleString('ru')} ${new Date(b.banned_until).toLocaleString('ru')} - - `).join(''); - renderIcons(); + + `; + }).join(''); + renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(allBans.length / PAGE_SIZE)), p => { page = p; renderPage(); }); } document.getElementById('bansBody').addEventListener('click', async e => { const ip = e.target.closest('[data-unban]')?.dataset.unban; if (!ip) return; - const btn = e.target.closest('[data-unban]'); - btn.disabled = true; const res = await fetch(`${API}/api/bans/${encodeURIComponent(ip)}`, { method: 'DELETE', headers: hdr() }); if (res.ok) showToast('IP разблокирован: ' + ip); else showToast('Ошибка разблокировки'); - btn.disabled = false; loadBans(); }); -document.getElementById('refreshBtn').addEventListener('click', loadBans); +function openBanModal() { + document.getElementById('bIp').value = ''; + document.getElementById('bReason').value = ''; + document.getElementById('bHours').value = 24; + document.getElementById('banModal').classList.add('open'); +} + +function closeBanModal() { + document.getElementById('banModal').classList.remove('open'); +} + +async function saveBan() { + const ip = document.getElementById('bIp').value.trim(); + const reason = document.getElementById('bReason').value.trim(); + const hours = parseInt(document.getElementById('bHours').value, 10) || 24; + if (!ip) { showToast('Укажите IP-адрес'); return; } + const res = await fetch(`${API}/api/bans`, { + method: 'POST', + headers: hdrJson(), + body: JSON.stringify({ ip, reason: reason || 'manual', hours }), + }); + const data = await res.json().catch(() => ({})); + if (res.ok) { + showToast(`IP ${ip} заблокирован на ${hours} ч`); + closeBanModal(); + loadBans(); + } else { + showToast(data.error || 'Ошибка блокировки'); + } +} + +document.getElementById('addBanBtn').addEventListener('click', openBanModal); +document.getElementById('bCancel').addEventListener('click', closeBanModal); +document.getElementById('bSave').addEventListener('click', saveBan); +document.getElementById('banModal').addEventListener('click', e => { if (e.target.id === 'banModal') closeBanModal(); }); +document.getElementById('bIp').addEventListener('keydown', e => { if (e.key === 'Enter') saveBan(); }); +document.getElementById('bHours').addEventListener('keydown', e => { if (e.key === 'Enter') saveBan(); }); (async () => { if (await requireAdminPage()) { diff --git a/server.js b/server.js index d0075b6..716efe2 100644 --- a/server.js +++ b/server.js @@ -77,14 +77,17 @@ function ipOf(req) { } async function banIP(req, reason, ms) { - const ip = ipOf(req); + await banIpAddr(ipOf(req), reason, ms, req); +} + +async function banIpAddr(ip, reason, ms, actorReq) { const until = new Date(Date.now() + ms); banMemory.set(ip, { reason, banned_until: until.toISOString() }); await pool.query( 'INSERT INTO banned_ips (ip, reason, banned_until) VALUES ($1, $2, $3) ON CONFLICT (ip) DO UPDATE SET reason = $2, banned_until = $3', [ip, reason, until.toISOString()] ); - await logAudit(req, 'ip.ban', { ip, reason }); + await logAudit(actorReq, 'ip.ban', { ip, reason }); console.log(`IP banned: ${ip} (${reason})`); } @@ -703,6 +706,19 @@ app.get('/api/bans', requireAuth, requireAdmin, async (_, res) => { res.json(rows); }); +app.post('/api/bans', requireAuth, requireAdmin, async (req, res) => { + const ip = String(req.body?.ip || '').trim(); + if (!ip || ip.length > 64 || !/^[0-9a-fA-F:.]+$/.test(ip)) { + return res.status(400).json({ error: 'Некорректный IP' }); + } + const reason = (typeof req.body?.reason === 'string' && req.body.reason.trim()) + ? req.body.reason.trim().slice(0, 100) + : 'manual'; + const hours = Math.min(Math.max(parseInt(req.body?.hours, 10) || 24, 1), 24 * 30); + await banIpAddr(ip, reason, hours * 60 * 60 * 1000, req); + res.json({ ok: true, ip, reason, banned_until: banMemory.get(ip).banned_until }); +}); + app.delete('/api/bans/:ip', requireAuth, requireAdmin, async (req, res) => { const ip = String(req.params.ip || '').trim(); if (!ip || ip.length > 64 || !/^[0-9a-fA-F:.]+$/.test(ip)) {