add admin audit log (11), hide stacktraces via NODE_ENV=production (13), validate spam_interval_min>=1 (16)
This commit is contained in:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user