nakama/backend/routes/posts.js

407 lines
14 KiB
JavaScript

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;
}
const 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();
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: 'Нет прав на удаление' });
}
// Удалить изображения если есть
if (post.images && post.images.length > 0) {
post.images.forEach(imagePath => {
const fullPath = path.join(__dirname, '..', imagePath);
if (fs.existsSync(fullPath)) {
fs.unlinkSync(fullPath);
}
});
} else if (post.imageUrl) {
const imagePath = path.join(__dirname, '..', post.imageUrl);
if (fs.existsSync(imagePath)) {
fs.unlinkSync(imagePath);
}
}
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;