nakama/backend/middleware/upload.js

301 lines
10 KiB
JavaScript

const multer = require('multer');
const path = require('path');
const { uploadFile, isEnabled: isMinioEnabled } = require('../utils/minio');
const { log } = require('./logger');
const fs = require('fs');
// Временное хранилище для файлов
const tempStorage = multer.memoryStorage();
// Конфигурация multer
const multerConfig = {
storage: tempStorage,
limits: {
fileSize: 10 * 1024 * 1024, // 10MB
files: 10
},
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 allowedMimes = [
// Основные форматы изображений
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp',
// Дополнительные форматы изображений
'image/bmp', 'image/x-ms-bmp', 'image/tiff', 'image/tif',
'image/heic', 'image/heif', 'image/avif', 'image/x-icon',
// Видео форматы
'video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/webm',
'video/x-matroska', 'video/avi'
];
// Также проверяем по расширению файла (на случай неправильного MIME типа)
const allowedExts = [
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif',
'.heic', '.heif', '.avif', '.ico',
'.mp4', '.mov', '.avi', '.webm', '.mkv'
];
const fileExt = ext.toLowerCase();
const isValidMime = allowedMimes.includes(file.mimetype);
const isValidExt = allowedExts.includes(fileExt);
if (!isValidMime && !isValidExt) {
return cb(new Error(`Неподдерживаемый формат файла. Разрешены: JPEG, PNG, GIF, WebP, BMP, HEIC, MP4, MOV, AVI, WebM. Получен: ${file.mimetype || 'неизвестно'}`));
}
cb(null, true);
}
};
/**
* Middleware для загрузки файлов
* Автоматически загружает в MinIO если включен, иначе локально
*/
function createUploadMiddleware(fieldName, maxCount = 5, folder = 'posts') {
const upload = multer(multerConfig);
const multerMiddleware = maxCount === 1
? upload.single(fieldName)
: upload.array(fieldName, maxCount);
return async (req, res, next) => {
multerMiddleware(req, res, async (err) => {
if (err) {
log('error', 'Ошибка multer', { error: err.message });
return res.status(400).json({ error: err.message });
}
try {
// Проверить наличие файлов
const files = req.files || (req.file ? [req.file] : []);
if (!files.length) {
return next();
}
// Если MinIO включен, загрузить туда
if (isMinioEnabled()) {
const uploadedUrls = [];
for (const file of files) {
try {
const fileUrl = await uploadFile(
file.buffer,
file.originalname,
file.mimetype,
folder
);
uploadedUrls.push(fileUrl);
} catch (uploadError) {
log('error', 'Ошибка загрузки в MinIO', {
error: uploadError.message,
filename: file.originalname
});
throw uploadError;
}
}
// Сохранить URLs в req для дальнейшей обработки
req.uploadedFiles = uploadedUrls;
req.uploadMethod = 'minio';
log('info', 'Файлы загружены в MinIO', {
count: uploadedUrls.length,
folder
});
} else {
// Локальное хранилище (fallback)
const uploadDir = path.join(__dirname, '../uploads', folder);
// Создать директорию если не существует
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const uploadedPaths = [];
for (const file of files) {
const timestamp = Date.now();
const random = Math.round(Math.random() * 1E9);
const ext = path.extname(file.originalname);
const filename = `${timestamp}-${random}${ext}`;
const filepath = path.join(uploadDir, filename);
// Сохранить файл
fs.writeFileSync(filepath, file.buffer);
// Относительный путь для URL
const relativePath = `/uploads/${folder}/${filename}`;
uploadedPaths.push(relativePath);
}
req.uploadedFiles = uploadedPaths;
req.uploadMethod = 'local';
log('info', 'Файлы загружены локально', {
count: uploadedPaths.length,
folder
});
}
next();
} catch (error) {
log('error', 'Ошибка обработки загруженных файлов', { error: error.message });
return res.status(500).json({ error: 'Ошибка загрузки файлов' });
}
});
};
}
/**
* Middleware для удаления файлов из MinIO при ошибке
*/
function cleanupOnError() {
return (err, req, res, next) => {
if (req.uploadedFiles && req.uploadMethod === 'minio') {
const { deleteFiles } = require('../utils/minio');
deleteFiles(req.uploadedFiles).catch(cleanupErr => {
log('error', 'Ошибка очистки файлов MinIO', { error: cleanupErr.message });
});
}
next(err);
};
}
// Специальная конфигурация для музыки (больший лимит размера)
const musicMulterConfig = {
storage: tempStorage,
limits: {
fileSize: 105 * 1024 * 1024, // 105MB для ZIP альбомов
files: 1
},
fileFilter: (req, file, cb) => {
const allowedMimeTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/mp4', 'audio/m4a', 'application/zip', 'application/x-zip-compressed'];
const ext = path.extname(file.originalname).toLowerCase();
const allowedExts = ['.mp3', '.wav', '.ogg', '.m4a', '.flac', '.zip'];
if (allowedMimeTypes.includes(file.mimetype) || allowedExts.includes(ext)) {
cb(null, true);
} else {
cb(new Error('Неподдерживаемый формат файла. Разрешены: MP3, WAV, OGG, M4A, FLAC, ZIP'));
}
}
};
/**
* Middleware для загрузки музыки (треки и ZIP альбомы)
*/
function createMusicUploadMiddleware(fieldName = 'track') {
const upload = multer(musicMulterConfig);
const multerMiddleware = upload.single(fieldName);
return async (req, res, next) => {
multerMiddleware(req, res, async (err) => {
if (err) {
log('error', 'Ошибка multer (музыка)', { error: err.message });
return res.status(400).json({ error: err.message });
}
if (!req.file) {
return next();
}
try {
// Если MinIO включен, загрузить туда
if (isMinioEnabled()) {
try {
const fileUrl = await uploadFile(
req.file.buffer,
req.file.originalname,
req.file.mimetype,
'music'
);
req.uploadedMusicFile = {
url: fileUrl,
buffer: req.file.buffer,
originalname: req.file.originalname,
mimetype: req.file.mimetype,
size: req.file.size
};
req.uploadMethod = 'minio';
log('info', 'Музыкальный файл загружен в MinIO', {
filename: req.file.originalname,
size: req.file.size,
url: fileUrl
});
} catch (uploadError) {
log('error', 'Ошибка загрузки в MinIO', {
error: uploadError.message,
filename: req.file.originalname
});
throw uploadError;
}
} else {
// Локальное хранилище (fallback)
const uploadDir = path.join(__dirname, '../uploads/music');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const timestamp = Date.now();
const random = Math.round(Math.random() * 1E9);
const ext = path.extname(req.file.originalname);
const filename = `${timestamp}-${random}${ext}`;
const filepath = path.join(uploadDir, filename);
fs.writeFileSync(filepath, req.file.buffer);
const relativePath = `/uploads/music/${filename}`;
req.uploadedMusicFile = {
url: relativePath,
path: filepath,
buffer: req.file.buffer,
originalname: req.file.originalname,
mimetype: req.file.mimetype,
size: req.file.size
};
req.uploadMethod = 'local';
log('info', 'Музыкальный файл загружен локально', {
filename,
path: relativePath
});
}
next();
} catch (error) {
log('error', 'Ошибка обработки музыкального файла', { error: error.message });
return res.status(500).json({ error: 'Ошибка загрузки файла' });
}
});
};
}
module.exports = {
createUploadMiddleware,
cleanupOnError,
createMusicUploadMiddleware,
// Готовые middleware для разных случаев
uploadPostImages: createUploadMiddleware('images', 5, 'posts'),
uploadAvatar: createUploadMiddleware('avatar', 1, 'avatars'),
uploadChannelMedia: createUploadMiddleware('images', 10, 'channel'),
uploadMusicTrack: createMusicUploadMiddleware('track'),
uploadMusicAlbum: createMusicUploadMiddleware('album')
};