nakama/backend/routes/posts.js

568 lines
21 KiB
JavaScript
Raw Normal View History

2025-11-03 20:35:01 +00:00
const express = require('express');
const router = express.Router();
2026-01-01 19:39:12 +00:00
const { authenticate, authenticateOptional } = require('../middleware/auth');
2025-11-03 20:35:01 +00:00
const { postCreationLimiter, interactionLimiter } = require('../middleware/rateLimiter');
const { searchLimiter } = require('../middleware/rateLimiter');
2025-11-04 21:51:05 +00:00
const { validatePostContent, validateTags, validateImageUrl } = require('../middleware/validator');
const { logSecurityEvent } = require('../middleware/logger');
const { strictPostLimiter, fileUploadLimiter } = require('../middleware/security');
2025-11-20 22:07:37 +00:00
const { uploadPostImages, cleanupOnError } = require('../middleware/upload');
const { deleteFiles } = require('../utils/minio');
2025-11-03 20:35:01 +00:00
const Post = require('../models/Post');
const Notification = require('../models/Notification');
2025-12-07 22:15:00 +00:00
const Tag = require('../models/Tag');
const User = require('../models/User');
2025-11-03 20:35:01 +00:00
const { extractHashtags } = require('../utils/hashtags');
2025-12-11 00:57:03 +00:00
// Получить один пост по 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')
2025-12-15 07:28:47 +00:00
.populate({
path: 'attachedTrack',
populate: { path: 'artist album' }
})
2025-12-11 00:57:03 +00:00
.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: 'Ошибка сервера' });
}
});
2026-01-01 19:39:12 +00:00
// Получить ленту постов (доступно для гостей)
router.get('/', authenticateOptional, async (req, res) => {
2025-11-03 20:35:01 +00:00
try {
2025-12-07 22:15:00 +00:00
const { page = 1, limit = 20, tag, userId, filter = 'all' } = req.query;
2025-11-03 20:35:01 +00:00
const query = {};
2026-01-01 19:39:12 +00:00
const isGuest = req.user?.isGuest;
2025-11-03 20:35:01 +00:00
// Фильтр по тегу
if (tag) {
query.tags = tag;
}
// Фильтр по пользователю
if (userId) {
query.author = userId;
}
2025-12-07 22:15:00 +00:00
// Фильтры: 'all', 'interests', 'following'
2026-01-01 19:39:12 +00:00
if (filter === 'interests' && !isGuest) {
2025-12-07 22:15:00 +00:00
// Лента по интересам - посты с тегами из 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
});
}
2026-01-01 19:39:12 +00:00
} else if (filter === 'following' && !isGuest) {
2025-12-07 22:15:00 +00:00
// Лента подписок - посты от пользователей, на которых подписан
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
});
}
2025-11-03 20:35:01 +00:00
}
2026-01-01 19:39:12 +00:00
// Для гостей или filter === 'all' - показываем все посты без дополнительных фильтров
2025-12-07 22:15:00 +00:00
// 'all' - все посты, без дополнительных фильтров
// Применить whitelist настройки пользователя (только NSFW и Homo)
2026-01-01 19:39:12 +00:00
if (req.user?.settings?.whitelist?.noNSFW) {
2025-11-03 20:35:01 +00:00
query.isNSFW = false;
}
2026-01-01 19:39:12 +00:00
if (req.user?.settings?.whitelist?.noHomo) {
2025-12-01 14:26:18 +00:00
// Скрывать только посты, помеченные как гомосексуальные.
// Посты без флага (старые) остаются видимыми.
query.isHomo = { $ne: true };
}
2025-11-03 20:35:01 +00:00
2025-12-01 05:40:27 +00:00
let posts = await Post.find(query)
2025-11-03 20:35:01 +00:00
.populate('author', 'username firstName lastName photoUrl')
2025-12-15 07:28:47 +00:00
.populate({
path: 'attachedTrack',
populate: { path: 'artist album' }
})
2025-11-03 20:35:01 +00:00
.populate('mentionedUsers', 'username firstName lastName')
.populate('comments.author', 'username firstName lastName photoUrl')
.sort({ createdAt: -1 })
.limit(limit * 1)
.skip((page - 1) * limit)
.exec();
2025-12-01 05:40:27 +00:00
// Фильтруем посты без автора (защита от ошибок)
posts = posts.filter(post => post.author !== null && post.author !== undefined);
2025-11-03 20:35:01 +00:00
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: 'Ошибка сервера' });
}
});
// Создать пост
2025-11-20 22:07:37 +00:00
router.post('/', authenticate, strictPostLimiter, postCreationLimiter, fileUploadLimiter, uploadPostImages, async (req, res) => {
2025-11-03 20:35:01 +00:00
try {
2025-12-15 07:28:47 +00:00
const { content, tags, mentionedUsers, isNSFW, isHomo, externalImages, attachedTrackId } = req.body;
2025-11-03 20:35:01 +00:00
2025-11-04 21:51:05 +00:00
// Валидация контента
if (content && !validatePostContent(content)) {
logSecurityEvent('INVALID_POST_CONTENT', req);
return res.status(400).json({ error: 'Недопустимый контент поста' });
}
2025-11-03 20:35:01 +00:00
// Проверка тегов
2025-11-04 21:51:05 +00:00
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: 'Недопустимые теги' });
}
2025-11-03 20:35:01 +00:00
if (!parsedTags.length) {
return res.status(400).json({ error: 'Теги обязательны' });
}
// Извлечь хэштеги из контента
const hashtags = extractHashtags(content);
2025-11-03 22:17:25 +00:00
// Обработка изображений
let images = [];
2025-11-20 22:07:37 +00:00
// Загруженные файлы (через middleware)
if (req.uploadedFiles && req.uploadedFiles.length > 0) {
images = req.uploadedFiles;
2025-11-03 22:17:25 +00:00
}
// Внешние изображения (из поиска)
if (externalImages) {
2025-11-04 21:51:05 +00:00
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 изображения' });
}
}
2025-11-03 22:17:25 +00:00
images = [...images, ...externalUrls];
}
2025-11-04 21:51:05 +00:00
// Ограничение на количество изображений
if (images.length > 5) {
return res.status(400).json({ error: 'Максимум 5 изображений в посте' });
}
2025-11-03 22:17:25 +00:00
// Обратная совместимость - imageUrl для первого изображения
const imageUrl = images.length > 0 ? images[0] : null;
2025-11-03 20:35:01 +00:00
const post = new Post({
author: req.user._id,
content,
2025-11-03 22:17:25 +00:00
imageUrl, // Для совместимости
images, // Новое поле
2025-11-03 20:35:01 +00:00
tags: parsedTags,
hashtags,
mentionedUsers: mentionedUsers ? JSON.parse(mentionedUsers) : [],
2025-12-15 07:28:47 +00:00
attachedTrack: attachedTrackId || null,
2025-12-01 14:26:18 +00:00
isNSFW: isNSFW === 'true',
// Флаг гомосексуального контента - полный аналог NSFW по логике,
// но управляется отдельно
isHomo: isHomo === 'true' || isHomo === true
2025-11-03 20:35:01 +00:00
});
await post.save();
await post.populate('author', 'username firstName lastName photoUrl');
2025-12-15 20:37:41 +00:00
if (post.attachedTrack) {
await post.populate({
path: 'attachedTrack',
populate: { path: 'artist album' }
});
}
2025-11-03 20:35:01 +00:00
2025-12-07 22:15:00 +00:00
// Увеличить счетчики использования тегов
if (parsedTags.length > 0) {
await Tag.updateMany(
{ name: { $in: parsedTags } },
{ $inc: { usageCount: 1 } }
);
}
2025-12-07 02:20:45 +00:00
// Начислить баллы за создание поста
const { awardPostCreation } = require('../utils/tickets');
await awardPostCreation(req.user._id);
2025-12-04 17:44:05 +00:00
2025-11-03 20:35:01 +00:00
// Создать уведомления для упомянутых пользователей
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);
}
2025-12-04 20:00:39 +00:00
// Создать уведомления для подписчиков о новом посте
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);
}
2025-11-03 20:35:01 +00:00
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);
2025-12-07 02:20:45 +00:00
// Начислить баллы
const { awardLikeGiven, awardLikeReceived } = require('../utils/tickets');
// Баллы тому, кто ставит лайк
await awardLikeGiven(req.user._id);
// Баллы автору поста (если это не свой пост)
2025-11-03 20:35:01 +00:00
if (!post.author.equals(req.user._id)) {
2025-12-07 02:20:45 +00:00
await awardLikeReceived(post.author, req.user._id);
// Создать уведомление
2025-11-03 20:35:01 +00:00
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');
2025-12-07 02:20:45 +00:00
// Начислить баллы за комментарий
const { awardCommentWritten, awardCommentReceived } = require('../utils/tickets');
const commentLength = content.trim().length;
// Баллы тому, кто пишет комментарий (только если >= 10 символов)
if (commentLength >= 10) {
await awardCommentWritten(req.user._id, commentLength);
}
// Баллы автору поста (если это не свой пост)
2025-11-03 20:35:01 +00:00
if (!post.author.equals(req.user._id)) {
2025-12-07 02:20:45 +00:00
await awardCommentReceived(post.author, req.user._id);
// Создать уведомление
2025-11-03 20:35:01 +00:00
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: 'Ошибка сервера' });
}
});
2025-11-04 21:51:05 +00:00
// Редактировать пост
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: 'Нет прав на редактирование' });
}
2025-12-15 00:37:34 +00:00
// Валидация контента (разрешаем пустой контент, если есть изображения)
if (content !== undefined) {
// Если контент не пустой, валидируем его
if (content && content.trim().length > 0) {
if (!validatePostContent(content)) {
2025-12-15 04:17:54 +00:00
logSecurityEvent('INVALID_POST_CONTENT', req);
return res.status(400).json({ error: 'Недопустимый контент поста' });
2025-12-15 00:37:34 +00:00
}
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 = [];
}
2025-11-04 21:51:05 +00:00
}
// Валидация тегов
2025-12-15 00:37:34 +00:00
if (tags !== undefined) {
2025-11-04 21:51:05 +00:00
let parsedTags = [];
2025-12-15 00:37:34 +00:00
if (tags) {
2025-12-15 04:17:54 +00:00
try {
2025-12-15 00:37:34 +00:00
parsedTags = typeof tags === 'string' ? JSON.parse(tags) : tags;
2025-12-15 04:17:54 +00:00
} catch (e) {
return res.status(400).json({ error: 'Неверный формат тегов' });
}
if (!validateTags(parsedTags)) {
logSecurityEvent('INVALID_TAGS', req, { tags: parsedTags });
return res.status(400).json({ error: 'Недопустимые теги' });
2025-12-15 00:37:34 +00:00
}
2025-11-04 21:51:05 +00:00
}
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);
2025-12-15 00:37:34 +00:00
// Более детальная обработка ошибок
if (error.name === 'CastError') {
return res.status(400).json({ error: 'Неверный ID поста' });
}
2025-11-04 21:51:05 +00:00
res.status(500).json({ error: 'Ошибка сервера' });
}
});
2025-11-03 20:35:01 +00:00
// Удалить пост (автор или модератор)
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: 'Нет прав на удаление' });
}
2025-11-21 01:44:54 +00:00
// Удалить изображения из 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]);
2025-11-04 21:51:05 +00:00
}
2025-11-03 20:35:01 +00:00
}
2025-11-21 01:44:54 +00:00
if (filesToDelete.length > 0) {
await deleteFiles(filesToDelete);
console.log(`✅ Удалено ${filesToDelete.length} файлов из MinIO`);
}
} catch (error) {
console.error('❌ Ошибка удаления файлов из MinIO:', error);
// Продолжаем удаление поста даже если файлы не удалились
2025-11-03 20:35:01 +00:00
}
2025-12-07 03:08:07 +00:00
// Списать все билеты, связанные с удаленным постом (только если пост создан сегодня)
const { deductPostDeletion } = require('../utils/tickets');
await deductPostDeletion(post);
2025-11-03 20:35:01 +00:00
await Post.findByIdAndDelete(req.params.id);
res.json({ message: 'Пост удален' });
} catch (error) {
console.error('Ошибка удаления поста:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
2025-11-04 21:51:05 +00:00
// Редактировать комментарий
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: 'Ошибка сервера' });
}
});
2025-11-03 20:35:01 +00:00
module.exports = router;