/** * 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= — фильтр по конкретной компании (необязательно). // Максимум 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 };