diff --git a/SETUP_TELEGRAM_BOT.md b/SETUP_TELEGRAM_BOT.md new file mode 100644 index 0000000..ec84bf9 --- /dev/null +++ b/SETUP_TELEGRAM_BOT.md @@ -0,0 +1,119 @@ +# 🔧 Установка Telegram Bot Token + +## Проблема + +Ошибка: `TELEGRAM_BOT_TOKEN не установлен` + +## Решение + +### 1. Получить токен от BotFather + +1. Откройте Telegram +2. Найдите бота [@BotFather](https://t.me/BotFather) +3. Отправьте команду `/newbot` +4. Следуйте инструкциям: + - Введите имя бота (например: `My Nakama Bot`) + - Введите username бота (например: `my_nakama_bot`) +5. Получите токен (формат: `123456789:ABCdefGHIjklMNOpqrsTUVwxyz`) + +### 2. Установить токен на сервере + +#### Вариант A: Через .env файл (Рекомендуется) + +```bash +ssh root@ваш_IP +cd /var/www/nakama/backend + +# Создать .env файл если его нет +nano .env + +# Добавить строку: +TELEGRAM_BOT_TOKEN=ваш_токен_от_BotFather + +# Сохранить: Ctrl+O, Enter, Ctrl+X +``` + +#### Вариант B: Через PM2 ecosystem + +```bash +ssh root@ваш_IP +cd /var/www/nakama + +# Создать ecosystem.config.js +nano ecosystem.config.js +``` + +Добавьте: +```javascript +module.exports = { + apps: [{ + name: 'nakama-backend', + script: './backend/server.js', + env: { + NODE_ENV: 'production', + TELEGRAM_BOT_TOKEN: 'ваш_токен_от_BotFather', + // ... другие переменные + } + }] +}; +``` + +#### Вариант C: Через export (Временное решение) + +```bash +ssh root@ваш_IP +export TELEGRAM_BOT_TOKEN="ваш_токен_от_BotFather" +pm2 restart nakama-backend --update-env +``` + +### 3. Перезапустить backend + +```bash +pm2 restart nakama-backend +``` + +### 4. Проверить логи + +```bash +pm2 logs nakama-backend --lines 20 +``` + +Должно быть: +``` +✅ Telegram Bot инициализирован +``` + +**Не должно быть:** +``` +⚠️ TELEGRAM_BOT_TOKEN не установлен! +``` + +## Проверка работы + +После установки токена: +1. Откройте приложение +2. Попробуйте отправить изображение в Telegram из поиска +3. Изображение должно прийти в личные сообщения с ботом + +## Важно + +- Токен должен быть в формате: `123456789:ABCdefGHIjklMNOpqrsTUVwxyz` +- **НЕ** добавляйте кавычки в .env файле! +- **НЕ** делитесь токеном публично! +- Токен должен быть установлен до запуска бота + +## Пример .env файла + +```env +NODE_ENV=production +PORT=3000 +MONGODB_URI=mongodb://localhost:27017/nakama +TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz +FRONTEND_URL=https://nakama.glpshchn.ru +``` + +## Дополнительная информация + +- [Telegram Bot API](https://core.telegram.org/bots/api) +- [BotFather](https://t.me/BotFather) + diff --git a/backend/bot.js b/backend/bot.js index c8f0c88..03fc3c9 100644 --- a/backend/bot.js +++ b/backend/bot.js @@ -130,7 +130,7 @@ async function sendPhotosToUser(userId, photos) { media.push({ type: 'photo', media: photoUrl, - caption: index === 0 ? `Из NakamaSpace\n${batch.length} фото` : undefined, + caption: index === 0 ? `Из NakamaHost\n${batch.length} фото` : undefined, parse_mode: 'HTML' }); } else { @@ -140,7 +140,7 @@ async function sendPhotosToUser(userId, photos) { media.push({ type: 'photo', media: photoUrl, - caption: index === 0 ? `Из NakamaSpace\n${batch.length} фото` : undefined, + caption: index === 0 ? `Из NakamaHost\n${batch.length} фото` : undefined, parse_mode: 'HTML' }); } @@ -167,7 +167,7 @@ async function handleWebAppData(userId, dataString) { const data = JSON.parse(dataString); if (data.action === 'send_image') { - const caption = `Из NakamaSpace\n\n${data.caption || ''}`; + const caption = `Из NakamaHost\n\n${data.caption || ''}`; await sendPhotoToUser(userId, data.url, caption); return { success: true, message: 'Изображение отправлено!' }; } diff --git a/backend/check-env.js b/backend/check-env.js new file mode 100644 index 0000000..036ade2 --- /dev/null +++ b/backend/check-env.js @@ -0,0 +1,61 @@ +// Скрипт для проверки переменных окружения +const dotenv = require('dotenv'); +const path = require('path'); +const fs = require('fs'); + +console.log('🔍 Проверка переменных окружения...\n'); + +// Проверить наличие .env файла +const envPath = path.join(__dirname, '.env'); +console.log(`📁 Путь к .env: ${envPath}`); + +if (fs.existsSync(envPath)) { + console.log('✅ Файл .env найден\n'); + + // Загрузить .env + dotenv.config({ path: envPath }); + + // Проверить TELEGRAM_BOT_TOKEN + const token = process.env.TELEGRAM_BOT_TOKEN; + if (token) { + console.log('✅ TELEGRAM_BOT_TOKEN установлен'); + console.log(` Токен: ${token.substring(0, 10)}...${token.substring(token.length - 4)}`); + console.log(` Длина: ${token.length} символов`); + } else { + console.log('❌ TELEGRAM_BOT_TOKEN НЕ установлен!'); + console.log('\n📝 Проверьте .env файл:'); + console.log(' Должна быть строка: TELEGRAM_BOT_TOKEN=ваш_токен'); + console.log(' Без кавычек и пробелов вокруг ='); + } + + // Показать все переменные из .env + console.log('\n📋 Все переменные из .env:'); + const envContent = fs.readFileSync(envPath, 'utf-8'); + const lines = envContent.split('\n').filter(line => line.trim() && !line.startsWith('#')); + lines.forEach(line => { + const key = line.split('=')[0].trim(); + const value = line.split('=').slice(1).join('=').trim(); + if (key === 'TELEGRAM_BOT_TOKEN') { + console.log(` ${key}=${value.substring(0, 10)}...${value.substring(value.length - 4)}`); + } else { + console.log(` ${key}=${value.substring(0, 20)}...`); + } + }); +} else { + console.log('❌ Файл .env НЕ найден!'); + console.log(`\n📝 Создайте файл .env в: ${envPath}`); + console.log(' Добавьте строку: TELEGRAM_BOT_TOKEN=ваш_токен'); +} + +console.log('\n🔍 Проверка переменных окружения системы:'); +const systemToken = process.env.TELEGRAM_BOT_TOKEN; +if (systemToken) { + console.log('✅ TELEGRAM_BOT_TOKEN найден в системных переменных'); +} else { + console.log('⚠️ TELEGRAM_BOT_TOKEN не найден в системных переменных'); +} + +console.log('\n💡 Для PM2 нужно использовать:'); +console.log(' pm2 restart nakama-backend --update-env'); +console.log(' или добавить в ecosystem.config.js'); + diff --git a/backend/config/index.js b/backend/config/index.js index 6a47a58..7a627b7 100644 --- a/backend/config/index.js +++ b/backend/config/index.js @@ -1,4 +1,5 @@ // Централизованная конфигурация приложения +// Важно: dotenv.config() должен быть вызван ДО этого файла module.exports = { // Сервер diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 9acef9c..c4bad73 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -1,5 +1,8 @@ const crypto = require('crypto'); const User = require('../models/User'); +const { validateTelegramId } = require('./validator'); +const { logSecurityEvent } = require('./logger'); +const config = require('../config'); // Проверка Telegram Init Data function validateTelegramWebAppData(initData, botToken) { @@ -84,17 +87,29 @@ const authenticate = async (req, res, next) => { req.telegramUser = telegramUser; - // Проверка подписи Telegram (только в production и если есть токен) - if (process.env.NODE_ENV === 'production' && process.env.TELEGRAM_BOT_TOKEN) { - const isValid = validateTelegramWebAppData(initData, process.env.TELEGRAM_BOT_TOKEN); + // Валидация Telegram ID + if (!validateTelegramId(telegramUser.id)) { + logSecurityEvent('INVALID_TELEGRAM_ID', req, { telegramId: telegramUser.id }); + return res.status(401).json({ error: 'Неверный ID пользователя' }); + } + + // Проверка подписи Telegram (строгая проверка в production) + if (config.telegramBotToken) { + const isValid = validateTelegramWebAppData(initData, config.telegramBotToken); if (!isValid) { - console.warn('⚠️ Неверная подпись Telegram Init Data для пользователя:', telegramUser.id); - // В production можно либо отклонить, либо пропустить с предупреждением - // Для строгой проверки раскомментируйте: - // return res.status(401).json({ error: 'Неверные данные авторизации' }); + logSecurityEvent('INVALID_TELEGRAM_SIGNATURE', req, { + telegramId: telegramUser.id, + hasToken: !!config.telegramBotToken + }); + + // В production строгая проверка + if (config.isProduction()) { + return res.status(401).json({ error: 'Неверные данные авторизации' }); + } } - } else if (process.env.NODE_ENV === 'production') { + } else if (config.isProduction()) { + logSecurityEvent('MISSING_BOT_TOKEN', req); console.warn('⚠️ TELEGRAM_BOT_TOKEN не установлен, проверка подписи пропущена'); } diff --git a/backend/middleware/errorHandler.js b/backend/middleware/errorHandler.js new file mode 100644 index 0000000..04bfbd4 --- /dev/null +++ b/backend/middleware/errorHandler.js @@ -0,0 +1,76 @@ +const config = require('../config'); + +// Централизованная обработка ошибок +const errorHandler = (err, req, res, next) => { + // Логирование ошибки + console.error('❌ Ошибка:', { + message: err.message, + stack: config.isDevelopment() ? err.stack : undefined, + path: req.path, + method: req.method, + ip: req.ip, + user: req.user?.id || 'anonymous' + }); + + // Определение типа ошибки и статус кода + let statusCode = err.statusCode || err.status || 500; + let message = err.message || 'Внутренняя ошибка сервера'; + + // Обработка специфических ошибок + if (err.name === 'ValidationError') { + statusCode = 400; + message = 'Ошибка валидации данных'; + } else if (err.name === 'CastError') { + statusCode = 400; + message = 'Неверный формат данных'; + } else if (err.name === 'MongoError' && err.code === 11000) { + statusCode = 409; + message = 'Дубликат записи'; + } else if (err.name === 'MulterError') { + statusCode = 400; + if (err.code === 'LIMIT_FILE_SIZE') { + message = 'Файл слишком большой'; + } else if (err.code === 'LIMIT_FILE_COUNT') { + message = 'Слишком много файлов'; + } else { + message = 'Ошибка загрузки файла'; + } + } else if (err.name === 'AxiosError') { + statusCode = 502; + message = 'Ошибка внешнего сервиса'; + } + + // Отправка ответа + res.status(statusCode).json({ + success: false, + error: message, + ...(config.isDevelopment() && { stack: err.stack }) + }); +}; + +// Обработка 404 +const notFoundHandler = (req, res, next) => { + res.status(404).json({ + success: false, + error: 'Маршрут не найден' + }); +}; + +// Обработка необработанных промисов +process.on('unhandledRejection', (reason, promise) => { + console.error('❌ Unhandled Rejection:', reason); + // В production можно отправить уведомление +}); + +// Обработка необработанных исключений +process.on('uncaughtException', (error) => { + console.error('❌ Uncaught Exception:', error); + // Graceful shutdown + process.exit(1); +}); + +module.exports = { + errorHandler, + notFoundHandler +}; + diff --git a/backend/middleware/logger.js b/backend/middleware/logger.js new file mode 100644 index 0000000..1d723e1 --- /dev/null +++ b/backend/middleware/logger.js @@ -0,0 +1,99 @@ +const fs = require('fs'); +const path = require('path'); +const config = require('../config'); + +// Создать директорию для логов если её нет +const logsDir = path.join(__dirname, '../logs'); +if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); +} + +// Функция для логирования +const log = (level, message, data = {}) => { + const timestamp = new Date().toISOString(); + const logMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`; + + // Логирование в консоль + if (level === 'error') { + console.error(logMessage, data); + } else if (level === 'warn') { + console.warn(logMessage, data); + } else { + console.log(logMessage, data); + } + + // Логирование в файл (только в production) + if (config.isProduction()) { + const logFile = path.join(logsDir, `${level}.log`); + const fileMessage = `${logMessage} ${JSON.stringify(data)}\n`; + + fs.appendFile(logFile, fileMessage, (err) => { + if (err) { + console.error('Ошибка записи в лог файл:', err); + } + }); + } +}; + +// Middleware для логирования запросов +const requestLogger = (req, res, next) => { + const start = Date.now(); + + // Логировать после завершения запроса + res.on('finish', () => { + const duration = Date.now() - start; + const logData = { + method: req.method, + path: req.path, + status: res.statusCode, + duration: `${duration}ms`, + ip: req.ip, + userAgent: req.get('user-agent'), + userId: req.user?.id || 'anonymous' + }; + + if (res.statusCode >= 400) { + log('error', 'Request failed', logData); + } else if (res.statusCode >= 300) { + log('warn', 'Request redirect', logData); + } else { + log('info', 'Request completed', logData); + } + }); + + next(); +}; + +// Логирование подозрительной активности +const logSecurityEvent = (type, req, details = {}) => { + const securityData = { + type, + ip: req.ip, + userAgent: req.get('user-agent'), + path: req.path, + method: req.method, + userId: req.user?.id || 'anonymous', + ...details + }; + + log('warn', 'Security event', securityData); + + // В production можно отправить уведомление + if (config.isProduction()) { + const securityLogFile = path.join(logsDir, 'security.log'); + const message = `[${new Date().toISOString()}] [SECURITY] ${type}: ${JSON.stringify(securityData)}\n`; + + fs.appendFile(securityLogFile, message, (err) => { + if (err) { + console.error('Ошибка записи в security лог:', err); + } + }); + } +}; + +module.exports = { + log, + requestLogger, + logSecurityEvent +}; + diff --git a/backend/middleware/security.js b/backend/middleware/security.js new file mode 100644 index 0000000..b080a83 --- /dev/null +++ b/backend/middleware/security.js @@ -0,0 +1,100 @@ +const helmet = require('helmet'); +const mongoSanitize = require('express-mongo-sanitize'); +const xss = require('xss-clean'); +const hpp = require('hpp'); +const rateLimit = require('express-rate-limit'); +const config = require('../config'); + +// Настройка Helmet для безопасности headers +const helmetConfig = helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + scriptSrc: ["'self'", "https://telegram.org", "'unsafe-inline'"], + imgSrc: ["'self'", "data:", "https:", "blob:"], + connectSrc: ["'self'", "https://api.telegram.org", "https://e621.net", "https://gelbooru.com"], + fontSrc: ["'self'", "data:"], + objectSrc: ["'none'"], + // Запретить использование консоли и eval + scriptSrcAttr: ["'none'"], + baseUri: ["'self'"], + formAction: ["'self'"], + frameAncestors: ["'none'"], + upgradeInsecureRequests: config.isProduction() ? [] : null, + }, + }, + crossOriginEmbedderPolicy: false, + crossOriginResourcePolicy: { policy: "cross-origin" }, + // Запретить использование консоли + noSniff: true, + xssFilter: true +}); + +// Sanitize MongoDB операторы (защита от NoSQL injection) +const sanitizeMongo = mongoSanitize({ + replaceWith: '_', + onSanitize: ({ req, key }) => { + console.warn(`⚠️ Sanitized MongoDB operator in ${req.path} at key: ${key}`); + } +}); + +// XSS защита +const xssProtection = xss(); + +// Защита от HTTP Parameter Pollution +const hppProtection = hpp(); + +// Строгий rate limit для авторизации +const strictAuthLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 минут + max: 5, // 5 попыток + message: 'Слишком много попыток авторизации, попробуйте позже', + standardHeaders: true, + legacyHeaders: false, + skipSuccessfulRequests: true, +}); + +// Rate limit для создания постов (защита от спама) +const strictPostLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 1 час + max: 10, // 10 постов + message: 'Превышен лимит создания постов, попробуйте позже', + standardHeaders: true, + legacyHeaders: false, + skipSuccessfulRequests: true, +}); + +// Rate limit для файлов +const fileUploadLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 1 час + max: 50, // 50 загрузок + message: 'Превышен лимит загрузки файлов', + standardHeaders: true, + legacyHeaders: false, +}); + +// DDoS защита - агрессивный rate limit +const ddosProtection = rateLimit({ + windowMs: 60 * 1000, // 1 минута + max: 100, // 100 запросов в минуту + message: 'Слишком много запросов, попробуйте позже', + standardHeaders: true, + legacyHeaders: false, + skip: (req) => { + // Пропускать health check + return req.path === '/health'; + } +}); + +module.exports = { + helmetConfig, + sanitizeMongo, + xssProtection, + hppProtection, + strictAuthLimiter, + strictPostLimiter, + fileUploadLimiter, + ddosProtection +}; + diff --git a/backend/middleware/validator.js b/backend/middleware/validator.js new file mode 100644 index 0000000..0c6e2fb --- /dev/null +++ b/backend/middleware/validator.js @@ -0,0 +1,156 @@ +const validator = require('validator'); + +// Валидация и санитизация входных данных +const sanitizeInput = (req, res, next) => { + // Рекурсивная функция для очистки объекта + const sanitizeObject = (obj) => { + if (typeof obj !== 'object' || obj === null) { + return typeof obj === 'string' ? validator.escape(obj) : obj; + } + + if (Array.isArray(obj)) { + return obj.map(item => sanitizeObject(item)); + } + + const sanitized = {}; + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + const value = obj[key]; + if (typeof value === 'string') { + sanitized[key] = validator.escape(validator.trim(value)); + } else { + sanitized[key] = sanitizeObject(value); + } + } + } + return sanitized; + }; + + // Очистка body + if (req.body) { + req.body = sanitizeObject(req.body); + } + + // Очистка query + if (req.query) { + req.query = sanitizeObject(req.query); + } + + // Очистка params (только строковые значения) + if (req.params) { + for (const key in req.params) { + if (typeof req.params[key] === 'string') { + req.params[key] = validator.escape(req.params[key]); + } + } + } + + next(); +}; + +// Валидация URL +const validateUrl = (url) => { + if (!url || typeof url !== 'string') { + return false; + } + + // Проверка на path traversal + if (url.includes('..') || url.includes('./') || url.includes('../')) { + return false; + } + + // Проверка на валидный URL + return validator.isURL(url, { + protocols: ['http', 'https'], + require_protocol: true, + require_valid_protocol: true + }); +}; + +// Валидация Telegram User ID +const validateTelegramId = (id) => { + if (!id || typeof id !== 'number' && typeof id !== 'string') { + return false; + } + + const numId = typeof id === 'string' ? parseInt(id, 10) : id; + return !isNaN(numId) && numId > 0 && numId < Number.MAX_SAFE_INTEGER; +}; + +// Валидация контента поста +const validatePostContent = (content) => { + if (!content || typeof content !== 'string') { + return false; + } + + // Максимальная длина + if (content.length > 5000) { + return false; + } + + // Проверка на опасные паттерны + const dangerousPatterns = [ + /