refactor: шаг 6 — GET / и POST /add /edit /delete вынесены в src/routes/entries.js
This commit is contained in:
@@ -66,148 +66,15 @@ async function start() {
|
|||||||
// ── Все маршруты ниже требуют валидного JWT (проверяет auth.middleware) ───────
|
// ── Все маршруты ниже требуют валидного JWT (проверяет auth.middleware) ───────
|
||||||
app.use(auth.middleware);
|
app.use(auth.middleware);
|
||||||
|
|
||||||
// ── Главная страница: список белых адресов ────────────────────────────────────
|
// GET /, POST /add, POST /edit/:id, POST /delete/:id.
|
||||||
app.get('/', async (req, res) => {
|
// src/routes/entries.js содержит логику и комментарии.
|
||||||
const { clientId, companyName, isAdmin } = req.user;
|
app.use(require('./src/routes/entries').createRouter({
|
||||||
try {
|
q,
|
||||||
if (isAdmin) {
|
doubleCsrfProtection,
|
||||||
// Администратор видит все компании с фильтром по выбранной.
|
mutationLimiter,
|
||||||
// ?company=<id> — числовой PK из таблицы companies.
|
generateCsrfToken,
|
||||||
const companies = await q.getAllCompanies();
|
backUrl,
|
||||||
const selectedId = req.query.company
|
|
||||||
? parseInt(req.query.company, 10)
|
|
||||||
: (companies[0] ? companies[0].id : null);
|
|
||||||
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, selectedCompany,
|
|
||||||
error: req.query.error || null,
|
|
||||||
message: req.query.message || null,
|
|
||||||
wasNormalized: req.query.wasNormalized === '1',
|
|
||||||
// CSRF-токен для форм (add, edit, delete) на странице
|
|
||||||
csrfToken: generateCsrfToken(req, res),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обычный пользователь: только его компания
|
|
||||||
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',
|
|
||||||
// CSRF-токен для форм (add, edit, delete)
|
|
||||||
csrfToken: generateCsrfToken(req, res),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error('GET / error:', e);
|
|
||||||
res.render('index', {
|
|
||||||
entries: [], limit: 15, used: 0,
|
|
||||||
user: req.user, isAdmin,
|
|
||||||
companies: null, selectedCompany: null,
|
|
||||||
error: 'Ошибка загрузки данных: ' + e.message,
|
|
||||||
message: null, wasNormalized: false,
|
|
||||||
csrfToken: generateCsrfToken(req, res),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Добавление записи ─────────────────────────────────────────────────────────
|
|
||||||
// mutationLimiter: не более 30 POST-запросов/мин с одного IP.
|
|
||||||
// doubleCsrfProtection: проверяет CSRF-токен из скрытого поля формы.
|
|
||||||
app.post('/add', mutationLimiter, doubleCsrfProtection, async (req, res) => {
|
|
||||||
const { value, comment } = req.body;
|
|
||||||
const { clientId, companyName, email, isAdmin } = req.user;
|
|
||||||
|
|
||||||
let company;
|
|
||||||
try {
|
|
||||||
if (isAdmin && req.body.company_id) {
|
|
||||||
// Администратор добавляет в выбранную компанию (скрытое поле 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.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) {
|
|
||||||
// При ошибке возвращаемся к тому же виду что был
|
|
||||||
const cid = company ? company.id : req.body.company_id;
|
|
||||||
res.redirect(backUrl(isAdmin, cid, { error: e.message }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Редактирование записи ─────────────────────────────────────────────────────
|
|
||||||
app.post('/edit/:id', mutationLimiter, doubleCsrfProtection, async (req, res) => {
|
|
||||||
const { value, comment } = req.body;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Удаление записи (soft delete) ────────────────────────────────────────────
|
|
||||||
app.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 }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Журнал аудита (только admin) ──────────────────────────────────────────────
|
// ── Журнал аудита (только admin) ──────────────────────────────────────────────
|
||||||
// ТЗ 4.6: «журнал доступен для просмотра только администратору»
|
// ТЗ 4.6: «журнал доступен для просмотра только администратору»
|
||||||
|
|||||||
@@ -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 };
|
||||||
Reference in New Issue
Block a user