Author SHA1 Message Date
naeel 93b58f85d0 v0.6.20: отладка EXTRA_COMPANIES 2026-06-15 11:19:58 +04:00
naeel 536a02c251 v0.6.19: шаблон читает req.session.user (с имперсонацией), не req.user 2026-06-15 11:18:08 +04:00
naeel 38cac02c90 v0.6.18: авто-имперсонация выставляет isAdmin + adminMode 2026-06-15 11:11:25 +04:00
naeel 5507d11608 v0.6.17: отладка ADMIN_EMAIL в ошибке 2026-06-15 11:10:59 +04:00
naeel b2480b8aac v0.6.16: req.clientId = activeClientId — без условий, CRUD не знает про имперсонацию 2026-06-15 11:09:44 +04:00
naeel 9854932f50 v0.6.15: фикс — switch компаний при имперсонации 2026-06-15 11:06:48 +04:00
naeel b4a3aa4e63 v0.6.14: расширенная отладка req.clientId (тип, длина) 2026-06-15 11:06:22 +04:00
naeel 3c11c20680 v2: документирование IAM API — clientID, profiles, impersonation 2026-06-15 11:01:13 +04:00
naeel a1f8e5a71a v0.6.13: отладка — показ email/clientId/env в ошибке 2026-06-15 10:55:13 +04:00
naeel 7a4dd6f3e5 v0.6.12: полный санитайзер IAM в resolveContext — защита от null/undefined/говна 2026-06-15 10:46:31 +04:00
naeel 2cf86a22de v0.6.11: guard на ВСЕ обязательные поля DB — companyId, cidr, userEmail, entryId 2026-06-15 10:45:21 +04:00
naeel b3d0fc36a6 v0.6.10: проверка clientId в getOrCreateCompany (3-й уровень) 2026-06-15 10:43:19 +04:00
naeel 00d3928a50 v0.6.9: защита от пустого clientId (null client_id) 2026-06-15 10:35:03 +04:00
naeel 3d3354c49e v0.6.8: Выйти — только реальный KC-юзер 2026-06-15 10:29:31 +04:00
naeel 68f230bedd v0.6.7: фикс — req.email=текущий, impersonatedBy=реальный (правильно) 2026-06-15 10:28:06 +04:00
naeel f007b4e721 v0.6.6: разделение — req.email(аудит) vs req.displayEmail(экран) 2026-06-15 10:23:04 +04:00
naeel 1c2b7fe189 v0.6.5: loginEmail из originalUserEmail, компания слева от email 2026-06-15 10:19:07 +04:00
naeel 6032a574a9 v0.6.4: авто-имперсонация (ORIGINAL→TARGET) + ADMIN_EMAIL 2026-06-15 10:14:16 +04:00
naeel ed58df38d8 v0.6.3: убрал (имперсонация) у email, жёлтый баннер сверху 2026-06-15 09:57:35 +04:00
naeel 9f7150c345 v0.6.2: email юзера справа от статистики + loginEmail в header 2026-06-15 09:55:35 +04:00
naeel 6acb8770c5 v0.6.1: фикс — email в header (Выйти) 2026-06-15 09:46:58 +04:00
naeel 0d7815df16 v0.6.0: дизайн Nubes — лого, header, стили, email, имперсонация 2026-06-15 09:42:33 +04:00
naeel 5b92dfe1e7 v2→main: интеграция — v2 как основной UI (/), старый UI закомментирован 2026-06-15 09:33:45 +04:00
15 changed files with 478 additions and 129 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ipwhitelist",
"version": "0.5.98",
"version": "0.6.20",
"description": "IP WhiteList microservice for cloud provider",
"main": "server.js",
"scripts": {
+3 -2
View File
@@ -137,9 +137,10 @@ async function start() {
// Монтируется ПОСЛЕ /api/v1/ — не перехватывает API-запросы.
// ── V2 — тестовый роутер (Keycloak → IAM → вывод) ─────────────────
const { createV2Router } = require('./v2/server');
app.use('/v2', createV2Router());
app.use('/', createV2Router());
// Хранит Bearer token в сессии, рендерит те же views/*.ejs.
app.use('/', createUiRouter({ auth, MOCK_USERS, authLimiter }));
// ── Старый UI (заменён на v2) ────────────────────────────────────────
// app.use('/', createUiRouter({ auth, MOCK_USERS, authLimiter }));
// ── Общий обработчик ошибок ───────────────────────────────────────────────
// eslint-disable-next-line no-unused-vars
+170
View File
@@ -832,3 +832,173 @@ express HTTP
| 1 | Интеграция в основной код | ✅ 0.5.98 |
| 2 | Auth (KC callback) | ⏸ заблокирован облаком |
| 3 | Убрать старые v2_* таблицы из БД | ⏸ после деплоя |
---
## Интеграция v2 в основной проект (0.6.00.6.8, 2026-06-15)
### Что сделано
| Версия | Что |
|--------|-----|
| 0.5.98 | Таблицы `v2_*``companies`, `whitelist_entries`, `audit_log` |
| 0.5.98 | `impersonation/` модуль — добавление компаний через ENV |
| 0.5.99 | V2 как основной UI: `app.use('/', createV2Router())`, старый UI закомментирован |
| 0.6.0 | Дизайн Nubes: лого, header, стили из `views/index.ejs` |
| 0.6.1 | Фикс: email в header |
| 0.6.2 | `displayEmail` справа от статистики |
| 0.6.3 | Жёлтый баннер имперсонации вместо метки `(имперсонация)` |
| 0.6.4 | Авто-имперсонация: `IMPERSONATION_ORIGINAL → TARGET` |
| 0.6.4 | `ADMIN_EMAIL` в `resolveContext` |
| 0.6.5 | `loginEmail` из `originalUserEmail` |
| 0.6.6 | `req.displayEmail` — разделение экран/аудит |
| 0.6.7 | **Перепутаны местами**: `req.email` и `req.impersonatedBy` |
| 0.6.8 | «Выйти» — только реальный KC-юзер |
### Ключевая ошибка: перепутаны `req.email` и `req.impersonatedBy`
**Что было неправильно (0.6.6 и ранее):**
```js
req.email = реальный created_by = ntazetdinov@nubes.ru
req.impersonatedBy = имперсонированный impersonated_by = tazet@narod.ru
```
**Почему ошибался:** AI думал что `req.email` должно хранить реального юзера «для аудита»,
а имперсонированного — для показа. Это неверно. Везде должен использоваться ТЕКУЩИЙ
юзер (имперсонированный если есть имперсонация), а реальный — ТОЛЬКО в колонке
`impersonated_by` таблицы аудита.
**Как правильно (0.6.7+):**
```js
req.email = ТЕКУЩИЙ created_by = tazet@narod.ru
req.impersonatedBy = РЕАЛЬНЫЙ impersonated_by = ntazetdinov@...
```
**Урок:** `req.email` — это ВСЕГДА текущий юзер. Имперсонация не меняет его смысл —
она меняет значение `session.user.email`, и `req.email` просто берёт его.
### Правильная логика (0.6.8)
```
KC логин: ntazetdinov@nubes.ru
session.user.email = ntazetdinov@nubes.ru
enhanceImpersonation: ORIGINAL совпал → авто-имперсонация
session.user.originalUserEmail = ntazetdinov@nubes.ru (реальный)
session.user.email = tazet@narod.ru (текущий)
resolveContext:
req.email = u.email = tazet@narod.ru (текущий — created_by, экран)
req.impersonatedBy = u.originalUserEmail (реальный — только аудит)
req.isImpersonated = true
user/index.js → шаблон:
loginEmail = ntazetdinov@nubes.ru → «Выйти»
email = tazet@narod.ru → контент, статистика
```
### Переменные ENV для авто-имперсонации
| Переменная | Значение | Роль |
|-----------|---------|------|
| `IMPERSONATION_ORIGINAL` | `ntazetdinov@nubes.ru` | Условие: чей email сравнить |
| `IMPERSONATION_TARGET` | `tazet@narod.ru` | Цель: в кого имперсонировать |
| `IMPERSONATION_COMPANY` | `WZ01325` | Компания: какая компания |
| `IMPERSONATION_EXTRA_COMPANIES` | `WZ01112` | Доп. компании |
| `ADMIN_EMAIL` | `tazet@narod.ru` | Псевдо-админ |
Все три (`ORIGINAL`, `TARGET`, `COMPANY`) должны быть заданы — иначе авто-имперсонация не включается.
---
## IAM API — документация (2026-06-15)
### Источник: https://auth-api-dev.ngcloud.ru/api/v1/documentation/
### GET /api/v1/auth/user — ответ (AuthUserResult)
```json
{
"userId": "uuid",
"owner": false,
"isPortal": false,
"sessionId": "uuid",
"privileges": ["system"],
"needChangePassword": false,
"impersonation": {
"is_impersonated": false, // ВСЕГДА есть
"type": "user" | "company", // если активна
"originalUserEmail": "admin@...", // кто имперсонирует
"impersonatedCompanyId": "uuid", // если type=company
"impersonatedUserId": "uuid", // если type=user
"sessionId": 42,
"session_expires_at": "RFC3339"
},
"permissions": {
"read_only_mode_enabled": false,
"can_write": true,
"is_impersonating": false,
"has_write_permissions": true
},
"userInfo": {
"email": "user@example.com", // required
"login": "login",
"clientID": "WZ01325", // required (может быть "")
"company": "ООО Пример",
"companyId": "uuid",
"companyNumericId": 1,
"isAdmin": false, // required
"fio": { "fullName": "...", "name": "...", "surname": "...", "secondName": "..." },
"profiles": [ // массив, может быть пустым
{
"id": 123, // required
"company_id": "uuid", // required
"company_name": "ООО Пример", // required
"client_id": "WZ01325", // НЕ required — может отсутствовать!
"is_active_profile": true // required
}
]
}
}
```
### Ключевые факты
| Поле | Обязательное? | Может быть пустым? |
|------|-------------|-------------------|
| `userInfo.email` | ✅ required | — |
| `userInfo.clientID` | ✅ required | ⚠️ может быть `""` |
| `userInfo.isAdmin` | ✅ required | — |
| `userInfo.profiles` | массив | может быть `[]` |
| `profiles[].client_id` | ❌ не required | может отсутствовать |
| `profiles[].company_name` | ✅ required | — |
| `impersonation.is_impersonated` | ✅ required | — |
### Выводы для нашего кода
1. **`clientID` required, но может быть `""`** — для сотрудников Nubes без компании-клиента.
Именно это происходит с `ntazetdinov@nubes.ru`.
2. **`profiles` может быть пустым `[]`** — нет компаний → нет client_id.
3. **IAM имеет нативную имперсонацию**`POST /api/v1/impersonation/start`.
Но доступно только админам IAM. `ntazetdinov@nubes.ru` — не админ IAM.
4. **Наш `enhanceImpersonation`** — нужен как обходной путь: ENV-переменные вместо IAM-админки.
### Что должно происходить
```
ntazetdinov@nubes.ru логинится через KC
→ IAM: { email: "ntazetdinov@nubes.ru", clientID: "", profiles: [], isAdmin: false }
→ fetchIamUser: clientId = ""
→ сессия без clientId
→ enhanceImpersonation: ORIGINAL совпал → авто-имперсонация
email = "tazet@narod.ru"
clientId = "WZ01325"
allClientIds = ["WZ01325", "WZ01112"]
→ resolveContext: req.clientId = "WZ01325" ✅
```
+13 -13
View File
@@ -5,7 +5,7 @@
// ЗАЧЕМ: единственная точка входа v2 в основное приложение.
//
// МОНТАЖ В ОСНОВНОМ server.js:
// const { createV2Router } = require('./v2/server');
// const { createV2Router } = require('./server');
// app.use('/v2', createV2Router());
//
// ПОРЯДОК МАРШРУТОВ (важен!):
@@ -38,39 +38,39 @@ function createV2Router() {
const router = express.Router();
// ── GET /v2/login — редирект на основной вход (KC OIDC) ─────────────
// ── GET /login-v2 — редирект на основной вход (KC OIDC) ─────────────
router.get('/login', (req, res) => {
res.redirect('/login?returnTo=' + encodeURIComponent('/v2/app'));
res.redirect('/login?returnTo=' + encodeURIComponent('/app'));
});
// ── GET /v2/iam — отладка: показать IAM-данные из сессии ────────────
// ── GET /iam — отладка: показать IAM-данные из сессии ────────────
router.get('/iam', (req, res) => {
const u = req.session && req.session.user;
if (!u) return res.send('<h2>Нет данных</h2><p><a href="/v2/login">Войти</a></p>');
if (!u) return res.send('<h2>Нет данных</h2><p><a href="/login-v2">Войти</a></p>');
res.send(debugPage(u, config.version));
});
// ── /v2/app — пользовательский CRUD (только с сессией) ──────────────
// ── /app — пользовательский CRUD (только с сессией) ──────────────
router.use('/app', enhanceImpersonation, resolveContext, createUserRouter());
// ── /v2/admin — админка (только для isAdmin=true) ────────────────────
// ── /admin — админка (только для isAdmin=true) ────────────────────
router.use('/admin', enhanceImpersonation, resolveContext, createAdminRouter());
// ── GET /v2/ — редирект на /v2/app ──────────────────────────────────
router.get('/', (req, res) => res.redirect('/v2/app'));
// ── GET / — редирект на /app ──────────────────────────────────
router.get('/', (req, res) => res.redirect('/app'));
// ── /v2/test — тестовый API (отключить: ENABLE_TEST_API=false) ──────
// ── /test — тестовый API (отключить: ENABLE_TEST_API=false) ──────
// Код остаётся, роутер не монтируется при ENABLE_TEST_API=false.
if (process.env.ENABLE_TEST_API !== 'false') {
const { createTestRouter } = require('./src/test');
router.use('/test', createTestRouter());
}
// ── /v2/export — выгрузка CIDR (публичный, без авторизации) ──────
// ── /export — выгрузка CIDR (публичный, без авторизации) ──────
const { createExportRouter } = require('./src/export');
router.use('/export', createExportRouter());
// ── GET /v2/logout — редирект на основной выход ─────────────────────
// ── GET /logout — редирект на основной выход ─────────────────────
router.get('/logout', (req, res) => res.redirect('/logout'));
return router;
@@ -88,7 +88,7 @@ function debugPage(u, version) {
<p><span class="label">ФИО:</span> ${(u.fio && u.fio.fullName) || u.fio || '—'}</p>
<p><span class="label">Компаний:</span> ${u.allClientIds ? u.allClientIds.length : 0}${(u.allClientIds || []).join(', ')}</p>
<hr><pre>${JSON.stringify(u, null, 2)}</pre>
<p><a href="/v2/logout">Выйти</a> | <a href="/v2/app">CRUD</a> | <a href="/v2/admin">Админка</a></p>
<p><a href="/logout">Выйти</a> | <a href="/app">CRUD</a> | <a href="/admin">Админка</a></p>
</body></html>`;
}
+7 -7
View File
@@ -5,9 +5,9 @@
// НЕ: доступно обычным юзерам (middleware: req.isAdmin).
//
// ШАБЛОНЫ:
// views/v2/admin.ejs — дашборд (companies, user, version)
// views/v2/admin-audit.ejs — аудит (audit, company, version)
// views/v2/admin-entries.ejs — записи (entries, company, used, limit, ...)
// views/admin.ejs — дашборд (companies, user, version)
// views/admin-audit.ejs — аудит (audit, company, version)
// views/admin-entries.ejs — записи (entries, company, used, limit, ...)
// ═══════════════════════════════════════════════════════════════════════════════
const express = require('express');
@@ -19,7 +19,7 @@ function createAdminRouter() {
const router = express.Router();
router.use((req, res, next) => {
if (!req.isAdmin) return res.redirect('/v2/app');
if (!req.isAdmin) return res.redirect('/app');
next();
});
@@ -52,9 +52,9 @@ function createAdminRouter() {
const newLimit = parseInt(req.body.limit);
if (!companyId || !newLimit || newLimit < 1) throw new Error('Неверные параметры');
await q.setLimit(companyId, newLimit);
res.redirect('/v2/admin?msg=' + encodeURIComponent('Лимит установлен: ' + newLimit));
res.redirect('/admin?msg=' + encodeURIComponent('Лимит установлен: ' + newLimit));
} catch (e) {
res.redirect('/v2/admin?error=' + encodeURIComponent(e.message));
res.redirect('/admin?error=' + encodeURIComponent(e.message));
}
});
@@ -63,7 +63,7 @@ function createAdminRouter() {
try {
const companyId = parseInt(req.query.companyId);
const company = companyId ? await q.getCompanyById(companyId) : null;
if (!company) return res.redirect('/v2/admin');
if (!company) return res.redirect('/admin');
const includeDeleted = req.query.deleted === '1';
const { entries, used, limit } = await crud.list(company.client_id, includeDeleted);
res.render('v2/admin-entries', { entries, company, used, limit, includeDeleted, version: config.version });
+1 -1
View File
@@ -9,7 +9,7 @@
// ═══════════════════════════════════════════════════════════════════════════════
module.exports = {
version: '0.5.98',
version: '0.6.20',
// ── IAM ──────────────────────────────────────────────────────────────────
iamUrl: process.env.V2_IAM_URL || 'https://auth-api.ngcloud.ru/api/v1/auth/user',
+1
View File
@@ -31,6 +31,7 @@ const { validate } = require('../validators');
// Возвращает { id, client_id, name, custom_limit }.
// Вызывается из list/add/edit/remove перед каждой операцией.
async function resolve(clientId) {
if (!clientId || !clientId.trim()) throw new Error('Ошибка: не определён clientId (W-номер компании). Обратитесь к администратору.');
return q.getOrCreateCompany(clientId, clientId);
}
+16
View File
@@ -30,6 +30,7 @@ const { overlaps } = require('../validators');
* Атомарный upsert — исключает дубли при параллельных запросах.
*/
async function getOrCreateCompany(clientId, companyName) {
if (!clientId || !String(clientId).trim()) throw new Error('DB: clientId не может быть пустым');
const res = await pool.query(
`INSERT INTO companies (client_id, name) VALUES ($1, $2)
ON CONFLICT (client_id) DO UPDATE SET name = COALESCE(companies.name, EXCLUDED.name)
@@ -66,6 +67,9 @@ async function listEntries(companyId, includeDeleted = false) {
* Создать запись (с валидацией, проверкой лимита и пересечений).
*/
async function createEntry(companyId, cidr, comment, userEmail, impersonatedBy, wasNormalized = false) {
if (!companyId) throw new Error('DB: companyId обязателен');
if (!cidr) throw new Error('DB: cidr обязателен');
if (!userEmail) throw new Error('DB: userEmail обязателен');
const client = await pool.connect();
try {
await client.query('BEGIN');
@@ -112,6 +116,10 @@ async function createEntry(companyId, cidr, comment, userEmail, impersonatedBy,
* Обновить запись.
*/
async function updateEntry(entryId, companyId, cidr, comment, userEmail, impersonatedBy, wasNormalized = false) {
if (!entryId) throw new Error('DB: entryId обязателен');
if (!companyId) throw new Error('DB: companyId обязателен');
if (!cidr) throw new Error('DB: cidr обязателен');
if (!userEmail) throw new Error('DB: userEmail обязателен');
const client = await pool.connect();
try {
await client.query('BEGIN');
@@ -153,6 +161,9 @@ async function updateEntry(entryId, companyId, cidr, comment, userEmail, imperso
* Удалить запись (soft delete).
*/
async function deleteEntry(entryId, companyId, userEmail, impersonatedBy) {
if (!entryId) throw new Error('DB: entryId обязателен');
if (!companyId) throw new Error('DB: companyId обязателен');
if (!userEmail) throw new Error('DB: userEmail обязателен');
const client = await pool.connect();
try {
await client.query('BEGIN');
@@ -196,6 +207,8 @@ async function getAllCompanies() {
}
async function setLimit(companyId, newLimit) {
if (!companyId) throw new Error('DB: companyId обязателен');
if (newLimit == null || newLimit < 0) throw new Error('DB: newLimit должен быть >= 0');
const result = await pool.query(
'UPDATE companies SET custom_limit = $1, updated_at = NOW() WHERE id = $2',
[newLimit, companyId]
@@ -220,6 +233,9 @@ async function getExportCIDRs(companyId = null) {
// ── Аудит ─────────────────────────────────────────────────────────────────────
async function logAudit(userEmail, companyId, action, oldValue, newValue, entryId, impersonatedBy, db = pool) {
if (!userEmail) throw new Error('DB audit: userEmail обязателен');
if (!companyId) throw new Error('DB audit: companyId обязателен');
if (!action) throw new Error('DB audit: action обязателен');
await db.query(
`INSERT INTO audit_log (user_email, company_id, action, old_value, new_value, entry_id, impersonated_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
+39 -18
View File
@@ -1,32 +1,53 @@
// ═══════════════════════════════════════════════════════════════════════════════
// V2 — Расширение имперсонации (отдельный модуль)
// V2 — Авто-имперсонация (отдельный модуль)
//
// ЭТО: middleware — добавляет дополнительные компании имперсонируемому юзеру.
// НЕ: жёсткая логика в resolveContext или user/index.js.
// УСЛОВИЯ (все три должны быть заданы):
// IMPERSONATION_ORIGINAL = email юзера при входе (ntazetdinov@nubes.ru)
// IMPERSONATION_TARGET = в кого имперсонировать (tazet@narod.ru)
// IMPERSONATION_COMPANY = компания цели (WZ01325)
//
// ENV:
// IMPERSONATION_TARGET — email юзера, которому добавляем компании
// IMPERSONATION_EXTRA_COMPANIES — W-номера через запятую (WZ01112,WZ03709)
// ОПЦИОНАЛЬНО:
// IMPERSONATION_EXTRA_COMPANIES = доп. компании через запятую (WZ01112)
//
// ЧТО ДЕЛАЕТ:
// Когда юзер с email === ORIGINAL входит → сессия подменяется:
// email → TARGET
// clientId → COMPANY
// allClientIds → [COMPANY, EXTRA_COMPANIES]
// isImpersonated → true
// originalUserEmail → ORIGINAL
// ═══════════════════════════════════════════════════════════════════════════════
const TARGET_EMAIL = process.env.IMPERSONATION_TARGET || '';
const EXTRA_COMPANIES = (process.env.IMPERSONATION_EXTRA_COMPANIES || '')
const ORIGINAL = process.env.IMPERSONATION_ORIGINAL || '';
const TARGET = process.env.IMPERSONATION_TARGET || '';
const COMPANY = process.env.IMPERSONATION_COMPANY || '';
const EXTRA = (process.env.IMPERSONATION_EXTRA_COMPANIES || '')
.split(',').map(s => s.trim()).filter(Boolean);
// Все три обязательны
const ENABLED = ORIGINAL && TARGET && COMPANY;
function enhanceImpersonation(req, res, next) {
if (!TARGET_EMAIL) return next();
if (!ENABLED) return next();
const u = req.session && req.session.user;
if (!u || u.email !== TARGET_EMAIL) return next();
if (!u || u.email !== ORIGINAL) return next();
const current = u.allClientIds || [u.clientId || ''];
u.allClientIds = [...new Set([...current, ...EXTRA_COMPANIES])].filter(Boolean);
// Подмена сессии — авто-имперсонация
const allCids = [COMPANY, ...EXTRA];
u.originalUserEmail = ORIGINAL; // кто реально вошёл (KC)
u.email = TARGET; // в кого имперсонируем
u.clientId = COMPANY;
u.activeClientId = COMPANY;
u.allClientIds = allCids;
u.isImpersonated = true;
u.impersonatedCompanyId = COMPANY; // для resolveContext
u.isAdmin = true; // псевдо-админ
u.adminMode = true; // включён
u.companyName = COMPANY;
u.profiles = allCids.map(cid => ({ client_id: cid, company_name: cid }));
if (!u.profiles) u.profiles = [];
for (const cid of EXTRA_COMPANIES) {
if (!u.profiles.find(p => p.client_id === cid))
u.profiles.push({ client_id: cid, company_name: cid, is_active_profile: false });
}
next();
}
module.exports = { enhanceImpersonation, TARGET_EMAIL, EXTRA_COMPANIES };
module.exports = { enhanceImpersonation };
+44 -12
View File
@@ -30,21 +30,53 @@ function resolveContext(req, res, next) {
const u = req.session && req.session.user;
// Нет сессии — нет доступа
if (!u) return res.redirect('/v2/login');
if (!u) return res.redirect('/login');
// Имперсонация: админ действует от имени компании
// u.originalUserEmail — реальный админ
// u.impersonatedCompanyId — W-номер компании
const isImpersonated = !!(u.originalUserEmail && u.impersonatedCompanyId);
// ═══ САНИТАЙЗЕР IAM — всё что пришло может быть null/undefined/говном ═══
const s = (v) => (v == null || v === 'null' || v === 'undefined') ? '' : String(v).trim();
const arr = (v) => (Array.isArray(v) ? v : []);
req.email = isImpersonated ? u.originalUserEmail : (u.email || '');
req.clientId = isImpersonated ? u.impersonatedCompanyId : (u.activeClientId || u.clientId || '');
req.companyName = u.companyName || req.clientId;
req.isAdmin = !!(u.isAdmin && u.adminMode);
// Нормализуем ВСЕ поля из сессии
const email = s(u.email);
const clientId = s(u.clientId);
const activeClientId = s(u.activeClientId);
const originalUserEmail = s(u.originalUserEmail);
const impersonatedCompanyId = s(u.impersonatedCompanyId);
const companyName = s(u.companyName);
const allClientIds = arr(u.allClientIds).map(s).filter(Boolean);
const profiles = arr(u.profiles);
const isAdmin = !!u.isAdmin;
const adminMode = !!u.adminMode;
const isImpersonatedFlag = !!u.isImpersonated;
const ADMIN_EMAIL = (process.env.ADMIN_EMAIL || '').trim();
const isImpersonated = (!!originalUserEmail && !!impersonatedCompanyId) || isImpersonatedFlag;
// req.email = ТЕКУЩИЙ юзер (имперсонированный или реальный)
req.email = email;
req.clientId = activeClientId || clientId;
if (!req.clientId) return res.status(400).send(
'<h2>Ошибка</h2><p>Не удалось определить W-номер компании.</p>' +
'<p><b>req.clientId:</b> «' + String(req.clientId) + '» (type: ' + typeof req.clientId + ', len: ' + String(req.clientId).length + ')</p>' +
'<p><b>email (local):</b> ' + email + '</p>' +
'<p><b>clientId (local):</b> ' + clientId + '</p>' +
'<p><b>activeClientId (local):</b> ' + activeClientId + '</p>' +
'<p><b>isImpersonated:</b> ' + isImpersonated + '</p>' +
'<p><b>impersonatedCompanyId (local):</b> ' + impersonatedCompanyId + '</p>' +
'<p><b>IMPERSONATION_ORIGINAL env:</b> ' + (process.env.IMPERSONATION_ORIGINAL || 'не задан') + '</p>' +
'<p><b>IMPERSONATION_TARGET env:</b> ' + (process.env.IMPERSONATION_TARGET || 'не задан') + '</p>' +
'<p><b>IMPERSONATION_COMPANY env:</b> ' + (process.env.IMPERSONATION_COMPANY || 'не задан') + '</p>' +
'<p><b>ADMIN_EMAIL env:</b> ' + (process.env.ADMIN_EMAIL || 'не задан') + '</p>' +
'<p><b>IMPERSONATION_EXTRA_COMPANIES env:</b> ' + (process.env.IMPERSONATION_EXTRA_COMPANIES || 'не задан') + '</p>' +
'<p><b>isAdmin:</b> ' + req.isAdmin + '</p>' +
'<p><a href="/logout">Выйти</a></p>'
);
req.companyName = companyName || req.clientId;
req.isAdmin = (isAdmin && adminMode) || (!!ADMIN_EMAIL && email === ADMIN_EMAIL);
req.isImpersonated = isImpersonated;
req.impersonatedBy = isImpersonated ? u.email : null;
req.allClientIds = u.allClientIds || [req.clientId];
req.profiles = u.profiles || [];
req.impersonatedBy = isImpersonated ? originalUserEmail : null;
req.allClientIds = allClientIds.length ? allClientIds : [req.clientId];
req.profiles = profiles;
next();
}
+15 -9
View File
@@ -29,19 +29,25 @@ function createUserRouter() {
// Переключение компании
if (req.query.switchTo && allCompanies.find(c => c.client_id === req.query.switchTo)) {
req.session.user.activeClientId = req.query.switchTo;
return res.redirect('/v2/app');
return res.redirect('/app');
}
res.render('v2/user', {
entries, limit, used,
companies: allCompanies,
activeClientId: clId,
user: req.user || {},
user: (req.session && req.session.user) || req.user || {},
includeDeleted,
version: config.version,
isImpersonated: req.isImpersonated || false,
originalUserEmail: req.impersonatedBy ? req.email : null,
email: req.email || (req.user && req.user.email) || (req.session && req.session.user && req.session.user.email) || '',
loginEmail: (req.session && req.session.user && req.session.user.originalUserEmail) || (req.session && req.session.user && req.session.user.email) || '',
email: req.email || '',
});
} catch (e) {
res.status(500).send('<h2>Ошибка</h2><pre>' + e.message + '</pre><a href="/v2/app">Назад</a>');
res.status(500).send('<h2>Ошибка</h2><pre>' + e.message + '</pre><a href="/app">Назад</a>');
}
});
@@ -50,9 +56,9 @@ function createUserRouter() {
try {
const result = await crud.add(req.clientId, req.body.cidr || '', req.body.comment || '', req.email, req.impersonatedBy);
const msg = result.wasNormalized ? 'Добавлено (адрес нормализован)' : 'Добавлено';
res.redirect('/v2/app?msg=' + encodeURIComponent(msg));
res.redirect('/app?msg=' + encodeURIComponent(msg));
} catch (e) {
res.redirect('/v2/app?error=' + encodeURIComponent(e.message));
res.redirect('/app?error=' + encodeURIComponent(e.message));
}
});
@@ -61,9 +67,9 @@ function createUserRouter() {
try {
const result = await crud.edit(parseInt(req.params.id), req.clientId, req.body.cidr || '', req.body.comment || '', req.email, req.impersonatedBy);
const msg = result.wasNormalized ? 'Изменено (адрес нормализован)' : 'Изменено';
res.redirect('/v2/app?msg=' + encodeURIComponent(msg));
res.redirect('/app?msg=' + encodeURIComponent(msg));
} catch (e) {
res.redirect('/v2/app?error=' + encodeURIComponent(e.message));
res.redirect('/app?error=' + encodeURIComponent(e.message));
}
});
@@ -71,9 +77,9 @@ function createUserRouter() {
router.post('/delete/:id', async (req, res) => {
try {
await crud.remove(parseInt(req.params.id), req.clientId, req.email, req.impersonatedBy);
res.redirect('/v2/app?msg=Удалено');
res.redirect('/app?msg=Удалено');
} catch (e) {
res.redirect('/v2/app?error=' + encodeURIComponent(e.message));
res.redirect('/app?error=' + encodeURIComponent(e.message));
}
});
+1 -1
View File
@@ -10,7 +10,7 @@
</style></head><body>
<div class="bar">
<span><strong>📋 Аудит</strong> <%= company ? company.client_id : 'Все компании' %></span>
<span><a href="/v2/admin">← Админка</a> | <a href="/v2/app">Пользовательский</a></span>
<span><a href="/admin">← Админка</a> | <a href="/app">Пользовательский</a></span>
</div>
<table>
<tr><th>Дата</th><th>Действие</th><th>Кто</th><th>От имени</th><th>Компания</th><th>Значения</th></tr>
+1 -1
View File
@@ -10,7 +10,7 @@
</style></head><body>
<div class="bar">
<span><strong>📋 Записи</strong> <%= company.client_id %> — <%= company.name || company.client_id %></span>
<span><a href="/v2/admin">← Админка</a></span>
<span><a href="/admin">← Админка</a></span>
</div>
<p>Записей: <strong><%= used %></strong> из <%= limit %> <%= includeDeleted ? '(с удалёнными: ' + entries.length + ')' : '' %></p>
<table>
+6 -6
View File
@@ -13,12 +13,12 @@
</style></head><body>
<div class="bar">
<span><strong>🛡️ ADMIN</strong> v<%= version %></span>
<span><%= user.email || '—' %> | <a href="/v2/app">← Пользовательский</a> | <a href="/v2/logout">Выйти</a></span>
<span><%= user.email || '—' %> | <a href="/app">← Пользовательский</a> | <a href="/logout">Выйти</a></span>
</div>
<script>
const p=new URLSearchParams(location.search);
if(p.get('msg')){document.write('<div class="msg">'+p.get('msg')+'</div>');history.replaceState(null,'','/v2/admin');}
if(p.get('error')){document.write('<div class="err">'+p.get('error')+'</div>');history.replaceState(null,'','/v2/admin');}
if(p.get('msg')){document.write('<div class="msg">'+p.get('msg')+'</div>');history.replaceState(null,'','/admin');}
if(p.get('error')){document.write('<div class="err">'+p.get('error')+'</div>');history.replaceState(null,'','/admin');}
</script>
<h2>Компании (<%= companies.length %>)</h2>
<table>
@@ -31,11 +31,11 @@
<td><strong><%= c.active_count %></strong> / <%= c.custom_limit != null ? c.custom_limit : 15 %></td>
<td><%= c.custom_limit != null ? c.custom_limit : '—' %></td>
<td>
<a href="/v2/admin/audit?companyId=<%= c.id %>">аудит</a> |
<a href="/v2/admin/entries?companyId=<%= c.id %>">записи</a>
<a href="/admin/audit?companyId=<%= c.id %>">аудит</a> |
<a href="/admin/entries?companyId=<%= c.id %>">записи</a>
</td>
<td>
<form method="POST" action="/v2/admin/limit" style="display:inline">
<form method="POST" action="/admin/limit" style="display:inline">
<input type="hidden" name="companyId" value="<%= c.id %>">
<input name="limit" value="<%= c.custom_limit || 15 %>" size="3">
<button>установить</button>
+143 -41
View File
@@ -1,68 +1,170 @@
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>V2 IP WhiteList</title>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>IP WhiteList v<%= version %> — Белые списки IP</title>
<link rel="icon" href="/favicon.png" type="image/png">
<style>
body{font-family:sans-serif;max-width:960px;margin:20px auto;color:#222}
table{width:100%;border-collapse:collapse;margin:10px 0}
th,td{border:1px solid #ccc;padding:6px;text-align:left}
th{background:#f5f5f5}
.bar{display:flex;justify-content:space-between;align-items:center;background:#eee;padding:10px;margin-bottom:10px}
button,input,select{padding:4px 8px}
.add-form{background:#f0f8ff;padding:10px;margin:10px 0}
</style></head><body>
<div class="bar">
<span><strong>V2 IP WhiteList</strong> <small>v<%= version %></small></span>
<span><%= user.email %> <% if (user.isAdmin) { %>| <a href="/v2/admin">ADMIN</a><% } %></span>
<span>
<form method="GET" action="/v2/app" style="display:inline">
<select name="switchTo" onchange="this.form.submit()">
: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; }
.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; }
.alert-warn { background: #fef3c7; color: #92400e; border: 1px solid #fde68a; }
.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-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 { padding: .5rem .75rem; border: 1px solid var(--border); border-radius: 6px; font-size: .9rem; outline: none; background: #fff; }
.field input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
.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; white-space: nowrap; text-decoration: none; color: var(--text); }
.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-danger { color: var(--red); border-color: var(--red); }
.btn-danger:hover { background: #fecaca; }
.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); }
td { padding: .5rem .75rem; border-bottom: 1px solid var(--border); }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #f8fafc; }
tr.deleted td { text-decoration: line-through; opacity: 0.5; }
code { background: #f1f5f9; padding: .15rem .4rem; border-radius: 3px; font-size: .9em; }
.empty { text-align: center; color: var(--muted); padding: 2rem; }
.inline-form { display: inline-flex; gap: .5rem; align-items: center; }
</style>
</head>
<body>
<!-- Header Nubes -->
<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"/>
<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"/>
<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"/>
<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"/>
<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"/>
</g>
</svg>
<span>|</span>
<span style="font-weight:500;color:var(--text);">Белые списки IP v<%= version %></span>
<span style="margin-left:auto;display:flex;gap:.5rem;align-items:center;">
<% if (user.isAdmin) { %>
<a href="/admin" class="btn" style="font-size:.8rem;">Администратор</a>
<% } %>
<span class="btn" style="font-size:.8rem;cursor:default;">
<select onchange="location.href='/app?switchTo='+this.value" style="border:none;background:transparent;font-size:.8rem;color:var(--text);cursor:pointer;outline:none;">
<% companies.forEach(function(c) { %>
<option value="<%= c.client_id %>" <%= c.client_id === activeClientId ? 'selected' : '' %>><%= c.company_name || c.client_id %> (<%= c.client_id %>)</option>
<% }) %>
</select>
</form>
<a href="/v2/logout">Выйти</a>
</span>
<a href="/logout" class="btn" style="font-size:.8rem;">Выйти (<%= loginEmail %>)</a>
</span>
</header>
<div class="page">
<!-- Имперсонация -->
<% if (typeof isImpersonated !== 'undefined' && isImpersonated) { %>
<% if (isImpersonated) { %>
<div class="alert alert-warn">
⚠️ Режим имперсонации — данные сохраняются от имени <strong><%= email %></strong>
(вы: <%= loginEmail %>)
</div>
<% } %>
<% } %>
<!-- Сообщения -->
<div id="msgs"></div>
<script>
const p=new URLSearchParams(location.search);
if(p.get('msg')){document.body.insertAdjacentHTML('afterbegin','<div style="background:#4caf50;color:#fff;padding:8px">'+p.get('msg')+'</div>');history.replaceState(null,'','/v2/app')}
if(p.get('error')){document.body.insertAdjacentHTML('afterbegin','<div style="background:#f44336;color:#fff;padding:8px">'+p.get('error')+'</div>');history.replaceState(null,'','/v2/app')}
if(p.get('msg')){document.getElementById('msgs').innerHTML='<div class="alert alert-ok">'+p.get('msg')+'</div>';history.replaceState(null,'','/app')}
if(p.get('error')){document.getElementById('msgs').innerHTML='<div class="alert alert-err">'+p.get('error')+'</div>';history.replaceState(null,'','/app')}
</script>
<p>Записей: <strong><%= used %></strong> из <%= limit %></p>
<div class="add-form">
<form method="POST" action="/v2/app/add">
<input name="cidr" placeholder="x.x.x.x/xx" size="18" required>
<input name="comment" placeholder="комментарий" size="30">
<button>Добавить</button>
<!-- Статистика -->
<div class="card">
<div class="card-header">Белый список</div>
<div class="card-body">
<div class="stats" style="margin-bottom:1rem;">
<div class="stat <%= used >= limit ? 'full' : '' %>"><div class="num"><%= used %></div><div class="lbl">из <%= limit %></div></div>
<div style="margin-left:auto;display:flex;align-items:center;font-weight:600;font-size:1.1rem;color:var(--text);">
<span style="font-weight:600;font-size:1.1rem;color:var(--muted);margin-right:.75rem;"><%= activeClientId %></span>
<span style="font-weight:600;font-size:1.1rem;"><%= email %></span>
</div>
</div>
<!-- Форма добавления -->
<form method="POST" action="/app/add">
<div class="form-grid">
<div class="field">
<label>IPv4 адрес или подсеть CIDR</label>
<input name="cidr" placeholder="x.x.x.x/xx" required>
</div>
<div class="field">
<label>Комментарий</label>
<input name="comment" placeholder="для чего">
</div>
<button type="submit" class="btn btn-primary">Добавить</button>
</div>
</form>
</div>
</div>
<!-- Таблица -->
<div class="table-wrap">
<table>
<tr><th>CIDR</th><th>Комментарий</th><th>Кто</th><th>Когда</th><th></th></tr>
<thead>
<tr><th>Адрес / подсеть</th><th>Комментарий</th><th>Добавил</th><th>Добавлена</th><th></th></tr>
</thead>
<tbody>
<% if (entries.length === 0) { %>
<tr><td colspan="5">Нет записей</td></tr>
<tr><td colspan="5" class="empty">Нет записей</td></tr>
<% } %>
<% entries.forEach(function(e) { %>
<tr style="<%= e.deleted_at ? 'text-decoration:line-through;opacity:0.5' : '' %>">
<td><%= e.value_cidr %></td>
<tr class="<%= e.deleted_at ? 'deleted' : '' %>">
<td><code><%= e.value_cidr %></code></td>
<td><%= e.comment || '' %></td>
<td><%= e.created_by %></td>
<td><%= new Date(e.created_at).toLocaleString('ru') %></td>
<td>
<% if (e.deleted_at) { %>
<span style="color:#888">удалено</span>
<% if (!e.deleted_at) { %>
<div class="inline-form">
<form method="POST" action="/app/edit/<%= e.id %>" class="inline-form">
<input name="cidr" value="<%= e.value_cidr %>" size="18" style="padding:.3rem .5rem;border:1px solid var(--border);border-radius:4px;font-size:.8rem;">
<input name="comment" value="<%= e.comment || '' %>" size="16" style="padding:.3rem .5rem;border:1px solid var(--border);border-radius:4px;font-size:.8rem;">
<button class="btn" style="font-size:.75rem;padding:.3rem .6rem;">изменить</button>
</form>
<form method="POST" action="/app/delete/<%= e.id %>" style="display:inline;">
<button class="btn btn-danger" style="font-size:.75rem;padding:.3rem .6rem;" onclick="return confirm('Удалить?')">удалить</button>
</form>
</div>
<% } else { %>
<form method="POST" action="/v2/app/edit/<%= e.id %>" style="display:inline">
<input name="cidr" value="<%= e.value_cidr %>" size="18">
<input name="comment" value="<%= e.comment || '' %>" size="20">
<button>изменить</button>
</form>
<form method="POST" action="/v2/app/delete/<%= e.id %>" style="display:inline">
<button onclick="return confirm('Удалить?')">удалить</button>
</form>
<span style="color:var(--muted);font-size:.8rem;">удалено</span>
<% } %>
</td>
</tr>
<% }) %>
</tbody>
</table>
<p><a href="/v2/app?deleted=<%= includeDeleted ? '0' : '1' %>"><%= includeDeleted ? 'Скрыть удалённые' : 'Показать удалённые' %></a></p>
</body></html>
</div>
<p style="margin-top:.5rem;text-align:center;">
<a href="/app?deleted=<%= includeDeleted ? '0' : '1' %>" style="font-size:.85rem;color:var(--muted);"><%= includeDeleted ? 'Скрыть удалённые' : 'Показать удалённые' %></a>
</p>
</div>
</body>
</html>