diff --git a/server.js b/server.js index a751f8f..491f5ef 100644 --- a/server.js +++ b/server.js @@ -76,78 +76,15 @@ async function start() { backUrl, })); - // ── Журнал аудита (только admin) ────────────────────────────────────────────── - // ТЗ 4.6: «журнал доступен для просмотра только администратору» - app.get('/audit', requireAdmin, async (req, res) => { - try { - // Если передан ?company=X — показываем только эту компанию - const companyId = req.query.company ? parseInt(req.query.company, 10) : null; - const companies = await q.getAllCompanies(); - 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); - res.render('audit', { - user: req.user, rows: [], companies: [], - selectedCompanyId: null, error: 'Ошибка загрузки: ' + e.message, - }); - } - }); - - // ── Страница управления лимитами (только admin) ─────────────────────────────── - app.get('/admin', requireAdmin, async (req, res) => { - try { - const companies = await q.getAllCompanies(); - 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); - } - }); - - // ── Изменение лимита компании (только admin) ────────────────────────────────── - // ТЗ 4.5: «администратор может задать индивидуальный лимит» - app.post('/admin/limit/:companyId', requireAdmin, mutationLimiter, doubleCsrfProtection, async (req, res) => { - 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) { - // Пустое поле = сброс на глобальный дефолт - newLimit = null; - } else { - newLimit = parseInt(raw, 10); - if (!Number.isFinite(newLimit) || newLimit < 0) { - throw new Error('Лимит должен быть целым числом ≥ 0, или пустым для сброса'); - } - } - - await q.setLimit(companyId, newLimit); - const label = newLimit === null ? 'сброшен на дефолт' : `установлен: ${newLimit}`; - res.redirect('/admin?message=' + encodeURIComponent(`Лимит ${label}`)); - } catch (e) { - res.redirect('/admin?error=' + encodeURIComponent(e.message)); - } - }); + // GET /audit, GET /admin, POST /admin/limit/:companyId. + // src/routes/admin.js содержит логику и комментарии. + app.use(require('./src/routes/admin').createRouter({ + q, + requireAdmin, + doubleCsrfProtection, + mutationLimiter, + generateCsrfToken, + })); // ── CSRF error handler ──────────────────────────────────────────────────────── // doubleCsrfProtection вызывает next(err) с кодом 403 при невалидном токене. diff --git a/src/routes/admin.js b/src/routes/admin.js new file mode 100644 index 0000000..49ae2ce --- /dev/null +++ b/src/routes/admin.js @@ -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= — фильтр по конкретной компании (необязательно). + // Максимум 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 };