From bc83d0e0bf75fce7bf51c33dc054377da57ff0ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sat, 30 May 2026 18:22:26 +0300 Subject: [PATCH] =?UTF-8?q?chore:=20delete=20src/routes/=20(UI-only,=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D1=83?= =?UTF-8?q?=D0=B5=D1=82=D1=81=D1=8F=20=D0=B2=20API)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/admin.js | 139 -------------------------- src/routes/auth.js | 223 ------------------------------------------ src/routes/entries.js | 218 ----------------------------------------- src/routes/export.js | 74 -------------- 4 files changed, 654 deletions(-) delete mode 100644 src/routes/admin.js delete mode 100644 src/routes/auth.js delete mode 100644 src/routes/entries.js delete mode 100644 src/routes/export.js diff --git a/src/routes/admin.js b/src/routes/admin.js deleted file mode 100644 index 49ae2ce..0000000 --- a/src/routes/admin.js +++ /dev/null @@ -1,139 +0,0 @@ -/** - * 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 }; diff --git a/src/routes/auth.js b/src/routes/auth.js deleted file mode 100644 index c500751..0000000 --- a/src/routes/auth.js +++ /dev/null @@ -1,223 +0,0 @@ -/** - * src/routes/auth.js — маршруты аутентификации. - * - * OIDC-режим (KC_CLIENT_ID + KC_CLIENT_SECRET заданы в окружении): - * GET /login → генерирует state, сохраняет в сессии, редиректит на Keycloak - * GET /callback → проверяет state, обменивает code→токен, кладёт user в сессию - * GET /logout → уничтожает сессию + редирект на KK logout - * - * Mock-режим (нет OIDC credentials): - * GET /login → рендерит views/login.ejs — список MOCK_USERS - * POST /login → CSRF → сохраняет выбранного user в сессию → / - * GET /logout → уничтожает сессию → /login - * - * Dev-backdoor (DEV_MODE=true или DEV_SECRET задан): - * GET /dev-login → рендерит views/dev-login.ejs (требует ?key если DEV_SECRET задан) - * POST /dev-login → CSRF → кладёт произвольного user в сессию → / - * Работает в ЛЮБОМ режиме (OIDC или mock) — обходит нормальный поток. - * - * @param {{ auth, doubleCsrfProtection, generateCsrfToken, MOCK_USERS }} deps - * @returns {import('express').Router} - */ - -'use strict'; - -const crypto = require('crypto'); -const { Router } = require('express'); - -/** - * Валидирует redirect-цель: разрешает только локальные пути (начинается с '/', - * не начинается с '//'). - * Защита от open redirect: //evil.com и https://evil.com → '/'. - * @param {string|undefined} target - * @returns {string} - */ -function safeReturn(target) { - if (typeof target === 'string' && target.startsWith('/') && !target.startsWith('//')) { - return target; - } - return '/'; -} - -function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, MOCK_USERS, authLimiter }) { - const router = Router(); - - // ── GET /login ───────────────────────────────────────────────────────────── - router.get('/login', (req, res) => { - if (auth.isOidc) { - // OIDC: генерируем state (защита от CSRF на callback), редиректим на KK - const state = crypto.randomBytes(16).toString('hex'); - req.session.oidcState = state; - req.session.returnTo = req.query.returnTo || '/'; - return res.redirect(auth.buildAuthUrl(state)); - } - - // Mock-режим: форма выбора пользователя - const csrfToken = generateCsrfToken(req, res); - res.render('login', { - users: MOCK_USERS, - error: req.query.error || null, - csrfToken, - devLoginUrl: auth.devLoginEnabled ? '/dev-login' : null, - }); - }); - - // ── POST /login (только mock-режим) ──────────────────────────────────────── - router.post('/login', authLimiter, doubleCsrfProtection, (req, res) => { - if (auth.isOidc) return res.redirect('/login'); - - const user = MOCK_USERS.find(u => u.id === req.body.user); - if (!user) { - return res.redirect('/login?error=' + encodeURIComponent('Пользователь не найден')); - } - - const userData = { - email: user.email, - clientId: user.clientId, - companyId: user.companyId, - companyName: user.companyName, - isAdmin: user.role === 'admin', - }; - - // session.regenerate() меняет идентификатор сессии — защита от session fixation (A07). - // Данные пишем уже в новую сессию. - req.session.regenerate((err) => { - if (err) { console.error('[login] session.regenerate error:', err); return res.status(500).send('Session error'); } - req.session.user = userData; - req.session.save((saveErr) => { - if (saveErr) { console.error('[login] session.save error:', saveErr); return res.status(500).send('Session error'); } - res.redirect(safeReturn(req.query.returnTo)); - }); - }); - }); - - // ── GET /callback (только OIDC-режим) ────────────────────────────────────── - router.get('/callback', authLimiter, async (req, res) => { - if (!auth.isOidc) return res.redirect('/login'); - - const { code, state, error } = req.query; - - if (error) { - console.error('[auth] Keycloak error:', error, req.query.error_description || ''); - return res.redirect('/login?error=' + encodeURIComponent('Keycloak: ' + error)); - } - - // Проверяем state (защита от CSRF) - if (!state || state !== req.session.oidcState) { - return res.redirect('/login?error=' + encodeURIComponent('Недействительный state')); - } - delete req.session.oidcState; - - try { - const { user, idToken, refreshToken } = await auth.exchangeCode(code); - // Сохраняем returnTo до regenerate, так как regenerate сбросит сессию. - const returnTo = safeReturn(req.session.returnTo); - // session.regenerate() — защита от session fixation (A07). - req.session.regenerate((err) => { - if (err) { console.error('[callback] session.regenerate error:', err); return res.status(500).send('Session error'); } - req.session.user = user; - req.session.idToken = idToken; - req.session.refreshToken = refreshToken; - req.session.save((saveErr) => { - if (saveErr) { console.error('[callback] session.save error:', saveErr); return res.status(500).send('Session error'); } - res.redirect(returnTo); - }); - }); - } catch (e) { - console.error('[auth] exchangeCode error:', e.message); - res.redirect('/login?error=' + encodeURIComponent('Ошибка авторизации: ' + e.message)); - } - }); - - // ── GET /dev-login ───────────────────────────────────────────────────────── - // - // Тестовый вход в обход OIDC/mock. Доступен если: - // • DEV_MODE=true → без пароля - // • DEV_SECRET=xyz → с ?key=xyz или session.devKey - router.get('/dev-login', (req, res) => { - if (!auth.devLoginEnabled) return res.status(404).send('Not Found'); - - if (auth.DEV_SECRET) { - const keyOk = req.query.key === auth.DEV_SECRET || req.session.devKey === auth.DEV_SECRET; - if (!keyOk) return res.status(403).send('403 Forbidden: неверный dev-ключ'); - req.session.devKey = auth.DEV_SECRET; // кешируем в сессии: ?key= нужен только раз - } - - const csrfToken = generateCsrfToken(req, res); - res.render('dev-login', { - users: MOCK_USERS, - error: req.query.error || null, - csrfToken, - }); - }); - - // ── POST /dev-login ──────────────────────────────────────────────────────── - router.post('/dev-login', authLimiter, doubleCsrfProtection, (req, res) => { - if (!auth.devLoginEnabled) return res.status(404).send('Not Found'); - - if (auth.DEV_SECRET && req.session.devKey !== auth.DEV_SECRET) { - return res.status(403).send('403 Forbidden'); - } - - let user; - - if (req.body.preset) { - // Пресет из MOCK_USERS - const mockUser = MOCK_USERS.find(u => u.id === req.body.preset); - if (!mockUser) { - return res.redirect('/dev-login?error=' + encodeURIComponent('Пользователь не найден')); - } - user = { - email: mockUser.email, - clientId: mockUser.clientId, - companyId: mockUser.companyId, - companyName: mockUser.companyName, - isAdmin: mockUser.role === 'admin', - }; - } else { - // Произвольные поля - const clientId = (req.body.clientId || '').trim(); - if (!clientId) { - return res.redirect('/dev-login?error=' + encodeURIComponent('ClientID обязателен')); - } - user = { - email: (req.body.email || '').trim() || clientId + '@dev.local', - clientId, - companyId: (req.body.companyId || '').trim() || null, - companyName: (req.body.companyName || '').trim() || clientId, - isAdmin: req.body.isAdmin === 'on', - }; - } - - req.session.regenerate((err) => { - if (err) { console.error('[dev-login] session.regenerate error:', err); return res.status(500).send('Session error'); } - req.session.user = user; - // Восстанавливаем devKey в новой сессии — иначе следующий запрос потребует повторного ?key= - if (auth.DEV_SECRET) req.session.devKey = auth.DEV_SECRET; - req.session.save((saveErr) => { - if (saveErr) { console.error('[dev-login] session.save error:', saveErr); return res.status(500).send('Session error'); } - res.redirect('/'); - }); - }); - }); - - // ── GET /logout ──────────────────────────────────────────────────────────── - router.get('/logout', (req, res) => { - const idToken = req.session.idToken || null; - const isOidc = auth.isOidc; - - req.session.destroy(() => { - res.clearCookie('connect.sid'); - res.clearCookie('jwt'); // удаляем устаревший cookie если остался от старой версии - - if (isOidc && idToken) { - return res.redirect(auth.buildLogoutUrl(idToken)); - } - res.redirect('/login'); - }); - }); - - return router; -} - -module.exports = { createRouter, safeReturn }; diff --git a/src/routes/entries.js b/src/routes/entries.js deleted file mode 100644 index d62ac12..0000000 --- a/src/routes/entries.js +++ /dev/null @@ -1,218 +0,0 @@ -/** - * 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 }; diff --git a/src/routes/export.js b/src/routes/export.js deleted file mode 100644 index 5292ee8..0000000 --- a/src/routes/export.js +++ /dev/null @@ -1,74 +0,0 @@ -/** - * src/routes/export.js — маршрут GET /export (требует авторизации). - * - * Назначение: отдаёт агрегированный список активных CIDR в виде текстового файла. - * - * Изоляция по ролям: - * - Обычный пользователь → только CIDR своей компании. - * - Администратор → все компании (или фильтр по ?company=). - * - * Особенности: - * - Требует авторизации: регистрируется ПОСЛЕ auth.middleware в server.js. - * - Ограничен exportLimiter: не более 20 запросов/мин с одного IP. - * - aggregateCIDRs объединяет перекрывающиеся диапазоны → минимальный набор. - * - * Паттерн «фабрика»: - * Вместо прямых require(зависимостей) получаем их через параметр deps. - * Это позволяет легко подменять зависимости в тестах (mock q, mock aggregateCIDRs). - * - * @param {{ q: object, exportLimiter: Function, aggregateCIDRs: Function }} deps - * @returns {import('express').Router} - */ - -'use strict'; - -const { Router } = require('express'); - -function createRouter({ q, exportLimiter, aggregateCIDRs }) { - const router = Router(); - - // GET /export — агрегированный whitelist CIDR в текстовом виде. - // Требует авторизации (регистрируется ПОСЛЕ auth.middleware в server.js). - // Изоляция по ролям: - // - Обычный пользователь → только CIDR своей компании. - // - Администратор → все компании, или фильтр по ?company=. - // exportLimiter ограничивает частоту запросов (20/мин). - router.get('/export', exportLimiter, async (req, res) => { - try { - let companyId = null; - - if (!req.user.isAdmin) { - // Обычный пользователь: только его компания. - // getOrCreateCompany — атомарная UPSERT, безопасна для параллельных вызовов. - const company = await q.getOrCreateCompany(req.user.clientId, req.user.companyName); - companyId = company.id; - } else if (req.query.company) { - // Администратор с необязательным фильтром по компании. - const parsed = parseInt(req.query.company, 10); - if (Number.isFinite(parsed) && parsed > 0) companyId = parsed; - } - // Администратор без ?company → companyId остаётся null → все компании. - - const cidrs = await q.getExportCIDRs(companyId); - - // Суммаризация: объединяем пересекающиеся и смежные диапазоны. - // Например: 192.168.0.0/25 + 192.168.0.128/25 → 192.168.0.0/24 - const aggregated = aggregateCIDRs(cidrs); - - // Content-Type: text/plain — чтобы браузер не пытался разбирать как HTML. - // Content-Disposition: inline — открывается в браузере, а не скачивается. - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - res.setHeader('Content-Disposition', 'inline; filename="whitelist.txt"'); - - // Один CIDR на строку. Если список пустой — возвращаем пустую строку (не ошибку). - res.send(aggregated.join('\n') + (aggregated.length ? '\n' : '')); - } catch (e) { - console.error('Export error:', e); - res.status(500).send('Export error'); - } - }); - - return router; -} - -module.exports = { createRouter };