From 8c1bafeb97d35760ab6f05ff4dbf7202187826a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sat, 30 May 2026 09:11:41 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=80=D0=BE=D1=83=D1=82=D1=8B=20/edit,?= =?UTF-8?q?=20/audit,=20/admin,=20/admin/limit=20=E2=80=94=20admin-=D0=B2?= =?UTF-8?q?=D0=B5=D1=82=D0=BA=D0=B8,=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D1=87=D0=BD=D1=8B=D0=B9=20/export=20=D1=81=20=D0=B0=D0=B3?= =?UTF-8?q?=D1=80=D0=B5=D0=B3=D0=B0=D1=86=D0=B8=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server.js | 286 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 256 insertions(+), 30 deletions(-) diff --git a/server.js b/server.js index b5d1428..f9389cc 100644 --- a/server.js +++ b/server.js @@ -3,7 +3,8 @@ const cookieParser = require('cookie-parser'); const path = require('path'); require('dotenv').config(); const { checkConnection } = require('./src/db'); -const { initAuth } = require('./src/auth'); +const { initAuth, requireAdmin } = require('./src/auth'); +const { aggregateCIDRs } = require('./src/validators'); const q = require('./src/queries'); const app = express(); @@ -15,78 +16,303 @@ app.use(express.static(path.join(__dirname, 'public'))); app.use(express.urlencoded({ extended: true, limit: '32kb' })); app.use(cookieParser()); +// ── Мок-пользователи для dev/staging ────────────────────────────────────────── +// В продакшене удаляются — вход через Keycloak, clientId берётся из токена. const MOCK_USERS = [ - { id: 'admin', label: 'Администратор (WZ01112)', role: 'admin', clientId: 'WZ01112', companyId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', companyName: 'Nubes Admin', email: 'admin@nubes.ru' }, - { id: 'test', label: 'Тест (WZ01325)', role: 'user', clientId: 'WZ01325', companyId: '3e64aac6-dcfc-4082-88dc-da19c86555a5', companyName: 'Тест', email: 'tazet@narod.ru' }, - { id: 'client2', label: 'Компания 2 (WZ02001)', role: 'user', clientId: 'WZ02001', companyId: '11111111-2222-3333-4444-555555555555', companyName: 'Вторая Компания', email: 'user2@example.com' }, + { id: 'admin', label: 'Администратор (WZ01112)', role: 'admin', clientId: 'WZ01112', companyId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', companyName: 'Nubes Admin', email: 'admin@nubes.ru' }, + { id: 'test', label: 'Тест (WZ01325)', role: 'user', clientId: 'WZ01325', companyId: '3e64aac6-dcfc-4082-88dc-da19c86555a5', companyName: 'Тест', email: 'tazet@narod.ru' }, + { id: 'client2', label: 'Компания 2 (WZ02001)', role: 'user', clientId: 'WZ02001', companyId: '11111111-2222-3333-4444-555555555555', companyName: 'Вторая Компания', email: 'user2@example.com' }, ]; +// ── Вспомогательная функция редиректа ───────────────────────────────────────── +// Строит URL для возврата на главную страницу с учётом контекста: +// admin возвращается к выбранной компании (/?company=X), +// обычный пользователь — просто на /. +function backUrl(isAdmin, companyId, extra = {}) { + const qs = new URLSearchParams(); + if (isAdmin && companyId) qs.set('company', companyId); + for (const [k, v] of Object.entries(extra)) qs.set(k, String(v)); + const str = qs.toString(); + return str ? '/?' + str : '/'; +} + async function start() { const auth = await initAuth(); + // ── Публичные маршруты (до auth.middleware) ────────────────────────────────── + + // k8s liveness probe app.get('/healthz', (req, res) => res.send('OK')); + // JWKS endpoint для валидации токенов внешними сервисами (только в mock-режиме) if (auth.jwksHandler) app.get('/.well-known/jwks.json', auth.jwksHandler); - app.get('/login', (req, res) => res.render('login', { users: MOCK_USERS, error: req.query.error || null })); + // /export — агрегированный список всех активных CIDR по всем компаниям. + // ТЗ: «авторизация не требуется, доступ ограничивается на сетевом уровне». + // Маршрут регистрируется ДО auth.middleware чтобы не требовать jwt-cookie. + app.get('/export', async (req, res) => { + try { + // Получаем все активные CIDR без фильтра по компании + const cidrs = await q.getExportCIDRs(); + // Суммаризация: объединяем пересекающиеся диапазоны → минимальный набор + 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('Export error:', e); + res.status(500).send('Export error'); + } + }); + // Страница входа — отдаём форму выбора мок-пользователя + app.get('/login', (req, res) => + res.render('login', { users: MOCK_USERS, error: req.query.error || null }) + ); + + // Обработка логина: создаём JWT и кладём в httpOnly cookie app.post('/login', async (req, res) => { const user = MOCK_USERS.find(u => u.id === req.body.user); if (!user) return res.redirect('/login?error=' + encodeURIComponent('Пользователь не найден')); try { - const token = await auth.issue({ clientId: user.clientId, companyId: user.companyId, companyName: user.companyName, email: user.email }); - res.cookie('jwt', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 24 * 60 * 60 * 1000 }); + const token = await auth.issue({ + clientId: user.clientId, + companyId: user.companyId, + companyName: user.companyName, + email: user.email, + }); + res.cookie('jwt', token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 24 * 60 * 60 * 1000, // 24 часа + }); res.redirect(req.query.returnTo || '/'); - } catch (e) { res.redirect('/login?error=' + encodeURIComponent('Ошибка выпуска токена')); } + } catch (e) { + res.redirect('/login?error=' + encodeURIComponent('Ошибка выпуска токена')); + } }); + // Выход: очищаем cookie и возвращаем на /login app.get('/logout', (req, res) => { res.clearCookie('jwt'); res.redirect('/login'); }); + // ── Все маршруты ниже требуют валидного JWT (проверяет auth.middleware) ─────── app.use(auth.middleware); + // ── Главная страница: список белых адресов ──────────────────────────────────── app.get('/', async (req, res) => { - const { clientId, companyName } = req.user; + 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', + }); + } + + // Обычный пользователь: только его компания 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, error: req.query.error || null, message: req.query.message || null, wasNormalized: req.query.wasNormalized === '1' }); - } catch (e) { res.render('index', { entries: [], limit: 15, used: 0, user: req.user, error: 'Ошибка загрузки', message: null, wasNormalized: false }); } + 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', + }); + } 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, + }); + } }); + // ── Добавление записи ───────────────────────────────────────────────────────── app.post('/add', async (req, res) => { const { value, comment } = req.body; - const { clientId, companyName, email } = req.user; + const { clientId, companyName, email, isAdmin } = req.user; + + let company; try { - const company = await q.getOrCreateCompany(clientId, companyName); + 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 message = result.wasNormalized ? `Адрес нормализован в ${result.entry.value_cidr}` : 'Добавлено'; - const wasNormalized = result.wasNormalized ? '1' : '0'; - res.redirect('/?message=' + encodeURIComponent(message) + '&wasNormalized=' + wasNormalized); - } catch (e) { res.redirect('/?error=' + encodeURIComponent(e.message)); } + 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', 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', async (req, res) => { - const { clientId, companyName, email } = req.user; + const { clientId, companyName, email, isAdmin } = req.user; + const entryId = parseInt(req.params.id, 10); + + let company; try { - const company = await q.getOrCreateCompany(clientId, companyName); - await q.deleteEntry(req.params.id, company.id, email); - res.redirect('/'); - } catch (e) { res.redirect('/?error=' + encodeURIComponent(e.message)); } + 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 })); + } }); - app.get('/export', async (req, res) => { + // ── Журнал аудита (только admin) ────────────────────────────────────────────── + // ТЗ 4.6: «журнал доступен для просмотра только администратору» + app.get('/audit', requireAdmin, async (req, res) => { try { - const { clientId, companyName } = req.user; - const company = await q.getOrCreateCompany(clientId, companyName); - const cidrs = await q.getExportCIDRs(company.id); - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - res.send(cidrs.join('\n') + '\n'); - } catch (e) { res.status(500).send('Export error'); } + // Если передан ?company=X — показываем только эту компанию + const companyId = req.query.company ? parseInt(req.query.company, 10) : null; + const companies = await q.getAllCompanies(); + 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); + res.render('audit', { + user: req.user, rows: [], companies: [], + selectedCompanyId: null, error: 'Ошибка загрузки: ' + e.message, + }); + } }); - checkConnection().then(() => console.log('DB connected')).catch(e => console.error('DB not ready:', e.message)); + // ── Страница управления лимитами (только admin) ─────────────────────────────── + app.get('/admin', requireAdmin, async (req, res) => { + try { + const companies = await q.getAllCompanies(); + 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, + }); + } catch (e) { + console.error('GET /admin error:', e); + res.status(500).send('Ошибка: ' + e.message); + } + }); + + // ── Изменение лимита компании (только admin) ────────────────────────────────── + // ТЗ 4.5: «администратор может задать индивидуальный лимит» + app.post('/admin/limit/:companyId', requireAdmin, async (req, res) => { + 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) { + // Пустое поле = сброс на глобальный дефолт + newLimit = null; + } else { + newLimit = parseInt(raw, 10); + if (!Number.isFinite(newLimit) || newLimit < 0) { + throw new Error('Лимит должен быть целым числом ≥ 0, или пустым для сброса'); + } + } + + await q.setLimit(companyId, newLimit); + const label = newLimit === null ? 'сброшен на дефолт' : `установлен: ${newLimit}`; + res.redirect('/admin?message=' + encodeURIComponent(`Лимит ${label}`)); + } catch (e) { + res.redirect('/admin?error=' + encodeURIComponent(e.message)); + } + }); + + // ── Старт сервера ───────────────────────────────────────────────────────────── + checkConnection() + .then(() => console.log('DB connected')) + .catch(e => console.error('DB not ready:', e.message)); + app.listen(PORT, () => console.log('Server on port ' + PORT)); } start().catch(e => { console.error('Startup error:', e); process.exit(1); }); +