Bans page: users-style table with badges, client-side pagination, manual ban modal (POST /api/bans with ip/reason/hours), unban row buttons
This commit is contained in:
+45
-5
@@ -12,12 +12,16 @@
|
|||||||
<div class="main">
|
<div class="main">
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h2>Блокировки IP</h2>
|
<h2>Блокировки IP</h2>
|
||||||
<p class="page-sub">IP автоматически блокируется на 24 часа: за заполнение скрытого антиспам-поля, за 10 неудачных попыток входа или 10 неверных паролей на защищённой ссылке</p>
|
<p class="page-sub">Автобан на 24 часа за антиспам и брутфорс. Здесь можно разбанить или добавить вручную</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="trash-head">
|
|
||||||
<span class="trash-muted" id="bansInfo"></span>
|
<div class="actions-row" style="margin-bottom:16px">
|
||||||
<button class="btn-primary ghost" id="refreshBtn" type="button"><i data-lucide="refresh-cw"></i> Обновить</button>
|
<button class="btn-primary" id="addBanBtn" type="button">
|
||||||
|
<span style="font-size:1.2rem">+</span> Забанить IP вручную
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="audit-wrap">
|
||||||
<table class="audit-table" id="bansTable">
|
<table class="audit-table" id="bansTable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -28,8 +32,35 @@
|
|||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="bansBody"><tr><td colspan="5">Загрузка…</td></tr></tbody>
|
<tbody id="bansBody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div class="empty" id="bansEmpty" style="display:none">Активных блокировок нет</div>
|
||||||
|
</div>
|
||||||
|
<div class="pager" id="pager"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="banModal">
|
||||||
|
<div class="edit-modal" style="max-width:440px">
|
||||||
|
<h3>Заблокировать IP</h3>
|
||||||
|
<div class="settings-stack" style="gap:12px;max-width:none">
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>IP-адрес <span style="color:#ef4444">*</span></label>
|
||||||
|
<input type="text" id="bIp" class="settings-input" placeholder="например: 185.220.101.34" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Причина</label>
|
||||||
|
<input type="text" id="bReason" class="settings-input" placeholder="например: Спам с этого адреса" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Срок (часов, 1–720)</label>
|
||||||
|
<input type="number" id="bHours" class="settings-input" min="1" max="720" value="24">
|
||||||
|
</div>
|
||||||
|
<div class="card-foot" style="justify-content:flex-end;border-top:none;padding-top:0">
|
||||||
|
<button type="button" class="btn-primary ghost" id="bCancel">Отмена</button>
|
||||||
|
<button type="button" class="btn-primary" id="bSave">Заблокировать</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -37,5 +68,14 @@
|
|||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="js/bans.js"></script>
|
<script src="js/bans.js"></script>
|
||||||
|
<style>
|
||||||
|
.reason-badge{font-size:.7rem;font-weight:600;padding:2px 10px;border-radius:999px;white-space:nowrap}
|
||||||
|
.reason-honeypot{color:#ea580c;background:rgba(234,88,12,.12)}
|
||||||
|
.reason-login-bruteforce,.reason-share-password-bruteforce{color:#7c3aed;background:rgba(124,58,237,.12)}
|
||||||
|
.reason-manual{color:#ef4444;background:rgba(239,68,68,.12)}
|
||||||
|
.row-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.05rem;padding:4px 8px;border-radius:6px}
|
||||||
|
.row-btn:hover{background:var(--bg);color:var(--accent)}
|
||||||
|
</style>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
+66
-19
@@ -1,3 +1,4 @@
|
|||||||
|
const PAGE_SIZE = 15;
|
||||||
const REASONS = {
|
const REASONS = {
|
||||||
'honeypot': 'Антиспам-поле заполнено',
|
'honeypot': 'Антиспам-поле заполнено',
|
||||||
'login-bruteforce': 'Подбор пароля (10 неудачных входов)',
|
'login-bruteforce': 'Подбор пароля (10 неудачных входов)',
|
||||||
@@ -5,49 +6,95 @@ const REASONS = {
|
|||||||
'manual': 'Вручную',
|
'manual': 'Вручную',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let allBans = [];
|
||||||
|
let page = 1;
|
||||||
|
|
||||||
function reasonLabel(r) {
|
function reasonLabel(r) {
|
||||||
return REASONS[r] || r || '—';
|
return REASONS[r] || r || '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadBans() {
|
async function loadBans() {
|
||||||
const tbody = document.getElementById('bansBody');
|
|
||||||
const info = document.getElementById('bansInfo');
|
|
||||||
const res = await fetch(`${API}/api/bans`, { headers: hdr() });
|
const res = await fetch(`${API}/api/bans`, { headers: hdr() });
|
||||||
|
const tbody = document.getElementById('bansBody');
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
tbody.innerHTML = `<tr><td colspan="5">Ошибка загрузки (${res.status})</td></tr>`;
|
tbody.innerHTML = `<tr><td colspan="5" style="color:var(--muted)">Ошибка загрузки (${res.status})</td></tr>`;
|
||||||
info.textContent = '';
|
allBans = [];
|
||||||
|
renderPager(document.getElementById('pager'), 1, 1, () => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const bans = await res.json();
|
allBans = await res.json();
|
||||||
info.textContent = `Активных блокировок: ${bans.length}`;
|
const maxPage = Math.max(1, Math.ceil(allBans.length / PAGE_SIZE));
|
||||||
if (!bans.length) {
|
if (page > maxPage) page = maxPage;
|
||||||
tbody.innerHTML = '<tr><td colspan="5" style="color:var(--muted)">Активных блокировок нет</td></tr>';
|
renderPage();
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
tbody.innerHTML = bans.map(b => `
|
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 `
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>${esc(b.ip)}</b></td>
|
<td><b>${esc(b.ip)}</b></td>
|
||||||
<td>${esc(reasonLabel(b.reason))}</td>
|
<td><span class="reason-badge ${rcls}">${esc(reason)}</span></td>
|
||||||
<td>${new Date(b.created_at).toLocaleString('ru')}</td>
|
<td>${new Date(b.created_at).toLocaleString('ru')}</td>
|
||||||
<td>${new Date(b.banned_until).toLocaleString('ru')}</td>
|
<td>${new Date(b.banned_until).toLocaleString('ru')}</td>
|
||||||
<td><button class="danger-btn" data-unban="${esc(b.ip)}" type="button">Разблокировать</button></td>
|
<td><button class="row-btn" data-unban="${esc(b.ip)}" title="Разблокировать">✕</button></td>
|
||||||
</tr>`).join('');
|
</tr>`;
|
||||||
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 => {
|
document.getElementById('bansBody').addEventListener('click', async e => {
|
||||||
const ip = e.target.closest('[data-unban]')?.dataset.unban;
|
const ip = e.target.closest('[data-unban]')?.dataset.unban;
|
||||||
if (!ip) return;
|
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() });
|
const res = await fetch(`${API}/api/bans/${encodeURIComponent(ip)}`, { method: 'DELETE', headers: hdr() });
|
||||||
if (res.ok) showToast('IP разблокирован: ' + ip);
|
if (res.ok) showToast('IP разблокирован: ' + ip);
|
||||||
else showToast('Ошибка разблокировки');
|
else showToast('Ошибка разблокировки');
|
||||||
btn.disabled = false;
|
|
||||||
loadBans();
|
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 () => {
|
(async () => {
|
||||||
if (await requireAdminPage()) {
|
if (await requireAdminPage()) {
|
||||||
|
|||||||
@@ -77,14 +77,17 @@ function ipOf(req) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function banIP(req, reason, ms) {
|
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);
|
const until = new Date(Date.now() + ms);
|
||||||
banMemory.set(ip, { reason, banned_until: until.toISOString() });
|
banMemory.set(ip, { reason, banned_until: until.toISOString() });
|
||||||
await pool.query(
|
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',
|
'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()]
|
[ip, reason, until.toISOString()]
|
||||||
);
|
);
|
||||||
await logAudit(req, 'ip.ban', { ip, reason });
|
await logAudit(actorReq, 'ip.ban', { ip, reason });
|
||||||
console.log(`IP banned: ${ip} (${reason})`);
|
console.log(`IP banned: ${ip} (${reason})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -703,6 +706,19 @@ app.get('/api/bans', requireAuth, requireAdmin, async (_, res) => {
|
|||||||
res.json(rows);
|
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) => {
|
app.delete('/api/bans/:ip', requireAuth, requireAdmin, async (req, res) => {
|
||||||
const ip = String(req.params.ip || '').trim();
|
const ip = String(req.params.ip || '').trim();
|
||||||
if (!ip || ip.length > 64 || !/^[0-9a-fA-F:.]+$/.test(ip)) {
|
if (!ip || ip.length > 64 || !/^[0-9a-fA-F:.]+$/.test(ip)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user