nakama/backend/utils/minio.js

359 lines
10 KiB
JavaScript
Raw Normal View History

2025-11-20 22:07:37 +00:00
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<string>} - 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<boolean>}
*/
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<number>} - Количество удаленных файлов
*/
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<string>}
*/
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<boolean>}
*/
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<object>}
*/
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
};