v0.5.11: зачистка мёртвого кода + новые интеграционные тесты

Удалено:
- src/routes/*.js (5 файлов) — старая монолитная архитектура
- views/dev-login.ejs — старый dev-логин
- tests/debug-session.js — тесты старой архитектуры

Добавлено:
- safeReturn в src/auth.js
- Новые интеграционные тесты tests/integration.js (67 тестов, UI+API)

Обновлено:
- tests/run-tests.js — убраны старые require, добавлены новые модули
- tests/tz-compliance.js — /exp → /api/v1/entries/export
- server.js — убран require('./src/routes/exp')
This commit is contained in:
2026-06-02 18:04:33 +03:00
parent 4694b7e8a6
commit 15b458eb35
12 changed files with 357 additions and 1767 deletions
-139
View File
@@ -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=<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 };
-232
View File
@@ -1,232 +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',
};
// Добавляем allClientIds для поддержки мульти-компании
userData.allClientIds = user.clientId.split(',').map(s => s.trim()).filter(Boolean);
userData.activeClientId = userData.allClientIds[0] || user.clientId;
// 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('Пользователь не найден'));
}
const allClientIds = mockUser.clientId.split(',').map(s => s.trim()).filter(Boolean);
user = {
email: mockUser.email,
clientId: mockUser.clientId,
allClientIds,
activeClientId: allClientIds[0] || 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 обязателен'));
}
const allClientIds = clientId.split(',').map(s => s.trim()).filter(Boolean);
user = {
email: (req.body.email || '').trim() || clientId + '@dev.local',
clientId,
allClientIds,
activeClientId: allClientIds[0] || 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 };
-230
View File
@@ -1,230 +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=<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, allClientIds, activeClientId } = req.user;
// ── Переключатель компании для обычного пользователя ─────────────────
// ?switchTo=WZ02001 — меняет активную компанию в сессии (только если есть в allClientIds)
const switchTo = (req.query.switchTo || '').trim();
let effectiveClientId = clientId;
let effectiveCompanyName = companyName;
if (!isAdmin && switchTo && allClientIds && allClientIds.includes(switchTo)) {
effectiveClientId = switchTo;
effectiveCompanyName = switchTo; // companyName обновится из БД через getOrCreateCompany
req.session.user.clientId = switchTo;
req.session.user.activeClientId = switchTo;
}
const csrfToken = generateCsrfToken(req, res);
try {
if (isAdmin) {
// ── Ветка admin ───────────────────────────────────────────────────────
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, // dropdown-переключатель всех компаний в БД
selectedCompany,
allClientIds: null, // admin использует companies (не clientIds)
activeClientId: null,
error: req.query.error || null,
message: req.query.message || null,
wasNormalized: req.query.wasNormalized === '1',
csrfToken,
});
}
// ── Ветка обычного пользователя ────────────────────────────────────────
const company = await q.getOrCreateCompany(effectiveClientId, effectiveCompanyName);
const limit = await q.getLimit(company);
const entries = await q.listEntries(company.id);
// Если у пользователя несколько компаний — готовим список для переключателя
const hasMultiple = allClientIds && allClientIds.length > 1;
res.render('index', {
entries,
limit,
used: entries.length,
user: req.user,
isAdmin: false,
companies: null,
selectedCompany: company,
allClientIds: hasMultiple ? allClientIds : null,
activeClientId: effectiveClientId,
error: req.query.error || null,
message: req.query.message || null,
wasNormalized: req.query.wasNormalized === '1',
csrfToken,
});
} catch (e) {
console.error('GET / error:', e);
res.render('index', {
entries: [], limit: 15, used: 0,
user: req.user, isAdmin,
companies: null, selectedCompany: null,
allClientIds: null, activeClientId: 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 };
-25
View File
@@ -1,25 +0,0 @@
// ВРЕМЕННЫЙ публичный endpoint GET /exp — без авторизации, все компании.
// TODO: удалить этот файл + строку в server.js когда /export станет публичным (ТЗ п. 4.7)
'use strict';
const { Router } = require('express');
const { exportLimiter } = require('../middleware/rateLimit');
const { aggregateCIDRs } = require('../validators');
const q = require('../queries');
const router = Router();
router.get('/exp', exportLimiter, async (req, res) => {
try {
const cidrs = await q.getExportCIDRs(null); // null = все компании
const aggregated = aggregateCIDRs(cidrs);
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Content-Disposition', 'inline; filename="whitelist.txt"');
res.send(aggregated.join('\n') + (aggregated.length ? '\n' : ''));
} catch (e) {
console.error('/exp error:', e);
res.status(500).send('Export error');
}
});
module.exports = router;
-81
View File
@@ -1,81 +0,0 @@
/**
* src/routes/export.js — маршрут GET /export (требует авторизации).
*
* Назначение: отдаёт агрегированный список активных CIDR в виде текстового файла.
*
* Изоляция по ролям:
* - Обычный пользователь → только CIDR своей компании.
* - Администратор → все компании (или фильтр по ?company=<id>).
*
* Особенности:
* - Требует авторизации: регистрируется ПОСЛЕ 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=<id>.
// exportLimiter ограничивает частоту запросов (20/мин).
router.get('/export', exportLimiter, async (req, res) => {
try {
let companyId = null;
if (!req.user.isAdmin) {
// Обычный пользователь: только его компания.
// ?client_id= для мульти-компании
const requestedId = (req.query.client_id || '').trim();
const effectiveClientId = (requestedId && req.user.allClientIds && req.user.allClientIds.includes(requestedId))
? requestedId
: req.user.clientId;
const company = await q.getOrCreateCompany(effectiveClientId, effectiveClientId);
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.
// ?view=1 → inline (просмотр в браузере), иначе → attachment (скачивание)
// ?filename=X → своё имя файла, по умолчанию white-list.txt
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
const fname = req.query.filename || 'white-list.txt';
const disp = req.query.view === '1' ? 'inline' : 'attachment';
res.setHeader('Content-Disposition', `${disp}; filename="${fname}"`);
// Один 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 };