v2: CRUD-модуль — /v2/app с выбором компании, добавление/изменение/удаление записей
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ipwhitelist",
|
"name": "ipwhitelist",
|
||||||
"version": "0.5.66",
|
"version": "0.5.67",
|
||||||
"description": "IP WhiteList microservice for cloud provider",
|
"description": "IP WhiteList microservice for cloud provider",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
const express = require('express');
|
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 crud = require('./src/crud');
|
||||||
|
|
||||||
const oidcConfig = {
|
const oidcConfig = {
|
||||||
baseUrl: config.kcBaseUrl,
|
baseUrl: config.kcBaseUrl,
|
||||||
@@ -102,6 +103,9 @@ ${u.isImpersonated ? `<p style="background:#ffc107;color:#000;padding:5px;">⚠
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── CRUD (/v2/app) ────────────────────────────────────────────
|
||||||
|
router.use('/app', crud.createCrudRouter());
|
||||||
|
|
||||||
// ── GET /v2/logout ───────────────────────────────────────────────────────
|
// ── GET /v2/logout ───────────────────────────────────────────────────────
|
||||||
router.get('/logout', (req, res) => {
|
router.get('/logout', (req, res) => {
|
||||||
delete req.session.v2_user;
|
delete req.session.v2_user;
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- V2 schema — отдельные таблицы, не пересекаются с основным приложением
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- Компании (по W-номеру = client_id)
|
||||||
|
CREATE TABLE IF NOT EXISTS v2_companies (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
client_id VARCHAR(64) UNIQUE NOT NULL, -- WZ01112, WZ03709 ...
|
||||||
|
name VARCHAR(255), -- название компании
|
||||||
|
custom_limit INTEGER DEFAULT NULL -- NULL = использовать DEFAULT_LIMIT
|
||||||
|
CHECK (custom_limit IS NULL OR custom_limit >= 0),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Записи белого списка (soft delete через deleted_at)
|
||||||
|
CREATE TABLE IF NOT EXISTS v2_entries (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
company_id INTEGER NOT NULL REFERENCES v2_companies(id) ON DELETE RESTRICT,
|
||||||
|
value_cidr VARCHAR(18) NOT NULL -- формат: x.x.x.x/xx (CIDR или /32)
|
||||||
|
CHECK (value_cidr ~ '^(\d{1,3}\.){3}\d{1,3}/\d{1,2}$'),
|
||||||
|
comment VARCHAR(255),
|
||||||
|
created_by VARCHAR(255) NOT NULL, -- email создателя
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_by VARCHAR(255),
|
||||||
|
updated_at TIMESTAMPTZ,
|
||||||
|
deleted_by VARCHAR(255),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Уникальность: в одной компании не может быть двух активных записей с одинаковым CIDR
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_v2_entries_active_cidr
|
||||||
|
ON v2_entries(company_id, value_cidr) WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- Быстрый список активных записей компании
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_v2_entries_active
|
||||||
|
ON v2_entries(company_id, created_at DESC) WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- Аудит (неизменяемый журнал)
|
||||||
|
CREATE TABLE IF NOT EXISTS v2_audit (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_email VARCHAR(255) NOT NULL, -- кто совершил действие
|
||||||
|
company_id INTEGER NOT NULL, -- в какой компании
|
||||||
|
action VARCHAR(32) NOT NULL -- CREATE, UPDATE, DELETE
|
||||||
|
CHECK (action IN ('CREATE','UPDATE','DELETE')),
|
||||||
|
old_value TEXT, -- предыдущий CIDR (для UPDATE)
|
||||||
|
new_value TEXT, -- новый CIDR
|
||||||
|
entry_id INTEGER, -- id записи в v2_entries
|
||||||
|
impersonated_by VARCHAR(255), -- кто реально (если имперсонация)
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_v2_audit_company_time
|
||||||
|
ON v2_audit(company_id, created_at DESC);
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// V2 — CRUD-роутер (только для авторизованных)
|
||||||
|
//
|
||||||
|
// Вход: sessionUser из v2_ сессии (email, clientId, allClientIds, profiles, ...)
|
||||||
|
// Всегда показывает выбор компании (даже если одна).
|
||||||
|
// Переключение — смена activeClientId в сессии (без IAM).
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const q = require('../db/queries');
|
||||||
|
const { validate } = require('../validators');
|
||||||
|
|
||||||
|
function createCrudRouter() {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ── middleware: только для залогиненных ─────────────────────────────────
|
||||||
|
router.use((req, res, next) => {
|
||||||
|
if (!req.session.v2_user) return res.redirect('/v2/login');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
function user(req) { return req.session.v2_user; }
|
||||||
|
function token(req) { return req.session.v2_token; }
|
||||||
|
|
||||||
|
// activeClientId — текущая выбранная компания
|
||||||
|
function activeClientId(req) {
|
||||||
|
return req.session.v2_user.activeClientId || req.session.v2_user.clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Список компаний для выпадающего списка
|
||||||
|
function companies(req) {
|
||||||
|
const u = user(req);
|
||||||
|
if (u.profiles && u.profiles.length > 0) return u.profiles;
|
||||||
|
// fallback: одна компания из clientId
|
||||||
|
return [{ client_id: u.clientId, company_name: u.companyName || u.clientId, is_active_profile: true }];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /v2/app — главная страница ──────────────────────────────────────
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const clId = activeClientId(req);
|
||||||
|
const allCompanies = companies(req);
|
||||||
|
const company = await q.getOrCreateCompany(clId, clId);
|
||||||
|
|
||||||
|
const includeDeleted = req.query.deleted === '1';
|
||||||
|
const entries = await q.listEntries(company.id, includeDeleted);
|
||||||
|
const limit = await q.getLimit(company);
|
||||||
|
const used = entries.filter(e => !e.deleted_at).length;
|
||||||
|
|
||||||
|
const switchTo = req.query.switchTo;
|
||||||
|
if (switchTo && allCompanies.find(c => c.client_id === switchTo)) {
|
||||||
|
req.session.v2_user.activeClientId = switchTo;
|
||||||
|
return res.redirect('/v2/app');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.send(renderApp({ entries, company, limit, used, allCompanies, clId, user: user(req), includeDeleted }));
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send('<h2>Ошибка</h2><pre>' + e.message + '</pre><a href="/v2/app">Назад</a>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /v2/app/add ────────────────────────────────────────────────────
|
||||||
|
router.post('/add', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const clId = activeClientId(req);
|
||||||
|
const company = await q.getOrCreateCompany(clId, clId);
|
||||||
|
const u = user(req);
|
||||||
|
|
||||||
|
const result = await q.createEntry(
|
||||||
|
company.id,
|
||||||
|
req.body.cidr || '',
|
||||||
|
req.body.comment || '',
|
||||||
|
u.email,
|
||||||
|
u.originalUserEmail || null // impersonated_by
|
||||||
|
);
|
||||||
|
|
||||||
|
const msg = result.wasNormalized ? 'Добавлено (адрес нормализован)' : 'Добавлено';
|
||||||
|
res.redirect('/v2/app?msg=' + encodeURIComponent(msg));
|
||||||
|
} catch (e) {
|
||||||
|
res.redirect('/v2/app?error=' + encodeURIComponent(e.message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /v2/app/edit/:id ───────────────────────────────────────────────
|
||||||
|
router.post('/edit/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const clId = activeClientId(req);
|
||||||
|
const company = await q.getOrCreateCompany(clId, clId);
|
||||||
|
const u = user(req);
|
||||||
|
|
||||||
|
const result = await q.updateEntry(
|
||||||
|
parseInt(req.params.id),
|
||||||
|
company.id,
|
||||||
|
req.body.cidr || '',
|
||||||
|
req.body.comment || '',
|
||||||
|
u.email,
|
||||||
|
u.originalUserEmail || null
|
||||||
|
);
|
||||||
|
|
||||||
|
const msg = result.wasNormalized ? 'Изменено (адрес нормализован)' : 'Изменено';
|
||||||
|
res.redirect('/v2/app?msg=' + encodeURIComponent(msg));
|
||||||
|
} catch (e) {
|
||||||
|
res.redirect('/v2/app?error=' + encodeURIComponent(e.message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /v2/app/delete/:id ─────────────────────────────────────────────
|
||||||
|
router.post('/delete/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const clId = activeClientId(req);
|
||||||
|
const company = await q.getOrCreateCompany(clId, clId);
|
||||||
|
const u = user(req);
|
||||||
|
|
||||||
|
await q.deleteEntry(
|
||||||
|
parseInt(req.params.id),
|
||||||
|
company.id,
|
||||||
|
u.email,
|
||||||
|
u.originalUserEmail || null
|
||||||
|
);
|
||||||
|
|
||||||
|
res.redirect('/v2/app?msg=' + encodeURIComponent('Удалено'));
|
||||||
|
} catch (e) {
|
||||||
|
res.redirect('/v2/app?error=' + encodeURIComponent(e.message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTML-рендеринг (временный, без EJS) ─────────────────────────────────────
|
||||||
|
|
||||||
|
function renderApp({ entries, company, limit, used, allCompanies, clId, user, includeDeleted }) {
|
||||||
|
const msg = (s) => s ? `<div style="background:#4caf50;color:#fff;padding:8px;margin:8px 0">${s}</div>` : '';
|
||||||
|
const err = (s) => s ? `<div style="background:#f44336;color:#fff;padding:8px;margin:8px 0">${s}</div>` : '';
|
||||||
|
|
||||||
|
const companyOptions = allCompanies.map(c =>
|
||||||
|
`<option value="${c.client_id}" ${c.client_id === clId ? 'selected' : ''}>${c.company_name || c.client_id} (${c.client_id})</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const rows = entries.map(e => {
|
||||||
|
const del = e.deleted_at;
|
||||||
|
const style = del ? 'text-decoration:line-through;opacity:0.5' : '';
|
||||||
|
const actions = del ? '<span style="color:#888">удалено</span>' : `
|
||||||
|
<form method="POST" action="/v2/app/edit/${e.id}" style="display:inline">
|
||||||
|
<input name="cidr" value="${e.value_cidr}" size="18">
|
||||||
|
<input name="comment" value="${e.comment || ''}" size="20">
|
||||||
|
<button>изменить</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="/v2/app/delete/${e.id}" style="display:inline">
|
||||||
|
<button onclick="return confirm('Удалить?')">удалить</button>
|
||||||
|
</form>`;
|
||||||
|
return `<tr style="${style}">
|
||||||
|
<td>${e.value_cidr}</td><td>${e.comment || ''}</td><td>${e.created_by}</td>
|
||||||
|
<td>${new Date(e.created_at).toLocaleString('ru')}</td>
|
||||||
|
<td>${actions}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf-8"><title>V2 — IP WhiteList</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; max-width:960px; margin:20px auto; color:#222; }
|
||||||
|
table { width:100%; border-collapse:collapse; margin:10px 0; }
|
||||||
|
th,td { border:1px solid #ccc; padding:6px; text-align:left; }
|
||||||
|
th { background:#f5f5f5; }
|
||||||
|
.bar { display:flex; justify-content:space-between; align-items:center; background:#eee; padding:10px; margin-bottom:10px; }
|
||||||
|
button,input,select { padding:4px 8px; }
|
||||||
|
.add-form { background:#f0f8ff; padding:10px; margin:10px 0; }
|
||||||
|
a { color:#1976d2; }
|
||||||
|
</style></head><body>
|
||||||
|
|
||||||
|
<div class="bar">
|
||||||
|
<span><strong>V2 IP WhiteList</strong></span>
|
||||||
|
<span>${user.email} | ${user.fio || ''} ${user.isAdmin ? '| ADMIN' : ''}</span>
|
||||||
|
<span>
|
||||||
|
<form method="GET" action="/v2/app" style="display:inline">
|
||||||
|
<select name="switchTo" onchange="this.form.submit()">
|
||||||
|
${companyOptions}
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
<a href="/v2/logout">Выйти</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${msg('')}${err('')}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const m = params.get('msg'), e = params.get('error');
|
||||||
|
if (m) document.querySelector('body').insertAdjacentHTML('afterbegin', '<div style="background:#4caf50;color:#fff;padding:8px">'+m+'</div>');
|
||||||
|
if (e) document.querySelector('body').insertAdjacentHTML('afterbegin', '<div style="background:#f44336;color:#fff;padding:8px">'+e+'</div>');
|
||||||
|
if (m||e) history.replaceState(null,'','/v2/app');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p>Записей: <strong>${used}</strong> из ${limit}</p>
|
||||||
|
|
||||||
|
<div class="add-form">
|
||||||
|
<form method="POST" action="/v2/app/add">
|
||||||
|
<input name="cidr" placeholder="x.x.x.x/xx" size="18" required>
|
||||||
|
<input name="comment" placeholder="комментарий" size="30">
|
||||||
|
<button>Добавить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr><th>CIDR</th><th>Комментарий</th><th>Кто</th><th>Когда</th><th></th></tr>
|
||||||
|
${rows || '<tr><td colspan="5">Нет записей</td></tr>'}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p><a href="/v2/app?deleted=${includeDeleted ? '0' : '1'}">${includeDeleted ? 'Скрыть удалённые' : 'Показать удалённые'}</a></p>
|
||||||
|
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createCrudRouter };
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// V2 — подключение к PostgreSQL
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: process.env.DB_PORT || 5432,
|
||||||
|
database: process.env.DB_NAME || 'ipwhitelist',
|
||||||
|
user: process.env.DB_USER || 'super',
|
||||||
|
password: process.env.DB_PASS || '',
|
||||||
|
ssl: process.env.DB_SSLMODE === 'require' ? { rejectUnauthorized: false } : false,
|
||||||
|
max: 10,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
pool.on('error', (err) => {
|
||||||
|
console.error('[v2:db] pool error:', err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = { pool };
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// V2 — SQL-запросы (единственное место работы с БД)
|
||||||
|
// Все функции работают с таблицами v2_companies, v2_entries, v2_audit
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const { pool } = require('./index');
|
||||||
|
const { validate, overlaps } = require('../validators');
|
||||||
|
|
||||||
|
// ── Компании ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить или создать компанию по client_id (W-номеру).
|
||||||
|
* Атомарный upsert — исключает дубли при параллельных запросах.
|
||||||
|
*/
|
||||||
|
async function getOrCreateCompany(clientId, companyName) {
|
||||||
|
const res = await pool.query(
|
||||||
|
`INSERT INTO v2_companies (client_id, name) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (client_id) DO UPDATE SET name = COALESCE(v2_companies.name, EXCLUDED.name)
|
||||||
|
RETURNING *`,
|
||||||
|
[clientId, companyName || clientId]
|
||||||
|
);
|
||||||
|
return res.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Лимит записей компании: custom_limit или DEFAULT_LIMIT (15).
|
||||||
|
*/
|
||||||
|
async function getLimit(company) {
|
||||||
|
const defaultLimit = parseInt(process.env.DEFAULT_LIMIT, 10) || 15;
|
||||||
|
return company.custom_limit != null ? company.custom_limit : defaultLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Записи ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Список записей компании.
|
||||||
|
* @param {number} companyId — v2_companies.id
|
||||||
|
* @param {boolean} includeDeleted — показать удалённые
|
||||||
|
*/
|
||||||
|
async function listEntries(companyId, includeDeleted = false) {
|
||||||
|
let sql = 'SELECT * FROM v2_entries WHERE company_id = $1';
|
||||||
|
const params = [companyId];
|
||||||
|
if (!includeDeleted) sql += ' AND deleted_at IS NULL';
|
||||||
|
sql += ' ORDER BY created_at DESC';
|
||||||
|
return (await pool.query(sql, params)).rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создать запись (с валидацией, проверкой лимита и пересечений).
|
||||||
|
*/
|
||||||
|
async function createEntry(companyId, rawValue, comment, userEmail, impersonatedBy) {
|
||||||
|
const { cidr, wasNormalized } = validate(rawValue);
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
const company = (await client.query(
|
||||||
|
'SELECT * FROM v2_companies WHERE id = $1 FOR UPDATE', [companyId]
|
||||||
|
)).rows[0];
|
||||||
|
if (!company) throw new Error('Компания не найдена');
|
||||||
|
|
||||||
|
const limit = await getLimit(company);
|
||||||
|
const cnt = (await client.query(
|
||||||
|
'SELECT COUNT(*)::int AS c FROM v2_entries WHERE company_id = $1 AND deleted_at IS NULL',
|
||||||
|
[companyId]
|
||||||
|
)).rows[0].c;
|
||||||
|
if (cnt >= limit) throw new Error(`Лимит исчерпан: ${cnt} из ${limit}`);
|
||||||
|
|
||||||
|
const existing = (await client.query(
|
||||||
|
'SELECT value_cidr FROM v2_entries WHERE company_id = $1 AND deleted_at IS NULL',
|
||||||
|
[companyId]
|
||||||
|
)).rows;
|
||||||
|
for (const row of existing) {
|
||||||
|
if (row.value_cidr === cidr) throw new Error('Такой адрес уже существует');
|
||||||
|
if (overlaps(cidr, row.value_cidr))
|
||||||
|
throw new Error(`Пересечение с существующей записью ${row.value_cidr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await client.query(
|
||||||
|
`INSERT INTO v2_entries (company_id, value_cidr, comment, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||||
|
[companyId, cidr, comment || null, userEmail]
|
||||||
|
);
|
||||||
|
await logAudit(userEmail, companyId, 'CREATE', null, cidr, res.rows[0].id, impersonatedBy, client);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return { entry: res.rows[0], wasNormalized };
|
||||||
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить запись.
|
||||||
|
*/
|
||||||
|
async function updateEntry(entryId, companyId, rawValue, comment, userEmail, impersonatedBy) {
|
||||||
|
const { cidr, wasNormalized } = validate(rawValue);
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
await client.query('SELECT * FROM v2_companies WHERE id = $1 FOR UPDATE', [companyId]);
|
||||||
|
|
||||||
|
const old = (await client.query(
|
||||||
|
'SELECT * FROM v2_entries WHERE id = $1 AND company_id = $2 AND deleted_at IS NULL',
|
||||||
|
[entryId, companyId]
|
||||||
|
)).rows[0];
|
||||||
|
if (!old) throw new Error('Запись не найдена');
|
||||||
|
|
||||||
|
const existing = (await client.query(
|
||||||
|
'SELECT value_cidr FROM v2_entries WHERE company_id = $1 AND deleted_at IS NULL AND id <> $2',
|
||||||
|
[companyId, entryId]
|
||||||
|
)).rows;
|
||||||
|
for (const row of existing) {
|
||||||
|
if (row.value_cidr === cidr) throw new Error('Такой адрес уже существует');
|
||||||
|
if (overlaps(cidr, row.value_cidr))
|
||||||
|
throw new Error(`Пересечение с существующей записью ${row.value_cidr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await client.query(
|
||||||
|
`UPDATE v2_entries SET value_cidr = $1, comment = $2, updated_by = $3, updated_at = NOW()
|
||||||
|
WHERE id = $4 AND company_id = $5 RETURNING *`,
|
||||||
|
[cidr, comment || old.comment, userEmail, entryId, companyId]
|
||||||
|
);
|
||||||
|
await logAudit(userEmail, companyId, 'UPDATE', old.value_cidr, cidr, entryId, impersonatedBy, client);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return { entry: res.rows[0], wasNormalized };
|
||||||
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удалить запись (soft delete).
|
||||||
|
*/
|
||||||
|
async function deleteEntry(entryId, companyId, userEmail, impersonatedBy) {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
const old = (await client.query(
|
||||||
|
'SELECT * FROM v2_entries WHERE id = $1 AND company_id = $2 AND deleted_at IS NULL',
|
||||||
|
[entryId, companyId]
|
||||||
|
)).rows[0];
|
||||||
|
if (!old) throw new Error('Запись не найдена');
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
'UPDATE v2_entries SET deleted_by = $1, deleted_at = NOW() WHERE id = $2 AND company_id = $3',
|
||||||
|
[userEmail, entryId, companyId]
|
||||||
|
);
|
||||||
|
await logAudit(userEmail, companyId, 'DELETE', old.value_cidr, null, entryId, impersonatedBy, client);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Админ ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function getCompanyById(id) {
|
||||||
|
const res = await pool.query('SELECT * FROM v2_companies WHERE id = $1', [id]);
|
||||||
|
return res.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllCompanies() {
|
||||||
|
const res = await pool.query(`
|
||||||
|
SELECT c.*, COUNT(e.id)::int AS active_count
|
||||||
|
FROM v2_companies c
|
||||||
|
LEFT JOIN v2_entries e ON e.company_id = c.id AND e.deleted_at IS NULL
|
||||||
|
GROUP BY c.id
|
||||||
|
ORDER BY c.name, c.client_id
|
||||||
|
`);
|
||||||
|
return res.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setLimit(companyId, newLimit) {
|
||||||
|
const result = await pool.query(
|
||||||
|
'UPDATE v2_companies SET custom_limit = $1, updated_at = NOW() WHERE id = $2',
|
||||||
|
[newLimit, companyId]
|
||||||
|
);
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
const err = new Error('Company not found: ' + companyId);
|
||||||
|
err.status = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Экспорт ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function getExportCIDRs(companyId = null) {
|
||||||
|
let sql = 'SELECT value_cidr FROM v2_entries WHERE deleted_at IS NULL';
|
||||||
|
const params = [];
|
||||||
|
if (companyId) { sql += ' AND company_id = $1'; params.push(companyId); }
|
||||||
|
sql += ' ORDER BY value_cidr';
|
||||||
|
return (await pool.query(sql, params)).rows.map(r => r.value_cidr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Аудит ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function logAudit(userEmail, companyId, action, oldValue, newValue, entryId, impersonatedBy, db = pool) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO v2_audit (user_email, company_id, action, old_value, new_value, entry_id, impersonated_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||||
|
[userEmail, companyId, action, oldValue, newValue, entryId || null, impersonatedBy || null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAudit(companyId = null) {
|
||||||
|
let sql = `
|
||||||
|
SELECT a.*, c.name AS company_name, c.client_id
|
||||||
|
FROM v2_audit a
|
||||||
|
JOIN v2_companies c ON c.id = a.company_id
|
||||||
|
`;
|
||||||
|
const params = [];
|
||||||
|
if (companyId) { sql += ' WHERE a.company_id = $1'; params.push(companyId); }
|
||||||
|
sql += ' ORDER BY a.created_at DESC LIMIT 500';
|
||||||
|
return (await pool.query(sql, params)).rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getOrCreateCompany, getLimit,
|
||||||
|
listEntries, createEntry, updateEntry, deleteEntry,
|
||||||
|
getExportCIDRs, getAudit,
|
||||||
|
getCompanyById, getAllCompanies, setLimit,
|
||||||
|
};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// V2 — валидация IPv4/CIDR
|
||||||
|
// Чистые функции, только net (встроенный Node.js)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const net = require('net');
|
||||||
|
|
||||||
|
const BLOCKED_RANGES = [
|
||||||
|
'10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16',
|
||||||
|
'100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16',
|
||||||
|
'192.0.0.0/24', '192.0.2.0/24', '198.51.100.0/24',
|
||||||
|
'203.0.113.0/24', '198.18.0.0/15', '224.0.0.0/4',
|
||||||
|
'240.0.0.0/4', '0.0.0.0/8', '255.255.255.255/32',
|
||||||
|
];
|
||||||
|
|
||||||
|
function validate(input) {
|
||||||
|
const raw = (input || '').trim();
|
||||||
|
if (!raw) throw new Error('Пустое значение');
|
||||||
|
if (raw.includes(':')) throw new Error('IPv6 не поддерживается');
|
||||||
|
if ((raw.match(/\//g) || []).length > 1) throw new Error('Некорректный формат');
|
||||||
|
|
||||||
|
let cidr = raw.includes('/') ? raw : raw + '/32';
|
||||||
|
const [addr, maskStr] = cidr.split('/');
|
||||||
|
|
||||||
|
if (!/^\d{1,2}$/.test(maskStr)) throw new Error('Некорректная маска');
|
||||||
|
const mask = parseInt(maskStr, 10);
|
||||||
|
if (mask < 22 || mask > 32) throw new Error('Маска должна быть от /22 до /32');
|
||||||
|
if (!net.isIPv4(addr)) throw new Error('Некорректный IPv4 адрес');
|
||||||
|
|
||||||
|
const ipNum = addr.split('.').reduce((acc, o) => (acc << 8) + parseInt(o, 10), 0) >>> 0;
|
||||||
|
const netMask = ~((1 << (32 - mask)) - 1) >>> 0;
|
||||||
|
const network = (ipNum & netMask) >>> 0;
|
||||||
|
const networkAddr = [
|
||||||
|
(network >>> 24) & 0xff, (network >>> 16) & 0xff,
|
||||||
|
(network >>> 8) & 0xff, network & 0xff,
|
||||||
|
].join('.');
|
||||||
|
const normalized = networkAddr + '/' + mask;
|
||||||
|
|
||||||
|
for (const blocked of BLOCKED_RANGES)
|
||||||
|
if (overlaps(normalized, blocked))
|
||||||
|
throw new Error(`Диапазон ${normalized} пересекается с запрещённым (${blocked})`);
|
||||||
|
|
||||||
|
return { cidr: normalized, wasNormalized: addr !== networkAddr };
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlaps(cidr1, cidr2) {
|
||||||
|
const a = cidrToRange(cidr1), b = cidrToRange(cidr2);
|
||||||
|
return a.start <= b.end && b.start <= a.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cidrToRange(cidr) {
|
||||||
|
const [addr, maskStr] = cidr.split('/');
|
||||||
|
const ip = addr.split('.').reduce((acc, o) => (acc << 8) + parseInt(o, 10), 0) >>> 0;
|
||||||
|
const mask = parseInt(maskStr, 10);
|
||||||
|
return { start: ip, end: (ip | ((1 << (32 - mask)) - 1)) >>> 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateCIDRs(cidrs) {
|
||||||
|
const ranges = cidrs.map(c => cidrToRange(c)).sort((a, b) => a.start - b.start);
|
||||||
|
const merged = [];
|
||||||
|
for (const r of ranges) {
|
||||||
|
const last = merged[merged.length - 1];
|
||||||
|
if (last && r.start <= last.end + 1) last.end = Math.max(last.end, r.end);
|
||||||
|
else merged.push({ ...r });
|
||||||
|
}
|
||||||
|
return merged.map(r => {
|
||||||
|
let start = r.start, end = r.end, cidrs = [];
|
||||||
|
while (start <= end) {
|
||||||
|
let maxMask = 32;
|
||||||
|
while (maxMask > 0 && ((start & ((1 << (32 - maxMask)) - 1)) !== 0 || (start + (1 << (32 - maxMask)) - 1) > end)) maxMask--;
|
||||||
|
cidrs.push(numToIP(start) + '/' + maxMask);
|
||||||
|
start += (1 << (32 - maxMask));
|
||||||
|
}
|
||||||
|
return cidrs;
|
||||||
|
}).flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
function numToIP(n) {
|
||||||
|
return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff].join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { validate, overlaps, cidrToRange, aggregateCIDRs, numToIP, BLOCKED_RANGES };
|
||||||
Reference in New Issue
Block a user