v2: CRUD-модуль — /v2/app с выбором компании, добавление/изменение/удаление записей

This commit is contained in:
2026-06-12 16:53:33 +04:00
parent 03d480de12
commit e91f1b0ff3
7 changed files with 612 additions and 1 deletions
+216
View File
@@ -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 };
+23
View File
@@ -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 };
+232
View File
@@ -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,
};
+82
View File
@@ -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 };