diff --git a/server.js b/server.js
index 380b1ab..b27982e 100644
--- a/server.js
+++ b/server.js
@@ -23,14 +23,13 @@ const helmet = require('helmet');
require('dotenv').config();
const { checkConnection } = require('./src/db');
-const { initAuth, requireAdmin } = require('./src/auth');
-const { aggregateCIDRs } = require('./src/validators');
+const { initAuth } = require('./src/auth');
const q = require('./src/queries');
-const { MOCK_USERS, backUrl, APP_VERSION } = require('./src/config');
-const { mutationLimiter, exportLimiter, authLimiter } = require('./src/middleware/rateLimit');
-const { initCsrf } = require('./src/middleware/csrf');
+const { MOCK_USERS, APP_VERSION } = require('./src/config');
+const { authLimiter } = require('./src/middleware/rateLimit');
const { createSessionMiddleware } = require('./src/middleware/session');
const { CSP_DIRECTIVES } = require('./src/middleware/csp');
+const { createUiRouter } = require('./ui/index');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -57,34 +56,18 @@ async function start() {
const auth = await initAuth();
// ── Сессии ────────────────────────────────────────────────────────────────
- // PG-стор: сессии переживают рестарт, работают на нескольких подах.
- // Таблица `session` создаётся автоматически при первом старте.
- // src/middleware/session.js содержит логику и комментарии.
const { pool } = require('./src/db');
app.use(createSessionMiddleware(pool));
- // ── CSRF защита ────────────────────────────────────────────────────────────
- // initCsrf() читает CSRF_SECRET и NODE_ENV из process.env.
- // Возвращает: doubleCsrfProtection (middleware на POST) и
- // generateCsrfToken (вызов в GET-роутах для передачи токена в шаблон).
- const { doubleCsrfProtection, generateCsrfToken } = initCsrf();
-
- // ── Публичные маршруты (до auth.middleware) ──────────────────────────────────
-
// ── Fail-fast в production ────────────────────────────────────────────────
// Если секреты не заданы — приложение в проде немедленно падает.
// Дефолтные значения — только для локальной разработки.
if (process.env.NODE_ENV === 'production') {
const DEFAULT_SESSION = 'dev-session-secret-change-me';
- const DEFAULT_CSRF = 'dev-csrf-secret-change-in-prod';
if (!process.env.SESSION_SECRET || process.env.SESSION_SECRET === DEFAULT_SESSION) {
console.error('FATAL: SESSION_SECRET не задан или равен дефолту в production!');
process.exit(1);
}
- if (!process.env.CSRF_SECRET || process.env.CSRF_SECRET === DEFAULT_CSRF) {
- console.error('FATAL: CSRF_SECRET не задан или равен дефолту в production!');
- process.exit(1);
- }
}
// k8s liveness probe — простая проверка что сервер жив
@@ -93,65 +76,18 @@ async function start() {
// JWKS endpoint для валидации токенов внешними сервисами (только в mock-режиме)
if (auth.jwksHandler) app.get('/.well-known/jwks.json', auth.jwksHandler);
- // GET /login, POST /login, GET /logout.
- // src/routes/auth.js содержит логику и комментарии.
- app.use(require('./src/routes/auth').createRouter({
- auth,
- doubleCsrfProtection,
- generateCsrfToken,
- MOCK_USERS,
- authLimiter,
- }));
-
// ── REST API v1 (Bearer JWT, без сессий/CSRF) ─────────────────────────────
const { createApiRouter } = require('./src/api/index');
app.use('/api/v1', createApiRouter({ auth, q }));
- // ── Все маршруты ниже требуют авторизации (auth.middleware) ────────────────
- app.use(auth.middleware);
+ // ── UI-слой: SSR через EJS, данные из /api/v1/* ───────────────────────────
+ // Монтируется ПОСЛЕ /api/v1/ — не перехватывает API-запросы.
+ // Хранит Bearer token в сессии, рендерит те же views/*.ejs.
+ app.use('/', createUiRouter({ auth, MOCK_USERS, authLimiter }));
- // GET /export: CIDR текущего пользователя (или всех компаний для admin).
- // Перемещён за auth.middleware — изоляция данных по компании.
- // src/routes/export.js содержит логику и комментарии.
- app.use(require('./src/routes/export').createRouter({
- q,
- exportLimiter,
- aggregateCIDRs,
- }));
-
- // GET /, POST /add, POST /edit/:id, POST /delete/:id.
- // src/routes/entries.js содержит логику и комментарии.
- app.use(require('./src/routes/entries').createRouter({
- q,
- doubleCsrfProtection,
- mutationLimiter,
- generateCsrfToken,
- backUrl,
- }));
-
- // GET /audit, GET /admin, POST /admin/limit/:companyId.
- // src/routes/admin.js содержит логику и комментарии.
- app.use(require('./src/routes/admin').createRouter({
- q,
- requireAdmin,
- doubleCsrfProtection,
- mutationLimiter,
- generateCsrfToken,
- }));
-
- // ── CSRF error handler ────────────────────────────────────────────────────────
- // doubleCsrfProtection вызывает next(err) с кодом 403 при невалидном токене.
- // Ловим ДО общего обработчика — возвращаем понятное сообщение пользователю.
+ // ── Общий обработчик ошибок ───────────────────────────────────────────────
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
- if (err && (err.status === 403 || err.code === 'EBADCSRFTOKEN')) {
- return res.status(403).send(
- '
' +
- 'Недействительный CSRF-токен. ' +
- 'Вернитесь назад и повторите действие.' +
- '
'
- );
- }
console.error('Unhandled error:', err);
res.status(500).send('Внутренняя ошибка сервера');
});
diff --git a/src/api/routes/admin.js b/src/api/routes/admin.js
index c1c8848..8ea30ac 100644
--- a/src/api/routes/admin.js
+++ b/src/api/routes/admin.js
@@ -38,6 +38,21 @@ function createAdminRouter({ q }) {
}
});
+ // GET /api/v1/audit — журнал действий (admin only)
+ // ?company= — фильтр по компании (опционально)
+ router.get('/audit', apiRequireAdmin, async (req, res) => {
+ try {
+ const companyId = req.query.company ? parseInt(req.query.company, 10) : null;
+ const [rows, companies] = await Promise.all([
+ q.getAudit(companyId && Number.isFinite(companyId) ? companyId : null),
+ q.getAllCompanies(),
+ ]);
+ res.json({ rows, companies });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+ });
+
return router;
}
diff --git a/src/api/routes/entries.js b/src/api/routes/entries.js
index 9cf9785..96852b2 100644
--- a/src/api/routes/entries.js
+++ b/src/api/routes/entries.js
@@ -1,12 +1,13 @@
'use strict';
const express = require('express');
+const { aggregateCIDRs } = require('../../validators');
function createEntriesRouter({ q }) {
const router = express.Router();
const json = express.json({ limit: '32kb' });
- // ── Хелпер: маппинг сообщений ошибок на HTTP-коды ───────────────────────
+ // Маппинг сообщений ошибок на HTTP-коды
function handleQueryError(e, res) {
const msg = e.message || '';
if (
@@ -22,12 +23,54 @@ function createEntriesRouter({ q }) {
return res.status(500).json({ error: msg });
}
- // GET /api/v1/entries — список записей текущей компании
+ // Определить компанию из запроса.
+ // Admin с ?company= → компания по ID.
+ // Иначе → компания пользователя (UPSERT).
+ async function resolveCompany(req) {
+ if (req.user.isAdmin && req.query.company) {
+ const id = parseInt(req.query.company, 10);
+ if (!Number.isFinite(id) || id <= 0) {
+ throw Object.assign(new Error('Invalid company id'), { status: 400 });
+ }
+ const c = await q.getCompanyById(id);
+ if (!c) throw Object.assign(new Error('Company not found'), { status: 404 });
+ return c;
+ }
+ return q.getOrCreateCompany(req.user.clientId, req.user.companyName);
+ }
+
+ // GET /api/v1/entries — список записей + лимит
router.get('/', async (req, res) => {
try {
- const company = await q.getOrCreateCompany(req.user.clientId, req.user.companyName);
- const entries = await q.listEntries(company.id);
- res.json({ entries });
+ const company = await resolveCompany(req);
+ const [entries, limit] = await Promise.all([
+ q.listEntries(company.id),
+ q.getLimit(company),
+ ]);
+ res.json({ entries, limit, used: entries.length });
+ } catch (e) {
+ res.status(e.status || 500).json({ error: e.message });
+ }
+ });
+
+ // GET /api/v1/entries/export — агрегированный список CIDR (text/plain)
+ // Доступен всем аутентифицированным пользователям.
+ // Admin: все компании или ?company=. User: только своя компания.
+ router.get('/export', async (req, res) => {
+ try {
+ let companyId = null;
+ if (!req.user.isAdmin) {
+ const company = await q.getOrCreateCompany(req.user.clientId, req.user.companyName);
+ companyId = company.id;
+ } else if (req.query.company) {
+ const id = parseInt(req.query.company, 10);
+ if (Number.isFinite(id) && id > 0) companyId = id;
+ }
+ const cidrs = await q.getExportCIDRs(companyId);
+ 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) {
res.status(500).json({ error: e.message });
}
@@ -38,10 +81,11 @@ function createEntriesRouter({ q }) {
const { value, comment } = req.body || {};
if (!value) return res.status(400).json({ error: 'value required' });
try {
- const company = await q.getOrCreateCompany(req.user.clientId, req.user.companyName);
+ const company = await resolveCompany(req);
const { entry, wasNormalized } = await q.createEntry(company.id, value, comment || '', req.user.email);
res.status(201).json({ entry, wasNormalized });
} catch (e) {
+ if (e.status) return res.status(e.status).json({ error: e.message });
handleQueryError(e, res);
}
});
@@ -51,10 +95,11 @@ function createEntriesRouter({ q }) {
const { value, comment } = req.body || {};
if (!value) return res.status(400).json({ error: 'value required' });
try {
- const company = await q.getOrCreateCompany(req.user.clientId, req.user.companyName);
+ const company = await resolveCompany(req);
const { entry, wasNormalized } = await q.updateEntry(req.params.id, company.id, value, comment ?? '', req.user.email);
res.json({ entry, wasNormalized });
} catch (e) {
+ if (e.status) return res.status(e.status).json({ error: e.message });
handleQueryError(e, res);
}
});
@@ -62,10 +107,11 @@ function createEntriesRouter({ q }) {
// DELETE /api/v1/entries/:id — удалить запись
router.delete('/:id', async (req, res) => {
try {
- const company = await q.getOrCreateCompany(req.user.clientId, req.user.companyName);
+ const company = await resolveCompany(req);
await q.deleteEntry(req.params.id, company.id, req.user.email);
res.status(204).send();
} catch (e) {
+ if (e.status) return res.status(e.status).json({ error: e.message });
handleQueryError(e, res);
}
});
diff --git a/ui/api-client.js b/ui/api-client.js
new file mode 100644
index 0000000..2101af5
--- /dev/null
+++ b/ui/api-client.js
@@ -0,0 +1,73 @@
+'use strict';
+
+/**
+ * ui/api-client.js — HTTP-клиент к /api/v1/*.
+ *
+ * UI-роуты не ходят напрямую в БД — только сюда.
+ * Токен берётся из req.session.token (установлен на /login).
+ *
+ * Все ошибки пробрасываются как Error с понятным message.
+ */
+
+const http = require('http');
+const https = require('https');
+
+const API_BASE = process.env.API_BASE || 'http://localhost:' + (process.env.PORT || 3000);
+
+/**
+ * Выполнить запрос к API.
+ * @param {string} method — GET, POST, PATCH, DELETE
+ * @param {string} path — /api/v1/entries
+ * @param {string} token — Bearer token
+ * @param {object} [body] — JSON body (для POST/PATCH)
+ * @returns {Promise<{status: number, data: any}>}
+ */
+function apiRequest(method, path, token, body) {
+ return new Promise((resolve, reject) => {
+ const url = new URL(API_BASE + path);
+ const lib = url.protocol === 'https:' ? https : http;
+ const bodyStr = body ? JSON.stringify(body) : null;
+
+ const opts = {
+ hostname: url.hostname,
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
+ path: url.pathname + url.search,
+ method,
+ headers: {
+ 'Authorization': 'Bearer ' + token,
+ 'Accept': 'application/json',
+ },
+ };
+ if (bodyStr) {
+ opts.headers['Content-Type'] = 'application/json';
+ opts.headers['Content-Length'] = Buffer.byteLength(bodyStr);
+ }
+
+ const req = lib.request(opts, (res) => {
+ let raw = '';
+ res.on('data', c => { raw += c; });
+ res.on('end', () => {
+ const ct = res.headers['content-type'] || '';
+ const data = ct.includes('application/json') ? JSON.parse(raw) : raw;
+ resolve({ status: res.statusCode, data });
+ });
+ });
+ req.on('error', reject);
+ req.on('timeout', () => { req.destroy(); reject(new Error('API timeout')); });
+ if (bodyStr) req.write(bodyStr);
+ req.end();
+ });
+}
+
+// Хелперы — один вызов на метод
+const api = {
+ get: (path, token) => apiRequest('GET', path, token),
+ post: (path, token, body) => apiRequest('POST', path, token, body),
+ patch: (path, token, body) => apiRequest('PATCH', path, token, body),
+ delete: (path, token) => apiRequest('DELETE', path, token),
+
+ // Достать Bearer из сессии
+ token: (req) => req.session && req.session.token,
+};
+
+module.exports = api;
diff --git a/ui/index.js b/ui/index.js
new file mode 100644
index 0000000..9ecaa95
--- /dev/null
+++ b/ui/index.js
@@ -0,0 +1,90 @@
+'use strict';
+
+/**
+ * ui/index.js — UI-слой: SSR через EJS, данные из /api/v1/*.
+ *
+ * createUiRouter({ auth, MOCK_USERS, authLimiter }) → Express Router.
+ *
+ * Монтируется в server.js ПОСЛЕ /api/v1/.
+ *
+ * Middleware:
+ * 1. requireToken — если нет session.token → redirect /login
+ * 2. resolveUser — декодирует payload из токена → req.user
+ * (без проверки подписи; API валидирует при каждом запросе)
+ *
+ * Если API возвращает 401 → сессия уничтожается → redirect /login.
+ */
+
+'use strict';
+
+const { Router } = require('express');
+const jwt = require('jsonwebtoken');
+
+const { createRouter: createAuthRouter } = require('./routes/auth');
+const { createRouter: createEntriesRouter } = require('./routes/entries');
+const { createRouter: createAdminRouter } = require('./routes/admin');
+const { createRouter: createExportRouter } = require('./routes/export');
+const api = require('./api-client');
+
+const ADMIN_CLIENT_ID = process.env.ADMIN_CLIENT_ID || 'WZ01112';
+
+function createUiRouter({ auth, MOCK_USERS, authLimiter }) {
+ const router = Router();
+
+ // ── Публичные маршруты: /login, /login-token, /logout ────────────────────
+ router.use('/', createAuthRouter({ auth, MOCK_USERS, authLimiter }));
+
+ // ── requireToken: все маршруты ниже требуют session.token ────────────────
+ router.use((req, res, next) => {
+ const token = api.token(req);
+ if (!token) {
+ return res.redirect('/login?returnTo=' + encodeURIComponent(req.originalUrl));
+ }
+ next();
+ });
+
+ // ── resolveUser: payload → req.user ──────────────────────────────────────
+ // Подпись не проверяем здесь — API проверяет при каждом запросе.
+ router.use((req, res, next) => {
+ if (req.user) return next();
+ try {
+ const payload = jwt.decode(api.token(req));
+ if (!payload) throw new Error('empty payload');
+ const clientId = payload.ClientID || payload.clientId || payload.sub || '';
+ req.user = {
+ clientId,
+ companyId: payload.company_id || payload.companyId || null,
+ companyName: payload.company_name || payload.companyName || clientId,
+ email: payload.email || clientId + '@unknown',
+ isAdmin: clientId === ADMIN_CLIENT_ID,
+ };
+ } catch (e) {
+ req.session.destroy(() =>
+ res.redirect('/login?error=' + encodeURIComponent('Токен недействителен, войдите заново'))
+ );
+ return;
+ }
+ next();
+ });
+
+ // ── Основные маршруты (все за requireToken + resolveUser) ─────────────────
+ router.use('/', createEntriesRouter());
+ router.use('/', createExportRouter());
+ router.use('/', createAdminRouter());
+
+ // ── 404 ───────────────────────────────────────────────────────────────────
+ router.use((req, res) => {
+ res.status(404).render('error', { status: 404, message: 'Страница не найдена' });
+ });
+
+ // ── Общий обработчик ошибок ───────────────────────────────────────────────
+ // eslint-disable-next-line no-unused-vars
+ router.use((err, req, res, next) => {
+ console.error('[UI] Error:', err);
+ res.status(500).render('error', { status: 500, message: 'Внутренняя ошибка сервера' });
+ });
+
+ return router;
+}
+
+module.exports = { createUiRouter };
diff --git a/ui/routes/admin.js b/ui/routes/admin.js
new file mode 100644
index 0000000..c01b824
--- /dev/null
+++ b/ui/routes/admin.js
@@ -0,0 +1,88 @@
+'use strict';
+
+/**
+ * ui/routes/admin.js — /admin, /audit (только для администратора).
+ * /export — доступен всем аутентифицированным (смотри ui/routes/export.js).
+ *
+ * Данные из:
+ * GET /api/v1/companies
+ * PATCH /api/v1/companies/:id/limit
+ * GET /api/v1/audit
+ */
+
+const { Router } = require('express');
+const api = require('../api-client');
+
+function requireAdmin(req, res, next) {
+ if (!req.user || !req.user.isAdmin) {
+ return res.status(403).render('error', {
+ status: 403,
+ message: 'Доступ запрещён — требуются права администратора.',
+ });
+ }
+ next();
+}
+
+function createRouter() {
+ const router = Router();
+ router.use(requireAdmin);
+
+ // GET /admin — список компаний с лимитами
+ router.get('/admin', async (req, res) => {
+ const token = api.token(req);
+ try {
+ const r = await api.get('/api/v1/companies', token);
+ const companies = r.data.companies || [];
+ res.render('admin', {
+ user: req.user,
+ companies,
+ error: req.query.error || null,
+ success: req.query.success || null,
+ csrfToken: '',
+ });
+ } catch (e) {
+ res.render('admin', {
+ user: req.user, companies: [],
+ error: 'Ошибка загрузки: ' + e.message, success: null, csrfToken: '',
+ });
+ }
+ });
+
+ // POST /admin/limit/:id — установить лимит компании
+ router.post('/admin/limit/:id', async (req, res) => {
+ const token = api.token(req);
+ const limit = parseInt(req.body.limit, 10);
+ try {
+ const r = await api.patch('/api/v1/companies/' + req.params.id + '/limit', token, { limit });
+ if (r.status === 200) return res.redirect('/admin?success=' + encodeURIComponent('Лимит обновлён'));
+ res.redirect('/admin?error=' + encodeURIComponent(r.data.error || 'Ошибка'));
+ } catch (e) {
+ res.redirect('/admin?error=' + encodeURIComponent(e.message));
+ }
+ });
+
+ // GET /audit — журнал действий
+ router.get('/audit', async (req, res) => {
+ const token = api.token(req);
+ const companyId = req.query.company ? parseInt(req.query.company, 10) : null;
+ try {
+ const r = await api.get('/api/v1/audit' + (companyId ? '?company=' + companyId : ''), token);
+ res.render('audit', {
+ user: req.user,
+ rows: r.data.rows || [],
+ companies: r.data.companies || [],
+ selectedCompanyId: companyId,
+ error: null,
+ });
+ } catch (e) {
+ res.render('audit', {
+ user: req.user, rows: [], companies: [],
+ selectedCompanyId: null, error: 'Ошибка загрузки: ' + e.message,
+ });
+ }
+ });
+
+ return router;
+}
+
+module.exports = { createRouter };
diff --git a/ui/routes/auth.js b/ui/routes/auth.js
new file mode 100644
index 0000000..51111b1
--- /dev/null
+++ b/ui/routes/auth.js
@@ -0,0 +1,72 @@
+'use strict';
+
+/**
+ * ui/routes/auth.js — /login, /logout для UI-слоя.
+ *
+ * Два режима:
+ * Mock (DEV_MODE=true) — страница с пресетами MOCK_USERS.
+ * Выбранный пользователь → генерируем mock Bearer token → session.token.
+ *
+ * Token (всегда доступен) — форма вставки реального Bearer токена от auth-api/KC.
+ * Вставленный токен → session.token (без проверки подписи здесь,
+ * API сам проверит при первом запросе).
+ *
+ * /logout — уничтожает сессию.
+ */
+
+const { Router } = require('express');
+
+function createRouter({ auth, MOCK_USERS, authLimiter }) {
+ const router = Router();
+ const devMode = () => process.env.DEV_MODE === 'true';
+
+ // GET /login
+ router.get('/login', (req, res) => {
+ if (req.session && req.session.token) return res.redirect('/');
+ res.render('ui-login', {
+ devMode: devMode(),
+ MOCK_USERS: devMode() ? MOCK_USERS : [],
+ error: req.query.error || null,
+ returnTo: req.query.returnTo || '/',
+ });
+ });
+
+ // POST /login — mock user
+ router.post('/login', authLimiter, (req, res) => {
+ const { clientId, returnTo } = req.body;
+ if (!clientId) return res.redirect('/login?error=Выберите+пользователя');
+
+ const user = MOCK_USERS.find(u => u.clientId === clientId);
+ if (!user) return res.redirect('/login?error=Пользователь+не+найден');
+
+ const token = auth.issueMockToken({
+ clientId: user.clientId,
+ companyId: user.companyId || '00000000-0000-0000-0000-000000000001',
+ companyName: user.companyName || user.clientId,
+ email: user.email || user.clientId + '@mock.local',
+ });
+
+ req.session.token = token;
+ req.session.user = { clientId: user.clientId, email: user.email, isAdmin: user.isAdmin };
+ res.redirect(returnTo || '/');
+ });
+
+ // POST /login-token — вставить реальный Bearer токен
+ router.post('/login-token', authLimiter, (req, res) => {
+ const { token, returnTo } = req.body;
+ if (!token || !token.trim()) return res.redirect('/login?error=Токен+не+может+быть+пустым');
+
+ req.session.token = token.trim();
+ req.session.user = null; // заполнится при первом запросе к API
+ res.redirect(returnTo || '/');
+ });
+
+ // GET /logout
+ router.get('/logout', (req, res) => {
+ req.session.destroy(() => res.redirect('/login'));
+ });
+
+ return router;
+}
+
+module.exports = { createRouter };
diff --git a/ui/routes/entries.js b/ui/routes/entries.js
new file mode 100644
index 0000000..53d264b
--- /dev/null
+++ b/ui/routes/entries.js
@@ -0,0 +1,132 @@
+'use strict';
+
+/**
+ * ui/routes/entries.js — главная страница: список записей + CRUD.
+ *
+ * Данные из /api/v1/entries (Bearer token из сессии).
+ * Admin: поддержка ?company= (числовой PK) для управления другой компанией.
+ * Формы с company_id скрытым полем → передаётся в API как ?company=.
+ */
+
+const { Router } = require('express');
+const api = require('../api-client');
+
+function createRouter() {
+ const router = Router();
+
+ // Вычислить URL для возврата (с учётом выбранной компании)
+ function backUrl(req) {
+ const companyId = req.query.company || (req.body && req.body.company_id);
+ return companyId ? '/?company=' + companyId : '/';
+ }
+
+ // Суффикс ?company= для вызовов API
+ function companyQuery(req) {
+ const companyId = req.query.company || (req.body && req.body.company_id);
+ return companyId ? '?company=' + companyId : '';
+ }
+
+ // GET / — список записей
+ router.get('/', async (req, res) => {
+ const token = api.token(req);
+ try {
+ let entries = [], limit = 15, selectedCompany = null, companies = [];
+
+ if (req.user.isAdmin) {
+ const cr = await api.get('/api/v1/companies', token);
+ companies = cr.data.companies || [];
+
+ const companyId = req.query.company ? parseInt(req.query.company, 10) : null;
+ selectedCompany = companyId ? companies.find(c => c.id === companyId) || null : null;
+
+ if (selectedCompany) {
+ const er = await api.get('/api/v1/entries?company=' + companyId, token);
+ entries = er.data.entries || [];
+ limit = er.data.limit || selectedCompany.custom_limit || selectedCompany.default_limit || 15;
+ }
+ } else {
+ const er = await api.get('/api/v1/entries', token);
+ entries = er.data.entries || [];
+ limit = er.data.limit || 15;
+ }
+
+ res.render('index', {
+ entries,
+ limit,
+ used: entries.length,
+ user: req.user,
+ isAdmin: req.user.isAdmin,
+ companies,
+ selectedCompany,
+ error: req.query.error || null,
+ success: req.query.success || null,
+ csrfToken: '',
+ });
+ } catch (e) {
+ res.render('index', {
+ entries: [], limit: 15, used: 0,
+ user: req.user, isAdmin: req.user.isAdmin,
+ companies: [], selectedCompany: null,
+ error: 'Ошибка загрузки данных: ' + e.message,
+ success: null, csrfToken: '',
+ });
+ }
+ });
+
+ // POST /add — создать запись
+ router.post('/add', async (req, res) => {
+ const token = api.token(req);
+ const { value, comment } = req.body;
+ const cq = companyQuery(req);
+ const back = backUrl(req);
+ try {
+ const r = await api.post('/api/v1/entries' + cq, token, { value, comment });
+ if (r.status === 201) {
+ const msg = r.data.wasNormalized
+ ? 'Запись добавлена (CIDR нормализован до ' + r.data.entry.value_cidr + ')'
+ : 'Запись добавлена';
+ return res.redirect(back + (back.includes('?') ? '&' : '?') + 'success=' + encodeURIComponent(msg));
+ }
+ res.redirect(back + (back.includes('?') ? '&' : '?') + 'error=' + encodeURIComponent(r.data.error || 'Ошибка'));
+ } catch (e) {
+ res.redirect(back + (back.includes('?') ? '&' : '?') + 'error=' + encodeURIComponent(e.message));
+ }
+ });
+
+ // POST /edit/:id — обновить запись
+ router.post('/edit/:id', async (req, res) => {
+ const token = api.token(req);
+ const { value, comment } = req.body;
+ const cq = companyQuery(req);
+ const back = backUrl(req);
+ try {
+ const r = await api.patch('/api/v1/entries/' + req.params.id + cq, token, { value, comment });
+ if (r.status === 200) {
+ return res.redirect(back + (back.includes('?') ? '&' : '?') + 'success=' + encodeURIComponent('Запись обновлена'));
+ }
+ res.redirect(back + (back.includes('?') ? '&' : '?') + 'error=' + encodeURIComponent(r.data.error || 'Ошибка'));
+ } catch (e) {
+ res.redirect(back + (back.includes('?') ? '&' : '?') + 'error=' + encodeURIComponent(e.message));
+ }
+ });
+
+ // POST /delete/:id — удалить запись
+ router.post('/delete/:id', async (req, res) => {
+ const token = api.token(req);
+ const cq = companyQuery(req);
+ const back = backUrl(req);
+ try {
+ const r = await api.delete('/api/v1/entries/' + req.params.id + cq, token);
+ if (r.status === 204) {
+ return res.redirect(back + (back.includes('?') ? '&' : '?') + 'success=' + encodeURIComponent('Запись удалена'));
+ }
+ res.redirect(back + (back.includes('?') ? '&' : '?') + 'error=' + encodeURIComponent((r.data && r.data.error) || 'Ошибка'));
+ } catch (e) {
+ res.redirect(back + (back.includes('?') ? '&' : '?') + 'error=' + encodeURIComponent(e.message));
+ }
+ });
+
+ return router;
+}
+
+module.exports = { createRouter };
diff --git a/ui/routes/export.js b/ui/routes/export.js
new file mode 100644
index 0000000..bfb9556
--- /dev/null
+++ b/ui/routes/export.js
@@ -0,0 +1,31 @@
+'use strict';
+
+/**
+ * ui/routes/export.js — GET /export.
+ * Доступен всем аутентифицированным (не только admin).
+ * Проксирует GET /api/v1/entries/export (text/plain).
+ */
+
+const { Router } = require('express');
+const api = require('../api-client');
+
+function createRouter() {
+ const router = Router();
+
+ router.get('/export', async (req, res) => {
+ const token = api.token(req);
+ const companyQuery = req.query.company ? '?company=' + parseInt(req.query.company, 10) : '';
+ try {
+ const r = await api.get('/api/v1/entries/export' + companyQuery, token);
+ res.set('Content-Type', 'text/plain; charset=utf-8');
+ res.set('Content-Disposition', 'inline; filename="whitelist.txt"');
+ res.send(r.data);
+ } catch (e) {
+ res.status(500).send('Ошибка экспорта: ' + e.message);
+ }
+ });
+
+ return router;
+}
+
+module.exports = { createRouter };
diff --git a/views/error.ejs b/views/error.ejs
new file mode 100644
index 0000000..ce2b192
--- /dev/null
+++ b/views/error.ejs
@@ -0,0 +1,32 @@
+
+
+
+
+ Ошибка <%= status %> — IP WhiteList
+
+
+
+
+
+
+
diff --git a/views/ui-login.ejs b/views/ui-login.ejs
new file mode 100644
index 0000000..5222326
--- /dev/null
+++ b/views/ui-login.ejs
@@ -0,0 +1,103 @@
+
+
+
+
+ Вход — IP WhiteList
+
+
+
+
+
+
+
IP WhiteList
+
Вход в систему
+
+ <% if (error) { %>
<%= error %>
<% } %>
+
+ <% if (devMode && MOCK_USERS && MOCK_USERS.length > 0) { %>
+ <%# ── Блок mock-пользователей (только DEV_MODE=true) ── %>
+
+ ⚠ ТЕСТОВЫЙ РЕЖИМ
+ Вход без проверки пароля. Только для разработки.
+
+
+
Быстрый вход (mock)
+
+
+
+ <% } %>
+
+ <%# ── Блок вставки Bearer токена (всегда виден) ── %>
+
Войти с Bearer токеном
+
+
+
+
+
+