From 87a841fa5613706f728188ce45ad6a3471fb79fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sat, 30 May 2026 13:40:50 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20=D1=88=D0=B0=D0=B3=206=20=E2=80=94?= =?UTF-8?q?=20GET=20/=20=D0=B8=20POST=20/add=20/edit=20/delete=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=BD=D0=B5=D1=81=D0=B5=D0=BD=D1=8B=20=D0=B2=20src/route?= =?UTF-8?q?s/entries.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server.js | 151 ++--------------------------- src/routes/entries.js | 218 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+), 142 deletions(-) create mode 100644 src/routes/entries.js diff --git a/server.js b/server.js index e5ba71f..a751f8f 100644 --- a/server.js +++ b/server.js @@ -66,148 +66,15 @@ async function start() { // ── Все маршруты ниже требуют валидного JWT (проверяет auth.middleware) ─────── app.use(auth.middleware); - // ── Главная страница: список белых адресов ──────────────────────────────────── - app.get('/', async (req, res) => { - const { clientId, companyName, isAdmin } = req.user; - try { - if (isAdmin) { - // Администратор видит все компании с фильтром по выбранной. - // ?company= — числовой PK из таблицы companies. - const companies = await q.getAllCompanies(); - 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 })); - } - }); + // GET /, POST /add, POST /edit/:id, POST /delete/:id. + // src/routes/entries.js содержит логику и комментарии. + app.use(require('./src/routes/entries').createRouter({ + q, + doubleCsrfProtection, + mutationLimiter, + generateCsrfToken, + backUrl, + })); // ── Журнал аудита (только admin) ────────────────────────────────────────────── // ТЗ 4.6: «журнал доступен для просмотра только администратору» diff --git a/src/routes/entries.js b/src/routes/entries.js new file mode 100644 index 0000000..d62ac12 --- /dev/null +++ b/src/routes/entries.js @@ -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= + * - В 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 };