const { S3Client, PutObjectCommand, DeleteObjectCommand, HeadBucketCommand, CreateBucketCommand, ListObjectsV2Command, PutBucketPolicyCommand } = require('@aws-sdk/client-s3'); const { Upload } = require('@aws-sdk/lib-storage'); const config = require('../config'); const { log } = require('../middleware/logger'); let s3Client = null; /** * Инициализация S3 клиента для MinIO */ function initMinioClient() { if (!config.minio.enabled) { log('info', 'MinIO отключен, используется локальное хранилище'); return null; } try { const endpoint = config.minio.useSSL ? `https://${config.minio.endpoint}:${config.minio.port}` : `http://${config.minio.endpoint}:${config.minio.port}`; s3Client = new S3Client({ endpoint: endpoint, region: config.minio.region || 'us-east-1', credentials: { accessKeyId: config.minio.accessKey, secretAccessKey: config.minio.secretKey }, forcePathStyle: true, // Важно для MinIO! tls: config.minio.useSSL }); log('info', 'S3 клиент для MinIO инициализирован', { endpoint: endpoint, bucket: config.minio.bucket, region: config.minio.region }); // Создать bucket если не существует ensureBucket(); return s3Client; } catch (error) { log('error', 'Ошибка инициализации S3 клиента', { error: error.message }); return null; } } /** * Убедиться что bucket существует */ async function ensureBucket() { if (!s3Client) return; try { // Проверить существование bucket try { await s3Client.send(new HeadBucketCommand({ Bucket: config.minio.bucket })); log('info', `Bucket ${config.minio.bucket} существует`); } catch (headError) { // Bucket не существует, создаем if (headError.name === 'NotFound' || headError.$metadata?.httpStatusCode === 404) { await s3Client.send(new CreateBucketCommand({ Bucket: config.minio.bucket })); log('info', `Bucket ${config.minio.bucket} создан`); // Установить публичную политику для bucket (опционально) if (config.minio.publicBucket) { const policy = { Version: '2012-10-17', Statement: [{ Effect: 'Allow', Principal: { AWS: ['*'] }, Action: ['s3:GetObject'], Resource: [`arn:aws:s3:::${config.minio.bucket}/*`] }] }; await s3Client.send(new PutBucketPolicyCommand({ Bucket: config.minio.bucket, Policy: JSON.stringify(policy) })); log('info', `Bucket ${config.minio.bucket} установлен как публичный`); } } else { throw headError; } } } catch (error) { log('error', 'Ошибка проверки/создания bucket', { error: error.message }); } } /** * Нормализовать имя файла для MinIO (только ASCII, транслитерация кириллицы) * @param {string} filename - Оригинальное имя файла * @returns {string} - Безопасное имя файла */ function normalizeFilename(filename) { if (!filename) return 'file'; // Извлечь расширение const ext = filename.split('.').pop() || ''; const nameWithoutExt = filename.substring(0, filename.lastIndexOf('.')) || filename; // Простая транслитерация кириллицы const translitMap = { 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', 'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm', 'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', 'ф': 'f', 'х': 'h', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch', 'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya', 'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D', 'Е': 'E', 'Ё': 'Yo', 'Ж': 'Zh', 'З': 'Z', 'И': 'I', 'Й': 'Y', 'К': 'K', 'Л': 'L', 'М': 'M', 'Н': 'N', 'О': 'O', 'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T', 'У': 'U', 'Ф': 'F', 'Х': 'H', 'Ц': 'Ts', 'Ч': 'Ch', 'Ш': 'Sh', 'Щ': 'Sch', 'Ъ': '', 'Ы': 'Y', 'Ь': '', 'Э': 'E', 'Ю': 'Yu', 'Я': 'Ya' }; // Транслитерировать имя let normalized = nameWithoutExt .split('') .map(char => translitMap[char] || char) .join(''); // Убрать все не-ASCII символы (оставить только буквы, цифры, дефис, подчеркивание) normalized = normalized.replace(/[^a-zA-Z0-9_-]/g, '_'); // Если имя пустое после нормализации, использовать 'file' if (!normalized || normalized.trim() === '') { normalized = 'file'; } // Вернуть с расширением (расширение тоже нормализуем) const safeExt = ext.replace(/[^a-zA-Z0-9]/g, '') || 'bin'; return safeExt ? `${normalized}.${safeExt}` : normalized; } /** * Извлечь расширение файла безопасным способом * @param {string} filename - Имя файла * @returns {string} - Расширение файла */ function getFileExtension(filename) { if (!filename) return 'bin'; const parts = filename.split('.'); if (parts.length < 2) return 'bin'; const ext = parts.pop().toLowerCase(); // Оставить только безопасные символы в расширении return ext.replace(/[^a-z0-9]/g, '') || 'bin'; } /** * Загрузить файл в MinIO через S3 SDK * @param {Buffer} buffer - Буфер файла * @param {string} filename - Имя файла * @param {string} contentType - MIME тип * @param {string} folder - Папка в bucket (например, 'posts', 'avatars') * @returns {Promise} - URL файла */ async function uploadFile(buffer, filename, contentType, folder = 'posts') { if (!s3Client) { throw new Error('S3 клиент не инициализирован'); } try { // Нормализовать имя файла для MinIO (только ASCII) const safeFilename = normalizeFilename(filename); const ext = getFileExtension(filename); // Генерировать уникальное имя файла (используем только timestamp и random, без оригинального имени) const timestamp = Date.now(); const random = Math.round(Math.random() * 1E9); const objectName = `${folder}/${timestamp}-${random}.${ext}`; // Загрузить файл через S3 SDK const upload = new Upload({ client: s3Client, params: { Bucket: config.minio.bucket, Key: objectName, // Используем безопасное имя для Key Body: buffer, ContentType: contentType, CacheControl: 'public, max-age=31536000', // 1 год Metadata: { // Сохраняем оригинальное имя в метаданных (URL-encoded для безопасности) originalname: encodeURIComponent(filename), uploadedAt: new Date().toISOString() } } }); await upload.done(); // Вернуть URL файла const fileUrl = getFileUrl(objectName); log('info', 'Файл загружен в MinIO через S3', { objectName, originalName: filename, size: buffer.length, url: fileUrl }); return fileUrl; } catch (error) { log('error', 'Ошибка загрузки файла в MinIO', { error: error.message, filename: filename }); throw error; } } /** * Удалить файл из MinIO через S3 SDK * @param {string} fileUrl - URL файла или путь к объекту * @returns {Promise} */ async function deleteFile(fileUrl) { if (!s3Client) { throw new Error('S3 клиент не инициализирован'); } try { // Извлечь путь к объекту из URL const objectName = extractObjectName(fileUrl); if (!objectName) { log('warn', 'Не удалось извлечь имя объекта из URL', { fileUrl }); return false; } await s3Client.send(new DeleteObjectCommand({ Bucket: config.minio.bucket, Key: objectName })); log('info', 'Файл удален из MinIO через S3', { objectName }); return true; } catch (error) { log('error', 'Ошибка удаления файла из MinIO', { error: error.message }); return false; } } /** * Удалить несколько файлов * @param {string[]} fileUrls - Массив URL файлов * @returns {Promise} - Количество удаленных файлов */ async function deleteFiles(fileUrls) { if (!s3Client || !fileUrls || !fileUrls.length) { return 0; } let deleted = 0; for (const fileUrl of fileUrls) { try { const success = await deleteFile(fileUrl); if (success) deleted++; } catch (error) { log('error', 'Ошибка при удалении файла', { fileUrl, error: error.message }); } } return deleted; } /** * Получить временный URL для доступа к файлу (presigned URL) * @param {string} objectName - Имя объекта * @param {number} expirySeconds - Время жизни URL в секундах (по умолчанию 7 дней) * @returns {Promise} */ async function getPresignedUrl(objectName, expirySeconds = 7 * 24 * 60 * 60) { if (!s3Client) { throw new Error('S3 клиент не инициализирован'); } try { const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); const { GetObjectCommand } = require('@aws-sdk/client-s3'); const command = new GetObjectCommand({ Bucket: config.minio.bucket, Key: objectName }); const url = await getSignedUrl(s3Client, command, { expiresIn: expirySeconds }); return url; } catch (error) { log('error', 'Ошибка получения presigned URL', { error: error.message }); throw error; } } /** * Получить публичный URL файла * @param {string} objectName - Имя объекта * @returns {string} */ function getFileUrl(objectName) { if (config.minio.publicUrl) { // Использовать кастомный публичный URL (например, через CDN) return `${config.minio.publicUrl}/${config.minio.bucket}/${objectName}`; } // Использовать прямой URL MinIO const protocol = config.minio.useSSL ? 'https' : 'http'; const port = config.minio.port === 80 || config.minio.port === 443 ? '' : `:${config.minio.port}`; return `${protocol}://${config.minio.endpoint}${port}/${config.minio.bucket}/${objectName}`; } /** * Извлечь имя объекта из URL * @param {string} fileUrl - URL файла * @returns {string|null} */ function extractObjectName(fileUrl) { if (!fileUrl) return null; try { // Если это уже имя объекта (путь) if (!fileUrl.startsWith('http')) { return fileUrl; } // Извлечь из URL const url = new URL(fileUrl); const pathParts = url.pathname.split('/'); // Убрать bucket из пути const bucketIndex = pathParts.indexOf(config.minio.bucket); if (bucketIndex !== -1) { return pathParts.slice(bucketIndex + 1).join('/'); } // Попробовать альтернативный формат return pathParts.slice(1).join('/'); } catch (error) { log('error', 'Ошибка парсинга URL', { fileUrl, error: error.message }); return null; } } /** * Проверить доступность MinIO через S3 SDK * @returns {Promise} */ async function checkConnection() { if (!s3Client) { return false; } try { await s3Client.send(new HeadBucketCommand({ Bucket: config.minio.bucket })); return true; } catch (error) { log('error', 'MinIO недоступен', { error: error.message }); return false; } } /** * Получить статистику bucket через S3 SDK * @returns {Promise} */ async function getBucketStats() { if (!s3Client) { throw new Error('S3 клиент не инициализирован'); } try { let totalSize = 0; let totalFiles = 0; let continuationToken = undefined; do { const response = await s3Client.send(new ListObjectsV2Command({ Bucket: config.minio.bucket, ContinuationToken: continuationToken })); if (response.Contents) { for (const obj of response.Contents) { totalSize += obj.Size || 0; totalFiles++; } } continuationToken = response.NextContinuationToken; } while (continuationToken); return { totalFiles, totalSize, totalSizeMB: (totalSize / (1024 * 1024)).toFixed(2), totalSizeGB: (totalSize / (1024 * 1024 * 1024)).toFixed(2), bucket: config.minio.bucket }; } catch (error) { log('error', 'Ошибка получения статистики bucket', { error: error.message }); throw error; } } module.exports = { initMinioClient, uploadFile, deleteFile, deleteFiles, getPresignedUrl, getFileUrl, checkConnection, getBucketStats, isEnabled: () => config.minio.enabled };