refactor: шаг 7 — /audit /admin /admin/limit вынесены в src/routes/admin.js

This commit is contained in:
2026-05-30 13:42:06 +03:00
parent 87a841fa56
commit 3f18e05d2d
2 changed files with 148 additions and 72 deletions
+139
View File
@@ -0,0 +1,139 @@
/**
* src/routes/admin.js — маршруты администратора.
*
* Маршруты:
* GET /audit — журнал аудита всех изменений
* GET /admin — страница управления лимитами компаний
* POST /admin/limit/:companyId — изменить или сбросить лимит компании
*
* Все маршруты защищены requireAdmin — возвращают 403 для не-admin.
* Дополнительно POST /admin/limit:
* - mutationLimiter: не более 30 запросов/мин с одного IP
* - doubleCsrfProtection: проверка CSRF-токена
*
* Зависимости (через deps):
* q — объект из src/queries.js (все DB-операции)
* requireAdmin — middleware из src/auth.js
* doubleCsrfProtection — middleware проверки CSRF
* mutationLimiter — middleware ограничения частоты POST
* generateCsrfToken — функция генерации CSRF-токена для шаблонов
*
* @param {{ q, requireAdmin, doubleCsrfProtection, mutationLimiter, generateCsrfToken }} deps
* @returns {import('express').Router}
*/
'use strict';
const { Router } = require('express');
function createRouter({ q, requireAdmin, doubleCsrfProtection, mutationLimiter, generateCsrfToken }) {
const router = Router();
// ── GET /audit ────────────────────────────────────────────────────────────────
// Журнал всех изменений белого списка (CREATE / UPDATE / DELETE).
// ТЗ 4.6: «журнал доступен для просмотра только администратору».
//
// ?company=<id> — фильтр по конкретной компании (необязательно).
// Максимум 500 строк (ограничение в queries.js).
router.get('/audit', requireAdmin, async (req, res) => {
try {
// Парсим фильтр: если ?company не задан — показываем все компании
const companyId = req.query.company ? parseInt(req.query.company, 10) : null;
// Грузим список компаний для dropdown-фильтра в шаблоне
const companies = await q.getAllCompanies();
// Строки аудита (JOIN с companies → company_name, client_id)
const rows = await q.getAudit(companyId || null);
res.render('audit', {
user: req.user,
rows,
companies,
selectedCompanyId: companyId,
error: req.query.error || null,
});
} catch (e) {
console.error('GET /audit error:', e);
// При ошибке рендерим страницу с пустым списком, не падаем с 500
res.render('audit', {
user: req.user,
rows: [],
companies: [],
selectedCompanyId: null,
error: 'Ошибка загрузки: ' + e.message,
});
}
});
// ── GET /admin ────────────────────────────────────────────────────────────────
// Страница управления лимитами: таблица компаний с их active_count и лимитом.
// Для каждой компании — форма изменения лимита (POST /admin/limit/:id).
//
// DEFAULT_LIMIT из env — используется как дефолт при отображении и при сбросе.
router.get('/admin', requireAdmin, async (req, res) => {
try {
const companies = await q.getAllCompanies();
// Если DEFAULT_LIMIT не задан в env — используем 15 (как в queries.js)
const defaultLimit = parseInt(process.env.DEFAULT_LIMIT, 10) || 15;
res.render('admin', {
user: req.user,
companies,
defaultLimit,
message: req.query.message || null,
error: req.query.error || null,
// CSRF-токен для форм /admin/limit/:id (по одной на каждую строку таблицы)
csrfToken: generateCsrfToken(req, res),
});
} catch (e) {
console.error('GET /admin error:', e);
res.status(500).send('Ошибка: ' + e.message);
}
});
// ── POST /admin/limit/:companyId ──────────────────────────────────────────────
// Изменить или сбросить индивидуальный лимит компании.
// ТЗ 4.5: «администратор может задать индивидуальный лимит».
//
// Логика:
// - Пустое поле limit → null → сброс на DEFAULT_LIMIT (глобальный дефолт)
// - Число ≥ 0 → custom_limit = это число
// - Отрицательное или нечисловое → ошибка
router.post('/admin/limit/:companyId', requireAdmin, mutationLimiter, doubleCsrfProtection,
async (req, res) => {
// Валидация числового PK компании из URL-параметра
const companyId = parseInt(req.params.companyId, 10);
if (!Number.isFinite(companyId) || companyId <= 0) {
return res.redirect('/admin?error=' + encodeURIComponent('Некорректный ID компании'));
}
try {
const raw = req.body.limit;
let newLimit;
if (raw === '' || raw === null || raw === undefined) {
// Пустое поле — сброс: setLimit(id, null) → queries.js удаляет custom_limit
newLimit = null;
} else {
newLimit = parseInt(raw, 10);
if (!Number.isFinite(newLimit) || newLimit < 0) {
throw new Error('Лимит должен быть целым числом ≥ 0, или пустым для сброса');
}
}
await q.setLimit(companyId, newLimit);
// Формируем человекочитаемое сообщение для flash-уведомления
const label = newLimit === null ? 'сброшен на дефолт' : `установлен: ${newLimit}`;
res.redirect('/admin?message=' + encodeURIComponent(`Лимит ${label}`));
} catch (e) {
res.redirect('/admin?error=' + encodeURIComponent(e.message));
}
}
);
return router;
}
module.exports = { createRouter };