nakama/backend/routes/posts.js

568 lines
21 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 { authenticate, authenticateOptional } = 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 Tag = require('../models/Tag');
const User = require('../models/User');
const { extractHashtags } = require('../utils/hashtags');
// Получить один пост по ID (должен быть ПЕРЕД общим маршрутом GET /)
router.get('/:id', authenticate, async (req, res) => {
try {
const post = await Post.findById(req.params.id)
.populate('author', 'username firstName lastName photoUrl')
.populate('mentionedUsers', 'username firstName lastName')
.populate('comments.author', 'username firstName lastName photoUrl')
.populate({
path: 'attachedTrack',
populate: { path: 'artist album' }
})
.exec();
if (!post) {
return res.status(404).json({ error: 'Пост не найден' });
}
// Проверить whitelist настройки пользователя
if (req.user.settings.whitelist.noNSFW && post.isNSFW) {
return res.status(403).json({ error: 'Пост скрыт настройками' });
}
if (req.user.settings.whitelist.noHomo && post.isHomo) {
return res.status(403).json({ error: 'Пост скрыт настройками' });
}
res.json({ post });
} catch (error) {
console.error('Ошибка получения поста:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Получить ленту постов (доступно для гостей)
router.get('/', authenticateOptional, async (req, res) => {
try {
const { page = 1, limit = 20, tag, userId, filter = 'all' } = req.query;
const query = {};
const isGuest = req.user?.isGuest;
// Фильтр по тегу
if (tag) {
query.tags = tag;
}
// Фильтр по пользователю
if (userId) {
query.author = userId;
}
// Фильтры: 'all', 'interests', 'following'
if (filter === 'interests' && !isGuest) {
// Лента по интересам - посты с тегами из preferredTags пользователя
const user = await User.findById(req.user._id).select('preferredTags');
if (user.preferredTags && user.preferredTags.length > 0) {
query.tags = { $in: user.preferredTags };
} else {
// Если нет предпочитаемых тегов, вернуть пустой результат
return res.json({
posts: [],
totalPages: 0,
currentPage: page
});
}
} else if (filter === 'following' && !isGuest) {
// Лента подписок - посты от пользователей, на которых подписан
const user = await User.findById(req.user._id).select('following');
if (user.following && user.following.length > 0) {
query.author = { $in: user.following };
} else {
// Если нет подписок, вернуть пустой результат
return res.json({
posts: [],
totalPages: 0,
currentPage: page
});
}
}
// Для гостей или filter === 'all' - показываем все посты без дополнительных фильтров
// 'all' - все посты, без дополнительных фильтров
// Применить whitelist настройки пользователя (только NSFW и Homo)
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({
path: 'attachedTrack',
populate: { path: 'artist album' }
})
.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, attachedTrackId } = 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) : [],
attachedTrack: attachedTrackId || null,
isNSFW: isNSFW === 'true',
// Флаг гомосексуального контента - полный аналог NSFW по логике,
// но управляется отдельно
isHomo: isHomo === 'true' || isHomo === true
});
await post.save();
await post.populate('author', 'username firstName lastName photoUrl');
if (post.attachedTrack) {
await post.populate({
path: 'attachedTrack',
populate: { path: 'artist album' }
});
}
// Увеличить счетчики использования тегов
if (parsedTags.length > 0) {
await Tag.updateMany(
{ name: { $in: parsedTags } },
{ $inc: { usageCount: 1 } }
);
}
// Начислить баллы за создание поста
const { awardPostCreation } = require('../utils/tickets');
await awardPostCreation(req.user._id);
// Создать уведомления для упомянутых пользователей
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);
// Начислить баллы
const { awardLikeGiven, awardLikeReceived } = require('../utils/tickets');
// Баллы тому, кто ставит лайк
await awardLikeGiven(req.user._id);
// Баллы автору поста (если это не свой пост)
if (!post.author.equals(req.user._id)) {
await awardLikeReceived(post.author, 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');
// Начислить баллы за комментарий
const { awardCommentWritten, awardCommentReceived } = require('../utils/tickets');
const commentLength = content.trim().length;
// Баллы тому, кто пишет комментарий (только если >= 10 символов)
if (commentLength >= 10) {
await awardCommentWritten(req.user._id, commentLength);
}
// Баллы автору поста (если это не свой пост)
if (!post.author.equals(req.user._id)) {
await awardCommentReceived(post.author, 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) {
// Если контент не пустой, валидируем его
if (content && content.trim().length > 0) {
if (!validatePostContent(content)) {
logSecurityEvent('INVALID_POST_CONTENT', req);
return res.status(400).json({ error: 'Недопустимый контент поста' });
}
post.content = content.trim();
// Обновить хэштеги
post.hashtags = extractHashtags(content);
} else {
// Пустой контент разрешен, если есть изображения
if (!post.images || post.images.length === 0) {
return res.status(400).json({ error: 'Пост должен содержать текст или изображение' });
}
post.content = '';
post.hashtags = [];
}
}
// Валидация тегов
if (tags !== undefined) {
let parsedTags = [];
if (tags) {
try {
parsedTags = typeof tags === 'string' ? JSON.parse(tags) : 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 (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);
// Более детальная обработка ошибок
if (error.name === 'CastError') {
return res.status(400).json({ error: 'Неверный ID поста' });
}
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);
// Продолжаем удаление поста даже если файлы не удалились
}
// Списать все билеты, связанные с удаленным постом (только если пост создан сегодня)
const { deductPostDeletion } = require('../utils/tickets');
await deductPostDeletion(post);
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;