feat: UI-слой через /api/v1/* (ui/, views/ui-login.ejs, views/error.ejs)

- ui/api-client.js: HTTP-клиент к /api/v1/* (token из session.token)
- ui/routes/auth.js: /login (mock-пресеты), /login-token (Bearer paste), /logout
- ui/routes/entries.js: /, /add, /edit/:id, /delete/:id через API
- ui/routes/admin.js: /admin, /admin/limit/:id, /audit через API
- ui/routes/export.js: /export → проксирует /api/v1/entries/export
- ui/index.js: createUiRouter() — requireToken + resolveUser middleware
- views/ui-login.ejs: страница входа (mock + token)
- views/error.ejs: шаблон для 403/404/500

API расширения:
- GET /api/v1/entries теперь возвращает limit и used
- GET /api/v1/entries?company=<id> (admin): записи другой компании
- POST/PATCH/DELETE /api/v1/entries?company=<id> (admin): мутации для другой компании
- GET /api/v1/entries/export: агрегированный CIDR (перенесён из монолита)
- GET /api/v1/audit?company=<id>: журнал (перенесён из монолита)

server.js: заменены src/routes/* на ui/createUiRouter(), убран CSRF
This commit is contained in:
2026-05-30 19:02:51 +03:00
parent 87216389e3
commit c85a438ba2
11 changed files with 699 additions and 81 deletions
+73
View File
@@ -0,0 +1,73 @@
'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) {
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',
},
};
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'] || '';
const data = ct.includes('application/json') ? JSON.parse(raw) : raw;
resolve({ status: res.statusCode, data });
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('API timeout')); });
if (bodyStr) req.write(bodyStr);
req.end();
});
}
// Хелперы — один вызов на метод
const api = {
get: (path, token) => apiRequest('GET', path, token),
post: (path, token, body) => apiRequest('POST', path, token, body),
patch: (path, token, body) => apiRequest('PATCH', path, token, body),
delete: (path, token) => apiRequest('DELETE', path, token),
// Достать Bearer из сессии
token: (req) => req.session && req.session.token,
};
module.exports = api;