From ce159a3fcd948681c1b456f7780198091e951544 Mon Sep 17 00:00:00 2001 From: glpshchn <464976@niuitmo.ru> Date: Mon, 10 Nov 2025 23:13:22 +0300 Subject: [PATCH] Update files --- backend/bots/serverMonitor.js | 398 +++++++++++++ backend/config/index.js | 6 + backend/jobs/avatarUpdater.js | 124 ++++ backend/middleware/auth.js | 75 ++- backend/middleware/errorHandler.js | 7 +- backend/middleware/logger.js | 42 +- backend/models/ModerationAdmin.js | 30 + backend/models/User.js | 8 +- backend/routes/auth.js | 36 +- backend/routes/modApp.js | 419 ++++++++++++++ backend/routes/moderation.js | 8 +- backend/routes/search.js | 82 ++- backend/routes/users.js | 34 +- backend/server.js | 50 ++ backend/services/moderationAdmin.js | 89 +++ backend/websocket.js | 80 +++ frontend/src/App.jsx | 64 ++- frontend/src/components/TelegramLogin.css | 35 -- frontend/src/components/TelegramLogin.jsx | 55 -- frontend/src/pages/Profile.css | 128 ++++- frontend/src/pages/Profile.jsx | 153 +++-- frontend/src/pages/Search.css | 27 - frontend/src/pages/Search.jsx | 107 +--- frontend/src/utils/api.js | 16 - moderation/frontend/index.html | 14 + moderation/frontend/package.json | 25 + moderation/frontend/src/App.jsx | 660 ++++++++++++++++++++++ moderation/frontend/src/main.jsx | 11 + moderation/frontend/src/styles.css | 434 ++++++++++++++ moderation/frontend/src/utils/api.js | 64 +++ moderation/frontend/vite.config.js | 25 + package.json | 4 +- 32 files changed, 2909 insertions(+), 401 deletions(-) create mode 100644 backend/bots/serverMonitor.js create mode 100644 backend/jobs/avatarUpdater.js create mode 100644 backend/models/ModerationAdmin.js create mode 100644 backend/routes/modApp.js create mode 100644 backend/services/moderationAdmin.js delete mode 100644 frontend/src/components/TelegramLogin.css delete mode 100644 frontend/src/components/TelegramLogin.jsx create mode 100644 moderation/frontend/index.html create mode 100644 moderation/frontend/package.json create mode 100644 moderation/frontend/src/App.jsx create mode 100644 moderation/frontend/src/main.jsx create mode 100644 moderation/frontend/src/styles.css create mode 100644 moderation/frontend/src/utils/api.js create mode 100644 moderation/frontend/vite.config.js diff --git a/backend/bots/serverMonitor.js b/backend/bots/serverMonitor.js new file mode 100644 index 0000000..15d69cf --- /dev/null +++ b/backend/bots/serverMonitor.js @@ -0,0 +1,398 @@ +const axios = require('axios'); +const os = require('os'); +const { exec } = require('child_process'); +const FormData = require('form-data'); +const fs = require('fs'); +const config = require('../config'); +const { log } = require('../middleware/logger'); +const { listAdmins, addAdmin, removeAdmin, isModerationAdmin, normalizeUsername } = require('../services/moderationAdmin'); + +const BOT_TOKEN = config.moderationBotToken; +const TELEGRAM_API = BOT_TOKEN ? `https://api.telegram.org/bot${BOT_TOKEN}` : null; +const ERROR_SUPPORT_SUFFIX = ' Сообщите об ошибке в https://t.me/NakamaReportbot'; +const OWNER_USERNAMES = new Set(config.moderationOwnerUsernames || []); + +let isPolling = false; +let offset = 0; + +const execAsync = (command) => + new Promise((resolve, reject) => { + exec(command, (error, stdout, stderr) => { + if (error) { + return reject(new Error(stderr || error.message)); + } + resolve(stdout); + }); + }); + +const formatBytes = (bytes) => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + const value = bytes / Math.pow(k, i); + return `${value.toFixed(value >= 10 ? 0 : 1)} ${sizes[i]}`; +}; + +const formatDuration = (seconds) => { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + const segments = []; + if (days) segments.push(`${days}д`); + if (hours) segments.push(`${hours}ч`); + if (minutes || segments.length === 0) segments.push(`${minutes}м`); + + return segments.join(' '); +}; + +const getDiskUsage = async () => { + try { + const output = await execAsync('df -h /'); + const lines = output.trim().split('\n'); + if (lines.length < 2) return null; + const parts = lines[1].split(/\s+/); + return { + filesystem: parts[0], + size: parts[1], + used: parts[2], + available: parts[3], + percent: parseInt(parts[4], 10) + }; + } catch (error) { + log('error', 'Не удалось получить информацию о диске', { error: error.message }); + return null; + } +}; + +const buildStatus = ({ loadPerCore, memUsage, diskUsage }) => { + const issues = []; + let severity = 0; + + if (loadPerCore > 1.5) { + issues.push('Высокая загрузка CPU (>150% на ядро)'); + severity = Math.max(severity, 2); + } else if (loadPerCore > 1.0) { + issues.push('Нагрузка CPU растёт (>100% на ядро)'); + severity = Math.max(severity, 1); + } + + if (memUsage > 90) { + issues.push('Критический уровень памяти (>90%)'); + severity = Math.max(severity, 2); + } else if (memUsage > 75) { + issues.push('Память почти заполнена (>75%)'); + severity = Math.max(severity, 1); + } + + if (diskUsage && diskUsage.percent) { + if (diskUsage.percent > 90) { + issues.push('Заканчивается место на диске (>90%)'); + severity = Math.max(severity, 2); + } else if (diskUsage.percent > 80) { + issues.push('Мало свободного места на диске (>80%)'); + severity = Math.max(severity, 1); + } + } + + if (severity === 0) { + return { icon: '✅', text: 'Нагрузка в норме' }; + } + + if (severity === 1) { + return { icon: '⚠️', text: `Есть предупреждения:\n${issues.map((i) => `• ${i}`).join('\n')}` }; + } + + return { icon: '🔥', text: `Критические метрики:\n${issues.map((i) => `• ${i}`).join('\n')}` }; +}; + +const buildStatsMessage = async () => { + const load = os.loadavg(); + const cpuCount = os.cpus().length || 1; + const loadPerCore = load[0] / cpuCount; + + const totalMem = os.totalmem(); + const freeMem = os.freemem(); + const usedMem = totalMem - freeMem; + const memUsage = (usedMem / totalMem) * 100; + + const diskUsage = await getDiskUsage(); + + const uptime = formatDuration(os.uptime()); + const processUptime = formatDuration(process.uptime()); + + const status = buildStatus({ loadPerCore, memUsage, diskUsage }); + + const now = new Date(); + const formatter = new Intl.DateTimeFormat('ru-RU', { + dateStyle: 'long', + timeStyle: 'medium' + }); + + const lines = [ + `Статистика сервера ${status.icon}`, + `⏰ Время: ${formatter.format(now)}`, + `🆙 Аптайм системы: ${uptime}`, + `🔁 Аптайм процесса: ${processUptime}`, + '', + `🧠 Загрузка CPU: ${(load[0] || 0).toFixed(2)} / ${(load[1] || 0).toFixed(2)} / ${(load[2] || 0).toFixed(2)}`, + `⚙️ На ядро: ${(loadPerCore * 100).toFixed(0)}% (ядер: ${cpuCount})`, + '', + `💾 Память: ${formatBytes(usedMem)} / ${formatBytes(totalMem)} (${memUsage.toFixed(1)}%)`, + `🟢 Свободно: ${formatBytes(freeMem)}`, + '' + ]; + + if (diskUsage) { + lines.push( + `💽 Диск: ${diskUsage.used} / ${diskUsage.size} (${diskUsage.percent}% занято)`, + `📂 Свободно: ${diskUsage.available}` + ); + } else { + lines.push('💽 Диск: не удалось получить информацию'); + } + + lines.push('', `🏷️ Платформа: ${os.type()} ${os.release()}`, `🔧 Node.js: ${process.version}`); + + if (status.text) { + lines.push('', status.text); + } + + return lines.join('\n'); +}; + +const sendMessage = async (chatId, text) => { + if (!TELEGRAM_API) return; + + try { + await axios.post(`${TELEGRAM_API}/sendMessage`, { + chat_id: chatId, + text: `${text}${ERROR_SUPPORT_SUFFIX}`, + parse_mode: 'HTML', + disable_web_page_preview: true + }); + } catch (error) { + log('error', 'Не удалось отправить сообщение модераторским ботом', { + error: error.response?.data || error.message + }); + } +}; + +const requireOwner = (message) => { + const username = normalizeUsername(message.from?.username || ''); + return OWNER_USERNAMES.has(username); +}; + +const handleListAdmins = async (chatId) => { + const admins = await listAdmins(); + if (!admins.length) { + await sendMessage(chatId, 'Список модераторов пуст.'); + return; + } + + const lines = admins.map((admin, index) => { + const name = [admin.firstName, admin.lastName].filter(Boolean).join(' ') || '-'; + return `${index + 1}. @${admin.username} (${name || 'нет имени'})`; + }); + + await sendMessage(chatId, `Модераторы MiniApp\n${lines.join('\n')}`); +}; + +const handleAddAdmin = async (chatId, message, args) => { + if (!requireOwner(message)) { + await sendMessage(chatId, 'У вас нет прав добавлять модераторов.'); + return; + } + + const username = normalizeUsername(args[1] || ''); + if (!username) { + await sendMessage(chatId, 'Использование: /addadmin @username'); + return; + } + + try { + const admin = await addAdmin({ username, addedBy: message.from?.username }); + await sendMessage( + chatId, + `✅ @${admin.username} добавлен в список модераторов MiniApp. Теперь этому пользователю доступен модераторский интерфейс.` + ); + } catch (error) { + await sendMessage(chatId, `❌ ${error.message}`); + } +}; + +const handleRemoveAdmin = async (chatId, message, args) => { + if (!requireOwner(message)) { + await sendMessage(chatId, 'У вас нет прав удалять модераторов.'); + return; + } + + const username = normalizeUsername(args[1] || ''); + if (!username) { + await sendMessage(chatId, 'Использование: /removeadmin @username'); + return; + } + + try { + await removeAdmin(username); + await sendMessage(chatId, `✅ @${username} удалён из списка модераторов MiniApp.`); + } catch (error) { + await sendMessage(chatId, `❌ ${error.message}`); + } +}; + +const handleCommand = async (message) => { + const chatId = message.chat.id; + const text = (message.text || '').trim(); + const args = text.split(/\s+/); + const command = args[0].toLowerCase(); + + if (command === '/start') { + await sendMessage( + chatId, + 'NakamaHost Moderation\nКоманды:\n• /load — состояние сервера\n• /admins — список админов\n• /addadmin @username — добавить админа (только владельцы)\n• /removeadmin @username — убрать админа (только владельцы)' + ); + return; + } + + if (command === '/load') { + if (!requireOwner(message)) { + await sendMessage(chatId, 'Команда доступна только владельцу.'); + return; + } + const reply = await buildStatsMessage(); + await sendMessage(chatId, reply); + return; + } + + if (command === '/admins') { + if (!requireOwner(message)) { + await sendMessage(chatId, 'Команда доступна только владельцу.'); + return; + } + await handleListAdmins(chatId); + return; + } + + if (command === '/addadmin') { + if (!requireOwner(message)) { + await sendMessage(chatId, 'Команда доступна только владельцу.'); + return; + } + await handleAddAdmin(chatId, message, args); + return; + } + + if (command === '/removeadmin') { + if (!requireOwner(message)) { + await sendMessage(chatId, 'Команда доступна только владельцу.'); + return; + } + await handleRemoveAdmin(chatId, message, args); + return; + } + + if (command.startsWith('/')) { + await sendMessage(chatId, 'Неизвестная команда. Используйте /start, /load, /admins.'); + } +}; + +const processUpdate = async (update) => { + const message = update.message || update.edited_message; + if (!message || !message.text) { + return; + } + + try { + await handleCommand(message); + } catch (error) { + log('error', 'Ошибка обработки команды модераторского бота', { error: error.message }); + await sendMessage(message.chat.id, `Не удалось обработать команду: ${error.message}`); + } +}; + +const pollUpdates = async () => { + if (!TELEGRAM_API) return; + + while (isPolling) { + try { + const response = await axios.get(`${TELEGRAM_API}/getUpdates`, { + params: { + timeout: 25, + offset + } + }); + + const updates = response.data?.result || []; + for (const update of updates) { + offset = update.update_id + 1; + await processUpdate(update); + } + } catch (error) { + log('error', 'Ошибка опроса Telegram для модераторского бота', { + error: error.response?.data || error.message + }); + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + } +}; + +const startServerMonitorBot = () => { + if (!TELEGRAM_API) { + log('warn', 'MODERATION_BOT_TOKEN не установлен, модераторский бот не запущен'); + return; + } + + if (isPolling) { + return; + } + + isPolling = true; + log('info', 'Модераторский Telegram бот запущен'); + pollUpdates().catch((error) => { + log('error', 'Не удалось запустить модераторский бот', { error: error.message }); + }); +}; + +const sendChannelMediaGroup = async (files, caption) => { + if (!TELEGRAM_API) throw new Error('Модераторский бот не настроен'); + + const chatId = config.moderationChannelUsername || '@reichenbfurry'; + + const form = new FormData(); + const media = files.map((file, index) => ({ + type: 'photo', + media: `attach://file${index}`, + ...(index === 0 ? { caption: `${caption}${ERROR_SUPPORT_SUFFIX}`, parse_mode: 'HTML' } : {}) + })); + + form.append('chat_id', chatId); + form.append('media', JSON.stringify(media)); + + files.forEach((file, index) => { + form.append(`file${index}`, fs.createReadStream(file.path), { + filename: file.filename || `image${index}.jpg` + }); + }); + + try { + await axios.post(`${TELEGRAM_API}/sendMediaGroup`, form, { + headers: form.getHeaders() + }); + } catch (error) { + log('error', 'Не удалось отправить медиа-группу в канал', { error: error.response?.data || error.message }); + throw error; + } finally { + files.forEach((file) => { + fs.unlink(file.path, () => {}); + }); + } +}; + +module.exports = { + startServerMonitorBot, + sendChannelMediaGroup, + isModerationAdmin +}; + diff --git a/backend/config/index.js b/backend/config/index.js index 7a627b7..a0175f0 100644 --- a/backend/config/index.js +++ b/backend/config/index.js @@ -17,6 +17,12 @@ module.exports = { // Telegram telegramBotToken: process.env.TELEGRAM_BOT_TOKEN, + moderationBotToken: process.env.MODERATION_BOT_TOKEN || process.env.SERVER_MONITOR_BOT_TOKEN || '7604181694:AAGmnpWtR2rknbZreWNoU3PtVWMFJdlwVmc', + moderationOwnerUsernames: (process.env.MODERATION_OWNER_USERNAMES || 'glpshchn00') + .split(',') + .map((name) => name.trim().toLowerCase()) + .filter(Boolean), + moderationChannelUsername: process.env.MODERATION_CHANNEL_USERNAME || '@reichenbfurry', // Gelbooru API gelbooruApiKey: process.env.GELBOORU_API_KEY || '638e2433d451fc02e848811acdafdce08317073c01ed78e38139115c19fe04afa367f736726514ef1337565d4c05b3cbe2c81125c424301e90d29d1f7f4cceff', diff --git a/backend/jobs/avatarUpdater.js b/backend/jobs/avatarUpdater.js new file mode 100644 index 0000000..84506c9 --- /dev/null +++ b/backend/jobs/avatarUpdater.js @@ -0,0 +1,124 @@ +const axios = require('axios'); +const User = require('../models/User'); +const config = require('../config'); +const { log } = require('../middleware/logger'); + +const DAY_MS = 24 * 60 * 60 * 1000; + +async function fetchLatestAvatar(telegramId) { + if (!config.telegramBotToken) { + return null; + } + + try { + const apiBase = `https://api.telegram.org/bot${config.telegramBotToken}`; + + const photosResponse = await axios.get(`${apiBase}/getUserProfilePhotos`, { + params: { + user_id: telegramId, + limit: 1 + }, + timeout: 15000 + }); + + if (!photosResponse.data?.ok || photosResponse.data.result.total_count === 0) { + return null; + } + + const photoSizes = photosResponse.data.result.photos?.[0]; + if (!Array.isArray(photoSizes) || photoSizes.length === 0) { + return null; + } + + const highestQualityPhoto = photoSizes[photoSizes.length - 1]; + if (!highestQualityPhoto?.file_id) { + return null; + } + + const fileResponse = await axios.get(`${apiBase}/getFile`, { + params: { + file_id: highestQualityPhoto.file_id + }, + timeout: 15000 + }); + + if (!fileResponse.data?.ok || !fileResponse.data.result?.file_path) { + return null; + } + + const filePath = fileResponse.data.result.file_path; + return `https://api.telegram.org/file/bot${config.telegramBotToken}/${filePath}`; + } catch (error) { + log('error', 'Ошибка получения аватарки из Telegram', { + telegramId, + error: error.message + }); + return null; + } +} + +async function updateAllUserAvatars() { + if (!config.telegramBotToken) { + log('warn', 'Обновление аватарок отключено: TELEGRAM_BOT_TOKEN не установлен'); + return; + } + + const users = await User.find({ telegramId: { $exists: true } }); + log('info', 'Начато обновление аватарок пользователей', { count: users.length }); + + for (const user of users) { + try { + const latestAvatar = await fetchLatestAvatar(user.telegramId); + if (latestAvatar && latestAvatar !== user.photoUrl) { + user.photoUrl = latestAvatar; + await user.save(); + log('info', 'Аватарка обновлена', { userId: user._id, telegramId: user.telegramId }); + } + } catch (error) { + log('error', 'Не удалось обновить аватарку пользователя', { + userId: user._id, + telegramId: user.telegramId, + error: error.message + }); + } + } + + log('info', 'Обновление аватарок завершено'); +} + +function msUntilNextRun(hour = 3) { + const now = new Date(); + const nextRun = new Date(now); + nextRun.setHours(hour, 0, 0, 0); + if (nextRun <= now) { + nextRun.setDate(nextRun.getDate() + 1); + } + return nextRun.getTime() - now.getTime(); +} + +function scheduleAvatarUpdates() { + if (!config.telegramBotToken) { + log('warn', 'Расписание обновления аватарок отключено: TELEGRAM_BOT_TOKEN не установлен'); + return; + } + + const initialDelay = msUntilNextRun(); + + setTimeout(() => { + updateAllUserAvatars().catch((error) => { + log('error', 'Ошибка при запуске обновления аватарок', { error: error.message }); + }); + + setInterval(() => { + updateAllUserAvatars().catch((error) => { + log('error', 'Ошибка при плановом обновлении аватарок', { error: error.message }); + }); + }, DAY_MS); + }, initialDelay); +} + +module.exports = { + scheduleAvatarUpdates, + updateAllUserAvatars +}; + diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 54f1e70..b69342e 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -4,6 +4,50 @@ const { validateTelegramId } = require('./validator'); const { logSecurityEvent } = require('./logger'); const config = require('../config'); +const OFFICIAL_CLIENT_MESSAGE = 'Используйте официальный клиент. Сообщите об ошибке в https://t.me/NakamaReportbot'; +const ALLOWED_SEARCH_PREFERENCES = ['furry', 'anime']; + +const touchUserActivity = async (user) => { + if (!user) return; + const now = Date.now(); + const shouldUpdate = + !user.lastActiveAt || + Math.abs(now - new Date(user.lastActiveAt).getTime()) > 5 * 60 * 1000; + + if (shouldUpdate) { + user.lastActiveAt = new Date(now); + await user.save(); + } +}; + +const ensureUserSettings = async (user) => { + if (!user) return; + let updated = false; + + if (!user.settings) { + user.settings = {}; + updated = true; + } + + if (!ALLOWED_SEARCH_PREFERENCES.includes(user.settings.searchPreference)) { + user.settings.searchPreference = 'furry'; + updated = true; + } + + if (!user.settings.whitelist) { + user.settings.whitelist = { noNSFW: true }; + updated = true; + } else if (user.settings.whitelist.noNSFW === undefined) { + user.settings.whitelist.noNSFW = true; + updated = true; + } + + if (updated) { + user.markModified('settings'); + await user.save(); + } +}; + // Проверка Telegram Init Data function validateTelegramWebAppData(initData, botToken) { const urlParams = new URLSearchParams(initData); @@ -41,7 +85,7 @@ const authenticate = async (req, res, next) => { const user = await User.findOne({ telegramId: telegramUserId.toString() }); if (!user) { - return res.status(401).json({ error: 'Пользователь не найден' }); + return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE }); } if (user.banned) { @@ -59,7 +103,7 @@ const authenticate = async (req, res, next) => { if (!initData) { console.warn('⚠️ Нет x-telegram-init-data заголовка'); - return res.status(401).json({ error: 'Не авторизован' }); + return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE }); } // Получаем user из initData @@ -84,13 +128,24 @@ const authenticate = async (req, res, next) => { }); await user.save(); console.log(`✅ Создан новый пользователь: ${user.username}`); + } else { + user.username = parsed.user.username || parsed.user.first_name; + user.firstName = parsed.user.first_name; + user.lastName = parsed.user.last_name; + if (parsed.user.photo_url) { + user.photoUrl = parsed.user.photo_url; + } + await user.save(); } + await ensureUserSettings(user); + await touchUserActivity(user); req.user = user; return next(); } + return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE }); } catch (e2) { console.error('❌ Ошибка парсинга initData:', e2.message); - return res.status(401).json({ error: 'Неверный формат данных авторизации' }); + return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE }); } } @@ -98,7 +153,7 @@ const authenticate = async (req, res, next) => { if (!userParam) { console.warn('⚠️ Нет user параметра в initData'); - return res.status(401).json({ error: 'Данные пользователя не найдены' }); + return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE }); } let telegramUser; @@ -106,7 +161,7 @@ const authenticate = async (req, res, next) => { telegramUser = JSON.parse(userParam); } catch (e) { console.error('❌ Ошибка парсинга user:', e.message); - return res.status(401).json({ error: 'Ошибка парсинга данных пользователя' }); + return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE }); } req.telegramUser = telegramUser; @@ -149,8 +204,18 @@ const authenticate = async (req, res, next) => { }); await user.save(); console.log(`✅ Создан новый пользователь: ${user.username}`); + } else { + user.username = telegramUser.username || telegramUser.first_name; + user.firstName = telegramUser.first_name; + user.lastName = telegramUser.last_name; + if (telegramUser.photo_url) { + user.photoUrl = telegramUser.photo_url; + } + await user.save(); } + await ensureUserSettings(user); + await touchUserActivity(user); req.user = user; next(); } catch (error) { diff --git a/backend/middleware/errorHandler.js b/backend/middleware/errorHandler.js index 04bfbd4..7a013c8 100644 --- a/backend/middleware/errorHandler.js +++ b/backend/middleware/errorHandler.js @@ -1,9 +1,10 @@ const config = require('../config'); +const { log } = require('./logger'); // Централизованная обработка ошибок const errorHandler = (err, req, res, next) => { // Логирование ошибки - console.error('❌ Ошибка:', { + log('error', 'Ошибка обработчика', { message: err.message, stack: config.isDevelopment() ? err.stack : undefined, path: req.path, @@ -58,13 +59,13 @@ const notFoundHandler = (req, res, next) => { // Обработка необработанных промисов process.on('unhandledRejection', (reason, promise) => { - console.error('❌ Unhandled Rejection:', reason); + log('error', 'Unhandled Rejection', { reason: reason instanceof Error ? reason.message : reason }); // В production можно отправить уведомление }); // Обработка необработанных исключений process.on('uncaughtException', (error) => { - console.error('❌ Uncaught Exception:', error); + log('error', 'Uncaught Exception', { message: error.message, stack: error.stack }); // Graceful shutdown process.exit(1); }); diff --git a/backend/middleware/logger.js b/backend/middleware/logger.js index 3c48e42..0ccf06d 100644 --- a/backend/middleware/logger.js +++ b/backend/middleware/logger.js @@ -1,17 +1,30 @@ 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 getDatePrefix = () => { + return new Date().toISOString().slice(0, 10); +}; + +const appendLog = (fileName, message) => { + const filePath = path.join(logsDir, fileName); + fs.appendFile(filePath, `${message}\n`, (err) => { + if (err) { + console.error('Ошибка записи в лог файл:', err); + } + }); +}; + // Функция для логирования const log = (level, message, data = {}) => { const timestamp = new Date().toISOString(); const logMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`; + const serializedData = Object.keys(data).length ? ` ${JSON.stringify(data)}` : ''; + const fullMessage = `${logMessage}${serializedData}`; // Логирование в консоль if (level === 'error') { @@ -22,17 +35,8 @@ const log = (level, message, data = {}) => { 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); - } - }); - } + const fileName = `${level}-${getDatePrefix()}.log`; + appendLog(fileName, fullMessage); }; // Middleware для логирования запросов @@ -89,16 +93,8 @@ const logSecurityEvent = (type, req, 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); - } - }); - } + const securityMessage = `[${new Date().toISOString()}] [SECURITY] ${type}: ${JSON.stringify(securityData)}`; + appendLog(`security-${getDatePrefix()}.log`, securityMessage); }; module.exports = { diff --git a/backend/models/ModerationAdmin.js b/backend/models/ModerationAdmin.js new file mode 100644 index 0000000..03716d5 --- /dev/null +++ b/backend/models/ModerationAdmin.js @@ -0,0 +1,30 @@ +const mongoose = require('mongoose'); + +const ModerationAdminSchema = new mongoose.Schema({ + telegramId: { + type: String, + required: true, + unique: true + }, + username: { + type: String, + required: true, + lowercase: true, + trim: true, + unique: true + }, + firstName: String, + lastName: String, + addedBy: { + type: String, + lowercase: true, + trim: true + }, + createdAt: { + type: Date, + default: Date.now + } +}); + +module.exports = mongoose.model('ModerationAdmin', ModerationAdminSchema); + diff --git a/backend/models/User.js b/backend/models/User.js index 04ef57e..5e4c897 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -39,10 +39,14 @@ const UserSchema = new mongoose.Schema({ }, searchPreference: { type: String, - enum: ['furry', 'anime', 'mixed'], - default: 'mixed' + enum: ['furry', 'anime'], + default: 'furry' } }, + lastActiveAt: { + type: Date, + default: Date.now + }, banned: { type: Boolean, default: false diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 3e4e97c..33c85de 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -7,6 +7,30 @@ const { validateTelegramId } = require('../middleware/validator'); const { logSecurityEvent } = require('../middleware/logger'); const { strictAuthLimiter } = require('../middleware/security'); +const ALLOWED_SEARCH_PREFERENCES = ['furry', 'anime']; + +const normalizeUserSettings = (settings = {}) => { + const plainSettings = typeof settings.toObject === 'function' ? settings.toObject() : { ...settings }; + const whitelistSource = plainSettings.whitelist; + const whitelist = + whitelistSource && typeof whitelistSource.toObject === 'function' + ? whitelistSource.toObject() + : { ...(whitelistSource || {}) }; + + return { + ...plainSettings, + whitelist: { + noNSFW: whitelist?.noNSFW ?? true, + ...whitelist + }, + searchPreference: ALLOWED_SEARCH_PREFERENCES.includes(plainSettings.searchPreference) + ? plainSettings.searchPreference + : 'furry' + }; +}; + +const OFFICIAL_CLIENT_MESSAGE = 'Используйте официальный клиент. Сообщите об ошибке в https://t.me/NakamaReportbot'; + // Проверка подписи Telegram OAuth (Login Widget) function validateTelegramOAuth(authData, botToken) { if (!authData || !authData.hash) { @@ -129,6 +153,8 @@ router.post('/oauth', strictAuthLimiter, async (req, res) => { { path: 'following', select: 'username firstName lastName photoUrl' } ]); + const settings = normalizeUserSettings(populatedUser.settings); + res.json({ success: true, user: { @@ -142,7 +168,7 @@ router.post('/oauth', strictAuthLimiter, async (req, res) => { role: populatedUser.role, followersCount: populatedUser.followers.length, followingCount: populatedUser.following.length, - settings: populatedUser.settings, + settings, banned: populatedUser.banned } }); @@ -162,6 +188,8 @@ router.post('/verify', authenticate, async (req, res) => { { path: 'following', select: 'username firstName lastName photoUrl' } ]); + const settings = normalizeUserSettings(user.settings); + res.json({ success: true, user: { @@ -175,7 +203,7 @@ router.post('/verify', authenticate, async (req, res) => { role: user.role, followersCount: user.followers.length, followingCount: user.following.length, - settings: user.settings, + settings, banned: user.banned } }); @@ -198,7 +226,7 @@ router.post('/session', async (req, res) => { const user = await User.findOne({ telegramId: telegramId.toString() }); if (!user) { - return res.status(404).json({ error: 'Пользователь не найден' }); + return res.status(404).json({ error: OFFICIAL_CLIENT_MESSAGE }); } if (user.banned) { @@ -224,7 +252,7 @@ router.post('/session', async (req, res) => { role: populatedUser.role, followersCount: populatedUser.followers.length, followingCount: populatedUser.following.length, - settings: populatedUser.settings, + settings, banned: populatedUser.banned } }); diff --git a/backend/routes/modApp.js b/backend/routes/modApp.js new file mode 100644 index 0000000..fd925fa --- /dev/null +++ b/backend/routes/modApp.js @@ -0,0 +1,419 @@ +const express = require('express'); +const router = express.Router(); +const fs = require('fs'); +const path = require('path'); +const multer = require('multer'); +const { authenticate } = require('../middleware/auth'); +const { logSecurityEvent } = require('../middleware/logger'); +const User = require('../models/User'); +const Post = require('../models/Post'); +const Report = require('../models/Report'); +const { listAdmins, isModerationAdmin, normalizeUsername } = require('../services/moderationAdmin'); +const { sendChannelMediaGroup } = require('../bots/serverMonitor'); +const config = require('../config'); + +const TEMP_DIR = path.join(__dirname, '../uploads/mod-channel'); +if (!fs.existsSync(TEMP_DIR)) { + fs.mkdirSync(TEMP_DIR, { recursive: true }); +} + +const upload = multer({ + storage: multer.diskStorage({ + destination: TEMP_DIR, + filename: (_req, file, cb) => { + const unique = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; + const ext = path.extname(file.originalname || ''); + cb(null, `${unique}${ext || '.jpg'}`); + } + }), + limits: { + files: 10, + fileSize: 15 * 1024 * 1024 // 15MB + } +}); + +const OWNER_USERNAMES = new Set(config.moderationOwnerUsernames || []); + +const requireModerationAccess = async (req, res, next) => { + const username = normalizeUsername(req.user?.username); + const telegramId = req.user?.telegramId; + + if (!username || !telegramId) { + return res.status(401).json({ error: 'Требуется авторизация' }); + } + + if (OWNER_USERNAMES.has(username) || req.user.role === 'admin') { + req.isModerationAdmin = true; + return next(); + } + + const allowed = await isModerationAdmin({ telegramId, username }); + if (!allowed) { + return res.status(403).json({ error: 'Недостаточно прав для модерации' }); + } + + req.isModerationAdmin = true; + return next(); +}; + +const serializeUser = (user) => ({ + id: user._id, + username: user.username, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + banned: user.banned, + bannedUntil: user.bannedUntil, + lastActiveAt: user.lastActiveAt, + createdAt: user.createdAt +}); + +router.post('/auth/verify', authenticate, requireModerationAccess, async (req, res) => { + const admins = await listAdmins(); + + res.json({ + success: true, + user: { + id: req.user._id, + username: req.user.username, + firstName: req.user.firstName, + lastName: req.user.lastName, + role: req.user.role, + telegramId: req.user.telegramId + }, + admins + }); +}); + +router.get('/users', authenticate, requireModerationAccess, async (req, res) => { + const { filter = 'active', page = 1, limit = 50 } = req.query; + const pageNum = Math.max(parseInt(page, 10) || 1, 1); + const limitNum = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200); + const skip = (pageNum - 1) * limitNum; + + const threshold = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + let query = {}; + + if (filter === 'active') { + query = { lastActiveAt: { $gte: threshold } }; + } else if (filter === 'inactive') { + query = { + $or: [ + { lastActiveAt: { $lt: threshold } }, + { lastActiveAt: { $exists: false } } + ], + banned: { $ne: true } + }; + } else if (filter === 'banned') { + query = { banned: true }; + } + + const [users, total] = await Promise.all([ + User.find(query) + .sort({ lastActiveAt: -1 }) + .skip(skip) + .limit(limitNum) + .lean(), + User.countDocuments(query) + ]); + + res.json({ + users: users.map(serializeUser), + total, + totalPages: Math.ceil(total / limitNum), + currentPage: pageNum + }); +}); + +router.put('/users/:id/ban', authenticate, requireModerationAccess, async (req, res) => { + const { banned, days } = req.body; + + const user = await User.findById(req.params.id); + if (!user) { + return res.status(404).json({ error: 'Пользователь не найден' }); + } + + user.banned = !!banned; + if (user.banned) { + const durationDays = Math.max(parseInt(days, 10) || 7, 1); + user.bannedUntil = new Date(Date.now() + durationDays * 24 * 60 * 60 * 1000); + } else { + user.bannedUntil = null; + } + + await user.save(); + res.json({ user: serializeUser(user) }); +}); + +router.get('/posts', authenticate, requireModerationAccess, async (req, res) => { + const { page = 1, limit = 20, author, tag } = req.query; + const pageNum = Math.max(parseInt(page, 10) || 1, 1); + const limitNum = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 100); + const skip = (pageNum - 1) * limitNum; + + const query = {}; + if (author) { + query.author = author; + } + if (tag) { + query.tags = tag; + } + + const [posts, total] = await Promise.all([ + Post.find(query) + .populate('author', 'username firstName lastName role banned bannedUntil lastActiveAt') + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limitNum) + .lean(), + Post.countDocuments(query) + ]); + + const serialized = posts.map((post) => ({ + id: post._id, + author: post.author ? serializeUser(post.author) : null, + content: post.content, + hashtags: post.hashtags, + tags: post.tags, + images: post.images || (post.imageUrl ? [post.imageUrl] : []), + commentsCount: post.comments?.length || 0, + likesCount: post.likes?.length || 0, + isNSFW: post.isNSFW, + createdAt: post.createdAt + })); + + res.json({ + posts: serialized, + total, + totalPages: Math.ceil(total / limitNum), + currentPage: pageNum + }); +}); + +router.put('/posts/:id', authenticate, requireModerationAccess, async (req, res) => { + const { content, hashtags, tags, isNSFW } = req.body; + + const post = await Post.findById(req.params.id); + if (!post) { + return res.status(404).json({ error: 'Пост не найден' }); + } + + if (content !== undefined) { + post.content = content; + post.hashtags = Array.isArray(hashtags) + ? hashtags.map((tag) => tag.toLowerCase()) + : post.hashtags; + } + + if (tags !== undefined) { + post.tags = Array.isArray(tags) ? tags : post.tags; + } + + if (isNSFW !== undefined) { + post.isNSFW = !!isNSFW; + } + + post.editedAt = new Date(); + await post.save(); + + await post.populate('author', 'username firstName lastName role banned bannedUntil'); + + res.json({ + post: { + id: post._id, + author: post.author ? serializeUser(post.author) : null, + content: post.content, + hashtags: post.hashtags, + tags: post.tags, + images: post.images, + isNSFW: post.isNSFW, + editedAt: post.editedAt, + createdAt: post.createdAt + } + }); +}); + +router.delete('/posts/:id', authenticate, requireModerationAccess, async (req, res) => { + const post = await Post.findById(req.params.id); + if (!post) { + return res.status(404).json({ error: 'Пост не найден' }); + } + + // Удалить локальные изображения + if (post.images && post.images.length) { + post.images.forEach((imagePath) => { + if (!imagePath.startsWith('/uploads')) return; + const fullPath = path.join(__dirname, '..', imagePath); + if (fs.existsSync(fullPath)) { + fs.unlink(fullPath, () => {}); + } + }); + } + + await Post.deleteOne({ _id: post._id }); + res.json({ success: true }); +}); + +router.delete('/posts/:id/images/:index', authenticate, requireModerationAccess, async (req, res) => { + const { id, index } = req.params; + const idx = parseInt(index, 10); + + const post = await Post.findById(id); + if (!post) { + return res.status(404).json({ error: 'Пост не найден' }); + } + + if (!Array.isArray(post.images) || idx < 0 || idx >= post.images.length) { + return res.status(400).json({ error: 'Неверный индекс изображения' }); + } + + const [removed] = post.images.splice(idx, 1); + post.imageUrl = post.images[0] || null; + await post.save(); + + if (removed && removed.startsWith('/uploads')) { + const fullPath = path.join(__dirname, '..', removed); + if (fs.existsSync(fullPath)) { + fs.unlink(fullPath, () => {}); + } + } + + res.json({ images: post.images }); +}); + +router.post('/posts/:id/ban', authenticate, requireModerationAccess, async (req, res) => { + const { id } = req.params; + const { days = 7 } = req.body; + + const post = await Post.findById(id).populate('author'); + if (!post || !post.author) { + return res.status(404).json({ error: 'Пост или автор не найден' }); + } + + const durationDays = Math.max(parseInt(days, 10) || 7, 1); + post.author.banned = true; + post.author.bannedUntil = new Date(Date.now() + durationDays * 24 * 60 * 60 * 1000); + await post.author.save(); + + res.json({ user: serializeUser(post.author) }); +}); + +router.get('/reports', authenticate, requireModerationAccess, async (req, res) => { + const { page = 1, limit = 30, status = 'pending' } = req.query; + const pageNum = Math.max(parseInt(page, 10) || 1, 1); + const limitNum = Math.min(Math.max(parseInt(limit, 10) || 30, 1), 100); + const skip = (pageNum - 1) * limitNum; + + const query = status === 'all' ? {} : { status }; + + const [reports, total] = await Promise.all([ + Report.find(query) + .populate('reporter', 'username firstName lastName telegramId') + .populate({ + path: 'post', + populate: { + path: 'author', + select: 'username firstName lastName telegramId banned bannedUntil' + } + }) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limitNum) + .lean(), + Report.countDocuments(query) + ]); + + res.json({ + reports: reports.map((report) => ({ + id: report._id, + reporter: report.reporter ? serializeUser(report.reporter) : null, + status: report.status, + reason: report.reason || 'Не указана', + createdAt: report.createdAt, + post: report.post + ? { + id: report.post._id, + content: report.post.content, + images: report.post.images || (report.post.imageUrl ? [report.post.imageUrl] : []), + author: report.post.author ? serializeUser(report.post.author) : null + } + : null + })), + total, + totalPages: Math.ceil(total / limitNum), + currentPage: pageNum + }); +}); + +router.put('/reports/:id', authenticate, requireModerationAccess, async (req, res) => { + const { status = 'reviewed' } = req.body; + const report = await Report.findById(req.params.id); + + if (!report) { + return res.status(404).json({ error: 'Репорт не найден' }); + } + + report.status = status; + report.reviewedBy = req.user._id; + await report.save(); + + res.json({ success: true }); +}); + +router.post( + '/channel/publish', + authenticate, + requireModerationAccess, + upload.array('images', 10), + async (req, res) => { + const { description = '', tags, slot } = req.body; + const files = req.files || []; + + if (!files.length) { + return res.status(400).json({ error: 'Загрузите хотя бы одно изображение' }); + } + + const slotNumber = Math.max(Math.min(parseInt(slot, 10) || 1, 10), 1); + + let tagsArray = []; + if (typeof tags === 'string' && tags.trim()) { + try { + tagsArray = JSON.parse(tags); + } catch { + tagsArray = tags.split(/[,\s]+/).filter(Boolean); + } + } else if (Array.isArray(tags)) { + tagsArray = tags; + } + + const formattedTags = tagsArray + .map((tag) => tag.trim()) + .filter(Boolean) + .map((tag) => (tag.startsWith('#') ? tag : `#${tag}`)); + + if (!formattedTags.includes(`#a${slotNumber}`)) { + formattedTags.push(`#a${slotNumber}`); + } + + const captionLines = []; + if (description) { + captionLines.push(description); + } + if (formattedTags.length) { + captionLines.push('', formattedTags.join(' ')); + } + + const caption = captionLines.join('\n'); + + try { + await sendChannelMediaGroup(files, caption); + res.json({ success: true }); + } catch (error) { + logSecurityEvent('CHANNEL_PUBLISH_FAILED', req, { error: error.message }); + res.status(500).json({ error: 'Не удалось опубликовать в канал' }); + } + } +); + +module.exports = router; + diff --git a/backend/routes/moderation.js b/backend/routes/moderation.js index 1423257..3916ec8 100644 --- a/backend/routes/moderation.js +++ b/backend/routes/moderation.js @@ -10,19 +10,21 @@ router.post('/report', authenticate, async (req, res) => { try { const { postId, reason } = req.body; - if (!postId || !reason) { - return res.status(400).json({ error: 'postId и reason обязательны' }); + if (!postId) { + return res.status(400).json({ error: 'postId обязателен' }); } const post = await Post.findById(postId); if (!post) { return res.status(404).json({ error: 'Пост не найден' }); } + + const finalReason = reason?.trim() || 'Без указания причины'; const report = new Report({ reporter: req.user._id, post: postId, - reason + reason: finalReason }); await report.save(); diff --git a/backend/routes/search.js b/backend/routes/search.js index e19c108..7356f1a 100644 --- a/backend/routes/search.js +++ b/backend/routes/search.js @@ -4,6 +4,46 @@ const axios = require('axios'); const { authenticate } = require('../middleware/auth'); const config = require('../config'); +const E621_USER_AGENT = 'NakamaSpace/1.0 (by Reichenbach on e621)'; +const CACHE_TTL_MS = 60 * 1000; // 1 минута + +const searchCache = new Map(); + +function getCacheKey(source, params) { + return `${source}:${params.query}:${params.limit || ''}:${params.page || ''}`; +} + +function getFromCache(key) { + const entry = searchCache.get(key); + if (!entry) return null; + if (entry.expires < Date.now()) { + searchCache.delete(key); + return null; + } + return entry.data; +} + +function setCache(key, data) { + if (searchCache.size > 200) { + // Удалить устаревшие записи, если превышен лимит + for (const [cacheKey, entry] of searchCache.entries()) { + if (entry.expires < Date.now()) { + searchCache.delete(cacheKey); + } + } + if (searchCache.size > 200) { + const oldestKey = searchCache.keys().next().value; + if (oldestKey) { + searchCache.delete(oldestKey); + } + } + } + searchCache.set(key, { + data, + expires: Date.now() + CACHE_TTL_MS + }); +} + // Функция для создания прокси URL function createProxyUrl(originalUrl) { if (!originalUrl) return null; @@ -42,7 +82,7 @@ router.get('/proxy/:encodedUrl', async (req, res) => { const response = await axios.get(originalUrl, { responseType: 'stream', headers: { - 'User-Agent': 'NakamaHost/1.0', + 'User-Agent': E621_USER_AGENT, 'Referer': urlObj.origin }, timeout: 30000 // 30 секунд таймаут @@ -73,6 +113,12 @@ router.get('/furry', authenticate, async (req, res) => { return res.status(400).json({ error: 'Параметр query обязателен' }); } + const cacheKey = getCacheKey('e621', { query: query.trim(), limit, page }); + const cached = getFromCache(cacheKey); + if (cached) { + return res.json(cached); + } + // Поддержка множественных тегов через пробел // e621 API автоматически обрабатывает теги через пробел в параметре tags @@ -84,7 +130,7 @@ router.get('/furry', authenticate, async (req, res) => { page: parseInt(page) || 1 }, headers: { - 'User-Agent': 'NakamaHost/1.0' + 'User-Agent': E621_USER_AGENT }, timeout: 30000, validateStatus: (status) => status < 500 // Не бросать ошибку для 429 @@ -112,7 +158,9 @@ router.get('/furry', authenticate, async (req, res) => { source: 'e621' })); - return res.json({ posts }); + const payload = { posts }; + setCache(cacheKey, payload); + return res.json(payload); } catch (error) { // Обработка 429 ошибок if (error.response && error.response.status === 429) { @@ -138,9 +186,11 @@ router.get('/anime', authenticate, async (req, res) => { return res.status(400).json({ error: 'Параметр query обязателен' }); } - // Поддержка множественных тегов через пробел - // Gelbooru API автоматически обрабатывает теги через пробел в параметре tags - + const cacheKey = getCacheKey('gelbooru', { query: query.trim(), limit, page }); + const cached = getFromCache(cacheKey); + if (cached) { + return res.json(cached); + } try { const response = await axios.get('https://gelbooru.com/index.php', { params: { @@ -187,7 +237,9 @@ router.get('/anime', authenticate, async (req, res) => { source: 'gelbooru' })); - return res.json({ posts }); + const payload = { posts }; + setCache(cacheKey, payload); + return res.json(payload); } catch (error) { // Обработка 429 ошибок if (error.response && error.response.status === 429) { @@ -215,6 +267,12 @@ router.get('/furry/tags', authenticate, async (req, res) => { if (!query || query.length < 2) { return res.json({ tags: [] }); } + + const cacheKey = getCacheKey('e621-tags', { query: query.trim().toLowerCase() }); + const cached = getFromCache(cacheKey); + if (cached) { + return res.json(cached); + } try { const response = await axios.get('https://e621.net/tags.json', { @@ -224,7 +282,7 @@ router.get('/furry/tags', authenticate, async (req, res) => { limit: 10 }, headers: { - 'User-Agent': 'NakamaHost/1.0' + 'User-Agent': E621_USER_AGENT }, timeout: 10000, validateStatus: (status) => status < 500 // Не бросать ошибку для 429 @@ -247,7 +305,9 @@ router.get('/furry/tags', authenticate, async (req, res) => { count: tag.post_count })); - return res.json({ tags }); + const payload = { tags }; + setCache(cacheKey, payload); + return res.json(payload); } catch (error) { // Обработка 429 ошибок if (error.response && error.response.status === 429) { @@ -320,7 +380,9 @@ router.get('/anime/tags', authenticate, async (req, res) => { count: tag.count || tag.post_count || 0 })).filter(tag => tag.name); - return res.json({ tags }); + const payload = { tags }; + setCache(cacheKey, payload); + return res.json(payload); } catch (error) { // Обработка 429 ошибок if (error.response && error.response.status === 429) { diff --git a/backend/routes/users.js b/backend/routes/users.js index c34e19e..7eac307 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -5,6 +5,8 @@ const User = require('../models/User'); const Post = require('../models/Post'); const Notification = require('../models/Notification'); +const ALLOWED_SEARCH_PREFERENCES = ['furry', 'anime']; + // Получить профиль пользователя router.get('/:id', authenticate, async (req, res) => { try { @@ -121,7 +123,37 @@ router.put('/profile', authenticate, async (req, res) => { } if (settings) { - req.user.settings = { ...req.user.settings, ...settings }; + req.user.settings = req.user.settings || {}; + + if (settings.whitelist) { + const currentWhitelist = + req.user.settings.whitelist && typeof req.user.settings.whitelist.toObject === 'function' + ? req.user.settings.whitelist.toObject() + : { ...(req.user.settings.whitelist || {}) }; + + req.user.settings.whitelist = { + ...currentWhitelist, + ...settings.whitelist + }; + } + + if (settings.searchPreference) { + req.user.settings.searchPreference = ALLOWED_SEARCH_PREFERENCES.includes(settings.searchPreference) + ? settings.searchPreference + : 'furry'; + } + + if (!req.user.settings.searchPreference) { + req.user.settings.searchPreference = 'furry'; + } + + if (!req.user.settings.whitelist) { + req.user.settings.whitelist = { noNSFW: true }; + } else if (req.user.settings.whitelist.noNSFW === undefined) { + req.user.settings.whitelist.noNSFW = true; + } + + req.user.markModified('settings'); } await req.user.save(); diff --git a/backend/server.js b/backend/server.js index e55d6ad..e7fd724 100644 --- a/backend/server.js +++ b/backend/server.js @@ -24,6 +24,10 @@ const { const { sanitizeInput } = require('./middleware/validator'); const { requestLogger, logSecurityEvent } = require('./middleware/logger'); const { errorHandler, notFoundHandler } = require('./middleware/errorHandler'); +const { scheduleAvatarUpdates } = require('./jobs/avatarUpdater'); +const { startServerMonitorBot } = require('./bots/serverMonitor'); + +const ERROR_SUPPORT_SUFFIX = ' Сообщите об ошибке в https://t.me/NakamaReportbot'; const app = express(); const server = http.createServer(app); @@ -66,6 +70,49 @@ app.use(requestLogger); // Static files app.use('/uploads', express.static(path.join(__dirname, config.uploadsDir))); +// Дополнение ошибок сообщением о канале связи +app.use((req, res, next) => { + const originalJson = res.json.bind(res); + + res.json = (body) => { + const appendSuffix = (obj) => { + if (!obj || typeof obj !== 'object') { + return; + } + + if (typeof obj.error === 'string' && !obj.error.includes(ERROR_SUPPORT_SUFFIX)) { + obj.error += ERROR_SUPPORT_SUFFIX; + } + + if (typeof obj.message === 'string' && res.statusCode >= 400 && !obj.message.includes(ERROR_SUPPORT_SUFFIX)) { + obj.message += ERROR_SUPPORT_SUFFIX; + } + + if (Array.isArray(obj.errors)) { + obj.errors = obj.errors.map((item) => { + if (typeof item === 'string') { + return item.includes(ERROR_SUPPORT_SUFFIX) ? item : `${item}${ERROR_SUPPORT_SUFFIX}`; + } + if (item && typeof item === 'object' && typeof item.message === 'string' && !item.message.includes(ERROR_SUPPORT_SUFFIX)) { + return { ...item, message: `${item.message}${ERROR_SUPPORT_SUFFIX}` }; + } + return item; + }); + } + }; + + if (Array.isArray(body)) { + body.forEach((item) => appendSuffix(item)); + } else { + appendSuffix(body); + } + + return originalJson(body); + }; + + next(); +}); + // DDoS защита (применяется перед другими rate limiters) app.use(ddosProtection); @@ -104,6 +151,7 @@ app.use('/api/search/posts', require('./routes/postSearch')); app.use('/api/moderation', require('./routes/moderation')); app.use('/api/statistics', require('./routes/statistics')); app.use('/api/bot', require('./routes/bot')); +app.use('/api/mod-app', require('./routes/modApp')); // Базовый роут app.get('/', (req, res) => { @@ -118,6 +166,8 @@ app.use(errorHandler); // Инициализировать WebSocket initWebSocket(server); +scheduleAvatarUpdates(); +startServerMonitorBot(); // Graceful shutdown process.on('SIGTERM', () => { diff --git a/backend/services/moderationAdmin.js b/backend/services/moderationAdmin.js new file mode 100644 index 0000000..783f9f4 --- /dev/null +++ b/backend/services/moderationAdmin.js @@ -0,0 +1,89 @@ +const ModerationAdmin = require('../models/ModerationAdmin'); +const User = require('../models/User'); +const config = require('../config'); + +const normalizeUsername = (username = '') => username.replace(/^@/, '').trim().toLowerCase(); + +const ownerUsernames = new Set(config.moderationOwnerUsernames || []); + +const listAdmins = async () => { + const admins = await ModerationAdmin.find().sort({ username: 1 }).lean(); + return admins.map((admin) => ({ + telegramId: admin.telegramId, + username: admin.username, + firstName: admin.firstName, + lastName: admin.lastName, + addedBy: admin.addedBy, + createdAt: admin.createdAt + })); +}; + +const isModerationAdmin = async ({ telegramId, username }) => { + const normalized = normalizeUsername(username); + if (ownerUsernames.has(normalized)) { + return true; + } + + if (telegramId) { + const byId = await ModerationAdmin.findOne({ telegramId }).lean(); + if (byId) { + return true; + } + } + + if (normalized) { + const byUsername = await ModerationAdmin.findOne({ username: normalized }).lean(); + if (byUsername) { + return true; + } + } + + return false; +}; + +const addAdmin = async ({ username, addedBy }) => { + const normalized = normalizeUsername(username); + if (!normalized) { + throw new Error('Укажите username в формате @username'); + } + + const user = await User.findOne({ username: normalized }).lean(); + if (!user) { + throw new Error(`Пользователь ${normalized} не найден в Nakama`); + } + + const existing = await ModerationAdmin.findOne({ username: normalized }); + if (existing) { + return existing; + } + + const admin = new ModerationAdmin({ + telegramId: user.telegramId, + username: normalized, + firstName: user.firstName, + lastName: user.lastName, + addedBy: normalizeUsername(addedBy) + }); + await admin.save(); + return admin; +}; + +const removeAdmin = async (username) => { + const normalized = normalizeUsername(username); + if (!normalized) { + throw new Error('Укажите username в формате @username'); + } + if (ownerUsernames.has(normalized)) { + throw new Error('Нельзя удалить владельца'); + } + await ModerationAdmin.deleteOne({ username: normalized }); +}; + +module.exports = { + listAdmins, + isModerationAdmin, + addAdmin, + removeAdmin, + normalizeUsername +}; + diff --git a/backend/websocket.js b/backend/websocket.js index 17e272e..24e54d5 100644 --- a/backend/websocket.js +++ b/backend/websocket.js @@ -1,8 +1,12 @@ const { Server } = require('socket.io'); const config = require('./config'); const Notification = require('./models/Notification'); +const { isModerationAdmin, normalizeUsername } = require('./services/moderationAdmin'); +const { log } = require('./middleware/logger'); let io = null; +let moderationNamespace = null; +const connectedModerators = new Map(); // Инициализация WebSocket сервера function initWebSocket(server) { @@ -34,10 +38,86 @@ function initWebSocket(server) { }); }); + registerModerationChat(); + console.log('✅ WebSocket сервер инициализирован'); return io; } +function registerModerationChat() { + if (!io || moderationNamespace) { + return; + } + + moderationNamespace = io.of('/mod-chat'); + + const broadcastOnline = () => { + const unique = Array.from( + new Map( + Array.from(connectedModerators.values()).map((value) => [value.username, value]) + ).values() + ); + moderationNamespace.emit('online', unique); + }; + + moderationNamespace.on('connection', (socket) => { + socket.data.authorized = false; + + socket.on('auth', async (payload = {}) => { + const username = normalizeUsername(payload.username); + const telegramId = payload.telegramId; + + if (!username || !telegramId) { + socket.emit('unauthorized'); + return socket.disconnect(true); + } + + const allowed = await isModerationAdmin({ username, telegramId }); + if (!allowed) { + socket.emit('unauthorized'); + return socket.disconnect(true); + } + + socket.data.authorized = true; + socket.data.username = username; + socket.data.telegramId = telegramId; + connectedModerators.set(socket.id, { + username, + telegramId + }); + + socket.emit('ready'); + broadcastOnline(); + }); + + socket.on('message', (payload = {}) => { + if (!socket.data.authorized) { + return; + } + + const text = (payload.text || '').trim(); + if (!text) { + return; + } + + const message = { + id: `${Date.now()}-${Math.round(Math.random() * 1e6)}`, + username: socket.data.username, + telegramId: socket.data.telegramId, + text, + createdAt: new Date().toISOString() + }; + + moderationNamespace.emit('message', message); + }); + + socket.on('disconnect', () => { + connectedModerators.delete(socket.id); + broadcastOnline(); + }); + }); +} + // Отправить уведомление пользователю в real-time function sendNotification(userId, notification) { if (io) { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 91c493a..7daf68d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,7 +1,7 @@ import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router-dom' import { useState, useEffect, useRef } from 'react' -import { initTelegramApp, getTelegramUser, isThirdPartyClient } from './utils/telegram' -import { verifyAuth, authWithTelegramOAuth, verifySession } from './utils/api' +import { initTelegramApp } from './utils/telegram' +import { verifyAuth, verifySession } from './utils/api' import { initTheme } from './utils/theme' import Layout from './components/Layout' import Feed from './pages/Feed' @@ -11,7 +11,6 @@ import Profile from './pages/Profile' import UserProfile from './pages/UserProfile' import CommentsPage from './pages/CommentsPage' import PostMenuPage from './pages/PostMenuPage' -import TelegramLogin from './components/TelegramLogin' import './styles/index.css' function AppContent() { @@ -136,26 +135,6 @@ function AppContent() { } } - const handleTelegramAuth = async (telegramUser) => { - try { - setLoading(true) - // Отправить данные OAuth на backend - const userData = await authWithTelegramOAuth(telegramUser) - - // Сохранить сессию для будущих загрузок - localStorage.setItem('nakama_user', JSON.stringify(userData)) - localStorage.setItem('nakama_auth_type', 'oauth') - - setUser(userData) - setShowLogin(false) - } catch (err) { - console.error('Ошибка авторизации:', err) - setError(err.message || 'Ошибка авторизации через Telegram') - } finally { - setLoading(false) - } - } - if (loading) { return (
+ return ( ++ Для доступа к NakamaHost откройте бота в официальном приложении Telegram. + Если вы уже используете официальный клиент и видите это сообщение, + пожалуйста сообщите об ошибке в + + @NakamaReportbot + . +
+ +Авторизуйтесь через Telegram для доступа к приложению
-Каждый взнос помогает развивать NakamaHost и запускать новые функции.
+{report.reason || 'Причина не указана'}
+ {report.post && ( +Загрузка модераторского интерфейса...
+{error}
+