From e0cec0f943068233c7beb6d795e5a56cbabf0afd Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 8 Sep 2026 12:12:29 +0300 Subject: [PATCH] add admin audit log (11), hide stacktraces via NODE_ENV=production (13), validate spam_interval_min>=1 (16) --- db/init.sql | 10 ++++ docker-compose.yml | 1 + public/admin.css | 9 +++- public/admin.js | 1 + public/audit.html | 112 +++++++++++++++++++++++++++++++++++++++++++ public/settings.html | 6 +-- server.js | 62 +++++++++++++++++++++++- 7 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 public/audit.html diff --git a/db/init.sql b/db/init.sql index 292e494..1d195e5 100644 --- a/db/init.sql +++ b/db/init.sql @@ -71,3 +71,13 @@ CREATE TABLE IF NOT EXISTS project_files ( created_at TIMESTAMPTZ DEFAULT now(), detached_at TIMESTAMPTZ ); + +CREATE TABLE IF NOT EXISTS audit_log ( + id SERIAL PRIMARY KEY, + action VARCHAR(100) NOT NULL, + target JSONB, + ip VARCHAR(45), + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at DESC); diff --git a/docker-compose.yml b/docker-compose.yml index f10c5b7..6dcc158 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,7 @@ services: environment: DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/whereldo ADMIN_PASSWORD: ${ADMIN_PASSWORD} + NODE_ENV: production TZ: Europe/Moscow depends_on: db: diff --git a/public/admin.css b/public/admin.css index 8573f75..08e981e 100644 --- a/public/admin.css +++ b/public/admin.css @@ -296,4 +296,11 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b .stat .label a{color:var(--accent);text-decoration:none;font-weight:600} .stat .label a:hover{text-decoration:underline} @media(max-width:1100px){.dash-grid{grid-template-columns:1fr}} -@media(max-width:640px){.photos-grid-dash{grid-template-columns:repeat(2,1fr)}} \ No newline at end of file +@media(max-width:640px){.photos-grid-dash{grid-template-columns:repeat(2,1fr)}} +.actions-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap} +.audit-table{width:100%;border-collapse:collapse;font-size:.85rem} +.audit-table th{text-align:left;color:var(--muted);font-weight:600;padding:8px 10px;border-bottom:1px solid var(--border);font-size:.75rem;text-transform:uppercase;letter-spacing:.03em} +.audit-table td{padding:10px;border-bottom:1px solid var(--border);vertical-align:top} +.audit-table tr:last-child td{border-bottom:none} +.audit-table tr:hover td{background:rgba(37,99,235,.04)} +.audit-wrap{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);overflow-x:auto} diff --git a/public/admin.js b/public/admin.js index f2b014b..497f136 100644 --- a/public/admin.js +++ b/public/admin.js @@ -28,6 +28,7 @@ const NAV = [ { page: 'links', label: 'Ссылки' }, { page: 'students', label: 'Ученики' }, { page: 'groups', label: 'Группы' }, + { page: 'audit', label: 'Аудит' }, { page: 'settings', label: 'Настройки' } ]; function buildSidebar(active) { diff --git a/public/audit.html b/public/audit.html new file mode 100644 index 0000000..9af415d --- /dev/null +++ b/public/audit.html @@ -0,0 +1,112 @@ + + + + + +Аудит — Админ-панель + + + +
+ +
+
+

Аудит-журнал

+

Действия администратора

+
+ +
+ + + +
+ +
+ + + + + + + + + + +
ВремяДействиеДеталиIP
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/public/settings.html b/public/settings.html index 6331e5b..e490a7c 100644 --- a/public/settings.html +++ b/public/settings.html @@ -21,12 +21,12 @@
🛡️

Защита от повторов

-

Если ученик уже отправил ответ, следующая отправка от него будет отклонена в течение заданного времени. 0 — отключить.

+

Если ученик уже отправил ответ, следующая отправка от него будет отклонена в течение заданного времени. Минимальное значение — 1 минута.

- + минут
@@ -136,7 +136,7 @@ async function loadSettings() { async function saveSettings() { const v = document.getElementById('spamInterval').value; - if (v === '' || Number(v) < 0) { alert('Введите число от 0'); return; } + if (v === '' || Number(v) < 1) { alert('Введите число от 1'); return; } const btn = document.getElementById('saveBtn'); btn.dataset.loadingText = 'Сохранение...'; setBtnLoading(btn, true); diff --git a/server.js b/server.js index 50468fa..7e28768 100644 --- a/server.js +++ b/server.js @@ -163,6 +163,28 @@ async function sweepOrphanedUploads() { } } +async function ensureAuditTable() { + await pool.query(`CREATE TABLE IF NOT EXISTS audit_log ( + id SERIAL PRIMARY KEY, + action VARCHAR(100) NOT NULL, + target JSONB, + ip VARCHAR(45), + created_at TIMESTAMPTZ DEFAULT now() + )`); + await pool.query('CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at DESC)'); +} + +async function logAudit(req, action, target) { + try { + await pool.query( + 'INSERT INTO audit_log (action, target, ip) VALUES ($1, $2, $3)', + [action, target ?? null, req?.ip?.slice(0, 45) || null] + ); + } catch (e) { + console.error('audit log failed:', e); + } +} + // --- Settings --- app.get('/api/settings', requireAdmin, async (_, res) => { const { rows } = await pool.query('SELECT key, value FROM settings ORDER BY key'); @@ -181,6 +203,14 @@ app.get('/api/public-settings', apiLimiter, async (_, res) => { app.put('/api/settings', requireAdmin, async (req, res) => { const { settings } = req.body; if (!settings || typeof settings !== 'object') return res.status(400).json({ error: 'settings required' }); + for (const [key, value] of Object.entries(settings)) { + if (key === 'spam_interval_min') { + const n = parseInt(String(value), 10); + if (!Number.isFinite(n) || n < 1) { + return res.status(400).json({ error: 'spam_interval_min должен быть целым числом ≥ 1' }); + } + } + } const client = await pool.connect(); try { await client.query('BEGIN'); @@ -192,6 +222,7 @@ app.put('/api/settings', requireAdmin, async (req, res) => { ); } await client.query('COMMIT'); + await logAudit(req, 'settings.update', { settings }); const { rows } = await pool.query('SELECT key, value FROM settings ORDER BY key'); const out = {}; rows.forEach(r => { out[r.key] = r.value; }); @@ -204,6 +235,15 @@ app.put('/api/settings', requireAdmin, async (req, res) => { } }); +app.get('/api/audit', requireAdmin, async (req, res) => { + const limit = Math.min(parseInt(req.query.limit, 10) || 100, 1000); + const { rows } = await pool.query( + 'SELECT id, action, target, ip, created_at FROM audit_log ORDER BY id DESC LIMIT $1', + [limit] + ); + res.json(rows); +}); + // --- Backup / Restore --- const gunzipAsync = require('util').promisify(require('zlib').gunzip); const zlib = require('zlib'); @@ -323,7 +363,7 @@ function normalizeRestoreData(data) { return { groups, students, entries, project_files, settings }; } -app.get('/api/backup', requireAdmin, async (_, res) => { +app.get('/api/backup', requireAdmin, async (req, res) => { const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-')); try { const [g, s, e, st, pf] = await Promise.all([ @@ -352,6 +392,7 @@ app.get('/api/backup', requireAdmin, async (_, res) => { fs.unlinkSync(outPath); res.setHeader('Content-Type', 'application/gzip'); res.setHeader('Content-Disposition', `attachment; filename="whatido-backup-${stamp}.tar.gz"`); + await logAudit(req, 'backup.download', {}); res.send(buf); } catch (err) { console.error(err); @@ -492,6 +533,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req fs.rmSync(staging, { recursive: true, force: true }); cleanupUpload(req); await sweepOrphanedUploads().catch(err => console.error('Upload sweep:', err)); + await logAudit(req, 'backup.restore', {}); res.json({ ok: true }); }); @@ -535,6 +577,7 @@ app.post('/api/links', requireAdmin, async (req, res) => { VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`, [token, name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, anonymize_names || false, expiresAt, passwordHash] ); + await logAudit(req, 'link.create', { id: rows[0].id, name: name.trim() }); res.status(201).json(normDates(rows[0])); }); @@ -578,11 +621,13 @@ app.put('/api/links/:id', requireAdmin, async (req, res) => { values ); if (!rows.length) return res.status(404).json({ error: 'Не найдено' }); + await logAudit(req, 'link.update', { id: req.params.id, name: name.trim() }); res.json(normDates(rows[0])); }); app.delete('/api/links/:id', requireAdmin, async (req, res) => { await pool.query('DELETE FROM share_links WHERE id = $1', [req.params.id]); + await logAudit(req, 'link.delete', { id: req.params.id }); res.json({ ok: true }); }); @@ -754,6 +799,7 @@ app.put('/api/groups/:id', requireAdmin, async (req, res) => { day_of_week === undefined || day_of_week === null || day_of_week === '' ? null : day_of_week, time_start || null, time_end || null, req.params.id] ); + await logAudit(req, 'group.update', { id: req.params.id, ...req.body }); res.json(rows[0]); } catch (e) { if (e.code === '23505') return res.status(409).json({ error: 'Duplicate name' }); @@ -769,6 +815,7 @@ app.post('/api/groups', requireAdmin, async (req, res) => { 'INSERT INTO groups (name) VALUES ($1) RETURNING *', [name.trim()] ); + await logAudit(req, 'group.create', { id: rows[0].id, name: name.trim() }); res.status(201).json(rows[0]); } catch (e) { if (e.code === '23505') return res.status(409).json({ error: 'Duplicate' }); @@ -783,6 +830,7 @@ app.delete('/api/groups/:id', requireAdmin, async (req, res) => { ); rows.forEach(r => safeUnlink(r.photo_path)); await pool.query('DELETE FROM groups WHERE id = $1', [req.params.id]); + await logAudit(req, 'group.delete', { id: req.params.id }); res.json({ ok: true }); }); @@ -823,6 +871,7 @@ app.post('/api/groups/:id/photos', requireAdmin, (req, res, next) => { VALUES ($1, $2, $3, $4) RETURNING *`, [req.params.id, `/uploads/${req.file.filename}`, caption?.trim() || null, taken_at || null] ); + await logAudit(req, 'group.photo.create', { group_id: req.params.id, photo_path: rows[0].photo_path }); res.status(201).json(rows[0]); } catch (e) { safeUnlink(`uploads/${req.file.filename}`); @@ -841,6 +890,7 @@ app.put('/api/groups/:id/photos/:photoId', requireAdmin, async (req, res) => { [caption?.trim() || null, taken_at || null, req.params.photoId, req.params.id] ); if (!rows.length) return res.status(404).json({ error: 'Не найдено' }); + await logAudit(req, 'group.photo.update', { group_id: req.params.id, photo_id: req.params.photoId }); res.json(rows[0]); }); @@ -852,6 +902,7 @@ app.delete('/api/groups/:id/photos/:photoId', requireAdmin, async (req, res) => if (!rows.length) return res.status(404).json({ error: 'Не найдено' }); safeUnlink(rows[0].photo_path); await pool.query('DELETE FROM group_photos WHERE id = $1', [req.params.photoId]); + await logAudit(req, 'group.photo.delete', { group_id: req.params.id, photo_id: req.params.photoId }); res.json({ ok: true }); }); @@ -880,6 +931,7 @@ app.post('/api/students', requireAdmin, async (req, res) => { 'INSERT INTO students (name, group_id) VALUES ($1, $2) RETURNING *', [name.trim(), gid] ); + await logAudit(req, 'student.create', { id: rows[0].id, name: name.trim() }); res.status(201).json(rows[0]); } catch (e) { if (e.code === '23505') return res.status(409).json({ error: 'Duplicate' }); @@ -901,6 +953,7 @@ app.put('/api/students/:id', requireAdmin, async (req, res) => { req.params.id] ); if (!rows.length) return res.status(404).json({ error: 'Not found' }); + await logAudit(req, 'student.update', { id: req.params.id, name: name.trim() }); res.json(rows[0]); } catch (e) { if (e.code === '23505') return res.status(409).json({ error: 'Duplicate' }); @@ -921,11 +974,13 @@ app.post('/api/students/batch-group', requireAdmin, async (req, res) => { `UPDATE students SET group_id = $1 WHERE id IN (${placeholders}) RETURNING id`, params ); + await logAudit(req, 'student.batch-group', { group_id, count: rows.length }); res.json({ ok: true, updated: rows.length }); }); app.delete('/api/students/:id', requireAdmin, async (req, res) => { await pool.query('DELETE FROM students WHERE id = $1', [req.params.id]); + await logAudit(req, 'student.delete', { id: req.params.id }); res.json({ ok: true }); }); @@ -1257,22 +1312,26 @@ app.put('/api/entries/:id', requireAdmin, async (req, res) => { ] ); if (!rows.length) return res.status(404).json({ error: 'Not found' }); + await logAudit(req, 'entry.update', { id: req.params.id }); res.json(rows[0]); }); app.delete('/api/entries/:id', requireAdmin, async (req, res) => { await pool.query('UPDATE entries SET deleted_at = now() WHERE id = $1', [req.params.id]); + await logAudit(req, 'entry.soft-delete', { id: req.params.id }); res.json({ ok: true }); }); app.put('/api/entries/:id/restore', requireAdmin, async (req, res) => { await pool.query('UPDATE entries SET deleted_at = NULL WHERE id = $1', [req.params.id]); + await logAudit(req, 'entry.restore', { id: req.params.id }); res.json({ ok: true }); }); app.delete('/api/entries/:id/permanent', requireAdmin, async (req, res) => { await removeEntryFiles(req.params.id); await pool.query('DELETE FROM entries WHERE id = $1', [req.params.id]); + await logAudit(req, 'entry.permanent-delete', { id: req.params.id }); res.json({ ok: true }); }); @@ -1375,3 +1434,4 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) { } sweepOrphanedUploads().catch(err => console.error('Upload sweep:', err)); +ensureAuditTable().catch(err => console.error('Audit table:', err));