458 lines
15 KiB
JavaScript
458 lines
15 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const multer = require('multer');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
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 Post = require('../models/Post');
|
|
const Notification = require('../models/Notification');
|
|
const { extractHashtags } = require('../utils/hashtags');
|
|
|
|
// Настройка multer для загрузки изображений
|
|
const storage = multer.diskStorage({
|
|
destination: (req, file, cb) => {
|
|
const dir = path.join(__dirname, '../uploads/posts');
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
cb(null, dir);
|
|
},
|
|
filename: (req, file, cb) => {
|
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
|
cb(null, uniqueSuffix + path.extname(file.originalname));
|
|
}
|
|
});
|
|
|
|
const upload = multer({
|
|
storage: storage,
|
|
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
|
fileFilter: (req, file, cb) => {
|
|
// Запрещенные расширения (исполняемые файлы)
|
|
const forbiddenExts = ['.exe', '.bat', '.cmd', '.sh', '.ps1', '.js', '.jar', '.app', '.dmg', '.deb', '.rpm', '.msi', '.scr', '.vbs', '.com', '.pif', '.cpl'];
|
|
const ext = path.extname(file.originalname).toLowerCase();
|
|
|
|
// Проверить на запрещенные расширения
|
|
if (forbiddenExts.includes(ext)) {
|
|
return cb(new Error('Запрещенный тип файла'));
|
|
}
|
|
|
|
// Разрешенные типы изображений
|
|
const allowedTypes = /jpeg|jpg|png|gif|webp/;
|
|
const extname = allowedTypes.test(ext);
|
|
const mimetype = allowedTypes.test(file.mimetype);
|
|
|
|
// Дополнительная проверка MIME типа
|
|
const allowedMimes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
|
if (!allowedMimes.includes(file.mimetype)) {
|
|
return cb(new Error('Только изображения разрешены'));
|
|
}
|
|
|
|
if (mimetype && extname) {
|
|
return cb(null, true);
|
|
} else {
|
|
cb(new Error('Только изображения разрешены'));
|
|
}
|
|
}
|
|
});
|
|
|
|
// Поддержка до 5 изображений в одном посте
|
|
const uploadMultiple = upload.array('images', 5);
|
|
|
|
// Получить ленту постов
|
|
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, uploadMultiple, 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 = [];
|
|
|
|
// Загруженные файлы
|
|
if (req.files && req.files.length > 0) {
|
|
images = req.files.map(file => `/uploads/posts/${file.filename}`);
|
|
}
|
|
|
|
// Внешние изображения (из поиска)
|
|
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;
|
|
|