v2: разделение слоёв — crud/index.js API-слой, user/ и test/ через crud

This commit is contained in:
2026-06-13 06:55:18 +04:00
parent 4b5ccc6a4c
commit 3c061b6c4b
6 changed files with 267 additions and 240 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
// ═══════════════════════════════════════════════════════════════════════════════
module.exports = {
version: '0.5.89',
version: '0.5.90',
iamUrl: process.env.V2_IAM_API_URL || 'https://auth-api.ngcloud.ru',
appUrl: process.env.V2_APP_URL || 'https://whitelist.nodejsk8s.services.ngcloud.ru',
};
+34 -208
View File
@@ -1,217 +1,43 @@
// ═══════════════════════════════════════════════════════════════════════════════
// V2 — CRUD-роутер (только для авторизованных)
// V2 — CRUD API-слой (чистые функции, без Express)
//
// Вход: sessionUser из v2_ сессии (email, clientId, allClientIds, profiles, ...)
// Всегда показывает выбор компании (даже если одна).
// Переключение — смена activeClientId в сессии (без IAM).
// Принимает clientId (W-номер), сам резолвит companyId через getOrCreateCompany.
// Фронтенд (user/) и тесты (test/) дергают эти функции.
//
// Сигнатуры:
// list(clientId) → { entries, used, limit }
// add(clientId, cidr, comment, email, impBy) → { entry, wasNormalized }
// edit(entryId, clientId, cidr, comment, email, impBy) → { entry, wasNormalized }
// remove(entryId, clientId, email, impBy) → void
// ═══════════════════════════════════════════════════════════════════════════════
const express = require('express');
const q = require('../db/queries');
const config = require('../config');
const { validate } = require('../validators');
const q = require('../db/queries');
function createCrudRouter() {
const router = express.Router();
// ── middleware: только для залогиненных ─────────────────────────────────
router.use((req, res, next) => {
if (!req.session.user) return res.redirect('/v2/login');
next();
});
// ── helpers ──────────────────────────────────────────────────────────────
function user(req) { return req.session.user; }
function token(req) { return req.session.token; }
// activeClientId — текущая выбранная компания
function activeClientId(req) {
return req.session.user.activeClientId || req.session.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.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;
async function resolve(clientId) {
return q.getOrCreateCompany(clientId, clientId);
}
// ── 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> <small>v${config.version}</small></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>`;
async function list(clientId, includeDeleted = false) {
const co = await resolve(clientId);
const entries = await q.listEntries(co.id, includeDeleted);
const limit = await q.getLimit(co);
const used = entries.filter(e => !e.deleted_at).length;
return { entries, used, limit };
}
module.exports = { createCrudRouter };
async function add(clientId, cidr, comment, email, impBy) {
const co = await resolve(clientId);
return q.createEntry(co.id, cidr, comment, email, impBy);
}
async function edit(entryId, clientId, cidr, comment, email, impBy) {
const co = await resolve(clientId);
return q.updateEntry(entryId, co.id, cidr, comment, email, impBy);
}
async function remove(entryId, clientId, email, impBy) {
const co = await resolve(clientId);
return q.deleteEntry(entryId, co.id, email, impBy);
}
module.exports = { list, add, edit, remove };
+10 -12
View File
@@ -4,6 +4,7 @@
// ═══════════════════════════════════════════════════════════════════════════════
const express = require('express');
const crud = require('../crud');
const q = require('../db/queries');
const { pool } = require('../db');
@@ -30,25 +31,25 @@ function createTestRouter() {
switch (action) {
// ── LIST ──
case 'list':
const entries = await q.listEntries(company.id, req.query.deleted === '1');
return res.json({ ok: true, entries, count: entries.length, company: { id: company.id, client_id: company.client_id } });
case 'list': {
const { entries, used, limit } = await crud.list(clientId, req.query.deleted === '1');
return res.json({ ok: true, entries, count: entries.length, used, limit }); }
// ── ADD ──
case 'add': {
const r = await q.createEntry(company.id, req.query.cidr || '', req.query.comment || '', email, impBy);
const r = await crud.add(clientId, req.query.cidr || '', req.query.comment || '', email, impBy);
return res.json({ ok: true, entry: r.entry, wasNormalized: r.wasNormalized });
}
// ── EDIT ──
case 'edit': {
const r = await q.updateEntry(parseInt(req.query.id), company.id, req.query.cidr || '', req.query.comment || '', email, impBy);
const r = await crud.edit(parseInt(req.query.id), clientId, req.query.cidr || '', req.query.comment || '', email, impBy);
return res.json({ ok: true, entry: r.entry, wasNormalized: r.wasNormalized });
}
// ── DELETE ──
case 'delete':
await q.deleteEntry(parseInt(req.query.id), company.id, email, impBy);
await crud.remove(parseInt(req.query.id), clientId, email, impBy);
return res.json({ ok: true });
// ── AUDIT ──
@@ -112,10 +113,9 @@ function createTestRouter() {
const impBy = idx === 3 ? 'real@nubes.ru' : null;
const prefix = cidPrefix[idx];
tasks.push((async () => {
const co = await q.getOrCreateCompany(clientId, clientId);
for (let j = 0; j < perCompany; j++) {
try {
await q.createEntry(co.id, prefix + '.0.' + j + '.0/24', 'chaos', email, impBy);
await crud.add(clientId, prefix + '.0.' + j + '.0/24', 'chaos', email, impBy);
ok++;
} catch (e) { errors++; }
}
@@ -127,12 +127,10 @@ function createTestRouter() {
const checks = [];
let totalEntries = 0, totalAudit = 0, anyDupes = false, anyOverLimit = false;
for (const clientId of cids) {
const co = await q.getOrCreateCompany(clientId, clientId);
const entries = await q.listEntries(co.id);
const { entries, limit } = await crud.list(clientId);
const cidrs = entries.map(e => e.value_cidr);
const dupes = cidrs.length !== new Set(cidrs).size;
const limit = await q.getLimit(co);
const audit = await q.getAudit(co.id);
const audit = await q.getAudit((await q.getOrCreateCompany(clientId, clientId)).id);
totalEntries += entries.length;
totalAudit += audit.length;
if (dupes) anyDupes = true;
+11 -18
View File
@@ -1,11 +1,13 @@
// ═══════════════════════════════════════════════════════════════════════════════
// V2 — пользовательский CRUD
// Вход: req.v2_* из resolveContext (email, clientId, allClientIds, profiles, ...)
// V2 — пользовательский CRUD (фронтенд, только Express)
//
// Вход: req.clientId, req.email, req.impersonatedBy из resolveContext.
// Всегда показывает выбор компании (даже если одна).
// НЕ работает с БД напрямую — только через crud API-слой.
// ═══════════════════════════════════════════════════════════════════════════════
const express = require('express');
const q = require('../db/queries');
const crud = require('../crud');
function createUserRouter() {
const router = express.Router();
@@ -18,11 +20,8 @@ function createUserRouter() {
? req.profiles
: [{ client_id: clId, company_name: req.companyName || clId }];
const company = await q.getOrCreateCompany(clId, allCompanies.find(c => c.client_id === clId)?.company_name || 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 { entries, used, limit } = await crud.list(clId, includeDeleted);
// Переключение компании
if (req.query.switchTo && allCompanies.find(c => c.client_id === req.query.switchTo)) {
@@ -30,7 +29,7 @@ function createUserRouter() {
return res.redirect('/v2/app');
}
res.send(renderPage({ entries, company, limit, used, allCompanies, clId, user: req.user, includeDeleted }));
res.send(renderPage({ entries, limit, used, allCompanies, clId, user: req.user, includeDeleted }));
} catch (e) {
res.status(500).send('<h2>Ошибка</h2><pre>' + e.message + '</pre><a href="/v2/app">Назад</a>');
}
@@ -39,9 +38,7 @@ function createUserRouter() {
// ── POST /add ───────────────────────────────────────────────────────────
router.post('/add', async (req, res) => {
try {
const clId = req.clientId;
const company = await q.getOrCreateCompany(clId, clId);
const result = await q.createEntry(company.id, req.body.cidr || '', req.body.comment || '', req.email, req.impersonatedBy);
const result = await crud.add(req.clientId, req.body.cidr || '', req.body.comment || '', req.email, req.impersonatedBy);
const msg = result.wasNormalized ? 'Добавлено (адрес нормализован)' : 'Добавлено';
res.redirect('/v2/app?msg=' + encodeURIComponent(msg));
} catch (e) {
@@ -52,9 +49,7 @@ function createUserRouter() {
// ── POST /edit/:id ──────────────────────────────────────────────────────
router.post('/edit/:id', async (req, res) => {
try {
const clId = req.clientId;
const company = await q.getOrCreateCompany(clId, clId);
const result = await q.updateEntry(parseInt(req.params.id), company.id, req.body.cidr || '', req.body.comment || '', req.email, req.impersonatedBy);
const result = await crud.edit(parseInt(req.params.id), req.clientId, req.body.cidr || '', req.body.comment || '', req.email, req.impersonatedBy);
const msg = result.wasNormalized ? 'Изменено (адрес нормализован)' : 'Изменено';
res.redirect('/v2/app?msg=' + encodeURIComponent(msg));
} catch (e) {
@@ -65,9 +60,7 @@ function createUserRouter() {
// ── POST /delete/:id ────────────────────────────────────────────────────
router.post('/delete/:id', async (req, res) => {
try {
const clId = req.clientId;
const company = await q.getOrCreateCompany(clId, clId);
await q.deleteEntry(parseInt(req.params.id), company.id, req.email, req.impersonatedBy);
await crud.remove(parseInt(req.params.id), req.clientId, req.email, req.impersonatedBy);
res.redirect('/v2/app?msg=Удалено');
} catch (e) {
res.redirect('/v2/app?error=' + encodeURIComponent(e.message));
@@ -79,7 +72,7 @@ function createUserRouter() {
// ── HTML-рендеринг (временный, будет заменён на EJS) ─────────────────────
function renderPage({ entries, company, limit, used, allCompanies, clId, user, includeDeleted }) {
function renderPage({ entries, limit, used, allCompanies, clId, user, includeDeleted }) {
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('');