nakama/backend/routes/modApp.js

420 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const express = require('express');
const router = express.Router();
const fs = require('fs');
const path = require('path');
const multer = require('multer');
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 { 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', 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.post(
'/channel/publish',
authenticateModeration,
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;