Compare commits
6
Commits
ui-monolith
...
api-v1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
868e80ba71 | ||
|
|
3894962219 | ||
|
|
e586a8f2a6 | ||
|
|
bc83d0e0bf | ||
|
|
dcd21d1e28 | ||
|
|
2738c010a7 |
+1
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ipwhitelist",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"description": "IP WhiteList microservice for cloud provider",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
@@ -8,14 +8,9 @@
|
||||
"dev": "node --watch server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"connect-pg-simple": "^10.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"csrf-csrf": "^4.0.3",
|
||||
"dotenv": "^16.4.7",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^4.21.1",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"express-session": "^1.19.0",
|
||||
"helmet": "^8.2.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"pg": "^8.13.1"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1017 B |
@@ -1,162 +1,57 @@
|
||||
/**
|
||||
* server.js — точка входа приложения.
|
||||
* server.js — API-only точка входа (ветка api-v1).
|
||||
*
|
||||
* Отвечает только за:
|
||||
* 1. Инициализацию Express и глобальных middleware (helmet, static, body-parser)
|
||||
* 2. Инициализацию auth (initAuth) и CSRF (initCsrf)
|
||||
* 3. Подключение роутеров из src/routes/ с передачей зависимостей
|
||||
* 4. Старт HTTP-сервера
|
||||
* Обслуживает только /api/v1/* (Bearer JWT, JSON).
|
||||
* UI — отдельный слой, работает через API.
|
||||
*
|
||||
* Бизнес-логика — в src/routes/*.js
|
||||
* DB-запросы — в src/queries.js
|
||||
* Auth/JWT — в src/auth.js
|
||||
* Валидация — в src/validators.js
|
||||
* Конфиг/хелперы — в src/config.js
|
||||
* Зависимости:
|
||||
* src/auth.js — initAuth (валидация Bearer)
|
||||
* src/queries.js — все SQL-запросы
|
||||
* src/api/ — REST API router
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const path = require('path');
|
||||
const helmet = require('helmet');
|
||||
const helmet = require('helmet');
|
||||
require('dotenv').config();
|
||||
|
||||
const { checkConnection } = require('./src/db');
|
||||
const { initAuth, requireAdmin } = require('./src/auth');
|
||||
const { aggregateCIDRs } = require('./src/validators');
|
||||
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 { createSessionMiddleware } = require('./src/middleware/session');
|
||||
const { CSP_DIRECTIVES } = require('./src/middleware/csp');
|
||||
const { checkConnection } = require('./src/db');
|
||||
const { initAuth } = require('./src/auth');
|
||||
const q = require('./src/queries');
|
||||
const { createApiRouter } = require('./src/api/index');
|
||||
|
||||
const app = express();
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
// Безопасные HTTP-заголовки (без CSP браузерного — API не отдаёт HTML)
|
||||
app.use(helmet({ contentSecurityPolicy: false }));
|
||||
|
||||
// Версия доступна во всех шаблонах как <%= appVersion %> без передачи вручную.
|
||||
app.locals.appVersion = APP_VERSION;
|
||||
|
||||
// ── Безопасность HTTP-заголовков ───────────────────────────────────────────────
|
||||
// helmet выставляет все security-заголовки одним вызовом, включая CSP.
|
||||
// Директивы CSP вынесены в src/middleware/csp.js.
|
||||
app.use(helmet({ contentSecurityPolicy: { directives: CSP_DIRECTIVES } }));
|
||||
|
||||
// За reverse-proxy (nginx/k8s ingress) — доверяем первому хопу для secure cookies.
|
||||
// За reverse-proxy (nginx/k8s ingress)
|
||||
if (process.env.NODE_ENV === 'production') app.set('trust proxy', 1);
|
||||
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.use(express.urlencoded({ extended: true, limit: '32kb' }));
|
||||
app.use(cookieParser());
|
||||
|
||||
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 — простая проверка что сервер жив
|
||||
// k8s liveness probe
|
||||
app.get('/healthz', (req, res) => res.send('OK'));
|
||||
|
||||
// JWKS endpoint для валидации токенов внешними сервисами (только в mock-режиме)
|
||||
// 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');
|
||||
// REST API v1
|
||||
app.use('/api/v1', createApiRouter({ auth, q }));
|
||||
|
||||
// ── Все маршруты ниже требуют авторизации (auth.middleware) ────────────────
|
||||
app.use(auth.middleware);
|
||||
// 404 для всего остального
|
||||
app.use((req, res) => res.status(404).json({ error: 'Not found' }));
|
||||
|
||||
// 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 при невалидном токене.
|
||||
// Ловим ДО общего обработчика — возвращаем понятное сообщение пользователю.
|
||||
// Общий error handler
|
||||
// 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(
|
||||
'<p style="font-family:sans-serif;padding:2rem">' +
|
||||
'Недействительный CSRF-токен. ' +
|
||||
'<a href="javascript:history.back()">Вернитесь назад</a> и повторите действие.' +
|
||||
'</p>'
|
||||
);
|
||||
}
|
||||
console.error('Unhandled error:', err);
|
||||
res.status(500).send('Внутренняя ошибка сервера');
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
// ── Старт сервера ─────────────────────────────────────────────────────────────
|
||||
checkConnection()
|
||||
.then(() => console.log('DB connected'))
|
||||
.catch(e => console.error('DB not ready:', e.message));
|
||||
@@ -164,7 +59,6 @@ async function start() {
|
||||
return app;
|
||||
}
|
||||
|
||||
// Запускаем сервер только если файл запущен напрямую (не через require)
|
||||
if (require.main === module) {
|
||||
start()
|
||||
.then(a => a.listen(PORT, () => console.log('Server on port ' + PORT)))
|
||||
@@ -172,4 +66,3 @@ if (require.main === module) {
|
||||
}
|
||||
|
||||
module.exports = { start };
|
||||
|
||||
|
||||
+2
-1
@@ -23,6 +23,7 @@ const express = require('express');
|
||||
const { requireBearer } = require('./middleware/bearerAuth');
|
||||
const { createEntriesRouter } = require('./routes/entries');
|
||||
const { createAdminRouter } = require('./routes/admin');
|
||||
const { aggregateCIDRs } = require('../validators');
|
||||
|
||||
function createApiRouter({ auth, q }) {
|
||||
const router = express.Router();
|
||||
@@ -33,7 +34,7 @@ function createApiRouter({ auth, q }) {
|
||||
router.use(auth.middleware);
|
||||
|
||||
router.use('/entries', createEntriesRouter({ q }));
|
||||
router.use('/', createAdminRouter({ q }));
|
||||
router.use('/', createAdminRouter({ q, aggregateCIDRs }));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
+43
-1
@@ -10,7 +10,7 @@ function apiRequireAdmin(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
function createAdminRouter({ q }) {
|
||||
function createAdminRouter({ q, aggregateCIDRs }) {
|
||||
const router = express.Router();
|
||||
const json = express.json({ limit: '8kb' });
|
||||
|
||||
@@ -38,6 +38,48 @@ function createAdminRouter({ q }) {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/v1/export — агрегированный CIDR whitelist (text/plain)
|
||||
// Обычный пользователь — только своя компания.
|
||||
// Admin — все компании, или ?company=<id> для фильтра.
|
||||
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 parsed = parseInt(req.query.company, 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) companyId = parsed;
|
||||
}
|
||||
const cidrs = await q.getExportCIDRs(companyId);
|
||||
const aggregated = aggregateCIDRs(cidrs);
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.send(aggregated.join('\n') + (aggregated.length ? '\n' : ''));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/v1/audit — лог операций.
|
||||
// Обычный пользователь — только своя компания.
|
||||
// Admin — все компании, или ?company=<id> для фильтра.
|
||||
router.get('/audit', 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 parsed = parseInt(req.query.company, 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) companyId = parsed;
|
||||
}
|
||||
const rows = await q.getAudit(companyId);
|
||||
res.json({ audit: rows });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* src/middleware/csp.js — Content Security Policy директивы.
|
||||
*
|
||||
* Экспортирует объект CSP_DIRECTIVES для передачи в helmet({ contentSecurityPolicy }).
|
||||
* Так CSP применяется один раз вместе с остальными заголовками helmet.
|
||||
*
|
||||
* Текущее ограничение: EJS-шаблоны используют inline <style>, <script> и
|
||||
* атрибуты onclick/style= — поэтому 'unsafe-inline' пока необходим.
|
||||
* Это всё равно лучше отключённого CSP: блокируются внешние ресурсы,
|
||||
* фреймирование, object/embed, переопределение base.
|
||||
*
|
||||
* Что блокирует эта политика:
|
||||
* - Загрузку скриптов/стилей с внешних доменов
|
||||
* - Фреймирование страниц (clickjacking) — frame-ancestors 'none'
|
||||
* - <object>, <embed>, <applet> — object-src 'none'
|
||||
* - Переопределение <base href> — base-uri 'self'
|
||||
* - POST форм на внешние домены — form-action 'self'
|
||||
*
|
||||
* TODO (при переносе стилей в .css и скриптов в .js файлы):
|
||||
* убрать 'unsafe-inline', добавить nonce или hash для оставшихся inline-блоков.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const CSP_DIRECTIVES = {
|
||||
defaultSrc: ["'self'"],
|
||||
// 'unsafe-inline' нужен пока стили и скрипты inline в EJS.
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:'], // data: для SVG-иконок
|
||||
fontSrc: ["'self'"],
|
||||
connectSrc: ["'self'"],
|
||||
objectSrc: ["'none'"], // блок <object>/<embed>/<applet>
|
||||
baseUri: ["'self'"], // блок переопределения <base>
|
||||
formAction: ["'self'"], // POST только на свой домен
|
||||
frameAncestors: ["'none'"], // clickjacking protection
|
||||
};
|
||||
|
||||
module.exports = { CSP_DIRECTIVES };
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* src/middleware/csrf.js — инициализация CSRF-защиты.
|
||||
*
|
||||
* Используется паттерн «double-submit cookie»:
|
||||
* 1. Сервер генерирует подписанный CSRF-токен и кладёт его в cookie.
|
||||
* 2. Шаблон (EJS) вставляет токен в скрытое поле формы (<input name="_csrf">).
|
||||
* 3. При POST-запросе doubleCsrfProtection сравнивает cookie и поле формы.
|
||||
* 4. Если они не совпадают или токен протух — 403 Forbidden.
|
||||
*
|
||||
* Почему не стандартный csurf:
|
||||
* csurf устарел (deprecated), csrf-csrf — его современная замена для Express 5+.
|
||||
*
|
||||
* Функция initCsrf() возвращает два объекта:
|
||||
* - doubleCsrfProtection — middleware, вешается на POST-роуты
|
||||
* - generateCsrfToken(req, res) — вызывается в GET-роутах для получения токена
|
||||
* и передачи его в шаблон через csrfToken.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { doubleCsrf } = require('csrf-csrf');
|
||||
|
||||
/**
|
||||
* Создаёт и возвращает CSRF middleware и генератор токенов.
|
||||
*
|
||||
* Вызывается один раз при старте приложения (в server.js → start()).
|
||||
* Параметры берутся из process.env:
|
||||
* - CSRF_SECRET: секрет для HMAC подписи. В dev используется дефолтное
|
||||
* значение — в продакшене ОБЯЗАТЕЛЬНО задать через env!
|
||||
* - NODE_ENV: если 'production' — cookie выставляется с флагом secure (HTTPS only).
|
||||
*
|
||||
* @returns {{ doubleCsrfProtection: Function, generateCsrfToken: Function }}
|
||||
*/
|
||||
function initCsrf() {
|
||||
const { doubleCsrfProtection, generateCsrfToken } = doubleCsrf({
|
||||
// Секрет для HMAC-подписи токена.
|
||||
// ⚠️ В продакшене задать CSRF_SECRET в .env или через k8s Secret.
|
||||
getSecret: () => process.env.CSRF_SECRET || 'dev-csrf-secret-change-in-prod',
|
||||
|
||||
// Идентификатор сессии — привязывает CSRF-токен к конкретной сессии пользователя.
|
||||
// Используем req.sessionID (выставляется express-session до вызова CSRF middleware).
|
||||
// Если сессии нет — пустая строка (допустимо для /login до установки сессии).
|
||||
getSessionIdentifier: (req) => req.sessionID || '',
|
||||
|
||||
// ⚠️ csrf-csrf v4 по умолчанию читает токен из заголовка x-csrf-token.
|
||||
// Наши формы (EJS) отправляют его в теле POST как поле _csrf.
|
||||
// Без этой строки токен не находится → 403 на каждый POST.
|
||||
getCsrfTokenFromRequest: (req) => req.body && req.body._csrf,
|
||||
|
||||
// Имя cookie без префикса __Host- (дефолт __Host-psifi.x-csrf-token
|
||||
// требует Secure-флаг; при NODE_ENV≠production secure:false → cookie
|
||||
// отклоняется браузером по HTTPS и валидация падает).
|
||||
cookieName: 'csrf-token',
|
||||
|
||||
cookieOptions: {
|
||||
// secure: true → cookie передаётся только по HTTPS. Важно в продакшене.
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax', // lax: cookie отправляется при навигации, но не из iframe
|
||||
httpOnly: true, // cookie недоступна из JS (защита от XSS-кражи CSRF-токена)
|
||||
},
|
||||
|
||||
size: 64, // длина случайной части токена в байтах (достаточно для безопасности)
|
||||
});
|
||||
|
||||
return { doubleCsrfProtection, generateCsrfToken };
|
||||
}
|
||||
|
||||
module.exports = { initCsrf };
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* src/middleware/session.js — конфигурация express-session.
|
||||
*
|
||||
* Стор выбирается по наличию пула БД:
|
||||
* - pool передан → connect-pg-simple (PG): сессии переживают рестарт, работают
|
||||
* на нескольких подах.
|
||||
* - pool не передан → MemoryStore: только для юнит-тестов (не требует БД).
|
||||
*
|
||||
* Таблица session создаётся автоматически connect-pg-simple при первом старте
|
||||
* (createTableIfMissing: true). SQL-схема совместима с PostgreSQL 9.6+.
|
||||
*
|
||||
* Экспортирует одну функцию: createSessionMiddleware(pool) → express middleware.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const session = require('express-session');
|
||||
const ConnectPgSimple = require('connect-pg-simple');
|
||||
|
||||
const SESSION_MAX_AGE = 8 * 60 * 60 * 1000; // 8 часов, в мс
|
||||
|
||||
/**
|
||||
* Создаёт и возвращает сконфигурированный express-session middleware.
|
||||
* @param {import('pg').Pool|null} pool — pg Pool; null → MemoryStore (тесты)
|
||||
* @returns {import('express').RequestHandler}
|
||||
*/
|
||||
function createSessionMiddleware(pool) {
|
||||
const store = pool ? buildPgStore(pool) : undefined; // undefined → MemoryStore
|
||||
|
||||
return session({
|
||||
store,
|
||||
secret: process.env.SESSION_SECRET || 'dev-session-secret-change-me',
|
||||
resave: false,
|
||||
// true: сессия сохраняется даже пустой (нужно чтобы sessionID не менялся
|
||||
// между GET /login и POST /login — иначе CSRF-токен привязанный к sessionID
|
||||
// из GET не совпадёт с sessionID на POST и будет 403).
|
||||
saveUninitialized: true,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production', // HTTPS only в проде
|
||||
sameSite: 'lax',
|
||||
maxAge: SESSION_MAX_AGE,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт connect-pg-simple Store поверх переданного пула.
|
||||
* Таблица `session` создаётся автоматически если отсутствует.
|
||||
* @param {import('pg').Pool} pool
|
||||
* @returns {session.Store}
|
||||
*/
|
||||
function buildPgStore(pool) {
|
||||
const PgSession = ConnectPgSimple(session);
|
||||
return new PgSession({
|
||||
pool,
|
||||
createTableIfMissing: true,
|
||||
// Автоочистка протухших сессий раз в час (прунинг не блокирует запросы).
|
||||
pruneSessionInterval: 60 * 60, // секунды
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { createSessionMiddleware };
|
||||
@@ -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,223 +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',
|
||||
};
|
||||
|
||||
// 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('Пользователь не найден'));
|
||||
}
|
||||
user = {
|
||||
email: mockUser.email,
|
||||
clientId: 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 обязателен'));
|
||||
}
|
||||
user = {
|
||||
email: (req.body.email || '').trim() || clientId + '@dev.local',
|
||||
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,218 +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 } = req.user;
|
||||
|
||||
// Генерируем CSRF-токен здесь, один раз для всей страницы.
|
||||
// Он попадёт в три формы: /add, /edit (modal), /delete (каждая строка).
|
||||
const csrfToken = generateCsrfToken(req, res);
|
||||
|
||||
try {
|
||||
if (isAdmin) {
|
||||
// ── Ветка admin ───────────────────────────────────────────────────────
|
||||
// Загружаем все компании (с active_count через LEFT JOIN)
|
||||
const companies = await q.getAllCompanies();
|
||||
|
||||
// ?company=X — числовой PK выбранной компании.
|
||||
// Если параметра нет — берём первую компанию из списка.
|
||||
const selectedId = req.query.company
|
||||
? parseInt(req.query.company, 10)
|
||||
: (companies[0] ? companies[0].id : null);
|
||||
|
||||
// Ищем объект компании по id. Если id не найден — берём первую.
|
||||
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, // текущая выбранная компания
|
||||
error: req.query.error || null,
|
||||
message: req.query.message || null,
|
||||
wasNormalized: req.query.wasNormalized === '1',
|
||||
csrfToken,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Ветка обычного пользователя ────────────────────────────────────────
|
||||
// getOrCreateCompany: создаёт запись в companies если её ещё нет.
|
||||
// clientId и companyName берутся из JWT (нельзя подменить через форму).
|
||||
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,
|
||||
isAdmin: false,
|
||||
companies: null, // пользователь не видит список компаний
|
||||
selectedCompany: company, // его единственная компания
|
||||
error: req.query.error || null,
|
||||
message: req.query.message || null,
|
||||
wasNormalized: req.query.wasNormalized === '1',
|
||||
csrfToken,
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
console.error('GET / error:', e);
|
||||
// При ошибке рендерим страницу с сообщением, не падаем с 500
|
||||
res.render('index', {
|
||||
entries: [], limit: 15, used: 0,
|
||||
user: req.user, isAdmin,
|
||||
companies: null, selectedCompany: 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,74 +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) {
|
||||
// Обычный пользователь: только его компания.
|
||||
// getOrCreateCompany — атомарная UPSERT, безопасна для параллельных вызовов.
|
||||
const company = await q.getOrCreateCompany(req.user.clientId, req.user.companyName);
|
||||
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.
|
||||
// Content-Disposition: inline — открывается в браузере, а не скачивается.
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', 'inline; filename="whitelist.txt"');
|
||||
|
||||
// Один 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 };
|
||||
-207
@@ -1,207 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Управление лимитами — IP WhiteList</title>
|
||||
<link rel="icon" href="/favicon.png" type="image/png">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f5f5; --card: #ffffff; --text: #1a1a1a; --muted: #6b7280;
|
||||
--border: #d1d5db; --grey-light: #f3f4f6; --blue: #2563eb; --blue-h: #1d4ed8;
|
||||
--red: #dc2626; --green: #16a34a; --amber: #d97706;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5;
|
||||
}
|
||||
.page { max-width: 900px; margin: 1.5rem auto; padding: 0 1rem; }
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 1px 2px rgba(0,0,0,.04); margin-bottom: 1rem; overflow: hidden; }
|
||||
.card-header { background: var(--grey-light); padding: .75rem 1rem; font-weight: 600; font-size: 1rem; border-bottom: 1px solid var(--border); }
|
||||
.card-body { padding: 1rem; }
|
||||
.alert { padding: .75rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: .9rem; }
|
||||
.alert-ok { background: #dcfce7; color: #166534; border: 1px solid #bbf7d0; }
|
||||
.alert-err { background: #fecaca; color: #991b1b; border: 1px solid #fca5a5; }
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: .35rem;
|
||||
padding: .4rem .85rem; border: 1px solid var(--border); border-radius: 6px;
|
||||
font-size: .82rem; font-weight: 500; background: #fff; cursor: pointer;
|
||||
text-decoration: none; color: var(--text); transition: background .15s;
|
||||
}
|
||||
.btn:hover { background: var(--grey-light); }
|
||||
.btn-sm { padding: .3rem .65rem; font-size: .78rem; }
|
||||
.btn-primary { background: var(--blue); color: #fff; border-color: var(--blue); }
|
||||
.btn-primary:hover { background: var(--blue-h); }
|
||||
/* Таблица */
|
||||
.table-wrap { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: .85rem; }
|
||||
th {
|
||||
text-align: left; padding: .5rem .75rem;
|
||||
background: var(--grey-light); color: var(--muted);
|
||||
font-weight: 500; font-size: .78rem; text-transform: uppercase; letter-spacing: .5px;
|
||||
border-bottom: 1px solid var(--border); border-right: 1px solid var(--border);
|
||||
}
|
||||
th:last-child { border-right: none; }
|
||||
td { padding: .5rem .75rem; border-bottom: 1px solid var(--border); border-right: 1px solid var(--border); vertical-align: middle; }
|
||||
td:last-child { border-right: none; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: #f8fafc; }
|
||||
/* Инпут лимита */
|
||||
.limit-input {
|
||||
width: 80px; padding: .3rem .5rem; border: 1px solid var(--border);
|
||||
border-radius: 5px; font-size: .85rem; text-align: center;
|
||||
}
|
||||
.limit-input:focus { border-color: var(--blue); outline: none; box-shadow: 0 0 0 2px rgba(37,99,235,.1); }
|
||||
/* Индикатор использования */
|
||||
.usage-bar { display: flex; align-items: center; gap: .5rem; }
|
||||
.bar { height: 6px; border-radius: 3px; background: var(--border); flex: 1; min-width: 60px; }
|
||||
.bar-fill { height: 100%; border-radius: 3px; background: var(--blue); }
|
||||
.bar-fill.full { background: var(--red); }
|
||||
.bar-fill.warn { background: var(--amber); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header style="background:#fff;border-bottom:1px solid var(--border);padding:0 1.5rem;height:48px;display:flex;align-items:center;gap:.75rem;font-size:.9rem;color:var(--muted);">
|
||||
<svg width="130" height="28" viewBox="0 0 311 69" style="display:block;">
|
||||
<g transform="translate(-330 -228)">
|
||||
<path d="M348.061 246.203 332.42 246.203 330 256.661 336.75 256.661 336.794 295.573 336.794 296.585 336.795 296.585 347.25 296.585 347.25 263.611C347.25 259.78 350.368 256.663 354.2 256.663L376.489 256.793C380.322 256.793 383.44 259.912 383.44 263.743L383.44 296.717 393.896 296.717 393.896 263.743C393.896 254.144 386.087 246.338 376.495 246.338L354.196 246.204 348.061 246.221 348.061 246.203Z" fill="#001C34" fill-rule="evenodd"/>
|
||||
<path d="M582.095 296.65 626.768 296.65C634.793 296.65 641 290.03 641 282.009 641 273.985 634.473 267.458 626.449 267.458L601.724 267.458C599.446 267.458 597.595 265.607 597.595 263.328L597.595 260.825C597.595 258.548 599.446 256.697 601.724 256.697L628.041 256.697 630.452 246.276 601.724 246.276C593.7 246.276 587.174 252.802 587.174 260.825L587.174 263.328C587.174 271.352 593.7 277.879 601.724 277.879L626.449 277.879C628.726 277.879 630.579 279.732 630.579 282.009 630.579 284.283 628.726 286.4 626.449 286.4L584.126 286.4 582.095 296.65Z" fill="#001C34" fill-rule="evenodd"/>
|
||||
<path d="M564.549 246.347 537.193 246.347C529.027 246.347 522.385 252.989 522.385 261.156L522.385 279.521C522.385 288.916 530.028 296.557 539.422 296.557L576.846 296.557 579.196 286.401 539.422 286.401C535.775 286.401 532.805 283.172 532.805 279.521L532.748 278.028 567.684 278.028 579.354 277.959 579.354 268.958 579.354 261.156C579.354 252.989 572.712 246.347 564.549 246.347ZM568.935 267.54 532.805 267.54 532.805 261.156C532.805 258.737 534.774 256.767 537.193 256.767L564.549 256.767C566.966 256.767 568.935 258.737 568.935 261.156L568.935 267.54Z" fill="#001C34" fill-rule="evenodd"/>
|
||||
<path d="M507.156 273.231 507.156 279.597C507.156 283.414 504.066 286.344 500.247 286.344L477.934 286.39C474.41 286.39 471.493 283.744 471.064 280.335L471.064 263.006C471.494 259.597 474.41 256.951 477.934 256.951L500.23 257.083C504.049 257.083 507.156 260.192 507.156 264.009L507.156 265.16 507.156 273.232ZM460.623 260.161 460.589 230.089 471.064 228.058 471.064 247.957C473.172 247.043 475.49 246.53 477.929 246.53L500.235 246.662C509.793 246.662 517.575 254.444 517.575 264.009L517.575 265.159 517.575 273.231 517.575 279.597C517.575 289.161 509.793 296.941 500.235 296.941L477.929 296.809C468.37 296.809 460.589 289.029 460.589 279.464L460.589 272.972 460.589 263.876 460.623 260.161Z" fill="#001C34" fill-rule="evenodd"/>
|
||||
<path d="M445.037 271.59 445.037 279.317C445.037 283.134 441.93 286.241 438.111 286.241L416.092 286.11C412.273 286.11 409.166 283.002 409.166 279.185L409.166 276.635 409.222 276.644 409.222 244.013 398.748 246.044 398.752 249.955 398.748 249.957 398.748 279.184C398.748 288.749 406.529 296.529 416.087 296.529L438.116 296.661C447.674 296.661 455.456 288.881 455.456 279.316L455.456 276.639 455.482 276.643 455.482 244.013 445.008 246.043 445.037 271.589Z" fill="#001C34" fill-rule="evenodd"/>
|
||||
</g>
|
||||
</svg>
|
||||
<span>|</span>
|
||||
<span style="font-weight:500;color:var(--text);">Управление лимитами</span>
|
||||
<span style="margin-left:auto;display:flex;gap:.5rem;">
|
||||
<a href="/" class="btn btn-sm">← Список адресов</a>
|
||||
<a href="/audit" class="btn btn-sm">Журнал аудита</a>
|
||||
<a href="/logout" class="btn btn-sm">Выйти (<%= user.email %>)</a>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<% if (message) { %><div class="alert alert-ok"><%= message %></div><% } %>
|
||||
<% if (error) { %><div class="alert alert-err"><%= error %></div><% } %>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
Компании и лимиты
|
||||
<span style="margin-left:.5rem;font-weight:400;font-size:.82rem;color:var(--muted);">
|
||||
Глобальный дефолт: <strong><%= defaultLimit %></strong> записей.
|
||||
Пустое поле = сброс на дефолт.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<% if (companies.length === 0) { %>
|
||||
<div style="text-align:center;color:var(--muted);padding:2rem;">Нет компаний в системе</div>
|
||||
<% } else { %>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Компания</th>
|
||||
<th>ClientID</th>
|
||||
<th>Использовано</th>
|
||||
<th>Лимит</th>
|
||||
<th>Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% companies.forEach(c => { %>
|
||||
<%
|
||||
// Вычисляем эффективный лимит (кастомный или дефолтный)
|
||||
const effectiveLimit = c.custom_limit != null ? c.custom_limit : defaultLimit;
|
||||
const pct = effectiveLimit > 0 ? Math.min(c.active_count / effectiveLimit, 1) : 1;
|
||||
const barClass = pct >= 1 ? 'full' : pct >= 0.8 ? 'warn' : '';
|
||||
%>
|
||||
<tr>
|
||||
<td><strong><%= c.name %></strong></td>
|
||||
<td style="color:var(--muted);font-size:.82rem;"><%= c.client_id %></td>
|
||||
<!-- Индикатор использования: полоска + цифры -->
|
||||
<td>
|
||||
<div class="usage-bar">
|
||||
<div class="bar">
|
||||
<div class="bar-fill <%= barClass %>" style="width:<%= Math.round(pct*100) %>%"></div>
|
||||
</div>
|
||||
<span style="white-space:nowrap;font-size:.82rem;">
|
||||
<%= c.active_count %> / <%= effectiveLimit %>
|
||||
<% if (c.custom_limit != null) { %>
|
||||
<span style="color:var(--amber);margin-left:.25rem;" title="Индивидуальный лимит">★</span>
|
||||
<% } %>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Текущий лимит: кастомный или "по умолчанию" -->
|
||||
<td style="color:var(--muted);font-size:.82rem;">
|
||||
<% if (c.custom_limit != null) { %>
|
||||
<span style="color:var(--amber);font-weight:600;"><%= c.custom_limit %></span>
|
||||
<span style="font-size:.75rem;">(индивидуальный)</span>
|
||||
<% } else { %>
|
||||
<span>дефолт (<%= defaultLimit %>)</span>
|
||||
<% } %>
|
||||
</td>
|
||||
<!-- Форма изменения лимита для этой компании -->
|
||||
<td>
|
||||
<form method="POST" action="/admin/limit/<%= c.id %>"
|
||||
style="display:flex;align-items:center;gap:.5rem;"
|
||||
onsubmit="return confirmLimit(this, '<%= c.name %>')">
|
||||
<%# CSRF-токен: одно значение на всю страницу (генерируется сервером) %>
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
<input
|
||||
class="limit-input"
|
||||
type="number"
|
||||
name="limit"
|
||||
min="0"
|
||||
placeholder="<%= defaultLimit %>"
|
||||
value="<%= c.custom_limit != null ? c.custom_limit : '' %>"
|
||||
title="Введите число ≥ 0, или оставьте пустым для дефолта (<%=defaultLimit%>)"
|
||||
>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Сохранить</button>
|
||||
<% if (c.custom_limit != null) { %>
|
||||
<!-- Кнопка сброса: отправляет пустое значение → null → дефолт -->
|
||||
<button type="button" class="btn btn-sm"
|
||||
onclick="resetLimit(this, <%= c.id %>)"
|
||||
title="Сбросить на дефолт">↺</button>
|
||||
<% } %>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Подтверждение перед сохранением лимита
|
||||
function confirmLimit(form, companyName) {
|
||||
const val = form.querySelector('input[name="limit"]').value;
|
||||
const label = val === '' ? 'сбросить на дефолт' : 'установить лимит ' + val;
|
||||
return confirm('Компания «' + companyName + '»: ' + label + '?');
|
||||
}
|
||||
|
||||
// Сброс лимита на дефолт — отправляет форму с пустым полем
|
||||
function resetLimit(btn, companyId) {
|
||||
if (!confirm('Сбросить лимит на дефолт?')) return;
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/admin/limit/' + companyId;
|
||||
// Пустое значение → сервер интерпретирует как null → дефолт
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'hidden';
|
||||
inp.name = 'limit';
|
||||
inp.value = '';
|
||||
form.appendChild(inp);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
</script>
|
||||
<footer style="text-align:right;padding:.5rem 1.5rem;font-size:.72rem;color:var(--muted);">v<%= appVersion %></footer>
|
||||
</body>
|
||||
</html>
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Журнал аудита — IP WhiteList</title>
|
||||
<link rel="icon" href="/favicon.png" type="image/png">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f5f5; --card: #ffffff; --text: #1a1a1a; --muted: #6b7280;
|
||||
--border: #d1d5db; --grey-light: #f3f4f6; --blue: #2563eb; --blue-h: #1d4ed8;
|
||||
--red: #dc2626; --green: #16a34a; --amber: #d97706;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5;
|
||||
}
|
||||
.page { max-width: 1200px; margin: 1.5rem auto; padding: 0 1rem; }
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 1px 2px rgba(0,0,0,.04); margin-bottom: 1rem; overflow: hidden; }
|
||||
.card-header { background: var(--grey-light); padding: .75rem 1rem; font-weight: 600; font-size: 1rem; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 1rem; }
|
||||
.card-body { padding: 1rem; }
|
||||
.alert { padding: .75rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: .9rem; }
|
||||
.alert-err { background: #fecaca; color: #991b1b; border: 1px solid #fca5a5; }
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: .35rem;
|
||||
padding: .4rem .85rem; border: 1px solid var(--border); border-radius: 6px;
|
||||
font-size: .82rem; font-weight: 500; background: #fff; cursor: pointer;
|
||||
text-decoration: none; color: var(--text); transition: background .15s;
|
||||
}
|
||||
.btn:hover { background: var(--grey-light); }
|
||||
.btn-sm { padding: .3rem .65rem; font-size: .78rem; }
|
||||
/* Фильтр */
|
||||
.filter-bar { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
|
||||
.filter-bar select { padding: .4rem .75rem; border: 1px solid var(--border); border-radius: 6px; font-size: .85rem; background: #fff; }
|
||||
/* Таблица */
|
||||
.table-wrap { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: .82rem; }
|
||||
th {
|
||||
text-align: left; padding: .5rem .75rem;
|
||||
background: var(--grey-light); color: var(--muted);
|
||||
font-weight: 500; font-size: .78rem; text-transform: uppercase; letter-spacing: .5px;
|
||||
border-bottom: 1px solid var(--border); border-right: 1px solid var(--border);
|
||||
}
|
||||
th:last-child { border-right: none; }
|
||||
td { padding: .45rem .75rem; border-bottom: 1px solid var(--border); border-right: 1px solid var(--border); vertical-align: top; }
|
||||
td:last-child { border-right: none; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: #f8fafc; }
|
||||
code { background: #f1f5f9; padding: .1rem .35rem; border-radius: 3px; font-size: .88em; }
|
||||
.empty { text-align: center; color: var(--muted); padding: 2rem; }
|
||||
/* Badges действий */
|
||||
.badge { display: inline-block; padding: .15rem .5rem; border-radius: 4px; font-size: .72rem; font-weight: 600; text-transform: uppercase; }
|
||||
.badge-create { background: #dcfce7; color: #166534; }
|
||||
.badge-update { background: #dbeafe; color: #1e40af; }
|
||||
.badge-delete { background: #fecaca; color: #991b1b; }
|
||||
/* Стрелка old→new */
|
||||
.arrow { color: var(--muted); margin: 0 .3rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Шапка — такая же как в index.ejs -->
|
||||
<header style="background:#fff;border-bottom:1px solid var(--border);padding:0 1.5rem;height:48px;display:flex;align-items:center;gap:.75rem;font-size:.9rem;color:var(--muted);">
|
||||
<svg width="130" height="28" viewBox="0 0 311 69" style="display:block;">
|
||||
<g transform="translate(-330 -228)">
|
||||
<path d="M348.061 246.203 332.42 246.203 330 256.661 336.75 256.661 336.794 295.573 336.794 296.585 336.795 296.585 347.25 296.585 347.25 263.611C347.25 259.78 350.368 256.663 354.2 256.663L376.489 256.793C380.322 256.793 383.44 259.912 383.44 263.743L383.44 296.717 393.896 296.717 393.896 263.743C393.896 254.144 386.087 246.338 376.495 246.338L354.196 246.204 348.061 246.221 348.061 246.203Z" fill="#001C34" fill-rule="evenodd"/>
|
||||
<path d="M582.095 296.65 626.768 296.65C634.793 296.65 641 290.03 641 282.009 641 273.985 634.473 267.458 626.449 267.458L601.724 267.458C599.446 267.458 597.595 265.607 597.595 263.328L597.595 260.825C597.595 258.548 599.446 256.697 601.724 256.697L628.041 256.697 630.452 246.276 601.724 246.276C593.7 246.276 587.174 252.802 587.174 260.825L587.174 263.328C587.174 271.352 593.7 277.879 601.724 277.879L626.449 277.879C628.726 277.879 630.579 279.732 630.579 282.009 630.579 284.283 628.726 286.4 626.449 286.4L584.126 286.4 582.095 296.65Z" fill="#001C34" fill-rule="evenodd"/>
|
||||
<path d="M564.549 246.347 537.193 246.347C529.027 246.347 522.385 252.989 522.385 261.156L522.385 279.521C522.385 288.916 530.028 296.557 539.422 296.557L576.846 296.557 579.196 286.401 539.422 286.401C535.775 286.401 532.805 283.172 532.805 279.521L532.748 278.028 567.684 278.028 579.354 277.959 579.354 268.958 579.354 261.156C579.354 252.989 572.712 246.347 564.549 246.347ZM568.935 267.54 532.805 267.54 532.805 261.156C532.805 258.737 534.774 256.767 537.193 256.767L564.549 256.767C566.966 256.767 568.935 258.737 568.935 261.156L568.935 267.54Z" fill="#001C34" fill-rule="evenodd"/>
|
||||
<path d="M507.156 273.231 507.156 279.597C507.156 283.414 504.066 286.344 500.247 286.344L477.934 286.39C474.41 286.39 471.493 283.744 471.064 280.335L471.064 263.006C471.494 259.597 474.41 256.951 477.934 256.951L500.23 257.083C504.049 257.083 507.156 260.192 507.156 264.009L507.156 265.16 507.156 273.232ZM460.623 260.161 460.589 230.089 471.064 228.058 471.064 247.957C473.172 247.043 475.49 246.53 477.929 246.53L500.235 246.662C509.793 246.662 517.575 254.444 517.575 264.009L517.575 265.159 517.575 273.231 517.575 279.597C517.575 289.161 509.793 296.941 500.235 296.941L477.929 296.809C468.37 296.809 460.589 289.029 460.589 279.464L460.589 272.972 460.589 263.876 460.623 260.161Z" fill="#001C34" fill-rule="evenodd"/>
|
||||
<path d="M445.037 271.59 445.037 279.317C445.037 283.134 441.93 286.241 438.111 286.241L416.092 286.11C412.273 286.11 409.166 283.002 409.166 279.185L409.166 276.635 409.222 276.644 409.222 244.013 398.748 246.044 398.752 249.955 398.748 249.957 398.748 279.184C398.748 288.749 406.529 296.529 416.087 296.529L438.116 296.661C447.674 296.661 455.456 288.881 455.456 279.316L455.456 276.639 455.482 276.643 455.482 244.013 445.008 246.043 445.037 271.589Z" fill="#001C34" fill-rule="evenodd"/>
|
||||
</g>
|
||||
</svg>
|
||||
<span>|</span>
|
||||
<span style="font-weight:500;color:var(--text);">Журнал аудита</span>
|
||||
<span style="margin-left:auto;display:flex;gap:.5rem;">
|
||||
<a href="/" class="btn btn-sm">← Список адресов</a>
|
||||
<a href="/admin" class="btn btn-sm">Управление лимитами</a>
|
||||
<a href="/logout" class="btn btn-sm">Выйти (<%= user.email %>)</a>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<% if (error) { %><div class="alert alert-err"><%= error %></div><% } %>
|
||||
|
||||
<!-- Фильтр по компании -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
Фильтр
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="GET" action="/audit" class="filter-bar">
|
||||
<label style="font-size:.85rem;color:var(--muted);">Компания:</label>
|
||||
<select name="company" onchange="this.form.submit()">
|
||||
<option value="">— Все компании —</option>
|
||||
<% companies.forEach(c => { %>
|
||||
<option value="<%= c.id %>" <%= selectedCompanyId === c.id ? 'selected' : '' %>>
|
||||
<%= c.name %> (<%= c.client_id %>)
|
||||
</option>
|
||||
<% }) %>
|
||||
</select>
|
||||
<% if (selectedCompanyId) { %>
|
||||
<a href="/audit" class="btn btn-sm">Сбросить</a>
|
||||
<% } %>
|
||||
<span style="margin-left:auto;color:var(--muted);font-size:.82rem;">
|
||||
Показано: <strong><%= rows.length %></strong> записей (макс 500)
|
||||
</span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Таблица аудита -->
|
||||
<div class="card">
|
||||
<div class="card-header">Записи журнала</div>
|
||||
<% if (rows.length === 0) { %>
|
||||
<div class="empty">Нет записей в журнале аудита</div>
|
||||
<% } else { %>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Когда</th>
|
||||
<th>Кто</th>
|
||||
<th>Компания</th>
|
||||
<th>Действие</th>
|
||||
<th>Значение</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% rows.forEach(r => { %>
|
||||
<tr>
|
||||
<!-- Дата в читаемом виде -->
|
||||
<td style="white-space:nowrap;color:var(--muted);">
|
||||
<%= new Date(r.created_at).toLocaleString('ru', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
}) %>
|
||||
</td>
|
||||
<td><%= r.user_email %></td>
|
||||
<!-- Название компании из JOIN, client_id в скобках -->
|
||||
<td>
|
||||
<%= r.company_name %><br>
|
||||
<span style="color:var(--muted);font-size:.78rem;"><%= r.client_id %></span>
|
||||
</td>
|
||||
<!-- Цветной badge типа действия -->
|
||||
<td>
|
||||
<span class="badge badge-<%= r.action.toLowerCase() %>"><%= r.action %></span>
|
||||
</td>
|
||||
<!-- old_value → new_value: показываем что изменилось -->
|
||||
<td>
|
||||
<% if (r.old_value && r.new_value) { %>
|
||||
<code><%= r.old_value %></code>
|
||||
<span class="arrow">→</span>
|
||||
<code><%= r.new_value %></code>
|
||||
<% } else if (r.new_value) { %>
|
||||
<code><%= r.new_value %></code>
|
||||
<% } else if (r.old_value) { %>
|
||||
<code style="text-decoration:line-through;color:var(--muted);"><%= r.old_value %></code>
|
||||
<% } else { %>
|
||||
<span style="color:var(--muted);">—</span>
|
||||
<% } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<footer style="text-align:right;padding:.5rem 1.5rem;font-size:.72rem;color:var(--muted);">v<%= appVersion %></footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,140 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Dev-вход — IP WhiteList</title>
|
||||
<link rel="icon" href="/favicon.png" type="image/png">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f5f5; --card: #ffffff; --text: #1a1a1a; --muted: #6b7280;
|
||||
--border: #d1d5db; --grey-light: #f3f4f6; --blue: #2563eb; --blue-h: #1d4ed8;
|
||||
--amber: #d97706; --amber-bg: #fef3c7; --amber-border: #fde68a;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5;
|
||||
display: flex; align-items: center; justify-content: center; min-height: 100vh;
|
||||
}
|
||||
.login-card {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.06); width: 440px; padding: 2rem;
|
||||
}
|
||||
h1 { font-size: 1.2rem; margin-bottom: .25rem; }
|
||||
.sub { color: var(--muted); font-size: .85rem; margin-bottom: 1.25rem; }
|
||||
.warn {
|
||||
padding: .75rem 1rem; background: var(--amber-bg); border: 1px solid var(--amber-border);
|
||||
border-radius: 8px; font-size: .82rem; color: var(--amber); margin-bottom: 1.5rem;
|
||||
}
|
||||
.warn strong { display: block; margin-bottom: .25rem; font-size: .9rem; }
|
||||
hr { border: none; border-top: 1px solid var(--border); margin: 1.25rem 0; }
|
||||
.section-title { font-size: .75rem; font-weight: 600; color: var(--muted);
|
||||
text-transform: uppercase; letter-spacing: .5px; margin-bottom: .75rem; }
|
||||
.field { display: flex; flex-direction: column; gap: .25rem; margin-bottom: .85rem; }
|
||||
.field label { font-size: .8rem; font-weight: 500; color: var(--muted); text-transform: uppercase; }
|
||||
.field select, .field input[type=text], .field input[type=email] {
|
||||
padding: .5rem .75rem; border: 1px solid var(--border); border-radius: 6px;
|
||||
font-size: .9rem; outline: none; background: #fff; width: 100%;
|
||||
}
|
||||
.field select:focus, .field input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
|
||||
.check-row { display: flex; align-items: center; gap: .5rem; margin-bottom: .85rem; }
|
||||
.check-row input[type=checkbox] { width: 16px; height: 16px; cursor: pointer; }
|
||||
.check-row label { font-size: .88rem; cursor: pointer; }
|
||||
.btn {
|
||||
width: 100%; padding: .6rem; border: none; border-radius: 6px; font-size: .9rem;
|
||||
font-weight: 500; cursor: pointer; background: var(--blue); color: #fff;
|
||||
transition: background .15s; margin-top: .25rem;
|
||||
}
|
||||
.btn:hover { background: var(--blue-h); }
|
||||
.error { color: #dc2626; font-size: .85rem; margin-bottom: 1rem; }
|
||||
.hint { font-size: .78rem; color: var(--muted); margin-top: .25rem; }
|
||||
.or-divider { text-align: center; font-size: .8rem; color: var(--muted); margin: 1.25rem 0 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="login-card">
|
||||
<h1>Тестовый вход</h1>
|
||||
<p class="sub">Dev-backdoor — в обход Keycloak</p>
|
||||
|
||||
<div class="warn">
|
||||
<strong>⚠ ТЕСТОВЫЙ РЕЖИМ</strong>
|
||||
Этот вход создаёт произвольного пользователя без проверки пароля.
|
||||
Используйте только для тестирования.
|
||||
</div>
|
||||
|
||||
<% if (error) { %><div class="error"><%= error %></div><% } %>
|
||||
|
||||
<form method="POST" action="/dev-login">
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
|
||||
<%# ── Пресеты ── %>
|
||||
<p class="section-title">Быстрый выбор</p>
|
||||
<div class="field">
|
||||
<label>Пресет</label>
|
||||
<select name="preset" id="preset-select">
|
||||
<option value="">— вручную —</option>
|
||||
<% users.forEach(u => { %>
|
||||
<option value="<%= u.id %>"><%= u.label %> (<%= u.role === 'admin' ? 'admin' : 'user' %>)</option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="or-divider">— или задайте вручную —</div>
|
||||
|
||||
<%# ── Кастомные поля ── %>
|
||||
<p class="section-title">Произвольный пользователь</p>
|
||||
|
||||
<div class="field">
|
||||
<label>ClientID <span style="color:#dc2626">*</span></label>
|
||||
<input type="text" name="clientId" placeholder="WZ01999" autocomplete="off">
|
||||
<span class="hint">WZ-номер компании в Nubes (обязателен если пресет не выбран)</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Company ID</label>
|
||||
<input type="text" name="companyId" placeholder="uuid или пусто" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Company Name</label>
|
||||
<input type="text" name="companyName" placeholder="Название компании" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Email</label>
|
||||
<input type="email" name="email" placeholder="test@example.com" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="check-row">
|
||||
<input type="checkbox" name="isAdmin" id="isAdmin">
|
||||
<label for="isAdmin">Администратор (isAdmin = true)</label>
|
||||
</div>
|
||||
|
||||
<button class="btn" type="submit">Войти (dev)</button>
|
||||
</form>
|
||||
|
||||
<hr>
|
||||
<p style="font-size:.78rem;color:var(--muted);text-align:center">
|
||||
<a href="/login" style="color:inherit">← Обычный вход</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Если выбран пресет — очищаем кастомные поля (чтобы не путаться).
|
||||
// Если пресет = "", кастомные поля активны.
|
||||
document.getElementById('preset-select').addEventListener('change', function() {
|
||||
const custom = ['clientId','companyId','companyName','email'];
|
||||
const clear = !!this.value;
|
||||
custom.forEach(id => {
|
||||
const el = document.querySelector('[name=' + id + ']');
|
||||
if (el) { el.disabled = clear; if (clear) el.value = ''; }
|
||||
});
|
||||
const chk = document.getElementById('isAdmin');
|
||||
if (chk) { chk.disabled = clear; if (clear) chk.checked = false; }
|
||||
});
|
||||
</script>
|
||||
|
||||
<footer style="text-align:right;padding:.5rem 1.5rem;font-size:.72rem;color:var(--muted);">v<%= appVersion %></footer>
|
||||
</body>
|
||||
</html>
|
||||
-409
@@ -1,409 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Белые списки IP — Nubes</title>
|
||||
<link rel="icon" href="/favicon.png" type="image/png">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f5f5;
|
||||
--card: #ffffff;
|
||||
--text: #1a1a1a;
|
||||
--muted: #6b7280;
|
||||
--border: #d1d5db;
|
||||
--grey-light: #f3f4f6;
|
||||
--blue: #2563eb;
|
||||
--blue-h: #1d4ed8;
|
||||
--red: #dc2626;
|
||||
--red-h: #b91c1c;
|
||||
--green: #16a34a;
|
||||
--amber: #d97706;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.page { max-width: 1100px; margin: 1.5rem auto; padding: 0 1rem; }
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.04);
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-header {
|
||||
background: var(--grey-light);
|
||||
padding: .75rem 1rem;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.card-body { padding: 1rem; }
|
||||
|
||||
/* Alerts */
|
||||
.alert { padding: .75rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: .9rem; }
|
||||
.alert-ok { background: #dcfce7; color: #166534; border: 1px solid #bbf7d0; }
|
||||
.alert-err { background: #fecaca; color: #991b1b; border: 1px solid #fca5a5; }
|
||||
.alert-warn{ background: #fef3c7; color: #92400e; border: 1px solid #fde68a; }
|
||||
|
||||
/* Stats row */
|
||||
.stats { display: flex; gap: 2rem; }
|
||||
.stat { text-align: center; }
|
||||
.stat .num { font-size: 1.6rem; font-weight: 700; }
|
||||
.stat .lbl { font-size: .8rem; color: var(--muted); }
|
||||
.stat.full .num { color: var(--red); }
|
||||
|
||||
/* Form */
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: .75rem 1rem;
|
||||
align-items: end;
|
||||
}
|
||||
.field { display: flex; flex-direction: column; gap: .25rem; }
|
||||
.field label {
|
||||
font-size: .8rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
}
|
||||
.field input, .field textarea {
|
||||
padding: .5rem .75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: .9rem;
|
||||
outline: none;
|
||||
transition: border .15s;
|
||||
background: #fff;
|
||||
}
|
||||
.field input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .35rem;
|
||||
padding: .5rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: .85rem;
|
||||
font-weight: 500;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:hover { background: var(--grey-light); }
|
||||
.btn-primary { background: var(--blue); color: #fff; border-color: var(--blue); }
|
||||
.btn-primary:hover { background: var(--blue-h); }
|
||||
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; border-color: #93c5fd; }
|
||||
.btn-danger { color: var(--red); border-color: var(--red); }
|
||||
.btn-danger:hover { background: #fecaca; }
|
||||
|
||||
/* Table */
|
||||
.table-wrap {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; font-size: .85rem; }
|
||||
th {
|
||||
text-align: left;
|
||||
padding: .5rem .75rem;
|
||||
background: var(--grey-light);
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
font-size: .8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
th:last-child { border-right: none; }
|
||||
td {
|
||||
padding: .5rem .75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
td:last-child { border-right: none; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: #f8fafc; }
|
||||
code { background: #f1f5f9; padding: .15rem .4rem; border-radius: 3px; font-size: .9em; }
|
||||
.empty { text-align: center; color: var(--muted); padding: 2rem; }
|
||||
|
||||
.text-center { text-align: center; }
|
||||
.mt-3 { margin-top: 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header style="background:#fff;border-bottom:1px solid var(--border);padding:0 1.5rem;height:48px;display:flex;align-items:center;gap:.75rem;font-size:.9rem;color:var(--muted);">
|
||||
<svg width="130" height="28" viewBox="0 0 311 69" style="display:block;">
|
||||
<g transform="translate(-330 -228)">
|
||||
<path d="M348.061 246.203 332.42 246.203 330 256.661 336.75 256.661 336.794 295.573 336.794 296.585 336.795 296.585 347.25 296.585 347.25 263.611C347.25 259.78 350.368 256.663 354.2 256.663L376.489 256.793C380.322 256.793 383.44 259.912 383.44 263.743L383.44 296.717 393.896 296.717 393.896 263.743C393.896 254.144 386.087 246.338 376.495 246.338L354.196 246.204 348.061 246.221 348.061 246.203Z" fill="#001C34" fill-rule="evenodd"/>
|
||||
<path d="M582.095 296.65 626.768 296.65C634.793 296.65 641 290.03 641 282.009 641 273.985 634.473 267.458 626.449 267.458L601.724 267.458C599.446 267.458 597.595 265.607 597.595 263.328L597.595 260.825C597.595 258.548 599.446 256.697 601.724 256.697L628.041 256.697 630.452 246.276 601.724 246.276C593.7 246.276 587.174 252.802 587.174 260.825L587.174 263.328C587.174 271.352 593.7 277.879 601.724 277.879L626.449 277.879C628.726 277.879 630.579 279.732 630.579 282.009 630.579 284.283 628.726 286.4 626.449 286.4L584.126 286.4 582.095 296.65Z" fill="#001C34" fill-rule="evenodd"/>
|
||||
<path d="M564.549 246.347 537.193 246.347C529.027 246.347 522.385 252.989 522.385 261.156L522.385 279.521C522.385 288.916 530.028 296.557 539.422 296.557L576.846 296.557 579.196 286.401 539.422 286.401C535.775 286.401 532.805 283.172 532.805 279.521L532.748 278.028 567.684 278.028 579.354 277.959 579.354 268.958 579.354 261.156C579.354 252.989 572.712 246.347 564.549 246.347ZM568.935 267.54 532.805 267.54 532.805 261.156C532.805 258.737 534.774 256.767 537.193 256.767L564.549 256.767C566.966 256.767 568.935 258.737 568.935 261.156L568.935 267.54Z" fill="#001C34" fill-rule="evenodd"/>
|
||||
<path d="M507.156 273.231 507.156 279.597C507.156 283.414 504.066 286.344 500.247 286.344L477.934 286.39C474.41 286.39 471.493 283.744 471.064 280.335L471.064 263.006C471.494 259.597 474.41 256.951 477.934 256.951L500.23 257.083C504.049 257.083 507.156 260.192 507.156 264.009L507.156 265.16 507.156 273.232ZM460.623 260.161 460.589 230.089 471.064 228.058 471.064 247.957C473.172 247.043 475.49 246.53 477.929 246.53L500.235 246.662C509.793 246.662 517.575 254.444 517.575 264.009L517.575 265.159 517.575 273.231 517.575 279.597C517.575 289.161 509.793 296.941 500.235 296.941L477.929 296.809C468.37 296.809 460.589 289.029 460.589 279.464L460.589 272.972 460.589 263.876 460.623 260.161Z" fill="#001C34" fill-rule="evenodd"/>
|
||||
<path d="M445.037 271.59 445.037 279.317C445.037 283.134 441.93 286.241 438.111 286.241L416.092 286.11C412.273 286.11 409.166 283.002 409.166 279.185L409.166 276.635 409.222 276.644 409.222 244.013 398.748 246.044 398.752 249.955 398.748 249.957 398.748 279.184C398.748 288.749 406.529 296.529 416.087 296.529L438.116 296.661C447.674 296.661 455.456 288.881 455.456 279.316L455.456 276.639 455.482 276.643 455.482 244.013 445.008 246.043 445.037 271.589Z" fill="#001C34" fill-rule="evenodd"/>
|
||||
</g>
|
||||
</svg>
|
||||
<span>|</span>
|
||||
<span style="font-weight:500;color:var(--text);">Белые списки IP</span>
|
||||
<!-- Кнопки правой части header: admin-ссылки + выход -->
|
||||
<span style="margin-left:auto;display:flex;gap:.5rem;align-items:center;">
|
||||
<% if (isAdmin) { %>
|
||||
<a href="/audit" style="font-size:.8rem;color:var(--muted);text-decoration:none;padding:.2rem .6rem;border:1px solid var(--border);border-radius:5px;background:#fff;">Аудит</a>
|
||||
<a href="/admin" style="font-size:.8rem;color:var(--muted);text-decoration:none;padding:.2rem .6rem;border:1px solid var(--border);border-radius:5px;background:#fff;">Лимиты</a>
|
||||
<% } %>
|
||||
<a href="/logout" style="font-size:.8rem;color:var(--muted);text-decoration:none;padding:.2rem .6rem;border:1px solid var(--border);border-radius:5px;background:#fff;">Выйти (<%= user.email %>)</a>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<% if (message) { %><div class="alert <%= wasNormalized ? 'alert-warn' : 'alert-ok' %>"><%= message %></div><% } %>
|
||||
<% if (error) { %><div class="alert alert-err"><%= error %></div><% } %>
|
||||
|
||||
<%
|
||||
// Для admin: selectedCompany — выбранная компания (объект из getAllCompanies).
|
||||
// Для обычного пользователя: selectedCompany — их компания (из getOrCreateCompany).
|
||||
// company_id — числовой PK для передачи в скрытые поля форм.
|
||||
const companyPk = selectedCompany ? selectedCompany.id : null;
|
||||
%>
|
||||
|
||||
<!-- Переключатель компании — только для администратора -->
|
||||
<% if (isAdmin && companies) { %>
|
||||
<div class="card">
|
||||
<div class="card-body" style="padding:.75rem 1rem;">
|
||||
<form method="GET" action="/" style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;">
|
||||
<span style="font-size:.85rem;color:var(--muted);font-weight:500;">Компания:</span>
|
||||
<select name="company" onchange="this.form.submit()"
|
||||
style="padding:.4rem .75rem;border:1px solid var(--border);border-radius:6px;font-size:.85rem;background:#fff;">
|
||||
<% companies.forEach(c => { %>
|
||||
<option value="<%= c.id %>" <%= selectedCompany && c.id === selectedCompany.id ? 'selected' : '' %>>
|
||||
<%= c.name %> (<%= c.client_id %>) — <%= c.active_count %> зап.
|
||||
</option>
|
||||
<% }) %>
|
||||
</select>
|
||||
<!-- Быстрый доступ к admin-страницам прямо из главной -->
|
||||
<a href="/audit<%= selectedCompany ? '?company=' + selectedCompany.id : '' %>"
|
||||
style="font-size:.82rem;color:var(--muted);text-decoration:none;">→ Аудит этой компании</a>
|
||||
<a href="/admin"
|
||||
style="font-size:.82rem;color:var(--muted);text-decoration:none;">→ Лимиты</a>
|
||||
<span style="margin-left:auto;font-size:.8rem;color:var(--muted);">
|
||||
Режим: <strong style="color:var(--amber);">Администратор</strong>
|
||||
</span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<!-- Статистика -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="stats">
|
||||
<div class="stat <%= used >= limit ? 'full' : '' %>">
|
||||
<div class="num"><%= used %> / <%= limit %></div>
|
||||
<div class="lbl">записей</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<!-- Для admin показываем clientId выбранной компании, для user — их собственный -->
|
||||
<div class="num"><%= selectedCompany ? selectedCompany.client_id : user.clientId %></div>
|
||||
<div class="lbl">компания</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="num"><%= user.email %></div>
|
||||
<div class="lbl">пользователь</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Форма добавления -->
|
||||
<div class="card">
|
||||
<div class="card-header">Добавить адрес</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/add">
|
||||
<%
|
||||
// Передаём числовой PK компании в скрытом поле.
|
||||
// Для admin: server.js возьмёт его из req.body.company_id.
|
||||
// Для обычного пользователя: server.js игнорирует это поле
|
||||
// и использует clientId из токена — защита от подмены.
|
||||
%>
|
||||
<input type="hidden" name="company_id" value="<%= companyPk || '' %>">
|
||||
<%# CSRF-токен: защита от межсайтовой подделки запроса (double-submit cookie) %>
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label>IPv4 адрес или подсеть CIDR</label>
|
||||
<input name="value" placeholder="Например: 203.0.113.10 или 203.0.113.0/24" pattern="^(\d{1,3}\.){3}\d{1,3}(/\d{1,2})?$" maxlength="18" title="IPv4 адрес или CIDR /22–/32" required <%= used >= limit ? 'disabled' : '' %>>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Комментарий</label>
|
||||
<input name="comment" placeholder="Необязательно" maxlength="255">
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit" <%= used >= limit ? 'disabled' : '' %> style="align-self:end">
|
||||
<%= used >= limit ? 'Лимит исчерпан' : 'Добавить' %>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Таблица -->
|
||||
<div class="card">
|
||||
<div class="card-header">Доверенные адреса</div>
|
||||
<% if (entries.length === 0) { %>
|
||||
<div class="empty">Нет добавленных адресов</div>
|
||||
<% } else { %>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Адрес / Подсеть</th>
|
||||
<th>Комментарий</th>
|
||||
<th>Добавил</th>
|
||||
<th>Дата</th>
|
||||
<th>Изменён</th>
|
||||
<th class="text-center">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% entries.forEach(e => { %>
|
||||
<tr>
|
||||
<td><code><%= e.value_cidr %></code></td>
|
||||
<td><%= e.comment || '—' %></td>
|
||||
<td><%= e.created_by %></td>
|
||||
<td><%= new Date(e.created_at).toLocaleDateString('ru', {day:'numeric',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit'}) %></td>
|
||||
<!-- Дата последнего редактирования (null если не менялась) -->
|
||||
<td style="color:var(--muted);font-size:.8rem;">
|
||||
<%= e.updated_at
|
||||
? new Date(e.updated_at).toLocaleDateString('ru', {day:'numeric',month:'short',hour:'2-digit',minute:'2-digit'})
|
||||
: '—' %>
|
||||
</td>
|
||||
<td class="text-center" style="white-space:nowrap;">
|
||||
<%
|
||||
// data-* атрибуты для JS-заполнения modal без inline-onclick с параметрами.
|
||||
// Так как комментарий может содержать кавычки — EJS экранирует их в HTML.
|
||||
// Но в data-атрибуте JSON::stringify безопаснее.
|
||||
%>
|
||||
<!-- Кнопка редактирования: открывает modal и заполняет его данными из data-* -->
|
||||
<button class="btn"
|
||||
data-id="<%= e.id %>"
|
||||
data-value="<%= e.value_cidr %>"
|
||||
data-comment="<%= e.comment || '' %>"
|
||||
data-company="<%= companyPk || '' %>"
|
||||
onclick="openEditModal(this)"
|
||||
style="margin-right:.25rem;">
|
||||
Изменить
|
||||
</button>
|
||||
<!-- Форма удаления: company_id нужен для admin-ветки в server.js -->
|
||||
<form method="POST" action="/delete/<%= e.id %>"
|
||||
style="display:inline"
|
||||
onsubmit="return confirm('Удалить запись <%= e.value_cidr %>?')">
|
||||
<input type="hidden" name="company_id" value="<%= companyPk || '' %>">
|
||||
<%# CSRF-токен: одно значение на всю страницу, сгенерировано сервером %>
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
<button class="btn btn-danger">Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ══ Modal редактирования записи ══════════════════════════════════════════ -->
|
||||
<!--
|
||||
Модальное окно — фиксированный overlay поверх страницы.
|
||||
Открывается кнопкой "Изменить" в таблице (через JS openEditModal).
|
||||
Закрывается кнопкой "Отмена", кликом по backdrop или Escape.
|
||||
Данные заполняются из data-* атрибутов кнопки — никаких лишних fetch-запросов.
|
||||
-->
|
||||
<div id="edit-modal"
|
||||
style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;
|
||||
background:rgba(0,0,0,.4);z-index:200;align-items:center;justify-content:center;">
|
||||
<div style="background:#fff;border-radius:12px;padding:1.5rem;width:500px;
|
||||
max-width:92vw;box-shadow:0 8px 32px rgba(0,0,0,.18);">
|
||||
<h3 style="font-size:1rem;font-weight:600;margin-bottom:1.25rem;">Редактировать запись</h3>
|
||||
<form method="POST" id="edit-form">
|
||||
<!-- Скрытые поля: action (action='/edit/N') и company_id заполняет openEditModal() -->
|
||||
<input type="hidden" id="edit-company-id" name="company_id" value="">
|
||||
<%# CSRF-токен: значение рендерится сервером один раз для всей страницы %>
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
|
||||
<div class="field" style="margin-bottom:.75rem;">
|
||||
<label>IPv4 адрес или подсеть CIDR</label>
|
||||
<input id="edit-value" name="value"
|
||||
pattern="^(\d{1,3}\.){3}\d{1,3}(/\d{1,2})?$"
|
||||
maxlength="18"
|
||||
title="IPv4 адрес или CIDR /22–/32"
|
||||
required
|
||||
style="padding:.5rem .75rem;border:1px solid var(--border);border-radius:6px;font-size:.9rem;">
|
||||
</div>
|
||||
<div class="field" style="margin-bottom:1.25rem;">
|
||||
<label>Комментарий</label>
|
||||
<input id="edit-comment" name="comment"
|
||||
maxlength="255"
|
||||
placeholder="Необязательно"
|
||||
style="padding:.5rem .75rem;border:1px solid var(--border);border-radius:6px;font-size:.9rem;">
|
||||
</div>
|
||||
<div style="display:flex;gap:.75rem;justify-content:flex-end;">
|
||||
<button type="button" class="btn" onclick="closeEditModal()">Отмена</button>
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* Открыть modal редактирования.
|
||||
* Вызывается из onclick кнопки "Изменить" в таблице.
|
||||
* @param {HTMLElement} btn — кнопка с data-id, data-value, data-comment, data-company
|
||||
*/
|
||||
function openEditModal(btn) {
|
||||
// Заполняем поля из data-атрибутов (EJS уже HTML-экранировал значения)
|
||||
document.getElementById('edit-value').value = btn.dataset.value;
|
||||
document.getElementById('edit-comment').value = btn.dataset.comment;
|
||||
document.getElementById('edit-company-id').value = btn.dataset.company;
|
||||
// Устанавливаем action формы на конкретный /edit/:id
|
||||
document.getElementById('edit-form').action = '/edit/' + btn.dataset.id;
|
||||
// Показываем modal (display:flex для центрирования)
|
||||
const modal = document.getElementById('edit-modal');
|
||||
modal.style.display = 'flex';
|
||||
// Фокус на поле ввода для удобства клавиатурных пользователей
|
||||
setTimeout(() => document.getElementById('edit-value').focus(), 50);
|
||||
}
|
||||
|
||||
/** Закрыть modal без сохранения */
|
||||
function closeEditModal() {
|
||||
document.getElementById('edit-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
// Закрытие кликом по backdrop (за пределами карточки)
|
||||
document.getElementById('edit-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeEditModal();
|
||||
});
|
||||
|
||||
// Закрытие клавишей Escape
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') closeEditModal();
|
||||
});
|
||||
</script>
|
||||
|
||||
<footer style="text-align:right;padding:.5rem 1.5rem;font-size:.72rem;color:var(--muted);">v<%= appVersion %></footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,85 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Вход — IP WhiteList</title>
|
||||
<link rel="icon" href="/favicon.png" type="image/png">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f5f5; --card: #ffffff; --text: #1a1a1a; --muted: #6b7280;
|
||||
--border: #d1d5db; --grey-light: #f3f4f6; --blue: #2563eb; --blue-h: #1d4ed8;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5;
|
||||
display: flex; align-items: center; justify-content: center; min-height: 100vh;
|
||||
}
|
||||
.login-card {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.06); width: 380px; padding: 2rem;
|
||||
}
|
||||
h1 { font-size: 1.2rem; margin-bottom: .25rem; }
|
||||
.sub { color: var(--muted); font-size: .85rem; margin-bottom: 1.5rem; }
|
||||
.field { display: flex; flex-direction: column; gap: .25rem; margin-bottom: 1rem; }
|
||||
.field label { font-size: .8rem; font-weight: 500; color: var(--muted); text-transform: uppercase; }
|
||||
.field select, .field input {
|
||||
padding: .5rem .75rem; border: 1px solid var(--border); border-radius: 6px;
|
||||
font-size: .9rem; outline: none; background: #fff;
|
||||
}
|
||||
.field select:focus, .field input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
|
||||
.btn {
|
||||
width: 100%; padding: .6rem; border: none; border-radius: 6px; font-size: .9rem;
|
||||
font-weight: 500; cursor: pointer; background: var(--blue); color: #fff;
|
||||
transition: background .15s;
|
||||
}
|
||||
.btn:hover { background: var(--blue-h); }
|
||||
.badge {
|
||||
display: inline-block; padding: .15rem .5rem; border-radius: 4px;
|
||||
font-size: .7rem; font-weight: 600; text-transform: uppercase;
|
||||
}
|
||||
.badge-admin { background: #fef3c7; color: #92400e; }
|
||||
.badge-user { background: #dcfce7; color: #166534; }
|
||||
.note {
|
||||
margin-top: 1.5rem; padding: .75rem; background: #fef3c7; border-radius: 8px;
|
||||
font-size: .8rem; color: #92400e;
|
||||
}
|
||||
.error { color: #dc2626; font-size: .85rem; margin-bottom: 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="login-card">
|
||||
<h1>Вход в IP WhiteList</h1>
|
||||
<p class="sub">Мок-аутентификация — имитация Keycloak</p>
|
||||
|
||||
<% if (error) { %><div class="error"><%= error %></div><% } %>
|
||||
|
||||
<form method="POST" action="/login">
|
||||
<%# CSRF-токен: защита от CSRF-атаки на форму входа %>
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
<div class="field">
|
||||
<label>Пользователь</label>
|
||||
<select name="user">
|
||||
<% users.forEach(u => { %>
|
||||
<option value="<%= u.id %>">
|
||||
<%= u.label %> <%= u.role === 'admin' ? '(admin)' : '' %>
|
||||
</option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn" type="submit">Войти</button>
|
||||
</form>
|
||||
|
||||
<div class="note">
|
||||
В реальном продакшене здесь будет редирект на Keycloak.<br>
|
||||
После входа пользователь сохраняется в session middleware.
|
||||
<% if (devLoginUrl) { %>
|
||||
<br><a href="<%= devLoginUrl %>" style="color:inherit">Тестовый вход (dev-backdoor) →</a>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer style="text-align:right;padding:.5rem 1.5rem;font-size:.72rem;color:var(--muted);">v<%= appVersion %></footer>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user