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; } 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, 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' }); await post.save(); await post.populate('author', 'username firstName lastName photoUrl'); // Создать уведомления для упомянутых пользователей 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); } 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;