nakama/backend/utils/minio.js

428 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string>} - 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<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 (!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<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
};