feat: роуты /edit, /audit, /admin, /admin/limit — admin-ветки, публичный /export с агрегацией
This commit is contained in:
@@ -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' },
|
||||
];
|
||||
|
||||
// ── Вспомогательная функция редиректа ─────────────────────────────────────────
|
||||
// Строит 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=<id> — числовой 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); });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user