refactor: шаг 6 — GET / и POST /add /edit /delete вынесены в src/routes/entries.js

This commit is contained in:
2026-05-30 13:40:50 +03:00
parent 55d26c1ece
commit 87a841fa56
2 changed files with 227 additions and 142 deletions
+218
View File
@@ -0,0 +1,218 @@
/**
* src/routes/entries.js — основные маршруты работы с белым списком IP.
*
* Маршруты:
* GET / — главная страница: список адресов (admin видит все компании)
* POST /add — добавление новой записи
* POST /edit/:id — редактирование существующей записи
* POST /delete/:id — удаление записи
*
* Все маршруты требуют авторизации (auth.middleware уже применён в server.js выше).
* POST-маршруты дополнительно проверяются:
* - mutationLimiter: не более 30 запросов/мин с одного IP
* - doubleCsrfProtection: проверка CSRF-токена из скрытого поля формы
*
* Admin vs user логика:
* Admin (isAdmin === true, clientId === ADMIN_CLIENT_ID):
* - На главной видит все компании, может переключаться через ?company=<id>
* - В POST-запросах принимает company_id из тела формы
* Обычный пользователь:
* - Видит только свою компанию (из JWT-токена)
* - company_id из тела формы игнорируется (нельзя переключиться)
*
* @param {{ q, doubleCsrfProtection, mutationLimiter, generateCsrfToken, backUrl, requireAdmin }} deps
* @returns {import('express').Router}
*/
'use strict';
const { Router } = require('express');
function createRouter({ q, doubleCsrfProtection, mutationLimiter, generateCsrfToken, backUrl }) {
const router = Router();
// ── GET / ─────────────────────────────────────────────────────────────────────
// Главная страница: список активных CIDR для компании.
// Admin: видит все компании + переключатель ?company=<числовой PK>.
// User: только своя компания.
router.get('/', async (req, res) => {
const { clientId, companyName, isAdmin } = req.user;
// Генерируем CSRF-токен здесь, один раз для всей страницы.
// Он попадёт в три формы: /add, /edit (modal), /delete (каждая строка).
const csrfToken = generateCsrfToken(req, res);
try {
if (isAdmin) {
// ── Ветка admin ───────────────────────────────────────────────────────
// Загружаем все компании (с active_count через LEFT JOIN)
const companies = await q.getAllCompanies();
// ?company=X — числовой PK выбранной компании.
// Если параметра нет — берём первую компанию из списка.
const selectedId = req.query.company
? parseInt(req.query.company, 10)
: (companies[0] ? companies[0].id : null);
// Ищем объект компании по id. Если id не найден — берём первую.
const selectedCompany = companies.find(c => c.id === selectedId) || companies[0] || null;
const entries = selectedCompany ? await q.listEntries(selectedCompany.id) : [];
const limit = selectedCompany ? await q.getLimit(selectedCompany) : 0;
return res.render('index', {
entries,
limit,
used: entries.length,
user: req.user,
isAdmin: true,
companies, // для dropdown-переключателя
selectedCompany, // текущая выбранная компания
error: req.query.error || null,
message: req.query.message || null,
wasNormalized: req.query.wasNormalized === '1',
csrfToken,
});
}
// ── Ветка обычного пользователя ────────────────────────────────────────
// getOrCreateCompany: создаёт запись в companies если её ещё нет.
// clientId и companyName берутся из JWT (нельзя подменить через форму).
const company = await q.getOrCreateCompany(clientId, companyName);
const limit = await q.getLimit(company);
const entries = await q.listEntries(company.id);
res.render('index', {
entries,
limit,
used: entries.length,
user: req.user,
isAdmin: false,
companies: null, // пользователь не видит список компаний
selectedCompany: company, // его единственная компания
error: req.query.error || null,
message: req.query.message || null,
wasNormalized: req.query.wasNormalized === '1',
csrfToken,
});
} catch (e) {
console.error('GET / error:', e);
// При ошибке рендерим страницу с сообщением, не падаем с 500
res.render('index', {
entries: [], limit: 15, used: 0,
user: req.user, isAdmin,
companies: null, selectedCompany: null,
error: 'Ошибка загрузки данных: ' + e.message,
message: null,
wasNormalized: false,
csrfToken,
});
}
});
// ── POST /add ─────────────────────────────────────────────────────────────────
// Добавление новой записи в белый список.
// createEntry: валидирует CIDR, проверяет лимит, пишет в audit_log (транзакция).
router.post('/add', mutationLimiter, doubleCsrfProtection, async (req, res) => {
const { value, comment } = req.body;
const { clientId, companyName, email, isAdmin } = req.user;
let company;
try {
// Определяем компанию: admin — из скрытого поля company_id,
// обычный пользователь — только своя (из токена, нельзя подменить).
if (isAdmin && req.body.company_id) {
const id = parseInt(req.body.company_id, 10);
if (!Number.isFinite(id) || id <= 0) throw new Error('Некорректный company_id');
company = await q.getCompanyById(id);
if (!company) throw new Error('Компания не найдена');
} else {
company = await q.getOrCreateCompany(clientId, companyName);
}
// createEntry возвращает { entry, wasNormalized }
// wasNormalized=true если IP был нормализован (напр. 192.168.1.1/24 → 192.168.1.0/24)
const result = await q.createEntry(company.id, value, comment, email);
const msg = result.wasNormalized
? `Адрес нормализован в ${result.entry.value_cidr}`
: 'Добавлено';
res.redirect(backUrl(isAdmin, company.id, {
message: msg,
wasNormalized: result.wasNormalized ? '1' : '0',
}));
} catch (e) {
// Ошибки: лимит исчерпан, дубликат, невалидный CIDR, etc.
const cid = company ? company.id : req.body.company_id;
res.redirect(backUrl(isAdmin, cid, { error: e.message }));
}
});
// ── POST /edit/:id ────────────────────────────────────────────────────────────
// Обновление существующей записи.
// updateEntry: валидирует CIDR, проверяет принадлежность записи компании,
// пишет в audit_log (транзакция).
router.post('/edit/:id', mutationLimiter, doubleCsrfProtection, async (req, res) => {
const { value, comment } = req.body;
const { clientId, companyName, email, isAdmin } = req.user;
// parseInt с radix=10 — защита от октальных и hex-значений
const entryId = parseInt(req.params.id, 10);
let company;
try {
if (isAdmin && req.body.company_id) {
const id = parseInt(req.body.company_id, 10);
if (!Number.isFinite(id) || id <= 0) throw new Error('Некорректный company_id');
company = await q.getCompanyById(id);
if (!company) throw new Error('Компания не найдена');
} else {
company = await q.getOrCreateCompany(clientId, companyName);
}
const result = await q.updateEntry(entryId, company.id, value, comment, email);
const msg = result.wasNormalized
? `Адрес нормализован в ${result.entry.value_cidr}`
: 'Сохранено';
res.redirect(backUrl(isAdmin, company.id, {
message: msg,
wasNormalized: result.wasNormalized ? '1' : '0',
}));
} catch (e) {
const cid = company ? company.id : req.body.company_id;
res.redirect(backUrl(isAdmin, cid, { error: e.message }));
}
});
// ── POST /delete/:id ─────────────────────────────────────────────────────────
// Удаление записи.
// deleteEntry: проверяет принадлежность записи компании, пишет в audit_log.
router.post('/delete/:id', mutationLimiter, doubleCsrfProtection, async (req, res) => {
const { clientId, companyName, email, isAdmin } = req.user;
const entryId = parseInt(req.params.id, 10);
let company;
try {
if (isAdmin && req.body.company_id) {
const id = parseInt(req.body.company_id, 10);
if (!Number.isFinite(id) || id <= 0) throw new Error('Некорректный company_id');
company = await q.getCompanyById(id);
if (!company) throw new Error('Компания не найдена');
} else {
company = await q.getOrCreateCompany(clientId, companyName);
}
await q.deleteEntry(entryId, company.id, email);
res.redirect(backUrl(isAdmin, company.id, { message: 'Запись удалена' }));
} catch (e) {
const cid = company ? company.id : req.body.company_id;
res.redirect(backUrl(isAdmin, cid, { error: e.message }));
}
});
return router;
}
module.exports = { createRouter };