Часовой пояс и формат времени (24h/12h) перенесены из браузера в настройки приложения: валидация IANA-зоны, сид в db/init.sql + db/migration.sql, отдача в GET /api/public-settings, раздел #sec-datetime в настройках с живым превью. Границы суток в SQL переведены на tzDayStart/tzDayEnd + bindTz ($TZ$) — раньше $n::date по TIMESTAMPTZ считал дни в UTC (у контейнера TimeZone=UTC) и молча сдвигал выборку на день; хардкод Europe/Moscow вычищен, now() заменён на tzWall(). Новый public/js/datetime.js: три семейства хелперов (instant / чистая DATE-строка без Date() / чистая TIME-строка), подключён на всех страницах включая публичные share/report/index, initDateTime() встроен в checkAuth(). Прямые toLocale*/getFullYear/toISOString().slice(0,10) в public/ выпилены. Попутно: bindTz добавляет параметр только при наличии $TZ$ в SQL (иначе запрос виснет вечно без global error handler) — регрессия закрыта в api.smoketest.js; починены off-by-one месяца в report.js и группировка по дате в share.js.
110 lines
4.2 KiB
JavaScript
110 lines
4.2 KiB
JavaScript
const PAGE_SIZE = 15;
|
|
const REASONS = {
|
|
'honeypot': 'Антиспам-поле заполнено',
|
|
'login-bruteforce': 'Подбор пароля (10 неудачных входов)',
|
|
'share-password-bruteforce': 'Подбор пароля ссылки',
|
|
'manual': 'Вручную',
|
|
};
|
|
|
|
let allBans = [];
|
|
let page = 1;
|
|
|
|
function reasonLabel(r) {
|
|
return REASONS[r] || r || '—';
|
|
}
|
|
|
|
async function loadBans() {
|
|
const res = await fetch(`${API}/api/bans`, { headers: hdr() });
|
|
const tbody = document.getElementById('bansBody');
|
|
if (!res.ok) {
|
|
tbody.innerHTML = `<tr><td colspan="5" style="color:var(--muted)">Ошибка загрузки (${res.status})</td></tr>`;
|
|
allBans = [];
|
|
renderPager(document.getElementById('pager'), 1, 1, () => {});
|
|
return;
|
|
}
|
|
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 `
|
|
<tr>
|
|
<td><b>${esc(b.ip)}</b></td>
|
|
<td><span class="reason-badge ${rcls}">${esc(reason)}</span></td>
|
|
<td>${fmtFull(b.created_at)}</td>
|
|
<td>${fmtFull(b.banned_until)}</td>
|
|
<td><button class="row-btn" data-unban="${esc(b.ip)}" title="Разблокировать">✕</button></td>
|
|
</tr>`;
|
|
}).join('');
|
|
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(allBans.length / PAGE_SIZE)), p => { page = p; renderPage(); });
|
|
}
|
|
|
|
async function doUnban(ip) {
|
|
const ban = allBans.find(b => b.ip === ip) || {};
|
|
if (!confirm(`Удалить IP ${ip} из блокировки (${reasonLabel(ban.reason)})?`)) return;
|
|
const res = await fetch(`${API}/api/bans/${encodeURIComponent(ip)}`, { method: 'DELETE', headers: hdr() });
|
|
if (res.ok) showToast('IP удалён из блокировки: ' + ip);
|
|
else showToast('Ошибка разблокировки');
|
|
loadBans();
|
|
}
|
|
|
|
document.getElementById('bansBody').addEventListener('click', e => {
|
|
const ip = e.target.closest('[data-unban]')?.dataset.unban;
|
|
if (ip) doUnban(ip);
|
|
});
|
|
|
|
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()) {
|
|
buildSidebar(document.body.dataset.page);
|
|
loadBans();
|
|
}
|
|
})();
|