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:
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user