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 через 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 { // Генерировать уникальное имя файла const timestamp = Date.now(); const random = Math.round(Math.random() * 1E9); const ext = filename.split('.').pop(); const objectName = `${folder}/${timestamp}-${random}.${ext}`; // Загрузить файл через S3 SDK const upload = new Upload({ client: s3Client, params: { Bucket: config.minio.bucket, Key: objectName, Body: buffer, ContentType: contentType, CacheControl: 'public, max-age=31536000', // 1 год Metadata: { originalname: filename, uploadedAt: new Date().toISOString() } } }); await upload.done(); // Вернуть URL файла const fileUrl = getFileUrl(objectName); log('info', 'Файл загружен в MinIO через S3', { objectName, size: buffer.length, url: fileUrl }); return fileUrl; } catch (error) { log('error', 'Ошибка загрузки файла в MinIO', { error: error.message }); 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 (!minioClient || !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 };