const express = require('express');
const router = express.Router();
const fs = require('fs');
const path = require('path');
const multer = require('multer');
const crypto = require('crypto');
const { authenticateModeration } = require('../middleware/auth');
const { logSecurityEvent } = require('../middleware/logger');
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 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;
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,
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);
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', authenticateModeration, 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', 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();
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', 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,
upload.array('images', 10),
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 {
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;