refactor: server.js API-only, drop UI routes/session/csrf/ejs

This commit is contained in:
2026-05-30 18:22:19 +03:00
parent 2738c010a7
commit dcd21d1e28
+19 -126
View File
@@ -1,162 +1,57 @@
/** /**
* server.js — точка входа приложения. * server.js — API-only точка входа (ветка api-v1).
* *
* Отвечает только за: * Обслуживает только /api/v1/* (Bearer JWT, JSON).
* 1. Инициализацию Express и глобальных middleware (helmet, static, body-parser) * UI — отдельный слой, работает через API.
* 2. Инициализацию auth (initAuth) и CSRF (initCsrf)
* 3. Подключение роутеров из src/routes/ с передачей зависимостей
* 4. Старт HTTP-сервера
* *
* Бизнес-логика — в src/routes/*.js * Зависимости:
* DB-запросы — в src/queries.js * src/auth.js — initAuth (валидация Bearer)
* Auth/JWT — в src/auth.js * src/queries.js — все SQL-запросы
* Валидация — в src/validators.js * src/api/ — REST API router
* Конфиг/хелперы — в src/config.js
*/ */
'use strict'; 'use strict';
const express = require('express'); const express = require('express');
const cookieParser = require('cookie-parser');
const path = require('path');
const helmet = require('helmet'); const helmet = require('helmet');
require('dotenv').config(); require('dotenv').config();
const { checkConnection } = require('./src/db'); const { checkConnection } = require('./src/db');
const { initAuth, requireAdmin } = require('./src/auth'); const { initAuth } = require('./src/auth');
const { aggregateCIDRs } = require('./src/validators');
const q = require('./src/queries'); const q = require('./src/queries');
const { MOCK_USERS, backUrl, APP_VERSION } = require('./src/config'); const { createApiRouter } = require('./src/api/index');
const { mutationLimiter, exportLimiter, authLimiter } = require('./src/middleware/rateLimit');
const { initCsrf } = require('./src/middleware/csrf');
const { createSessionMiddleware } = require('./src/middleware/session');
const { CSP_DIRECTIVES } = require('./src/middleware/csp');
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
app.set('view engine', 'ejs'); // Безопасные HTTP-заголовки (без CSP браузерного — API не отдаёт HTML)
app.set('views', path.join(__dirname, 'views')); app.use(helmet({ contentSecurityPolicy: false }));
// Версия доступна во всех шаблонах как <%= appVersion %> без передачи вручную. // За reverse-proxy (nginx/k8s ingress)
app.locals.appVersion = APP_VERSION;
// ── Безопасность HTTP-заголовков ───────────────────────────────────────────────
// helmet выставляет все security-заголовки одним вызовом, включая CSP.
// Директивы CSP вынесены в src/middleware/csp.js.
app.use(helmet({ contentSecurityPolicy: { directives: CSP_DIRECTIVES } }));
// За reverse-proxy (nginx/k8s ingress) — доверяем первому хопу для secure cookies.
if (process.env.NODE_ENV === 'production') app.set('trust proxy', 1); if (process.env.NODE_ENV === 'production') app.set('trust proxy', 1);
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.urlencoded({ extended: true, limit: '32kb' }));
app.use(cookieParser());
async function start() { async function start() {
const auth = await initAuth(); const auth = await initAuth();
// ── Сессии ──────────────────────────────────────────────────────────────── // k8s liveness probe
// 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 — простая проверка что сервер жив
app.get('/healthz', (req, res) => res.send('OK')); app.get('/healthz', (req, res) => res.send('OK'));
// JWKS endpoint для валидации токенов внешними сервисами (только в mock-режиме) // JWKS endpoint (только в mock-режиме, для тестов)
if (auth.jwksHandler) app.get('/.well-known/jwks.json', auth.jwksHandler); if (auth.jwksHandler) app.get('/.well-known/jwks.json', auth.jwksHandler);
// GET /login, POST /login, GET /logout. // REST API v1
// 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 })); app.use('/api/v1', createApiRouter({ auth, q }));
// ── Все маршруты ниже требуют авторизации (auth.middleware) ──────────────── // 404 для всего остального
app.use(auth.middleware); app.use((req, res) => res.status(404).json({ error: 'Not found' }));
// GET /export: CIDR текущего пользователя (или всех компаний для admin). // Общий error handler
// Перемещён за 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 // eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => { 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); console.error('Unhandled error:', err);
res.status(500).send('Внутренняя ошибка сервера'); res.status(500).json({ error: 'Internal server error' });
}); });
// ── Старт сервера ─────────────────────────────────────────────────────────────
checkConnection() checkConnection()
.then(() => console.log('DB connected')) .then(() => console.log('DB connected'))
.catch(e => console.error('DB not ready:', e.message)); .catch(e => console.error('DB not ready:', e.message));
@@ -164,7 +59,6 @@ async function start() {
return app; return app;
} }
// Запускаем сервер только если файл запущен напрямую (не через require)
if (require.main === module) { if (require.main === module) {
start() start()
.then(a => a.listen(PORT, () => console.log('Server on port ' + PORT))) .then(a => a.listen(PORT, () => console.log('Server on port ' + PORT)))
@@ -172,4 +66,3 @@ if (require.main === module) {
} }
module.exports = { start }; module.exports = { start };