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({
|
media.push({
|
||||||
type: 'photo',
|
type: 'photo',
|
||||||
media: photoUrl,
|
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'
|
parse_mode: 'HTML'
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -140,7 +140,7 @@ async function sendPhotosToUser(userId, photos) {
|
||||||
media.push({
|
media.push({
|
||||||
type: 'photo',
|
type: 'photo',
|
||||||
media: photoUrl,
|
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'
|
parse_mode: 'HTML'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -167,7 +167,7 @@ async function handleWebAppData(userId, dataString) {
|
||||||
const data = JSON.parse(dataString);
|
const data = JSON.parse(dataString);
|
||||||
|
|
||||||
if (data.action === 'send_image') {
|
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);
|
await sendPhotoToUser(userId, data.url, caption);
|
||||||
return { success: true, message: 'Изображение отправлено!' };
|
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 = {
|
module.exports = {
|
||||||
// Сервер
|
// Сервер
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const User = require('../models/User');
|
const User = require('../models/User');
|
||||||
|
const { validateTelegramId } = require('./validator');
|
||||||
|
const { logSecurityEvent } = require('./logger');
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
// Проверка Telegram Init Data
|
// Проверка Telegram Init Data
|
||||||
function validateTelegramWebAppData(initData, botToken) {
|
function validateTelegramWebAppData(initData, botToken) {
|
||||||
|
|
@ -84,17 +87,29 @@ const authenticate = async (req, res, next) => {
|
||||||
|
|
||||||
req.telegramUser = telegramUser;
|
req.telegramUser = telegramUser;
|
||||||
|
|
||||||
// Проверка подписи Telegram (только в production и если есть токен)
|
// Валидация Telegram ID
|
||||||
if (process.env.NODE_ENV === 'production' && process.env.TELEGRAM_BOT_TOKEN) {
|
if (!validateTelegramId(telegramUser.id)) {
|
||||||
const isValid = validateTelegramWebAppData(initData, process.env.TELEGRAM_BOT_TOKEN);
|
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) {
|
if (!isValid) {
|
||||||
console.warn('⚠️ Неверная подпись Telegram Init Data для пользователя:', telegramUser.id);
|
logSecurityEvent('INVALID_TELEGRAM_SIGNATURE', req, {
|
||||||
// В production можно либо отклонить, либо пропустить с предупреждением
|
telegramId: telegramUser.id,
|
||||||
// Для строгой проверки раскомментируйте:
|
hasToken: !!config.telegramBotToken
|
||||||
// return res.status(401).json({ error: 'Неверные данные авторизации' });
|
});
|
||||||
|
|
||||||
|
// В 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 не установлен, проверка подписи пропущена');
|
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,
|
required: true,
|
||||||
maxlength: 500
|
maxlength: 500
|
||||||
},
|
},
|
||||||
|
editedAt: {
|
||||||
|
type: Date
|
||||||
|
},
|
||||||
createdAt: {
|
createdAt: {
|
||||||
type: Date,
|
type: Date,
|
||||||
default: Date.now
|
default: Date.now
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,130 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
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) => {
|
router.post('/verify', authenticate, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = await req.user.populate([
|
const user = await req.user.populate([
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ const fs = require('fs');
|
||||||
const { authenticate } = require('../middleware/auth');
|
const { authenticate } = require('../middleware/auth');
|
||||||
const { postCreationLimiter, interactionLimiter } = require('../middleware/rateLimiter');
|
const { postCreationLimiter, interactionLimiter } = require('../middleware/rateLimiter');
|
||||||
const { searchLimiter } = 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 Post = require('../models/Post');
|
||||||
const Notification = require('../models/Notification');
|
const Notification = require('../models/Notification');
|
||||||
const { extractHashtags } = require('../utils/hashtags');
|
const { extractHashtags } = require('../utils/hashtags');
|
||||||
|
|
@ -29,10 +32,26 @@ const upload = multer({
|
||||||
storage: storage,
|
storage: storage,
|
||||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
||||||
fileFilter: (req, file, cb) => {
|
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 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);
|
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) {
|
if (mimetype && extname) {
|
||||||
return cb(null, true);
|
return cb(null, true);
|
||||||
} else {
|
} 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 {
|
try {
|
||||||
const { content, tags, mentionedUsers, isNSFW, externalImages } = req.body;
|
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) {
|
if (!parsedTags.length) {
|
||||||
return res.status(400).json({ error: 'Теги обязательны' });
|
return res.status(400).json({ error: 'Теги обязательны' });
|
||||||
}
|
}
|
||||||
|
|
@ -117,10 +153,29 @@ router.post('/', authenticate, postCreationLimiter, uploadMultiple, async (req,
|
||||||
|
|
||||||
// Внешние изображения (из поиска)
|
// Внешние изображения (из поиска)
|
||||||
if (externalImages) {
|
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];
|
images = [...images, ...externalUrls];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ограничение на количество изображений
|
||||||
|
if (images.length > 5) {
|
||||||
|
return res.status(400).json({ error: 'Максимум 5 изображений в посте' });
|
||||||
|
}
|
||||||
|
|
||||||
// Обратная совместимость - imageUrl для первого изображения
|
// Обратная совместимость - imageUrl для первого изображения
|
||||||
const imageUrl = images.length > 0 ? images[0] : null;
|
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) => {
|
router.delete('/:id', authenticate, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -250,8 +364,15 @@ router.delete('/:id', authenticate, async (req, res) => {
|
||||||
return res.status(403).json({ error: 'Нет прав на удаление' });
|
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);
|
const imagePath = path.join(__dirname, '..', post.imageUrl);
|
||||||
if (fs.existsSync(imagePath)) {
|
if (fs.existsSync(imagePath)) {
|
||||||
fs.unlinkSync(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;
|
module.exports = router;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ router.get('/proxy/:encodedUrl', async (req, res) => {
|
||||||
const response = await axios.get(originalUrl, {
|
const response = await axios.get(originalUrl, {
|
||||||
responseType: 'stream',
|
responseType: 'stream',
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'NakamaSpace/1.0',
|
'User-Agent': 'NakamaHost/1.0',
|
||||||
'Referer': urlObj.origin
|
'Referer': urlObj.origin
|
||||||
},
|
},
|
||||||
timeout: 30000 // 30 секунд таймаут
|
timeout: 30000 // 30 секунд таймаут
|
||||||
|
|
@ -67,94 +67,143 @@ router.get('/proxy/:encodedUrl', async (req, res) => {
|
||||||
// e621 API поиск
|
// e621 API поиск
|
||||||
router.get('/furry', authenticate, async (req, res) => {
|
router.get('/furry', authenticate, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { query, limit = 50, page = 1 } = req.query;
|
const { query, limit = 320, page = 1 } = req.query; // e621 поддерживает до 320 постов на страницу
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return res.status(400).json({ error: 'Параметр query обязателен' });
|
return res.status(400).json({ error: 'Параметр query обязателен' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios.get('https://e621.net/posts.json', {
|
// Поддержка множественных тегов через пробел
|
||||||
params: {
|
// e621 API автоматически обрабатывает теги через пробел в параметре tags
|
||||||
tags: query,
|
|
||||||
limit,
|
try {
|
||||||
page
|
const response = await axios.get('https://e621.net/posts.json', {
|
||||||
},
|
params: {
|
||||||
headers: {
|
tags: query.trim(), // Множественные теги через пробел
|
||||||
'User-Agent': 'NakamaSpace/1.0'
|
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 => ({
|
if (!response.data || !response.data.posts || !Array.isArray(response.data.posts)) {
|
||||||
id: post.id,
|
console.warn('⚠️ e621 вернул неверный формат данных');
|
||||||
url: createProxyUrl(post.file.url),
|
return res.json({ posts: [] });
|
||||||
preview: createProxyUrl(post.preview.url),
|
}
|
||||||
tags: post.tags.general,
|
|
||||||
rating: post.rating,
|
const posts = response.data.posts.map(post => ({
|
||||||
score: post.score.total,
|
id: post.id,
|
||||||
source: 'e621'
|
url: createProxyUrl(post.file.url),
|
||||||
}));
|
preview: createProxyUrl(post.preview.url),
|
||||||
|
tags: post.tags.general,
|
||||||
res.json({ posts });
|
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) {
|
} catch (error) {
|
||||||
console.error('Ошибка e621 API:', error);
|
console.error('Ошибка поиска e621:', error);
|
||||||
res.status(500).json({ error: 'Ошибка поиска' });
|
return res.json({ posts: [] }); // Возвращаем пустой массив вместо ошибки
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Gelbooru API поиск
|
// Gelbooru API поиск
|
||||||
router.get('/anime', authenticate, async (req, res) => {
|
router.get('/anime', authenticate, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { query, limit = 50, page = 1 } = req.query;
|
const { query, limit = 320, page = 1 } = req.query; // Gelbooru поддерживает до 320 постов на страницу
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return res.status(400).json({ error: 'Параметр query обязателен' });
|
return res.status(400).json({ error: 'Параметр query обязателен' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios.get('https://gelbooru.com/index.php', {
|
// Поддержка множественных тегов через пробел
|
||||||
params: {
|
// Gelbooru API автоматически обрабатывает теги через пробел в параметре tags
|
||||||
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
|
try {
|
||||||
let postsData = [];
|
const response = await axios.get('https://gelbooru.com/index.php', {
|
||||||
if (Array.isArray(response.data)) {
|
params: {
|
||||||
postsData = response.data;
|
page: 'dapi',
|
||||||
} else if (response.data && response.data.post) {
|
s: 'post',
|
||||||
postsData = Array.isArray(response.data.post) ? response.data.post : [response.data.post];
|
q: 'index',
|
||||||
} else if (response.data && Array.isArray(response.data)) {
|
json: 1,
|
||||||
postsData = response.data;
|
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) {
|
} catch (error) {
|
||||||
console.error('Ошибка Gelbooru API:', error.message);
|
console.error('Ошибка поиска Gelbooru:', error);
|
||||||
if (error.response) {
|
return res.json({ posts: [] }); // Возвращаем пустой массив вместо ошибки
|
||||||
console.error('Gelbooru ответ:', error.response.status, error.response.data);
|
|
||||||
}
|
|
||||||
res.status(500).json({ error: 'Ошибка поиска Gelbooru', details: error.message });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -167,26 +216,51 @@ router.get('/furry/tags', authenticate, async (req, res) => {
|
||||||
return res.json({ tags: [] });
|
return res.json({ tags: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios.get('https://e621.net/tags.json', {
|
try {
|
||||||
params: {
|
const response = await axios.get('https://e621.net/tags.json', {
|
||||||
'search[name_matches]': `${query}*`,
|
params: {
|
||||||
'search[order]': 'count',
|
'search[name_matches]': `${query}*`,
|
||||||
limit: 10
|
'search[order]': 'count',
|
||||||
},
|
limit: 10
|
||||||
headers: {
|
},
|
||||||
'User-Agent': 'NakamaSpace/1.0'
|
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 => ({
|
if (!response.data || !Array.isArray(response.data)) {
|
||||||
name: tag.name,
|
console.warn('⚠️ e621 вернул не массив:', typeof response.data);
|
||||||
count: tag.post_count
|
return res.json({ tags: [] });
|
||||||
}));
|
}
|
||||||
|
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error('Ошибка получения тегов:', 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: [] });
|
return res.json({ tags: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios.get('https://gelbooru.com/index.php', {
|
try {
|
||||||
params: {
|
const response = await axios.get('https://gelbooru.com/index.php', {
|
||||||
page: 'dapi',
|
params: {
|
||||||
s: 'tag',
|
page: 'dapi',
|
||||||
q: 'index',
|
s: 'tag',
|
||||||
json: 1,
|
q: 'index',
|
||||||
name_pattern: `${query}%`,
|
json: 1,
|
||||||
orderby: 'count',
|
name_pattern: `${query}%`,
|
||||||
limit: 10,
|
orderby: 'count',
|
||||||
api_key: config.gelbooruApiKey,
|
limit: 10,
|
||||||
user_id: config.gelbooruUserId
|
api_key: config.gelbooruApiKey,
|
||||||
},
|
user_id: config.gelbooruUserId
|
||||||
headers: {
|
},
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
headers: {
|
||||||
},
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||||
timeout: 30000
|
},
|
||||||
});
|
timeout: 30000,
|
||||||
|
validateStatus: (status) => status < 500 // Не бросать ошибку для 429
|
||||||
// Обработка разных форматов ответа Gelbooru
|
});
|
||||||
let tagsData = [];
|
|
||||||
if (Array.isArray(response.data)) {
|
// Обработка 429 (Too Many Requests)
|
||||||
tagsData = response.data;
|
if (response.status === 429) {
|
||||||
} else if (response.data && response.data.tag) {
|
console.warn('⚠️ Gelbooru rate limit (429)');
|
||||||
tagsData = Array.isArray(response.data.tag) ? response.data.tag : [response.data.tag];
|
return res.json({ tags: [] });
|
||||||
} else if (response.data && Array.isArray(response.data)) {
|
}
|
||||||
tagsData = response.data;
|
|
||||||
|
// Обработка разных форматов ответа 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) {
|
} catch (error) {
|
||||||
console.error('Ошибка получения тегов Gelbooru:', error.message);
|
console.error('Ошибка получения тегов Gelbooru:', error);
|
||||||
if (error.response) {
|
|
||||||
console.error('Gelbooru ответ:', error.response.status, error.response.data);
|
|
||||||
}
|
|
||||||
// В случае ошибки возвращаем пустой массив вместо ошибки
|
// В случае ошибки возвращаем пустой массив вместо ошибки
|
||||||
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 dotenv = require('dotenv');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
|
|
||||||
|
// Загрузить переменные окружения ДО импорта config
|
||||||
|
dotenv.config({ path: path.join(__dirname, '.env') });
|
||||||
|
|
||||||
const { generalLimiter } = require('./middleware/rateLimiter');
|
const { generalLimiter } = require('./middleware/rateLimiter');
|
||||||
const { initRedis } = require('./utils/redis');
|
const { initRedis } = require('./utils/redis');
|
||||||
const { initWebSocket } = require('./websocket');
|
const { initWebSocket } = require('./websocket');
|
||||||
const config = require('./config');
|
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 app = express();
|
||||||
const server = http.createServer(app);
|
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 настройки
|
// CORS настройки
|
||||||
const corsOptions = {
|
const corsOptions = {
|
||||||
origin: config.corsOrigin === '*' ? '*' : config.corsOrigin.split(','),
|
origin: config.corsOrigin === '*' ? '*' : config.corsOrigin.split(','),
|
||||||
credentials: true,
|
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(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)));
|
app.use('/uploads', express.static(path.join(__dirname, config.uploadsDir)));
|
||||||
|
|
||||||
// Доверять proxy для правильного IP (для rate limiting за nginx/cloudflare)
|
// DDoS защита (применяется перед другими rate limiters)
|
||||||
if (config.isProduction()) {
|
app.use(ddosProtection);
|
||||||
app.set('trust proxy', 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rate limiting
|
// Rate limiting
|
||||||
app.use('/api', generalLimiter);
|
app.use('/api', generalLimiter);
|
||||||
|
|
@ -45,10 +82,7 @@ app.get('/health', (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// MongoDB подключение
|
// MongoDB подключение
|
||||||
mongoose.connect(config.mongoUri, {
|
mongoose.connect(config.mongoUri)
|
||||||
useNewUrlParser: true,
|
|
||||||
useUnifiedTopology: true
|
|
||||||
})
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log(`✅ MongoDB подключена: ${config.mongoUri.replace(/\/\/.*@/, '//***@')}`);
|
console.log(`✅ MongoDB подключена: ${config.mongoUri.replace(/\/\/.*@/, '//***@')}`);
|
||||||
// Инициализировать Redis (опционально)
|
// Инициализировать Redis (опционально)
|
||||||
|
|
@ -73,9 +107,15 @@ app.use('/api/bot', require('./routes/bot'));
|
||||||
|
|
||||||
// Базовый роут
|
// Базовый роут
|
||||||
app.get('/', (req, res) => {
|
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
|
// Инициализировать WebSocket
|
||||||
initWebSocket(server);
|
initWebSocket(server);
|
||||||
|
|
||||||
|
|
@ -99,5 +139,15 @@ server.listen(config.port, '0.0.0.0', () => {
|
||||||
if (config.isDevelopment()) {
|
if (config.isDevelopment()) {
|
||||||
console.log(` Frontend: ${config.frontendUrl}`);
|
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="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="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="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>
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
<style>
|
<style>
|
||||||
/* Предотвращение resize при открытии клавиатуры */
|
/* Предотвращение resize при открытии клавиатуры */
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { initTelegramApp, getTelegramUser } from './utils/telegram'
|
import { initTelegramApp, getTelegramUser, isThirdPartyClient } from './utils/telegram'
|
||||||
import { verifyAuth } from './utils/api'
|
import { verifyAuth, authWithTelegramOAuth } from './utils/api'
|
||||||
import { initTheme } from './utils/theme'
|
import { initTheme } from './utils/theme'
|
||||||
import Layout from './components/Layout'
|
import Layout from './components/Layout'
|
||||||
import Feed from './pages/Feed'
|
import Feed from './pages/Feed'
|
||||||
|
|
@ -11,12 +11,14 @@ import Profile from './pages/Profile'
|
||||||
import UserProfile from './pages/UserProfile'
|
import UserProfile from './pages/UserProfile'
|
||||||
import CommentsPage from './pages/CommentsPage'
|
import CommentsPage from './pages/CommentsPage'
|
||||||
import PostMenuPage from './pages/PostMenuPage'
|
import PostMenuPage from './pages/PostMenuPage'
|
||||||
|
import TelegramLogin from './components/TelegramLogin'
|
||||||
import './styles/index.css'
|
import './styles/index.css'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [user, setUser] = useState(null)
|
const [user, setUser] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
const [showLogin, setShowLogin] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Инициализировать тему
|
// Инициализировать тему
|
||||||
|
|
@ -32,13 +34,27 @@ function App() {
|
||||||
// Получить данные пользователя из Telegram
|
// Получить данные пользователя из Telegram
|
||||||
const telegramUser = getTelegramUser()
|
const telegramUser = getTelegramUser()
|
||||||
|
|
||||||
|
// Если нет Telegram Web App API, показываем Login Widget
|
||||||
if (!telegramUser) {
|
if (!telegramUser) {
|
||||||
throw new Error('Telegram User не найден')
|
setShowLogin(true)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Верифицировать через API
|
// Верифицировать через API
|
||||||
const userData = await verifyAuth()
|
const userData = await verifyAuth()
|
||||||
setUser(userData)
|
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) {
|
} catch (err) {
|
||||||
console.error('Ошибка инициализации:', err)
|
console.error('Ошибка инициализации:', err)
|
||||||
setError(err.message)
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<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) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|
@ -80,10 +118,31 @@ function App() {
|
||||||
<p style={{ color: 'var(--text-secondary)', textAlign: 'center' }}>
|
<p style={{ color: 'var(--text-secondary)', textAlign: 'center' }}>
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|
|
||||||
|
|
@ -95,9 +95,11 @@
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
color: white;
|
color: white;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
opacity: 0.7;
|
||||||
|
z-index: 10;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
@ -111,11 +113,42 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel-btn.prev {
|
.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 {
|
.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 {
|
.carousel-dots {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Heart, MessageCircle, MoreVertical, ChevronLeft, ChevronRight } from 'lucide-react'
|
import { Heart, MessageCircle, MoreVertical, ChevronLeft, ChevronRight, Download, Send } from 'lucide-react'
|
||||||
import { likePost, deletePost } from '../utils/api'
|
import { likePost, deletePost, sendPhotoToTelegram } from '../utils/api'
|
||||||
import { hapticFeedback, showConfirm } from '../utils/telegram'
|
import { hapticFeedback, showConfirm } from '../utils/telegram'
|
||||||
import './PostCard.css'
|
import './PostCard.css'
|
||||||
|
|
||||||
|
|
@ -83,7 +83,13 @@ export default function PostCard({ post, currentUser, onUpdate }) {
|
||||||
</div>
|
</div>
|
||||||
</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} />
|
<MoreVertical size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -150,16 +156,46 @@ export default function PostCard({ post, currentUser, onUpdate }) {
|
||||||
<div className="post-actions">
|
<div className="post-actions">
|
||||||
<button
|
<button
|
||||||
className={`action-btn ${liked ? 'active' : ''}`}
|
className={`action-btn ${liked ? 'active' : ''}`}
|
||||||
onClick={handleLike}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleLike()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Heart size={20} fill={liked ? '#FF3B30' : 'none'} stroke={liked ? '#FF3B30' : 'currentColor'} />
|
<Heart size={20} fill={liked ? '#FF3B30' : 'none'} stroke={liked ? '#FF3B30' : 'currentColor'} />
|
||||||
<span>{likesCount}</span>
|
<span>{likesCount}</span>
|
||||||
</button>
|
</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" />
|
<MessageCircle size={20} stroke="currentColor" />
|
||||||
<span>{post.comments.length}</span>
|
<span>{post.comments.length}</span>
|
||||||
</button>
|
</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>
|
||||||
</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 {
|
.comment-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
@ -170,6 +171,76 @@
|
||||||
color: var(--text-secondary);
|
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 {
|
.comment-text {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { ArrowLeft, Send } from 'lucide-react'
|
import { ArrowLeft, Send, Edit, Trash2 } from 'lucide-react'
|
||||||
import { getPosts, commentPost } from '../utils/api'
|
import { getPosts, commentPost, editComment, deleteComment } from '../utils/api'
|
||||||
import { hapticFeedback } from '../utils/telegram'
|
import { hapticFeedback, showConfirm } from '../utils/telegram'
|
||||||
import './CommentsPage.css'
|
import './CommentsPage.css'
|
||||||
|
|
||||||
export default function CommentsPage({ user }) {
|
export default function CommentsPage({ user }) {
|
||||||
|
|
@ -13,6 +13,8 @@ export default function CommentsPage({ user }) {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [comments, setComments] = useState([])
|
const [comments, setComments] = useState([])
|
||||||
|
const [editingCommentId, setEditingCommentId] = useState(null)
|
||||||
|
const [editCommentText, setEditCommentText] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPost()
|
loadPost()
|
||||||
|
|
@ -153,24 +155,109 @@ export default function CommentsPage({ user }) {
|
||||||
<span>Будьте первым!</span>
|
<span>Будьте первым!</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
comments.map((c, index) => (
|
comments.map((c, index) => {
|
||||||
<div key={index} className="comment-item fade-in">
|
const isEditing = editingCommentId === c._id
|
||||||
<img
|
const isOwnComment = c.author._id === user.id
|
||||||
src={c.author.photoUrl || '/default-avatar.png'}
|
const canEdit = isOwnComment || user.role === 'moderator' || user.role === 'admin'
|
||||||
alt={c.author.username}
|
|
||||||
className="comment-avatar"
|
return (
|
||||||
/>
|
<div key={index} className="comment-item fade-in">
|
||||||
<div className="comment-content">
|
<img
|
||||||
<div className="comment-header">
|
src={c.author.photoUrl || '/default-avatar.png'}
|
||||||
<span className="comment-author">
|
alt={c.author.username}
|
||||||
{c.author.firstName} {c.author.lastName}
|
className="comment-avatar"
|
||||||
</span>
|
/>
|
||||||
<span className="comment-time">{formatDate(c.createdAt)}</span>
|
<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>
|
</div>
|
||||||
<p className="comment-text">{c.content}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
))
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -146,3 +146,21 @@
|
||||||
opacity: 0.5;
|
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 { useState, useEffect } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { getPosts } from '../utils/api'
|
import { getPosts } from '../utils/api'
|
||||||
import PostCard from '../components/PostCard'
|
import PostCard from '../components/PostCard'
|
||||||
import CreatePostModal from '../components/CreatePostModal'
|
import CreatePostModal from '../components/CreatePostModal'
|
||||||
|
|
@ -7,16 +8,56 @@ import { hapticFeedback } from '../utils/telegram'
|
||||||
import './Feed.css'
|
import './Feed.css'
|
||||||
|
|
||||||
export default function Feed({ user }) {
|
export default function Feed({ user }) {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
const [posts, setPosts] = useState([])
|
const [posts, setPosts] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
const [filter, setFilter] = useState('all')
|
const [filter, setFilter] = useState('all')
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [hasMore, setHasMore] = useState(true)
|
const [hasMore, setHasMore] = useState(true)
|
||||||
|
const [highlightPostId, setHighlightPostId] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPosts()
|
// Проверить параметр post в URL
|
||||||
}, [filter])
|
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) => {
|
const loadPosts = async (pageNum = 1) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -64,7 +105,7 @@ export default function Feed({ user }) {
|
||||||
<div className="feed-page">
|
<div className="feed-page">
|
||||||
{/* Хедер */}
|
{/* Хедер */}
|
||||||
<div className="feed-header">
|
<div className="feed-header">
|
||||||
<h1>NakamaSpace</h1>
|
<h1>NakamaHost</h1>
|
||||||
<button className="create-btn" onClick={handleCreatePost}>
|
<button className="create-btn" onClick={handleCreatePost}>
|
||||||
<Plus size={20} />
|
<Plus size={20} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -117,7 +158,13 @@ export default function Feed({ user }) {
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{posts.map(post => (
|
{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 && (
|
{hasMore && (
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,11 @@
|
||||||
border-left: 3px solid var(--button-accent);
|
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 {
|
.notification-bubble:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
box-shadow: 0 1px 4px var(--shadow-sm);
|
box-shadow: 0 1px 4px var(--shadow-sm);
|
||||||
|
|
@ -182,3 +187,24 @@
|
||||||
border-left-color: #FF9500;
|
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') {
|
if (notification.type === 'follow') {
|
||||||
navigate(`/user/${notification.sender._id}`)
|
navigate(`/user/${notification.sender._id}`)
|
||||||
} else if (notification.post) {
|
} else if (notification.post) {
|
||||||
// Можно добавить переход к посту
|
// Переход к конкретному посту
|
||||||
navigate('/feed')
|
navigate(`/feed?post=${notification.post._id || notification.post}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,106 @@
|
||||||
color: #FF3B30;
|
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 {
|
.submit-btn {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { ArrowLeft, Trash2, Flag } from 'lucide-react'
|
import { ArrowLeft, Trash2, Flag, Edit, Share2 } from 'lucide-react'
|
||||||
import { getPosts, reportPost, deletePost } from '../utils/api'
|
import { getPosts, reportPost, deletePost, editPost } from '../utils/api'
|
||||||
import { hapticFeedback, showConfirm } from '../utils/telegram'
|
import { hapticFeedback, showConfirm, showAlert } from '../utils/telegram'
|
||||||
import './PostMenuPage.css'
|
import './PostMenuPage.css'
|
||||||
|
|
||||||
export default function PostMenuPage({ user }) {
|
export default function PostMenuPage({ user }) {
|
||||||
|
|
@ -11,7 +11,9 @@ export default function PostMenuPage({ user }) {
|
||||||
const [post, setPost] = useState(null)
|
const [post, setPost] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showReportModal, setShowReportModal] = useState(false)
|
const [showReportModal, setShowReportModal] = useState(false)
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false)
|
||||||
const [reportReason, setReportReason] = useState('')
|
const [reportReason, setReportReason] = useState('')
|
||||||
|
const [editContent, setEditContent] = useState('')
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="post-menu-page">
|
<div className="post-menu-page">
|
||||||
|
|
@ -180,11 +219,28 @@ export default function PostMenuPage({ user }) {
|
||||||
|
|
||||||
{/* Меню */}
|
{/* Меню */}
|
||||||
<div className="menu-items">
|
<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') ? (
|
{(post.author._id === user.id) || (user.role === 'moderator' || user.role === 'admin') ? (
|
||||||
<button className="menu-item danger" onClick={handleDelete}>
|
<>
|
||||||
<Trash2 size={20} />
|
<button
|
||||||
<span>Удалить пост</span>
|
className="menu-item"
|
||||||
</button>
|
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)}>
|
<button className="menu-item" onClick={() => setShowReportModal(true)}>
|
||||||
<Flag size={20} />
|
<Flag size={20} />
|
||||||
|
|
@ -192,6 +248,61 @@ export default function PostMenuPage({ user }) {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,33 @@
|
||||||
color: #000000;
|
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 {
|
.send-selected-bar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 80px;
|
bottom: 80px;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import api from '../utils/api'
|
||||||
import './Search.css'
|
import './Search.css'
|
||||||
|
|
||||||
export default function Search({ user }) {
|
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 [query, setQuery] = useState('')
|
||||||
const [results, setResults] = useState([])
|
const [results, setResults] = useState([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
@ -18,6 +18,9 @@ export default function Search({ user }) {
|
||||||
const [selectionMode, setSelectionMode] = useState(false)
|
const [selectionMode, setSelectionMode] = useState(false)
|
||||||
const [showCreatePost, setShowCreatePost] = useState(false)
|
const [showCreatePost, setShowCreatePost] = useState(false)
|
||||||
const [imageForPost, setImageForPost] = useState(null)
|
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 touchStartX = useRef(0)
|
||||||
const touchEndX = useRef(0)
|
const touchEndX = useRef(0)
|
||||||
|
|
||||||
|
|
@ -31,11 +34,21 @@ export default function Search({ user }) {
|
||||||
|
|
||||||
const loadTagSuggestions = async () => {
|
const loadTagSuggestions = async () => {
|
||||||
try {
|
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 = []
|
let tags = []
|
||||||
|
|
||||||
if (mode === 'furry' || mode === 'mixed') {
|
if (mode === 'furry') {
|
||||||
try {
|
try {
|
||||||
const furryTags = await getFurryTags(query)
|
const furryTags = await getFurryTags(lastTag)
|
||||||
if (furryTags && Array.isArray(furryTags)) {
|
if (furryTags && Array.isArray(furryTags)) {
|
||||||
tags = [...tags, ...furryTags.map(t => ({ ...t, source: 'e621' }))]
|
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 {
|
try {
|
||||||
const animeTags = await getAnimeTags(query)
|
const animeTags = await getAnimeTags(lastTag)
|
||||||
if (animeTags && Array.isArray(animeTags)) {
|
if (animeTags && Array.isArray(animeTags)) {
|
||||||
tags = [...tags, ...animeTags.map(t => ({ ...t, source: 'gelbooru' }))]
|
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) => {
|
const handleSearch = async (searchQuery = query) => {
|
||||||
if (!searchQuery.trim()) return
|
if (!searchQuery.trim()) return
|
||||||
|
|
||||||
|
|
@ -79,36 +120,26 @@ export default function Search({ user }) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
hapticFeedback('light')
|
hapticFeedback('light')
|
||||||
setResults([])
|
setResults([])
|
||||||
|
setCurrentPage(1)
|
||||||
|
setHasMore(true)
|
||||||
|
|
||||||
let allResults = []
|
let allResults = []
|
||||||
|
|
||||||
if (mode === 'furry' || mode === 'mixed') {
|
// Загружаем первую страницу результатов
|
||||||
try {
|
const firstPageResults = await loadMoreResults(searchQuery, 1)
|
||||||
const furryResults = await searchFurry(searchQuery, { limit: 30 })
|
|
||||||
if (furryResults && Array.isArray(furryResults)) {
|
|
||||||
allResults = [...allResults, ...furryResults]
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Ошибка e621 поиска:', error)
|
|
||||||
// Продолжаем поиск даже если e621 не работает
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === 'anime' || mode === 'mixed') {
|
if (firstPageResults.length > 0) {
|
||||||
try {
|
allResults = [...allResults, ...firstPageResults]
|
||||||
const animeResults = await searchAnime(searchQuery, { limit: 30 })
|
|
||||||
if (animeResults && Array.isArray(animeResults)) {
|
// Если получили меньше 320, значит это последняя страница
|
||||||
allResults = [...allResults, ...animeResults]
|
if (firstPageResults.length < 320) {
|
||||||
}
|
setHasMore(false)
|
||||||
} catch (error) {
|
} else {
|
||||||
console.error('Ошибка Gelbooru поиска:', error)
|
setHasMore(true)
|
||||||
// Продолжаем поиск даже если Gelbooru не работает
|
setCurrentPage(1)
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
setHasMore(false)
|
||||||
// Перемешать результаты если mixed режим
|
|
||||||
if (mode === 'mixed') {
|
|
||||||
allResults = allResults.sort(() => Math.random() - 0.5)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setResults(allResults)
|
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) => {
|
const handleTagClick = (tagName) => {
|
||||||
setQuery(tagName)
|
// Разбить текущий query по пробелам
|
||||||
handleSearch(tagName)
|
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) => {
|
const openViewer = (index) => {
|
||||||
|
|
@ -341,12 +403,6 @@ export default function Search({ user }) {
|
||||||
>
|
>
|
||||||
Anime
|
Anime
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
className={`mode-btn ${mode === 'mixed' ? 'active' : ''}`}
|
|
||||||
onClick={() => setMode('mixed')}
|
|
||||||
>
|
|
||||||
Mixed
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Строка поиска */}
|
{/* Строка поиска */}
|
||||||
|
|
@ -430,6 +486,19 @@ export default function Search({ user }) {
|
||||||
})}
|
})}
|
||||||
</div>
|
</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 && (
|
{selectionMode && selectedImages.length > 0 && (
|
||||||
<div className="send-selected-bar">
|
<div className="send-selected-bar">
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,13 @@ export default function UserProfile({ currentUser }) {
|
||||||
) : (
|
) : (
|
||||||
<div className="posts-list">
|
<div className="posts-list">
|
||||||
{posts.map(post => (
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { getTelegramInitData, getMockUser, isDevelopment } from './telegram'
|
import { getTelegramInitData } from './telegram'
|
||||||
|
|
||||||
// API URL из переменных окружения
|
// API URL из переменных окружения
|
||||||
const API_URL = import.meta.env.VITE_API_URL || (
|
const API_URL = import.meta.env.VITE_API_URL || (
|
||||||
|
|
@ -20,11 +20,8 @@ const api = axios.create({
|
||||||
api.interceptors.request.use((config) => {
|
api.interceptors.request.use((config) => {
|
||||||
const initData = getTelegramInitData()
|
const initData = getTelegramInitData()
|
||||||
|
|
||||||
// В dev режиме создаем mock initData
|
// Отправляем initData только если есть
|
||||||
if (!initData && isDevelopment()) {
|
if (initData) {
|
||||||
const mockUser = getMockUser()
|
|
||||||
config.headers['x-telegram-init-data'] = `user=${JSON.stringify(mockUser)}`
|
|
||||||
} else {
|
|
||||||
config.headers['x-telegram-init-data'] = initData
|
config.headers['x-telegram-init-data'] = initData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,6 +34,23 @@ export const verifyAuth = async () => {
|
||||||
return response.data.user
|
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
|
// Posts API
|
||||||
export const getPosts = async (params = {}) => {
|
export const getPosts = async (params = {}) => {
|
||||||
const response = await api.get('/posts', { params })
|
const response = await api.get('/posts', { params })
|
||||||
|
|
@ -62,6 +76,21 @@ export const commentPost = async (postId, content) => {
|
||||||
return response.data
|
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) => {
|
export const repostPost = async (postId) => {
|
||||||
const response = await api.post(`/posts/${postId}/repost`)
|
const response = await api.post(`/posts/${postId}/repost`)
|
||||||
return response.data
|
return response.data
|
||||||
|
|
@ -161,5 +190,11 @@ export const banUser = async (userId, banned, days) => {
|
||||||
return response.data
|
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
|
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 = () => {
|
export const isDevelopment = () => {
|
||||||
return !window.Telegram?.WebApp?.initDataUnsafe?.user
|
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