Update files
This commit is contained in:
parent
28e6cfb763
commit
51530709a6
|
|
@ -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)
|
||||
|
||||
|
|
@ -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: 'Изображение отправлено!' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
// Централизованная конфигурация приложения
|
||||
// Важно: dotenv.config() должен быть вызван ДО этого файла
|
||||
|
||||
module.exports = {
|
||||
// Сервер
|
||||
|
|
|
|||
|
|
@ -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 не установлен, проверка подписи пропущена');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
@ -11,6 +11,9 @@ const CommentSchema = new mongoose.Schema({
|
|||
required: true,
|
||||
maxlength: 500
|
||||
},
|
||||
editedAt: {
|
||||
type: Date
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,94 +67,143 @@ 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 обязателен' });
|
||||
}
|
||||
|
||||
const response = await axios.get('https://e621.net/posts.json', {
|
||||
params: {
|
||||
tags: query,
|
||||
limit,
|
||||
page
|
||||
},
|
||||
headers: {
|
||||
'User-Agent': 'NakamaSpace/1.0'
|
||||
// Поддержка множественных тегов через пробел
|
||||
// e621 API автоматически обрабатывает теги через пробел в параметре tags
|
||||
|
||||
try {
|
||||
const response = await axios.get('https://e621.net/posts.json', {
|
||||
params: {
|
||||
tags: query.trim(), // Множественные теги через пробел
|
||||
limit: Math.min(parseInt(limit) || 320, 320), // Максимум 320
|
||||
page: parseInt(page) || 1
|
||||
},
|
||||
headers: {
|
||||
'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: [] });
|
||||
}
|
||||
});
|
||||
|
||||
const posts = response.data.posts.map(post => ({
|
||||
id: post.id,
|
||||
url: createProxyUrl(post.file.url),
|
||||
preview: createProxyUrl(post.preview.url),
|
||||
tags: post.tags.general,
|
||||
rating: post.rating,
|
||||
score: post.score.total,
|
||||
source: 'e621'
|
||||
}));
|
||||
|
||||
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),
|
||||
preview: createProxyUrl(post.preview.url),
|
||||
tags: post.tags.general,
|
||||
rating: post.rating,
|
||||
score: post.score.total,
|
||||
source: 'e621'
|
||||
}));
|
||||
|
||||
return res.json({ posts });
|
||||
} catch (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 API:', error);
|
||||
res.status(500).json({ 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 обязателен' });
|
||||
}
|
||||
|
||||
const response = await axios.get('https://gelbooru.com/index.php', {
|
||||
params: {
|
||||
page: 'dapi',
|
||||
s: 'post',
|
||||
q: 'index',
|
||||
json: 1,
|
||||
tags: query,
|
||||
limit,
|
||||
pid: page,
|
||||
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
|
||||
});
|
||||
// Поддержка множественных тегов через пробел
|
||||
// Gelbooru API автоматически обрабатывает теги через пробел в параметре tags
|
||||
|
||||
// Обработка разных форматов ответа Gelbooru
|
||||
let postsData = [];
|
||||
if (Array.isArray(response.data)) {
|
||||
postsData = response.data;
|
||||
} else if (response.data && response.data.post) {
|
||||
postsData = Array.isArray(response.data.post) ? response.data.post : [response.data.post];
|
||||
} else if (response.data && Array.isArray(response.data)) {
|
||||
postsData = response.data;
|
||||
try {
|
||||
const response = await axios.get('https://gelbooru.com/index.php', {
|
||||
params: {
|
||||
page: 'dapi',
|
||||
s: 'post',
|
||||
q: 'index',
|
||||
json: 1,
|
||||
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,
|
||||
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)) {
|
||||
postsData = response.data;
|
||||
} else if (response.data && response.data.post) {
|
||||
postsData = Array.isArray(response.data.post) ? response.data.post : [response.data.post];
|
||||
} else if (response.data && Array.isArray(response.data)) {
|
||||
postsData = response.data;
|
||||
}
|
||||
|
||||
const posts = postsData.map(post => ({
|
||||
id: post.id,
|
||||
url: createProxyUrl(post.file_url),
|
||||
preview: createProxyUrl(post.preview_url || post.thumbnail_url || post.file_url),
|
||||
tags: post.tags ? (typeof post.tags === 'string' ? post.tags.split(' ') : post.tags) : [],
|
||||
rating: post.rating || 'unknown',
|
||||
score: post.score || 0,
|
||||
source: 'gelbooru'
|
||||
}));
|
||||
|
||||
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);
|
||||
}
|
||||
return res.json({ posts: [] }); // Возвращаем пустой массив вместо ошибки
|
||||
}
|
||||
|
||||
const posts = postsData.map(post => ({
|
||||
id: post.id,
|
||||
url: createProxyUrl(post.file_url),
|
||||
preview: createProxyUrl(post.preview_url || post.thumbnail_url || post.file_url),
|
||||
tags: post.tags ? (typeof post.tags === 'string' ? post.tags.split(' ') : post.tags) : [],
|
||||
rating: post.rating || 'unknown',
|
||||
score: post.score || 0,
|
||||
source: 'gelbooru'
|
||||
}));
|
||||
|
||||
res.json({ posts });
|
||||
} catch (error) {
|
||||
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 });
|
||||
console.error('Ошибка поиска Gelbooru:', error);
|
||||
return res.json({ posts: [] }); // Возвращаем пустой массив вместо ошибки
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -167,26 +216,51 @@ router.get('/furry/tags', authenticate, async (req, res) => {
|
|||
return res.json({ tags: [] });
|
||||
}
|
||||
|
||||
const response = await axios.get('https://e621.net/tags.json', {
|
||||
params: {
|
||||
'search[name_matches]': `${query}*`,
|
||||
'search[order]': 'count',
|
||||
limit: 10
|
||||
},
|
||||
headers: {
|
||||
'User-Agent': 'NakamaSpace/1.0'
|
||||
try {
|
||||
const response = await axios.get('https://e621.net/tags.json', {
|
||||
params: {
|
||||
'search[name_matches]': `${query}*`,
|
||||
'search[order]': 'count',
|
||||
limit: 10
|
||||
},
|
||||
headers: {
|
||||
'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: [] });
|
||||
}
|
||||
});
|
||||
|
||||
const tags = response.data.map(tag => ({
|
||||
name: tag.name,
|
||||
count: tag.post_count
|
||||
}));
|
||||
|
||||
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
|
||||
}));
|
||||
|
||||
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,47 +273,72 @@ router.get('/anime/tags', authenticate, async (req, res) => {
|
|||
return res.json({ tags: [] });
|
||||
}
|
||||
|
||||
const response = await axios.get('https://gelbooru.com/index.php', {
|
||||
params: {
|
||||
page: 'dapi',
|
||||
s: 'tag',
|
||||
q: 'index',
|
||||
json: 1,
|
||||
name_pattern: `${query}%`,
|
||||
orderby: 'count',
|
||||
limit: 10,
|
||||
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
|
||||
});
|
||||
|
||||
// Обработка разных форматов ответа Gelbooru
|
||||
let tagsData = [];
|
||||
if (Array.isArray(response.data)) {
|
||||
tagsData = response.data;
|
||||
} else if (response.data && response.data.tag) {
|
||||
tagsData = Array.isArray(response.data.tag) ? response.data.tag : [response.data.tag];
|
||||
} else if (response.data && Array.isArray(response.data)) {
|
||||
tagsData = response.data;
|
||||
try {
|
||||
const response = await axios.get('https://gelbooru.com/index.php', {
|
||||
params: {
|
||||
page: 'dapi',
|
||||
s: 'tag',
|
||||
q: 'index',
|
||||
json: 1,
|
||||
name_pattern: `${query}%`,
|
||||
orderby: 'count',
|
||||
limit: 10,
|
||||
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,
|
||||
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)) {
|
||||
tagsData = response.data;
|
||||
} else if (response.data && response.data.tag) {
|
||||
tagsData = Array.isArray(response.data.tag) ? response.data.tag : [response.data.tag];
|
||||
} else if (response.data && Array.isArray(response.data)) {
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
// В случае ошибки возвращаем пустой массив вместо ошибки
|
||||
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 });
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения тегов Gelbooru:', error.message);
|
||||
if (error.response) {
|
||||
console.error('Gelbooru ответ:', error.response.status, error.response.data);
|
||||
}
|
||||
console.error('Ошибка получения тегов Gelbooru:', error);
|
||||
// В случае ошибки возвращаем пустой массив вместо ошибки
|
||||
res.json({ tags: [] });
|
||||
return res.json({ tags: [] });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
@ -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)}...`);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 при открытии клавиатуры */
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,24 +155,109 @@ export default function CommentsPage({ user }) {
|
|||
<span>Будьте первым!</span>
|
||||
</div>
|
||||
) : (
|
||||
comments.map((c, index) => (
|
||||
<div key={index} className="comment-item fade-in">
|
||||
<img
|
||||
src={c.author.photoUrl || '/default-avatar.png'}
|
||||
alt={c.author.username}
|
||||
className="comment-avatar"
|
||||
/>
|
||||
<div className="comment-content">
|
||||
<div className="comment-header">
|
||||
<span className="comment-author">
|
||||
{c.author.firstName} {c.author.lastName}
|
||||
</span>
|
||||
<span className="comment-time">{formatDate(c.createdAt)}</span>
|
||||
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'}
|
||||
alt={c.author.username}
|
||||
className="comment-avatar"
|
||||
/>
|
||||
<div className="comment-content">
|
||||
<div className="comment-header">
|
||||
<span className="comment-author">
|
||||
{c.author.firstName} {c.author.lastName}
|
||||
</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>
|
||||
<p className="comment-text">{c.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
loadPosts()
|
||||
}, [filter])
|
||||
// Проверить параметр post в URL
|
||||
const postId = searchParams.get('post')
|
||||
if (postId) {
|
||||
setHighlightPostId(postId)
|
||||
// Загрузить конкретный пост если его нет в списке
|
||||
loadSpecificPost(postId)
|
||||
} else {
|
||||
loadPosts()
|
||||
}
|
||||
}, [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 && (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 danger" onClick={handleDelete}>
|
||||
<Trash2 size={20} />
|
||||
<span>Удалить пост</span>
|
||||
</button>
|
||||
<>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
// Если получили меньше 320, значит это последняя страница
|
||||
if (firstPageResults.length < 320) {
|
||||
setHasMore(false)
|
||||
} else {
|
||||
setHasMore(true)
|
||||
setCurrentPage(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Перемешать результаты если mixed режим
|
||||
if (mode === 'mixed') {
|
||||
allResults = allResults.sort(() => Math.random() - 0.5)
|
||||
} 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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 минуты
|
||||
|
||||
|
|
@ -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 минуты
|
||||
|
||||
|
|
@ -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 минуты
|
||||
|
||||
|
|
@ -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 минуты
|
||||
|
||||
|
|
@ -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 минуты
|
||||
|
||||
|
|
@ -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 минуты
|
||||
|
||||
Loading…
Reference in New Issue