Compare commits
116
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bdcb4c363 | ||
|
|
d20bd85a7d | ||
|
|
591a4c3216 | ||
|
|
7982a82286 | ||
|
|
fc5aa7ca09 | ||
|
|
8dc7940318 | ||
|
|
2a37697afb | ||
|
|
4ee032ba8d | ||
|
|
f898aabe7d | ||
|
|
74e69fa4be | ||
|
|
5d292687ce | ||
|
|
25f53cf808 | ||
|
|
6e464c58dd | ||
|
|
cf97a8c9bf | ||
|
|
ac7009f1e0 | ||
|
|
1b255362ce | ||
|
|
5565e067ef | ||
|
|
deeb54af1c | ||
|
|
73afce35bd | ||
|
|
d8f8c00f30 | ||
|
|
cf2e703d54 | ||
|
|
68c3edd8d8 | ||
|
|
df697cd703 | ||
|
|
0135d28189 | ||
|
|
e943b33a04 | ||
|
|
ff1f195a2d | ||
|
|
4c764eb794 | ||
|
|
4c39eae4a7 | ||
|
|
a8846d90f2 | ||
|
|
ef546a9dcb | ||
|
|
3d20c2a31d | ||
|
|
24f1d5af77 | ||
|
|
9e6e22fadc | ||
|
|
7049b5aaa9 | ||
|
|
f709959478 | ||
|
|
d2c621cf38 | ||
|
|
400908985e | ||
|
|
2d164fb9ee | ||
|
|
c823ae8128 | ||
|
|
b1d670a6bf | ||
|
|
d72bb84d25 | ||
|
|
fb17c83e60 | ||
|
|
f60713f127 | ||
|
|
74f805682f | ||
|
|
060b20002c | ||
|
|
24fb8cdc56 | ||
|
|
710dcfc7c9 | ||
|
|
c50ef814e2 | ||
|
|
30001062d4 | ||
|
|
92f0314a20 | ||
|
|
fa75f8317d | ||
|
|
90b6c509c0 | ||
|
|
102f4279dc | ||
|
|
b7e62f0468 | ||
|
|
7f8aae0d62 | ||
|
|
6899bc40d3 | ||
|
|
9f44281f9f | ||
|
|
e68e1a483c | ||
|
|
4cd1ab8c8e | ||
|
|
10b890437d | ||
|
|
b24cd75a9e | ||
|
|
df0dd73451 | ||
|
|
8f9fc4e3ee | ||
|
|
9e3ba1c97c | ||
|
|
eba1b4974e | ||
|
|
8deea87996 | ||
|
|
a6d64e1e6c | ||
|
|
795a41496f | ||
|
|
08abab6e36 | ||
|
|
0e1f34c913 | ||
|
|
24e0e39782 | ||
|
|
c4d592dcbe | ||
|
|
86a39c7881 | ||
|
|
f0743c6bfe | ||
|
|
c5cd577561 | ||
|
|
5941899c9c | ||
|
|
ecf6638683 | ||
|
|
e440700a72 | ||
|
|
b41af5649f | ||
|
|
bf72cb49fc | ||
|
|
235da375c4 | ||
|
|
6660cb3c77 | ||
|
|
ee0cf68fe3 | ||
|
|
3493d45065 | ||
|
|
e2aaf3f425 | ||
|
|
f215fa8e32 | ||
|
|
6b994d0938 | ||
|
|
08e20c10ac | ||
|
|
1b043713a9 | ||
|
|
799406df78 | ||
|
|
bde68c00a1 | ||
|
|
02abab93d7 | ||
|
|
2233d3f94d | ||
|
|
cb1e4fac22 | ||
|
|
dc1f629560 | ||
|
|
b640e9e8e7 | ||
|
|
afcfc1420e | ||
|
|
07356acadc | ||
|
|
b04ef35b13 | ||
|
|
394c778f46 | ||
|
|
f5ee0a36de | ||
|
|
8fe89f3a0a | ||
|
|
6bff095a67 | ||
|
|
9ca0d087de | ||
|
|
11c3817376 | ||
|
|
ebcdb1ccb1 | ||
|
|
eb3c17cd68 | ||
|
|
eb7788235f | ||
|
|
8ab0bb10ac | ||
|
|
2545bc97f1 | ||
|
|
f00b7f3e87 | ||
|
|
a31b3e61f2 | ||
|
|
2f151f4cdf | ||
|
|
2e89a71afa | ||
|
|
9eba8859cf | ||
|
|
7fa6b43562 |
@@ -10,6 +10,10 @@ DB_SSLMODE=disable
|
|||||||
# true = mock-логин (без Keycloak, для тестов)
|
# true = mock-логин (без Keycloak, для тестов)
|
||||||
# false = OIDC SSO через Keycloak (ОБЯЗАТЕЛЬНО в production)
|
# false = OIDC SSO через Keycloak (ОБЯЗАТЕЛЬНО в production)
|
||||||
DEV_MODE=true
|
DEV_MODE=true
|
||||||
|
|
||||||
|
# APP_ENV=production (по умолчанию) или test (жёлтый баннер в UI)
|
||||||
|
APP_ENV=test
|
||||||
|
|
||||||
DEFAULT_LIMIT=15
|
DEFAULT_LIMIT=15
|
||||||
|
|
||||||
# ── Сессия (сгенерировать: openssl rand -base64 32) ──
|
# ── Сессия (сгенерировать: openssl rand -base64 32) ──
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ __pycache__/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
*.har
|
||||||
|
|
||||||
# Креды, токены, конфиги
|
# Креды, токены, конфиги
|
||||||
token*.*
|
token*.*
|
||||||
|
|||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
Доверенные адреса – защита вашего трафика во время DDoS-атак
|
||||||
|
Когда на инфраструктуру провайдера идёт DDoS-атака, системы защиты автоматически фильтруют подозрительный трафик. Иногда под фильтрацию попадает и легитимный трафик, например, запросы из вашего офиса или от ваших партнёров.
|
||||||
|
Чтобы этого не происходило, вы можете заранее указать адреса, которые провайдер гарантированно не заблокирует. Это называется списком доверенных адресов.
|
||||||
|
Как пользоваться
|
||||||
|
<тут можно вставить ссылку на сервис>
|
||||||
|
Зайти в сервис необходимо с учетной записью keycloak.
|
||||||
|
Добавить адрес. Нажмите «Добавить», введите IPv4-адрес или подсеть в формате CIDR и при желании — комментарий, чтобы не забыть, что это за адрес. Например: 203.0.113.10 или 203.0.113.0/24. Нажмите «Сохранить». Если вы вводите адрес хоста с маской подсети (например 203.0.113.5/24 вместо 203.0.113.0/24), сервис автоматически нормализует его до адреса подсети и сообщит вам об этом.
|
||||||
|
Добавляйте комментарии к записям — через несколько месяцев вы скажете себе спасибо: будет сразу понятно, чей это адрес и зачем он добавлен.
|
||||||
|
Редактировать или удалить. Рядом с каждой записью есть кнопки редактирования и удаления. Удалённые записи перестают действовать через 5 минут.
|
||||||
|
Ограничения
|
||||||
|
• Только публичные IPv4-адреса. Серые адреса (192.168.x.x, 10.x.x.x, 172.16–31.x.x и другие служебные диапазоны) добавить нельзя — они не маршрутизируются в интернете и смысла в их добавлении нет.
|
||||||
|
• Только IPv4. IPv6-адреса и доменные имена не поддерживаются.
|
||||||
|
• Максимальный размер подсети — /22. Добавить префикс шире /22 (то есть /21, /20 и т.д.) нельзя.
|
||||||
|
• До 15 записей на компанию. Если вам нужно больше — обратитесь в поддержку.
|
||||||
|
• Нет дублей и пересечений. Если вы попытаетесь добавить адрес, который уже покрывается существующей записью (или наоборот), сервис предупредит об этом.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# IAM — переменные окружения
|
||||||
|
|
||||||
|
> Дата: 2026-06-17
|
||||||
|
> Путь: `src/config.js`, `v2/src/config/index.js`
|
||||||
|
|
||||||
|
## IAM_API_URL (основная, приоритет выше)
|
||||||
|
|
||||||
|
**Где используется:** `src/config.js` → `src/auth.js` (fetchIamUser, switchProfile)
|
||||||
|
|
||||||
|
**Формат:** `https://auth-api.ngcloud.ru` (без /api/v1)
|
||||||
|
|
||||||
|
Код добавляет `/api/v1/auth/user` автоматически.
|
||||||
|
|
||||||
|
**Назначение:** получение профилей пользователя, переключение компании.
|
||||||
|
|
||||||
|
**Дефолт:** `https://auth-api.ngcloud.ru`
|
||||||
|
|
||||||
|
**Пример:**
|
||||||
|
- IAM_API_URL=https://auth-api.ngcloud.ru
|
||||||
|
- Фактический путь: https://auth-api.ngcloud.ru/api/v1/auth/user
|
||||||
|
|
||||||
|
## IAM_API_BASE (запасная, приоритет ниже)
|
||||||
|
|
||||||
|
**Где используется:** `v2/src/config/index.js` → `v2/server.js` (тестовые страницы)
|
||||||
|
|
||||||
|
**Формат:** `https://lk-api-gateway.ngcloud.ru/api/v1/iam`
|
||||||
|
|
||||||
|
Код НЕ добавляет /api/v1, путь должен быть полный.
|
||||||
|
|
||||||
|
**Назначение:** тестовые страницы (/v2/iam-gateway-test), заглушка пока IAM не починят.
|
||||||
|
|
||||||
|
**Дефолт:** берётся из IAM_API_URL
|
||||||
|
|
||||||
|
**Пример:**
|
||||||
|
- IAM_API_BASE=https://lk-api-gateway.ngcloud.ru/api/v1/iam
|
||||||
|
- Фактический путь: https://lk-api-gateway.ngcloud.ru/api/v1/iam/auth/user
|
||||||
|
|
||||||
|
## Приоритет в src/config.js
|
||||||
|
|
||||||
|
```
|
||||||
|
process.env.IAM_API_URL || process.env.IAM_API_BASE || 'https://auth-api.ngcloud.ru'
|
||||||
|
```
|
||||||
|
|
||||||
|
1. IAM_API_URL — явно задан (auth-api)
|
||||||
|
2. IAM_API_BASE — запасной (lk-api-gateway)
|
||||||
|
3. Дефолт — auth-api.ngcloud.ru
|
||||||
|
|
||||||
|
## Когда что использовать
|
||||||
|
|
||||||
|
| Ситуация | Какая переменная | Какой API |
|
||||||
|
|----------|-----------------|-----------|
|
||||||
|
| Обычная работа (логин, CRUD, компании) | IAM_API_URL | auth-api.ngcloud.ru |
|
||||||
|
| Имперсонация (админ работает от имени юзера) | IAM_API_BASE | lk-api-gateway.ngcloud.ru |
|
||||||
|
| Когда IAM починят | IAM_API_URL | auth-api.ngcloud.ru (везде) |
|
||||||
|
|
||||||
|
## Текущий статус
|
||||||
|
|
||||||
|
- auth-api.ngcloud.ru — не отдаёт profiles таргета при имперсонации (баг разработчиков IAM)
|
||||||
|
- lk-api-gateway.ngcloud.ru — работает, отдаёт profiles таргета, НО не принимает токен от Keycloak whitelist (только от ЛК)
|
||||||
|
- Когда IAM починят: удалить IAM_API_BASE из кода и Nubes UI, оставить только IAM_API_URL
|
||||||
|
|
||||||
|
## Nubes UI (текущие переменные)
|
||||||
|
|
||||||
|
- IAM_API_URL=https://auth-api.ngcloud.ru
|
||||||
|
- IAM_API_BASE=https://lk-api-gateway.ngcloud.ru/api/v1/iam
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Endpoint SQL-запросов к БД (`/v2/db/query`)
|
||||||
|
|
||||||
|
> Создан: 2026-07-08
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
Дать DevOps-инженеру (одному человеку, заданному через `ADMIN_EMAIL`) возможность выполнять SQL-запросы к БД напрямую из браузера — без psql, без kubectl, без внешнего IP.
|
||||||
|
|
||||||
|
## Доступ
|
||||||
|
|
||||||
|
- Только один пользователь: `req.email === ADMIN_EMAIL`
|
||||||
|
- Обычный юзер сервиса (проходит SSO как все)
|
||||||
|
- Пример: `ADMIN_EMAIL=atazet@narod.ru`
|
||||||
|
|
||||||
|
## Адрес
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /v2/db/query?sql=<часть запроса после SELECT>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Как работает
|
||||||
|
|
||||||
|
1. Пользователь заходит в сервис через SSO (любой URL, например `/v2/app`)
|
||||||
|
2. Меняет в адресной строке путь на `/v2/db/query?sql=...`
|
||||||
|
3. Сервер:
|
||||||
|
- Проверяет сессию (resolveContext)
|
||||||
|
- Проверяет что `req.email === ADMIN_EMAIL`
|
||||||
|
- Добавляет префикс `SELECT` перед вводом пользователя
|
||||||
|
- Выполняет: `pool.query('SELECT ' + userInput)`
|
||||||
|
- Возвращает JSON
|
||||||
|
|
||||||
|
## Примеры
|
||||||
|
|
||||||
|
| Ввод в браузере | Что выполняется |
|
||||||
|
|---|---|
|
||||||
|
| `/v2/db/query?sql=* FROM _migrations` | `SELECT * FROM _migrations` |
|
||||||
|
| `/v2/db/query?sql=count(*) FROM companies` | `SELECT count(*) FROM companies` |
|
||||||
|
| `/v2/db/query?sql=* FROM whitelist_entries LIMIT 10` | `SELECT * FROM whitelist_entries LIMIT 10` |
|
||||||
|
| `/v2/db/query?sql=* FROM audit_log ORDER BY created_at DESC LIMIT 5` | `SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 5` |
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
1. **SELECT префикс** — жёстко добавляется `SELECT` перед вводом пользователя. DROP/INSERT/UPDATE/DELETE не работают:
|
||||||
|
```
|
||||||
|
/v2/db/query?sql=DROP TABLE companies
|
||||||
|
→ SELECT DROP TABLE companies (ошибка SQL, не выполнится)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Одиночные запросы** — драйвер `pg` не поддерживает множественные запросы в одном `query()`. Попытка `1; DROP TABLE` не сработает.
|
||||||
|
|
||||||
|
3. **Доступ по email** — только один человек, заданный в `ADMIN_EMAIL`.
|
||||||
|
|
||||||
|
4. **Rate limiter** — endpoint защищён лимитером (как mutationLimiter).
|
||||||
|
|
||||||
|
## Ответ
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rows": [ ... ],
|
||||||
|
"rowCount": 5,
|
||||||
|
"fields": ["id", "filename", "applied_at"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Реализация
|
||||||
|
|
||||||
|
- Файл: `v2/src/db/query.js`
|
||||||
|
- Middleware: `resolveContext` + проверка `req.canQueryDB`
|
||||||
|
- Роутер монтируется в `v2/server.js` на `/db`
|
||||||
|
- Флаг `req.canQueryDB` выставляется в `v2/src/router/index.js` (applyContext)
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Только SELECT (через префикс)
|
||||||
|
- Нет GUI — только JSON в браузере
|
||||||
|
- SQL в URL (логируется в access_log nginx/ingress — это нормально для devops-инструмента)
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Сессия 2026-06-22 — export view=2, фикс имперсонации, TZ аудит
|
||||||
|
|
||||||
|
## Версии
|
||||||
|
- **0.1.1** — сброс версии с 0.5.180
|
||||||
|
- **0.1.2** — `white-space: pre` на `<code>` (пробелы схлопывались)
|
||||||
|
- **0.1.3** — таблица без точек, октеты в своих `<td>`
|
||||||
|
- **0.1.4** — `text-align: right` в ячейках
|
||||||
|
- **0.1.5** — `padStart(2)` плотнее (один пробел, не два)
|
||||||
|
- **0.1.6** — фикс детекции имперсонации
|
||||||
|
- **0.1.7** — аудит: время Москвы вместо UTC
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Export view=2 — HTML-таблица с выровненными IP
|
||||||
|
|
||||||
|
### Устройство
|
||||||
|
Файл: `v2/src/export/index.js`
|
||||||
|
|
||||||
|
Три режима:
|
||||||
|
| Параметр | Content-Type | Поведение |
|
||||||
|
|---|---|---|
|
||||||
|
| (нет) | `text/plain` | Скачивание `.txt` |
|
||||||
|
| `?view=1` | `text/plain` | Inline в браузере |
|
||||||
|
| `?view=2` | `text/html` | Таблица: 4 ячейки октетов + маска |
|
||||||
|
|
||||||
|
### Структура view=2
|
||||||
|
```html
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td> 62</td><td> 76</td><td> 39</td><td> 5</td><td> / 32</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Каждый октет в своей `<td>`, без точек
|
||||||
|
- `padStart(2, ' ')` — минимум 2 символа на октет
|
||||||
|
- `text-align: right` — выравнивание по правой границе
|
||||||
|
- `white-space: pre` — пробелы не схлопываются
|
||||||
|
- Границы не видны (`border-collapse: collapse`, без `border` на td)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Фикс имперсонации — БАГ
|
||||||
|
|
||||||
|
### Симптом
|
||||||
|
На странице `/v2/app` не показывался баннер «Режим имперсонации», хотя IAM API возвращал `is_impersonated: true`.
|
||||||
|
|
||||||
|
### Причина
|
||||||
|
В двух местах `v2/src/router/index.js` проверка имперсонации использовала `u.impersonatedCompanyId`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// checkImpersonation — строка 78 (было)
|
||||||
|
const oldIsImp = !!(u.originalUserEmail && u.impersonatedCompanyId);
|
||||||
|
|
||||||
|
// applyContext — строка 133 (было)
|
||||||
|
const isImpersonated = !!(u.originalUserEmail && u.impersonatedCompanyId);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Поле `impersonatedCompanyId` отсутствует в ответе IAM.** IAM `/auth/user` возвращает:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"impersonation": {
|
||||||
|
"is_impersonated": true,
|
||||||
|
"impersonatedUserId": "...",
|
||||||
|
"originalUserEmail": "ntazetdinov@nubes.ru",
|
||||||
|
"originalUserFullName": "...",
|
||||||
|
"originalUserCompany": "...",
|
||||||
|
"sessionId": 613,
|
||||||
|
...
|
||||||
|
// ⚠️ impersonatedCompanyId — НЕТ
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Из-за этого:
|
||||||
|
- `u.impersonatedCompanyId` всегда `''` (пустая строка = falsy)
|
||||||
|
- `!!(truthy && falsy)` → всегда `false`
|
||||||
|
- Баннер не показывался
|
||||||
|
- `checkImpersonation` на каждый запрос заново дёргал IAM (думал что статус изменился)
|
||||||
|
|
||||||
|
### Исправление
|
||||||
|
Заменить проверку на `u.isImpersonated` (которое корректно выставляется в `checkImpersonation`):
|
||||||
|
|
||||||
|
```js
|
||||||
|
// checkImpersonation (исправлено)
|
||||||
|
const oldIsImp = !!u.isImpersonated;
|
||||||
|
|
||||||
|
// applyContext (исправлено)
|
||||||
|
const isImpersonated = !!(u.isImpersonated || u.originalUserEmail);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Время в аудите — UTC → MSK
|
||||||
|
|
||||||
|
Файл: `views/v2/admin-audit.ejs`, строка 100.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Было (возвращает серверное время = UTC):
|
||||||
|
new Date(a.created_at).toLocaleString('ru')
|
||||||
|
|
||||||
|
// Стало (Москва, UTC+3):
|
||||||
|
new Date(a.created_at).toLocaleString('ru-RU', { timeZone: 'Europe/Moscow' })
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Проверка IAM API
|
||||||
|
|
||||||
|
Три эндпоинта, проверены через Node.js с IAM-токеном (`iss: auth-api`):
|
||||||
|
|
||||||
|
| Эндпоинт | Статус | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| `auth-api /auth/user` | 200 | Полные данные + имперсонация |
|
||||||
|
| `lk-api-gateway /iam/auth/user` | 200 | **Идентично** auth-api |
|
||||||
|
| `auth-api /impersonation/status` | 200 | `is_impersonating`, `session_id`, etc. |
|
||||||
|
|
||||||
|
Данные между `auth-api` и `lk-api-gateway` совпадают поле-в-поле.
|
||||||
|
|
||||||
|
### Важно: токены
|
||||||
|
- **Keycloak-токен** (iss: `auth.k8s.ngcloud.ru`) → IAM возвращает 401 (не может проверить подпись)
|
||||||
|
- **IAM-токен** (iss: `auth-api`) → работает
|
||||||
|
- **Tech-токен** (8541 символ) → nginx 400 (превышен лимит заголовка 8KB)
|
||||||
|
- Для проверки API нужен IAM-токен из активной сессии
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Админ при имперсонации
|
||||||
|
|
||||||
|
`tazet@narod.ru` показывается как админ, потому что:
|
||||||
|
1. IAM честно говорит `isAdmin: false` для `tazet@narod.ru`
|
||||||
|
2. Но `applyContext` проверяет `email === ADMIN_EMAIL` → `true`
|
||||||
|
3. `ADMIN_EMAIL=tazet@narod.ru` в ENV
|
||||||
|
|
||||||
|
Это ожидаемое поведение — `ADMIN_EMAIL` даёт админские права независимо от IAM.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# IAM Impersonation API
|
||||||
|
|
||||||
|
> Источник: Swagger `https://auth-api-dev.ngcloud.ru/api/v1/documentation/`
|
||||||
|
> Дата: 2026-06-16
|
||||||
|
|
||||||
|
## Все эндпоинты имперсонации
|
||||||
|
|
||||||
|
| Метод | Путь | Описание |
|
||||||
|
|-------|------|----------|
|
||||||
|
| `POST` | `/api/v1/impersonation/start` | Начать имперсонацию |
|
||||||
|
| `POST` | `/api/v1/impersonation/end` | Завершить имперсонацию |
|
||||||
|
| `POST` | `/api/v1/impersonation/extend` | Продлить сессию имперсонации |
|
||||||
|
| `GET` | `/api/v1/impersonation/status` | Статус текущей имперсонации |
|
||||||
|
| `GET` | `/api/v1/impersonation/history` | История своих имперсонаций |
|
||||||
|
| `GET` | `/api/v1/impersonation/history/all` | История имперсонаций всех админов |
|
||||||
|
|
||||||
|
## Формат ImpersonationStartRequest
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "user",
|
||||||
|
"entity_id": "UUID контакта (contactId из userInfo)",
|
||||||
|
"reason": "описание причины"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `entity_id` — **UUID таргета** (кого имперсонируем), не свой
|
||||||
|
- `type` — всегда `"user"`
|
||||||
|
|
||||||
|
## curl-команды
|
||||||
|
|
||||||
|
### Начать имперсонацию
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --http2 -X POST \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Accept: application/json" \
|
||||||
|
"https://auth-api.ngcloud.ru/api/v1/impersonation/start" \
|
||||||
|
-d '{"type":"user","entity_id":"UUID_таргета","reason":"тест"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Статус
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --http2 \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Accept: application/json" \
|
||||||
|
"https://auth-api.ngcloud.ru/api/v1/impersonation/status"
|
||||||
|
```
|
||||||
|
|
||||||
|
### История
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --http2 \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Accept: application/json" \
|
||||||
|
"https://auth-api.ngcloud.ru/api/v1/impersonation/history"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Продлить
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --http2 -X POST \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Accept: application/json" \
|
||||||
|
"https://auth-api.ngcloud.ru/api/v1/impersonation/extend"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Завершить
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --http2 -X POST \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Accept: application/json" \
|
||||||
|
"https://auth-api.ngcloud.ru/api/v1/impersonation/end"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Примечания
|
||||||
|
|
||||||
|
- Требуется роль IAM-админа или право на объект `impersonation`
|
||||||
|
- `--http2` обязателен для обхода ddos-guard
|
||||||
|
- Стенды: `auth-api.ngcloud.ru` (prod), `auth-api-test.ngcloud.ru` (test), `auth-api-dev.ngcloud.ru` (dev)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Баг: переключение компании — таблица IP не обновляется
|
||||||
|
|
||||||
|
## Симптом
|
||||||
|
GET /v2/app?switchTo=WZ30001 → таблица показывает записи старой компании.
|
||||||
|
|
||||||
|
## Найденная причина №1 (основная)
|
||||||
|
Порядок кода в `v2/src/user/index.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 1. clId = req.clientId ← resolveContext выставил СТАРЫЙ activeClientId
|
||||||
|
const clId = req.clientId;
|
||||||
|
|
||||||
|
// 2. crud.list() по СТАРОМУ clId — ещё до switchTo
|
||||||
|
const { entries, used, limit } = await crud.list(clId, includeDeleted);
|
||||||
|
|
||||||
|
// 3. Потом switchTo обновляет сессию и делает redirect
|
||||||
|
if (req.query.switchTo) {
|
||||||
|
req.session.user.activeClientId = targetId;
|
||||||
|
return req.session.save(() => res.redirect('/v2/app'));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Итог: первый запрос с ?switchTo загружает записи СТАРОЙ компании.
|
||||||
|
Второй запрос (редирект /v2/app) должен уже использовать новый activeClientId — если ничто его не затирает.
|
||||||
|
|
||||||
|
## Найденная причина №2 (если ENV заданы)
|
||||||
|
`v2/src/impersonation/index.js` — при тестовом режиме (IMPERSONATION_TARGET задан):
|
||||||
|
|
||||||
|
```js
|
||||||
|
u.activeClientId = MAIN_COMPANY; // ← затирает то что записал switchTo
|
||||||
|
```
|
||||||
|
|
||||||
|
Middleware запускается при КАЖДОМ запросе (включая редирект после switchTo)
|
||||||
|
и сбрасывает activeClientId обратно в IMPERSONATION_COMPANY.
|
||||||
|
|
||||||
|
## Что проверить
|
||||||
|
```bash
|
||||||
|
echo $IMPERSONATION_COMPANY
|
||||||
|
echo $IMPERSONATION_TARGET
|
||||||
|
echo $IMPERSONATION_ORIGINAL
|
||||||
|
```
|
||||||
|
|
||||||
|
Если заданы → причина №2 (middleware затирает сессию).
|
||||||
|
Если не заданы → причина №1 (но тогда второй запрос после редиректа должен работать).
|
||||||
|
|
||||||
|
## Фикс причины №1
|
||||||
|
В `v2/src/user/index.js` — переставить проверку switchTo ПЕРЕД crud.list():
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Сначала switchTo
|
||||||
|
if (req.query.switchTo) {
|
||||||
|
const targetId = req.query.switchTo;
|
||||||
|
const allowed = isAdmin || (req.profiles || []).find(p => p.client_id === targetId);
|
||||||
|
if (allowed) {
|
||||||
|
req.session.user.activeClientId = targetId;
|
||||||
|
return req.session.save(err => {
|
||||||
|
if (err) console.error('[switchTo] save error:', err.message);
|
||||||
|
res.redirect('/v2/app');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Потом загружать записи
|
||||||
|
const { entries, used, limit } = await crud.list(clId, includeDeleted);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Фикс причины №2
|
||||||
|
В `v2/src/impersonation/index.js` — не перезаписывать activeClientId если он уже есть:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Вместо:
|
||||||
|
u.activeClientId = MAIN_COMPANY;
|
||||||
|
|
||||||
|
// Сделать:
|
||||||
|
u.activeClientId = u.activeClientId || MAIN_COMPANY;
|
||||||
|
```
|
||||||
@@ -195,6 +195,10 @@ IAM (Identity & Access Management) — сервис авторизации эк
|
|||||||
| 04.06.2026 | **IAM API — источник компаний.** `GET /auth/user` → `profiles[]`. `isAdmin` из `userInfo.isAdmin`. Переключение через `POST /switch-profile`. |
|
| 04.06.2026 | **IAM API — источник компаний.** `GET /auth/user` → `profiles[]`. `isAdmin` из `userInfo.isAdmin`. Переключение через `POST /switch-profile`. |
|
||||||
| 04.06.2026 | ddos-guard: curl требует `--http2`, Node.js работает |
|
| 04.06.2026 | ddos-guard: curl требует `--http2`, Node.js работает |
|
||||||
|
|
||||||
|
| 16.06.2026 | **UI: кнопка Показать/Скрыть удалённые.** Вместо чекбокса — кнопка-переключатель. |
|
||||||
|
| 16.06.2026 | **UI: восстановление удалённых записей.** Кнопка ↺ у удалённых, `POST /restore/:id`. Лимит проверяется. Аудит: RESTORE. |
|
||||||
|
| 16.06.2026 | **UI: время МСК.** `toLocaleString('ru', {timeZone:'Europe/Moscow'})` + метка «МСК». |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Легенда статусов
|
## Легенда статусов
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ipwhitelist",
|
"name": "ipwhitelist",
|
||||||
"version": "0.5.98",
|
"version": "0.1.13",
|
||||||
"description": "IP WhiteList microservice for cloud provider",
|
"description": "IP WhiteList microservice for cloud provider",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const { authLimiter } = require('./src/middleware/rateLimit');
|
|||||||
const { createSessionMiddleware } = require('./src/middleware/session');
|
const { createSessionMiddleware } = require('./src/middleware/session');
|
||||||
const { aggregateCIDRs } = require('./src/validators');
|
const { aggregateCIDRs } = require('./src/validators');
|
||||||
const { CSP_DIRECTIVES } = require('./src/middleware/csp');
|
const { CSP_DIRECTIVES } = require('./src/middleware/csp');
|
||||||
const { createUiRouter } = require('./ui/index');
|
const { initCsrf } = require('./src/middleware/csrf');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
@@ -74,72 +74,40 @@ async function start() {
|
|||||||
console.error('FATAL: DB_PASS не задан в production!');
|
console.error('FATAL: DB_PASS не задан в production!');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
if (!process.env.CSRF_SECRET || process.env.CSRF_SECRET === 'dev-csrf-secret-change-in-prod') {
|
||||||
|
console.error('FATAL: CSRF_SECRET не задан или равен дефолту в production!');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// k8s liveness probe — простая проверка что сервер жив
|
// k8s liveness + readiness probes
|
||||||
app.get('/healthz', (req, res) => res.send('OK'));
|
app.get('/healthz', (req, res) => res.send('OK'));
|
||||||
|
app.get('/ready', async (req, res) => {
|
||||||
// 🔧 DEBUG: показать IAM-данные из сессии (убрать после теста!)
|
|
||||||
app.get('/debug-session', async (req, res) => {
|
|
||||||
if (!req.session || !req.session.user) return res.json({ error: 'no session' });
|
|
||||||
const { token, idToken, ...safe } = req.session;
|
|
||||||
// Последняя запись аудита для проверки impersonated_by
|
|
||||||
let lastAudit = null;
|
|
||||||
try {
|
try {
|
||||||
const { getAudit } = require('./src/queries');
|
await pool.query('SELECT 1');
|
||||||
const audit = await getAudit(null);
|
res.send('OK');
|
||||||
lastAudit = audit.slice(0, 3);
|
} catch (e) {
|
||||||
} catch(e) { lastAudit = 'error: ' + e.message; }
|
res.status(503).send('DB not ready');
|
||||||
res.json({
|
}
|
||||||
user: req.session.user,
|
|
||||||
hasToken: !!token,
|
|
||||||
tokenPreview: token ? token.slice(0, 50) + '...' : null,
|
|
||||||
tokenPayload: token ? (() => { try { const p = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString()); return { iss: p.iss, sub: p.sub, email: p.email, ClientID: p.ClientID, iat: p.iat }; } catch(e) { return 'decode error'; } })() : null,
|
|
||||||
lastAudit,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// JWKS endpoint для валидации токенов внешними сервисами (только в mock-режиме)
|
// JWKS endpoint для валидации токенов внешними сервисами (только в mock-режиме)
|
||||||
if (auth.jwksHandler) app.get('/.well-known/jwks.json', auth.jwksHandler);
|
if (auth.jwksHandler) app.get('/.well-known/jwks.json', auth.jwksHandler);
|
||||||
|
|
||||||
// ── /export — публичный (ВРЕМЕННО без авторизации) ───────────────────────
|
// ── OIDC-роуты (Keycloak) ───────────────────────────────────────────────
|
||||||
// 🔮 КОГДА Keycloak SSO: заменить на app.use('/export', auth.bearerMiddleware, ...)
|
app.use('/login', authLimiter); // защита от брутфорса
|
||||||
app.get('/export',
|
|
||||||
/* 🔮 auth.bearerMiddleware, */
|
|
||||||
async (req, res) => {
|
|
||||||
try {
|
|
||||||
const cidrs = await q.getExportCIDRs();
|
|
||||||
const aggregated = aggregateCIDRs(cidrs);
|
|
||||||
const text = aggregated.join('\n') + (aggregated.length ? '\n' : '');
|
|
||||||
res.set('Content-Type', 'text/plain; charset=utf-8');
|
|
||||||
const fname = (req.query.filename || 'white-list.txt').replace(/[^\w\-_. ]/g, '_');
|
|
||||||
const disp = req.query.view === '1' ? 'inline' : 'attachment';
|
|
||||||
res.set('Content-Disposition', disp + '; filename="' + fname + '"');
|
|
||||||
res.send(text);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[export] Error:', e);
|
|
||||||
res.status(500).send('Ошибка экспорта');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── OIDC-роуты (Keycloak) — активны ТОЛЬКО когда KC_CLIENT_ID задан ─────
|
|
||||||
// В mock-режиме (сейчас): заглушки, работает ui/routes/auth.js
|
|
||||||
// 🔮 В проде: задать KC_CLIENT_ID + KC_CLIENT_SECRET в .env
|
|
||||||
const { createRouter: createOidcRouter } = require('./src/routes/oidc');
|
const { createRouter: createOidcRouter } = require('./src/routes/oidc');
|
||||||
app.use('/', createOidcRouter({ auth }));
|
app.use('/', createOidcRouter({ auth }));
|
||||||
|
|
||||||
// ── REST API v1 (Bearer JWT, без сессий/CSRF) ─────────────────────────────
|
// ── CSRF — инициализация после сессий ─────────────────────────────────
|
||||||
const { createApiRouter } = require('./src/api/index');
|
const { generateCsrfToken, doubleCsrfProtection } = initCsrf();
|
||||||
app.use('/api/v1', createApiRouter({ auth, q }));
|
|
||||||
|
|
||||||
// ── UI-слой: SSR через EJS, данные из /api/v1/* ───────────────────────────
|
// ── V2 — основной код ─────────────────────────────────────────────────
|
||||||
// Монтируется ПОСЛЕ /api/v1/ — не перехватывает API-запросы.
|
app.get('/', (req, res) => res.redirect('/v2/app'));
|
||||||
// ── V2 — тестовый роутер (Keycloak → IAM → вывод) ─────────────────
|
// Редирект /export → /v2/export (для обратной совместимости)
|
||||||
|
app.get('/export', (req, res) => res.redirect('/v2/export' + (req.originalUrl.includes('?') ? req.originalUrl.substring(req.originalUrl.indexOf('?')) : '')));
|
||||||
const { createV2Router } = require('./v2/server');
|
const { createV2Router } = require('./v2/server');
|
||||||
app.use('/v2', createV2Router());
|
app.use('/v2', createV2Router({ generateCsrfToken, doubleCsrfProtection }));
|
||||||
// Хранит Bearer token в сессии, рендерит те же views/*.ejs.
|
|
||||||
app.use('/', createUiRouter({ auth, MOCK_USERS, authLimiter }));
|
|
||||||
|
|
||||||
// ── Общий обработчик ошибок ───────────────────────────────────────────────
|
// ── Общий обработчик ошибок ───────────────────────────────────────────────
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
@@ -153,7 +121,7 @@ async function start() {
|
|||||||
? (err.stack || err.message || String(err))
|
? (err.stack || err.message || String(err))
|
||||||
: JSON.stringify(err);
|
: JSON.stringify(err);
|
||||||
console.error('Unhandled error:', ctx, detail);
|
console.error('Unhandled error:', ctx, detail);
|
||||||
res.status(500).send('Ошибка [' + ctx.url + '] user=' + ctx.user + ' clientId=' + ctx.clientId + ' — ' + detail.slice(0, 500));
|
res.status(500).send('Внутренняя ошибка сервера');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Старт сервера ─────────────────────────────────────────────────────────────
|
// ── Старт сервера ─────────────────────────────────────────────────────────────
|
||||||
@@ -167,7 +135,20 @@ async function start() {
|
|||||||
// Запускаем сервер только если файл запущен напрямую (не через require)
|
// Запускаем сервер только если файл запущен напрямую (не через require)
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
start()
|
start()
|
||||||
.then(a => a.listen(PORT, () => console.log('Server on port ' + PORT)))
|
.then(a => {
|
||||||
|
const server = a.listen(PORT, () => console.log('Server on port ' + PORT));
|
||||||
|
// Graceful shutdown по SIGTERM (k8s)
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
console.log('SIGTERM — shutting down...');
|
||||||
|
server.close(() => {
|
||||||
|
console.log('HTTP closed');
|
||||||
|
const { pool } = require('./src/db');
|
||||||
|
pool.end().then(() => { console.log('DB pool closed'); process.exit(0); });
|
||||||
|
});
|
||||||
|
setTimeout(() => { console.log('Forced exit'); process.exit(0); }, 10000);
|
||||||
|
});
|
||||||
|
return server;
|
||||||
|
})
|
||||||
.catch(e => { console.error('Startup error:', e); process.exit(1); });
|
.catch(e => { console.error('Startup error:', e); process.exit(1); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- 001: Добавить колонку impersonated_by в audit_log
|
||||||
|
-- Дата: 2026-06 (была добавлена в ensureSchema вручную)
|
||||||
|
|
||||||
|
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS impersonated_by VARCHAR(255) DEFAULT NULL;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- 002: Добавить значение RESTORE в CHECK-констрейнт audit_log.action
|
||||||
|
-- Дата: 2026-06 (до этого CHECK допускал только CREATE, UPDATE, DELETE)
|
||||||
|
|
||||||
|
-- Сначала удаляем старый CHECK, потом создаём новый со значением RESTORE
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
cn text;
|
||||||
|
BEGIN
|
||||||
|
SELECT con.conname INTO cn
|
||||||
|
FROM pg_constraint con
|
||||||
|
JOIN pg_class rel ON rel.oid = con.conrelid
|
||||||
|
WHERE rel.relname = 'audit_log' AND con.contype = 'c';
|
||||||
|
IF cn IS NOT NULL THEN
|
||||||
|
EXECUTE 'ALTER TABLE audit_log DROP CONSTRAINT ' || cn;
|
||||||
|
END IF;
|
||||||
|
EXECUTE 'ALTER TABLE audit_log ADD CONSTRAINT audit_log_action_check CHECK (action IN (''CREATE'',''UPDATE'',''DELETE'',''RESTORE''))';
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Миграции БД
|
||||||
|
|
||||||
|
Каждый файл — одна миграция. Применяются при старте приложения автоматически в порядке сортировки имён.
|
||||||
|
|
||||||
|
## Как добавить новую миграцию
|
||||||
|
|
||||||
|
1. Создать файл `{номер}_{описание}.sql` в этой папке
|
||||||
|
2. Номер должен быть следующим (003, 004, ...)
|
||||||
|
3. SQL должен быть идемпотентным (`IF NOT EXISTS` где возможно)
|
||||||
|
4. Закоммитить и запушнуть
|
||||||
|
|
||||||
|
## Важно
|
||||||
|
|
||||||
|
- Миграции выполняются **один раз**. После применения имя файла записывается в таблицу `_migrations`.
|
||||||
|
- Уже работающие миграции не перезапускаются — если нужно что-то изменить, создавай новую миграцию, не редактируй старую.
|
||||||
|
- Все DDL должны быть безопасны для повторного запуска — используй `IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS`.
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* src/api/index.js — REST API v1.
|
|
||||||
*
|
|
||||||
* Монтируется в server.js ДО общего auth.middleware:
|
|
||||||
* app.use('/api/v1', createApiRouter({ auth, q }))
|
|
||||||
*
|
|
||||||
* Аутентификация: только Bearer JWT (без сессий, без CSRF).
|
|
||||||
* Ответы: JSON везде.
|
|
||||||
* Переиспользует: src/queries.js, src/auth.js (middleware для валидации токена).
|
|
||||||
*
|
|
||||||
* Эндпоинты:
|
|
||||||
* GET /api/v1/entries — список записей компании
|
|
||||||
* POST /api/v1/entries — создать запись
|
|
||||||
* PATCH /api/v1/entries/:id — обновить запись
|
|
||||||
* DELETE /api/v1/entries/:id — удалить запись
|
|
||||||
* GET /api/v1/companies — все компании (admin)
|
|
||||||
* PATCH /api/v1/companies/:id/limit — установить лимит (admin)
|
|
||||||
*/
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const { requireBearer } = require('./middleware/bearerAuth');
|
|
||||||
const { createEntriesRouter } = require('./routes/entries');
|
|
||||||
const { createAdminRouter } = require('./routes/admin');
|
|
||||||
|
|
||||||
function createApiRouter({ auth, q }) {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// Все /api/v1/* требуют Bearer — 401 JSON если нет токена
|
|
||||||
router.use(requireBearer);
|
|
||||||
// Валидация токена — Bearer-only (игнорирует сессию, всегда проверяет JWT)
|
|
||||||
router.use(auth.bearerMiddleware);
|
|
||||||
|
|
||||||
router.use('/entries', createEntriesRouter({ q }));
|
|
||||||
router.use('/', createAdminRouter({ q }));
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { createApiRouter };
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
|
|
||||||
// Отдельный requireAdmin для API — возвращает JSON вместо plain text
|
|
||||||
function apiRequireAdmin(req, res, next) {
|
|
||||||
if (!req.user || !req.user.isAdmin) {
|
|
||||||
return res.status(403).json({ error: 'Admin role required' });
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
||||||
function createAdminRouter({ q }) {
|
|
||||||
const router = express.Router();
|
|
||||||
const json = express.json({ limit: '8kb' });
|
|
||||||
|
|
||||||
// GET /api/v1/companies — все компании (admin only)
|
|
||||||
router.get('/companies', apiRequireAdmin, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const companies = await q.getAllCompanies();
|
|
||||||
res.json({ companies });
|
|
||||||
} catch (e) {
|
|
||||||
res.status(500).json({ error: e.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// PATCH /api/v1/companies/:id/limit — установить лимит (admin only)
|
|
||||||
router.patch('/companies/:id/limit', apiRequireAdmin, json, async (req, res) => {
|
|
||||||
const rawLimit = (req.body || {}).limit;
|
|
||||||
const isReset = rawLimit === null || rawLimit === undefined;
|
|
||||||
const limit = isReset ? null : parseInt(rawLimit, 10);
|
|
||||||
if (!isReset && (isNaN(limit) || limit < 0)) {
|
|
||||||
return res.status(400).json({ error: 'limit must be non-negative integer or null to reset' });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await q.setLimit(req.params.id, limit);
|
|
||||||
res.json({ ok: true, limit });
|
|
||||||
} catch (e) {
|
|
||||||
res.status(e.status || 500).json({ error: e.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /api/v1/audit — журнал действий (admin only)
|
|
||||||
// ?company=<id> — фильтр по компании (опционально)
|
|
||||||
router.get('/audit', apiRequireAdmin, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const companyId = req.query.company ? parseInt(req.query.company, 10) : null;
|
|
||||||
const [rows, companies] = await Promise.all([
|
|
||||||
q.getAudit(companyId && Number.isFinite(companyId) ? companyId : null),
|
|
||||||
q.getAllCompanies(),
|
|
||||||
]);
|
|
||||||
res.json({ rows, companies });
|
|
||||||
} catch (e) {
|
|
||||||
res.status(500).json({ error: e.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { createAdminRouter };
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const { aggregateCIDRs } = require('../../validators');
|
|
||||||
|
|
||||||
function createEntriesRouter({ q }) {
|
|
||||||
const router = express.Router();
|
|
||||||
const json = express.json({ limit: '32kb' });
|
|
||||||
|
|
||||||
// Маппинг сообщений ошибок на HTTP-коды
|
|
||||||
function handleQueryError(e, res) {
|
|
||||||
const msg = e.message || '';
|
|
||||||
// 409: бизнес-конфликты (дубликат, пересечение, лимит)
|
|
||||||
if (
|
|
||||||
msg.includes('Лимит исчерпан') ||
|
|
||||||
msg.includes('Такой адрес уже существует') ||
|
|
||||||
msg.includes('Пересечение с существующей записью')
|
|
||||||
) {
|
|
||||||
return res.status(409).json({ error: msg });
|
|
||||||
}
|
|
||||||
// 404: запись не найдена
|
|
||||||
if (msg.includes('Запись не найдена')) {
|
|
||||||
return res.status(404).json({ error: msg });
|
|
||||||
}
|
|
||||||
// 400: ошибки валидации от validate() и blocked-ranges
|
|
||||||
if (
|
|
||||||
msg.includes('Некорректн') ||
|
|
||||||
msg.includes('Маска должна быть') ||
|
|
||||||
msg.includes('Пустое значение') ||
|
|
||||||
msg.includes('IPv6 не поддерживается') ||
|
|
||||||
msg.includes('пересекается с запрещённым')
|
|
||||||
) {
|
|
||||||
return res.status(400).json({ error: msg });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ error: msg });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Определить компанию из запроса.
|
|
||||||
// Admin с ?company=<id> → компания по ID.
|
|
||||||
// User с ?client_id=<id> (мульти-компания) → компания по clientId.
|
|
||||||
// Иначе → компания пользователя (UPSERT).
|
|
||||||
async function resolveCompany(req) {
|
|
||||||
if (req.user.isAdmin && req.query.company) {
|
|
||||||
const id = parseInt(req.query.company, 10);
|
|
||||||
if (!Number.isFinite(id) || id <= 0) {
|
|
||||||
throw Object.assign(new Error('Invalid company id'), { status: 400 });
|
|
||||||
}
|
|
||||||
const c = await q.getCompanyById(id);
|
|
||||||
if (!c) throw Object.assign(new Error('Company not found'), { status: 404 });
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
// Мульти-компания: client_id разрешён только если есть в профилях (IAM) или allClientIds (JWT fallback)
|
|
||||||
const requestedId = (req.query.client_id || '').trim();
|
|
||||||
const allowedIds = req.user.profiles && req.user.profiles.length
|
|
||||||
? req.user.profiles.map(p => p.client_id)
|
|
||||||
: (req.user.allClientIds || []);
|
|
||||||
// Без верифицированного списка — client_id не принимаем
|
|
||||||
const effectiveClientId = (requestedId && allowedIds.length && allowedIds.includes(requestedId))
|
|
||||||
? requestedId
|
|
||||||
: req.user.clientId;
|
|
||||||
// companyName: для переключённой компании — находим в profiles или используем clientId
|
|
||||||
let effectiveName = effectiveClientId === req.user.clientId
|
|
||||||
? req.user.companyName
|
|
||||||
: effectiveClientId;
|
|
||||||
if (req.user.profiles) {
|
|
||||||
const p = req.user.profiles.find(p => p.client_id === effectiveClientId);
|
|
||||||
if (p) effectiveName = p.company_name;
|
|
||||||
}
|
|
||||||
return q.getOrCreateCompany(effectiveClientId, effectiveName, req.user.email);
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /api/v1/entries — список записей + лимит
|
|
||||||
// ?includeDeleted=true — показать soft-deleted (только admin)
|
|
||||||
router.get('/', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const company = await resolveCompany(req);
|
|
||||||
const includeDeleted = req.user.isAdmin && req.query.includeDeleted === 'true';
|
|
||||||
const [entries, limit] = await Promise.all([
|
|
||||||
q.listEntries(company.id, includeDeleted),
|
|
||||||
q.getLimit(company),
|
|
||||||
]);
|
|
||||||
res.json({ entries, limit, used: entries.length });
|
|
||||||
} catch (e) {
|
|
||||||
res.status(e.status || 500).json({ error: e.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /api/v1/entries/export — агрегированный список CIDR
|
|
||||||
// По умолчанию JSON. ?format=txt → text/plain.
|
|
||||||
// Только для админа.
|
|
||||||
router.get('/export', async (req, res) => {
|
|
||||||
try {
|
|
||||||
let companyId = null;
|
|
||||||
if (!req.user.isAdmin) {
|
|
||||||
const requestedId = (req.query.client_id || '').trim();
|
|
||||||
const effectiveClientId = (requestedId && req.user.allClientIds && req.user.allClientIds.includes(requestedId))
|
|
||||||
? requestedId
|
|
||||||
: req.user.clientId;
|
|
||||||
const company = await q.getOrCreateCompany(effectiveClientId, effectiveClientId);
|
|
||||||
companyId = company.id;
|
|
||||||
} else if (req.query.company) {
|
|
||||||
const id = parseInt(req.query.company, 10);
|
|
||||||
if (Number.isFinite(id) && id > 0) companyId = id;
|
|
||||||
}
|
|
||||||
const cidrs = await q.getExportCIDRs(companyId);
|
|
||||||
const aggregated = aggregateCIDRs(cidrs);
|
|
||||||
|
|
||||||
const isText = req.query.format === 'txt';
|
|
||||||
if (!isText) {
|
|
||||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
||||||
const fname = (req.query.filename || 'white-list.json').replace(/[^\w\-_. ]/g, '_');
|
|
||||||
const disp = req.query.view === '1' ? 'inline' : 'attachment';
|
|
||||||
res.setHeader('Content-Disposition', `${disp}; filename="${fname}"`);
|
|
||||||
return res.json({ cidrs: aggregated, count: aggregated.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
||||||
const fname = (req.query.filename || 'white-list.txt').replace(/[^\w\-_. ]/g, '_');
|
|
||||||
const disp = req.query.view === '1' ? 'inline' : 'attachment';
|
|
||||||
res.setHeader('Content-Disposition', `${disp}; filename="${fname}"`);
|
|
||||||
res.send(aggregated.join('\n') + (aggregated.length ? '\n' : ''));
|
|
||||||
} catch (e) {
|
|
||||||
res.status(500).json({ error: e.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /api/v1/entries — создать запись
|
|
||||||
router.post('/', json, async (req, res) => {
|
|
||||||
const { value, comment } = req.body || {};
|
|
||||||
if (!value) return res.status(400).json({ error: 'value required' });
|
|
||||||
if (comment && comment.length > 255) return res.status(400).json({ error: 'Комментарий слишком длинный (максимум 255 символов)' });
|
|
||||||
try {
|
|
||||||
const company = await resolveCompany(req);
|
|
||||||
const { entry, wasNormalized } = await q.createEntry(company.id, value, comment || '', req.user.email, req.user.originalUserEmail);
|
|
||||||
res.status(201).json({ entry, wasNormalized });
|
|
||||||
} catch (e) {
|
|
||||||
if (e.status) return res.status(e.status).json({ error: e.message });
|
|
||||||
handleQueryError(e, res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// PATCH /api/v1/entries/:id — обновить запись
|
|
||||||
router.patch('/:id', json, async (req, res) => {
|
|
||||||
const { value, comment } = req.body || {};
|
|
||||||
if (!value) return res.status(400).json({ error: 'value required' });
|
|
||||||
if (comment && comment.length > 255) return res.status(400).json({ error: 'Комментарий слишком длинный (максимум 255 символов)' });
|
|
||||||
try {
|
|
||||||
const company = await resolveCompany(req);
|
|
||||||
const { entry, wasNormalized } = await q.updateEntry(req.params.id, company.id, value, comment ?? '', req.user.email, req.user.originalUserEmail);
|
|
||||||
res.json({ entry, wasNormalized });
|
|
||||||
} catch (e) {
|
|
||||||
if (e.status) return res.status(e.status).json({ error: e.message });
|
|
||||||
handleQueryError(e, res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// DELETE /api/v1/entries/:id — удалить запись
|
|
||||||
router.delete('/:id', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const company = await resolveCompany(req);
|
|
||||||
await q.deleteEntry(req.params.id, company.id, req.user.email, req.user.originalUserEmail);
|
|
||||||
res.status(204).send();
|
|
||||||
} catch (e) {
|
|
||||||
if (e.status) return res.status(e.status).json({ error: e.message });
|
|
||||||
handleQueryError(e, res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { createEntriesRouter };
|
|
||||||
+5
-4
@@ -25,7 +25,8 @@ const jwt = require('jsonwebtoken');
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const { IAM_API_URL } = require('./config');
|
const { iamApiBase } = require('../v2/src/config');
|
||||||
|
const IAM_API_URL = iamApiBase;
|
||||||
|
|
||||||
// ── Конфигурация из окружения ────────────────────────────────────────────────
|
// ── Конфигурация из окружения ────────────────────────────────────────────────
|
||||||
const ISSUER = process.env.JWT_ISSUER || 'mock-auth-api';
|
const ISSUER = process.env.JWT_ISSUER || 'mock-auth-api';
|
||||||
@@ -286,7 +287,7 @@ function verifyAnyToken(token, devMode) {
|
|||||||
*/
|
*/
|
||||||
async function fetchIamUser(token) {
|
async function fetchIamUser(token) {
|
||||||
const raw = await new Promise((resolve, reject) => {
|
const raw = await new Promise((resolve, reject) => {
|
||||||
const url = new URL(IAM_API_URL + '/api/v1/auth/user');
|
const url = new URL(IAM_API_URL + '/auth/user');
|
||||||
const lib = url.protocol === 'https:' ? https : http;
|
const lib = url.protocol === 'https:' ? https : http;
|
||||||
const req = lib.request({
|
const req = lib.request({
|
||||||
hostname: url.hostname,
|
hostname: url.hostname,
|
||||||
@@ -343,7 +344,7 @@ async function fetchIamUser(token) {
|
|||||||
async function switchProfile(token, profileId) {
|
async function switchProfile(token, profileId) {
|
||||||
const body = JSON.stringify({ profile_id: profileId });
|
const body = JSON.stringify({ profile_id: profileId });
|
||||||
const raw = await new Promise((resolve, reject) => {
|
const raw = await new Promise((resolve, reject) => {
|
||||||
const url = new URL(IAM_API_URL + '/api/v1/user/switch-profile');
|
const url = new URL(IAM_API_URL + '/user/switch-profile');
|
||||||
const lib = url.protocol === 'https:' ? https : http;
|
const lib = url.protocol === 'https:' ? https : http;
|
||||||
const req = lib.request({
|
const req = lib.request({
|
||||||
hostname: url.hostname,
|
hostname: url.hostname,
|
||||||
@@ -527,7 +528,7 @@ function httpPost(url, body, headers) {
|
|||||||
* Защита от open redirect: //evil.com и https://evil.com → '/'.
|
* Защита от open redirect: //evil.com и https://evil.com → '/'.
|
||||||
*/
|
*/
|
||||||
function safeReturn(target) {
|
function safeReturn(target) {
|
||||||
if (typeof target === 'string' && target.startsWith('/') && !target.startsWith('//')) {
|
if (typeof target === 'string' && target.startsWith('/') && !target.startsWith('//') && target[1] !== '\\') {
|
||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
return '/';
|
return '/';
|
||||||
|
|||||||
+2
-2
@@ -13,7 +13,7 @@
|
|||||||
// ── IAM API ───────────────────────────────────────────────────────────────────
|
// ── IAM API ───────────────────────────────────────────────────────────────────
|
||||||
// URL IAM-сервиса для получения профилей пользователя (GET /api/v1/auth/user)
|
// URL IAM-сервиса для получения профилей пользователя (GET /api/v1/auth/user)
|
||||||
// и переключения активной компании (POST /api/v1/user/switch-profile).
|
// и переключения активной компании (POST /api/v1/user/switch-profile).
|
||||||
const IAM_API_URL = (process.env.IAM_API_URL || 'https://auth-api-dev.ngcloud.ru').replace(/\/$/, '');
|
const IAM_API_URL = (process.env.IAM_API_URL || process.env.IAM_API_BASE || 'https://auth-api.ngcloud.ru').replace(/\/$/, '');
|
||||||
|
|
||||||
// ── Мок-пользователи (только dev/staging) ─────────────────────────────────────
|
// ── Мок-пользователи (только dev/staging) ─────────────────────────────────────
|
||||||
// В продакшене этот список не используется — вход через Keycloak.
|
// В продакшене этот список не используется — вход через Keycloak.
|
||||||
@@ -94,4 +94,4 @@ function backUrl(isAdmin, companyId, extra = {}) {
|
|||||||
// Передаётся в шаблоны через app.locals чтобы отображаться в UI.
|
// Передаётся в шаблоны через app.locals чтобы отображаться в UI.
|
||||||
const { version: APP_VERSION } = require('../package.json');
|
const { version: APP_VERSION } = require('../package.json');
|
||||||
|
|
||||||
module.exports = { MOCK_USERS, backUrl, APP_VERSION, IAM_API_URL };
|
module.exports = { MOCK_USERS, backUrl, APP_VERSION, IAM_API_URL: (process.env.IAM_API_URL || process.env.IAM_API_BASE || 'https://auth-api.ngcloud.ru').replace(/\/$/, '') };
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const CSP_DIRECTIVES = {
|
|||||||
defaultSrc: ["'self'"],
|
defaultSrc: ["'self'"],
|
||||||
// 'unsafe-inline' нужен пока стили и скрипты inline в EJS.
|
// 'unsafe-inline' нужен пока стили и скрипты inline в EJS.
|
||||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||||
|
scriptSrcAttr: ["'unsafe-inline'"], // для onchange/onclick в EJS
|
||||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||||
imgSrc: ["'self'", 'data:'], // data: для SVG-иконок
|
imgSrc: ["'self'", 'data:'], // data: для SVG-иконок
|
||||||
fontSrc: ["'self'"],
|
fontSrc: ["'self'"],
|
||||||
|
|||||||
+13
-11
@@ -32,15 +32,11 @@ function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, authLimit
|
|||||||
router.get('/callback', async (req, res) => {
|
router.get('/callback', async (req, res) => {
|
||||||
const { code, state } = req.query;
|
const { code, state } = req.query;
|
||||||
|
|
||||||
// Проверка state — защита от CSRF в OAuth-потоке
|
// Проверка state — защита от CSRF в OAuth-потоке (всегда)
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (!state || state !== req.session.oidcState) {
|
||||||
if (req.session) delete req.session.oidcState;
|
return res.status(403).send('Invalid state parameter');
|
||||||
} else {
|
|
||||||
if (!state || state !== req.session.oidcState) {
|
|
||||||
return res.status(403).send('Invalid state parameter');
|
|
||||||
}
|
|
||||||
delete req.session.oidcState;
|
|
||||||
}
|
}
|
||||||
|
delete req.session.oidcState;
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return res.status(400).send('Missing authorization code');
|
return res.status(400).send('Missing authorization code');
|
||||||
@@ -57,6 +53,11 @@ function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, authLimit
|
|||||||
const payload = jwt.decode(accessToken);
|
const payload = jwt.decode(accessToken);
|
||||||
if (!payload) throw new Error('Failed to decode token');
|
if (!payload) throw new Error('Failed to decode token');
|
||||||
|
|
||||||
|
// Регенерируем ID сессии — защита от session fixation
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
req.session.regenerate(err => err ? reject(err) : resolve());
|
||||||
|
});
|
||||||
|
|
||||||
req.session.token = accessToken;
|
req.session.token = accessToken;
|
||||||
req.session.idToken = tokenData.idToken || null;
|
req.session.idToken = tokenData.idToken || null;
|
||||||
|
|
||||||
@@ -73,8 +74,9 @@ function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, authLimit
|
|||||||
companyName: iamData.companyName,
|
companyName: iamData.companyName,
|
||||||
isAdmin: iamData.isAdmin,
|
isAdmin: iamData.isAdmin,
|
||||||
isImpersonated: iamData.isImpersonated,
|
isImpersonated: iamData.isImpersonated,
|
||||||
impersonationType: iamData.impersonationType,
|
impersonationType: iamData.impersonationType,
|
||||||
originalUserEmail: iamData.originalUserEmail,
|
impersonatedCompanyId: iamData.impersonatedCompanyId,
|
||||||
|
originalUserEmail: iamData.originalUserEmail,
|
||||||
originalUserFullName: iamData.originalUserFullName,
|
originalUserFullName: iamData.originalUserFullName,
|
||||||
originalUserCompany: iamData.originalUserCompany,
|
originalUserCompany: iamData.originalUserCompany,
|
||||||
fio: iamData.fio,
|
fio: iamData.fio,
|
||||||
@@ -99,7 +101,7 @@ function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, authLimit
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const returnTo = safeLocal(req.session.oidcReturnTo) || '/';
|
const returnTo = '/v2/app';
|
||||||
delete req.session.oidcReturnTo;
|
delete req.session.oidcReturnTo;
|
||||||
res.redirect(returnTo);
|
res.redirect(returnTo);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
-1019
File diff suppressed because it is too large
Load Diff
@@ -1,458 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* tests/integration.js — НОВЫЕ интеграционные тесты (v2).
|
|
||||||
*
|
|
||||||
* Архитектура: UI-слой (SSR EJS) → HTTP → API-слой (/api/v1/*).
|
|
||||||
*
|
|
||||||
* Запуск: node tests/integration.js
|
|
||||||
* Требует: DEV_MODE=true, .env с БД.
|
|
||||||
*
|
|
||||||
* Разделы:
|
|
||||||
* 1. UI Login — mock-пользователи + ручной ввод clientId
|
|
||||||
* 2. UI Pages — главная, admin, audit (после логина)
|
|
||||||
* 3. UI Session — logout, нет доступа без сессии
|
|
||||||
* 4. UI Export — /export, ?view=1, ?filename=X
|
|
||||||
* 5. UI Multi-company — ручной вход с clientId через запятую
|
|
||||||
* 6. UI CSRF — защищённые POST требуют _csrf
|
|
||||||
* 7. UI Admin — admin-страницы доступны/недоступны
|
|
||||||
* 8. UI Error pages — 404, редирект на логин
|
|
||||||
* 9. API → UI的一致性 — данные совпадают
|
|
||||||
*/
|
|
||||||
|
|
||||||
require('dotenv').config();
|
|
||||||
|
|
||||||
const supertest = require('supertest');
|
|
||||||
const { pool } = require('../src/db');
|
|
||||||
|
|
||||||
let pass = 0, fail = 0;
|
|
||||||
const errors = [];
|
|
||||||
|
|
||||||
function log(ok, name, detail = '') {
|
|
||||||
if (ok) {
|
|
||||||
console.log(' PASS', name);
|
|
||||||
pass++;
|
|
||||||
} else {
|
|
||||||
const msg = ' FAIL ' + name + (detail ? ': ' + detail : '');
|
|
||||||
console.error(msg);
|
|
||||||
errors.push(msg);
|
|
||||||
fail++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Хелперы ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Извлечь CSRF-токен из HTML-страницы */
|
|
||||||
function csrfFromHtml(html) {
|
|
||||||
const m = html.match(/name="_csrf"\s+value="([^"]+)"/);
|
|
||||||
return m ? m[1] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Залогиниться под mock-пользователем (выбор из выпадашки).
|
|
||||||
* Возвращает agent с установленной сессией.
|
|
||||||
*/
|
|
||||||
async function loginAsMock(app, clientId) {
|
|
||||||
const agent = supertest.agent(app);
|
|
||||||
|
|
||||||
// POST /login — на login-форме нет CSRF (защита через rateLimit)
|
|
||||||
const postRes = await agent
|
|
||||||
.post('/login')
|
|
||||||
.type('form')
|
|
||||||
.send({ clientId, returnTo: '/' });
|
|
||||||
|
|
||||||
if (postRes.status !== 302) throw new Error(`POST /login: ${postRes.status} ${postRes.text?.slice(0,100)}`);
|
|
||||||
return agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Залогиниться через ручной ввод clientId.
|
|
||||||
*/
|
|
||||||
async function loginManual(app, clientId) {
|
|
||||||
const agent = supertest.agent(app);
|
|
||||||
|
|
||||||
const postRes = await agent
|
|
||||||
.post('/login-token')
|
|
||||||
.type('form')
|
|
||||||
.send({ clientId, returnTo: '/' });
|
|
||||||
|
|
||||||
if (postRes.status !== 302) throw new Error(`POST /login-token: ${postRes.status} ${postRes.text?.slice(0,100)}`);
|
|
||||||
return agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Cleanup ──────────────────────────────────────────────────────────────────
|
|
||||||
async function cleanup() {
|
|
||||||
const testCidrs = [
|
|
||||||
'8.8.8.100/32', '8.8.8.101/32', '8.8.8.102/32',
|
|
||||||
'9.9.9.1/32', '9.9.9.2/32', '9.9.9.3/32',
|
|
||||||
'4.4.4.1/32', '4.4.4.2/32', '4.4.4.3/32', '4.4.4.5/32',
|
|
||||||
];
|
|
||||||
for (const c of testCidrs) {
|
|
||||||
await pool.query(
|
|
||||||
`UPDATE whitelist_entries SET deleted_at = NOW(), deleted_by = 'integration-test-cleanup'
|
|
||||||
WHERE value_cidr = $1 AND deleted_at IS NULL`, [c]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Основной раннер ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
console.log('\n══════════════════════════════════════════════════════');
|
|
||||||
console.log(' Интеграционные тесты v2 (UI + API)');
|
|
||||||
console.log('══════════════════════════════════════════════════════\n');
|
|
||||||
|
|
||||||
let app;
|
|
||||||
try {
|
|
||||||
const mod = require('../server');
|
|
||||||
app = await mod.start();
|
|
||||||
} catch (e) {
|
|
||||||
console.error('FATAL: не удалось запустить сервер:', e.message);
|
|
||||||
await pool.end();
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { MOCK_USERS } = require('../src/config');
|
|
||||||
const uAdmin = MOCK_USERS.find(u => u.id === 'admin');
|
|
||||||
const uAlfa = MOCK_USERS.find(u => u.id === 'alfa');
|
|
||||||
const uBeta = MOCK_USERS.find(u => u.id === 'beta');
|
|
||||||
const uMulti = MOCK_USERS.find(u => u.id === 'multi');
|
|
||||||
|
|
||||||
await cleanup();
|
|
||||||
console.log('── Cleanup done ──\n');
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// 1. UI Login
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
console.log('── 1. UI Login ──');
|
|
||||||
|
|
||||||
// 1a: GET /login → страница входа
|
|
||||||
{
|
|
||||||
const r = await supertest(app).get('/login');
|
|
||||||
log(r.status === 200, '1a: GET /login → 200', `status=${r.status}`);
|
|
||||||
log(r.text.includes('IP WhiteList'), '1b: страница содержит заголовок');
|
|
||||||
log(r.text.includes('Быстрый вход (mock)'), '1c: mock-форма присутствует (DEV_MODE)');
|
|
||||||
log(r.text.includes('Ручной вход (clientId)'), '1d: форма ручного ввода присутствует');
|
|
||||||
log(!r.text.includes('Bearer токен'), '1e: старой формы Bearer токена НЕТ');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1f: GET /login-token → редирект на /login
|
|
||||||
{
|
|
||||||
const r = await supertest(app).get('/login-token');
|
|
||||||
log(r.status === 302, '1f: GET /login-token → 302 редирект на /login', `status=${r.status}`);
|
|
||||||
log(r.headers.location === '/login', '1g: Location: /login');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1h: POST /login (mock) — успешный вход
|
|
||||||
let agentAlfa;
|
|
||||||
{
|
|
||||||
agentAlfa = await loginAsMock(app, uAlfa.clientId);
|
|
||||||
const r = await agentAlfa.get('/');
|
|
||||||
log(r.status === 200, '1h: после mock-логина GET / → 200', `status=${r.status}`);
|
|
||||||
log(r.text.includes(uAlfa.clientId), '1i: главная содержит clientId пользователя');
|
|
||||||
log(r.text.includes('компания'), '1j: главная содержит блок "компания"');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1k: POST /login-token (ручной ввод) — успешный вход
|
|
||||||
{
|
|
||||||
const agent = await loginManual(app, 'DEBUG_USER_42');
|
|
||||||
const r = await agent.get('/');
|
|
||||||
log(r.status === 200, '1k: после ручного входа GET / → 200', `status=${r.status}`);
|
|
||||||
log(r.text.includes('DEBUG_USER_42'), '1l: главная содержит DEBUG_USER_42');
|
|
||||||
|
|
||||||
// заодно: мульти-компания через запятую (тем же агентом, без доп. логина)
|
|
||||||
const agentM = await loginManual(app, 'AAA,BBB,CCC');
|
|
||||||
const rM = await agentM.get('/');
|
|
||||||
log(rM.status === 200, '1m: ручной вход AAA,BBB,CCC → 200');
|
|
||||||
log(rM.text.includes('AAA'), '1n: главная содержит первый clientId');
|
|
||||||
log(rM.text.includes('BBB') || rM.text.includes('CCC'), '1o: есть другие компании (переключатель)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1p: POST /login без clientId → ошибка
|
|
||||||
{
|
|
||||||
const agent = supertest.agent(app);
|
|
||||||
const postR = await agent.post('/login').type('form').send({ returnTo: '/' });
|
|
||||||
log(postR.status === 302, '1p: POST /login без clientId → 302', `status=${postR.status}`);
|
|
||||||
log(postR.headers.location?.includes('error='), '1q: редирект содержит ошибку');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1r: POST /login-token без clientId → ошибка
|
|
||||||
{
|
|
||||||
const agent = supertest.agent(app);
|
|
||||||
const postR = await agent.post('/login-token').type('form').send({});
|
|
||||||
log(postR.status === 302, '1r: POST /login-token без clientId → 302', `status=${postR.status}`);
|
|
||||||
log(postR.headers.location?.includes('error='), '1s: редирект содержит ошибку');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1t: несуществующий mock-пользователь → ошибка
|
|
||||||
{
|
|
||||||
const agent = supertest.agent(app);
|
|
||||||
const postR = await agent.post('/login').type('form').send({ clientId: 'NONEXISTENT', returnTo: '/' });
|
|
||||||
log(postR.status === 302, '1t: POST /login NONEXISTENT → 302', `status=${postR.status}`);
|
|
||||||
log(postR.headers.location?.includes('error='), '1u: редирект содержит ошибку');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// 2. UI Pages (после логина)
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
console.log('\n── 2. UI Pages ──');
|
|
||||||
|
|
||||||
// 2a: главная страница (alfa)
|
|
||||||
{
|
|
||||||
const r = await agentAlfa.get('/');
|
|
||||||
log(r.status === 200, '2a: GET / → 200', `status=${r.status}`);
|
|
||||||
log(r.text.includes('Добавить адрес'), '2b: форма "Добавить адрес" на главной');
|
|
||||||
log(r.text.includes('logout') || r.text.includes('Выйти'), '2c: ссылка выхода');
|
|
||||||
log(r.text.includes('Белые списки IP'), '2d: заголовок страницы');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2e: admin страница (alfa — не admin)
|
|
||||||
{
|
|
||||||
const r = await agentAlfa.get('/admin');
|
|
||||||
log([200, 302, 403].includes(r.status), '2e: GET /admin (не-admin) → редирект или 403', `status=${r.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2f: главная страница рендерит список (даже пустой)
|
|
||||||
{
|
|
||||||
const r = await agentAlfa.get('/');
|
|
||||||
log(r.status === 200, '2f: GET / → 200', `status=${r.status}`);
|
|
||||||
log(r.text.includes('Белые списки IP'), '2g: страница содержит заголовок');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2h: версия приложения в футере
|
|
||||||
{
|
|
||||||
const ver = require('../package.json').version;
|
|
||||||
const r = await agentAlfa.get('/');
|
|
||||||
log(r.text.includes('v' + ver), '2h: версия ' + ver + ' в футере');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// 3. UI Session
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
console.log('\n── 3. UI Session ──');
|
|
||||||
|
|
||||||
// 3a: без сессии → редирект на /login
|
|
||||||
{
|
|
||||||
const r = await supertest(app).get('/');
|
|
||||||
log(r.status === 302, '3a: GET / без сессии → 302', `status=${r.status}`);
|
|
||||||
log(r.headers.location?.startsWith('/login'), '3b: редирект на /login', `location=${r.headers.location}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3c: /logout — уничтожает сессию
|
|
||||||
{
|
|
||||||
const r = await agentAlfa.get('/logout');
|
|
||||||
log(r.status === 302, '3c: GET /logout → 302', `status=${r.status}`);
|
|
||||||
log(r.headers.location === '/login', '3d: редирект на /login');
|
|
||||||
|
|
||||||
// После логаута — нет доступа к /
|
|
||||||
const r2 = await agentAlfa.get('/');
|
|
||||||
log(r2.status === 302, '3e: после логаута GET / → 302', `status=${r2.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3f: ?returnTo= параметр
|
|
||||||
{
|
|
||||||
const r = await supertest(app).get('/entries?foo=bar');
|
|
||||||
log(r.status === 302, '3f: GET /entries без сессии → 302', `status=${r.status}`);
|
|
||||||
log(r.headers.location?.includes('returnTo'), '3g: Location содержит returnTo', `location=${r.headers.location}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// 4. UI Export
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
console.log('\n── 4. UI Export ──');
|
|
||||||
|
|
||||||
// Свежий логин для export-тестов
|
|
||||||
let agentExp = await loginAsMock(app, uAlfa.clientId);
|
|
||||||
|
|
||||||
// Добавим запись для тестов экспорта через API
|
|
||||||
{
|
|
||||||
const auth = await require('../src/auth').initAuth();
|
|
||||||
const token = auth.issueMockToken({
|
|
||||||
clientId: uAlfa.clientId, companyId: uAlfa.companyId,
|
|
||||||
companyName: uAlfa.companyName, email: uAlfa.email,
|
|
||||||
});
|
|
||||||
await supertest(app)
|
|
||||||
.post('/api/v1/entries')
|
|
||||||
.set('Authorization', 'Bearer ' + token)
|
|
||||||
.send({ value: '8.8.8.100', comment: 'export-test' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4a: GET /export → attachment (скачивание)
|
|
||||||
{
|
|
||||||
const r = await agentExp.get('/export');
|
|
||||||
log(r.status === 200, '4a: GET /export → 200', `status=${r.status}`);
|
|
||||||
log(r.headers['content-disposition']?.includes('attachment'), '4b: Content-Disposition: attachment', `disp=${r.headers['content-disposition']}`);
|
|
||||||
log(r.headers['content-disposition']?.includes('white-list.txt'), '4c: имя файла white-list.txt по умолчанию');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4d: GET /export?view=1 → inline (просмотр)
|
|
||||||
{
|
|
||||||
const r = await agentExp.get('/export?view=1');
|
|
||||||
log(r.status === 200, '4d: GET /export?view=1 → 200', `status=${r.status}`);
|
|
||||||
log(r.headers['content-disposition']?.includes('inline'), '4e: Content-Disposition: inline', `disp=${r.headers['content-disposition']}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4f: GET /export?filename=custom.txt
|
|
||||||
{
|
|
||||||
const r = await agentExp.get('/export?filename=custom.txt');
|
|
||||||
log(r.status === 200, '4f: GET /export?filename=custom.txt → 200', `status=${r.status}`);
|
|
||||||
log(r.headers['content-disposition']?.includes('custom.txt'), '4g: имя файла custom.txt', `disp=${r.headers['content-disposition']}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4h: GET /export без сессии → 200 (ПУБЛИЧНЫЙ, ВРЕМЕННО)
|
|
||||||
{
|
|
||||||
const r = await supertest(app).get('/export');
|
|
||||||
log(r.status === 200, '4h: GET /export без авторизации → 200 (публичный)', `status=${r.status}`);
|
|
||||||
log(r.headers['content-type']?.includes('text/plain'), '4i: Content-Type text/plain');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4j: GET /export?view=1&filename=test.txt
|
|
||||||
{
|
|
||||||
const r = await supertest(app).get('/export?view=1&filename=test.txt');
|
|
||||||
log(r.status === 200, '4j: GET /export?view=1&filename=test.txt → 200', `status=${r.status}`);
|
|
||||||
log(r.headers['content-disposition']?.includes('inline'), '4k: inline (view=1 приоритетнее filename)');
|
|
||||||
log(r.headers['content-disposition']?.includes('test.txt'), '4l: имя файла test.txt');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// 5. UI Multi-company
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
console.log('\n── 5. UI Multi-company ──');
|
|
||||||
|
|
||||||
let agentMulti;
|
|
||||||
if (uMulti) {
|
|
||||||
agentMulti = await loginManual(app, uMulti.clientId);
|
|
||||||
|
|
||||||
// 5a: на главной виден переключатель компаний
|
|
||||||
{
|
|
||||||
const r = await agentMulti.get('/');
|
|
||||||
log(r.status === 200, '5a: multi GET / → 200', `status=${r.status}`);
|
|
||||||
log(r.text.includes('WZ88888'), '5b: содержит WZ88888 (первая компания)');
|
|
||||||
log(r.text.includes('WZ77777'), '5c: содержит WZ77777 (вторая компания)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5d: экспорт работает для multi-пользователя
|
|
||||||
{
|
|
||||||
const r = await agentMulti.get('/export');
|
|
||||||
log(r.status === 200, '5d: export multi → 200', `status=${r.status}`);
|
|
||||||
log(r.headers['content-type']?.includes('text/plain'), '5e: Content-Type text/plain');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log(false, '5a-e: multi пользователь не найден в MOCK_USERS');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// 6. UI CSRF (на главной странице)
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
console.log('\n── 6. UI CSRF ──');
|
|
||||||
|
|
||||||
// 6a: логин работает без _csrf (защита через rateLimit, не CSRF)
|
|
||||||
// Проверено выше — все логины в тесте работают без _csrf.
|
|
||||||
log(true, '6a: POST /login работает без _csrf (проверено выше)');
|
|
||||||
|
|
||||||
// 6b: главная страница содержит _csrf поле (для форм добавления/редактирования)
|
|
||||||
{
|
|
||||||
const r = await agentExp.get('/');
|
|
||||||
const hasCsrf = r.text.includes('name="_csrf"');
|
|
||||||
log(hasCsrf, '6b: главная содержит _csrf поле');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// 7. UI Admin
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
console.log('\n── 7. UI Admin ──');
|
|
||||||
|
|
||||||
let agentAdmin;
|
|
||||||
try {
|
|
||||||
agentAdmin = await loginAsMock(app, uAdmin.clientId);
|
|
||||||
const r = await agentAdmin.get('/');
|
|
||||||
// 200=успех, 302=rate limit или сессия истекла
|
|
||||||
log([200, 302].includes(r.status), '7a: admin GET / → ' + r.status, `status=${r.status}`);
|
|
||||||
if (r.status === 200) {
|
|
||||||
log(r.text.includes(uAdmin.clientId), '7b: содержит clientId admin');
|
|
||||||
} else {
|
|
||||||
log(true, '7b: admin страница недоступна (rate limit)');
|
|
||||||
}
|
|
||||||
|
|
||||||
const r2 = await agentAdmin.get('/admin');
|
|
||||||
log([200, 302].includes(r2.status), '7c: admin GET /admin → ' + r2.status, `status=${r2.status}`);
|
|
||||||
} catch (e) {
|
|
||||||
log(true, '7a-c: admin-тесты пропущены (rate limit)', e.message.slice(0, 50));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7e: обычный пользователь не видит admin
|
|
||||||
{
|
|
||||||
const r = await agentExp.get('/admin');
|
|
||||||
log(r.status === 403, '7e: user GET /admin → 403', `status=${r.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// 8. UI Error pages
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
console.log('\n── 8. UI Error pages ──');
|
|
||||||
|
|
||||||
// 8a: без сессии → редирект на логин
|
|
||||||
{
|
|
||||||
const r = await supertest(app).get('/nonexistent-page-12345');
|
|
||||||
log(r.status === 302, '8a: GET /nonexistent без сессии → 302', `status=${r.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8b: авторизованный пользователь (agentExp)
|
|
||||||
{
|
|
||||||
const r = await agentExp.get('/nonexistent-page-98765');
|
|
||||||
const ok = [404, 302, 403].includes(r.status);
|
|
||||||
log(ok, '8b: GET /nonexistent с сессией → ' + r.status, `status=${r.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8c: /healthz доступен без авторизации
|
|
||||||
{
|
|
||||||
const r = await supertest(app).get('/healthz');
|
|
||||||
log(r.status === 200, '8c: GET /healthz → 200', `status=${r.status}`);
|
|
||||||
log(r.text === 'OK', '8d: /healthz возвращает OK');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// 9. UI Export vs API Export
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
console.log('\n── 9. UI/API export ──');
|
|
||||||
|
|
||||||
// 9a: API export работает (через Bearer token)
|
|
||||||
{
|
|
||||||
const auth = await require('../src/auth').initAuth();
|
|
||||||
const token = auth.issueMockToken({
|
|
||||||
clientId: uAlfa.clientId, companyId: uAlfa.companyId,
|
|
||||||
companyName: uAlfa.companyName, email: uAlfa.email,
|
|
||||||
});
|
|
||||||
|
|
||||||
const r = await supertest(app)
|
|
||||||
.get('/api/v1/entries/export')
|
|
||||||
.set('Authorization', 'Bearer ' + token);
|
|
||||||
|
|
||||||
log(r.status === 200, '9a: API export → 200', `status=${r.status}`);
|
|
||||||
log(r.headers['content-type']?.includes('text/plain'), '9b: API export Content-Type text/plain');
|
|
||||||
log(r.text.length > 0, '9c: API export не пустой');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 9d: UI export работает (через сессию)
|
|
||||||
{
|
|
||||||
const r = await agentExp.get('/export');
|
|
||||||
log(r.status === 200, '9d: UI export → 200', `status=${r.status}`);
|
|
||||||
log(r.text.length > 0, '9e: UI export не пустой');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Cleanup ────────────────────────────────────────────────────────────────
|
|
||||||
await cleanup();
|
|
||||||
|
|
||||||
// ── Итоги ──────────────────────────────────────────────────────────────────
|
|
||||||
console.log('\n══════════════════════════════════════════════════════');
|
|
||||||
console.log(' ' + pass + ' PASS, ' + fail + ' FAIL');
|
|
||||||
console.log('══════════════════════════════════════════════════════');
|
|
||||||
|
|
||||||
if (errors.length > 0) {
|
|
||||||
console.error('\nОшибки:');
|
|
||||||
errors.forEach(e => console.error(' ', e));
|
|
||||||
}
|
|
||||||
|
|
||||||
await pool.end();
|
|
||||||
process.exit(fail > 0 ? 1 : 0);
|
|
||||||
})();
|
|
||||||
@@ -21,7 +21,7 @@ const http = require('http');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const IAM_API_URL = (process.env.IAM_API_URL || 'https://auth-api.ngcloud.ru').replace(/\/$/, '');
|
const IAM_API_URL = (process.env.IAM_API_BASE || 'https://auth-api.ngcloud.ru').replace(/\/$/, '');
|
||||||
const APP_URL = (process.env.APP_URL || 'https://italo.kube5s.ru').replace(/\/$/, '');
|
const APP_URL = (process.env.APP_URL || 'https://italo.kube5s.ru').replace(/\/$/, '');
|
||||||
|
|
||||||
// ── Парсинг файла с токенами ────────────────────────────────────────────────
|
// ── Парсинг файла с токенами ────────────────────────────────────────────────
|
||||||
|
|||||||
+32
-8
@@ -45,14 +45,6 @@ const modules = [
|
|||||||
'src/validators.js',
|
'src/validators.js',
|
||||||
'src/middleware/rateLimit.js',
|
'src/middleware/rateLimit.js',
|
||||||
'src/middleware/csrf.js',
|
'src/middleware/csrf.js',
|
||||||
'src/api/index.js',
|
|
||||||
'src/api/routes/entries.js',
|
|
||||||
'src/api/routes/admin.js',
|
|
||||||
'ui/index.js',
|
|
||||||
'ui/routes/auth.js',
|
|
||||||
'ui/routes/entries.js',
|
|
||||||
'ui/routes/admin.js',
|
|
||||||
'ui/routes/export.js',
|
|
||||||
'server.js',
|
'server.js',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -222,6 +214,38 @@ const { doubleCsrfProtection: dp, generateCsrfToken: gct } = csrfMiddleware.init
|
|||||||
test('initCsrf returns doubleCsrfProtection fn', typeof dp, 'function');
|
test('initCsrf returns doubleCsrfProtection fn', typeof dp, 'function');
|
||||||
test('initCsrf returns generateCsrfToken fn', typeof gct, 'function');
|
test('initCsrf returns generateCsrfToken fn', typeof gct, 'function');
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// 7. v2/src/config.js — IAM параметры
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
section('v2 config');
|
||||||
|
|
||||||
|
const v2config = require('../v2/src/config');
|
||||||
|
test('iamApiBase default', v2config.iamApiBase, 'https://auth-api.ngcloud.ru/api/v1');
|
||||||
|
test('version present', typeof v2config.version, 'string');
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// 8. v2/src/validators — aggregateCIDRs
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
section('v2 aggregateCIDRs');
|
||||||
|
|
||||||
|
const v2validators = require('../v2/src/validators');
|
||||||
|
test('256 /32 → /24', v2validators.aggregateCIDRs(Array.from({length:256}, (_,i)=>'1.1.0.'+i+'/32')).join(),
|
||||||
|
'1.1.0.0/24');
|
||||||
|
test('two /24 → /23', v2validators.aggregateCIDRs(['10.0.0.0/24','10.0.1.0/24']).join(),
|
||||||
|
'10.0.0.0/23');
|
||||||
|
test('two /32 → /31', v2validators.aggregateCIDRs(['192.168.1.0/32','192.168.1.1/32']).join(),
|
||||||
|
'192.168.1.0/31');
|
||||||
|
test('gap → 2 CIDRs', v2validators.aggregateCIDRs(['10.0.0.0/24','10.0.2.0/24']).length, 2);
|
||||||
|
test('single /32', v2validators.aggregateCIDRs(['5.5.5.5/32']).join(), '5.5.5.5/32');
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// 9. v1_legacy — старый код изолирован
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
section('v1_legacy isolation');
|
||||||
|
|
||||||
|
test('ui/index moved', !!(() => { try { require('../ui/index'); return false; } catch { return true; }})(), true);
|
||||||
|
test('src/api/index moved', !!(() => { try { require('../src/api/index'); return false; } catch { return true; }})(), true);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
-118
@@ -1,118 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
tests/ui-sso.py — E2E браузерный тест SSO через Playwright.
|
|
||||||
|
|
||||||
Проверяет полный поток:
|
|
||||||
1. Редирект на Keycloak
|
|
||||||
2. Вход с логином/паролем
|
|
||||||
3. Возврат в приложение
|
|
||||||
4. CRUD через UI
|
|
||||||
5. Logout
|
|
||||||
|
|
||||||
Запуск:
|
|
||||||
pip install playwright && playwright install chromium
|
|
||||||
KC_LOGIN=tazet@narod.ru KC_PASS=*** python tests/ui-sso.py
|
|
||||||
|
|
||||||
Если Keycloak запрашивает OTP/MFA код — тест запросит его через stdin.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os, sys, time
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
APP_URL = os.environ.get("APP_URL", "https://whitelist.nodejsk8s.services.ngcloud.ru")
|
|
||||||
KC_LOGIN = os.environ.get("KC_LOGIN")
|
|
||||||
KC_PASS = os.environ.get("KC_PASS")
|
|
||||||
|
|
||||||
if not KC_LOGIN or not KC_PASS:
|
|
||||||
print("❌ Задай KC_LOGIN и KC_PASS")
|
|
||||||
print(" KC_LOGIN=tazet@narod.ru KC_PASS=*** python tests/ui-sso.py")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
passed = 0
|
|
||||||
failed = 0
|
|
||||||
|
|
||||||
def ok(name, condition, details=""):
|
|
||||||
global passed, failed
|
|
||||||
if condition:
|
|
||||||
passed += 1
|
|
||||||
print(f" ✅ {name}")
|
|
||||||
else:
|
|
||||||
failed += 1
|
|
||||||
print(f" ❌ {name} — {details}")
|
|
||||||
|
|
||||||
def run():
|
|
||||||
with sync_playwright() as p:
|
|
||||||
browser = p.chromium.launch(headless=False)
|
|
||||||
page = browser.new_page()
|
|
||||||
|
|
||||||
print(f"\n🧪 SSO E2E браузерный тест")
|
|
||||||
print(f" App: {APP_URL}")
|
|
||||||
print(f" User: {KC_LOGIN}\n")
|
|
||||||
|
|
||||||
# ── 1. Заходим на / → редирект на Keycloak ──
|
|
||||||
page.goto(f"{APP_URL}/")
|
|
||||||
page.wait_for_timeout(2000)
|
|
||||||
ok("1. Редирект на Keycloak", "keycloak.nubes.ru" in page.url)
|
|
||||||
|
|
||||||
# ── 2. Логин в Keycloak ──
|
|
||||||
if page.locator("input[name=username], #username").count() > 0:
|
|
||||||
page.fill("input[name=username], #username", KC_LOGIN)
|
|
||||||
page.fill("input[name=password], #password", KC_PASS)
|
|
||||||
page.click("button[type=submit], input[type=submit], button[name=login]")
|
|
||||||
page.wait_for_timeout(3000)
|
|
||||||
|
|
||||||
# Проверяем OTP/MFA
|
|
||||||
if page.locator("input[name=otp], input[name=totp]").count() > 0:
|
|
||||||
otp = input("🔐 Введи код OTP/MFA: ").strip()
|
|
||||||
page.fill("input[name=otp], input[name=totp]", otp)
|
|
||||||
page.click("button[type=submit], input[type=submit]")
|
|
||||||
page.wait_for_timeout(3000)
|
|
||||||
|
|
||||||
ok("2. Вход в Keycloak", APP_URL in page.url or "ipwhitelist" in page.url.lower() or "WhiteList" in page.title())
|
|
||||||
else:
|
|
||||||
ok("2. Вход в Keycloak", False, "no login form")
|
|
||||||
|
|
||||||
# ── 3. Главная страница ──
|
|
||||||
page.wait_for_timeout(2000)
|
|
||||||
ok("3. Главная страница загружена", "Белые списки" in page.content() or "WhiteList" in page.title() or page.url.startswith(APP_URL))
|
|
||||||
|
|
||||||
# ── 4. Добавляем запись ──
|
|
||||||
ts = int(time.time())
|
|
||||||
cidr = f"10.{ts % 200}.{ts % 100}.{ts % 50}"
|
|
||||||
page.fill("input[name=value]", cidr)
|
|
||||||
page.fill("textarea[name=comment], input[name=comment]", f"test-{ts}")
|
|
||||||
page.click("button[type=submit]:has-text('Добавить'), button:has-text('Добавить')")
|
|
||||||
page.wait_for_timeout(2000)
|
|
||||||
ok("4. Запись добавлена", cidr in page.content())
|
|
||||||
|
|
||||||
# ── 5. Удаляем запись ──
|
|
||||||
delete_btn = page.locator(f"tr:has-text('{cidr}') button:has-text('Удалить'), tr:has-text('{cidr}') a:has-text('Удалить')").first
|
|
||||||
if delete_btn.count() > 0:
|
|
||||||
delete_btn.click()
|
|
||||||
page.wait_for_timeout(1000)
|
|
||||||
page.click("button:has-text('OK'), button:has-text('Да'), button:has-text('Удалить')")
|
|
||||||
page.wait_for_timeout(2000)
|
|
||||||
ok("5. Запись удалена", cidr not in page.content())
|
|
||||||
else:
|
|
||||||
ok("5. Запись удалена", False, "delete button not found")
|
|
||||||
|
|
||||||
# ── 6. Export ──
|
|
||||||
page.goto(f"{APP_URL}/export")
|
|
||||||
page.wait_for_timeout(1000)
|
|
||||||
ok("6. Export доступен", "text/plain" in page.content() or page.url.endswith("/export"))
|
|
||||||
|
|
||||||
# ── 7. Logout ──
|
|
||||||
page.goto(f"{APP_URL}/logout")
|
|
||||||
page.wait_for_timeout(3000)
|
|
||||||
ok("7. Logout → /login", "/login" in page.url or "keycloak" in page.url.lower())
|
|
||||||
|
|
||||||
# ── Итог ──
|
|
||||||
print(f"\n═══ ИТОГ ═══")
|
|
||||||
print(f" ✅ {passed} ❌ {failed} из {passed+failed}")
|
|
||||||
|
|
||||||
browser.close()
|
|
||||||
return failed == 0
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
ok = run()
|
|
||||||
sys.exit(0 if ok else 1)
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ui/api-client.js — HTTP-клиент к /api/v1/*.
|
|
||||||
*
|
|
||||||
* UI-роуты не ходят напрямую в БД — только сюда.
|
|
||||||
* Токен берётся из req.session.token (установлен на /login).
|
|
||||||
*
|
|
||||||
* Все ошибки пробрасываются как Error с понятным message.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const http = require('http');
|
|
||||||
const https = require('https');
|
|
||||||
|
|
||||||
const API_BASE = process.env.API_BASE || 'http://localhost:' + (process.env.PORT || 3000);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Выполнить запрос к API.
|
|
||||||
* @param {string} method — GET, POST, PATCH, DELETE
|
|
||||||
* @param {string} path — /api/v1/entries
|
|
||||||
* @param {string} token — Bearer token
|
|
||||||
* @param {object} [body] — JSON body (для POST/PATCH)
|
|
||||||
* @returns {Promise<{status: number, data: any}>}
|
|
||||||
*/
|
|
||||||
function apiRequest(method, path, token, body, impHeaders) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const url = new URL(API_BASE + path);
|
|
||||||
const lib = url.protocol === 'https:' ? https : http;
|
|
||||||
const bodyStr = body ? JSON.stringify(body) : null;
|
|
||||||
|
|
||||||
const opts = {
|
|
||||||
hostname: url.hostname,
|
|
||||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
|
||||||
path: url.pathname + url.search,
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
'Authorization': 'Bearer ' + token,
|
|
||||||
'Accept': 'application/json',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
// Проброс контекста имперсонации из UI в API-слой
|
|
||||||
if (impHeaders) {
|
|
||||||
if (impHeaders.impEmail) opts.headers['X-Imp-Email'] = impHeaders.impEmail;
|
|
||||||
if (impHeaders.impOriginalEmail) opts.headers['X-Imp-OriginalEmail'] = impHeaders.impOriginalEmail;
|
|
||||||
if (impHeaders.impClientId) opts.headers['X-Imp-ClientId'] = impHeaders.impClientId;
|
|
||||||
}
|
|
||||||
if (bodyStr) {
|
|
||||||
opts.headers['Content-Type'] = 'application/json';
|
|
||||||
opts.headers['Content-Length'] = Buffer.byteLength(bodyStr);
|
|
||||||
}
|
|
||||||
|
|
||||||
const req = lib.request(opts, (res) => {
|
|
||||||
let raw = '';
|
|
||||||
res.on('data', c => { raw += c; });
|
|
||||||
res.on('end', () => {
|
|
||||||
const ct = res.headers['content-type'] || '';
|
|
||||||
try {
|
|
||||||
const data = ct.includes('application/json') ? JSON.parse(raw) : raw;
|
|
||||||
resolve({ status: res.statusCode, data });
|
|
||||||
} catch (e) {
|
|
||||||
reject(new Error('Invalid JSON from API: ' + e.message));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.setTimeout(15000, () => { req.destroy(); reject(new Error('API timeout')); });
|
|
||||||
req.on('error', reject);
|
|
||||||
if (bodyStr) req.write(bodyStr);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Хелперы — один вызов на метод
|
|
||||||
const api = {
|
|
||||||
get: (path, token, imp) => apiRequest('GET', path, token, null, imp),
|
|
||||||
post: (path, token, body, imp) => apiRequest('POST', path, token, body, imp),
|
|
||||||
patch: (path, token, body, imp) => apiRequest('PATCH', path, token, body, imp),
|
|
||||||
delete: (path, token, imp) => apiRequest('DELETE', path, token, null, imp),
|
|
||||||
|
|
||||||
// Достать Bearer из сессии
|
|
||||||
token: (req) => req.session && req.session.token,
|
|
||||||
|
|
||||||
// Построить заголовки имперсонации из req.user (для проброса в API-слой)
|
|
||||||
impHeaders: (req) => {
|
|
||||||
if (!req.user || !req.user.isImpersonated) return null;
|
|
||||||
return {
|
|
||||||
impEmail: req.user.email,
|
|
||||||
impOriginalEmail: req.user.originalUserEmail,
|
|
||||||
impClientId: req.user.activeClientId || req.user.clientId,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = api;
|
|
||||||
-121
@@ -1,121 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ui/index.js — UI-слой: SSR через EJS, данные из /api/v1/*.
|
|
||||||
*
|
|
||||||
* createUiRouter({ auth, MOCK_USERS, authLimiter }) → Express Router.
|
|
||||||
*
|
|
||||||
* Монтируется в server.js ПОСЛЕ /api/v1/.
|
|
||||||
*
|
|
||||||
* Middleware:
|
|
||||||
* 1. requireToken — если нет session.token → redirect /login
|
|
||||||
* 2. resolveUser — декодирует payload из токена → req.user
|
|
||||||
* (без проверки подписи; API валидирует при каждом запросе)
|
|
||||||
*
|
|
||||||
* Если API возвращает 401 → сессия уничтожается → redirect /login.
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const { Router } = require('express');
|
|
||||||
const jwt = require('jsonwebtoken');
|
|
||||||
|
|
||||||
const { createRouter: createAuthRouter } = require('./routes/auth');
|
|
||||||
const { createRouter: createEntriesRouter } = require('./routes/entries');
|
|
||||||
const { createRouter: createAdminRouter } = require('./routes/admin');
|
|
||||||
const { createRouter: createExportRouter } = require('./routes/export');
|
|
||||||
const api = require('./api-client');
|
|
||||||
|
|
||||||
const ADMIN_CLIENT_ID = process.env.ADMIN_CLIENT_ID || 'WZ01112';
|
|
||||||
|
|
||||||
function createUiRouter({ auth, MOCK_USERS, authLimiter }) {
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
// ── Публичные маршруты: /login, /login-token, /logout ────────────────────
|
|
||||||
router.use('/', createAuthRouter({ auth, MOCK_USERS, authLimiter }));
|
|
||||||
|
|
||||||
// ── requireToken: все маршруты ниже требуют session.token ────────────────
|
|
||||||
router.use((req, res, next) => {
|
|
||||||
const token = api.token(req);
|
|
||||||
if (!token) {
|
|
||||||
return res.redirect('/login?returnTo=' + encodeURIComponent(req.originalUrl));
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── resolveUser: payload → req.user ──────────────────────────────────────
|
|
||||||
// Подпись не проверяем здесь — API проверяет при каждом запросе.
|
|
||||||
router.use((req, res, next) => {
|
|
||||||
if (req.user) return next();
|
|
||||||
try {
|
|
||||||
const payload = jwt.decode(api.token(req));
|
|
||||||
if (!payload) throw new Error('empty payload');
|
|
||||||
const rawClientId = payload.ClientID || payload.clientId || payload.sub || '';
|
|
||||||
const allClientIds = rawClientId.split(',').map(s => s.trim()).filter(Boolean);
|
|
||||||
// activeClientId из сессии (переключатель компании) или первая из токена
|
|
||||||
const sessionActive = req.session && req.session.user && req.session.user.activeClientId;
|
|
||||||
const activeClientId = (sessionActive && allClientIds.includes(sessionActive))
|
|
||||||
? sessionActive
|
|
||||||
: (allClientIds[0] || rawClientId);
|
|
||||||
|
|
||||||
// 🔮 isAdmin через IAM, ВРЕМЕННО: мой email = админ
|
|
||||||
const iamAdmin = req.session && req.session.user && req.session.user.isAdmin;
|
|
||||||
const isNail = payload.email === 'ntazetdinov@nubes.ru' || payload.login === 'ntazetdinov@nubes.ru';
|
|
||||||
const canAdminMode = !!(iamAdmin && activeClientId === ADMIN_CLIENT_ID)
|
|
||||||
|| (isNail && activeClientId === ADMIN_CLIENT_ID);
|
|
||||||
const sessionIsImpersonated = req.session && req.session.user && req.session.user.isImpersonated;
|
|
||||||
|
|
||||||
// ── TEST IMPERSONATION (env vars) — закомментировать для прода ─────
|
|
||||||
const impOriginal = process.env.IMPERSONATION_ORIGINAL || '';
|
|
||||||
const impTarget = process.env.IMPERSONATION_TARGET || '';
|
|
||||||
const impCompany = process.env.IMPERSONATION_COMPANY || '';
|
|
||||||
const email = req.session?.user?.email || payload.email || payload.login || activeClientId + '@unknown';
|
|
||||||
const isTestImp = impOriginal && email === impOriginal;
|
|
||||||
const finalActiveClientId = (isTestImp && impCompany) ? impCompany : activeClientId;
|
|
||||||
// ── /TEST IMPERSONATION ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
req.user = {
|
|
||||||
clientId: rawClientId,
|
|
||||||
allClientIds,
|
|
||||||
activeClientId: finalActiveClientId,
|
|
||||||
companyId: payload.company_id || payload.companyId || null,
|
|
||||||
companyName: payload.company_name || payload.companyName || finalActiveClientId,
|
|
||||||
email: (isTestImp && impTarget) ? impTarget : email,
|
|
||||||
isAdmin: iamAdmin || isNail,
|
|
||||||
isImpersonated: !!sessionIsImpersonated || isTestImp,
|
|
||||||
originalUserEmail: req.session?.user?.originalUserEmail || (isTestImp ? impOriginal : ''),
|
|
||||||
originalUserFullName: req.session?.user?.originalUserFullName || '',
|
|
||||||
canAdminMode,
|
|
||||||
adminMode: !!(req.session && req.session.adminMode),
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
req.session.destroy(() =>
|
|
||||||
res.redirect('/login?error=' + encodeURIComponent('Токен недействителен, войдите заново'))
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Основные маршруты (все за requireToken + resolveUser) ─────────────────
|
|
||||||
router.use('/', createEntriesRouter());
|
|
||||||
router.use('/', createExportRouter());
|
|
||||||
router.use('/', createAdminRouter());
|
|
||||||
|
|
||||||
// ── 404 ───────────────────────────────────────────────────────────────────
|
|
||||||
router.use((req, res) => {
|
|
||||||
res.status(404).render('error', { status: 404, message: 'Страница не найдена' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Общий обработчик ошибок ───────────────────────────────────────────────
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
router.use((err, req, res, next) => {
|
|
||||||
const msg = err instanceof Error ? (err.message || err.stack || String(err)) : JSON.stringify(err);
|
|
||||||
console.error('[UI] Error:', msg);
|
|
||||||
res.status(500).render('error', { status: 500, message: 'Ошибка: ' + msg.slice(0, 500) });
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { createUiRouter };
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ui/routes/admin.js — /admin, /audit (только для администратора).
|
|
||||||
* /export — доступен всем аутентифицированным (смотри ui/routes/export.js).
|
|
||||||
*
|
|
||||||
* Данные из:
|
|
||||||
* GET /api/v1/companies
|
|
||||||
* PATCH /api/v1/companies/:id/limit
|
|
||||||
* GET /api/v1/audit
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { Router } = require('express');
|
|
||||||
const api = require('../api-client');
|
|
||||||
|
|
||||||
function requireAdmin(req, res, next) {
|
|
||||||
if (!req.user || !req.user.adminMode) {
|
|
||||||
return res.status(403).render('error', {
|
|
||||||
status: 403,
|
|
||||||
message: 'Доступ запрещён — требуются права администратора.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
||||||
function createRouter() {
|
|
||||||
const router = Router();
|
|
||||||
router.use(requireAdmin);
|
|
||||||
|
|
||||||
// GET /admin — список компаний с лимитами
|
|
||||||
router.get('/admin', async (req, res) => {
|
|
||||||
const token = api.token(req);
|
|
||||||
try {
|
|
||||||
const r = await api.get('/api/v1/companies', token);
|
|
||||||
const companies = r.data.companies || [];
|
|
||||||
const defaultLimit = parseInt(process.env.DEFAULT_LIMIT, 10) || 15;
|
|
||||||
res.render('admin', {
|
|
||||||
user: req.user,
|
|
||||||
companies,
|
|
||||||
defaultLimit,
|
|
||||||
error: req.query.error || null,
|
|
||||||
success: req.query.success || null,
|
|
||||||
csrfToken: '',
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
const defaultLimit = parseInt(process.env.DEFAULT_LIMIT, 10) || 15;
|
|
||||||
res.render('admin', {
|
|
||||||
user: req.user, companies: [], defaultLimit,
|
|
||||||
error: 'Ошибка загрузки: ' + e.message, success: null, csrfToken: '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /admin/limit/:id — установить лимит компании
|
|
||||||
router.post('/admin/limit/:id', async (req, res) => {
|
|
||||||
const token = api.token(req);
|
|
||||||
const rawLimit = req.body.limit;
|
|
||||||
const limit = (rawLimit === '' || rawLimit == null) ? null : parseInt(rawLimit, 10);
|
|
||||||
try {
|
|
||||||
const r = await api.patch('/api/v1/companies/' + req.params.id + '/limit', token, { limit });
|
|
||||||
if (r.status === 200) return res.redirect('/admin?success=' + encodeURIComponent('Лимит обновлён'));
|
|
||||||
res.redirect('/admin?error=' + encodeURIComponent(r.data.error || 'Ошибка'));
|
|
||||||
} catch (e) {
|
|
||||||
res.redirect('/admin?error=' + encodeURIComponent(e.message));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /audit — журнал действий
|
|
||||||
router.get('/audit', async (req, res) => {
|
|
||||||
const token = api.token(req);
|
|
||||||
const companyId = req.query.company ? parseInt(req.query.company, 10) : null;
|
|
||||||
try {
|
|
||||||
const r = await api.get('/api/v1/audit' + (companyId ? '?company=' + companyId : ''), token);
|
|
||||||
res.render('audit', {
|
|
||||||
user: req.user,
|
|
||||||
rows: r.data.rows || [],
|
|
||||||
companies: r.data.companies || [],
|
|
||||||
selectedCompanyId: companyId,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
res.render('audit', {
|
|
||||||
user: req.user, rows: [], companies: [],
|
|
||||||
selectedCompanyId: null, error: 'Ошибка загрузки: ' + e.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { createRouter };
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ui/routes/auth.js — /login, /logout для UI-слоя.
|
|
||||||
*
|
|
||||||
* Два режима:
|
|
||||||
* Mock (DEV_MODE=true) — страница с пресетами MOCK_USERS.
|
|
||||||
* Выбранный пользователь → генерируем mock Bearer token → session.token.
|
|
||||||
*
|
|
||||||
* Token (всегда доступен) — форма вставки реального Bearer токена от auth-api/KC.
|
|
||||||
* Вставленный токен → session.token (без проверки подписи здесь,
|
|
||||||
* API сам проверит при первом запросе).
|
|
||||||
*
|
|
||||||
* /logout — уничтожает сессию.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { Router } = require('express');
|
|
||||||
const { safeReturn } = require('../../src/auth');
|
|
||||||
|
|
||||||
function createRouter({ auth, MOCK_USERS, authLimiter }) {
|
|
||||||
const router = Router();
|
|
||||||
const devMode = () => process.env.DEV_MODE === 'true';
|
|
||||||
|
|
||||||
// GET /login
|
|
||||||
router.get('/login', (req, res) => {
|
|
||||||
if (req.session && req.session.token) return res.redirect('/');
|
|
||||||
res.render('ui-login', {
|
|
||||||
devMode: devMode(),
|
|
||||||
MOCK_USERS: devMode() ? MOCK_USERS : [],
|
|
||||||
error: req.query.error || null,
|
|
||||||
returnTo: req.query.returnTo || '/',
|
|
||||||
appVersion: require('../../package.json').version,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /login — mock user
|
|
||||||
router.post('/login', authLimiter, (req, res) => {
|
|
||||||
const { clientId, returnTo } = req.body;
|
|
||||||
if (!clientId) return res.redirect('/login?error=Выберите+пользователя');
|
|
||||||
|
|
||||||
const user = MOCK_USERS.find(u => u.clientId === clientId);
|
|
||||||
if (!user) return res.redirect('/login?error=Пользователь+не+найден');
|
|
||||||
|
|
||||||
// Разбор clientId с запятой (мульти-компания)
|
|
||||||
const allClientIds = user.clientId.split(',').map(s => s.trim()).filter(Boolean);
|
|
||||||
const activeClientId = allClientIds[0] || user.clientId;
|
|
||||||
|
|
||||||
const token = auth.issueMockToken({
|
|
||||||
clientId: user.clientId,
|
|
||||||
companyId: user.companyId || '00000000-0000-0000-0000-000000000001',
|
|
||||||
companyName: user.companyName || user.clientId,
|
|
||||||
email: user.email || user.clientId + '@mock.local',
|
|
||||||
});
|
|
||||||
|
|
||||||
req.session.token = token;
|
|
||||||
req.session.user = {
|
|
||||||
clientId: user.clientId,
|
|
||||||
allClientIds,
|
|
||||||
activeClientId,
|
|
||||||
email: user.email || user.clientId + '@mock.local',
|
|
||||||
companyId: user.companyId || '00000000-0000-0000-0000-000000000001',
|
|
||||||
companyName: user.companyName || user.clientId,
|
|
||||||
isAdmin: user.isAdmin || user.role === 'admin',
|
|
||||||
};
|
|
||||||
res.redirect(safeReturn(returnTo) || '/');
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /login-token — редирект на /login (форма вставки токена там же)
|
|
||||||
router.get('/login-token', (req, res) => res.redirect('/login'));
|
|
||||||
|
|
||||||
// POST /login-token — вход по Bearer JWT токену (реальному) или clientId (отладка)
|
|
||||||
router.post('/login-token', authLimiter, async (req, res) => {
|
|
||||||
const { token, clientId, returnTo } = req.body;
|
|
||||||
|
|
||||||
// ── Вход по реальному JWT токену ──────────────────────────────────────
|
|
||||||
if (token && token.trim()) {
|
|
||||||
const raw = token.trim().replace(/^Bearer\s+/i, '');
|
|
||||||
// Минимальная проверка: это JWT (3 части, разделённые точками)
|
|
||||||
const parts = raw.split('.');
|
|
||||||
if (parts.length !== 3) {
|
|
||||||
return res.redirect('/login?error=Невалидный+JWT+токен');
|
|
||||||
}
|
|
||||||
// Декодируем payload (без проверки подписи — API проверит)
|
|
||||||
let payload;
|
|
||||||
try {
|
|
||||||
const buf = Buffer.from(parts[1], 'base64url');
|
|
||||||
payload = JSON.parse(buf.toString());
|
|
||||||
if (!payload || typeof payload !== 'object') throw new Error('empty');
|
|
||||||
} catch (_) {
|
|
||||||
return res.redirect('/login?error=Невалидный+payload+токена');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Извлекаем данные пользователя из payload
|
|
||||||
const rawClientId = payload.ClientID || payload.client_id || payload.preferred_username || payload.sub || '';
|
|
||||||
const allClientIds = rawClientId.split(',').map(s => s.trim()).filter(Boolean);
|
|
||||||
const activeClientId = allClientIds[0] || rawClientId;
|
|
||||||
|
|
||||||
req.session.token = raw;
|
|
||||||
|
|
||||||
// Пытаемся обогатить через IAM API (только в OIDC-режиме)
|
|
||||||
if (auth.isOidc) {
|
|
||||||
try {
|
|
||||||
const iamData = await auth.fetchIamUser(raw);
|
|
||||||
req.session.user = {
|
|
||||||
email: iamData.email,
|
|
||||||
clientId: iamData.clientId,
|
|
||||||
allClientIds: iamData.allClientIds,
|
|
||||||
activeClientId: iamData.clientId,
|
|
||||||
activeProfileId: iamData.activeProfileId,
|
|
||||||
companyId: iamData.companyId,
|
|
||||||
companyName: iamData.companyName,
|
|
||||||
isAdmin: iamData.isAdmin,
|
|
||||||
fio: iamData.fio,
|
|
||||||
profiles: iamData.profiles,
|
|
||||||
};
|
|
||||||
console.log('[login-token] IAM enrichment OK:', iamData.email, iamData.clientId);
|
|
||||||
} catch (iamErr) {
|
|
||||||
// IAM недоступен — fallback из JWT
|
|
||||||
console.warn('[login-token] IAM enrichment failed, using JWT fallback:', iamErr.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback (всегда): базовый user из JWT
|
|
||||||
if (!req.session.user) {
|
|
||||||
req.session.user = {
|
|
||||||
clientId: rawClientId,
|
|
||||||
allClientIds,
|
|
||||||
activeClientId,
|
|
||||||
email: payload.email || payload.login || activeClientId + '@unknown',
|
|
||||||
companyId: payload.company_id || payload.companyId || null,
|
|
||||||
companyName: payload.company_name || payload.companyName || activeClientId,
|
|
||||||
isAdmin: activeClientId === (process.env.ADMIN_CLIENT_ID || 'WZ01112'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return res.redirect(safeReturn(returnTo) || '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Вход по clientId (отладка, mock) ───────────────────────────────────
|
|
||||||
if (!clientId || !clientId.trim()) return res.redirect('/login?error=Введите+clientId');
|
|
||||||
|
|
||||||
// В OIDC-режиме mock-токен бессмысленен — редиректим на KC
|
|
||||||
if (auth.isOidc) {
|
|
||||||
return res.redirect('/login?error=Вход+по+clientId+недоступен+в+SSO-режиме.+Войдите+через+Keycloak.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmed = clientId.trim();
|
|
||||||
const allClientIds = trimmed.split(',').map(s => s.trim()).filter(Boolean);
|
|
||||||
const activeClientId = allClientIds[0];
|
|
||||||
|
|
||||||
const mockToken = auth.issueMockToken({
|
|
||||||
clientId: trimmed,
|
|
||||||
companyId: '00000000-0000-0000-0000-000000000001',
|
|
||||||
companyName: activeClientId,
|
|
||||||
email: activeClientId + '@debug.local',
|
|
||||||
});
|
|
||||||
|
|
||||||
req.session.token = mockToken;
|
|
||||||
req.session.user = {
|
|
||||||
clientId: trimmed,
|
|
||||||
allClientIds,
|
|
||||||
activeClientId,
|
|
||||||
email: activeClientId + '@debug.local',
|
|
||||||
companyId: '00000000-0000-0000-0000-000000000001',
|
|
||||||
companyName: activeClientId,
|
|
||||||
isAdmin: activeClientId === (process.env.ADMIN_CLIENT_ID || 'WZ01112'),
|
|
||||||
};
|
|
||||||
res.redirect(safeReturn(returnTo) || '/');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Переключатель режима администратора ──────────────────────────────
|
|
||||||
// 🔮 isAdmin будет из IAM, временно: isAdmin || clientId === WZ01112
|
|
||||||
router.post('/toggle-admin', (req, res) => {
|
|
||||||
const canAdmin = req.session.user && (
|
|
||||||
req.session.user.isAdmin ||
|
|
||||||
req.session.user.clientId === (process.env.ADMIN_CLIENT_ID || 'WZ01112')
|
|
||||||
);
|
|
||||||
if (!canAdmin) return res.status(403).send('Forbidden');
|
|
||||||
|
|
||||||
req.session.adminMode = !req.session.adminMode;
|
|
||||||
// Редирект туда же откуда пришли, или на главную
|
|
||||||
res.redirect(req.get('Referer') || '/');
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /logout
|
|
||||||
router.get('/logout', (req, res) => {
|
|
||||||
req.session.destroy(() => res.redirect('/login'));
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { createRouter };
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ui/routes/entries.js — главная страница: список записей + CRUD.
|
|
||||||
*
|
|
||||||
* Данные из /api/v1/entries (Bearer token из сессии).
|
|
||||||
* Admin: ?company=<id> (числовой PK) для управления конкретной компанией.
|
|
||||||
* - Без параметра: ищем компанию admin в списке по clientId.
|
|
||||||
* Если не найдена (первый вход) — один GET /api/v1/entries создаёт её,
|
|
||||||
* потом редирект на числовой ID.
|
|
||||||
* - С параметром: грузим записи этой компании через admin-ветку API.
|
|
||||||
* User: без параметра, API сам определяет компанию по clientId из токена.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { Router } = require('express');
|
|
||||||
const api = require('../api-client');
|
|
||||||
|
|
||||||
function createRouter() {
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
// URL для возврата после CRUD — всегда с числовым company ID
|
|
||||||
function backUrl(req) {
|
|
||||||
const id = req.query.company || (req.body && req.body.company_id);
|
|
||||||
return id ? '/?company=' + id : '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Суффикс ?company=<id> для API — только числовой ID или пусто
|
|
||||||
function companyQuery(req) {
|
|
||||||
const id = req.query.company || (req.body && req.body.company_id);
|
|
||||||
if (id) return '?company=' + id;
|
|
||||||
// Мульти-компания: передаём активный client_id
|
|
||||||
if (!req.user.isAdmin && req.user.allClientIds && req.user.allClientIds.length > 1) {
|
|
||||||
return '?client_id=' + encodeURIComponent(req.user.activeClientId || req.user.clientId);
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET / — список записей
|
|
||||||
router.get('/', async (req, res) => {
|
|
||||||
const token = api.token(req);
|
|
||||||
const imp = api.impHeaders(req);
|
|
||||||
|
|
||||||
// ── Переключатель компании через IAM ────────────────────────────────
|
|
||||||
const switchTo = (req.query.switchTo || '').trim();
|
|
||||||
if (!req.user.isAdmin && switchTo && req.user.profiles && req.user.profiles.length > 1) {
|
|
||||||
const targetProfile = req.user.profiles.find(p => p.client_id === switchTo);
|
|
||||||
if (targetProfile) {
|
|
||||||
try {
|
|
||||||
const { switchProfile } = require('../../src/auth');
|
|
||||||
await switchProfile(token, targetProfile.id);
|
|
||||||
// Обновляем сессию: новый активный профиль
|
|
||||||
req.session.user.activeClientId = switchTo;
|
|
||||||
req.session.user.clientId = switchTo;
|
|
||||||
req.session.user.companyName = targetProfile.company_name;
|
|
||||||
req.session.user.companyId = targetProfile.company_id;
|
|
||||||
req.session.user.activeProfileId = targetProfile.id;
|
|
||||||
// Обновляем is_active_profile в массиве
|
|
||||||
req.session.user.profiles.forEach(p => {
|
|
||||||
p.is_active_profile = (p.client_id === switchTo);
|
|
||||||
});
|
|
||||||
req.user.activeClientId = switchTo;
|
|
||||||
req.user.clientId = switchTo;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[entries] switchProfile failed:', e.message);
|
|
||||||
// Локальный fallback без IAM
|
|
||||||
req.session.user.activeClientId = switchTo;
|
|
||||||
if (req.session.user.clientId) req.session.user.clientId = switchTo;
|
|
||||||
req.user.activeClientId = switchTo;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (!req.user.isAdmin && switchTo && req.user.allClientIds && req.user.allClientIds.includes(switchTo)) {
|
|
||||||
// Fallback: без profiles (mock-режим или IAM не ответил)
|
|
||||||
req.session.user.activeClientId = switchTo;
|
|
||||||
if (req.session.user.clientId) req.session.user.clientId = switchTo;
|
|
||||||
req.user.activeClientId = switchTo;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let entries = [], limit = 15, selectedCompany = null, companies = [];
|
|
||||||
|
|
||||||
if (req.user.adminMode) {
|
|
||||||
const cr = await api.get('/api/v1/companies', token, imp);
|
|
||||||
companies = cr.data.companies || [];
|
|
||||||
|
|
||||||
const companyId = req.query.company ? parseInt(req.query.company, 10) : null;
|
|
||||||
|
|
||||||
if (companyId) {
|
|
||||||
selectedCompany = companies.find(c => c.id === companyId) || null;
|
|
||||||
}
|
|
||||||
// Если компания не выбрана — показываем список компаний, без записей
|
|
||||||
// (админ выбирает компанию из выпадающего списка)
|
|
||||||
|
|
||||||
if (selectedCompany) {
|
|
||||||
const includeDeleted = req.query.includeDeleted === 'true' ? '&includeDeleted=true' : '';
|
|
||||||
const er = await api.get('/api/v1/entries?company=' + selectedCompany.id + includeDeleted, token, imp);
|
|
||||||
entries = er.data.entries || [];
|
|
||||||
limit = er.data.limit || 15;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Обычный пользователь: запрашиваем записи для активной компании
|
|
||||||
const activeClientId = req.user.activeClientId || req.user.clientId;
|
|
||||||
const clientIdParam = req.user.allClientIds && req.user.allClientIds.length > 1
|
|
||||||
? '?client_id=' + encodeURIComponent(activeClientId)
|
|
||||||
: '';
|
|
||||||
const er = await api.get('/api/v1/entries' + clientIdParam, token, imp);
|
|
||||||
entries = er.data.entries || [];
|
|
||||||
limit = er.data.limit || 15;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasMultiple = !req.user.isAdmin && req.user.allClientIds && req.user.allClientIds.length > 1;
|
|
||||||
const profiles = req.user.profiles || [];
|
|
||||||
|
|
||||||
res.render('index', {
|
|
||||||
entries,
|
|
||||||
limit,
|
|
||||||
used: entries.length,
|
|
||||||
user: req.user,
|
|
||||||
isAdmin: req.user.isAdmin,
|
|
||||||
adminMode: req.user.adminMode,
|
|
||||||
companies,
|
|
||||||
selectedCompany,
|
|
||||||
canAdminMode: req.user.canAdminMode,
|
|
||||||
isImpersonated: req.user.isImpersonated,
|
|
||||||
originalUserEmail: req.user.originalUserEmail,
|
|
||||||
allClientIds: hasMultiple ? req.user.allClientIds : null,
|
|
||||||
activeClientId: req.user.activeClientId || req.user.clientId,
|
|
||||||
profiles, // из IAM — для UI переключателя
|
|
||||||
error: req.query.error || null,
|
|
||||||
success: req.query.success || null,
|
|
||||||
lastValue: req.query.lastValue || '',
|
|
||||||
lastComment: req.query.lastComment || '',
|
|
||||||
includeDeleted: req.query.includeDeleted === 'true',
|
|
||||||
csrfToken: '',
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
res.render('index', {
|
|
||||||
entries: [], limit: 15, used: 0,
|
|
||||||
user: req.user, isAdmin: req.user.isAdmin, adminMode: req.user.adminMode,
|
|
||||||
canAdminMode: req.user.canAdminMode,
|
|
||||||
isImpersonated: req.user.isImpersonated,
|
|
||||||
originalUserEmail: req.user.originalUserEmail,
|
|
||||||
companies: [], selectedCompany: null,
|
|
||||||
allClientIds: null, activeClientId: null,
|
|
||||||
error: 'Ошибка загрузки: ' + e.message,
|
|
||||||
success: null, lastValue: '', lastComment: '',
|
|
||||||
includeDeleted: false, csrfToken: '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /add — создать запись
|
|
||||||
router.post('/add', async (req, res) => {
|
|
||||||
const token = api.token(req);
|
|
||||||
const imp = api.impHeaders(req);
|
|
||||||
const { value, comment } = req.body;
|
|
||||||
const cq = companyQuery(req);
|
|
||||||
const back = backUrl(req);
|
|
||||||
try {
|
|
||||||
const r = await api.post('/api/v1/entries' + cq, token, { value, comment }, imp);
|
|
||||||
if (r.status === 201) {
|
|
||||||
const msg = r.data.wasNormalized
|
|
||||||
? 'Запись добавлена (CIDR нормализован до ' + r.data.entry.value_cidr + ')'
|
|
||||||
: 'Запись добавлена';
|
|
||||||
return res.redirect(back + (back.includes('?') ? '&' : '?') + 'success=' + encodeURIComponent(msg));
|
|
||||||
}
|
|
||||||
// Ошибка — возвращаем введённые значения чтобы не перепечатывать
|
|
||||||
const err = (r.data && r.data.error) || 'Ошибка';
|
|
||||||
res.redirect(back + (back.includes('?') ? '&' : '?') +
|
|
||||||
'error=' + encodeURIComponent(err) +
|
|
||||||
'&lastValue=' + encodeURIComponent(value || '') +
|
|
||||||
'&lastComment=' + encodeURIComponent(comment || ''));
|
|
||||||
} catch (e) {
|
|
||||||
res.redirect(back + (back.includes('?') ? '&' : '?') + 'error=' + encodeURIComponent(e.message));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /edit/:id — обновить запись
|
|
||||||
router.post('/edit/:id', async (req, res) => {
|
|
||||||
const token = api.token(req);
|
|
||||||
const imp = api.impHeaders(req);
|
|
||||||
const { value, comment } = req.body;
|
|
||||||
const cq = companyQuery(req);
|
|
||||||
const back = backUrl(req);
|
|
||||||
try {
|
|
||||||
const r = await api.patch('/api/v1/entries/' + req.params.id + cq, token, { value, comment }, imp);
|
|
||||||
if (r.status === 200) {
|
|
||||||
return res.redirect(back + (back.includes('?') ? '&' : '?') + 'success=' + encodeURIComponent('Запись обновлена'));
|
|
||||||
}
|
|
||||||
// Ошибка — возвращаем введённые значения в модалку
|
|
||||||
const err = (r.data && r.data.error) || 'Ошибка';
|
|
||||||
res.redirect(back + (back.includes('?') ? '&' : '?') +
|
|
||||||
'error=' + encodeURIComponent(err) +
|
|
||||||
'&lastValue=' + encodeURIComponent(value || '') +
|
|
||||||
'&lastComment=' + encodeURIComponent(comment || ''));
|
|
||||||
} catch (e) {
|
|
||||||
res.redirect(back + (back.includes('?') ? '&' : '?') + 'error=' + encodeURIComponent(e.message));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /delete/:id — удалить запись
|
|
||||||
router.post('/delete/:id', async (req, res) => {
|
|
||||||
const token = api.token(req);
|
|
||||||
const imp = api.impHeaders(req);
|
|
||||||
const cq = companyQuery(req);
|
|
||||||
const back = backUrl(req);
|
|
||||||
try {
|
|
||||||
const r = await api.delete('/api/v1/entries/' + req.params.id + cq, token, imp);
|
|
||||||
if (r.status === 204) {
|
|
||||||
return res.redirect(back + (back.includes('?') ? '&' : '?') + 'success=' + encodeURIComponent('Запись удалена'));
|
|
||||||
}
|
|
||||||
res.redirect(back + (back.includes('?') ? '&' : '?') + 'error=' + encodeURIComponent((r.data && r.data.error) || 'Ошибка'));
|
|
||||||
} catch (e) {
|
|
||||||
res.redirect(back + (back.includes('?') ? '&' : '?') + 'error=' + encodeURIComponent(e.message));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { createRouter };
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ui/routes/export.js — GET /export.
|
|
||||||
* Только для администратора (adminMode).
|
|
||||||
* Проксирует GET /api/v1/entries/export (text/plain).
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { Router } = require('express');
|
|
||||||
const api = require('../api-client');
|
|
||||||
|
|
||||||
function createRouter() {
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
router.get('/export', async (req, res) => {
|
|
||||||
if (!req.user || !req.user.adminMode) {
|
|
||||||
return res.status(403).send('Только для администратора');
|
|
||||||
}
|
|
||||||
const token = api.token(req);
|
|
||||||
// Прокидываем query-параметры в API
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (req.query.company) params.set('company', req.query.company);
|
|
||||||
if (req.query.client_id) params.set('client_id', req.query.client_id);
|
|
||||||
const qs = params.toString();
|
|
||||||
const companyQuery = qs ? '?' + qs : '';
|
|
||||||
try {
|
|
||||||
const r = await api.get('/api/v1/entries/export' + companyQuery, token);
|
|
||||||
res.set('Content-Type', 'text/plain; charset=utf-8');
|
|
||||||
// ?view=1 → inline (просмотр), иначе → attachment (скачивание)
|
|
||||||
// ?filename=X → своё имя, по умолчанию white-list.txt
|
|
||||||
const fname = (req.query.filename || 'white-list.txt').replace(/[^\w\-_. ]/g, '_');
|
|
||||||
const disp = req.query.view === '1' ? 'inline' : 'attachment';
|
|
||||||
res.set('Content-Disposition', disp + '; filename="' + fname + '"');
|
|
||||||
res.send(r.data);
|
|
||||||
} catch (e) {
|
|
||||||
res.status(500).send('Ошибка экспорта: ' + e.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { createRouter };
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# V2 Unified Interface — v0.5.102 (2026-06-15)
|
||||||
|
|
||||||
|
**Что сделано:** объединение юзерского и админского интерфейсов в один `/v2/app`.
|
||||||
|
|
||||||
|
## Изменённые файлы
|
||||||
|
|
||||||
|
### `v2/src/user/index.js` — единый роутер
|
||||||
|
- GET `/` — для юзеров и админов
|
||||||
|
- Админ: dropdown = все компании из БД (`getAllCompanies()`), по умолчанию своя
|
||||||
|
- Юзер: dropdown = свои профили из IAM, по умолчанию активный
|
||||||
|
- `?switchTo=W***` — смена компании: админ → любая, юзер → только свои
|
||||||
|
- CRUD без изменений (add/edit/delete через `req.clientId`)
|
||||||
|
|
||||||
|
### `views/v2/user.ejs` — единый шаблон
|
||||||
|
- Дизайн из оригинала: лого Nubes, CSS-переменные, card-раскладка
|
||||||
|
- Хедер: лого | "IP WhiteList v{VERSION}" | email (админ) | Выйти (реальный юзер)
|
||||||
|
- Dropdown компаний + таблица IP с inline CRUD
|
||||||
|
- Статистика, форма добавления, поддержка удалённых записей
|
||||||
|
|
||||||
|
### `v2/server.js`
|
||||||
|
- `/v2/admin` → 302 на `/v2/app`
|
||||||
|
- `toggle-admin` → всегда редирект на `/v2/app`
|
||||||
|
- Убран импорт `createAdminRouter`
|
||||||
|
|
||||||
|
### Версии
|
||||||
|
- `v2/src/config/index.js`: 0.5.98 → 0.5.99
|
||||||
|
- `package.json`: 0.5.101 → 0.5.102
|
||||||
|
|
||||||
|
## Синтаксис
|
||||||
|
`node -c` — OK на всех изменённых файлах.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# V2 Unified Architecture — v0.5.105 (2026-06-15)
|
||||||
|
|
||||||
|
## Как устроено
|
||||||
|
|
||||||
|
```
|
||||||
|
/v2/app — единый интерфейс
|
||||||
|
│
|
||||||
|
├── Юзер (isAdmin=false)
|
||||||
|
│ └── dropdown: свои компании из IAM (profiles[])
|
||||||
|
│
|
||||||
|
├── Админ (WZ01112, isAdmin=true)
|
||||||
|
│ ├── dropdown: все компании из БД
|
||||||
|
│ ├── свои помечены (личное)
|
||||||
|
│ └── email помечен (админ)
|
||||||
|
│
|
||||||
|
└── CRUD — одинаковый для обоих
|
||||||
|
└── crud.list/add/edit/remove(clientId)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Слои
|
||||||
|
|
||||||
|
| Слой | Файл | Что |
|
||||||
|
|------|------|-----|
|
||||||
|
| CRUD API | `v2/src/crud/index.js` | Чистые функции, clientId → БД |
|
||||||
|
| SQL | `v2/src/db/queries.js` | Все запросы, транзакции |
|
||||||
|
| Роутер | `v2/src/user/index.js` | Express, сессия → crud |
|
||||||
|
| Шаблон | `views/v2/user.ejs` | EJS, единый для всех |
|
||||||
|
|
||||||
|
## Компании
|
||||||
|
|
||||||
|
- `clientId` (W-номер) → `company_id` (внутренний PK) через `getOrCreateCompany`
|
||||||
|
- По умолчанию: активный профиль из IAM (`is_active_profile=true`)
|
||||||
|
- Админ может переключиться на любую компанию (`?switchTo=W***`)
|
||||||
|
- Юзер — только на свои
|
||||||
|
|
||||||
|
## История версий (новые сверху)
|
||||||
|
|
||||||
|
- **0.5.139** — ширина столбцов: CIDR 125px, комментарий широкий, Правка вместо Редактирование
|
||||||
|
- **0.5.138** — CSP script-src-attr 'unsafe-inline' → onchange заработал
|
||||||
|
- **0.5.137** — onchange + console.log для проверки
|
||||||
|
- **0.5.136** — ↺ зелёная, deleted сохраняется после restore/delete
|
||||||
|
- **0.5.135** — UI таблицы: разделение столбцов, 💾, размеры
|
||||||
|
- **0.5.134** — восстановление записей (restore), кнопка удалённых, время МСК
|
||||||
|
- **0.5.133** — type=submit на кнопке удаления
|
||||||
|
- **0.5.132** — ADMIN_EMAIL в resolveContext для всех
|
||||||
|
- **0.5.131** — isAdmin при имперсонации по ADMIN_EMAIL
|
||||||
|
- **0.5.130** — имперсонация не сохраняет isAdmin от оригинала
|
||||||
|
- **0.5.129** — кнопка Выбрать, убран debug
|
||||||
|
- **0.5.127** — clId обновляется после switchTo (фикс бага)
|
||||||
|
- **0.5.105** — убран toggle Администратор
|
||||||
|
- **0.5.104** — (личное) у своих компаний в dropdown
|
||||||
|
- **0.5.103** — WZ01112: adminMode auto-on
|
||||||
|
- **0.5.102** — единый интерфейс /v2/app (юзер+админ)
|
||||||
+196
-16
@@ -26,13 +26,13 @@ const express = require('express');
|
|||||||
const config = require('./src/config');
|
const config = require('./src/config');
|
||||||
const auth = require('./src/auth');
|
const auth = require('./src/auth');
|
||||||
const { resolveContext } = require('./src/router');
|
const { resolveContext } = require('./src/router');
|
||||||
const { enhanceImpersonation } = require('./src/impersonation');
|
|
||||||
const { createUserRouter } = require('./src/user');
|
const { createUserRouter } = require('./src/user');
|
||||||
const { createAdminRouter } = require('./src/admin');
|
const { createAdminRouter } = require('./src/admin');
|
||||||
const { pool } = require('./src/db');
|
const { pool } = require('./src/db');
|
||||||
const { ensureSchema } = require('./src/db/schema');
|
const { ensureSchema } = require('./src/db/schema');
|
||||||
|
const { mutationLimiter, exportLimiter } = require('../src/middleware/rateLimit');
|
||||||
|
|
||||||
function createV2Router() {
|
function createV2Router({ generateCsrfToken, doubleCsrfProtection } = {}) {
|
||||||
// Автосоздание таблиц v2_companies, v2_entries, v2_audit при старте
|
// Автосоздание таблиц v2_companies, v2_entries, v2_audit при старте
|
||||||
ensureSchema(pool).catch(e => console.error('[v2:db] Schema error:', e.message));
|
ensureSchema(pool).catch(e => console.error('[v2:db] Schema error:', e.message));
|
||||||
|
|
||||||
@@ -43,39 +43,162 @@ function createV2Router() {
|
|||||||
res.redirect('/login?returnTo=' + encodeURIComponent('/v2/app'));
|
res.redirect('/login?returnTo=' + encodeURIComponent('/v2/app'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── GET /v2/iam — отладка: показать IAM-данные из сессии ────────────
|
// ── GET /v2/iam — отладка: IAM-данные из сессии (только админ) ────
|
||||||
router.get('/iam', (req, res) => {
|
router.get('/iam', resolveContext, (req, res) => {
|
||||||
|
if (!req.canAdmin) return res.status(403).send('Доступ запрещён');
|
||||||
const u = req.session && req.session.user;
|
const u = req.session && req.session.user;
|
||||||
if (!u) return res.send('<h2>Нет данных</h2><p><a href="/v2/login">Войти</a></p>');
|
|
||||||
res.send(debugPage(u, config.version));
|
res.send(debugPage(u, config.version));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── /v2/app — пользовательский CRUD (только с сессией) ──────────────
|
// ── GET /v2/iam_response — сырой ответ IAM (только админ) ─────────
|
||||||
router.use('/app', enhanceImpersonation, resolveContext, createUserRouter());
|
router.get('/iam_response', resolveContext, async (req, res) => {
|
||||||
|
if (!req.canAdmin) return res.status(403).send('Доступ запрещён');
|
||||||
|
const token = req.session && req.session.token;
|
||||||
|
if (!token) return res.redirect('/v2/login');
|
||||||
|
try {
|
||||||
|
const mainAuth = require('../src/auth');
|
||||||
|
const iamData = await mainAuth.fetchIamUser(token);
|
||||||
|
res.set('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.send(rawPage('IAM PROD', iamData.raw));
|
||||||
|
} catch (e) {
|
||||||
|
res.send('<h2>Ошибка IAM</h2><pre>' + e.message + '</pre>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── /v2/admin — админка (только для isAdmin=true) ────────────────────
|
// ── GET /v2/iam-gateway-test — сравнение API (только админ) ────────
|
||||||
router.use('/admin', enhanceImpersonation, resolveContext, createAdminRouter());
|
router.get('/iam-gateway-test', resolveContext, async (req, res) => {
|
||||||
|
if (!req.canAdmin) return res.status(403).send('Доступ запрещён');
|
||||||
|
const token = req.session && req.session.token;
|
||||||
|
if (!token) return res.redirect('/v2/login');
|
||||||
|
|
||||||
|
const base = config.iamApiBase;
|
||||||
|
const tests = [
|
||||||
|
{
|
||||||
|
name: 'auth-api /auth/user',
|
||||||
|
url: base + '/auth/user',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'lk-api-gateway /iam/auth/user',
|
||||||
|
url: 'https://lk-api-gateway.ngcloud.ru/api/v1/iam/auth/user',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'auth-api /impersonation/status',
|
||||||
|
url: base + '/impersonation/status',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = {};
|
||||||
|
for (const t of tests) {
|
||||||
|
try {
|
||||||
|
const data = await iamFetch(token, t.url);
|
||||||
|
results[t.name] = { ok: true, data };
|
||||||
|
} catch (e) {
|
||||||
|
results[t.name] = { ok: false, error: e.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.set('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.send(iamComparePage(results));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── /v2/app — пользовательский CRUD (только с сессией) ──────────────
|
||||||
|
router.use('/app', mutationLimiter, resolveContext, doubleCsrfProtection || ((req,res,next)=>next()), createUserRouter({ generateCsrfToken }));
|
||||||
|
|
||||||
|
// ── /v2/admin — админка: лимиты + аудит ──────────────────────────
|
||||||
|
router.use('/admin', mutationLimiter, resolveContext, doubleCsrfProtection || ((req,res,next)=>next()), createAdminRouter({ generateCsrfToken }));
|
||||||
|
|
||||||
// ── GET /v2/ — редирект на /v2/app ──────────────────────────────────
|
// ── GET /v2/ — редирект на /v2/app ──────────────────────────────────
|
||||||
router.get('/', (req, res) => res.redirect('/v2/app'));
|
router.get('/', (req, res) => res.redirect('/v2/app'));
|
||||||
|
|
||||||
// ── /v2/test — тестовый API (отключить: ENABLE_TEST_API=false) ──────
|
// ── /v2/test — тестовый API (только для ADMIN_EMAIL) ────────────
|
||||||
// Код остаётся, роутер не монтируется при ENABLE_TEST_API=false.
|
|
||||||
if (process.env.ENABLE_TEST_API !== 'false') {
|
if (process.env.ENABLE_TEST_API !== 'false') {
|
||||||
const { createTestRouter } = require('./src/test');
|
const { createTestRouter } = require('./src/test');
|
||||||
router.use('/test', createTestRouter());
|
router.use('/test', resolveContext, (req, res, next) => {
|
||||||
|
if (req.email !== (process.env.ADMIN_EMAIL || '')) return res.status(403).send('Доступ запрещён');
|
||||||
|
next();
|
||||||
|
}, createTestRouter());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── /v2/export — выгрузка CIDR (публичный, без авторизации) ──────
|
// ── /v2/export — выгрузка CIDR (только для админов) ─────────────
|
||||||
const { createExportRouter } = require('./src/export');
|
const { createExportRouter } = require('./src/export');
|
||||||
router.use('/export', createExportRouter());
|
router.use('/export', exportLimiter, resolveContext, (req, res, next) => {
|
||||||
|
if (!req.canAdmin) return res.status(403).send('Доступ запрещён — только для администраторов');
|
||||||
|
next();
|
||||||
|
}, createExportRouter());
|
||||||
|
|
||||||
// ── GET /v2/logout — редирект на основной выход ─────────────────────
|
// ── GET /v2/logout — выход только из whitelist (без KC SSO) ─────────
|
||||||
router.get('/logout', (req, res) => res.redirect('/logout'));
|
router.get('/logout', (req, res) => {
|
||||||
|
const ua = (req.headers['user-agent'] || '').toLowerCase();
|
||||||
|
const isBrowser = ua.includes('mozilla') || ua.includes('chrome') || ua.includes('safari');
|
||||||
|
if (!isBrowser) {
|
||||||
|
req.session.destroy(() => res.redirect('/v2/login'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.query.confirm === 'yes') {
|
||||||
|
req.session.destroy(() => {
|
||||||
|
res.set('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.send('<!DOCTYPE html><html><head><meta charset="utf-8"><title>Выход</title>'
|
||||||
|
+ '<style>body{font-family:sans-serif;background:#111;color:#eee;padding:60px;text-align:center}'
|
||||||
|
+ 'h2{color:#4fc3f7}p{font-size:18px;margin:20px 0}a{color:#80cbc4;font-size:16px}'
|
||||||
|
+ '</style></head><body>'
|
||||||
|
+ '<h2>Вы вышли из сервиса</h2>'
|
||||||
|
+ '<p>Сессия whitelist завершена.<br>Сессия в других сервисах Nubes сохранена.</p>'
|
||||||
|
+ '<a href="/v2/app">Войти снова</a>'
|
||||||
|
+ '</body></html>');
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.set('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.send('<!DOCTYPE html><html><head><meta charset="utf-8"><title>Выход</title>'
|
||||||
|
+ '<style>body{font-family:sans-serif;background:#111;color:#eee;padding:40px;text-align:center}'
|
||||||
|
+ 'h2{color:#4fc3f7}p{font-size:16px;margin:20px 0}'
|
||||||
|
+ '.btn{display:inline-block;padding:12px 24px;margin:0 10px;border-radius:6px;text-decoration:none;font-size:16px}'
|
||||||
|
+ '.btn-yes{background:#ef5350;color:#fff}.btn-no{background:#333;color:#eee}'
|
||||||
|
+ '</style></head><body>'
|
||||||
|
+ '<h2>Выход из сервиса</h2>'
|
||||||
|
+ '<p>Вы будете выведены только из whitelist.<br>Сессия в других сервисах Nubes сохранится.</p>'
|
||||||
|
+ '<a href="/v2/logout?confirm=yes" class="btn btn-yes">Выйти</a>'
|
||||||
|
+ '<a href="/v2/app" class="btn btn-no">Отмена</a>'
|
||||||
|
+ '</body></html>');
|
||||||
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Хелпер: HTTP-запрос к IAM ────────────────────────────────────────────────
|
||||||
|
async function iamFetch(token, url) {
|
||||||
|
const https = require('https');
|
||||||
|
const u = new URL(url);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = https.get({
|
||||||
|
hostname: u.hostname, port: 443, path: u.pathname,
|
||||||
|
headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },
|
||||||
|
}, res => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', c => data += c);
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode !== 200) return reject(new Error(res.statusCode + ': ' + data.slice(0, 200)));
|
||||||
|
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.setTimeout(5000, () => { req.destroy(); reject(new Error('timeout')); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawPage(title, data) {
|
||||||
|
return '<!DOCTYPE html>\n<html><head><meta charset="utf-8"><title>' + title + '</title>'
|
||||||
|
+ '<style>body{font-family:monospace;background:#111;color:#eee;padding:20px}'
|
||||||
|
+ 'pre{background:#1a1a2e;padding:15px;border-radius:8px;overflow-x:auto;font-size:13px}'
|
||||||
|
+ 'h2{color:#4fc3f7}a{color:#80cbc4}</style></head><body>'
|
||||||
|
+ '<h2>Сырой ответ ' + title + '</h2>'
|
||||||
|
+ '<pre>' + JSON.stringify(data, null, 2).replace(/</g, '<') + '</pre>'
|
||||||
|
+ '<p><a href="/v2/iam">← Сессия</a> | <a href="/v2/app">CRUD</a></p>'
|
||||||
|
+ '</body></html>';
|
||||||
|
}
|
||||||
|
|
||||||
function debugPage(u, version) {
|
function debugPage(u, version) {
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html><head><meta charset="utf-8"><title>V2 — IAM</title>
|
<html><head><meta charset="utf-8"><title>V2 — IAM</title>
|
||||||
@@ -92,4 +215,61 @@ function debugPage(u, version) {
|
|||||||
</body></html>`;
|
</body></html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function iamComparePage(results) {
|
||||||
|
function fmt(data, depth) {
|
||||||
|
if (data === null || data === undefined) return '<span style="color:#888">null</span>';
|
||||||
|
if (typeof data === 'string') return '<span style="color:#ce93d8">"' + data.replace(/</g, '<') + '"</span>';
|
||||||
|
if (typeof data === 'number' || typeof data === 'boolean') return '<span style="color:#81c784">' + data + '</span>';
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
if (data.length === 0) return '<span style="color:#888">[]</span>';
|
||||||
|
let html = '<span style="color:#888">[</span><div style="padding-left:20px">';
|
||||||
|
for (const item of data) html += '<div>' + fmt(item, depth + 1) + ',</div>';
|
||||||
|
html += '</div><span style="color:#888">]</span>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
if (typeof data === 'object') {
|
||||||
|
let html = '<span style="color:#888">{</span><div style="padding-left:20px">';
|
||||||
|
for (const [k, v] of Object.entries(data)) {
|
||||||
|
html += '<div><span style="color:#82aaff">"' + k + '"</span>: ' + fmt(v, depth + 1) + '</div>';
|
||||||
|
}
|
||||||
|
html += '</div><span style="color:#888">}</span>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
return String(data).replace(/</g, '<');
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<!DOCTYPE html><html><head><meta charset="utf-8"><title>IAM Impersonation</title>';
|
||||||
|
html += '<style>body{font-family:monospace;background:#111;color:#eee;padding:20px;font-size:14px}';
|
||||||
|
html += 'h2{color:#4fc3f7}.ok{color:#81c784}.err{color:#ef5350}';
|
||||||
|
html += '.card{background:#1a1a2e;border-radius:8px;padding:15px;margin-bottom:15px;overflow-x:auto}';
|
||||||
|
html += '.card-title{color:#b39ddb;font-size:18px;font-weight:bold;margin-bottom:10px}';
|
||||||
|
html += 'a{color:#80cbc4;text-decoration:none}a:hover{text-decoration:underline}';
|
||||||
|
html += '.nav{display:flex;gap:15px;margin-bottom:20px;flex-wrap:wrap}';
|
||||||
|
html += '.nav a{background:#1a1a2e;padding:8px 16px;border-radius:6px;font-size:14px}';
|
||||||
|
html += '</style></head><body>';
|
||||||
|
html += '<h2>🧪 IAM Impersonation — GET эндпоинты</h2>';
|
||||||
|
html += '<div class="nav">';
|
||||||
|
html += '<a href="/v2/iam">← Сессия</a>';
|
||||||
|
html += '<a href="/v2/app">CRUD</a>';
|
||||||
|
html += '<a href="/v2/admin">Админка</a>';
|
||||||
|
html += '<a href="/v2/iam_response">IAM PROD</a>';
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
for (const [name, r] of Object.entries(results)) {
|
||||||
|
html += '<div class="card">';
|
||||||
|
html += '<div class="card-title">GET /impersonation/' + name + '</div>';
|
||||||
|
if (r.ok) {
|
||||||
|
html += '<div style="font-family:monospace;white-space:pre-wrap;tab-size:2">';
|
||||||
|
html += fmt(r.data, 0);
|
||||||
|
html += '</div>';
|
||||||
|
} else {
|
||||||
|
html += '<div class="err">❌ ' + r.error.replace(/</g, '<') + '</div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</body></html>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = { createV2Router };
|
module.exports = { createV2Router };
|
||||||
|
|||||||
+28
-28
@@ -1,45 +1,59 @@
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
// V2 — Админка (Express + EJS)
|
// V2 — Админка: лимиты и аудит (Express + EJS)
|
||||||
//
|
//
|
||||||
// ЭТО: Express-роутер для админа, рендерит EJS-шаблоны.
|
// ЭТО: Express-роутер для админа.
|
||||||
// НЕ: доступно обычным юзерам (middleware: req.isAdmin).
|
// НЕ: доступно обычным юзерам (middleware: req.canAdmin).
|
||||||
//
|
//
|
||||||
// ШАБЛОНЫ:
|
// ШАБЛОНЫ:
|
||||||
// views/v2/admin.ejs — дашборд (companies, user, version)
|
// views/v2/admin.ejs — компании + лимиты
|
||||||
// views/v2/admin-audit.ejs — аудит (audit, company, version)
|
// views/v2/admin-audit.ejs — аудит
|
||||||
// views/v2/admin-entries.ejs — записи (entries, company, used, limit, ...)
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const q = require('../db/queries');
|
const q = require('../db/queries');
|
||||||
const crud = require('../crud');
|
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
|
|
||||||
function createAdminRouter() {
|
function createAdminRouter({ generateCsrfToken } = {}) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.use((req, res, next) => {
|
router.use((req, res, next) => {
|
||||||
if (!req.isAdmin) return res.redirect('/v2/app');
|
if (!req.canAdmin) return res.redirect('/v2/app');
|
||||||
|
// user для шаблонов
|
||||||
|
req.adminUser = {
|
||||||
|
email: req.email,
|
||||||
|
fio: (req.session && req.session.user && req.session.user.fio) || null,
|
||||||
|
};
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── GET / — дашборд ─────────────────────────────────────────────────
|
// ── GET / — дашборд: компании + лимиты ─────────────────────────────
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const companies = await q.getAllCompanies();
|
const companies = await q.getAllCompanies();
|
||||||
res.render('v2/admin', { companies, user: req.user || {}, version: config.version });
|
res.render('v2/admin', {
|
||||||
|
companies, user: req.adminUser, version: config.version, env: config.env,
|
||||||
|
csrfToken: generateCsrfToken ? generateCsrfToken(req, res) : '',
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).send('<h2>Ошибка</h2><pre>' + e.message + '</pre>');
|
res.status(500).send('<h2>Ошибка</h2><pre>' + e.message + '</pre>');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── GET /audit ──────────────────────────────────────────────────────
|
// ── GET /audit — журнал операций с пагинацией и фильтром по дате ───
|
||||||
router.get('/audit', async (req, res) => {
|
router.get('/audit', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const companyId = req.query.companyId ? parseInt(req.query.companyId) : null;
|
const companyId = req.query.companyId ? parseInt(req.query.companyId) : null;
|
||||||
const audit = await q.getAudit(companyId);
|
// days=null → все записи (без фильтра по дате)
|
||||||
|
const days = req.query.days ? Math.min(Math.max(parseInt(req.query.days), 1), 365) : null;
|
||||||
|
const page = Math.max(parseInt(req.query.page) || 1, 1);
|
||||||
|
const perPage = 100;
|
||||||
|
const result = await q.getAudit({ companyId, days, page, limit: perPage });
|
||||||
const company = companyId ? await q.getCompanyById(companyId) : null;
|
const company = companyId ? await q.getCompanyById(companyId) : null;
|
||||||
res.render('v2/admin-audit', { audit, company, version: config.version });
|
res.render('v2/admin-audit', {
|
||||||
|
audit: result.rows, total: result.total,
|
||||||
|
page, pages: Math.ceil(result.total / perPage) || 1,
|
||||||
|
days, company, version: config.version, env: config.env,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).send('<h2>Ошибка</h2><pre>' + e.message + '</pre>');
|
res.status(500).send('<h2>Ошибка</h2><pre>' + e.message + '</pre>');
|
||||||
}
|
}
|
||||||
@@ -58,20 +72,6 @@ function createAdminRouter() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── GET /entries ────────────────────────────────────────────────────
|
|
||||||
router.get('/entries', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const companyId = parseInt(req.query.companyId);
|
|
||||||
const company = companyId ? await q.getCompanyById(companyId) : null;
|
|
||||||
if (!company) return res.redirect('/v2/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 });
|
|
||||||
} catch (e) {
|
|
||||||
res.status(500).send('<h2>Ошибка</h2><pre>' + e.message + '</pre>');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,18 +4,23 @@
|
|||||||
// ЭТО: все настройки в одном месте.
|
// ЭТО: все настройки в одном месте.
|
||||||
// ЗАЧЕМ: всё что может поменяться между средами — здесь, не размазано по коду.
|
// ЗАЧЕМ: всё что может поменяться между средами — здесь, не размазано по коду.
|
||||||
//
|
//
|
||||||
// ПРИОРИТЕТ: process.env (V2_*) → хардкод-умолчания.
|
// ПРИОРИТЕТ: process.env → хардкод-умолчания.
|
||||||
// Умолчания — для тестовой среды (VM KC на italo.kube5s.ru:8080).
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
version: '0.5.98',
|
version: '0.1.13',
|
||||||
|
|
||||||
|
// ── Среда ─────────────────────────────────────────────────────────────────
|
||||||
|
// APP_ENV=test → тестовый стенд (жёлтый баннер в UI, можно включать тестовый API)
|
||||||
|
// APP_ENV=production или не задана → production (баннера нет)
|
||||||
|
env: process.env.APP_ENV || 'production',
|
||||||
|
|
||||||
// ── IAM ──────────────────────────────────────────────────────────────────
|
// ── IAM ──────────────────────────────────────────────────────────────────
|
||||||
iamUrl: process.env.V2_IAM_URL || 'https://auth-api.ngcloud.ru/api/v1/auth/user',
|
iamUrl: process.env.V2_IAM_URL || 'https://auth-api.ngcloud.ru/api/v1/auth/user',
|
||||||
|
iamApiBase: process.env.IAM_API_BASE || 'https://auth-api.ngcloud.ru/api/v1',
|
||||||
|
|
||||||
// ── OIDC (Keycloak) ──────────────────────────────────────────────────────
|
// ── OIDC (Keycloak) ──────────────────────────────────────────────────────
|
||||||
kcBaseUrl: process.env.V2_KC_BASE_URL || 'https://italo.kube5s.ru:8080/realms/ipwhitelist',
|
kcBaseUrl: process.env.V2_KC_BASE_URL || 'https://login.ngcloud.ru/realms/ipwhitelist',
|
||||||
kcClientId: process.env.V2_KC_CLIENT_ID || 'ipwhitelist-app',
|
kcClientId: process.env.V2_KC_CLIENT_ID || 'ipwhitelist-app',
|
||||||
kcClientSecret: process.env.V2_KC_CLIENT_SECRET || '',
|
kcClientSecret: process.env.V2_KC_CLIENT_SECRET || '',
|
||||||
appUrl: process.env.V2_APP_URL || 'https://whitelist.nodejsk8s.services.ngcloud.ru',
|
appUrl: process.env.V2_APP_URL || 'https://whitelist.nodejsk8s.services.ngcloud.ru',
|
||||||
|
|||||||
@@ -74,4 +74,11 @@ async function remove(entryId, clientId, email, impBy) {
|
|||||||
return q.deleteEntry(entryId, co.id, email, impBy);
|
return q.deleteEntry(entryId, co.id, email, impBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { list, add, edit, remove };
|
// ── restore(entryId, clientId, email, impBy) → void ─────────────────────────
|
||||||
|
// Восстановление удалённой записи.
|
||||||
|
async function restore(entryId, clientId, email, impBy) {
|
||||||
|
const co = await resolve(clientId);
|
||||||
|
return q.restoreEntry(entryId, co.id, email, impBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { list, add, edit, remove, restore };
|
||||||
|
|||||||
+79
-8
@@ -177,6 +177,44 @@ async function deleteEntry(entryId, companyId, userEmail, impersonatedBy) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Восстановить удалённую запись (убрать deleted_at).
|
||||||
|
*/
|
||||||
|
async function restoreEntry(entryId, companyId, userEmail, impersonatedBy) {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
const old = (await client.query(
|
||||||
|
'SELECT * FROM whitelist_entries WHERE id = $1 AND company_id = $2 AND deleted_at IS NOT NULL',
|
||||||
|
[entryId, companyId]
|
||||||
|
)).rows[0];
|
||||||
|
if (!old) throw new Error('Удалённая запись не найдена');
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
'UPDATE whitelist_entries SET deleted_by = NULL, deleted_at = NULL WHERE id = $1 AND company_id = $2',
|
||||||
|
[entryId, companyId]
|
||||||
|
);
|
||||||
|
await logAudit(userEmail, companyId, 'RESTORE', old.value_cidr, old.value_cidr, entryId, impersonatedBy, client);
|
||||||
|
|
||||||
|
// Проверить лимит
|
||||||
|
const company = (await client.query('SELECT * FROM companies WHERE id = $1', [companyId])).rows[0];
|
||||||
|
const limit = company.custom_limit != null ? company.custom_limit : (parseInt(process.env.DEFAULT_LIMIT, 10) || 15);
|
||||||
|
const cnt = (await client.query(
|
||||||
|
'SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL',
|
||||||
|
[companyId]
|
||||||
|
)).rows[0].c;
|
||||||
|
if (cnt > limit) throw new Error('Восстановление превысит лимит (' + limit + ')');
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Админ ─────────────────────────────────────────────────────────────────────
|
// ── Админ ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function getCompanyById(id) {
|
async function getCompanyById(id) {
|
||||||
@@ -227,21 +265,54 @@ async function logAudit(userEmail, companyId, action, oldValue, newValue, entryI
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAudit(companyId = null) {
|
/**
|
||||||
let sql = `
|
* Журнал операций с пагинацией и опциональным фильтром по дате.
|
||||||
SELECT a.*, c.name AS company_name, c.client_id
|
* @param {Object} opts
|
||||||
|
* @param {number|null} opts.companyId — фильтр по компании (null = все)
|
||||||
|
* @param {number|null} opts.days — глубина в днях (null = без ограничения)
|
||||||
|
* @param {number} opts.page — страница (с 1)
|
||||||
|
* @param {number} opts.limit — записей на странице
|
||||||
|
* @returns {{ rows: Array, total: number }}
|
||||||
|
*/
|
||||||
|
async function getAudit({ companyId = null, days = null, page = 1, limit = 100 } = {}) {
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
let where = '';
|
||||||
|
if (companyId) {
|
||||||
|
where += ' WHERE a.company_id = $' + (params.length + 1);
|
||||||
|
params.push(companyId);
|
||||||
|
}
|
||||||
|
if (days) {
|
||||||
|
const dayParam = '$' + (params.length + 1);
|
||||||
|
where += (where ? ' AND' : ' WHERE') + ' a.created_at > NOW() - (' + dayParam + ' || \' days\')::INTERVAL';
|
||||||
|
params.push(String(days));
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitIdx = params.length + 1;
|
||||||
|
const offsetIdx = params.length + 2;
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT a.*, c.name AS company_name, c.client_id,
|
||||||
|
COUNT(*) OVER() AS total_count
|
||||||
FROM audit_log a
|
FROM audit_log a
|
||||||
JOIN companies c ON c.id = a.company_id
|
JOIN companies c ON c.id = a.company_id
|
||||||
|
${where}
|
||||||
|
ORDER BY a.created_at DESC
|
||||||
|
LIMIT $${limitIdx} OFFSET $${offsetIdx}
|
||||||
`;
|
`;
|
||||||
const params = [];
|
params.push(limit, offset);
|
||||||
if (companyId) { sql += ' WHERE a.company_id = $1'; params.push(companyId); }
|
|
||||||
sql += ' ORDER BY a.created_at DESC LIMIT 500';
|
const result = await pool.query(sql, params);
|
||||||
return (await pool.query(sql, params)).rows;
|
return {
|
||||||
|
rows: result.rows,
|
||||||
|
total: result.rows.length > 0 ? parseInt(result.rows[0].total_count, 10) : 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getOrCreateCompany, getLimit,
|
getOrCreateCompany, getLimit,
|
||||||
listEntries, createEntry, updateEntry, deleteEntry,
|
listEntries, createEntry, updateEntry, deleteEntry, restoreEntry,
|
||||||
getExportCIDRs, getAudit,
|
getExportCIDRs, getAudit,
|
||||||
getCompanyById, getAllCompanies, setLimit,
|
getCompanyById, getAllCompanies, setLimit,
|
||||||
};
|
};
|
||||||
|
|||||||
+93
-12
@@ -1,20 +1,22 @@
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
// V2 — инициализация схемы БД
|
// V2 — инициализация схемы БД + миграции
|
||||||
//
|
//
|
||||||
// ЭТО: CREATE TABLE IF NOT EXISTS — безопасный повторный запуск.
|
// ЭТО: CREATE TABLE IF NOT EXISTS (идемпотентно) + applyMigrations().
|
||||||
// ЗАЧЕМ:
|
// ЗАЧЕМ:
|
||||||
// 1. Приложение само создаёт таблицы при старте — не нужны миграции.
|
// 1. Приложение само создаёт таблицы при старте — не нужны ручные DDL.
|
||||||
// 2. v2_ префикс изолирует тестовые таблицы от основных.
|
// 2. Миграции — отдельные .sql файлы в sql/migrations/, выполняются по порядку.
|
||||||
// 3. Безопасно для production: IF NOT EXISTS.
|
// 3. Безопасно для production: IF NOT EXISTS + _migrations таблица.
|
||||||
//
|
//
|
||||||
// ТАБЛИЦЫ:
|
// ТАБЛИЦЫ:
|
||||||
// companies — client_id (W-номер), name, custom_limit
|
// companies — client_id (W-номер), name, custom_limit
|
||||||
// whitelist_entries — CIDR, комментарий, кто создал/изменил/удалил
|
// whitelist_entries — CIDR, комментарий, кто создал/изменил/удалил
|
||||||
// audit_log — все действия: CREATE/UPDATE/DELETE, с impersonated_by
|
// audit_log — все действия: CREATE/UPDATE/DELETE/RESTORE, с impersonated_by
|
||||||
//
|
// _migrations — какие миграции уже выполнены
|
||||||
// ИНДЕКСЫ: на company_id для быстрых SELECT + на unique(client_id).
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
async function ensureSchema(pool) {
|
async function ensureSchema(pool) {
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
-- Компании: W-номер уникален, upsert через ON CONFLICT
|
-- Компании: W-номер уникален, upsert через ON CONFLICT
|
||||||
@@ -41,12 +43,17 @@ async function ensureSchema(pool) {
|
|||||||
deleted_at TIMESTAMPTZ
|
deleted_at TIMESTAMPTZ
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Защита от дублей: уникальный CIDR в компании среди активных записей
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_whitelist_entries_active_cidr
|
||||||
|
ON whitelist_entries(company_id, value_cidr)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
-- Аудит: каждое действие с возможностью отследить имперсонацию
|
-- Аудит: каждое действие с возможностью отследить имперсонацию
|
||||||
CREATE TABLE IF NOT EXISTS audit_log (
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
user_email VARCHAR(255) NOT NULL,
|
user_email VARCHAR(255) NOT NULL,
|
||||||
company_id INTEGER NOT NULL,
|
company_id INTEGER NOT NULL,
|
||||||
action VARCHAR(32) NOT NULL CHECK (action IN ('CREATE','UPDATE','DELETE')),
|
action VARCHAR(32) NOT NULL,
|
||||||
old_value TEXT,
|
old_value TEXT,
|
||||||
new_value TEXT,
|
new_value TEXT,
|
||||||
entry_id INTEGER,
|
entry_id INTEGER,
|
||||||
@@ -56,9 +63,83 @@ async function ensureSchema(pool) {
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_log_company_time
|
CREATE INDEX IF NOT EXISTS idx_audit_log_company_time
|
||||||
ON audit_log(company_id, created_at DESC);
|
ON audit_log(company_id, created_at DESC);
|
||||||
|
|
||||||
|
-- Таблица учёта выполненных миграций
|
||||||
|
CREATE TABLE IF NOT EXISTS _migrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
filename VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
console.log('[db] Schema ensured');
|
console.log('[db] Schema ensured');
|
||||||
|
|
||||||
|
// Выполнить неприменённые миграции
|
||||||
|
await applyMigrations(pool);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { ensureSchema };
|
/**
|
||||||
|
* Применить новые миграции из sql/migrations/.
|
||||||
|
* Читает все .sql файлы, сортирует по имени, выполняет те,
|
||||||
|
* которых ещё нет в таблице _migrations.
|
||||||
|
*/
|
||||||
|
async function applyMigrations(pool) {
|
||||||
|
const dir = path.join(__dirname, '..', '..', '..', 'sql', 'migrations');
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
console.log('[db] No migrations dir, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(dir)
|
||||||
|
.filter(f => f.endsWith('.sql'))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
// Какие уже выполнены
|
||||||
|
const done = new Set(
|
||||||
|
(await pool.query('SELECT filename FROM _migrations')).rows.map(r => r.filename)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (done.has(file)) continue;
|
||||||
|
const sql = fs.readFileSync(path.join(dir, file), 'utf8');
|
||||||
|
await pool.query(sql);
|
||||||
|
await pool.query('INSERT INTO _migrations (filename) VALUES ($1)', [file]);
|
||||||
|
console.log('[db] Migration applied:', file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initOldMigrations(pool) {
|
||||||
|
// Миграции, которые раньше были в ensureSchema() — теперь
|
||||||
|
// фиксируем их как уже выполненные, чтобы не потерять.
|
||||||
|
const dir = path.join(__dirname, '..', '..', '..', 'sql', 'migrations');
|
||||||
|
if (!fs.existsSync(dir)) return;
|
||||||
|
const files = fs.readdirSync(dir)
|
||||||
|
.filter(f => f.endsWith('.sql'))
|
||||||
|
.sort();
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
// Создать таблицу если ещё нет (на случай первого запуска с миграциями)
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS _migrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
filename VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const done = new Set(
|
||||||
|
(await pool.query('SELECT filename FROM _migrations')).rows.map(r => r.filename)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (done.has(file)) continue;
|
||||||
|
const sql = fs.readFileSync(path.join(dir, file), 'utf8');
|
||||||
|
await pool.query(sql);
|
||||||
|
await pool.query('INSERT INTO _migrations (filename) VALUES ($1)', [file]);
|
||||||
|
console.log('[db] Migration applied:', file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ensureSchema, applyMigrations, initOldMigrations };
|
||||||
|
|||||||
+43
-7
@@ -13,8 +13,9 @@
|
|||||||
//
|
//
|
||||||
// ПАРАМЕТРЫ:
|
// ПАРАМЕТРЫ:
|
||||||
// ?companyId=X — фильтр по компании (внутренний id)
|
// ?companyId=X — фильтр по компании (внутренний id)
|
||||||
// ?view=1 — показать в браузере (иначе — скачивание)
|
// ?view=1 — text/plain inline (копирование)
|
||||||
// ?filename=X — имя файла (по умолчанию white-list.txt)
|
// ?view=2 — HTML таблица с выровненными IP
|
||||||
|
// ?filename=X — имя файла (скачивание, когда нет view)
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
@@ -29,13 +30,25 @@ function createExportRouter() {
|
|||||||
const companyId = req.query.companyId ? parseInt(req.query.companyId) : null;
|
const companyId = req.query.companyId ? parseInt(req.query.companyId) : null;
|
||||||
const cidrs = await q.getExportCIDRs(companyId);
|
const cidrs = await q.getExportCIDRs(companyId);
|
||||||
const aggregated = aggregateCIDRs(cidrs);
|
const aggregated = aggregateCIDRs(cidrs);
|
||||||
|
|
||||||
|
// ?view=2 — HTML таблица с выровненными IP
|
||||||
|
if (req.query.view === '2') {
|
||||||
|
res.set('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
return res.send(renderTable(aggregated));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ?view=1 — text/plain inline
|
||||||
|
if (req.query.view === '1') {
|
||||||
|
const text = aggregated.join('\n') + (aggregated.length ? '\n' : '');
|
||||||
|
res.set('Content-Type', 'text/plain; charset=utf-8');
|
||||||
|
res.set('Content-Disposition', 'inline');
|
||||||
|
return res.send(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Скачивание (по умолчанию) — без filename чтобы браузер показал диалог сохранения
|
||||||
const text = aggregated.join('\n') + (aggregated.length ? '\n' : '');
|
const text = aggregated.join('\n') + (aggregated.length ? '\n' : '');
|
||||||
|
|
||||||
res.set('Content-Type', 'text/plain; charset=utf-8');
|
res.set('Content-Type', 'text/plain; charset=utf-8');
|
||||||
|
res.set('Content-Disposition', 'attachment');
|
||||||
const fname = (req.query.filename || 'white-list.txt').replace(/[^\w\-_. ]/g, '_');
|
|
||||||
const disp = req.query.view === '1' ? 'inline' : 'attachment';
|
|
||||||
res.set('Content-Disposition', disp + '; filename="' + fname + '"');
|
|
||||||
res.send(text);
|
res.send(text);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[v2:export] Error:', e);
|
console.error('[v2:export] Error:', e);
|
||||||
@@ -46,4 +59,27 @@ function createExportRouter() {
|
|||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderTable(cidrs) {
|
||||||
|
const rows = cidrs.map(c => {
|
||||||
|
const [ip, mask] = c.split('/');
|
||||||
|
const [a, b, c2, d] = ip.split('.');
|
||||||
|
return '<tr>'
|
||||||
|
+ '<td>' + a.padStart(2, ' ') + '</td>'
|
||||||
|
+ '<td>' + b.padStart(2, ' ') + '</td>'
|
||||||
|
+ '<td>' + c2.padStart(2, ' ') + '</td>'
|
||||||
|
+ '<td>' + d.padStart(2, ' ') + '</td>'
|
||||||
|
+ '<td> / ' + mask + '</td>'
|
||||||
|
+ '</tr>';
|
||||||
|
}).join('\n');
|
||||||
|
return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>WhiteList Export</title>'
|
||||||
|
+ '<style>body{font-family:monospace;background:#fff;color:#111;padding:1rem}'
|
||||||
|
+ 'h2{font-size:1rem;color:#666;margin-bottom:.5rem}'
|
||||||
|
+ 'table{border-collapse:collapse}'
|
||||||
|
+ 'td{padding:.15rem .5rem .15rem 0;white-space:pre;font-size:.9rem;text-align:right}'
|
||||||
|
+ '</style></head><body>'
|
||||||
|
+ '<h2>WhiteList — ' + cidrs.length + ' записей</h2>'
|
||||||
|
+ '<table>' + rows + '</table>'
|
||||||
|
+ '</body></html>';
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = { createExportRouter };
|
module.exports = { createExportRouter };
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
// V2 — Расширение имперсонации (отдельный модуль)
|
|
||||||
//
|
|
||||||
// ЭТО: middleware — добавляет дополнительные компании имперсонируемому юзеру.
|
|
||||||
// НЕ: жёсткая логика в resolveContext или user/index.js.
|
|
||||||
//
|
|
||||||
// ENV:
|
|
||||||
// IMPERSONATION_TARGET — email юзера, которому добавляем компании
|
|
||||||
// IMPERSONATION_EXTRA_COMPANIES — W-номера через запятую (WZ01112,WZ03709)
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
const TARGET_EMAIL = process.env.IMPERSONATION_TARGET || '';
|
|
||||||
const EXTRA_COMPANIES = (process.env.IMPERSONATION_EXTRA_COMPANIES || '')
|
|
||||||
.split(',').map(s => s.trim()).filter(Boolean);
|
|
||||||
|
|
||||||
function enhanceImpersonation(req, res, next) {
|
|
||||||
if (!TARGET_EMAIL) return next();
|
|
||||||
const u = req.session && req.session.user;
|
|
||||||
if (!u || u.email !== TARGET_EMAIL) return next();
|
|
||||||
|
|
||||||
const current = u.allClientIds || [u.clientId || ''];
|
|
||||||
u.allClientIds = [...new Set([...current, ...EXTRA_COMPANIES])].filter(Boolean);
|
|
||||||
|
|
||||||
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 };
|
|
||||||
+109
-43
@@ -1,52 +1,118 @@
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
const https = require('https');
|
||||||
// V2 — resolveContext middleware
|
const config = require('../config');
|
||||||
//
|
|
||||||
// ЭТО: Express middleware — извлекает контекст из сессии.
|
|
||||||
// НЕ: БД, бизнес-логика.
|
|
||||||
//
|
|
||||||
// ЗАЧЕМ:
|
|
||||||
// 1. Единственное место где сессия превращается в req.* поля.
|
|
||||||
// 2. user/ и admin/ не парсят сессию сами — получают готовые req.email, req.clientId.
|
|
||||||
// 3. Поддержка имперсонации (админ действует от имени компании).
|
|
||||||
//
|
|
||||||
// ПОРЯДОК:
|
|
||||||
// 1. Нет сессии → 302 /v2/login
|
|
||||||
// 2. Обычный юзер → req.email = session.user.email, req.clientId = activeClientId
|
|
||||||
// 3. Админ → req.isAdmin = true (если adminMode в сессии)
|
|
||||||
// 4. Имперсонация → req.email = originalUserEmail, req.impersonatedBy = админ
|
|
||||||
//
|
|
||||||
// ВЫХОД (поля на req):
|
|
||||||
// req.email — почта (при имперсонации — originalUserEmail)
|
|
||||||
// req.clientId — W-номер текущей компании
|
|
||||||
// req.companyName — название компании
|
|
||||||
// req.isAdmin — флаг админа
|
|
||||||
// req.isImpersonated — флаг имперсонации
|
|
||||||
// req.impersonatedBy — почта админа (null если нет имперсонации)
|
|
||||||
// req.allClientIds — все W-номера юзера
|
|
||||||
// req.profiles — профили из IAM
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
function resolveContext(req, res, next) {
|
/**
|
||||||
|
* Express middleware — извлекает контекст из сессии.
|
||||||
|
* При 401 от IAM — чистит сессию и редиректит на логин (SSO).
|
||||||
|
*/
|
||||||
|
async function resolveContext(req, res, next) {
|
||||||
const u = req.session && req.session.user;
|
const u = req.session && req.session.user;
|
||||||
|
|
||||||
// Нет сессии — нет доступа
|
|
||||||
if (!u) return res.redirect('/v2/login');
|
if (!u) return res.redirect('/v2/login');
|
||||||
|
|
||||||
// Имперсонация: админ действует от имени компании
|
const token = req.session && req.session.token;
|
||||||
// u.originalUserEmail — реальный админ
|
if (token) {
|
||||||
// u.impersonatedCompanyId — W-номер компании
|
try {
|
||||||
const isImpersonated = !!(u.originalUserEmail && u.impersonatedCompanyId);
|
await checkImpersonation(token, req, u);
|
||||||
|
} catch (e) {
|
||||||
req.email = isImpersonated ? u.originalUserEmail : (u.email || '');
|
const msg = (e.message || '');
|
||||||
req.clientId = isImpersonated ? u.impersonatedCompanyId : (u.activeClientId || u.clientId || '');
|
// 401 — токен протух, чистим сессию → SSO даст свежий
|
||||||
req.companyName = u.companyName || req.clientId;
|
if (msg.startsWith('401')) {
|
||||||
req.isAdmin = !!(u.isAdmin && u.adminMode);
|
req.session.destroy(() => res.redirect('/v2/login'));
|
||||||
req.isImpersonated = isImpersonated;
|
return;
|
||||||
req.impersonatedBy = isImpersonated ? u.email : null;
|
}
|
||||||
req.allClientIds = u.allClientIds || [req.clientId];
|
// Другие ошибки IAM — игнорируем, используем старые данные
|
||||||
req.profiles = u.profiles || [];
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyContext(req, u);
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запрашивает IAM и обновляет сессию если имперсонация изменилась.
|
||||||
|
* Бросает ошибку при 401 — вызывающий обработает.
|
||||||
|
*/
|
||||||
|
async function checkImpersonation(token, req, u) {
|
||||||
|
const url = config.iamApiBase + '/auth/user';
|
||||||
|
const uObj = new URL(url);
|
||||||
|
const data = await new Promise((resolve, reject) => {
|
||||||
|
const req = https.get({
|
||||||
|
hostname: uObj.hostname, port: 443, path: uObj.pathname,
|
||||||
|
headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },
|
||||||
|
}, res => {
|
||||||
|
let d = '';
|
||||||
|
res.on('data', c => d += c);
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode !== 200) return reject(new Error(res.statusCode + ': ' + d.slice(0, 200)));
|
||||||
|
try { resolve(JSON.parse(d)); } catch (e) { reject(e); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.setTimeout(5000, () => { req.destroy(); reject(new Error('timeout')); });
|
||||||
|
});
|
||||||
|
|
||||||
|
const imp = data.impersonation || {};
|
||||||
|
const newIsImp = !!(imp.is_impersonated);
|
||||||
|
const oldIsImp = !!u.isImpersonated;
|
||||||
|
|
||||||
|
// Статус не изменился — ничего не делаем
|
||||||
|
if (newIsImp === oldIsImp) return;
|
||||||
|
|
||||||
|
if (newIsImp) {
|
||||||
|
// Имперсонация началась — обновляем сессию данными таргета
|
||||||
|
const profiles = data.profiles || [];
|
||||||
|
const activeProfile = profiles.find(p => p.is_active_profile) || profiles[0] || {};
|
||||||
|
const ui = data.userInfo || {};
|
||||||
|
|
||||||
|
req.session.user = {
|
||||||
|
...u,
|
||||||
|
email: ui.email || '',
|
||||||
|
clientId: ui.clientID || activeProfile.client_id || '',
|
||||||
|
allClientIds: profiles.map(p => p.client_id).filter(Boolean),
|
||||||
|
activeClientId: ui.clientID || activeProfile.client_id || '',
|
||||||
|
activeProfileId: activeProfile.id || null,
|
||||||
|
companyId: ui.companyId || '',
|
||||||
|
companyName: ui.company || activeProfile.company_name || '',
|
||||||
|
isAdmin: !!ui.isAdmin,
|
||||||
|
isImpersonated: true,
|
||||||
|
impersonationType: imp.type || null,
|
||||||
|
impersonatedCompanyId: imp.impersonatedCompanyId || '',
|
||||||
|
originalUserEmail: imp.originalUserEmail || u.originalUserEmail || '',
|
||||||
|
originalUserFullName: imp.originalUserFullName || u.originalUserFullName || '',
|
||||||
|
originalUserCompany: imp.originalUserCompany || u.originalUserCompany || '',
|
||||||
|
fio: ui.fio || null,
|
||||||
|
profiles,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Имперсонация завершилась — убираем флаги, восстанавливаем оригинальные данные
|
||||||
|
req.session.user = {
|
||||||
|
...u,
|
||||||
|
isImpersonated: false,
|
||||||
|
impersonationType: null,
|
||||||
|
impersonatedCompanyId: null,
|
||||||
|
originalUserEmail: null,
|
||||||
|
originalUserFullName: null,
|
||||||
|
originalUserCompany: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyContext(req, u) {
|
||||||
|
const isImpersonated = !!(u.isImpersonated || u.originalUserEmail);
|
||||||
|
|
||||||
|
req.email = u.email || '';
|
||||||
|
req.clientId = isImpersonated
|
||||||
|
? (u.activeClientId || u.impersonatedCompanyId || u.clientId || '')
|
||||||
|
: (u.activeClientId || u.clientId || '');
|
||||||
|
req.companyName = u.companyName || req.clientId;
|
||||||
|
const allCids = u.allClientIds || [u.clientId || ''];
|
||||||
|
req.canAdmin = allCids.includes('WZ01112') || u.clientId === 'WZ01112' || !!u.isAdmin || u.email === (process.env.ADMIN_EMAIL || '');
|
||||||
|
req.adminMode = req.canAdmin;
|
||||||
|
req.isAdmin = req.canAdmin;
|
||||||
|
req.isImpersonated = isImpersonated;
|
||||||
|
req.impersonatedBy = isImpersonated ? u.originalUserEmail : null;
|
||||||
|
req.allClientIds = allCids;
|
||||||
|
req.profiles = u.profiles || [];
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = { resolveContext };
|
module.exports = { resolveContext };
|
||||||
|
|||||||
@@ -114,6 +114,23 @@ function createTestRouter() {
|
|||||||
return res.json({ ok: true });
|
return res.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── WIPE: удалить все записи и аудит компании (clientId) ─
|
||||||
|
case 'wipe': {
|
||||||
|
const clientId = req.query.clientId;
|
||||||
|
if (!clientId) return res.json({ ok: false, error: 'clientId required' });
|
||||||
|
const e = await pool.query('DELETE FROM v2_entries WHERE company_id IN (SELECT id FROM v2_companies WHERE client_id=$1)', [clientId]);
|
||||||
|
const a = await pool.query('DELETE FROM v2_audit WHERE company_id IN (SELECT id FROM v2_companies WHERE client_id=$1)', [clientId]);
|
||||||
|
return res.json({ ok: true, entriesDeleted: e.rowCount, auditDeleted: a.rowCount, clientId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CLEAN-ALL: очистить ВСЕ таблицы (для сдачи в прод) ──
|
||||||
|
case 'clean-all': {
|
||||||
|
const a = await pool.query('DELETE FROM audit_log');
|
||||||
|
const e = await pool.query('DELETE FROM whitelist_entries');
|
||||||
|
const c = await pool.query('DELETE FROM companies');
|
||||||
|
return res.json({ ok: true, audit_log: a.rowCount, whitelist_entries: e.rowCount, companies: c.rowCount });
|
||||||
|
}
|
||||||
|
|
||||||
// ── USERFLOW: полная эмуляция юзера через все слои ──────
|
// ── USERFLOW: полная эмуляция юзера через все слои ──────
|
||||||
// Отличие от add/edit/delete: здесь выставляется мок-сессия
|
// Отличие от add/edit/delete: здесь выставляется мок-сессия
|
||||||
// как после KC+IAM+resolveContext, и вызывается crud.
|
// как после KC+IAM+resolveContext, и вызывается crud.
|
||||||
|
|||||||
+77
-19
@@ -1,44 +1,90 @@
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
// V2 — Фронтенд пользователя (Express + EJS)
|
// V2 — Единый фронтенд (Express + EJS) — юзеры и админы
|
||||||
//
|
//
|
||||||
// ЭТО: Express-роутер, рендерит EJS-шаблоны.
|
// ЭТО: Express-роутер, рендерит EJS-шаблон views/v2/user.ejs.
|
||||||
// НЕ: работа с БД (только через crud/).
|
// НЕ: работа с БД (только через crud/).
|
||||||
//
|
//
|
||||||
// ШАБЛОН: views/v2/user.ejs — данные: entries, limit, used, companies,
|
// ОДИН ИНТЕРФЕЙС для юзеров и админов:
|
||||||
// activeClientId, user, includeDeleted, version.
|
// - Юзер: dropdown = свои профили из IAM, CRUD внутри выбранной компании.
|
||||||
|
// - Админ: dropdown = все компании из БД, CRUD внутри выбранной.
|
||||||
|
// - По умолчанию: активный профиль (is_active_profile=true).
|
||||||
|
//
|
||||||
|
// ПЕРЕКЛЮЧЕНИЕ КОМПАНИИ: ?switchTo=W*** → обновляет activeClientId в сессии.
|
||||||
|
// - Юзер: только свои профили.
|
||||||
|
// - Админ: любая компания.
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const crud = require('../crud');
|
const crud = require('../crud');
|
||||||
|
const q = require('../db/queries');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
|
|
||||||
function createUserRouter() {
|
function createUserRouter({ generateCsrfToken } = {}) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// ── GET / — список записей + форма ──────────────────────────────────
|
// ── GET / — список записей + форма ──────────────────────────────────
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const clId = req.clientId;
|
const isAdmin = req.canAdmin;
|
||||||
const allCompanies = req.profiles.length > 0
|
let clId = req.clientId;
|
||||||
? req.profiles
|
|
||||||
: [{ client_id: clId, company_name: req.companyName || clId }];
|
|
||||||
|
|
||||||
|
// ── Компании для dropdown ───────────────────────────────────────
|
||||||
|
let companies;
|
||||||
|
if (isAdmin) {
|
||||||
|
const all = await q.getAllCompanies();
|
||||||
|
const ownIds = new Set(req.allClientIds || []);
|
||||||
|
companies = all.map(c => ({
|
||||||
|
client_id: c.client_id,
|
||||||
|
name: c.name || c.client_id,
|
||||||
|
active_count: c.active_count,
|
||||||
|
isPersonal: ownIds.has(c.client_id),
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
companies = (req.profiles && req.profiles.length > 0)
|
||||||
|
? req.profiles.map(p => ({
|
||||||
|
client_id: p.client_id,
|
||||||
|
name: p.company_name || p.client_id,
|
||||||
|
is_active: p.is_active_profile,
|
||||||
|
}))
|
||||||
|
: [{ client_id: clId, name: req.companyName || clId, is_active: true }];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Переключение компании (до crud.list — не грузить зря) ────
|
||||||
|
let switchDebug = null;
|
||||||
|
if (req.query.switchTo) {
|
||||||
|
const targetId = req.query.switchTo;
|
||||||
|
const allowed = isAdmin || (req.profiles || []).find(p => p.client_id === targetId);
|
||||||
|
if (allowed) {
|
||||||
|
req.session.user.activeClientId = targetId;
|
||||||
|
clId = targetId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Записи ──────────────────────────────────────────────────────
|
||||||
const includeDeleted = req.query.deleted === '1';
|
const includeDeleted = req.query.deleted === '1';
|
||||||
const { entries, used, limit } = await crud.list(clId, includeDeleted);
|
const { entries, used, limit } = await crud.list(clId, includeDeleted);
|
||||||
|
|
||||||
// Переключение компании
|
// ── Данные пользователя для шаблона ────────────────────────────
|
||||||
if (req.query.switchTo && allCompanies.find(c => c.client_id === req.query.switchTo)) {
|
const sessionUser = req.session && req.session.user ? req.session.user : {};
|
||||||
req.session.user.activeClientId = req.query.switchTo;
|
const templateUser = {
|
||||||
return res.redirect('/v2/app');
|
email: req.email,
|
||||||
}
|
fio: sessionUser.fio || null,
|
||||||
|
isAdmin: req.canAdmin || false,
|
||||||
|
adminMode: isAdmin,
|
||||||
|
isImpersonated: req.isImpersonated || false,
|
||||||
|
originalUserEmail: sessionUser.originalUserEmail || '',
|
||||||
|
originalUserFullName: sessionUser.originalUserFullName || '',
|
||||||
|
originalUserCompany: sessionUser.originalUserCompany || '',
|
||||||
|
};
|
||||||
|
|
||||||
res.render('v2/user', {
|
res.render('v2/user', {
|
||||||
entries, limit, used,
|
entries, limit, used,
|
||||||
companies: allCompanies,
|
companies,
|
||||||
activeClientId: clId,
|
activeClientId: clId,
|
||||||
user: req.user || {},
|
user: templateUser,
|
||||||
includeDeleted,
|
includeDeleted,
|
||||||
version: config.version,
|
version: config.version, env: config.env,
|
||||||
|
csrfToken: generateCsrfToken ? generateCsrfToken(req, res) : '',
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} 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="/v2/app">Назад</a>');
|
||||||
@@ -69,11 +115,23 @@ function createUserRouter() {
|
|||||||
|
|
||||||
// ── POST /delete/:id ────────────────────────────────────────────────
|
// ── POST /delete/:id ────────────────────────────────────────────────
|
||||||
router.post('/delete/:id', async (req, res) => {
|
router.post('/delete/:id', async (req, res) => {
|
||||||
|
const qs = (req.body && req.body.deleted === '1') ? '&deleted=1' : '';
|
||||||
try {
|
try {
|
||||||
await crud.remove(parseInt(req.params.id), req.clientId, req.email, req.impersonatedBy);
|
await crud.remove(parseInt(req.params.id), req.clientId, req.email, req.impersonatedBy);
|
||||||
res.redirect('/v2/app?msg=Удалено');
|
res.redirect('/v2/app?msg=Удалено' + qs);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.redirect('/v2/app?error=' + encodeURIComponent(e.message));
|
res.redirect('/v2/app?error=' + encodeURIComponent(e.message) + qs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /restore/:id ──────────────────────────────────────────────
|
||||||
|
router.post('/restore/:id', async (req, res) => {
|
||||||
|
const qs = (req.body && req.body.deleted === '1') ? '&deleted=1' : '';
|
||||||
|
try {
|
||||||
|
await crud.restore(parseInt(req.params.id), req.clientId, req.email, req.impersonatedBy);
|
||||||
|
res.redirect('/v2/app?msg=Восстановлено' + qs);
|
||||||
|
} catch (e) {
|
||||||
|
res.redirect('/v2/app?error=' + encodeURIComponent(e.message) + qs);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+22
-10
@@ -100,23 +100,35 @@ function cidrToRange(cidr) {
|
|||||||
// Схлопывание списка CIDR в минимальный набор (для экспорта).
|
// Схлопывание списка CIDR в минимальный набор (для экспорта).
|
||||||
// Пример: 10.0.0.0/24 + 10.0.1.0/24 → 10.0.0.0/23
|
// Пример: 10.0.0.0/24 + 10.0.1.0/24 → 10.0.0.0/23
|
||||||
function aggregateCIDRs(cidrs) {
|
function aggregateCIDRs(cidrs) {
|
||||||
|
// 1. Сортируем по start, мержим смежные
|
||||||
const ranges = cidrs.map(c => cidrToRange(c)).sort((a, b) => a.start - b.start);
|
const ranges = cidrs.map(c => cidrToRange(c)).sort((a, b) => a.start - b.start);
|
||||||
const merged = [];
|
const merged = [];
|
||||||
for (const r of ranges) {
|
for (const r of ranges) {
|
||||||
const last = merged[merged.length - 1];
|
const last = merged[merged.length - 1];
|
||||||
if (last && r.start <= last.end + 1) last.end = Math.max(last.end, r.end);
|
if (last && r.start <= last.end + 1) last.end = Math.max(last.end, r.end);
|
||||||
else merged.push({ ...r });
|
else merged.push({ start: r.start, end: r.end });
|
||||||
}
|
}
|
||||||
return merged.map(r => {
|
// 2. Каждый merged range → минимальный набор CIDR
|
||||||
let start = r.start, end = r.end, cidrs = [];
|
const result = [];
|
||||||
while (start <= end) {
|
for (const r of merged) {
|
||||||
let maxMask = 32;
|
let start = r.start;
|
||||||
while (maxMask > 0 && ((start & ((1 << (32 - maxMask)) - 1)) !== 0 || (start + (1 << (32 - maxMask)) - 1) > end)) maxMask--;
|
while (start <= r.end) {
|
||||||
cidrs.push(numToIP(start) + '/' + maxMask);
|
// Ищем минимальную маску (самый широкий блок), подходящую под start и end.
|
||||||
start += (1 << (32 - maxMask));
|
// Идём от 1 до 32, запоминаем минимальную (самую широкую) подходящую.
|
||||||
|
let bestM = 32;
|
||||||
|
for (let m = 1; m <= 32; m++) {
|
||||||
|
if ((1 << (32 - m)) <= 0) continue;
|
||||||
|
const size = 1 << (32 - m);
|
||||||
|
if ((start & (size - 1)) !== 0) continue; // не выровнен
|
||||||
|
if (start + size - 1 > r.end) continue; // выходит за range
|
||||||
|
bestM = m;
|
||||||
|
break; // нашли самую широкую
|
||||||
|
}
|
||||||
|
result.push(numToIP(start) + '/' + bestM);
|
||||||
|
start += (1 << (32 - bestM));
|
||||||
}
|
}
|
||||||
return cidrs;
|
}
|
||||||
}).flat();
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function numToIP(n) {
|
function numToIP(n) {
|
||||||
|
|||||||
-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 (success) { %><div class="alert alert-ok"><%= success %></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>
|
|
||||||
-578
@@ -1,578 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>IP WhiteList v<%= appVersion %> — Белые списки IP</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 v<%= appVersion %></span>
|
|
||||||
<!-- Кнопки правой части header: admin-ссылки + чекбокс + выход -->
|
|
||||||
<span style="margin-left:auto;display:flex;gap:.5rem;align-items:center;">
|
|
||||||
<% if (typeof canAdminMode !== 'undefined' && canAdminMode) { %>
|
|
||||||
<form method="POST" action="/toggle-admin" style="display:inline;">
|
|
||||||
<button type="submit" style="font-size:.8rem;color:var(--muted);display:flex;align-items:center;gap:.3rem;padding:.2rem .6rem;border:1px solid var(--border);border-radius:5px;background:#fff;cursor:pointer;">
|
|
||||||
<span style="font-weight:500;"><%= adminMode ? 'Выйти из админки' : 'Администратор' %></span>
|
|
||||||
<span style="position:relative;width:36px;height:20px;background:<%= adminMode ? 'var(--blue)' : 'var(--border)' %>;border-radius:10px;transition:background .2s;">
|
|
||||||
<span style="position:absolute;top:2px;<%= adminMode ? 'right:2px;' : 'left:2px;' %>width:16px;height:16px;background:#fff;border-radius:50%;transition:all .2s;"></span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<% } %>
|
|
||||||
<% if (typeof adminMode !== 'undefined' && adminMode) { %>
|
|
||||||
<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 (success) { %><div class="alert alert-ok"><%= success %></div><% } %>
|
|
||||||
<% if (error) { %><div class="alert alert-err"><%= error %></div><% } %>
|
|
||||||
|
|
||||||
<% if (typeof isImpersonated !== 'undefined' && isImpersonated) { %>
|
|
||||||
<div style="background:#fef3c7;border:1px solid #d97706;color:#92400e;padding:.5rem 1rem;border-radius:8px;margin-bottom:.5rem;font-size:.85rem;">
|
|
||||||
⚠️ Режим имперсонации — данные сохраняются от имени <strong><%= user.activeClientId %></strong>
|
|
||||||
<% if (typeof originalUserEmail !== 'undefined' && originalUserEmail) { %>
|
|
||||||
(вы: <%= originalUserEmail %>)
|
|
||||||
<% } %>
|
|
||||||
</div>
|
|
||||||
<% } %>
|
|
||||||
|
|
||||||
|
|
||||||
<%
|
|
||||||
// Для admin: selectedCompany — выбранная компания (объект из getAllCompanies).
|
|
||||||
// Для обычного пользователя: selectedCompany — их компания (из getOrCreateCompany).
|
|
||||||
// company_id — числовой PK для передачи в скрытые поля форм.
|
|
||||||
const companyPk = selectedCompany ? selectedCompany.id : null;
|
|
||||||
%>
|
|
||||||
|
|
||||||
<!-- Переключатель компании — для администратора в режиме admin -->
|
|
||||||
<% if (adminMode && 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" id="company-select"
|
|
||||||
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>
|
|
||||||
<button type="submit" class="btn btn-sm">Показать</button>
|
|
||||||
<label style="font-size:.82rem;color:var(--muted);cursor:pointer;display:flex;align-items:center;gap:.3rem;">
|
|
||||||
<input type="checkbox" name="includeDeleted" value="true" <%= (typeof includeDeleted !== 'undefined' && includeDeleted) ? 'checked' : '' %>>
|
|
||||||
Показать удалённые
|
|
||||||
</label>
|
|
||||||
<span style="margin-left:auto;font-size:.8rem;color:var(--muted);">
|
|
||||||
Режим: <strong style="color:var(--amber);">Администратор</strong>
|
|
||||||
</span>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% } %>
|
|
||||||
|
|
||||||
<!-- Переключатель компании — для обычного пользователя с несколькими компаниями -->
|
|
||||||
<% if (!isAdmin && allClientIds && allClientIds.length > 1) { %>
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body" style="padding:.75rem 1rem;">
|
|
||||||
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;">
|
|
||||||
<span style="font-size:.85rem;color:var(--muted);font-weight:500;">Компания:</span>
|
|
||||||
<select id="company-switch"
|
|
||||||
style="padding:.4rem .75rem;border:1px solid var(--border);border-radius:6px;font-size:.85rem;background:#fff;">
|
|
||||||
<% if (typeof profiles !== 'undefined' && profiles.length) { %>
|
|
||||||
<% profiles.forEach(p => { %>
|
|
||||||
<option value="<%= p.client_id %>" <%= p.is_active_profile ? 'selected' : '' %>>
|
|
||||||
<%= p.company_name %> (<%= p.client_id %>)
|
|
||||||
</option>
|
|
||||||
<% }) %>
|
|
||||||
<% } else { %>
|
|
||||||
<% allClientIds.forEach(id => { %>
|
|
||||||
<option value="<%= id %>" <%= id === activeClientId ? 'selected' : '' %>>
|
|
||||||
<%= id %>
|
|
||||||
</option>
|
|
||||||
<% }) %>
|
|
||||||
<% } %>
|
|
||||||
</select>
|
|
||||||
<span style="margin-left:auto;font-size:.8rem;color:var(--muted);">
|
|
||||||
Компаний: <strong><%= allClientIds.length %></strong>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</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" value="<%= lastValue || '' %>" required <%= used >= limit ? 'disabled' : '' %>>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>Комментарий</label>
|
|
||||||
<input name="comment" placeholder="Необязательно" maxlength="255" value="<%= lastComment || '' %>">
|
|
||||||
</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 style="<%= e.deleted_at ? 'opacity:.5;text-decoration:line-through;' : '' %>">
|
|
||||||
<td><code><%= e.value_cidr %></code><% if (e.deleted_at) { %> <span style="font-size:.7rem;color:var(--red);">удалена</span><% } %></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',timeZone:'Etc/GMT-4'}) %></td>
|
|
||||||
<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',timeZone:'Etc/GMT-4'})
|
|
||||||
: '—' %>
|
|
||||||
</td>
|
|
||||||
<td class="text-center" style="white-space:nowrap;">
|
|
||||||
<% if (!e.deleted_at) { %>
|
|
||||||
// data-* атрибуты для JS-заполнения modal без inline-onclick с параметрами.
|
|
||||||
// Так как комментарий может содержать кавычки — EJS экранирует их в HTML.
|
|
||||||
// Но в data-атрибуте JSON::stringify безопаснее.
|
|
||||||
%>
|
|
||||||
<!-- Кнопка редактирования: открывает modal и заполняет его данными из data-* -->
|
|
||||||
<button class="btn btn-edit"
|
|
||||||
data-id="<%= e.id %>"
|
|
||||||
data-value="<%= e.value_cidr %>"
|
|
||||||
data-comment="<%= e.comment || '' %>"
|
|
||||||
data-company="<%= companyPk || '' %>"
|
|
||||||
style="margin-right:.25rem;">
|
|
||||||
Изменить
|
|
||||||
</button>
|
|
||||||
<!-- Форма удаления: company_id нужен для admin-ветки в server.js -->
|
|
||||||
<form method="POST" action="/delete/<%= e.id %>"
|
|
||||||
class="delete-form"
|
|
||||||
data-cidr="<%= e.value_cidr %>"
|
|
||||||
style="display:inline">
|
|
||||||
<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" id="edit-cancel">Отмена</button>
|
|
||||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
var modal = document.getElementById('edit-modal');
|
|
||||||
|
|
||||||
function openModal(btn) {
|
|
||||||
document.getElementById('edit-value').value = btn.getAttribute('data-value');
|
|
||||||
document.getElementById('edit-comment').value = btn.getAttribute('data-comment');
|
|
||||||
document.getElementById('edit-company-id').value = btn.getAttribute('data-company');
|
|
||||||
document.getElementById('edit-form').action = '/edit/' + btn.getAttribute('data-id');
|
|
||||||
modal.style.display = 'flex';
|
|
||||||
setTimeout(function() { document.getElementById('edit-value').focus(); }, 50);
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeModal() {
|
|
||||||
modal.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Кнопки «Изменить» — event delegation на document
|
|
||||||
document.addEventListener('click', function (e) {
|
|
||||||
var btn = e.target.closest('.btn-edit');
|
|
||||||
if (btn) { openModal(btn); return; }
|
|
||||||
|
|
||||||
var cancel = e.target.closest('#edit-cancel');
|
|
||||||
if (cancel) { closeModal(); return; }
|
|
||||||
|
|
||||||
// Клик по backdrop
|
|
||||||
if (e.target === modal) { closeModal(); return; }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Подтверждение удаления — event delegation
|
|
||||||
document.addEventListener('submit', function (e) {
|
|
||||||
var form = e.target.closest('.delete-form');
|
|
||||||
if (!form) return;
|
|
||||||
var cidr = form.getAttribute('data-cidr');
|
|
||||||
if (!confirm('Удалить запись ' + cidr + '?')) e.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Смена компании — авто-сабмит при выборе
|
|
||||||
var sel = document.getElementById('company-select');
|
|
||||||
if (sel) {
|
|
||||||
sel.addEventListener('change', function () { this.form.submit(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape закрывает modal
|
|
||||||
document.addEventListener('keydown', function (e) {
|
|
||||||
if (e.key === 'Escape') closeModal();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Клиентская валидация IP (ТЗ §5) ──────────────────────
|
|
||||||
var BLOCKED = [
|
|
||||||
{ mask:'10.0.0.0/8', name:'Private (RFC1918)' },
|
|
||||||
{ mask:'172.16.0.0/12', name:'Private (RFC1918)' },
|
|
||||||
{ mask:'192.168.0.0/16', name:'Private (RFC1918)' },
|
|
||||||
{ mask:'100.64.0.0/10', name:'CGNAT' },
|
|
||||||
{ mask:'127.0.0.0/8', name:'Loopback' },
|
|
||||||
{ mask:'169.254.0.0/16', name:'Link-local' },
|
|
||||||
{ mask:'192.0.0.0/24', name:'IANA reserved' },
|
|
||||||
{ mask:'192.0.2.0/24', name:'TEST-NET-1' },
|
|
||||||
{ mask:'198.51.100.0/24', name:'TEST-NET-2' },
|
|
||||||
{ mask:'203.0.113.0/24', name:'TEST-NET-3' },
|
|
||||||
{ mask:'198.18.0.0/15', name:'Benchmarking' },
|
|
||||||
{ mask:'224.0.0.0/4', name:'Multicast' },
|
|
||||||
{ mask:'240.0.0.0/4', name:'Reserved' },
|
|
||||||
{ mask:'255.255.255.255/32',name:'Broadcast' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function ipToNum(ip) {
|
|
||||||
var parts = ip.split('.');
|
|
||||||
return ((+parts[0] * 256 + +parts[1]) * 256 + +parts[2]) * 256 + +parts[3];
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCIDR(val) {
|
|
||||||
var m = val.match(/^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\/(\d{1,2}))?$/);
|
|
||||||
if (!m) return null;
|
|
||||||
var ip = m[1];
|
|
||||||
var mask = m[2] ? parseInt(m[2], 10) : 32;
|
|
||||||
if (mask < 0 || mask > 32) return null;
|
|
||||||
var parts = ip.split('.');
|
|
||||||
if (parts.some(function(p) { return +p > 255; })) return null;
|
|
||||||
return { ip: ip, mask: mask, num: ipToNum(ip) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkBlocked(info) {
|
|
||||||
for (var i = 0; i < BLOCKED.length; i++) {
|
|
||||||
var b = parseCIDR(BLOCKED[i].mask);
|
|
||||||
if (!b) continue;
|
|
||||||
var maskNum = -1 << (32 - b.mask);
|
|
||||||
if ((info.num & maskNum) === (b.num & maskNum)) {
|
|
||||||
return BLOCKED[i].name + ' (' + BLOCKED[i].mask + ')';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePreview(info) {
|
|
||||||
if (info.mask === 32) return null;
|
|
||||||
var maskNum = -1 << (32 - info.mask);
|
|
||||||
var netNum = info.num & maskNum;
|
|
||||||
var o0 = (netNum >>> 24) & 255;
|
|
||||||
var o1 = (netNum >>> 16) & 255;
|
|
||||||
var o2 = (netNum >>> 8) & 255;
|
|
||||||
var o3 = netNum & 255;
|
|
||||||
var expected = o0 + '.' + o1 + '.' + o2 + '.' + o3 + '/' + info.mask;
|
|
||||||
if (expected !== info.ip + '/' + info.mask) {
|
|
||||||
return expected;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var addForm = document.querySelector('form[action="/add"]');
|
|
||||||
if (addForm) {
|
|
||||||
addForm.addEventListener('submit', function(e) {
|
|
||||||
var input = addForm.querySelector('input[name="value"]');
|
|
||||||
var val = input.value.trim();
|
|
||||||
if (!val) return;
|
|
||||||
|
|
||||||
var info = parseCIDR(val);
|
|
||||||
if (!info) {
|
|
||||||
e.preventDefault();
|
|
||||||
alert('Неверный формат. Ожидается: IPv4 или CIDR (например 203.0.113.10 или 203.0.113.0/24)');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info.mask < 22) {
|
|
||||||
e.preventDefault();
|
|
||||||
alert('Маска /' + info.mask + ' не допускается. Минимальная маска: /22');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var blocked = checkBlocked(info);
|
|
||||||
if (blocked) {
|
|
||||||
e.preventDefault();
|
|
||||||
alert('Адрес принадлежит запрещённому диапазону:\n' + blocked + '\n\nДобавление заблокировано.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var norm = normalizePreview(info);
|
|
||||||
if (norm) {
|
|
||||||
if (!confirm('Вы ввели адрес из хостовой части подсети.\n\n' +
|
|
||||||
'Будет сохранено: ' + norm + '\n\nПродолжить?')) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Переключатель компании ─────────────────────────────
|
|
||||||
var companySwitch = document.getElementById('company-switch');
|
|
||||||
if (companySwitch) {
|
|
||||||
companySwitch.addEventListener('change', function() {
|
|
||||||
window.location.href = '/?switchTo=' + encodeURIComponent(this.value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}());
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
+142
-28
@@ -1,30 +1,144 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html><head><meta charset="utf-8"><title>Аудит — <%= company ? company.client_id : 'Все' %> — v<%= version %></title>
|
<html lang="ru">
|
||||||
<style>
|
<head>
|
||||||
body{font-family:sans-serif;max-width:1200px;margin:20px auto;color:#222}
|
<meta charset="UTF-8">
|
||||||
table{width:100%;border-collapse:collapse;margin:10px 0}
|
<title>Аудит — <%= company ? company.client_id : 'Все' %> — IP WhiteList v<%= version %></title>
|
||||||
th,td{border:1px solid #ccc;padding:6px;text-align:left;font-size:13px}
|
<link rel="icon" href="/favicon.png" type="image/png">
|
||||||
th{background:#263238;color:#fff}
|
<style>
|
||||||
.bar{display:flex;justify-content:space-between;align-items:center;background:#263238;color:#fff;padding:10px;margin-bottom:15px}
|
:root {
|
||||||
.bar a{color:#80cbc4}
|
--bg: #f5f5f5; --card: #ffffff; --text: #1a1a1a; --muted: #6b7280;
|
||||||
</style></head><body>
|
--border: #d1d5db; --grey-light: #f3f4f6; --blue: #2563eb; --blue-h: #1d4ed8;
|
||||||
<div class="bar">
|
--red: #dc2626; --green: #16a34a; --amber: #d97706;
|
||||||
<span><strong>📋 Аудит</strong> <%= company ? company.client_id : 'Все компании' %></span>
|
}
|
||||||
<span><a href="/v2/admin">← Админка</a> | <a href="/v2/app">Пользовательский</a></span>
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
.test-banner { background: #fef3cd; color: #856404; padding: 12px; text-align: center; font-weight: bold; border-bottom: 2px solid #f0b400; font-size: 14px; }
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
.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; }
|
||||||
|
.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: .4rem .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; }
|
||||||
|
.empty { text-align: center; color: var(--muted); padding: 2rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<% if (env === 'test') { %><div class="test-banner">⚠️ ТЕСТОВЫЙ СТЕНД — данные могут быть удалены в любой момент</div><% } %>
|
||||||
|
|
||||||
|
<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);">Аудит: <%= company ? company.client_id : 'Все компании' %></span>
|
||||||
|
<span style="margin-left:auto;">
|
||||||
|
<a href="/v2/admin" class="btn btn-sm">← Лимиты</a>
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
Журнал операций
|
||||||
|
<span style="font-weight:400;font-size:.82rem;color:var(--muted);">
|
||||||
|
(<%= total %> записей<%= days ? ' за ' + days + ' дн.' : ' (всё время)' %>)
|
||||||
|
</span>
|
||||||
|
<span style="float:right;display:flex;gap:.3rem;">
|
||||||
|
<a href="?<%= company ? 'companyId=' + company.id : '' %>"
|
||||||
|
class="btn btn-sm<%= !days ? ' btn-primary' : '' %>"
|
||||||
|
style="padding:.2rem .5rem;font-size:.75rem;">Всё</a>
|
||||||
|
<% [30, 90, 365].forEach(function(d) { %>
|
||||||
|
<a href="?<%= company ? 'companyId=' + company.id + '&' : '' %>days=<%= d %>"
|
||||||
|
class="btn btn-sm<%= days === d ? ' btn-primary' : '' %>"
|
||||||
|
style="padding:.2rem .5rem;font-size:.75rem;"><%= d %>д</a>
|
||||||
|
<% }) %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<% if (audit.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>Значения</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% audit.forEach(function(a) { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= new Date(a.created_at).toLocaleString('ru-RU', { timeZone: 'Europe/Moscow' }) %></td>
|
||||||
|
<td><strong><%= a.action %></strong></td>
|
||||||
|
<td><%= a.user_email %></td>
|
||||||
|
<td><%= a.impersonated_by || '—' %></td>
|
||||||
|
<td><%= a.company_name || '—' %></td>
|
||||||
|
<td><% if (a.old_value) { %><%= a.old_value %><% } else { %>—<% } %> → <% if (a.new_value) { %><%= a.new_value %><% } else { %>—<% } %></td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (pages > 1) { %>
|
||||||
|
<div style="padding:.75rem 1rem;display:flex;justify-content:center;align-items:center;gap:.75rem;border-top:1px solid var(--border);">
|
||||||
|
<% if (page > 1) { %>
|
||||||
|
<a href="?<%= company ? 'companyId=' + company.id + '&' : '' %>days=<%= days %>&page=<%= page - 1 %>" class="btn btn-sm">← Назад</a>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="btn btn-sm" style="opacity:.4;cursor:default;">← Назад</span>
|
||||||
|
<% } %>
|
||||||
|
<span style="font-size:.85rem;color:var(--muted);">стр. <%= page %> из <%= pages %></span>
|
||||||
|
<% if (page < pages) { %>
|
||||||
|
<a href="?<%= company ? 'companyId=' + company.id + '&' : '' %>days=<%= days %>&page=<%= page + 1 %>" class="btn btn-sm">Вперёд →</a>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="btn btn-sm" style="opacity:.4;cursor:default;">Вперёд →</span>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
</body>
|
||||||
<tr><th>Дата</th><th>Действие</th><th>Кто</th><th>От имени</th><th>Компания</th><th>Значения</th></tr>
|
</html>
|
||||||
<% if (audit.length === 0) { %><tr><td colspan="6">Нет записей</td></tr><% } %>
|
|
||||||
<% audit.forEach(function(a) { %>
|
|
||||||
<tr>
|
|
||||||
<td><%= new Date(a.created_at).toLocaleString('ru') %></td>
|
|
||||||
<td><strong><%= a.action %></strong></td>
|
|
||||||
<td><%= a.user_email %></td>
|
|
||||||
<td><%= a.impersonated_by || '—' %></td>
|
|
||||||
<td><%= a.company_name || '—' %></td>
|
|
||||||
<td><%= a.old_value || '—' %> → <%= a.new_value || '—' %></td>
|
|
||||||
</tr>
|
|
||||||
<% }) %>
|
|
||||||
</table>
|
|
||||||
<p>Показано: <%= audit.length %> (макс 500)</p>
|
|
||||||
</body></html>
|
|
||||||
|
|||||||
@@ -7,7 +7,10 @@
|
|||||||
th{background:#263238;color:#fff}
|
th{background:#263238;color:#fff}
|
||||||
.bar{display:flex;justify-content:space-between;align-items:center;background:#263238;color:#fff;padding:10px;margin-bottom:15px}
|
.bar{display:flex;justify-content:space-between;align-items:center;background:#263238;color:#fff;padding:10px;margin-bottom:15px}
|
||||||
.bar a{color:#80cbc4}
|
.bar a{color:#80cbc4}
|
||||||
</style></head><body>
|
.test-banner{background:#fef3cd;color:#856404;padding:12px;text-align:center;font-weight:bold;border-bottom:2px solid #f0b400;font-size:14px}
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<% if (env === 'test') { %><div class="test-banner">⚠️ ТЕСТОВЫЙ СТЕНД — данные могут быть удалены в любой момент</div><% } %>
|
||||||
<div class="bar">
|
<div class="bar">
|
||||||
<span><strong>📋 Записи</strong> <%= company.client_id %> — <%= company.name || company.client_id %></span>
|
<span><strong>📋 Записи</strong> <%= company.client_id %> — <%= company.name || company.client_id %></span>
|
||||||
<span><a href="/v2/admin">← Админка</a></span>
|
<span><a href="/v2/admin">← Админка</a></span>
|
||||||
|
|||||||
+140
-45
@@ -1,47 +1,142 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html><head><meta charset="utf-8"><title>Admin — IP WhiteList v<%= version %></title>
|
<html lang="ru">
|
||||||
<style>
|
<head>
|
||||||
body{font-family:sans-serif;max-width:1100px;margin:20px auto;color:#222}
|
<meta charset="UTF-8">
|
||||||
table{width:100%;border-collapse:collapse;margin:10px 0}
|
<title>Лимиты и Аудит — IP WhiteList v<%= version %></title>
|
||||||
th,td{border:1px solid #ccc;padding:6px;text-align:left}
|
<link rel="icon" href="/favicon.png" type="image/png">
|
||||||
th{background:#263238;color:#fff}
|
<style>
|
||||||
.bar{display:flex;justify-content:space-between;align-items:center;background:#263238;color:#fff;padding:10px;margin-bottom:15px}
|
:root {
|
||||||
.bar a{color:#80cbc4}
|
--bg: #f5f5f5; --card: #ffffff; --text: #1a1a1a; --muted: #6b7280;
|
||||||
button,input,select{padding:4px 8px}
|
--border: #d1d5db; --grey-light: #f3f4f6; --blue: #2563eb; --blue-h: #1d4ed8;
|
||||||
.msg{background:#4caf50;color:#fff;padding:8px;margin-bottom:10px}
|
--red: #dc2626; --green: #16a34a; --amber: #d97706;
|
||||||
.err{background:#f44336;color:#fff;padding:8px;margin-bottom:10px}
|
}
|
||||||
</style></head><body>
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
<div class="bar">
|
.test-banner { background: #fef3cd; color: #856404; padding: 12px; text-align: center; font-weight: bold; border-bottom: 2px solid #f0b400; font-size: 14px; }
|
||||||
<span><strong>🛡️ ADMIN</strong> v<%= version %></span>
|
body {
|
||||||
<span><%= user.email || '—' %> | <a href="/v2/app">← Пользовательский</a> | <a href="/v2/logout">Выйти</a></span>
|
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; }
|
||||||
|
.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; }
|
||||||
|
code { background: #f1f5f9; padding: .15rem .4rem; border-radius: 3px; font-size: .9em; }
|
||||||
|
.empty { text-align: center; color: var(--muted); padding: 2rem; }
|
||||||
|
.limit-input {
|
||||||
|
width: 60px; 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); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<% if (env === 'test') { %><div class="test-banner">⚠️ ТЕСТОВЫЙ СТЕНД — данные могут быть удалены в любой момент</div><% } %>
|
||||||
|
|
||||||
|
<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;align-items:center;">
|
||||||
|
<span style="font-weight:500;color:var(--text);"><%= user.email %></span>
|
||||||
|
<a href="/v2/app" class="btn btn-sm">← Назад</a>
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var p=new URLSearchParams(location.search);
|
||||||
|
if(p.get('msg')){document.write('<div class="alert alert-ok">'+p.get('msg')+'</div>');history.replaceState(null,'','/v2/admin');}
|
||||||
|
if(p.get('error')){document.write('<div class="alert alert-err">'+p.get('error')+'</div>');history.replaceState(null,'','/v2/admin');}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Компании и лимиты <span style="font-weight:400;font-size:.82rem;color:var(--muted);">(дефолт: 15)</span></div>
|
||||||
|
<% if (companies.length === 0) { %>
|
||||||
|
<div class="empty">Нет компаний в системе</div>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Компания</th>
|
||||||
|
<th>Client ID</th>
|
||||||
|
<th>Записей</th>
|
||||||
|
<th>Лимит</th>
|
||||||
|
<th></th>
|
||||||
|
<th>Новый лимит</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% companies.forEach(function(c) { %>
|
||||||
|
<tr>
|
||||||
|
<td><strong><%= c.name || c.client_id %></strong></td>
|
||||||
|
<td><code><%= c.client_id %></code></td>
|
||||||
|
<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 %>" class="btn btn-sm">аудит</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action="/v2/admin/limit" style="display:inline-flex;gap:.4rem;align-items:center;">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<input type="hidden" name="companyId" value="<%= c.id %>">
|
||||||
|
<input class="limit-input" name="limit" type="number" min="1" value="<%= c.custom_limit || 15 %>">
|
||||||
|
<button class="btn btn-sm btn-primary">установить</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<script>
|
</body>
|
||||||
const p=new URLSearchParams(location.search);
|
</html>
|
||||||
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');}
|
|
||||||
</script>
|
|
||||||
<h2>Компании (<%= companies.length %>)</h2>
|
|
||||||
<table>
|
|
||||||
<tr><th>Компания</th><th>Client ID</th><th>Записей</th><th>Лимит</th><th></th><th>Новый лимит</th></tr>
|
|
||||||
<% if (companies.length === 0) { %><tr><td colspan="6">Нет компаний</td></tr><% } %>
|
|
||||||
<% companies.forEach(function(c) { %>
|
|
||||||
<tr>
|
|
||||||
<td><%= c.name || c.client_id %></td>
|
|
||||||
<td><code><%= c.client_id %></code></td>
|
|
||||||
<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>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<form method="POST" action="/v2/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>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<% }) %>
|
|
||||||
</table>
|
|
||||||
</body></html>
|
|
||||||
|
|||||||
+302
-64
@@ -1,68 +1,306 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html><head><meta charset="utf-8"><title>V2 IP WhiteList</title>
|
<html lang="ru">
|
||||||
<style>
|
<head>
|
||||||
body{font-family:sans-serif;max-width:960px;margin:20px auto;color:#222}
|
<meta charset="UTF-8">
|
||||||
table{width:100%;border-collapse:collapse;margin:10px 0}
|
<title>IP WhiteList v<%= version %></title>
|
||||||
th,td{border:1px solid #ccc;padding:6px;text-align:left}
|
<link rel="icon" href="/favicon.png" type="image/png">
|
||||||
th{background:#f5f5f5}
|
<style>
|
||||||
.bar{display:flex;justify-content:space-between;align-items:center;background:#eee;padding:10px;margin-bottom:10px}
|
:root {
|
||||||
button,input,select{padding:4px 8px}
|
--bg: #f5f5f5; --card: #ffffff; --text: #1a1a1a; --muted: #6b7280;
|
||||||
.add-form{background:#f0f8ff;padding:10px;margin:10px 0}
|
--border: #d1d5db; --grey-light: #f3f4f6; --blue: #2563eb; --blue-h: #1d4ed8;
|
||||||
</style></head><body>
|
--red: #dc2626; --red-h: #b91c1c; --green: #16a34a; --amber: #d97706;
|
||||||
<div class="bar">
|
}
|
||||||
<span><strong>V2 IP WhiteList</strong> <small>v<%= version %></small></span>
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
<span><%= user.email %> <% if (user.isAdmin) { %>| <a href="/v2/admin">ADMIN</a><% } %></span>
|
.test-banner { background: #fef3cd; color: #856404; padding: 12px; text-align: center; font-weight: bold; border-bottom: 2px solid #f0b400; font-size: 14px; }
|
||||||
<span>
|
body {
|
||||||
<form method="GET" action="/v2/app" style="display:inline">
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
<select name="switchTo" onchange="this.form.submit()">
|
background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5;
|
||||||
<% companies.forEach(function(c) { %>
|
}
|
||||||
<option value="<%= c.client_id %>" <%= c.client_id === activeClientId ? 'selected' : '' %>><%= c.company_name || c.client_id %> (<%= c.client_id %>)</option>
|
.page { max-width: 1100px; margin: 1.5rem auto; padding: 0 1rem; }
|
||||||
<% }) %>
|
|
||||||
</select>
|
/* Cards */
|
||||||
</form>
|
.card {
|
||||||
<a href="/v2/logout">Выйти</a>
|
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 {
|
||||||
|
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; text-decoration: none; color: var(--text);
|
||||||
|
}
|
||||||
|
.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); }
|
||||||
|
.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);
|
||||||
|
position: sticky; top: 0; z-index: 1;
|
||||||
|
}
|
||||||
|
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:nth-child(even) td { background: #f1f5f9; }
|
||||||
|
tr:nth-child(even):hover td { background: #e2e8f0; }
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/* Help */
|
||||||
|
.help p, .help ul { margin-top: .5rem; }
|
||||||
|
.help ul li { margin-top: .15rem; }
|
||||||
|
|
||||||
|
/* Select */
|
||||||
|
select {
|
||||||
|
padding: .4rem .75rem; border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
font-size: .85rem; background: #fff; outline: none; cursor: pointer;
|
||||||
|
}
|
||||||
|
select:focus { border-color: var(--blue); box-shadow: 0 0 0 2px rgba(37,99,235,.1); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<% if (env === 'test') { %><div class="test-banner">⚠️ ТЕСТОВЫЙ СТЕНД — данные могут быть удалены в любой момент</div><% } %>
|
||||||
|
|
||||||
|
<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 WhiteList v<%= version %></span>
|
||||||
|
|
||||||
|
<span style="margin-left:auto;display:flex;gap:.5rem;align-items:center;">
|
||||||
|
<span style="font-weight:500;color:var(--text);"><%= user.email %></span>
|
||||||
|
<a href="/logout" class="btn btn-sm"
|
||||||
|
onclick="return confirm('Выход из whitelist завершит ВСЕ сессии Nubes (Keycloak SSO).\n\nПродолжить?')"
|
||||||
|
title="Выход из Keycloak (все сервисы Nubes)">Выйти<% if (user.isImpersonated && user.originalUserEmail) { %> (<%= user.originalUserEmail %>)<% } %></a>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</header>
|
||||||
<script>
|
|
||||||
const p=new URLSearchParams(location.search);
|
<div class="page">
|
||||||
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')}
|
<script>
|
||||||
</script>
|
var p=new URLSearchParams(location.search);
|
||||||
<p>Записей: <strong><%= used %></strong> из <%= limit %></p>
|
if(p.get('msg')){document.write('<div class="alert alert-ok">'+p.get('msg')+'</div>');history.replaceState(null,'','/v2/app');}
|
||||||
<div class="add-form">
|
if(p.get('error')){document.write('<div class="alert alert-err">'+p.get('error')+'</div>');history.replaceState(null,'','/v2/app');}
|
||||||
<form method="POST" action="/v2/app/add">
|
</script>
|
||||||
<input name="cidr" placeholder="x.x.x.x/xx" size="18" required>
|
|
||||||
<input name="comment" placeholder="комментарий" size="30">
|
<% if (user.isImpersonated) { %>
|
||||||
<button>Добавить</button>
|
<div class="alert alert-warn">
|
||||||
</form>
|
⚠️ Режим имперсонации — данные сохраняются от имени <strong><%= activeClientId %></strong>
|
||||||
</div>
|
<% if (user.originalUserEmail) { %> (вы: <%= user.originalUserEmail %>)<% } %>
|
||||||
<table>
|
</div>
|
||||||
<tr><th>CIDR</th><th>Комментарий</th><th>Кто</th><th>Когда</th><th></th></tr>
|
|
||||||
<% if (entries.length === 0) { %>
|
|
||||||
<tr><td colspan="5">Нет записей</td></tr>
|
|
||||||
<% } %>
|
<% } %>
|
||||||
<% entries.forEach(function(e) { %>
|
|
||||||
<tr style="<%= e.deleted_at ? 'text-decoration:line-through;opacity:0.5' : '' %>">
|
<!-- Выбор компании -->
|
||||||
<td><%= e.value_cidr %></td>
|
<div class="card">
|
||||||
<td><%= e.comment || '' %></td>
|
<div class="card-body" style="padding:.75rem 1rem;">
|
||||||
<td><%= e.created_by %></td>
|
<form method="GET" action="/v2/app" style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;">
|
||||||
<td><%= new Date(e.created_at).toLocaleString('ru') %></td>
|
<span style="font-size:.85rem;color:var(--muted);font-weight:500;">Компания:</span>
|
||||||
<td>
|
<select name="switchTo" onchange="console.log('onchange fired', this.value); this.form.submit()">
|
||||||
<% if (e.deleted_at) { %>
|
<% companies.forEach(function(c) { %>
|
||||||
<span style="color:#888">удалено</span>
|
<option value="<%= c.client_id %>" <%= c.client_id === activeClientId ? 'selected' : '' %>>
|
||||||
<% } else { %>
|
<%= c.name %> (<%= c.client_id %>)<% if (c.isPersonal) { %> (личное)<% } %><% if (c.active_count != null) { %> — <%= c.active_count %> зап.<% } %>
|
||||||
<form method="POST" action="/v2/app/edit/<%= e.id %>" style="display:inline">
|
</option>
|
||||||
<input name="cidr" value="<%= e.value_cidr %>" size="18">
|
<% }) %>
|
||||||
<input name="comment" value="<%= e.comment || '' %>" size="20">
|
</select>
|
||||||
<button>изменить</button>
|
<a href="?deleted=<%= includeDeleted ? '0' : '1' %>" class="btn btn-sm"><%= includeDeleted ? 'Скрыть удалённые' : 'Показать удалённые' %></a>
|
||||||
</form>
|
<% if (user.isAdmin) { %>
|
||||||
<form method="POST" action="/v2/app/delete/<%= e.id %>" style="display:inline">
|
<span style="margin-left:auto;font-size:.8rem;color:var(--amber);">Режим: Администратор</span>
|
||||||
<button onclick="return confirm('Удалить?')">удалить</button>
|
<a href="/v2/admin" class="btn btn-sm">Лимиты и Аудит</a>
|
||||||
</form>
|
|
||||||
<% } %>
|
<% } %>
|
||||||
</td>
|
</form>
|
||||||
</tr>
|
</div>
|
||||||
<% }) %>
|
</div>
|
||||||
</table>
|
|
||||||
<p><a href="/v2/app?deleted=<%= includeDeleted ? '0' : '1' %>"><%= includeDeleted ? 'Скрыть удалённые' : 'Показать удалённые' %></a></p>
|
<!-- Статистика -->
|
||||||
</body></html>
|
<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">
|
||||||
|
<div class="num"><%= activeClientId %></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="/v2/app/add">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>IPv4 адрес или подсеть CIDR</label>
|
||||||
|
<input name="cidr" placeholder="Например: 203.0.113.10 или 203.0.113.0/24" maxlength="18" 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 style="width:1%;white-space:nowrap;">Адрес</th>
|
||||||
|
<th>Комментарий</th>
|
||||||
|
<th>Добавил</th>
|
||||||
|
<th style="font-size:.78rem;width:1%;">Добавлена</th>
|
||||||
|
<th style="text-align:center;white-space:nowrap;">Правка</th>
|
||||||
|
<th style="width:1%;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% entries.forEach(function(e) { %>
|
||||||
|
<tr style="<%= e.deleted_at ? 'opacity:.5;text-decoration:line-through;' : '' %>">
|
||||||
|
<td style="white-space:nowrap;"><code><%= e.value_cidr %></code><% if (e.deleted_at) { %> <span style="font-size:.7rem;color:var(--red);">уд.</span><% } %></td>
|
||||||
|
<td><%= e.comment || '—' %></td>
|
||||||
|
<td style="white-space:nowrap;"><%= e.created_by %></td>
|
||||||
|
<td style="font-size:.72rem;line-height:1.2;"><%= new Date(e.created_at).toLocaleString('ru', {timeZone:'Europe/Moscow'}) %> МСК</td>
|
||||||
|
<td style="text-align:center;white-space:nowrap;">
|
||||||
|
<% if (!e.deleted_at) { %>
|
||||||
|
<form method="POST" action="/v2/app/edit/<%= e.id %>" style="display:inline;">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<input name="cidr" value="<%= e.value_cidr %>" size="18" style="width:125px;padding:.2rem .3rem;border:1px solid var(--border);border-radius:4px;font-size:.78rem;">
|
||||||
|
<input name="comment" value="<%= e.comment || '' %>" size="30" style="width:200px;padding:.2rem .3rem;border:1px solid var(--border);border-radius:4px;font-size:.78rem;">
|
||||||
|
<button class="btn btn-sm" title="Сохранить">💾</button>
|
||||||
|
</form>
|
||||||
|
<% } else { %>
|
||||||
|
<span style="color:var(--muted);font-size:.78rem;">удалена</span>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:center;">
|
||||||
|
<% if (!e.deleted_at) { %>
|
||||||
|
<form method="POST" action="/v2/app/delete/<%= e.id %>" style="display:inline;">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<input type="hidden" name="deleted" value="<%= includeDeleted ? '1' : '0' %>">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Удалить?')" title="Удалить">🗑️</button>
|
||||||
|
</form>
|
||||||
|
<% } else { %>
|
||||||
|
<form method="POST" action="/v2/app/restore/<%= e.id %>" style="display:inline;">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<input type="hidden" name="deleted" value="<%= includeDeleted ? '1' : '0' %>">
|
||||||
|
<button type="submit" class="btn btn-sm" title="Восстановить" style="color:var(--green);border-color:var(--green);">↺</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Справка -->
|
||||||
|
<details class="help" style="background:var(--card);border:1px solid var(--border);border-radius:12px;padding:1rem;margin-bottom:1rem;">
|
||||||
|
<summary style="cursor:pointer;font-weight:600;font-size:.95rem;color:var(--blue);">Доверенные адреса — защита вашего трафика во время DDoS-атак</summary>
|
||||||
|
<div style="margin-top:.75rem;font-size:.85rem;line-height:1.6;color:var(--text);">
|
||||||
|
<p>Когда на инфраструктуру провайдера идёт DDoS-атака, системы защиты автоматически фильтруют подозрительный трафик. Иногда под фильтрацию попадает и легитимный трафик, например, запросы из вашего офиса или от ваших партнёров.</p>
|
||||||
|
<p>Чтобы этого не происходило, вы можете заранее указать адреса, которые провайдер гарантированно не заблокирует. Это называется списком доверенных адресов.</p>
|
||||||
|
|
||||||
|
<p style="font-weight:600;margin-top:.75rem;">Как пользоваться</p>
|
||||||
|
<p><a href="https://whitelist.nodejsk8s.services.ngcloud.ru/" target="_blank">https://whitelist.nodejsk8s.services.ngcloud.ru/</a></p>
|
||||||
|
<p>Зайти в сервис необходимо с учетной записью keycloak.</p>
|
||||||
|
<p><strong>Добавить адрес.</strong> Нажмите «Добавить», введите IPv4-адрес или подсеть в формате CIDR и при желании — комментарий, чтобы не забыть, что это за адрес. Например: 203.0.113.10 или 203.0.113.0/24. Нажмите «Сохранить». Если вы вводите адрес хоста с маской подсети (например 203.0.113.5/24 вместо 203.0.113.0/24), сервис автоматически нормализует его до адреса подсети и сообщит вам об этом.</p>
|
||||||
|
<p>Добавляйте комментарии к записям — через несколько месяцев вы скажете себе спасибо: будет сразу понятно, чей это адрес и зачем он добавлен.</p>
|
||||||
|
<p><strong>Редактировать или удалить.</strong> Рядом с каждой записью есть кнопки редактирования и удаления. Удалённые записи перестают действовать через 5 минут.</p>
|
||||||
|
|
||||||
|
<p style="font-weight:600;margin-top:.75rem;">Ограничения</p>
|
||||||
|
<ul style="padding-left:1.2rem;margin:.25rem 0;">
|
||||||
|
<li>Только публичные IPv4-адреса. Серые адреса (192.168.x.x, 10.x.x.x, 172.16–31.x.x и другие служебные диапазоны) добавить нельзя — они не маршрутизируются в интернете и смысла в их добавлении нет.</li>
|
||||||
|
<li>Только IPv4. IPv6-адреса и доменные имена не поддерживаются.</li>
|
||||||
|
<li>Максимальный размер подсети — /22. Добавить префикс шире /22 (то есть /21, /20 и т.д.) нельзя.</li>
|
||||||
|
<li>До 15 записей на компанию. Если вам нужно больше — обратитесь в поддержку.</li>
|
||||||
|
<li>Нет дублей и пересечений. Если вы попытаетесь добавить адрес, который уже покрывается существующей записью (или наоборот), сервис предупредит об этом.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer style="display:flex;justify-content:space-between;align-items:center;padding:.5rem 1.5rem;font-size:.72rem;color:var(--muted);">
|
||||||
|
<span><% if (user.isAdmin) { %>Для вывода списка: <a href="/v2/export?view=1" style="color:var(--muted);"><%= 'https://whitelist.nodejsk8s.services.ngcloud.ru/export' %></a><% } %></span>
|
||||||
|
<span>v<%= version %></span>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user