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 ( +
+

Используйте официальный клиент Telegram

+

+ Для доступа к NakamaHost откройте бота в официальном приложении Telegram. + Если вы уже используете официальный клиент и видите это сообщение, + пожалуйста сообщите об ошибке в  + + @NakamaReportbot + . +

+ +
+ ) } if (error) { diff --git a/frontend/src/components/TelegramLogin.css b/frontend/src/components/TelegramLogin.css deleted file mode 100644 index 85b7b6f..0000000 --- a/frontend/src/components/TelegramLogin.css +++ /dev/null @@ -1,35 +0,0 @@ -.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; -} - diff --git a/frontend/src/components/TelegramLogin.jsx b/frontend/src/components/TelegramLogin.jsx deleted file mode 100644 index eaa44a7..0000000 --- a/frontend/src/components/TelegramLogin.jsx +++ /dev/null @@ -1,55 +0,0 @@ -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 ( -
-
-

Вход через Telegram

-

Авторизуйтесь через Telegram для доступа к приложению

-
-
-
- ) -} - diff --git a/frontend/src/pages/Profile.css b/frontend/src/pages/Profile.css index feffdff..c91c21c 100644 --- a/frontend/src/pages/Profile.css +++ b/frontend/src/pages/Profile.css @@ -119,6 +119,107 @@ border-top: 1px solid var(--divider-color); } +.profile-powered { + margin: 12px auto 0; + text-align: center; + font-size: 12px; + color: var(--text-secondary); + opacity: 0.7; +} + +.donation-card { + display: flex; + flex-direction: column; + gap: 16px; + margin-top: 12px; +} + +.donation-content { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.donation-icon { + width: 36px; + height: 36px; + border-radius: 12px; + background: rgba(255, 59, 48, 0.12); + color: #ff3b30; + display: flex; + align-items: center; + justify-content: center; +} + +.donation-text h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--text-primary); +} + +.donation-text p { + margin: 4px 0 0; + font-size: 14px; + color: var(--text-secondary); + line-height: 1.4; +} + +.donation-button { + align-self: flex-start; + padding: 10px 18px; + border-radius: 12px; + background: var(--text-primary); + color: var(--bg-primary); + border: none; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s ease; +} + +.donation-button:hover { + opacity: 0.85; +} + +.search-switch { + display: flex; + background: var(--bg-primary); + border-radius: 14px; + padding: 6px; + gap: 6px; +} + +.search-switch-btn { + flex: 1; + border: none; + border-radius: 10px; + padding: 10px 12px; + background: transparent; + color: var(--text-secondary); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease; +} + +.search-switch-btn.active { + background: var(--bg-secondary); + color: var(--text-primary); +} + +.search-switch-btn:focus-visible { + outline: 2px solid var(--button-accent); + outline-offset: 2px; +} + +.profile-powered { + text-align: center; + font-size: 12px; + color: var(--text-secondary); + opacity: 0.7; +} + .stat-item { display: flex; flex-direction: column; @@ -304,33 +405,6 @@ border-bottom: 1px solid var(--divider-color); } -.radio-group { - display: flex; - flex-direction: column; - gap: 12px; -} - -.radio-item { - display: flex; - align-items: center; - gap: 12px; - padding: 12px; - border-radius: 12px; - background: var(--bg-primary); - cursor: pointer; -} - -.radio-item input { - width: 20px; - height: 20px; - accent-color: var(--button-accent); -} - -.radio-item span { - font-size: 15px; - color: var(--text-primary); -} - .char-count { text-align: right; font-size: 12px; diff --git a/frontend/src/pages/Profile.jsx b/frontend/src/pages/Profile.jsx index 26da7c2..3681de4 100644 --- a/frontend/src/pages/Profile.jsx +++ b/frontend/src/pages/Profile.jsx @@ -1,22 +1,42 @@ import { useState } from 'react' -import { Settings, Heart, Edit2, Star, Shield } from 'lucide-react' +import { Settings, Heart, Edit2, Shield } from 'lucide-react' import { updateProfile } from '../utils/api' -import { hapticFeedback, openTelegramLink } from '../utils/telegram' +import { hapticFeedback } from '../utils/telegram' import ThemeToggle from '../components/ThemeToggle' import './Profile.css' +const DONATION_URL = 'https://donatepay.ru/don/1435720' +const ALLOWED_SEARCH_PREFERENCES = ['furry', 'anime'] + +const normalizeSearchPreference = (value) => + ALLOWED_SEARCH_PREFERENCES.includes(value) ? value : 'furry' + +const DEFAULT_SETTINGS = { + whitelist: { + noNSFW: true + }, + searchPreference: 'furry' +} + +const normalizeSettings = (rawSettings = {}) => { + const mergedWhitelist = { + ...DEFAULT_SETTINGS.whitelist, + ...(rawSettings.whitelist || {}) + } + + return { + ...DEFAULT_SETTINGS, + ...rawSettings, + whitelist: mergedWhitelist, + searchPreference: normalizeSearchPreference(rawSettings.searchPreference) + } +} + export default function Profile({ user, setUser }) { const [showSettings, setShowSettings] = useState(false) const [showEditBio, setShowEditBio] = useState(false) const [bio, setBio] = useState(user.bio || '') - const [settings, setSettings] = useState(user.settings || { - whitelist: { - noFurry: false, - onlyAnime: false, - noNSFW: true - }, - searchPreference: 'mixed' - }) + const [settings, setSettings] = useState(normalizeSettings(user.settings)) const [saving, setSaving] = useState(false) const handleSaveBio = async () => { @@ -41,8 +61,10 @@ export default function Profile({ user, setUser }) { setSaving(true) hapticFeedback('light') - const updatedUser = await updateProfile({ settings }) - setUser({ ...user, settings }) + const normalizedSettings = normalizeSettings(settings) + await updateProfile({ settings: normalizedSettings }) + setUser({ ...user, settings: normalizedSettings }) + setSettings(normalizedSettings) setShowSettings(false) hapticFeedback('success') } catch (error) { @@ -55,23 +77,22 @@ export default function Profile({ user, setUser }) { const handleDonate = () => { hapticFeedback('light') - // В будущем здесь будет интеграция Telegram Stars - openTelegramLink('https://t.me/donate') + window.open(DONATION_URL, '_blank', 'noopener,noreferrer') } const updateWhitelistSetting = async (key, value) => { - const newSettings = { + const updatedSettings = normalizeSettings({ ...settings, whitelist: { ...settings.whitelist, [key]: value } - } - setSettings(newSettings) + }) + setSettings(updatedSettings) // Сохранить сразу на сервер try { - await updateProfile({ settings: newSettings }) + await updateProfile({ settings: updatedSettings }) hapticFeedback('success') } catch (error) { console.error('Ошибка сохранения настроек:', error) @@ -80,10 +101,11 @@ export default function Profile({ user, setUser }) { } const updateSearchPreference = (value) => { - setSettings({ + const updatedSettings = normalizeSettings({ ...settings, searchPreference: value }) + setSettings(updatedSettings) } return ( @@ -141,6 +163,24 @@ export default function Profile({ user, setUser }) {
+
+
+
+ +
+
+

Поддержите проект

+

Каждый взнос помогает развивать NakamaHost и запускать новые функции.

+
+
+ +
+ +
+ Powered by glpshcn \\ RBach \\ E621 \\ GelBooru +
{/* Быстрые настройки */}
@@ -221,36 +261,6 @@ export default function Profile({ user, setUser }) {

Фильтры контента

-
-
-
Без Furry
-
Скрыть посты с тегом Furry
-
- -
- -
-
-
Только Anime
-
Показывать только Anime
-
- -
-
Без NSFW
@@ -270,36 +280,21 @@ export default function Profile({ user, setUser }) {

Настройки поиска

-
- - - - - +
+ +
diff --git a/frontend/src/pages/Search.css b/frontend/src/pages/Search.css index ea058df..5a88fa4 100644 --- a/frontend/src/pages/Search.css +++ b/frontend/src/pages/Search.css @@ -221,33 +221,6 @@ color: #000000; } -.load-more-container { - padding: 20px; - display: flex; - justify-content: center; -} - -.load-more-btn { - padding: 12px 24px; - border-radius: 12px; - background: var(--bg-primary); - color: var(--text-primary); - font-size: 15px; - font-weight: 600; - border: 1px solid var(--divider-color); - cursor: pointer; - transition: all 0.2s; -} - -.load-more-btn:hover:not(:disabled) { - background: var(--bg-secondary); -} - -.load-more-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - .send-selected-bar { position: fixed; bottom: 80px; diff --git a/frontend/src/pages/Search.jsx b/frontend/src/pages/Search.jsx index b02f583..682c578 100644 --- a/frontend/src/pages/Search.jsx +++ b/frontend/src/pages/Search.jsx @@ -7,7 +7,8 @@ import api from '../utils/api' import './Search.css' export default function Search({ user }) { - const [mode, setMode] = useState(user.settings?.searchPreference || 'furry') + const initialMode = user.settings?.searchPreference === 'anime' ? 'anime' : 'furry' + const [mode, setMode] = useState(initialMode) const [query, setQuery] = useState('') const [results, setResults] = useState([]) const [loading, setLoading] = useState(false) @@ -18,9 +19,6 @@ export default function Search({ user }) { const [selectionMode, setSelectionMode] = useState(false) const [showCreatePost, setShowCreatePost] = useState(false) const [imageForPost, setImageForPost] = useState(null) - const [hasMore, setHasMore] = useState(true) - const [currentPage, setCurrentPage] = useState(1) - const [isLoadingMore, setIsLoadingMore] = useState(false) const touchStartX = useRef(0) const touchEndX = useRef(0) @@ -85,34 +83,6 @@ export default function Search({ user }) { } } - const loadMoreResults = async (searchQuery, pageNum) => { - let newResults = [] - - if (mode === 'furry') { - try { - const furryResults = await searchFurry(searchQuery, { limit: 320, page: pageNum }) - if (furryResults && Array.isArray(furryResults)) { - newResults = [...newResults, ...furryResults] - } - } catch (error) { - console.error('Ошибка e621 поиска:', error) - } - } - - if (mode === 'anime') { - try { - const animeResults = await searchAnime(searchQuery, { limit: 320, page: pageNum }) - if (animeResults && Array.isArray(animeResults)) { - newResults = [...newResults, ...animeResults] - } - } catch (error) { - console.error('Ошибка Gelbooru поиска:', error) - } - } - - return newResults - } - const handleSearch = async (searchQuery = query) => { if (!searchQuery.trim()) return @@ -120,28 +90,31 @@ export default function Search({ user }) { setLoading(true) hapticFeedback('light') setResults([]) - setCurrentPage(1) - setHasMore(true) let allResults = [] - - // Загружаем первую страницу результатов - const firstPageResults = await loadMoreResults(searchQuery, 1) - - if (firstPageResults.length > 0) { - allResults = [...allResults, ...firstPageResults] - - // Если получили меньше 320, значит это последняя страница - if (firstPageResults.length < 320) { - setHasMore(false) - } else { - setHasMore(true) - setCurrentPage(1) + + if (mode === 'furry') { + try { + const furryResults = await searchFurry(searchQuery, { limit: 320, page: 1 }) + if (Array.isArray(furryResults)) { + allResults = [...allResults, ...furryResults] + } + } catch (error) { + console.error('Ошибка e621 поиска:', error) } - } else { - setHasMore(false) } - + + if (mode === 'anime') { + try { + const animeResults = await searchAnime(searchQuery, { limit: 320, page: 1 }) + if (Array.isArray(animeResults)) { + allResults = [...allResults, ...animeResults] + } + } catch (error) { + console.error('Ошибка Gelbooru поиска:', error) + } + } + setResults(allResults) setTagSuggestions([]) @@ -159,29 +132,6 @@ 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) => { // Разбить текущий query по пробелам const queryParts = query.trim().split(/\s+/) @@ -487,17 +437,6 @@ export default function Search({ user }) {
{/* Кнопка загрузки дополнительных результатов */} - {hasMore && !selectionMode && ( -
- -
- )} {/* Кнопка отправки выбранных */} {selectionMode && selectedImages.length > 0 && ( diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 8a3fd9b..b92e776 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -57,22 +57,6 @@ export const verifySession = async (telegramId) => { } // Авторизация через Telegram OAuth (Login Widget) -export const authWithTelegramOAuth = async (userData) => { - // userData от Telegram Login Widget содержит: id, first_name, last_name, username, photo_url, auth_date, hash - const response = await api.post('/auth/oauth', { - user: { - id: userData.id, - first_name: userData.first_name, - last_name: userData.last_name, - username: userData.username, - photo_url: userData.photo_url - }, - auth_date: userData.auth_date, - hash: userData.hash - }) - return response.data.user -} - // Posts API export const getPosts = async (params = {}) => { const response = await api.get('/posts', { params }) diff --git a/moderation/frontend/index.html b/moderation/frontend/index.html new file mode 100644 index 0000000..1444e27 --- /dev/null +++ b/moderation/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + Nakama Moderation + + +
+ + + + diff --git a/moderation/frontend/package.json b/moderation/frontend/package.json new file mode 100644 index 0000000..8b6f0f6 --- /dev/null +++ b/moderation/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "nakama-moderation", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.6.0", + "lucide-react": "^0.292.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "socket.io-client": "^4.7.5" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.31", + "tailwindcss": "^3.3.5", + "vite": "^5.0.0" + } +} + diff --git a/moderation/frontend/src/App.jsx b/moderation/frontend/src/App.jsx new file mode 100644 index 0000000..585a4a7 --- /dev/null +++ b/moderation/frontend/src/App.jsx @@ -0,0 +1,660 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + verifyAuth, + fetchUsers, + banUser, + fetchPosts, + updatePost, + deletePost, + removePostImage, + banPostAuthor, + fetchReports, + updateReportStatus, + publishToChannel +} from './utils/api'; +import { io } from 'socket.io-client'; +import { + Loader2, + Users, + Image as ImageIcon, + ShieldCheck, + SendHorizontal, + MessageSquare, + RefreshCw, + Trash2, + Edit, + Ban +} from 'lucide-react'; + +const TABS = [ + { id: 'users', title: 'Пользователи', icon: Users }, + { id: 'posts', title: 'Посты', icon: ImageIcon }, + { id: 'reports', title: 'Репорты', icon: ShieldCheck }, + { id: 'chat', title: 'Чат', icon: MessageSquare }, + { id: 'publish', title: 'Публикация', icon: SendHorizontal } +]; + +const FILTERS = [ + { id: 'active', label: 'Активные < 7д' }, + { id: 'inactive', label: 'Неактивные' }, + { id: 'banned', label: 'Бан' } +]; + +const slotOptions = Array.from({ length: 10 }, (_, i) => i + 1); + +const initialChatState = { + messages: [], + online: [], + connected: false +}; + +function formatDate(dateString) { + if (!dateString) return '—'; + const date = new Date(dateString); + return date.toLocaleString('ru-RU', { + day: '2-digit', + month: 'short', + hour: '2-digit', + minute: '2-digit' + }); +} + +function classNames(...args) { + return args.filter(Boolean).join(' '); +} + +export default function App() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [tab, setTab] = useState('users'); + + // Users + const [userFilter, setUserFilter] = useState('active'); + const [usersData, setUsersData] = useState({ users: [], total: 0, totalPages: 1 }); + const [usersLoading, setUsersLoading] = useState(false); + + // Posts + const [postsData, setPostsData] = useState({ posts: [], totalPages: 1 }); + const [postsLoading, setPostsLoading] = useState(false); + + // Reports + const [reportsData, setReportsData] = useState({ reports: [], totalPages: 1 }); + const [reportsLoading, setReportsLoading] = useState(false); + + // Publish + const [publishState, setPublishState] = useState({ + description: '', + tags: '', + slot: 1, + files: [] + }); + const [publishing, setPublishing] = useState(false); + + // Chat + const [chatState, setChatState] = useState(initialChatState); + const [chatInput, setChatInput] = useState(''); + const chatSocketRef = useRef(null); + const chatListRef = useRef(null); + + const isTelegram = typeof window !== 'undefined' && window.Telegram?.WebApp; + + useEffect(() => { + if (isTelegram) { + window.Telegram.WebApp.disableVerticalSwipes(); + window.Telegram.WebApp.ready(); + window.Telegram.WebApp.expand(); + } + + const init = async () => { + try { + const response = await verifyAuth(); + setUser(response.data.user); + if (isTelegram) { + window.Telegram.WebApp.MainButton.setText('Закрыть'); + window.Telegram.WebApp.MainButton.show(); + window.Telegram.WebApp.MainButton.onClick(() => window.Telegram.WebApp.close()); + } + setLoading(false); + } catch (err) { + console.error(err); + setError('Нет доступа. Убедитесь, что вы добавлены как администратор.'); + setLoading(false); + } + }; + + init(); + + return () => { + if (isTelegram) { + window.Telegram.WebApp.MainButton.hide(); + } + }; + }, [isTelegram]); + + useEffect(() => { + if (tab === 'users') { + loadUsers(); + } else if (tab === 'posts') { + loadPosts(); + } else if (tab === 'reports') { + loadReports(); + } else if (tab === 'chat' && user) { + initChat(); + } + + return () => { + if (tab !== 'chat' && chatSocketRef.current) { + chatSocketRef.current.disconnect(); + chatSocketRef.current = null; + setChatState(initialChatState); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tab, user, userFilter]); + + const loadUsers = async () => { + setUsersLoading(true); + try { + const data = await fetchUsers({ filter: userFilter }); + setUsersData(data); + } catch (err) { + console.error(err); + } finally { + setUsersLoading(false); + } + }; + + const loadPosts = async () => { + setPostsLoading(true); + try { + const data = await fetchPosts(); + setPostsData(data); + } catch (err) { + console.error(err); + } finally { + setPostsLoading(false); + } + }; + + const loadReports = async () => { + setReportsLoading(true); + try { + const data = await fetchReports(); + setReportsData(data); + } catch (err) { + console.error(err); + } finally { + setReportsLoading(false); + } + }; + + const initChat = () => { + if (!user || chatSocketRef.current) return; + const socket = io('/mod-chat', { + transports: ['websocket', 'polling'] + }); + + socket.on('connect', () => { + socket.emit('auth', { + username: user.username, + telegramId: user.telegramId + }); + }); + + socket.on('ready', () => { + setChatState((prev) => ({ ...prev, connected: true })); + }); + + socket.on('unauthorized', () => { + setChatState((prev) => ({ ...prev, connected: false })); + socket.disconnect(); + }); + + socket.on('message', (message) => { + setChatState((prev) => ({ + ...prev, + messages: [...prev.messages, message] + })); + if (chatListRef.current) { + chatListRef.current.scrollTo({ + top: chatListRef.current.scrollHeight, + behavior: 'smooth' + }); + } + }); + + socket.on('online', (online) => { + setChatState((prev) => ({ ...prev, online })); + }); + + socket.on('disconnect', () => { + setChatState((prev) => ({ ...prev, connected: false })); + }); + + chatSocketRef.current = socket; + }; + + const handleSendChat = () => { + if (!chatSocketRef.current || !chatState.connected) return; + const text = chatInput.trim(); + if (!text) return; + chatSocketRef.current.emit('message', { text }); + setChatInput(''); + }; + + const handleBanUser = async (id, banned) => { + const days = banned ? parseInt(prompt('Введите срок бана в днях', '7'), 10) : 0; + await banUser(id, { banned, days }); + loadUsers(); + }; + + const handlePostEdit = async (post) => { + const newContent = prompt('Новый текст поста', post.content || ''); + if (newContent === null) return; + await updatePost(post.id, { content: newContent }); + loadPosts(); + }; + + const handlePostDelete = async (postId) => { + if (!window.confirm('Удалить пост?')) return; + await deletePost(postId); + loadPosts(); + }; + + const handleRemoveImage = async (postId, index) => { + await removePostImage(postId, index); + loadPosts(); + }; + + const handleBanAuthor = async (postId) => { + const days = parseInt(prompt('Срок бана автора (в днях)', '7'), 10); + await banPostAuthor(postId, { days }); + loadPosts(); + loadUsers(); + }; + + const handleReportStatus = async (reportId, status) => { + await updateReportStatus(reportId, { status }); + loadReports(); + }; + + const handlePublish = async () => { + if (!publishState.files.length) { + alert('Добавьте изображения'); + return; + } + + setPublishing(true); + try { + const formData = new FormData(); + publishState.files.forEach((file) => formData.append('images', file)); + formData.append('description', publishState.description); + formData.append('tags', JSON.stringify( + publishState.tags + .split(/[,\s]+/) + .map((tag) => tag.trim()) + .filter(Boolean) + )); + formData.append('slot', publishState.slot); + + await publishToChannel(formData); + setPublishState({ + description: '', + tags: '', + slot: 1, + files: [] + }); + alert('Опубликовано в канал @reichenbfurry'); + } catch (err) { + console.error(err); + alert('Не удалось опубликовать пост'); + } finally { + setPublishing(false); + } + }; + + const handleFileChange = (event) => { + const files = Array.from(event.target.files || []).slice(0, 10); + setPublishState((prev) => ({ ...prev, files })); + }; + + const renderUsers = () => ( +
+
+

Пользователи

+ +
+
+ {FILTERS.map((filter) => ( + + ))} +
+ + {usersLoading ? ( +
+ +
+ ) : ( +
+ {usersData.users.map((u) => ( +
+
+
@{u.username}
+
+ {u.firstName} {u.lastName || ''} +
+
+ Роль: {u.role} + Активность: {formatDate(u.lastActiveAt)} + {u.banned && Бан до {formatDate(u.bannedUntil)}} +
+
+
+ {u.banned ? ( + + ) : ( + + )} +
+
+ ))} +
+ )} +
+ ); + + const renderPosts = () => ( +
+
+

Посты

+ +
+ {postsLoading ? ( +
+ +
+ ) : ( +
+ {postsData.posts.map((post) => ( +
+
+
+ Автор: @{post.author?.username || 'Удалён'} — {formatDate(post.createdAt)} +
+
{post.content || 'Без текста'}
+
+ Лайки: {post.likesCount} + Комментарии: {post.commentsCount} + {post.isNSFW && NSFW} +
+ {post.images?.length ? ( +
+ {post.images.map((img, idx) => ( +
+ + +
+ ))} +
+ ) : null} +
+
+ + + +
+
+ ))} +
+ )} +
+ ); + + const renderReports = () => ( +
+
+

Репорты

+ +
+ {reportsLoading ? ( +
+ +
+ ) : ( +
+ {reportsData.reports.map((report) => ( +
+
+
+ Репорт от @{report.reporter?.username || 'Unknown'} — {formatDate(report.createdAt)} +
+
Статус: {report.status}
+
+

{report.reason || 'Причина не указана'}

+ {report.post && ( +
+ Пост: {report.post.content || 'Без текста'} +
+ )} +
+
+
+ + +
+
+ ))} +
+ )} +
+ ); + + const renderChat = () => ( +
+
+

Лайвчат админов

+ {chatState.connected ? ( + В сети + ) : ( + Подключение... + )} +
+
+ Онлайн:{' '} + {chatState.online.length + ? chatState.online.map((admin) => `@${admin.username}`).join(', ') + : '—'} +
+
+ {chatState.messages.map((message) => ( +
+
@{message.username}
+
{message.text}
+
{formatDate(message.createdAt)}
+
+ ))} +
+
+ setChatInput(e.target.value)} + placeholder="Сообщение для админов..." + /> + +
+
+ ); + + const renderPublish = () => ( +
+
+

Публикация в @reichenbfurry

+
+
+