nakama/backend/routes/posts.js

456 lines
16 KiB
JavaScript
Raw Permalink 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 { authenticate } = require('../middleware/auth');
const { postCreationLimiter, interactionLimiter } = require('../middleware/rateLimiter');
const { searchLimiter } = require('../middleware/rateLimiter');
const { validatePostContent, validateTags, validateImageUrl } = require('../middleware/validator');
const { logSecurityEvent } = require('../middleware/logger');
const { strictPostLimiter, fileUploadLimiter } = require('../middleware/security');
const { uploadPostImages, cleanupOnError } = require('../middleware/upload');
const { deleteFiles } = require('../utils/minio');
const Post = require('../models/Post');
const Notification = require('../models/Notification');
const { extractHashtags } = require('../utils/hashtags');
// Получить ленту постов
router.get('/', authenticate, async (req, res) => {
try {
const { page = 1, limit = 20, tag, userId } = req.query;
const query = {};
// Фильтр по тегу
if (tag) {
query.tags = tag;
}
// Фильтр по пользователю
if (userId) {
query.author = userId;
}
// Применить whitelist настройки пользователя
if (req.user.settings.whitelist.noFurry) {
query.tags = { $ne: 'furry' };
}
if (req.user.settings.whitelist.onlyAnime) {
query.tags = 'anime';
}
if (req.user.settings.whitelist.noNSFW) {
query.isNSFW = false;
}
if (req.user.settings.whitelist.noHomo) {
// Скрывать только посты, помеченные как гомосексуальные.
// Посты без флага (старые) остаются видимыми.
query.isHomo = { $ne: true };
}
let posts = await Post.find(query)
.populate('author', 'username firstName lastName photoUrl')
.populate('mentionedUsers', 'username firstName lastName')
.populate('comments.author', 'username firstName lastName photoUrl')
.sort({ createdAt: -1 })
.limit(limit * 1)
.skip((page - 1) * limit)
.exec();
// Фильтруем посты без автора (защита от ошибок)
posts = posts.filter(post => post.author !== null && post.author !== undefined);
const count = await Post.countDocuments(query);
res.json({
posts,
totalPages: Math.ceil(count / limit),
currentPage: page
});
} catch (error) {
console.error('Ошибка получения постов:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Создать пост
router.post('/', authenticate, strictPostLimiter, postCreationLimiter, fileUploadLimiter, uploadPostImages, async (req, res) => {
try {
const { content, tags, mentionedUsers, isNSFW, isHomo, externalImages } = req.body;
// Валидация контента
if (content && !validatePostContent(content)) {
logSecurityEvent('INVALID_POST_CONTENT', req);
return res.status(400).json({ error: 'Недопустимый контент поста' });
}
// Проверка тегов
let parsedTags = [];
try {
parsedTags = JSON.parse(tags || '[]');
} catch (e) {
return res.status(400).json({ error: 'Неверный формат тегов' });
}
if (!validateTags(parsedTags)) {
logSecurityEvent('INVALID_TAGS', req, { tags: parsedTags });
return res.status(400).json({ error: 'Недопустимые теги' });
}
if (!parsedTags.length) {
return res.status(400).json({ error: 'Теги обязательны' });
}
// Извлечь хэштеги из контента
const hashtags = extractHashtags(content);
// Обработка изображений
let images = [];
// Загруженные файлы (через middleware)
if (req.uploadedFiles && req.uploadedFiles.length > 0) {
images = req.uploadedFiles;
}
// Внешние изображения (из поиска)
if (externalImages) {
let externalUrls = [];
try {
externalUrls = JSON.parse(externalImages);
} catch (e) {
return res.status(400).json({ error: 'Неверный формат внешних изображений' });
}
// Валидация URL изображений
for (const url of externalUrls) {
if (!validateImageUrl(url)) {
logSecurityEvent('INVALID_IMAGE_URL', req, { url });
return res.status(400).json({ error: 'Недопустимый URL изображения' });
}
}
images = [...images, ...externalUrls];
}
// Ограничение на количество изображений
if (images.length > 5) {
return res.status(400).json({ error: 'Максимум 5 изображений в посте' });
}
// Обратная совместимость - imageUrl для первого изображения
const imageUrl = images.length > 0 ? images[0] : null;
const post = new Post({
author: req.user._id,
content,
imageUrl, // Для совместимости
images, // Новое поле
tags: parsedTags,
hashtags,
mentionedUsers: mentionedUsers ? JSON.parse(mentionedUsers) : [],
isNSFW: isNSFW === 'true',
// Флаг гомосексуального контента - полный аналог NSFW по логике,
// но управляется отдельно
isHomo: isHomo === 'true' || isHomo === true
});
await post.save();
await post.populate('author', 'username firstName lastName photoUrl');
// Проверка первого поста для реферальной системы
// Счетчик рефералов увеличивается только когда приглашенный пользователь создал первый пост
const userPostsCount = await Post.countDocuments({ author: req.user._id });
if (userPostsCount === 1 && req.user.referredBy) {
// Это первый пост пользователя, который был приглашен по реферальной ссылке
const User = require('../models/User');
await User.findByIdAndUpdate(req.user.referredBy, {
$inc: { referralsCount: 1 }
});
}
// Создать уведомления для упомянутых пользователей
if (post.mentionedUsers.length > 0) {
const notifications = post.mentionedUsers.map(userId => ({
recipient: userId,
sender: req.user._id,
type: 'mention',
post: post._id
}));
await Notification.insertMany(notifications);
}
// Создать уведомления для подписчиков о новом посте
const User = require('../models/User');
const author = await User.findById(req.user._id).select('followers');
if (author && author.followers && author.followers.length > 0) {
const newPostNotifications = author.followers.map(followerId => ({
recipient: followerId,
sender: req.user._id,
type: 'new_post',
post: post._id
}));
await Notification.insertMany(newPostNotifications);
}
res.status(201).json({ post });
} catch (error) {
console.error('Ошибка создания поста:', error);
res.status(500).json({ error: 'Ошибка создания поста' });
}
});
// Лайкнуть пост
router.post('/:id/like', authenticate, interactionLimiter, async (req, res) => {
try {
const post = await Post.findById(req.params.id);
if (!post) {
return res.status(404).json({ error: 'Пост не найден' });
}
const alreadyLiked = post.likes.includes(req.user._id);
if (alreadyLiked) {
// Убрать лайк
post.likes = post.likes.filter(id => !id.equals(req.user._id));
} else {
// Добавить лайк
post.likes.push(req.user._id);
// Создать уведомление
if (!post.author.equals(req.user._id)) {
const notification = new Notification({
recipient: post.author,
sender: req.user._id,
type: 'like',
post: post._id
});
await notification.save();
}
}
await post.save();
res.json({ likes: post.likes.length, liked: !alreadyLiked });
} catch (error) {
console.error('Ошибка лайка:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Добавить комментарий
router.post('/:id/comment', authenticate, interactionLimiter, async (req, res) => {
try {
const { content } = req.body;
if (!content || content.trim().length === 0) {
return res.status(400).json({ error: 'Комментарий не может быть пустым' });
}
const post = await Post.findById(req.params.id);
if (!post) {
return res.status(404).json({ error: 'Пост не найден' });
}
post.comments.push({
author: req.user._id,
content
});
await post.save();
await post.populate('comments.author', 'username firstName lastName photoUrl');
// Создать уведомление
if (!post.author.equals(req.user._id)) {
const notification = new Notification({
recipient: post.author,
sender: req.user._id,
type: 'comment',
post: post._id
});
await notification.save();
}
res.json({ comments: post.comments });
} catch (error) {
console.error('Ошибка комментария:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Редактировать пост
router.put('/:id', authenticate, async (req, res) => {
try {
const { content, tags, isNSFW } = req.body;
const post = await Post.findById(req.params.id);
if (!post) {
return res.status(404).json({ error: 'Пост не найден' });
}
// Проверить права
if (!post.author.equals(req.user._id) && req.user.role !== 'moderator' && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Нет прав на редактирование' });
}
// Валидация контента
if (content !== undefined && !validatePostContent(content)) {
logSecurityEvent('INVALID_POST_CONTENT', req);
return res.status(400).json({ error: 'Недопустимый контент поста' });
}
// Валидация тегов
if (tags) {
let parsedTags = [];
try {
parsedTags = JSON.parse(tags);
} catch (e) {
return res.status(400).json({ error: 'Неверный формат тегов' });
}
if (!validateTags(parsedTags)) {
logSecurityEvent('INVALID_TAGS', req, { tags: parsedTags });
return res.status(400).json({ error: 'Недопустимые теги' });
}
post.tags = parsedTags;
}
if (content !== undefined) {
post.content = content;
// Обновить хэштеги
post.hashtags = extractHashtags(content);
}
if (isNSFW !== undefined) {
post.isNSFW = isNSFW === 'true' || isNSFW === true;
}
post.editedAt = new Date();
await post.save();
await post.populate('author', 'username firstName lastName photoUrl');
res.json({ post });
} catch (error) {
console.error('Ошибка редактирования поста:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Удалить пост (автор или модератор)
router.delete('/:id', authenticate, async (req, res) => {
try {
const post = await Post.findById(req.params.id);
if (!post) {
return res.status(404).json({ error: 'Пост не найден' });
}
// Проверить права
if (!post.author.equals(req.user._id) && req.user.role !== 'moderator' && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Нет прав на удаление' });
}
// Удалить изображения из MinIO
try {
const filesToDelete = [];
if (post.images && post.images.length > 0) {
post.images.forEach(imageUrl => {
// Извлекаем имя файла из URL
// https://minio.glpshchn.ru/nakama-media/posts/123456.jpg -> posts/123456.jpg
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`);
}
} catch (error) {
console.error('❌ Ошибка удаления файлов из MinIO:', error);
// Продолжаем удаление поста даже если файлы не удалились
}
await Post.findByIdAndDelete(req.params.id);
res.json({ message: 'Пост удален' });
} catch (error) {
console.error('Ошибка удаления поста:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Редактировать комментарий
router.put('/:postId/comments/:commentId', authenticate, interactionLimiter, async (req, res) => {
try {
const { content } = req.body;
const post = await Post.findById(req.params.postId);
if (!post) {
return res.status(404).json({ error: 'Пост не найден' });
}
const comment = post.comments.id(req.params.commentId);
if (!comment) {
return res.status(404).json({ error: 'Комментарий не найден' });
}
// Проверить права
if (!comment.author.equals(req.user._id) && req.user.role !== 'moderator' && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Нет прав на редактирование' });
}
if (!content || content.trim().length === 0) {
return res.status(400).json({ error: 'Комментарий не может быть пустым' });
}
comment.content = content;
comment.editedAt = new Date();
await post.save();
await post.populate('comments.author', 'username firstName lastName photoUrl');
res.json({ comments: post.comments });
} catch (error) {
console.error('Ошибка редактирования комментария:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Удалить комментарий
router.delete('/:postId/comments/:commentId', authenticate, interactionLimiter, async (req, res) => {
try {
const post = await Post.findById(req.params.postId);
if (!post) {
return res.status(404).json({ error: 'Пост не найден' });
}
const comment = post.comments.id(req.params.commentId);
if (!comment) {
return res.status(404).json({ error: 'Комментарий не найден' });
}
// Проверить права
if (!comment.author.equals(req.user._id) && req.user.role !== 'moderator' && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Нет прав на удаление' });
}
post.comments.pull(req.params.commentId);
await post.save();
await post.populate('comments.author', 'username firstName lastName photoUrl');
res.json({ comments: post.comments });
} catch (error) {
console.error('Ошибка удаления комментария:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
module.exports = router;