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
+9 -73
View File
@@ -23,14 +23,13 @@ const helmet = require('helmet');
require('dotenv').config();
const { checkConnection } = require('./src/db');
const { initAuth, requireAdmin } = require('./src/auth');
const { aggregateCIDRs } = require('./src/validators');
const { initAuth } = require('./src/auth');
const q = require('./src/queries');
const { MOCK_USERS, backUrl, APP_VERSION } = require('./src/config');
const { mutationLimiter, exportLimiter, authLimiter } = require('./src/middleware/rateLimit');
const { initCsrf } = require('./src/middleware/csrf');
const { MOCK_USERS, APP_VERSION } = require('./src/config');
const { authLimiter } = require('./src/middleware/rateLimit');
const { createSessionMiddleware } = require('./src/middleware/session');
const { CSP_DIRECTIVES } = require('./src/middleware/csp');
const { createUiRouter } = require('./ui/index');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -57,34 +56,18 @@ async function start() {
const auth = await initAuth();
// ── Сессии ────────────────────────────────────────────────────────────────
// PG-стор: сессии переживают рестарт, работают на нескольких подах.
// Таблица `session` создаётся автоматически при первом старте.
// src/middleware/session.js содержит логику и комментарии.
const { pool } = require('./src/db');
app.use(createSessionMiddleware(pool));
// ── CSRF защита ────────────────────────────────────────────────────────────
// initCsrf() читает CSRF_SECRET и NODE_ENV из process.env.
// Возвращает: doubleCsrfProtection (middleware на POST) и
// generateCsrfToken (вызов в GET-роутах для передачи токена в шаблон).
const { doubleCsrfProtection, generateCsrfToken } = initCsrf();
// ── Публичные маршруты (до auth.middleware) ──────────────────────────────────
// ── Fail-fast в production ────────────────────────────────────────────────
// Если секреты не заданы — приложение в проде немедленно падает.
// Дефолтные значения — только для локальной разработки.
if (process.env.NODE_ENV === 'production') {
const DEFAULT_SESSION = 'dev-session-secret-change-me';
const DEFAULT_CSRF = 'dev-csrf-secret-change-in-prod';
if (!process.env.SESSION_SECRET || process.env.SESSION_SECRET === DEFAULT_SESSION) {
console.error('FATAL: SESSION_SECRET не задан или равен дефолту в production!');
process.exit(1);
}
if (!process.env.CSRF_SECRET || process.env.CSRF_SECRET === DEFAULT_CSRF) {
console.error('FATAL: CSRF_SECRET не задан или равен дефолту в production!');
process.exit(1);
}
}
// k8s liveness probe — простая проверка что сервер жив
@@ -93,65 +76,18 @@ async function start() {
// JWKS endpoint для валидации токенов внешними сервисами (только в mock-режиме)
if (auth.jwksHandler) app.get('/.well-known/jwks.json', auth.jwksHandler);
// GET /login, POST /login, GET /logout.
// src/routes/auth.js содержит логику и комментарии.
app.use(require('./src/routes/auth').createRouter({
auth,
doubleCsrfProtection,
generateCsrfToken,
MOCK_USERS,
authLimiter,
}));
// ── REST API v1 (Bearer JWT, без сессий/CSRF) ─────────────────────────────
const { createApiRouter } = require('./src/api/index');
app.use('/api/v1', createApiRouter({ auth, q }));
// ── Все маршруты ниже требуют авторизации (auth.middleware) ────────────────
app.use(auth.middleware);
// ── UI-слой: SSR через EJS, данные из /api/v1/* ───────────────────────────
// Монтируется ПОСЛЕ /api/v1/ — не перехватывает API-запросы.
// Хранит Bearer token в сессии, рендерит те же views/*.ejs.
app.use('/', createUiRouter({ auth, MOCK_USERS, authLimiter }));
// GET /export: CIDR текущего пользователя (или всех компаний для admin).
// Перемещён за auth.middleware — изоляция данных по компании.
// src/routes/export.js содержит логику и комментарии.
app.use(require('./src/routes/export').createRouter({
q,
exportLimiter,
aggregateCIDRs,
}));
// GET /, POST /add, POST /edit/:id, POST /delete/:id.
// src/routes/entries.js содержит логику и комментарии.
app.use(require('./src/routes/entries').createRouter({
q,
doubleCsrfProtection,
mutationLimiter,
generateCsrfToken,
backUrl,
}));
// GET /audit, GET /admin, POST /admin/limit/:companyId.
// src/routes/admin.js содержит логику и комментарии.
app.use(require('./src/routes/admin').createRouter({
q,
requireAdmin,
doubleCsrfProtection,
mutationLimiter,
generateCsrfToken,
}));
// ── CSRF error handler ────────────────────────────────────────────────────────
// doubleCsrfProtection вызывает next(err) с кодом 403 при невалидном токене.
// Ловим ДО общего обработчика — возвращаем понятное сообщение пользователю.
// ── Общий обработчик ошибок ───────────────────────────────────────────────
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
if (err && (err.status === 403 || err.code === 'EBADCSRFTOKEN')) {
return res.status(403).send(
'<p style="font-family:sans-serif;padding:2rem">' +
'Недействительный CSRF-токен. ' +
'<a href="javascript:history.back()">Вернитесь назад</a> и повторите действие.' +
'</p>'
);
}
console.error('Unhandled error:', err);
res.status(500).send('Внутренняя ошибка сервера');
});