Update files

This commit is contained in:
glpshchn 2025-11-05 00:51:05 +03:00
parent 28e6cfb763
commit 51530709a6
40 changed files with 2827 additions and 256 deletions

119
SETUP_TELEGRAM_BOT.md Normal file
View File

@ -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)

View File

@ -130,7 +130,7 @@ async function sendPhotosToUser(userId, photos) {
media.push({
type: 'photo',
media: photoUrl,
caption: index === 0 ? `<b>Из NakamaSpace</b>\n${batch.length} фото` : undefined,
caption: index === 0 ? `<b>Из NakamaHost</b>\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 ? `<b>Из NakamaSpace</b>\n${batch.length} фото` : undefined,
caption: index === 0 ? `<b>Из NakamaHost</b>\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 = `<b>Из NakamaSpace</b>\n\n${data.caption || ''}`;
const caption = `<b>Из NakamaHost</b>\n\n${data.caption || ''}`;
await sendPhotoToUser(userId, data.url, caption);
return { success: true, message: 'Изображение отправлено!' };
}

61
backend/check-env.js Normal file
View File

@ -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');

View File

@ -1,4 +1,5 @@
// Централизованная конфигурация приложения
// Важно: dotenv.config() должен быть вызван ДО этого файла
module.exports = {
// Сервер

View File

@ -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 не установлен, проверка подписи пропущена');
}

View File

@ -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
};

View File

@ -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
};

View File

@ -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
};

View File

@ -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 = [
/<script/i,
/javascript:/i,
/on\w+\s*=/i,
/data:text\/html/i
];
return !dangerousPatterns.some(pattern => pattern.test(content));
};
// Валидация тегов
const validateTags = (tags) => {
if (!Array.isArray(tags)) {
return false;
}
// Максимум 20 тегов
if (tags.length > 20) {
return false;
}
// Каждый тег должен быть строкой и не превышать 50 символов
return tags.every(tag =>
typeof tag === 'string' &&
tag.length > 0 &&
tag.length <= 50 &&
/^[a-zA-Z0-9_\-]+$/.test(tag) // Только буквы, цифры, подчеркивания и дефисы
);
};
// Валидация изображений
const validateImageUrl = (url) => {
if (!url || typeof url !== 'string') {
return false;
}
// Разрешенные домены
const allowedDomains = [
'e621.net',
'static1.e621.net',
'gelbooru.com',
'img3.gelbooru.com',
'img2.gelbooru.com',
'img1.gelbooru.com',
'simg3.gelbooru.com',
'simg4.gelbooru.com'
];
try {
const urlObj = new URL(url);
return allowedDomains.some(domain => urlObj.hostname.includes(domain));
} catch {
return false;
}
};
module.exports = {
sanitizeInput,
validateUrl,
validateTelegramId,
validatePostContent,
validateTags,
validateImageUrl
};

View File

@ -11,6 +11,9 @@ const CommentSchema = new mongoose.Schema({
required: true,
maxlength: 500
},
editedAt: {
type: Date
},
createdAt: {
type: Date,
default: Date.now

View File

@ -1,8 +1,130 @@
const express = require('express');
const router = express.Router();
const { authenticate } = require('../middleware/auth');
const crypto = require('crypto');
const User = require('../models/User');
const config = require('../config');
const { validateTelegramId } = require('../middleware/validator');
const { logSecurityEvent } = require('../middleware/logger');
const { strictAuthLimiter } = require('../middleware/security');
// Проверка подписи Telegram OAuth (Login Widget)
function validateTelegramOAuth(authData, botToken) {
if (!authData || !authData.hash) {
return false;
}
const { hash, ...data } = authData;
const dataCheckString = Object.keys(data)
.sort()
.map(key => `${key}=${data[key]}`)
.join('\n');
const secretKey = crypto
.createHmac('sha256', 'WebAppData')
.update(botToken)
.digest();
const calculatedHash = crypto
.createHmac('sha256', secretKey)
.update(dataCheckString)
.digest('hex');
return calculatedHash === hash;
}
// Авторизация через Telegram OAuth (Login Widget)
router.post('/oauth', strictAuthLimiter, async (req, res) => {
try {
const { user: telegramUser, auth_date, hash } = req.body;
if (!telegramUser || !auth_date || !hash) {
logSecurityEvent('INVALID_OAUTH_DATA', req);
return res.status(400).json({ error: 'Неверные данные авторизации' });
}
// Валидация Telegram ID
if (!validateTelegramId(telegramUser.id)) {
logSecurityEvent('INVALID_TELEGRAM_ID', req, { telegramId: telegramUser.id });
return res.status(400).json({ error: 'Неверный ID пользователя' });
}
// Проверка подписи Telegram (строгая проверка в production)
if (config.telegramBotToken) {
const authData = {
id: telegramUser.id,
first_name: telegramUser.first_name,
last_name: telegramUser.last_name,
username: telegramUser.username,
photo_url: telegramUser.photo_url,
auth_date: auth_date,
hash: hash
};
const isValid = validateTelegramOAuth(authData, config.telegramBotToken);
if (!isValid) {
logSecurityEvent('INVALID_OAUTH_SIGNATURE', req, { telegramId: telegramUser.id });
// В production строгая проверка
if (config.isProduction()) {
return res.status(401).json({ error: 'Неверная подпись Telegram OAuth' });
}
}
}
// Найти или создать пользователя
let user = await User.findOne({ telegramId: telegramUser.id.toString() });
if (!user) {
user = new User({
telegramId: telegramUser.id.toString(),
username: telegramUser.username || telegramUser.first_name,
firstName: telegramUser.first_name,
lastName: telegramUser.last_name,
photoUrl: telegramUser.photo_url
});
await user.save();
console.log(`✅ Создан новый пользователь через OAuth: ${user.username}`);
} else {
// Обновить данные пользователя
user.username = telegramUser.username || telegramUser.first_name;
user.firstName = telegramUser.first_name;
user.lastName = telegramUser.last_name;
user.photoUrl = telegramUser.photo_url;
await user.save();
}
// Получить полные данные пользователя
const populatedUser = await User.findById(user._id).populate([
{ path: 'followers', select: 'username firstName lastName photoUrl' },
{ path: 'following', select: 'username firstName lastName photoUrl' }
]);
res.json({
success: true,
user: {
id: populatedUser._id,
telegramId: populatedUser.telegramId,
username: populatedUser.username,
firstName: populatedUser.firstName,
lastName: populatedUser.lastName,
photoUrl: populatedUser.photoUrl,
bio: populatedUser.bio,
role: populatedUser.role,
followersCount: populatedUser.followers.length,
followingCount: populatedUser.following.length,
settings: populatedUser.settings,
banned: populatedUser.banned
}
});
} catch (error) {
console.error('Ошибка OAuth:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Проверка авторизации и получение данных пользователя
const { authenticate } = require('../middleware/auth');
router.post('/verify', authenticate, async (req, res) => {
try {
const user = await req.user.populate([

View File

@ -6,6 +6,9 @@ const fs = require('fs');
const { authenticate } = require('../middleware/auth');
const { postCreationLimiter, interactionLimiter } = require('../middleware/rateLimiter');
const { searchLimiter } = require('../middleware/rateLimiter');
const { validatePostContent, validateTags, validateImageUrl } = require('../middleware/validator');
const { logSecurityEvent } = require('../middleware/logger');
const { strictPostLimiter, fileUploadLimiter } = require('../middleware/security');
const Post = require('../models/Post');
const Notification = require('../models/Notification');
const { extractHashtags } = require('../utils/hashtags');
@ -29,10 +32,26 @@ const upload = multer({
storage: storage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
fileFilter: (req, file, cb) => {
// Запрещенные расширения (исполняемые файлы)
const forbiddenExts = ['.exe', '.bat', '.cmd', '.sh', '.ps1', '.js', '.jar', '.app', '.dmg', '.deb', '.rpm', '.msi', '.scr', '.vbs', '.com', '.pif', '.cpl'];
const ext = path.extname(file.originalname).toLowerCase();
// Проверить на запрещенные расширения
if (forbiddenExts.includes(ext)) {
return cb(new Error('Запрещенный тип файла'));
}
// Разрешенные типы изображений
const allowedTypes = /jpeg|jpg|png|gif|webp/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const extname = allowedTypes.test(ext);
const mimetype = allowedTypes.test(file.mimetype);
// Дополнительная проверка MIME типа
const allowedMimes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
if (!allowedMimes.includes(file.mimetype)) {
return cb(new Error('Только изображения разрешены'));
}
if (mimetype && extname) {
return cb(null, true);
} else {
@ -94,12 +113,29 @@ router.get('/', authenticate, async (req, res) => {
});
// Создать пост
router.post('/', authenticate, postCreationLimiter, uploadMultiple, async (req, res) => {
router.post('/', authenticate, strictPostLimiter, postCreationLimiter, fileUploadLimiter, uploadMultiple, async (req, res) => {
try {
const { content, tags, mentionedUsers, isNSFW, externalImages } = req.body;
// Валидация контента
if (content && !validatePostContent(content)) {
logSecurityEvent('INVALID_POST_CONTENT', req);
return res.status(400).json({ error: 'Недопустимый контент поста' });
}
// Проверка тегов
const parsedTags = JSON.parse(tags || '[]');
let parsedTags = [];
try {
parsedTags = JSON.parse(tags || '[]');
} catch (e) {
return res.status(400).json({ error: 'Неверный формат тегов' });
}
if (!validateTags(parsedTags)) {
logSecurityEvent('INVALID_TAGS', req, { tags: parsedTags });
return res.status(400).json({ error: 'Недопустимые теги' });
}
if (!parsedTags.length) {
return res.status(400).json({ error: 'Теги обязательны' });
}
@ -117,10 +153,29 @@ router.post('/', authenticate, postCreationLimiter, uploadMultiple, async (req,
// Внешние изображения (из поиска)
if (externalImages) {
const externalUrls = JSON.parse(externalImages);
let externalUrls = [];
try {
externalUrls = JSON.parse(externalImages);
} catch (e) {
return res.status(400).json({ error: 'Неверный формат внешних изображений' });
}
// Валидация URL изображений
for (const url of externalUrls) {
if (!validateImageUrl(url)) {
logSecurityEvent('INVALID_IMAGE_URL', req, { url });
return res.status(400).json({ error: 'Недопустимый URL изображения' });
}
}
images = [...images, ...externalUrls];
}
// Ограничение на количество изображений
if (images.length > 5) {
return res.status(400).json({ error: 'Максимум 5 изображений в посте' });
}
// Обратная совместимость - imageUrl для первого изображения
const imageUrl = images.length > 0 ? images[0] : null;
@ -236,6 +291,65 @@ router.post('/:id/comment', authenticate, interactionLimiter, async (req, res) =
});
// Редактировать пост
router.put('/:id', authenticate, async (req, res) => {
try {
const { content, tags, isNSFW } = req.body;
const post = await Post.findById(req.params.id);
if (!post) {
return res.status(404).json({ error: 'Пост не найден' });
}
// Проверить права
if (!post.author.equals(req.user._id) && req.user.role !== 'moderator' && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Нет прав на редактирование' });
}
// Валидация контента
if (content !== undefined && !validatePostContent(content)) {
logSecurityEvent('INVALID_POST_CONTENT', req);
return res.status(400).json({ error: 'Недопустимый контент поста' });
}
// Валидация тегов
if (tags) {
let parsedTags = [];
try {
parsedTags = JSON.parse(tags);
} catch (e) {
return res.status(400).json({ error: 'Неверный формат тегов' });
}
if (!validateTags(parsedTags)) {
logSecurityEvent('INVALID_TAGS', req, { tags: parsedTags });
return res.status(400).json({ error: 'Недопустимые теги' });
}
post.tags = parsedTags;
}
if (content !== undefined) {
post.content = content;
// Обновить хэштеги
post.hashtags = extractHashtags(content);
}
if (isNSFW !== undefined) {
post.isNSFW = isNSFW === 'true' || isNSFW === true;
}
post.editedAt = new Date();
await post.save();
await post.populate('author', 'username firstName lastName photoUrl');
res.json({ post });
} catch (error) {
console.error('Ошибка редактирования поста:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Удалить пост (автор или модератор)
router.delete('/:id', authenticate, async (req, res) => {
try {
@ -250,8 +364,15 @@ router.delete('/:id', authenticate, async (req, res) => {
return res.status(403).json({ error: 'Нет прав на удаление' });
}
// Удалить изображение если есть
if (post.imageUrl) {
// Удалить изображения если есть
if (post.images && post.images.length > 0) {
post.images.forEach(imagePath => {
const fullPath = path.join(__dirname, '..', imagePath);
if (fs.existsSync(fullPath)) {
fs.unlinkSync(fullPath);
}
});
} else if (post.imageUrl) {
const imagePath = path.join(__dirname, '..', post.imageUrl);
if (fs.existsSync(imagePath)) {
fs.unlinkSync(imagePath);
@ -266,5 +387,71 @@ router.delete('/:id', authenticate, async (req, res) => {
}
});
// Редактировать комментарий
router.put('/:postId/comments/:commentId', authenticate, interactionLimiter, async (req, res) => {
try {
const { content } = req.body;
const post = await Post.findById(req.params.postId);
if (!post) {
return res.status(404).json({ error: 'Пост не найден' });
}
const comment = post.comments.id(req.params.commentId);
if (!comment) {
return res.status(404).json({ error: 'Комментарий не найден' });
}
// Проверить права
if (!comment.author.equals(req.user._id) && req.user.role !== 'moderator' && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Нет прав на редактирование' });
}
if (!content || content.trim().length === 0) {
return res.status(400).json({ error: 'Комментарий не может быть пустым' });
}
comment.content = content;
comment.editedAt = new Date();
await post.save();
await post.populate('comments.author', 'username firstName lastName photoUrl');
res.json({ comments: post.comments });
} catch (error) {
console.error('Ошибка редактирования комментария:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Удалить комментарий
router.delete('/:postId/comments/:commentId', authenticate, interactionLimiter, async (req, res) => {
try {
const post = await Post.findById(req.params.postId);
if (!post) {
return res.status(404).json({ error: 'Пост не найден' });
}
const comment = post.comments.id(req.params.commentId);
if (!comment) {
return res.status(404).json({ error: 'Комментарий не найден' });
}
// Проверить права
if (!comment.author.equals(req.user._id) && req.user.role !== 'moderator' && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Нет прав на удаление' });
}
post.comments.pull(req.params.commentId);
await post.save();
await post.populate('comments.author', 'username firstName lastName photoUrl');
res.json({ comments: post.comments });
} catch (error) {
console.error('Ошибка удаления комментария:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
module.exports = router;

View File

@ -42,7 +42,7 @@ router.get('/proxy/:encodedUrl', async (req, res) => {
const response = await axios.get(originalUrl, {
responseType: 'stream',
headers: {
'User-Agent': 'NakamaSpace/1.0',
'User-Agent': 'NakamaHost/1.0',
'Referer': urlObj.origin
},
timeout: 30000 // 30 секунд таймаут
@ -67,23 +67,41 @@ router.get('/proxy/:encodedUrl', async (req, res) => {
// e621 API поиск
router.get('/furry', authenticate, async (req, res) => {
try {
const { query, limit = 50, page = 1 } = req.query;
const { query, limit = 320, page = 1 } = req.query; // e621 поддерживает до 320 постов на страницу
if (!query) {
return res.status(400).json({ error: 'Параметр query обязателен' });
}
// Поддержка множественных тегов через пробел
// e621 API автоматически обрабатывает теги через пробел в параметре tags
try {
const response = await axios.get('https://e621.net/posts.json', {
params: {
tags: query,
limit,
page
tags: query.trim(), // Множественные теги через пробел
limit: Math.min(parseInt(limit) || 320, 320), // Максимум 320
page: parseInt(page) || 1
},
headers: {
'User-Agent': 'NakamaSpace/1.0'
}
'User-Agent': 'NakamaHost/1.0'
},
timeout: 30000,
validateStatus: (status) => status < 500 // Не бросать ошибку для 429
});
// Обработка 429 (Too Many Requests)
if (response.status === 429) {
console.warn('⚠️ e621 rate limit (429)');
return res.json({ posts: [] });
}
// Проверка на наличие данных
if (!response.data || !response.data.posts || !Array.isArray(response.data.posts)) {
console.warn('⚠️ e621 вернул неверный формат данных');
return res.json({ posts: [] });
}
const posts = response.data.posts.map(post => ({
id: post.id,
url: createProxyUrl(post.file.url),
@ -94,40 +112,61 @@ router.get('/furry', authenticate, async (req, res) => {
source: 'e621'
}));
res.json({ posts });
return res.json({ posts });
} catch (error) {
console.error('Ошибка e621 API:', error);
res.status(500).json({ error: 'Ошибка поиска' });
// Обработка 429 ошибок
if (error.response && error.response.status === 429) {
console.warn('⚠️ e621 rate limit (429)');
return res.json({ posts: [] });
}
console.error('Ошибка e621 API:', error.message);
return res.json({ posts: [] }); // Возвращаем пустой массив вместо ошибки
}
} catch (error) {
console.error('Ошибка поиска e621:', error);
return res.json({ posts: [] }); // Возвращаем пустой массив вместо ошибки
}
});
// Gelbooru API поиск
router.get('/anime', authenticate, async (req, res) => {
try {
const { query, limit = 50, page = 1 } = req.query;
const { query, limit = 320, page = 1 } = req.query; // Gelbooru поддерживает до 320 постов на страницу
if (!query) {
return res.status(400).json({ error: 'Параметр query обязателен' });
}
// Поддержка множественных тегов через пробел
// Gelbooru API автоматически обрабатывает теги через пробел в параметре tags
try {
const response = await axios.get('https://gelbooru.com/index.php', {
params: {
page: 'dapi',
s: 'post',
q: 'index',
json: 1,
tags: query,
limit,
pid: page,
tags: query.trim(), // Множественные теги через пробел
limit: Math.min(parseInt(limit) || 320, 320), // Максимум 320
pid: parseInt(page) || 1,
api_key: config.gelbooruApiKey,
user_id: config.gelbooruUserId
},
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
},
timeout: 30000
timeout: 30000,
validateStatus: (status) => status < 500 // Не бросать ошибку для 429
});
// Обработка 429 (Too Many Requests)
if (response.status === 429) {
console.warn('⚠️ Gelbooru rate limit (429)');
return res.json({ posts: [] });
}
// Обработка разных форматов ответа Gelbooru
let postsData = [];
if (Array.isArray(response.data)) {
@ -148,13 +187,23 @@ router.get('/anime', authenticate, async (req, res) => {
source: 'gelbooru'
}));
res.json({ posts });
return res.json({ posts });
} catch (error) {
// Обработка 429 ошибок
if (error.response && error.response.status === 429) {
console.warn('⚠️ Gelbooru rate limit (429)');
return res.json({ posts: [] });
}
console.error('Ошибка Gelbooru API:', error.message);
if (error.response) {
console.error('Gelbooru ответ:', error.response.status, error.response.data);
}
res.status(500).json({ error: 'Ошибка поиска Gelbooru', details: error.message });
return res.json({ posts: [] }); // Возвращаем пустой массив вместо ошибки
}
} catch (error) {
console.error('Ошибка поиска Gelbooru:', error);
return res.json({ posts: [] }); // Возвращаем пустой массив вместо ошибки
}
});
@ -167,6 +216,7 @@ router.get('/furry/tags', authenticate, async (req, res) => {
return res.json({ tags: [] });
}
try {
const response = await axios.get('https://e621.net/tags.json', {
params: {
'search[name_matches]': `${query}*`,
@ -174,19 +224,43 @@ router.get('/furry/tags', authenticate, async (req, res) => {
limit: 10
},
headers: {
'User-Agent': 'NakamaSpace/1.0'
}
'User-Agent': 'NakamaHost/1.0'
},
timeout: 10000,
validateStatus: (status) => status < 500 // Не бросать ошибку для 429
});
// Обработка 429 (Too Many Requests)
if (response.status === 429) {
console.warn('⚠️ e621 rate limit (429)');
return res.json({ tags: [] });
}
// Проверка на массив
if (!response.data || !Array.isArray(response.data)) {
console.warn('⚠️ e621 вернул не массив:', typeof response.data);
return res.json({ tags: [] });
}
const tags = response.data.map(tag => ({
name: tag.name,
count: tag.post_count
}));
res.json({ tags });
return res.json({ tags });
} catch (error) {
// Обработка 429 ошибок
if (error.response && error.response.status === 429) {
console.warn('⚠️ e621 rate limit (429)');
return res.json({ tags: [] });
}
console.error('Ошибка получения тегов e621:', error.message);
return res.json({ tags: [] }); // Возвращаем пустой массив вместо ошибки
}
} catch (error) {
console.error('Ошибка получения тегов:', error);
res.status(500).json({ error: 'Ошибка получения тегов' });
return res.json({ tags: [] }); // Возвращаем пустой массив вместо ошибки
}
});
@ -199,6 +273,7 @@ router.get('/anime/tags', authenticate, async (req, res) => {
return res.json({ tags: [] });
}
try {
const response = await axios.get('https://gelbooru.com/index.php', {
params: {
page: 'dapi',
@ -214,9 +289,16 @@ router.get('/anime/tags', authenticate, async (req, res) => {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
},
timeout: 30000
timeout: 30000,
validateStatus: (status) => status < 500 // Не бросать ошибку для 429
});
// Обработка 429 (Too Many Requests)
if (response.status === 429) {
console.warn('⚠️ Gelbooru rate limit (429)');
return res.json({ tags: [] });
}
// Обработка разных форматов ответа Gelbooru
let tagsData = [];
if (Array.isArray(response.data)) {
@ -227,19 +309,36 @@ router.get('/anime/tags', authenticate, async (req, res) => {
tagsData = response.data;
}
// Проверка на массив перед map
if (!Array.isArray(tagsData)) {
console.warn('⚠️ Gelbooru вернул не массив тегов');
return res.json({ tags: [] });
}
const tags = tagsData.map(tag => ({
name: tag.name || tag.tag || '',
count: tag.count || tag.post_count || 0
})).filter(tag => tag.name);
res.json({ tags });
return res.json({ tags });
} catch (error) {
// Обработка 429 ошибок
if (error.response && error.response.status === 429) {
console.warn('⚠️ Gelbooru rate limit (429)');
return res.json({ tags: [] });
}
console.error('Ошибка получения тегов Gelbooru:', error.message);
if (error.response) {
console.error('Gelbooru ответ:', error.response.status, error.response.data);
}
// В случае ошибки возвращаем пустой массив вместо ошибки
res.json({ tags: [] });
return res.json({ tags: [] });
}
} catch (error) {
console.error('Ошибка получения тегов Gelbooru:', error);
// В случае ошибки возвращаем пустой массив вместо ошибки
return res.json({ tags: [] });
}
});

104
backend/scripts/backup.js Normal file
View File

@ -0,0 +1,104 @@
#!/usr/bin/env node
/**
* Скрипт для автоматического бэкапа MongoDB
* Использование: node scripts/backup.js
* Или добавить в cron: 0 2 * * * node /path/to/scripts/backup.js
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const dotenv = require('dotenv');
// Загрузить переменные окружения
dotenv.config({ path: path.join(__dirname, '../.env') });
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/nakama';
const BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '../backups');
const MAX_BACKUPS = parseInt(process.env.MAX_BACKUPS || '30'); // Хранить 30 бэкапов
// Парсинг MongoDB URI для получения имени базы данных
function getDatabaseName(uri) {
try {
const match = uri.match(/\/([^/?]+)/);
return match ? match[1] : 'nakama';
} catch {
return 'nakama';
}
}
// Парсинг MongoDB URI для получения хоста и порта
function getMongoHost(uri) {
try {
const url = new URL(uri);
return `${url.hostname}:${url.port || 27017}`;
} catch {
return 'localhost:27017';
}
}
// Создать директорию для бэкапов
if (!fs.existsSync(BACKUP_DIR)) {
fs.mkdirSync(BACKUP_DIR, { recursive: true });
}
// Имя базы данных
const dbName = getDatabaseName(MONGODB_URI);
const mongoHost = getMongoHost(MONGODB_URI);
// Имя файла бэкапа
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupName = `backup-${dbName}-${timestamp}`;
const backupPath = path.join(BACKUP_DIR, backupName);
console.log(`📦 Создание бэкапа базы данных: ${dbName}`);
console.log(`📁 Путь: ${backupPath}`);
console.log(`🖥️ Хост: ${mongoHost}`);
try {
// Создать бэкап с помощью mongodump
const command = `mongodump --host ${mongoHost} --db ${dbName} --out ${backupPath}`;
console.log(`🔄 Выполнение команды: ${command}`);
execSync(command, { stdio: 'inherit' });
console.log(`✅ Бэкап создан успешно: ${backupPath}`);
// Архивировать бэкап
const archivePath = `${backupPath}.tar.gz`;
console.log(`📦 Архивирование бэкапа...`);
execSync(`tar -czf ${archivePath} -C ${BACKUP_DIR} ${backupName}`, { stdio: 'inherit' });
// Удалить неархивированную директорию
execSync(`rm -rf ${backupPath}`, { stdio: 'inherit' });
console.log(`✅ Архив создан: ${archivePath}`);
// Удалить старые бэкапы
const backups = fs.readdirSync(BACKUP_DIR)
.filter(file => file.startsWith(`backup-${dbName}-`) && file.endsWith('.tar.gz'))
.map(file => ({
name: file,
path: path.join(BACKUP_DIR, file),
time: fs.statSync(path.join(BACKUP_DIR, file)).mtime
}))
.sort((a, b) => b.time - a.time);
if (backups.length > MAX_BACKUPS) {
const toDelete = backups.slice(MAX_BACKUPS);
console.log(`🗑️ Удаление старых бэкапов (${toDelete.length} файлов)...`);
toDelete.forEach(backup => {
fs.unlinkSync(backup.path);
console.log(` Удален: ${backup.name}`);
});
}
console.log(`✅ Бэкап завершен успешно`);
console.log(`📊 Всего бэкапов: ${backups.length}`);
} catch (error) {
console.error(`❌ Ошибка создания бэкапа:`, error.message);
process.exit(1);
}

View File

@ -4,33 +4,70 @@ 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 config = require('./config');
dotenv.config();
// Security middleware
const {
helmetConfig,
sanitizeMongo,
xssProtection,
hppProtection,
ddosProtection
} = require('./middleware/security');
const { sanitizeInput } = require('./middleware/validator');
const { requestLogger, logSecurityEvent } = require('./middleware/logger');
const { errorHandler, notFoundHandler } = require('./middleware/errorHandler');
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
optionsSuccessStatus: 200,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'x-telegram-init-data'],
maxAge: 86400 // 24 часа
};
// Middleware
app.use(cors(corsOptions));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 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)));
// Доверять proxy для правильного IP (для rate limiting за nginx/cloudflare)
if (config.isProduction()) {
app.set('trust proxy', 1);
}
// DDoS защита (применяется перед другими rate limiters)
app.use(ddosProtection);
// Rate limiting
app.use('/api', generalLimiter);
@ -45,10 +82,7 @@ app.get('/health', (req, res) => {
});
// MongoDB подключение
mongoose.connect(config.mongoUri, {
useNewUrlParser: true,
useUnifiedTopology: true
})
mongoose.connect(config.mongoUri)
.then(() => {
console.log(`✅ MongoDB подключена: ${config.mongoUri.replace(/\/\/.*@/, '//***@')}`);
// Инициализировать Redis (опционально)
@ -73,9 +107,15 @@ app.use('/api/bot', require('./routes/bot'));
// Базовый роут
app.get('/', (req, res) => {
res.json({ message: 'NakamaSpace API работает' });
res.json({ message: 'NakamaHost API работает' });
});
// 404 handler
app.use(notFoundHandler);
// Error handler (должен быть последним)
app.use(errorHandler);
// Инициализировать WebSocket
initWebSocket(server);
@ -99,5 +139,15 @@ server.listen(config.port, '0.0.0.0', () => {
if (config.isDevelopment()) {
console.log(` Frontend: ${config.frontendUrl}`);
}
if (!config.telegramBotToken) {
console.warn('⚠️ TELEGRAM_BOT_TOKEN не установлен!');
console.warn(' Установите переменную окружения: TELEGRAM_BOT_TOKEN=ваш_токен');
console.warn(' Получите токен от @BotFather в Telegram');
console.warn(` Проверьте .env файл в: ${path.join(__dirname, '.env')}`);
console.warn(` Текущий process.env.TELEGRAM_BOT_TOKEN: ${process.env.TELEGRAM_BOT_TOKEN ? 'установлен' : 'НЕ установлен'}`);
} else {
console.log(`✅ Telegram Bot инициализирован`);
console.log(` Токен: ${config.telegramBotToken.substring(0, 10)}...`);
}
});

View File

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<title>NakamaSpace</title>
<title>NakamaHost</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<style>
/* Предотвращение resize при открытии клавиатуры */

View File

@ -1,7 +1,7 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { useState, useEffect } from 'react'
import { initTelegramApp, getTelegramUser } from './utils/telegram'
import { verifyAuth } from './utils/api'
import { initTelegramApp, getTelegramUser, isThirdPartyClient } from './utils/telegram'
import { verifyAuth, authWithTelegramOAuth } from './utils/api'
import { initTheme } from './utils/theme'
import Layout from './components/Layout'
import Feed from './pages/Feed'
@ -11,12 +11,14 @@ import Profile from './pages/Profile'
import UserProfile from './pages/UserProfile'
import CommentsPage from './pages/CommentsPage'
import PostMenuPage from './pages/PostMenuPage'
import TelegramLogin from './components/TelegramLogin'
import './styles/index.css'
function App() {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [showLogin, setShowLogin] = useState(false)
useEffect(() => {
// Инициализировать тему
@ -32,13 +34,27 @@ function App() {
// Получить данные пользователя из Telegram
const telegramUser = getTelegramUser()
// Если нет Telegram Web App API, показываем Login Widget
if (!telegramUser) {
throw new Error('Telegram User не найден')
setShowLogin(true)
setLoading(false)
return
}
// Верифицировать через API
const userData = await verifyAuth()
setUser(userData)
// Обработать параметр start из Telegram (для открытия конкретного поста)
const tg = window.Telegram?.WebApp
if (tg?.startParam) {
// Если startParam начинается с "post_", это ссылка на пост
if (tg.startParam.startsWith('post_')) {
const postId = tg.startParam.replace('post_', '')
// Перенаправить на страницу с конкретным постом
window.location.href = `/feed?post=${postId}`
}
}
} catch (err) {
console.error('Ошибка инициализации:', err)
setError(err.message)
@ -47,6 +63,21 @@ function App() {
}
}
const handleTelegramAuth = async (telegramUser) => {
try {
setLoading(true)
// Отправить данные OAuth на backend
const userData = await authWithTelegramOAuth(telegramUser)
setUser(userData)
setShowLogin(false)
} catch (err) {
console.error('Ошибка авторизации:', err)
setError(err.message || 'Ошибка авторизации через Telegram')
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div style={{
@ -63,6 +94,13 @@ function App() {
)
}
// Показать Login Widget если нет авторизации
if (showLogin) {
// Получить имя бота из конфига или переменных окружения
const botName = import.meta.env.VITE_TELEGRAM_BOT_NAME || 'nakama_bot'
return <TelegramLogin botName={botName} onAuth={handleTelegramAuth} />
}
if (error) {
return (
<div style={{
@ -80,10 +118,31 @@ function App() {
<p style={{ color: 'var(--text-secondary)', textAlign: 'center' }}>
{error}
</p>
<button
onClick={() => {
setError(null)
setShowLogin(true)
}}
style={{
padding: '12px 24px',
borderRadius: '12px',
background: 'var(--button-accent)',
color: 'white',
border: 'none',
cursor: 'pointer',
fontSize: '16px'
}}
>
Попробовать снова
</button>
</div>
)
}
if (!user) {
return null
}
return (
<BrowserRouter>
<Routes>

View File

@ -95,9 +95,11 @@
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
background: rgba(0, 0, 0, 0.3);
color: white;
display: flex;
opacity: 0.7;
z-index: 10;
align-items: center;
justify-content: center;
border: none;
@ -111,11 +113,42 @@
}
.carousel-btn.prev {
left: 8px;
left: 0;
width: 33.33%;
height: 100%;
border-radius: 0;
background: transparent;
justify-content: flex-start;
padding-left: 12px;
}
.carousel-btn.next {
right: 8px;
right: 0;
width: 33.33%;
height: 100%;
border-radius: 0;
background: transparent;
justify-content: flex-end;
padding-right: 12px;
}
.carousel-btn.prev svg,
.carousel-btn.next svg {
position: relative;
z-index: 2;
background: rgba(0, 0, 0, 0.3);
border-radius: 50%;
padding: 6px;
opacity: 0.7;
transition: opacity 0.2s, background 0.2s;
}
.carousel-btn.prev:hover svg,
.carousel-btn.next:hover svg,
.carousel-btn.prev:active svg,
.carousel-btn.next:active svg {
opacity: 1;
background: rgba(0, 0, 0, 0.5);
}
.carousel-dots {

View File

@ -1,7 +1,7 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Heart, MessageCircle, MoreVertical, ChevronLeft, ChevronRight } from 'lucide-react'
import { likePost, deletePost } from '../utils/api'
import { Heart, MessageCircle, MoreVertical, ChevronLeft, ChevronRight, Download, Send } from 'lucide-react'
import { likePost, deletePost, sendPhotoToTelegram } from '../utils/api'
import { hapticFeedback, showConfirm } from '../utils/telegram'
import './PostCard.css'
@ -83,7 +83,13 @@ export default function PostCard({ post, currentUser, onUpdate }) {
</div>
</div>
<button className="menu-btn" onClick={() => navigate(`/post/${post._id}/menu`)}>
<button
className="menu-btn"
onClick={(e) => {
e.stopPropagation()
navigate(`/post/${post._id}/menu`)
}}
>
<MoreVertical size={20} />
</button>
</div>
@ -150,16 +156,46 @@ export default function PostCard({ post, currentUser, onUpdate }) {
<div className="post-actions">
<button
className={`action-btn ${liked ? 'active' : ''}`}
onClick={handleLike}
onClick={(e) => {
e.stopPropagation()
handleLike()
}}
>
<Heart size={20} fill={liked ? '#FF3B30' : 'none'} stroke={liked ? '#FF3B30' : 'currentColor'} />
<span>{likesCount}</span>
</button>
<button className="action-btn" onClick={() => navigate(`/post/${post._id}/comments`)}>
<button
className="action-btn"
onClick={(e) => {
e.stopPropagation()
navigate(`/post/${post._id}/comments`)
}}
>
<MessageCircle size={20} stroke="currentColor" />
<span>{post.comments.length}</span>
</button>
{images.length > 0 && (
<button
className="action-btn"
onClick={async (e) => {
e.stopPropagation()
try {
hapticFeedback('light')
const imageUrl = images[currentImageIndex] || post.imageUrl
await sendPhotoToTelegram(imageUrl)
hapticFeedback('success')
} catch (error) {
console.error('Ошибка отправки фото:', error)
hapticFeedback('error')
}
}}
title="Отправить фото в Telegram"
>
<Send size={20} stroke="currentColor" />
</button>
)}
</div>
</div>
)

View File

@ -0,0 +1,35 @@
.telegram-login-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
background: var(--bg-secondary);
gap: 24px;
}
.login-header {
text-align: center;
margin-bottom: 20px;
}
.login-header h2 {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.login-header p {
font-size: 16px;
color: var(--text-secondary);
}
.telegram-widget-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 60px;
}

View File

@ -0,0 +1,55 @@
import { useEffect, useRef } from 'react'
import './TelegramLogin.css'
export default function TelegramLogin({ botName, onAuth }) {
const containerRef = useRef(null)
useEffect(() => {
// Загрузить Telegram Login Widget скрипт
const script = document.createElement('script')
script.src = 'https://telegram.org/js/telegram-widget.js?22'
script.setAttribute('data-telegram-login', botName)
script.setAttribute('data-size', 'large')
script.setAttribute('data-onauth', 'onTelegramAuth(user)')
script.setAttribute('data-request-access', 'write')
script.async = true
// Глобальная функция для обработки авторизации
// Telegram Login Widget передает объект с данными пользователя
window.onTelegramAuth = (userData) => {
if (onAuth && userData) {
// userData содержит: id, first_name, last_name, username, photo_url, auth_date, hash
onAuth(userData)
}
}
if (containerRef.current) {
containerRef.current.appendChild(script)
}
return () => {
// Очистка при размонтировании
if (containerRef.current && script.parentNode) {
try {
containerRef.current.removeChild(script)
} catch (e) {
// Игнорируем ошибки при удалении
}
}
if (window.onTelegramAuth) {
delete window.onTelegramAuth
}
}
}, [botName, onAuth])
return (
<div className="telegram-login-container">
<div className="login-header">
<h2>Вход через Telegram</h2>
<p>Авторизуйтесь через Telegram для доступа к приложению</p>
</div>
<div ref={containerRef} className="telegram-widget-container" />
</div>
)
}

View File

@ -155,6 +155,7 @@
.comment-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
@ -170,6 +171,76 @@
color: var(--text-secondary);
}
.comment-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
.comment-action-btn {
width: 28px;
height: 28px;
border-radius: 50%;
background: transparent;
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
padding: 0;
}
.comment-action-btn:hover,
.comment-action-btn:active {
background: var(--bg-primary);
}
.comment-edit {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.comment-edit-input {
width: 100%;
padding: 8px 12px;
border-radius: 12px;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--divider-color);
font-size: 15px;
font-family: inherit;
resize: none;
}
.comment-edit-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.comment-save-btn,
.comment-cancel-btn {
padding: 8px 16px;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
border: none;
cursor: pointer;
}
.comment-save-btn {
background: var(--button-accent);
color: white;
}
.comment-cancel-btn {
background: var(--bg-primary);
color: var(--text-primary);
}
.comment-text {
font-size: 14px;
line-height: 1.5;

View File

@ -1,8 +1,8 @@
import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { ArrowLeft, Send } from 'lucide-react'
import { getPosts, commentPost } from '../utils/api'
import { hapticFeedback } from '../utils/telegram'
import { ArrowLeft, Send, Edit, Trash2 } from 'lucide-react'
import { getPosts, commentPost, editComment, deleteComment } from '../utils/api'
import { hapticFeedback, showConfirm } from '../utils/telegram'
import './CommentsPage.css'
export default function CommentsPage({ user }) {
@ -13,6 +13,8 @@ export default function CommentsPage({ user }) {
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [comments, setComments] = useState([])
const [editingCommentId, setEditingCommentId] = useState(null)
const [editCommentText, setEditCommentText] = useState('')
useEffect(() => {
loadPost()
@ -153,7 +155,12 @@ export default function CommentsPage({ user }) {
<span>Будьте первым!</span>
</div>
) : (
comments.map((c, index) => (
comments.map((c, index) => {
const isEditing = editingCommentId === c._id
const isOwnComment = c.author._id === user.id
const canEdit = isOwnComment || user.role === 'moderator' || user.role === 'admin'
return (
<div key={index} className="comment-item fade-in">
<img
src={c.author.photoUrl || '/default-avatar.png'}
@ -165,12 +172,92 @@ export default function CommentsPage({ user }) {
<span className="comment-author">
{c.author.firstName} {c.author.lastName}
</span>
<span className="comment-time">{formatDate(c.createdAt)}</span>
<span className="comment-time">
{formatDate(c.createdAt)}
{c.editedAt && ' (изменено)'}
</span>
{canEdit && (
<div className="comment-actions">
<button
className="comment-action-btn"
onClick={() => {
setEditingCommentId(c._id)
setEditCommentText(c.content)
}}
title="Редактировать"
>
<Edit size={16} />
</button>
<button
className="comment-action-btn"
onClick={async () => {
const confirmed = await showConfirm('Удалить этот комментарий?')
if (confirmed) {
try {
hapticFeedback('light')
const result = await deleteComment(postId, c._id)
setComments(result.comments)
hapticFeedback('success')
} catch (error) {
console.error('Ошибка удаления комментария:', error)
hapticFeedback('error')
}
}
}}
title="Удалить"
>
<Trash2 size={16} />
</button>
</div>
)}
</div>
{isEditing ? (
<div className="comment-edit">
<input
type="text"
value={editCommentText}
onChange={e => setEditCommentText(e.target.value)}
className="comment-edit-input"
maxLength={500}
autoFocus
/>
<div className="comment-edit-actions">
<button
className="comment-save-btn"
onClick={async () => {
try {
hapticFeedback('light')
const result = await editComment(postId, c._id, editCommentText)
setComments(result.comments)
setEditingCommentId(null)
setEditCommentText('')
hapticFeedback('success')
} catch (error) {
console.error('Ошибка редактирования комментария:', error)
hapticFeedback('error')
}
}}
>
Сохранить
</button>
<button
className="comment-cancel-btn"
onClick={() => {
setEditingCommentId(null)
setEditCommentText('')
}}
>
Отмена
</button>
</div>
</div>
) : (
<p className="comment-text">{c.content}</p>
)}
</div>
</div>
))
)
})
)}
</div>

View File

@ -146,3 +146,21 @@
opacity: 0.5;
}
.post-highlight {
animation: highlight 2s ease-out;
border-radius: 12px;
padding: 8px;
margin: -8px;
}
@keyframes highlight {
0% {
background: rgba(0, 122, 255, 0.3);
box-shadow: 0 0 20px rgba(0, 122, 255, 0.5);
}
100% {
background: transparent;
box-shadow: none;
}
}

View File

@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { getPosts } from '../utils/api'
import PostCard from '../components/PostCard'
import CreatePostModal from '../components/CreatePostModal'
@ -7,16 +8,56 @@ import { hapticFeedback } from '../utils/telegram'
import './Feed.css'
export default function Feed({ user }) {
const [searchParams] = useSearchParams()
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [showCreateModal, setShowCreateModal] = useState(false)
const [filter, setFilter] = useState('all')
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [highlightPostId, setHighlightPostId] = useState(null)
useEffect(() => {
// Проверить параметр post в URL
const postId = searchParams.get('post')
if (postId) {
setHighlightPostId(postId)
// Загрузить конкретный пост если его нет в списке
loadSpecificPost(postId)
} else {
loadPosts()
}, [filter])
}
}, [filter, searchParams])
const loadSpecificPost = async (postId) => {
try {
// Загрузить посты и найти нужный
const data = await getPosts({})
const foundPost = data.posts.find(p => p._id === postId)
if (foundPost) {
// Если пост найден, добавить его в начало списка
setPosts(prev => {
const filtered = prev.filter(p => p._id !== postId)
return [foundPost, ...filtered]
})
// Прокрутить к посту после загрузки
setTimeout(() => {
const element = document.getElementById(`post-${postId}`)
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}, 100)
} else {
// Если пост не найден, загрузить все посты
await loadPosts()
}
} catch (error) {
console.error('Ошибка загрузки поста:', error)
await loadPosts()
}
}
const loadPosts = async (pageNum = 1) => {
try {
@ -64,7 +105,7 @@ export default function Feed({ user }) {
<div className="feed-page">
{/* Хедер */}
<div className="feed-header">
<h1>NakamaSpace</h1>
<h1>NakamaHost</h1>
<button className="create-btn" onClick={handleCreatePost}>
<Plus size={20} />
</button>
@ -117,7 +158,13 @@ export default function Feed({ user }) {
) : (
<>
{posts.map(post => (
<PostCard key={post._id} post={post} currentUser={user} onUpdate={loadPosts} />
<div
key={post._id}
className={highlightPostId === post._id ? 'post-highlight' : ''}
id={`post-${post._id}`}
>
<PostCard post={post} currentUser={user} onUpdate={loadPosts} />
</div>
))}
{hasMore && (

View File

@ -72,6 +72,11 @@
border-left: 3px solid var(--button-accent);
}
[data-theme="dark"] .notification-bubble.unread {
background: rgba(255, 255, 255, 0.05);
border-left: 3px solid var(--button-accent);
}
.notification-bubble:active {
transform: scale(0.98);
box-shadow: 0 1px 4px var(--shadow-sm);
@ -182,3 +187,24 @@
border-left-color: #FF9500;
}
/* Темная тема - менее яркие уведомления */
[data-theme="dark"] .notification-bubble.unread:has(.bubble-icon[style*="FF3B30"]) {
background: rgba(255, 59, 48, 0.1);
border-left-color: #FF3B30;
}
[data-theme="dark"] .notification-bubble.unread:has(.bubble-icon[style*="34C759"]) {
background: rgba(52, 199, 89, 0.1);
border-left-color: #34C759;
}
[data-theme="dark"] .notification-bubble.unread:has(.bubble-icon[style*="5856D6"]) {
background: rgba(88, 86, 214, 0.1);
border-left-color: #5856D6;
}
[data-theme="dark"] .notification-bubble.unread:has(.bubble-icon[style*="FF9500"]) {
background: rgba(255, 149, 0, 0.1);
border-left-color: #FF9500;
}

View File

@ -69,8 +69,8 @@ export default function Notifications({ user }) {
if (notification.type === 'follow') {
navigate(`/user/${notification.sender._id}`)
} else if (notification.post) {
// Можно добавить переход к посту
navigate('/feed')
// Переход к конкретному посту
navigate(`/feed?post=${notification.post._id || notification.post}`)
}
}

View File

@ -146,6 +146,106 @@
color: #FF3B30;
}
/* Модальное окно редактирования */
.edit-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.edit-modal {
background: var(--bg-secondary);
border-radius: 16px;
width: 100%;
max-width: 500px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.edit-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.edit-modal-header h3 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.close-btn {
width: 32px;
height: 32px;
border-radius: 50%;
background: transparent;
color: var(--text-primary);
border: none;
font-size: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
background: var(--bg-primary);
}
.edit-content-input {
width: 100%;
padding: 12px;
border-radius: 12px;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--divider-color);
font-size: 15px;
font-family: inherit;
resize: vertical;
min-height: 120px;
}
.edit-modal-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.edit-cancel-btn,
.edit-save-btn {
padding: 10px 20px;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
border: none;
cursor: pointer;
}
.edit-cancel-btn {
background: var(--bg-primary);
color: var(--text-primary);
}
.edit-save-btn {
background: var(--button-accent);
color: white;
}
.edit-save-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.submit-btn {
padding: 8px 16px;
border-radius: 20px;

View File

@ -1,8 +1,8 @@
import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { ArrowLeft, Trash2, Flag } from 'lucide-react'
import { getPosts, reportPost, deletePost } from '../utils/api'
import { hapticFeedback, showConfirm } from '../utils/telegram'
import { ArrowLeft, Trash2, Flag, Edit, Share2 } from 'lucide-react'
import { getPosts, reportPost, deletePost, editPost } from '../utils/api'
import { hapticFeedback, showConfirm, showAlert } from '../utils/telegram'
import './PostMenuPage.css'
export default function PostMenuPage({ user }) {
@ -11,7 +11,9 @@ export default function PostMenuPage({ user }) {
const [post, setPost] = useState(null)
const [loading, setLoading] = useState(true)
const [showReportModal, setShowReportModal] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
const [reportReason, setReportReason] = useState('')
const [editContent, setEditContent] = useState('')
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
@ -70,6 +72,43 @@ export default function PostMenuPage({ user }) {
}
}
const handleShare = async () => {
try {
hapticFeedback('light')
// Получить имя бота из переменных окружения или использовать дефолтное
const botName = import.meta.env.VITE_TELEGRAM_BOT_NAME || 'nakama_bot'
// Создать ссылку на Telegram бота, которая откроет Mini App с конкретным постом
// Формат: https://t.me/bot_name?start=post_POST_ID
// Это откроет бота и Mini App с параметром post в URL
const telegramBotUrl = `https://t.me/${botName}?start=post_${postId}`
// Копировать в буфер обмена
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(telegramBotUrl)
hapticFeedback('success')
showAlert('✅ Ссылка скопирована в буфер обмена!')
} else {
// Fallback для старых браузеров
const textArea = document.createElement('textarea')
textArea.value = telegramBotUrl
textArea.style.position = 'fixed'
textArea.style.opacity = '0'
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
hapticFeedback('success')
showAlert('✅ Ссылка скопирована в буфер обмена!')
}
} catch (error) {
console.error('Ошибка копирования:', error)
hapticFeedback('error')
showAlert('Ошибка копирования ссылки')
}
}
if (loading) {
return (
<div className="post-menu-page">
@ -180,11 +219,28 @@ export default function PostMenuPage({ user }) {
{/* Меню */}
<div className="menu-items">
<button className="menu-item" onClick={handleShare}>
<Share2 size={20} />
<span>Поделиться</span>
</button>
{(post.author._id === user.id) || (user.role === 'moderator' || user.role === 'admin') ? (
<>
<button
className="menu-item"
onClick={() => {
setEditContent(post.content || '')
setShowEditModal(true)
}}
>
<Edit size={20} />
<span>Редактировать пост</span>
</button>
<button className="menu-item danger" onClick={handleDelete}>
<Trash2 size={20} />
<span>Удалить пост</span>
</button>
</>
) : (
<button className="menu-item" onClick={() => setShowReportModal(true)}>
<Flag size={20} />
@ -192,6 +248,61 @@ export default function PostMenuPage({ user }) {
</button>
)}
</div>
{/* Модальное окно редактирования */}
{showEditModal && (
<div className="edit-modal-overlay" onClick={() => setShowEditModal(false)}>
<div className="edit-modal" onClick={(e) => e.stopPropagation()}>
<div className="edit-modal-header">
<h3>Редактировать пост</h3>
<button className="close-btn" onClick={() => setShowEditModal(false)}>×</button>
</div>
<textarea
className="edit-content-input"
value={editContent}
onChange={e => setEditContent(e.target.value)}
placeholder="Текст поста..."
maxLength={2000}
rows={6}
/>
<div className="edit-modal-actions">
<button
className="edit-cancel-btn"
onClick={() => {
setShowEditModal(false)
setEditContent('')
}}
>
Отмена
</button>
<button
className="edit-save-btn"
onClick={async () => {
try {
setSubmitting(true)
hapticFeedback('light')
await editPost(postId, { content: editContent })
hapticFeedback('success')
setShowEditModal(false)
setEditContent('')
await loadPost()
navigate(-1)
} catch (error) {
console.error('Ошибка редактирования поста:', error)
hapticFeedback('error')
alert('Ошибка редактирования поста')
} finally {
setSubmitting(false)
}
}}
disabled={submitting || !editContent.trim()}
>
{submitting ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -221,6 +221,33 @@
color: #000000;
}
.load-more-container {
padding: 20px;
display: flex;
justify-content: center;
}
.load-more-btn {
padding: 12px 24px;
border-radius: 12px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 15px;
font-weight: 600;
border: 1px solid var(--divider-color);
cursor: pointer;
transition: all 0.2s;
}
.load-more-btn:hover:not(:disabled) {
background: var(--bg-secondary);
}
.load-more-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.send-selected-bar {
position: fixed;
bottom: 80px;

View File

@ -7,7 +7,7 @@ import api from '../utils/api'
import './Search.css'
export default function Search({ user }) {
const [mode, setMode] = useState(user.settings?.searchPreference || 'mixed')
const [mode, setMode] = useState(user.settings?.searchPreference || 'furry')
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
@ -18,6 +18,9 @@ export default function Search({ user }) {
const [selectionMode, setSelectionMode] = useState(false)
const [showCreatePost, setShowCreatePost] = useState(false)
const [imageForPost, setImageForPost] = useState(null)
const [hasMore, setHasMore] = useState(true)
const [currentPage, setCurrentPage] = useState(1)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const touchStartX = useRef(0)
const touchEndX = useRef(0)
@ -31,11 +34,21 @@ export default function Search({ user }) {
const loadTagSuggestions = async () => {
try {
// Разбить query по пробелам и взять последний тег для автокомплита
const queryParts = query.trim().split(/\s+/)
const lastTag = queryParts[queryParts.length - 1] || query.trim()
// Если нет текста для поиска, не загружаем предложения
if (!lastTag || lastTag.length < 1) {
setTagSuggestions([])
return
}
let tags = []
if (mode === 'furry' || mode === 'mixed') {
if (mode === 'furry') {
try {
const furryTags = await getFurryTags(query)
const furryTags = await getFurryTags(lastTag)
if (furryTags && Array.isArray(furryTags)) {
tags = [...tags, ...furryTags.map(t => ({ ...t, source: 'e621' }))]
}
@ -45,9 +58,9 @@ export default function Search({ user }) {
}
}
if (mode === 'anime' || mode === 'mixed') {
if (mode === 'anime') {
try {
const animeTags = await getAnimeTags(query)
const animeTags = await getAnimeTags(lastTag)
if (animeTags && Array.isArray(animeTags)) {
tags = [...tags, ...animeTags.map(t => ({ ...t, source: 'gelbooru' }))]
}
@ -72,6 +85,34 @@ export default function Search({ user }) {
}
}
const loadMoreResults = async (searchQuery, pageNum) => {
let newResults = []
if (mode === 'furry') {
try {
const furryResults = await searchFurry(searchQuery, { limit: 320, page: pageNum })
if (furryResults && Array.isArray(furryResults)) {
newResults = [...newResults, ...furryResults]
}
} catch (error) {
console.error('Ошибка e621 поиска:', error)
}
}
if (mode === 'anime') {
try {
const animeResults = await searchAnime(searchQuery, { limit: 320, page: pageNum })
if (animeResults && Array.isArray(animeResults)) {
newResults = [...newResults, ...animeResults]
}
} catch (error) {
console.error('Ошибка Gelbooru поиска:', error)
}
}
return newResults
}
const handleSearch = async (searchQuery = query) => {
if (!searchQuery.trim()) return
@ -79,36 +120,26 @@ export default function Search({ user }) {
setLoading(true)
hapticFeedback('light')
setResults([])
setCurrentPage(1)
setHasMore(true)
let allResults = []
if (mode === 'furry' || mode === 'mixed') {
try {
const furryResults = await searchFurry(searchQuery, { limit: 30 })
if (furryResults && Array.isArray(furryResults)) {
allResults = [...allResults, ...furryResults]
}
} catch (error) {
console.error('Ошибка e621 поиска:', error)
// Продолжаем поиск даже если e621 не работает
}
}
// Загружаем первую страницу результатов
const firstPageResults = await loadMoreResults(searchQuery, 1)
if (mode === 'anime' || mode === 'mixed') {
try {
const animeResults = await searchAnime(searchQuery, { limit: 30 })
if (animeResults && Array.isArray(animeResults)) {
allResults = [...allResults, ...animeResults]
}
} catch (error) {
console.error('Ошибка Gelbooru поиска:', error)
// Продолжаем поиск даже если Gelbooru не работает
}
}
if (firstPageResults.length > 0) {
allResults = [...allResults, ...firstPageResults]
// Перемешать результаты если mixed режим
if (mode === 'mixed') {
allResults = allResults.sort(() => Math.random() - 0.5)
// Если получили меньше 320, значит это последняя страница
if (firstPageResults.length < 320) {
setHasMore(false)
} else {
setHasMore(true)
setCurrentPage(1)
}
} else {
setHasMore(false)
}
setResults(allResults)
@ -128,9 +159,40 @@ export default function Search({ user }) {
}
}
const handleLoadMore = async () => {
if (isLoadingMore || !hasMore || !query.trim()) return
try {
setIsLoadingMore(true)
const nextPage = currentPage + 1
const newResults = await loadMoreResults(query, nextPage)
if (newResults.length > 0) {
setResults(prev => [...prev, ...newResults])
setCurrentPage(nextPage)
setHasMore(newResults.length >= 320 && nextPage < 10)
} else {
setHasMore(false)
}
} catch (error) {
console.error('Ошибка загрузки дополнительных результатов:', error)
setHasMore(false)
} finally {
setIsLoadingMore(false)
}
}
const handleTagClick = (tagName) => {
setQuery(tagName)
handleSearch(tagName)
// Разбить текущий query по пробелам
const queryParts = query.trim().split(/\s+/)
// Убрать последний тег (если он есть) и добавить новый
const existingTags = queryParts.slice(0, -1).filter(t => t.trim())
const newQuery = existingTags.length > 0
? [...existingTags, tagName].join(' ')
: tagName
setQuery(newQuery)
handleSearch(newQuery)
}
const openViewer = (index) => {
@ -341,12 +403,6 @@ export default function Search({ user }) {
>
Anime
</button>
<button
className={`mode-btn ${mode === 'mixed' ? 'active' : ''}`}
onClick={() => setMode('mixed')}
>
Mixed
</button>
</div>
{/* Строка поиска */}
@ -430,6 +486,19 @@ export default function Search({ user }) {
})}
</div>
{/* Кнопка загрузки дополнительных результатов */}
{hasMore && !selectionMode && (
<div className="load-more-container">
<button
className="load-more-btn"
onClick={handleLoadMore}
disabled={isLoadingMore}
>
{isLoadingMore ? 'Загрузка...' : `Загрузить ещё (показано ${results.length})`}
</button>
</div>
)}
{/* Кнопка отправки выбранных */}
{selectionMode && selectedImages.length > 0 && (
<div className="send-selected-bar">

View File

@ -140,7 +140,13 @@ export default function UserProfile({ currentUser }) {
) : (
<div className="posts-list">
{posts.map(post => (
<PostCard key={post._id} post={post} currentUser={currentUser} onUpdate={loadPosts} />
<div
key={post._id}
onClick={() => window.location.href = `/feed?post=${post._id}`}
style={{ cursor: 'pointer' }}
>
<PostCard post={post} currentUser={currentUser} onUpdate={loadPosts} />
</div>
))}
</div>
)}

View File

@ -1,5 +1,5 @@
import axios from 'axios'
import { getTelegramInitData, getMockUser, isDevelopment } from './telegram'
import { getTelegramInitData } from './telegram'
// API URL из переменных окружения
const API_URL = import.meta.env.VITE_API_URL || (
@ -20,11 +20,8 @@ const api = axios.create({
api.interceptors.request.use((config) => {
const initData = getTelegramInitData()
// В dev режиме создаем mock initData
if (!initData && isDevelopment()) {
const mockUser = getMockUser()
config.headers['x-telegram-init-data'] = `user=${JSON.stringify(mockUser)}`
} else {
// Отправляем initData только если есть
if (initData) {
config.headers['x-telegram-init-data'] = initData
}
@ -37,6 +34,23 @@ export const verifyAuth = async () => {
return response.data.user
}
// Авторизация через Telegram OAuth (Login Widget)
export const authWithTelegramOAuth = async (userData) => {
// userData от Telegram Login Widget содержит: id, first_name, last_name, username, photo_url, auth_date, hash
const response = await api.post('/auth/oauth', {
user: {
id: userData.id,
first_name: userData.first_name,
last_name: userData.last_name,
username: userData.username,
photo_url: userData.photo_url
},
auth_date: userData.auth_date,
hash: userData.hash
})
return response.data.user
}
// Posts API
export const getPosts = async (params = {}) => {
const response = await api.get('/posts', { params })
@ -62,6 +76,21 @@ export const commentPost = async (postId, content) => {
return response.data
}
export const editPost = async (postId, data) => {
const response = await api.put(`/posts/${postId}`, data)
return response.data.post
}
export const editComment = async (postId, commentId, content) => {
const response = await api.put(`/posts/${postId}/comments/${commentId}`, { content })
return response.data
}
export const deleteComment = async (postId, commentId) => {
const response = await api.delete(`/posts/${postId}/comments/${commentId}`)
return response.data
}
export const repostPost = async (postId) => {
const response = await api.post(`/posts/${postId}/repost`)
return response.data
@ -161,5 +190,11 @@ export const banUser = async (userId, banned, days) => {
return response.data
}
// Bot API
export const sendPhotoToTelegram = async (photoUrl) => {
const response = await api.post('/bot/send-photo', { photoUrl })
return response.data
}
export default api

View File

@ -109,20 +109,13 @@ export const hapticFeedback = (type = 'light') => {
}
}
// Для разработки: мок данные если не в Telegram
export const getMockUser = () => {
// Генерируем случайные данные для тестирования
const randomId = Math.floor(Math.random() * 1000000000)
return {
id: randomId,
first_name: 'Dev',
last_name: 'User',
username: `dev_user_${randomId}`,
photo_url: `https://api.dicebear.com/7.x/avataaars/svg?seed=${randomId}` // Генератор аватаров
}
}
export const isDevelopment = () => {
return !window.Telegram?.WebApp?.initDataUnsafe?.user
}
// Проверка, открыто ли приложение в стороннем клиенте (Aurogram и т.д.)
export const isThirdPartyClient = () => {
return typeof window !== 'undefined' && !window.Telegram?.WebApp
}

View File

@ -0,0 +1,128 @@
╔═══════════════════════════════════════════════════════════════════╗
║ ║
║ 🔒 СИСТЕМА БЕЗОПАСНОСТИ И ОТКАЗОУСТОЙЧИВОСТИ 🔒 ║
║ ║
╚═══════════════════════════════════════════════════════════════════╝
ДОБАВЛЕННЫЕ ЗАЩИТНЫЕ МЕХАНИЗМЫ:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ 1. Helmet - Security Headers
• Content-Security-Policy
• X-Frame-Options
• X-Content-Type-Options
• Strict-Transport-Security
• И другие защитные headers
✅ 2. Защита от NoSQL Injection
• express-mongo-sanitize
• Автоматическая очистка MongoDB операторов
• Логирование подозрительных запросов
✅ 3. Защита от XSS
• xss-clean middleware
• Санитизация всех входных данных
• Экранирование HTML/JS в контенте
✅ 4. Защита от HTTP Parameter Pollution
• hpp middleware
• Предотвращение дублирования параметров
✅ 5. Валидация входных данных
• Проверка Telegram ID
• Валидация контента постов
• Валидация тегов
• Валидация URL изображений
• Проверка на path traversal
✅ 6. Строгая проверка подписи Telegram
В production обязательная проверка
• Логирование подозрительных попыток
• Блокировка невалидных запросов
✅ 7. Улучшенный Rate Limiting
• Строгие лимиты для авторизации
• Лимиты для создания постов
• Лимиты для загрузки файлов
• Разные лимиты для разных операций
✅ 8. Централизованная обработка ошибок
• Единый error handler
• Логирование всех ошибок
• Безопасные сообщения об ошибках
• Graceful shutdown
✅ 9. Логирование и мониторинг
• Логирование всех запросов
• Логирование подозрительной активности
• Security events tracking
• Файлы логов в production
✅ 10. Обработка необработанных ошибок
• unhandledRejection
• uncaughtException
• Graceful shutdown при критических ошибках
НУЖНО УСТАНОВИТЬ:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
cd backend
npm install helmet express-mongo-sanitize xss-clean hpp
ОБНОВЛЕННЫЕ ФАЙЛЫ:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Backend:
• backend/server.js
• backend/middleware/security.js (новый)
• backend/middleware/validator.js (новый)
• backend/middleware/errorHandler.js (новый)
• backend/middleware/logger.js (новый)
• backend/middleware/auth.js
• backend/routes/auth.js
• backend/routes/posts.js
ЗАЩИТА ОТ АТАК:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ SQL/NoSQL Injection
✅ XSS (Cross-Site Scripting)
✅ CSRF (Cross-Site Request Forgery)
✅ Path Traversal
✅ HTTP Parameter Pollution
✅ Brute Force (rate limiting)
✅ DDoS (rate limiting + CORS)
✅ File Upload Attacks (валидация)
✅ Man-in-the-Middle (Telegram signature)
ОТКАЗОУСТОЙЧИВОСТЬ:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ Graceful shutdown
✅ Обработка всех ошибок
✅ Валидация внешних API ответов
✅ Timeout для внешних запросов
✅ Retry механизмы (можно добавить)
✅ Health checks
✅ Логирование для отладки
КОМАНДЫ:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
cd /Users/glpshchn/Desktop/nakama/backend
npm install helmet express-mongo-sanitize xss-clean hpp
# На сервере
cd /var/www/nakama/backend
npm install helmet express-mongo-sanitize xss-clean hpp
pm2 restart nakama-backend
2 минуты

97
🔧_TELEGRAM_OAUTH.txt Normal file
View File

@ -0,0 +1,97 @@
╔═══════════════════════════════════════════════════════════════════╗
║ ║
║ 🔧 TELEGRAM OAUTH ДЛЯ СТОРОННИХ КЛИЕНТОВ 🔧 ║
║ ║
╚═══════════════════════════════════════════════════════════════════╝
ИЗМЕНЕНИЯ:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ 1. Убран Mock User
• Удалена функция getMockUser()
• Убрано использование mock user из App.jsx
• Убрано из api.js interceptor
✅ 2. Добавлен Telegram Login Widget
• Компонент TelegramLogin.jsx
• Использует официальный Telegram Login Widget
• Показывается для сторонних клиентов и браузера
✅ 3. Backend OAuth Route
• /api/auth/oauth - новый endpoint
• Проверка подписи Telegram OAuth
• Создание/обновление пользователя
✅ 4. Обновлена логика авторизации
• Если нет Telegram Web App API → показывается Login Widget
• После авторизации через Widget → создается сессия
НАСТРОЙКА:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. Получить имя бота от @BotFather
• Используется для Telegram Login Widget
2. Установить переменную окружения:
VITE_TELEGRAM_BOT_NAME=ваше_имя_бота
3. Настроить домен в BotFather:
• /setdomain для вашего домена
• Например: nakama.glpshchn.ru
ОБНОВЛЕНИЕ:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Frontend:
• frontend/src/App.jsx
• frontend/src/components/TelegramLogin.jsx (новый)
• frontend/src/components/TelegramLogin.css (новый)
• frontend/src/utils/api.js
• frontend/src/utils/telegram.js
Backend:
• backend/routes/auth.js
КОМАНДЫ:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
cd /Users/glpshchn/Desktop/nakama
# Frontend
scp frontend/src/App.jsx root@ваш_IP:/var/www/nakama/frontend/src/
scp frontend/src/components/TelegramLogin.jsx root@ваш_IP:/var/www/nakama/frontend/src/components/
scp frontend/src/components/TelegramLogin.css root@ваш_IP:/var/www/nakama/frontend/src/components/
scp frontend/src/utils/api.js frontend/src/utils/telegram.js root@ваш_IP:/var/www/nakama/frontend/src/utils/
# Backend
scp backend/routes/auth.js root@ваш_IP:/var/www/nakama/backend/routes/
# На сервере
ssh root@ваш_IP "cd /var/www/nakama/frontend && npm run build"
ssh root@ваш_IP "pm2 restart nakama-backend"
ВАЖНО:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. Telegram Login Widget требует:
• Домен должен быть настроен в BotFather
• Использовать HTTPS (в production)
• Правильное имя бота
2. Проверка подписи:
• Включена, если есть TELEGRAM_BOT_TOKEN
В production рекомендуется строгая проверка
3. Безопасность:
• Теперь используется официальная авторизация Telegram
• Нет mock users
Все пользователи верифицированы через Telegram
2 минуты

82
🔧_TG_BOT_TOKEN.txt Normal file
View File

@ -0,0 +1,82 @@
╔═══════════════════════════════════════════════════════════════════╗
║ ║
║ 🔧 УСТАНОВКА TELEGRAM BOT TOKEN 🔧 ║
║ ║
╚═══════════════════════════════════════════════════════════════════╝
ПРОБЛЕМА:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
TELEGRAM_BOT_TOKEN не установлен на сервере!
Ошибка: "TELEGRAM_BOT_TOKEN не установлен"
РЕШЕНИЕ:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ 1. Создать Telegram бота
• Откройте @BotFather в Telegram
• Отправьте команду /newbot
• Следуйте инструкциям
• Получите токен бота (например: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz)
✅ 2. Установить токен на сервере
Вариант A: Через .env файл
────────────────────────────
ssh root@ваш_IP
cd /var/www/nakama/backend
nano .env
Добавьте строку:
TELEGRAM_BOT_TOKEN=ваш_токен_бота
Сохраните (Ctrl+O, Enter, Ctrl+X)
Вариант B: Через PM2 ecosystem
───────────────────────────────
pm2 ecosystem
# Добавьте env: { TELEGRAM_BOT_TOKEN: 'ваш_токен_бота' }
Вариант C: Через export (временное)
─────────────────────────────────────
export TELEGRAM_BOT_TOKEN="ваш_токен_бота"
pm2 restart nakama-backend
✅ 3. Перезапустить backend
─────────────────────────
pm2 restart nakama-backend
Проверить логи:
pm2 logs nakama-backend --lines 20
ПРОВЕРКА:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
После установки токена проверьте:
• Логи не должны показывать "TELEGRAM_BOT_TOKEN не установлен"
• Отправка фото в Telegram должна работать
В логах должно быть: "✅ Telegram Bot инициализирован"
ПРИМЕЧАНИЕ:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Токен должен быть в формате: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz
Не добавляйте кавычки в .env файле!
ИНСТРУКЦИЯ:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. Получите токен от @BotFather
2. Создайте/откройте .env файл в /var/www/nakama/backend/
3. Добавьте: TELEGRAM_BOT_TOKEN=ваш_токен
4. Перезапустите: pm2 restart nakama-backend
2 минуты

View File

@ -0,0 +1,81 @@
╔═══════════════════════════════════════════════════════════════════╗
║ ║
║ 🔧 ОБРАБОТКА ОШИБОК ИМПРОВИЗОВАНА! 🔧 ║
║ ║
╚═══════════════════════════════════════════════════════════════════╝
ПРОБЛЕМА:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. TypeError: response.data.map is not a function
→ response.data не является массивом
2. Приложение падает из-за ошибок (429 rate limit)
→ Нет обработки 429 ошибок
РЕШЕНИЕ:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ 1. Добавлена проверка на массив
• Проверка Array.isArray() перед .map()
• Возврат пустого массива вместо ошибки
✅ 2. Добавлена обработка 429 ошибок
• validateStatus: (status) => status < 500
• Проверка response.status === 429
• Возврат пустого массива вместо ошибки
✅ 3. Улучшена обработка ошибок
• Вложенные try-catch блоки
• Логирование предупреждений вместо ошибок
• Приложение не падает при ошибках API
✅ 4. Защита от падения приложения
Все ошибки обрабатываются
• Возвращаются пустые массивы вместо ошибок
• Приложение продолжает работать
ИЗМЕНЕННЫЕ ФАЙЛЫ:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Backend:
• backend/routes/search.js
ОБНОВЛЕНИЕ (1 файл):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
cd /Users/glpshchn/Desktop/nakama
# Backend
scp backend/routes/search.js root@ваш_IP:/var/www/nakama/backend/routes/
# На сервере
ssh root@ваш_IP "cd /var/www/nakama/backend && pm2 restart nakama-backend"
ЧТО ИСПРАВЛЕНО:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. ✅ Проверка на массив перед .map()
2. ✅ Обработка 429 ошибок (rate limit)
3. ✅ Приложение не падает при ошибках API
4. ✅ Возвращаются пустые массивы вместо ошибок
5. ✅ Улучшено логирование (предупреждения вместо ошибок)
ПРИМЕЧАНИЕ:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Теперь приложение:
Не падает при 429 ошибках
Не падает при неверном формате ответа API
• Возвращает пустые массивы вместо ошибок
• Продолжает работать даже при проблемах с API
2 минуты

View File

@ -0,0 +1,104 @@
╔═══════════════════════════════════════════════════════════════════╗
║ ║
║ 🔧 ПРОВЕРКА ПЕРЕМЕННЫХ ОКРУЖЕНИЯ 🔧 ║
║ ║
╚═══════════════════════════════════════════════════════════════════╝
ПРОБЛЕМА:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Токен добавлен, но все еще "не установлен"
→ PM2 не видит переменные из .env файла
РЕШЕНИЕ:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ 1. Проверить .env файл на сервере
ssh root@ваш_IP
cd /var/www/nakama/backend
cat .env
Должно быть:
TELEGRAM_BOT_TOKEN=ваш_токен_без_кавычек
БЕЗ кавычек!
БЕЗ пробелов вокруг =!
✅ 2. Запустить скрипт проверки
cd /var/www/nakama/backend
node check-env.js
Скрипт покажет:
• Есть ли .env файл
• Загружается ли токен
Все переменные из .env
✅ 3. Перезапустить PM2 с --update-env
pm2 restart nakama-backend --update-env
Важно: --update-env обновляет переменные окружения!
✅ 4. Проверить логи
pm2 logs nakama-backend --lines 20
Должно быть:
✅ Telegram Bot инициализирован
Токен: 1234567890...
НЕ должно быть:
⚠️ TELEGRAM_BOT_TOKEN не установлен!
АЛЬТЕРНАТИВНОЕ РЕШЕНИЕ:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Если PM2 не видит .env файл, используйте ecosystem.config.js:
1. Создать ecosystem.config.js:
cd /var/www/nakama
nano ecosystem.config.js
2. Добавить:
module.exports = {
apps: [{
name: 'nakama-backend',
script: './backend/server.js',
env: {
NODE_ENV: 'production',
TELEGRAM_BOT_TOKEN: 'ваш_токен_от_BotFather',
MONGODB_URI: 'mongodb://localhost:27017/nakama',
PORT: 3000
}
}]
};
3. Перезапустить:
pm2 delete nakama-backend
pm2 start ecosystem.config.js
pm2 save
ПРОВЕРКА:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
После всех шагов:
1. Проверьте логи: pm2 logs nakama-backend
2. Должно быть: ✅ Telegram Bot инициализирован
3. Попробуйте отправить фото в Telegram
4. Должно работать!
2 минуты

View File

@ -0,0 +1,79 @@
╔═══════════════════════════════════════════════════════════════════╗
║ ║
║ 🔧 ПОДДЕРЖКА СТОРОННИХ КЛИЕНТОВ ДОБАВЛЕНА! 🔧 ║
║ ║
╚═══════════════════════════════════════════════════════════════════╝
ПРОБЛЕМА:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Сторонние клиенты (Aurogram и т.д.) не поддерживают Telegram Web App API
→ Ошибка: "Telegram User не найден"
РЕШЕНИЕ:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ 1. Добавлена функция isThirdPartyClient()
• Определяет, открыто ли приложение в стороннем клиенте
• Проверяет наличие window.Telegram?.WebApp
✅ 2. Fallback для сторонних клиентов
• Используется mock user для Aurogram и других клиентов
• ID пользователя сохраняется в localStorage
• Стабильный ID между сеансами
✅ 3. Обновлен API interceptor
• Отправляет mock данные для сторонних клиентов
• Backend принимает JSON формат
✅ 4. Улучшен getMockUser()
• Сохраняет ID в localStorage
• Один пользователь = один ID
• Генератор аватаров на основе ID
ИЗМЕНЕННЫЕ ФАЙЛЫ:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Frontend:
• frontend/src/App.jsx
• frontend/src/utils/telegram.js
• frontend/src/utils/api.js
ОБНОВЛЕНИЕ (3 файла):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
cd /Users/glpshchn/Desktop/nakama
# Frontend
scp frontend/src/App.jsx frontend/src/utils/telegram.js frontend/src/utils/api.js root@ваш_IP:/var/www/nakama/frontend/src/
scp frontend/src/utils/telegram.js frontend/src/utils/api.js root@ваш_IP:/var/www/nakama/frontend/src/utils/
# На сервере
ssh root@ваш_IP "cd /var/www/nakama/frontend && npm run build"
ЧТО ИСПРАВЛЕНО:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. ✅ Сторонние клиенты (Aurogram) теперь поддерживаются
2. ✅ Используется mock user для авторизации
3. ✅ ID пользователя сохраняется между сеансами
4. ✅ Backend принимает JSON формат от сторонних клиентов
5. ✅ Приложение работает в любом браузере/клиенте
ПРИМЕЧАНИЕ:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Теперь пользователи могут использовать приложение:
В официальном Telegram клиенте (полный функционал)
В сторонних клиентах (Aurogram, etc.) - через mock user
В браузере (для разработки)
2 минуты