diff --git a/db/init.sql b/db/init.sql
index f593e9e..eecb0ad 100644
--- a/db/init.sql
+++ b/db/init.sql
@@ -6,6 +6,13 @@ CREATE TABLE IF NOT EXISTS branches (
created_at TIMESTAMPTZ DEFAULT now()
);
+CREATE TABLE IF NOT EXISTS banned_ips (
+ ip VARCHAR(64) PRIMARY KEY,
+ reason VARCHAR(100) NOT NULL,
+ banned_until TIMESTAMPTZ NOT NULL,
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+
CREATE TABLE IF NOT EXISTS groups (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
diff --git a/db/migration.sql b/db/migration.sql
index 941545a..4c2d413 100644
--- a/db/migration.sql
+++ b/db/migration.sql
@@ -4,6 +4,13 @@ CREATE TABLE IF NOT EXISTS students (
created_at TIMESTAMPTZ DEFAULT now()
);
+CREATE TABLE IF NOT EXISTS banned_ips (
+ ip VARCHAR(64) PRIMARY KEY,
+ reason VARCHAR(100) NOT NULL,
+ banned_until TIMESTAMPTZ NOT NULL,
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+
ALTER TABLE students ADD COLUMN IF NOT EXISTS group_id INT REFERENCES groups(id);
CREATE TABLE IF NOT EXISTS settings (
diff --git a/public/js/settings.js b/public/js/settings.js
index 4c32eb8..27b5e2c 100644
--- a/public/js/settings.js
+++ b/public/js/settings.js
@@ -238,6 +238,28 @@ document.getElementById('restoreFile').addEventListener('change', async (e) => {
e.target.value = '';
});
+async function loadBans() {
+ const el = document.getElementById('bansList');
+ const res = await fetch(`${API}/api/bans`, { headers: hdr() });
+ if (res.status === 403) { document.getElementById('bansCard').style.display = 'none'; return; }
+ if (!res.ok) { el.innerHTML = 'Не удалось загрузить'; return; }
+ const bans = await res.json();
+ if (!bans.length) { el.innerHTML = 'Активных блокировок нет'; return; }
+ el.innerHTML = bans.map(b => `
+
+ ${esc(b.ip)} — ${esc(b.reason)} до ${new Date(b.banned_until).toLocaleString('ru')}
+
+ `).join('');
+}
+
+document.getElementById('bansList').addEventListener('click', async e => {
+ const ip = e.target.closest('[data-unban]')?.dataset.unban;
+ if (!ip) return;
+ const res = await fetch(`${API}/api/bans/${encodeURIComponent(ip)}`, { method: 'DELETE', headers: hdr() });
+ if (res.ok) { showToast('IP разблокирован'); loadBans(); }
+ else showToast('Ошибка разблокировки');
+});
+
document.getElementById('backupBtn').addEventListener('click', downloadBackup);
document.getElementById('restoreBtn').addEventListener('click', () => document.getElementById('restoreFile').click());
document.getElementById('saveBtn').addEventListener('click', saveSettings);
@@ -247,5 +269,6 @@ document.getElementById('saveBtn').addEventListener('click', saveSettings);
buildSidebar(document.body.dataset.page);
bindDirty();
loadSettings();
+ loadBans();
}
})();
\ No newline at end of file
diff --git a/public/settings.html b/public/settings.html
index eb07e65..dec7571 100644
--- a/public/settings.html
+++ b/public/settings.html
@@ -138,6 +138,15 @@
+
+
+
+
+
Блокировки IP
+
+
IP автоматически блокируется на 24 часа: за заполнение скрытого антиспам-поля, за 10 неудачных попыток входа или 10 неверных паролей на защищённой ссылке.