nakama/backend/server.js

342 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
const dotenv = require('dotenv');
const path = require('path');
const http = require('http');
// Загрузить переменные окружения ДО импорта config
dotenv.config({ path: path.join(__dirname, '.env') });
const { generalLimiter } = require('./middleware/rateLimiter');
const { initRedis } = require('./utils/redis');
const { initWebSocket } = require('./websocket');
const { initMinioClient, checkConnection: checkMinioConnection } = require('./utils/minio');
const { printMinioConfig } = require('./utils/minioDebug');
const config = require('./config');
// Security middleware
const {
helmetConfig,
sanitizeMongo,
xssProtection,
hppProtection,
ddosProtection
} = require('./middleware/security');
const { sanitizeInput } = require('./middleware/validator');
const { requestLogger, logSecurityEvent, log, logSuccess, logError } = require('./middleware/logger');
const { errorHandler, notFoundHandler } = require('./middleware/errorHandler');
const { scheduleAvatarUpdates } = require('./jobs/avatarUpdater');
const { startServerMonitorBot } = require('./bots/serverMonitor');
const ERROR_SUPPORT_SUFFIX = ' Сообщите об ошибке в https://t.me/NakamaReportbot';
const app = express();
const server = http.createServer(app);
// Trust proxy для правильного IP (для rate limiting за nginx/cloudflare)
if (config.isProduction()) {
app.set('trust proxy', 1);
}
// Security headers (Helmet)
app.use(helmetConfig);
// CORS настройки
const corsOptions = {
origin: config.corsOrigin === '*' ? '*' : config.corsOrigin.split(','),
credentials: true,
optionsSuccessStatus: 200,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'x-telegram-init-data'],
maxAge: 86400 // 24 часа
};
app.use(cors(corsOptions));
// Body parsing с ограничениями
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Security middleware
app.use(sanitizeMongo); // Защита от NoSQL injection
app.use(xssProtection); // Защита от XSS
app.use(hppProtection); // Защита от HTTP Parameter Pollution
// Input sanitization
app.use(sanitizeInput);
// Request logging
app.use(requestLogger);
// Static files
app.use('/uploads', express.static(path.join(__dirname, config.uploadsDir)));
// Дополнение ошибок сообщением о канале связи
app.use((req, res, next) => {
const originalJson = res.json.bind(res);
res.json = (body) => {
const appendSuffix = (obj) => {
if (!obj || typeof obj !== 'object') {
return;
}
// Список ошибок, к которым НЕ нужно добавлять суффикс
const skipSuffixMessages = [
'Загрузите хотя бы одно изображение',
'Не удалось опубликовать в канал',
'Публиковать в канал могут только админы',
'Требуется авторизация',
'Требуются права',
'Неверный код подтверждения',
'Код подтверждения истёк',
'Номер админа уже занят',
'Пользователь не найден',
'Администратор не найден'
];
const shouldSkipSuffix = (text) => {
if (!text || typeof text !== 'string') return false;
return skipSuffixMessages.some(msg => text.includes(msg));
};
if (typeof obj.error === 'string' && !obj.error.includes(ERROR_SUPPORT_SUFFIX) && !shouldSkipSuffix(obj.error)) {
obj.error += ERROR_SUPPORT_SUFFIX;
}
if (typeof obj.message === 'string' && res.statusCode >= 400 && !obj.message.includes(ERROR_SUPPORT_SUFFIX) && !shouldSkipSuffix(obj.message)) {
obj.message += ERROR_SUPPORT_SUFFIX;
}
if (Array.isArray(obj.errors)) {
obj.errors = obj.errors.map((item) => {
if (typeof item === 'string') {
if (shouldSkipSuffix(item)) return item;
return item.includes(ERROR_SUPPORT_SUFFIX) ? item : `${item}${ERROR_SUPPORT_SUFFIX}`;
}
if (item && typeof item === 'object' && typeof item.message === 'string' && !item.message.includes(ERROR_SUPPORT_SUFFIX) && !shouldSkipSuffix(item.message)) {
return { ...item, message: `${item.message}${ERROR_SUPPORT_SUFFIX}` };
}
return item;
});
}
};
if (Array.isArray(body)) {
body.forEach((item) => appendSuffix(item));
} else {
appendSuffix(body);
}
return originalJson(body);
};
next();
});
// DDoS защита (применяется перед другими rate limiters)
app.use(ddosProtection);
// Rate limiting
app.use('/api', generalLimiter);
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'ok',
environment: config.nodeEnv,
timestamp: new Date().toISOString()
});
});
// MongoDB подключение
log('info', 'Подключение к MongoDB...', { uri: config.mongoUri.replace(/\/\/.*@/, '//***@') });
mongoose.connect(config.mongoUri)
.then(async () => {
const dbHost = config.mongoUri.includes('localhost') || config.mongoUri.includes('127.0.0.1')
? 'Local'
: config.mongoUri.match(/\/\/([^:\/]+)/)?.[1] || 'Remote';
logSuccess('MongoDB успешно подключена', {
host: dbHost,
database: mongoose.connection.name,
uri: config.mongoUri.replace(/\/\/.*@/, '//***@')
});
// MongoDB события
mongoose.connection.on('error', (err) => {
logError('MongoDB connection error', err);
});
mongoose.connection.on('disconnected', () => {
log('warn', 'MongoDB отключена');
});
mongoose.connection.on('reconnected', () => {
logSuccess('MongoDB переподключена');
});
// Инициализировать Redis (опционально)
if (config.redisUrl) {
try {
log('info', 'Подключение к Redis...');
await initRedis();
logSuccess('Redis подключен', { url: config.redisUrl.replace(/\/\/.*@/, '//***@') });
} catch (err) {
log('warn', 'Redis недоступен, работаем без кэша', {
error: err.message
});
}
} else {
log('info', 'Redis не настроен, кэширование отключено');
}
// Инициализировать MinIO (опционально)
if (config.minio.enabled) {
try {
log('info', 'Инициализация MinIO...');
// Вывести конфигурацию и проверки
printMinioConfig();
initMinioClient();
const minioOk = await checkMinioConnection();
if (minioOk) {
logSuccess('MinIO успешно подключен', {
endpoint: `${config.minio.endpoint}:${config.minio.port}`,
bucket: config.minio.bucket,
ssl: config.minio.useSSL
});
} else {
log('warn', 'MinIO недоступен, используется локальное хранилище');
}
} catch (err) {
logError('MinIO initialization failed', err, {
endpoint: `${config.minio.endpoint}:${config.minio.port}`
});
log('warn', 'Используется локальное хранилище');
}
} else {
log('info', 'MinIO отключен, используется локальное хранилище');
}
})
.catch(err => {
logError('MongoDB connection failed', err, {
uri: config.mongoUri.replace(/\/\/.*@/, '//***@'),
critical: true
});
console.error('\n❌ Не удалось подключиться к MongoDB!');
console.error(` Проверьте MONGODB_URI в .env: ${config.mongoUri.replace(/\/\/.*@/, '//***@')}`);
console.error(` Убедитесь что MongoDB запущена и доступна\n`);
process.exit(1);
});
// Routes
app.use('/api/auth', require('./routes/auth'));
app.use('/api/posts', require('./routes/posts'));
app.use('/api/users', require('./routes/users'));
app.use('/api/notifications', require('./routes/notifications'));
app.use('/api/search', require('./routes/search'));
app.use('/api/search/posts', require('./routes/postSearch'));
app.use('/api/moderation', require('./routes/moderation'));
app.use('/api/statistics', require('./routes/statistics'));
app.use('/api/bot', require('./routes/bot'));
app.use('/api/mod-app', require('./routes/modApp'));
app.use('/api/minio', require('./routes/minio-test'));
// Базовый роут
app.get('/', (req, res) => {
res.json({ message: 'Nakama API работает' });
});
// 404 handler
app.use(notFoundHandler);
// Error handler (должен быть последним)
app.use(errorHandler);
// Инициализировать WebSocket
initWebSocket(server);
scheduleAvatarUpdates();
startServerMonitorBot();
// Обработка необработанных ошибок
process.on('uncaughtException', (error) => {
logError('Uncaught Exception', error, {
critical: true
});
// Дать время записать логи
setTimeout(() => process.exit(1), 1000);
});
process.on('unhandledRejection', (reason, promise) => {
logError('Unhandled Rejection', new Error(reason), {
promise: promise.toString(),
critical: true
});
});
// Graceful shutdown
process.on('SIGTERM', () => {
log('warn', 'SIGTERM получен, закрываем сервер...');
server.close(() => {
logSuccess('Сервер закрыт');
mongoose.connection.close(false, () => {
logSuccess('MongoDB соединение закрыто');
process.exit(0);
});
});
});
process.on('SIGINT', () => {
log('warn', 'SIGINT получен (Ctrl+C), закрываем сервер...');
server.close(() => {
logSuccess('Сервер закрыт');
mongoose.connection.close(false, () => {
logSuccess('MongoDB соединение закрыто');
process.exit(0);
});
});
});
server.listen(config.port, '0.0.0.0', () => {
console.log('\n' + '='.repeat(60));
logSuccess('Сервер успешно запущен', {
port: config.port,
environment: config.nodeEnv,
api: `http://0.0.0.0:${config.port}/api`,
frontend: config.frontendUrl,
mongodb: config.mongoUri.replace(/\/\/.*@/, '//***@'), // Скрыть пароль
minioEnabled: config.minio.enabled,
redisEnabled: !!config.redisUrl
});
console.log(` 🌐 API: http://0.0.0.0:${config.port}/api`);
console.log(` 📦 MongoDB: ${config.mongoUri.includes('localhost') ? 'Local' : 'Remote'}`);
console.log(` ⚙️ Environment: ${config.nodeEnv}`);
if (config.minio.enabled) {
console.log(` 🗄️ MinIO: ${config.minio.endpoint}:${config.minio.port}`);
}
if (config.redisUrl) {
console.log(` 🔴 Redis: Connected`);
}
if (!config.telegramBotToken) {
log('warn', 'TELEGRAM_BOT_TOKEN не установлен!', {
path: path.join(__dirname, '.env'),
envSet: !!process.env.TELEGRAM_BOT_TOKEN
});
} else {
logSuccess('Telegram Bot инициализирован', {
token: config.telegramBotToken.substring(0, 10) + '...'
});
}
console.log('='.repeat(60) + '\n');
});