const express = require('express'); const router = express.Router(); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { authenticateModeration } = require('../middleware/auth'); const { logSecurityEvent } = require('../middleware/logger'); const { uploadChannelMedia, cleanupOnError } = require('../middleware/upload'); const { deleteFile } = require('../utils/minio'); const User = require('../models/User'); const Post = require('../models/Post'); const Report = require('../models/Report'); const ModerationAdmin = require('../models/ModerationAdmin'); const AdminConfirmation = require('../models/AdminConfirmation'); const { listAdmins, isModerationAdmin, normalizeUsername } = require('../services/moderationAdmin'); const { sendChannelMediaGroup, sendMessageToUser } = require('../bots/serverMonitor'); const config = require('../config'); 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; req.isOwner = true; return next(); } const allowed = await isModerationAdmin({ telegramId, username }); if (!allowed) { return res.status(403).json({ error: 'Недостаточно прав для модерации' }); } req.isModerationAdmin = true; req.isOwner = false; return next(); }; const requireOwner = (req, res, next) => { if (!req.isOwner) { return res.status(403).json({ error: 'Требуются права владельца' }); } 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', authenticateModeration, 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', authenticateModeration, 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', authenticateModeration, 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', authenticateModeration, 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, publishedToChannel: post.publishedToChannel, adminNumber: post.adminNumber, editedAt: post.editedAt, createdAt: post.createdAt })); res.json({ posts: serialized, total, totalPages: Math.ceil(total / limitNum), currentPage: pageNum }); }); router.put('/posts/:id', authenticateModeration, requireModerationAccess, async (req, res) => { const { content, hashtags, tags, isNSFW } = req.body; const post = await Post.findById(req.params.id).populate('author'); if (!post) { return res.status(404).json({ error: 'Пост не найден' }); } // Проверить, может ли админ редактировать этот пост // Админ может редактировать: // 1. Любой пост, если он владелец (req.isOwner) // 2. Только свои посты из канала (где adminNumber совпадает) if (!req.isOwner) { // Получить админа текущего пользователя const admin = await ModerationAdmin.findOne({ telegramId: req.user.telegramId }); // Если это пост из канала, проверить, что админ - автор if (post.publishedToChannel && post.adminNumber) { if (!admin || admin.adminNumber !== post.adminNumber) { return res.status(403).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'); // Если пост был опубликован в канале, обновить его там if (post.publishedToChannel && post.channelMessageId) { try { const { updateChannelMessage } = require('../bots/serverMonitor'); if (updateChannelMessage) { await updateChannelMessage(post.channelMessageId, post.content, post.hashtags); } } catch (error) { console.error('Не удалось обновить сообщение в канале:', error); // Продолжаем выполнение, даже если не удалось обновить в канале } } 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, publishedToChannel: post.publishedToChannel, adminNumber: post.adminNumber, editedAt: post.editedAt, createdAt: post.createdAt } }); }); router.delete('/posts/:id', authenticateModeration, requireModerationAccess, async (req, res) => { const post = await Post.findById(req.params.id); if (!post) { return res.status(404).json({ error: 'Пост не найден' }); } // Удалить изображения из MinIO try { const { deleteFiles } = require('../utils/minio'); const filesToDelete = []; if (post.images && post.images.length > 0) { post.images.forEach(imageUrl => { // Извлекаем имя файла из URL const match = imageUrl.match(/nakama-media\/(.+)$/); if (match) { filesToDelete.push(match[1]); } }); } else if (post.imageUrl) { const match = post.imageUrl.match(/nakama-media\/(.+)$/); if (match) { filesToDelete.push(match[1]); } } if (filesToDelete.length > 0) { await deleteFiles(filesToDelete); console.log(`✅ Удалено ${filesToDelete.length} файлов из MinIO (modApp)`); } } catch (error) { console.error('❌ Ошибка удаления файлов из MinIO:', error); } await Post.deleteOne({ _id: post._id }); res.json({ success: true }); }); router.delete('/posts/:id/images/:index', authenticateModeration, 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(); // Удалить изображение из MinIO if (removed) { try { const match = removed.match(/nakama-media\/(.+)$/); if (match) { await deleteFile(match[1]); console.log(`✅ Удалено изображение из MinIO: ${match[1]}`); } } catch (error) { console.error('❌ Ошибка удаления изображения из MinIO:', error); } } res.json({ images: post.images }); }); router.post('/posts/:id/ban', authenticateModeration, 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', authenticateModeration, 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', authenticateModeration, 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.get('/admins', authenticateModeration, requireModerationAccess, async (req, res) => { try { const admins = await ModerationAdmin.find().sort({ adminNumber: 1 }); res.json({ admins: admins.map(admin => ({ id: admin._id, telegramId: admin.telegramId, username: admin.username, firstName: admin.firstName, lastName: admin.lastName, adminNumber: admin.adminNumber, addedBy: admin.addedBy, createdAt: admin.createdAt })) }); } catch (error) { console.error('Ошибка получения списка админов:', error); res.status(500).json({ error: 'Ошибка получения списка админов' }); } }); // Инициировать добавление админа (только для владельца) router.post('/admins/initiate-add', authenticateModeration, requireModerationAccess, requireOwner, async (req, res) => { try { const { userId, adminNumber } = req.body; if (!userId || !adminNumber) { return res.status(400).json({ error: 'Не указан ID пользователя или номер админа' }); } if (adminNumber < 1 || adminNumber > 10) { return res.status(400).json({ error: 'Номер админа должен быть от 1 до 10' }); } // Проверить, не занят ли номер const existingAdmin = await ModerationAdmin.findOne({ adminNumber }); if (existingAdmin) { return res.status(400).json({ error: 'Номер админа уже занят' }); } // Проверить, существует ли пользователь const user = await User.findById(userId); if (!user) { return res.status(404).json({ error: 'Пользователь не найден' }); } // Проверить, не является ли пользователь уже админом const isAlreadyAdmin = await ModerationAdmin.findOne({ telegramId: user.telegramId }); if (isAlreadyAdmin) { return res.status(400).json({ error: 'Пользователь уже является админом' }); } // Генерировать 6-значный код const code = crypto.randomInt(100000, 999999).toString(); // Сохранить код подтверждения await AdminConfirmation.create({ userId: user.telegramId, code, adminNumber, action: 'add' }); // Отправить код владельцу (req.user - это ты) await sendMessageToUser( req.user.telegramId, `Подтверждение назначения админом\n\n` + `Назначаете пользователя @${user.username} (${user.firstName}) админом.\n` + `Номер админа: ${adminNumber}\n\n` + `Код подтверждения:\n` + `${code}\n\n` + `Код действителен 5 минут.` ); res.json({ success: true, message: 'Код подтверждения отправлен вам в бот', username: user.username }); } catch (error) { console.error('Ошибка инициирования добавления админа:', error); res.status(500).json({ error: 'Ошибка отправки кода подтверждения' }); } }); // Подтвердить добавление админа router.post('/admins/confirm-add', authenticateModeration, requireModerationAccess, requireOwner, async (req, res) => { try { const { userId, code } = req.body; if (!userId || !code) { return res.status(400).json({ error: 'Не указан ID пользователя или код' }); } const user = await User.findById(userId); if (!user) { return res.status(404).json({ error: 'Пользователь не найден' }); } // Найти код подтверждения const confirmation = await AdminConfirmation.findOne({ userId: user.telegramId, code, action: 'add' }); if (!confirmation) { return res.status(400).json({ error: 'Неверный код подтверждения' }); } // Проверить, не занят ли номер const existingAdmin = await ModerationAdmin.findOne({ adminNumber: confirmation.adminNumber }); if (existingAdmin) { await AdminConfirmation.deleteOne({ _id: confirmation._id }); return res.status(400).json({ error: 'Номер админа уже занят' }); } // Добавить админа const newAdmin = await ModerationAdmin.create({ telegramId: user.telegramId, username: normalizeUsername(user.username), firstName: user.firstName, lastName: user.lastName, adminNumber: confirmation.adminNumber, addedBy: normalizeUsername(req.user.username) }); // Удалить код подтверждения await AdminConfirmation.deleteOne({ _id: confirmation._id }); // Уведомить пользователя try { await sendMessageToUser( user.telegramId, `✅ Вы назначены администратором модерации!\n\n` + `Ваш номер: ${confirmation.adminNumber}\n` + `Теперь вы можете использовать модераторское приложение.` ); } catch (error) { console.error('Не удалось отправить уведомление пользователю:', error); } res.json({ success: true, admin: { id: newAdmin._id, telegramId: newAdmin.telegramId, username: newAdmin.username, firstName: newAdmin.firstName, lastName: newAdmin.lastName, adminNumber: newAdmin.adminNumber } }); } catch (error) { console.error('Ошибка подтверждения добавления админа:', error); res.status(500).json({ error: 'Ошибка добавления админа' }); } }); // Инициировать удаление админа (только для владельца) router.post('/admins/initiate-remove', authenticateModeration, requireModerationAccess, requireOwner, async (req, res) => { try { const { adminId } = req.body; if (!adminId) { return res.status(400).json({ error: 'Не указан ID админа' }); } const admin = await ModerationAdmin.findById(adminId); if (!admin) { return res.status(404).json({ error: 'Администратор не найден' }); } // Генерировать 6-значный код const code = crypto.randomInt(100000, 999999).toString(); // Сохранить код подтверждения await AdminConfirmation.create({ userId: admin.telegramId, code, adminNumber: admin.adminNumber, action: 'remove' }); // Отправить код владельцу (req.user - это ты) await sendMessageToUser( req.user.telegramId, `Подтверждение снятия админа\n\n` + `Снимаете пользователя @${admin.username} (${admin.firstName}) с должности админа.\n` + `Номер админа: ${admin.adminNumber}\n\n` + `Код подтверждения:\n` + `${code}\n\n` + `Код действителен 5 минут.` ); res.json({ success: true, message: 'Код подтверждения отправлен вам в бот', username: admin.username }); } catch (error) { console.error('Ошибка инициирования удаления админа:', error); res.status(500).json({ error: 'Ошибка отправки кода подтверждения' }); } }); // Подтвердить удаление админа router.post('/admins/confirm-remove', authenticateModeration, requireModerationAccess, requireOwner, async (req, res) => { try { const { adminId, code } = req.body; if (!adminId || !code) { return res.status(400).json({ error: 'Не указан ID админа или код' }); } const admin = await ModerationAdmin.findById(adminId); if (!admin) { return res.status(404).json({ error: 'Администратор не найден' }); } // Найти код подтверждения const confirmation = await AdminConfirmation.findOne({ userId: admin.telegramId, code, action: 'remove' }); if (!confirmation) { return res.status(400).json({ error: 'Неверный код подтверждения' }); } // Удалить админа await ModerationAdmin.deleteOne({ _id: admin._id }); // Удалить код подтверждения await AdminConfirmation.deleteOne({ _id: confirmation._id }); // Уведомить пользователя try { await sendMessageToUser( admin.telegramId, `❌ Вы сняты с должности администратора модерации\n\n` + `Доступ к модераторскому приложению прекращён.` ); } catch (error) { console.error('Не удалось отправить уведомление пользователю:', error); } res.json({ success: true }); } catch (error) { console.error('Ошибка подтверждения удаления админа:', error); res.status(500).json({ error: 'Ошибка удаления админа' }); } }); // ========== ПУБЛИКАЦИЯ В КАНАЛ ========== router.post( '/channel/publish', authenticateModeration, requireModerationAccess, uploadChannelMedia, async (req, res) => { const { description = '', tags } = req.body; const files = req.files || []; if (!files.length) { return res.status(400).json({ error: 'Загрузите хотя бы одно изображение' }); } // Получить номер админа из базы const admin = await ModerationAdmin.findOne({ telegramId: req.user.telegramId }); // Проверить, что админ имеет номер от 1 до 10 if (!admin || !admin.adminNumber || admin.adminNumber < 1 || admin.adminNumber > 10) { return res.status(403).json({ error: 'Публиковать в канал могут только админы с номерами от 1 до 10. Обратитесь к владельцу для назначения номера.' }); } const slotNumber = admin.adminNumber; 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 { // Отправить в канал и получить message_id const messageId = await sendChannelMediaGroup(files, caption); // Создать пост в базе данных const newPost = new Post({ author: req.user._id, content: description, hashtags: tagsArray.map(tag => tag.replace('#', '').toLowerCase()), images: [], // Медиа хранится в Telegram tags: ['other'], // Можно настроить определение типа контента publishedToChannel: true, channelMessageId: messageId, adminNumber: slotNumber, isNSFW: false }); await newPost.save(); res.json({ success: true, postId: newPost._id, messageId }); } catch (error) { logSecurityEvent('CHANNEL_PUBLISH_FAILED', req, { error: error.message }); res.status(500).json({ error: 'Не удалось опубликовать в канал' }); } } ); module.exports = router;