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 + + + + +
+
<%= status %>
+

<%= message %>

+ ← На главную +
+ + 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 токеном

+
+ +
+ + +
+ +
+
+ + + +