refactor: шаг 7 — /audit /admin /admin/limit вынесены в src/routes/admin.js
This commit is contained in:
@@ -76,78 +76,15 @@ async function start() {
|
|||||||
backUrl,
|
backUrl,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ── Журнал аудита (только admin) ──────────────────────────────────────────────
|
// GET /audit, GET /admin, POST /admin/limit/:companyId.
|
||||||
// ТЗ 4.6: «журнал доступен для просмотра только администратору»
|
// src/routes/admin.js содержит логику и комментарии.
|
||||||
app.get('/audit', requireAdmin, async (req, res) => {
|
app.use(require('./src/routes/admin').createRouter({
|
||||||
try {
|
q,
|
||||||
// Если передан ?company=X — показываем только эту компанию
|
requireAdmin,
|
||||||
const companyId = req.query.company ? parseInt(req.query.company, 10) : null;
|
doubleCsrfProtection,
|
||||||
const companies = await q.getAllCompanies();
|
mutationLimiter,
|
||||||
const rows = await q.getAudit(companyId || null);
|
generateCsrfToken,
|
||||||
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));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── CSRF error handler ────────────────────────────────────────────────────────
|
// ── CSRF error handler ────────────────────────────────────────────────────────
|
||||||
// doubleCsrfProtection вызывает next(err) с кодом 403 при невалидном токене.
|
// doubleCsrfProtection вызывает next(err) с кодом 403 при невалидном токене.
|
||||||
|
|||||||
@@ -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 };
|
||||||
Reference in New Issue
Block a user