add admin audit log (11), hide stacktraces via NODE_ENV=production (13), validate spam_interval_min>=1 (16)
This commit is contained in:
+10
@@ -71,3 +71,13 @@ CREATE TABLE IF NOT EXISTS project_files (
|
|||||||
created_at TIMESTAMPTZ DEFAULT now(),
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
detached_at TIMESTAMPTZ
|
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);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/whereldo
|
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/whereldo
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
NODE_ENV: production
|
||||||
TZ: Europe/Moscow
|
TZ: Europe/Moscow
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -297,3 +297,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.stat .label a:hover{text-decoration:underline}
|
.stat .label a:hover{text-decoration:underline}
|
||||||
@media(max-width:1100px){.dash-grid{grid-template-columns:1fr}}
|
@media(max-width:1100px){.dash-grid{grid-template-columns:1fr}}
|
||||||
@media(max-width:640px){.photos-grid-dash{grid-template-columns:repeat(2,1fr)}}
|
@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}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const NAV = [
|
|||||||
{ page: 'links', label: 'Ссылки' },
|
{ page: 'links', label: 'Ссылки' },
|
||||||
{ page: 'students', label: 'Ученики' },
|
{ page: 'students', label: 'Ученики' },
|
||||||
{ page: 'groups', label: 'Группы' },
|
{ page: 'groups', label: 'Группы' },
|
||||||
|
{ page: 'audit', label: 'Аудит' },
|
||||||
{ page: 'settings', label: 'Настройки' }
|
{ page: 'settings', label: 'Настройки' }
|
||||||
];
|
];
|
||||||
function buildSidebar(active) {
|
function buildSidebar(active) {
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Аудит — Админ-панель</title>
|
||||||
|
<link rel="stylesheet" href="admin.css">
|
||||||
|
</head>
|
||||||
|
<body data-page="audit">
|
||||||
|
<div class="layout">
|
||||||
|
<div class="sidebar" id="sidebar"></div>
|
||||||
|
<div class="main">
|
||||||
|
<div class="page-head">
|
||||||
|
<h2>Аудит-журнал</h2>
|
||||||
|
<p class="page-sub">Действия администратора</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions-row">
|
||||||
|
<input type="search" id="auditFilter" class="settings-input" placeholder="Фильтр по действию..." style="max-width:300px">
|
||||||
|
<button class="btn-link" onclick="loadAudit()" title="Обновить">Обновить</button>
|
||||||
|
<span class="hint" id="auditCount"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="audit-wrap" style="margin-top:14px">
|
||||||
|
<table class="audit-table" id="auditTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Время</th>
|
||||||
|
<th>Действие</th>
|
||||||
|
<th>Детали</th>
|
||||||
|
<th>IP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="empty" id="auditEmpty" style="display:none">Записей пока нет.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="admin.js"></script>
|
||||||
|
<script>
|
||||||
|
const ACTION_LABELS = {
|
||||||
|
'settings.update': 'Изменены настройки',
|
||||||
|
'group.create': 'Создана группа',
|
||||||
|
'group.update': 'Изменена группа',
|
||||||
|
'group.delete': 'Удалена группа',
|
||||||
|
'group.photo.create': 'Загружена фото группы',
|
||||||
|
'group.photo.update': 'Изменено фото группы',
|
||||||
|
'group.photo.delete': 'Удалено фото группы',
|
||||||
|
'student.create': 'Добавлен ученик',
|
||||||
|
'student.update': 'Изменён ученик',
|
||||||
|
'student.delete': 'Удалён ученик',
|
||||||
|
'student.batch-group': 'Ученики перенесены в группу',
|
||||||
|
'entry.update': 'Изменена запись',
|
||||||
|
'entry.soft-delete': 'Запись в корзину',
|
||||||
|
'entry.restore': 'Запись восстановлена',
|
||||||
|
'entry.permanent-delete': 'Запись удалена безвозвратно',
|
||||||
|
'link.create': 'Создана ссылка',
|
||||||
|
'link.update': 'Изменена ссылка',
|
||||||
|
'link.delete': 'Удалена ссылка',
|
||||||
|
'backup.download': 'Скачан бэкап',
|
||||||
|
'backup.restore': 'Выполнено восстановление',
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtTime(t) {
|
||||||
|
try { return new Date(t).toLocaleString('ru'); } catch { return t || ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function dt(t) {
|
||||||
|
if (!t) return '';
|
||||||
|
try {
|
||||||
|
const o = JSON.parse(t);
|
||||||
|
return esc(JSON.stringify(o).slice(0, 120));
|
||||||
|
} catch { return esc(String(t).slice(0, 120)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAudit() {
|
||||||
|
const el = document.getElementById('auditTable').querySelector('tbody');
|
||||||
|
el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Загрузка…</td></tr>';
|
||||||
|
const res = await fetch(`${API}/api/audit?limit=500`, { headers: hdr() });
|
||||||
|
if (!res.ok) { el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Ошибка загрузки</td></tr>'; return; }
|
||||||
|
const rows = await res.json();
|
||||||
|
document.getElementById('auditCount').textContent = rows.length ? `Записей: ${rows.length}` : '';
|
||||||
|
const q = (document.getElementById('auditFilter').value || '').toLowerCase().trim();
|
||||||
|
const filtered = q ? rows.filter(r => (ACTION_LABELS[r.action] || r.action).toLowerCase().includes(q)) : rows;
|
||||||
|
if (!filtered.length) {
|
||||||
|
document.getElementById('auditEmpty').style.display = 'block';
|
||||||
|
el.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('auditEmpty').style.display = 'none';
|
||||||
|
el.innerHTML = filtered.map(r => `
|
||||||
|
<tr>
|
||||||
|
<td style="white-space:nowrap">${fmtTime(r.created_at)}</td>
|
||||||
|
<td><b>${esc(ACTION_LABELS[r.action] || r.action)}</b></td>
|
||||||
|
<td style="color:var(--muted)">${dt(r.target)}</td>
|
||||||
|
<td style="color:var(--muted);white-space:nowrap">${esc(r.ip || '')}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('auditFilter').addEventListener('input', loadAudit);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (await checkAuth()) {
|
||||||
|
buildSidebar(document.body.dataset.page);
|
||||||
|
loadAudit();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -21,12 +21,12 @@
|
|||||||
<div class="ic">🛡️</div>
|
<div class="ic">🛡️</div>
|
||||||
<h3>Защита от повторов</h3>
|
<h3>Защита от повторов</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">Если ученик уже отправил ответ, следующая отправка от него будет отклонена в течение заданного времени. 0 — отключить.</p>
|
<p class="hint">Если ученик уже отправил ответ, следующая отправка от него будет отклонена в течение заданного времени. Минимальное значение — 1 минута.</p>
|
||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<div class="settings-field">
|
<div class="settings-field">
|
||||||
<label for="spamInterval">Интервал ожидания</label>
|
<label for="spamInterval">Интервал ожидания</label>
|
||||||
<div class="unit-row">
|
<div class="unit-row">
|
||||||
<input type="number" id="spamInterval" class="settings-input" min="0" max="1440" step="1">
|
<input type="number" id="spamInterval" class="settings-input" min="1" max="1440" step="1">
|
||||||
<span class="unit">минут</span>
|
<span class="unit">минут</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,7 +136,7 @@ async function loadSettings() {
|
|||||||
|
|
||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
const v = document.getElementById('spamInterval').value;
|
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');
|
const btn = document.getElementById('saveBtn');
|
||||||
btn.dataset.loadingText = 'Сохранение...';
|
btn.dataset.loadingText = 'Сохранение...';
|
||||||
setBtnLoading(btn, true);
|
setBtnLoading(btn, true);
|
||||||
|
|||||||
@@ -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 ---
|
// --- Settings ---
|
||||||
app.get('/api/settings', requireAdmin, async (_, res) => {
|
app.get('/api/settings', requireAdmin, async (_, res) => {
|
||||||
const { rows } = await pool.query('SELECT key, value FROM settings ORDER BY key');
|
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) => {
|
app.put('/api/settings', requireAdmin, async (req, res) => {
|
||||||
const { settings } = req.body;
|
const { settings } = req.body;
|
||||||
if (!settings || typeof settings !== 'object') return res.status(400).json({ error: 'settings required' });
|
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();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
@@ -192,6 +222,7 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
|
await logAudit(req, 'settings.update', { settings });
|
||||||
const { rows } = await pool.query('SELECT key, value FROM settings ORDER BY key');
|
const { rows } = await pool.query('SELECT key, value FROM settings ORDER BY key');
|
||||||
const out = {};
|
const out = {};
|
||||||
rows.forEach(r => { out[r.key] = r.value; });
|
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 ---
|
// --- Backup / Restore ---
|
||||||
const gunzipAsync = require('util').promisify(require('zlib').gunzip);
|
const gunzipAsync = require('util').promisify(require('zlib').gunzip);
|
||||||
const zlib = require('zlib');
|
const zlib = require('zlib');
|
||||||
@@ -323,7 +363,7 @@ function normalizeRestoreData(data) {
|
|||||||
return { groups, students, entries, project_files, settings };
|
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-'));
|
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
|
||||||
try {
|
try {
|
||||||
const [g, s, e, st, pf] = await Promise.all([
|
const [g, s, e, st, pf] = await Promise.all([
|
||||||
@@ -352,6 +392,7 @@ app.get('/api/backup', requireAdmin, async (_, res) => {
|
|||||||
fs.unlinkSync(outPath);
|
fs.unlinkSync(outPath);
|
||||||
res.setHeader('Content-Type', 'application/gzip');
|
res.setHeader('Content-Type', 'application/gzip');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="whatido-backup-${stamp}.tar.gz"`);
|
res.setHeader('Content-Disposition', `attachment; filename="whatido-backup-${stamp}.tar.gz"`);
|
||||||
|
await logAudit(req, 'backup.download', {});
|
||||||
res.send(buf);
|
res.send(buf);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(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 });
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
cleanupUpload(req);
|
cleanupUpload(req);
|
||||||
await sweepOrphanedUploads().catch(err => console.error('Upload sweep:', err));
|
await sweepOrphanedUploads().catch(err => console.error('Upload sweep:', err));
|
||||||
|
await logAudit(req, 'backup.restore', {});
|
||||||
res.json({ ok: true });
|
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 *`,
|
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]
|
[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]));
|
res.status(201).json(normDates(rows[0]));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -578,11 +621,13 @@ app.put('/api/links/:id', requireAdmin, async (req, res) => {
|
|||||||
values
|
values
|
||||||
);
|
);
|
||||||
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
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]));
|
res.json(normDates(rows[0]));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/links/:id', requireAdmin, async (req, res) => {
|
app.delete('/api/links/:id', requireAdmin, async (req, res) => {
|
||||||
await pool.query('DELETE FROM share_links WHERE id = $1', [req.params.id]);
|
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 });
|
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,
|
day_of_week === undefined || day_of_week === null || day_of_week === '' ? null : day_of_week,
|
||||||
time_start || null, time_end || null, req.params.id]
|
time_start || null, time_end || null, req.params.id]
|
||||||
);
|
);
|
||||||
|
await logAudit(req, 'group.update', { id: req.params.id, ...req.body });
|
||||||
res.json(rows[0]);
|
res.json(rows[0]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.code === '23505') return res.status(409).json({ error: 'Duplicate name' });
|
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 *',
|
'INSERT INTO groups (name) VALUES ($1) RETURNING *',
|
||||||
[name.trim()]
|
[name.trim()]
|
||||||
);
|
);
|
||||||
|
await logAudit(req, 'group.create', { id: rows[0].id, name: name.trim() });
|
||||||
res.status(201).json(rows[0]);
|
res.status(201).json(rows[0]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.code === '23505') return res.status(409).json({ error: 'Duplicate' });
|
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));
|
rows.forEach(r => safeUnlink(r.photo_path));
|
||||||
await pool.query('DELETE FROM groups WHERE id = $1', [req.params.id]);
|
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 });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -823,6 +871,7 @@ app.post('/api/groups/:id/photos', requireAdmin, (req, res, next) => {
|
|||||||
VALUES ($1, $2, $3, $4) RETURNING *`,
|
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||||
[req.params.id, `/uploads/${req.file.filename}`, caption?.trim() || null, taken_at || null]
|
[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]);
|
res.status(201).json(rows[0]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
safeUnlink(`uploads/${req.file.filename}`);
|
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]
|
[caption?.trim() || null, taken_at || null, req.params.photoId, req.params.id]
|
||||||
);
|
);
|
||||||
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
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]);
|
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: 'Не найдено' });
|
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
||||||
safeUnlink(rows[0].photo_path);
|
safeUnlink(rows[0].photo_path);
|
||||||
await pool.query('DELETE FROM group_photos WHERE id = $1', [req.params.photoId]);
|
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 });
|
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 *',
|
'INSERT INTO students (name, group_id) VALUES ($1, $2) RETURNING *',
|
||||||
[name.trim(), gid]
|
[name.trim(), gid]
|
||||||
);
|
);
|
||||||
|
await logAudit(req, 'student.create', { id: rows[0].id, name: name.trim() });
|
||||||
res.status(201).json(rows[0]);
|
res.status(201).json(rows[0]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.code === '23505') return res.status(409).json({ error: 'Duplicate' });
|
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]
|
req.params.id]
|
||||||
);
|
);
|
||||||
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
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]);
|
res.json(rows[0]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.code === '23505') return res.status(409).json({ error: 'Duplicate' });
|
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`,
|
`UPDATE students SET group_id = $1 WHERE id IN (${placeholders}) RETURNING id`,
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
|
await logAudit(req, 'student.batch-group', { group_id, count: rows.length });
|
||||||
res.json({ ok: true, updated: rows.length });
|
res.json({ ok: true, updated: rows.length });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/students/:id', requireAdmin, async (req, res) => {
|
app.delete('/api/students/:id', requireAdmin, async (req, res) => {
|
||||||
await pool.query('DELETE FROM students WHERE id = $1', [req.params.id]);
|
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 });
|
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' });
|
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||||||
|
await logAudit(req, 'entry.update', { id: req.params.id });
|
||||||
res.json(rows[0]);
|
res.json(rows[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/entries/:id', requireAdmin, async (req, res) => {
|
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 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 });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/entries/:id/restore', requireAdmin, async (req, res) => {
|
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 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 });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/entries/:id/permanent', requireAdmin, async (req, res) => {
|
app.delete('/api/entries/:id/permanent', requireAdmin, async (req, res) => {
|
||||||
await removeEntryFiles(req.params.id);
|
await removeEntryFiles(req.params.id);
|
||||||
await pool.query('DELETE FROM entries WHERE id = $1', [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 });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1375,3 +1434,4 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sweepOrphanedUploads().catch(err => console.error('Upload sweep:', err));
|
sweepOrphanedUploads().catch(err => console.error('Upload sweep:', err));
|
||||||
|
ensureAuditTable().catch(err => console.error('Audit table:', err));
|
||||||
|
|||||||
Reference in New Issue
Block a user