nakama/backend/routes/modApp.js

420 lines
12 KiB
JavaScript
Raw Normal View History

2025-11-10 20:13:22 +00:00
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;