nakama/backend/utils/minio.js

428 lines
14 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 });
}
}
2025-12-02 22:31:02 +00:00
/**
* Нормализовать имя файла для 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';
}
2025-11-20 22:07:37 +00:00
/**
* Загрузить файл в 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 {
2025-12-02 22:31:02 +00:00
// Нормализовать имя файла для MinIO (только ASCII)
const safeFilename = normalizeFilename(filename);
const ext = getFileExtension(filename);
// Генерировать уникальное имя файла (используем только timestamp и random, без оригинального имени)
2025-11-20 22:07:37 +00:00
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,
2025-12-02 22:31:02 +00:00
Key: objectName, // Используем безопасное имя для Key
2025-11-20 22:07:37 +00:00
Body: buffer,
ContentType: contentType,
CacheControl: 'public, max-age=31536000', // 1 год
Metadata: {
2025-12-02 22:31:02 +00:00
// Сохраняем оригинальное имя в метаданных (URL-encoded для безопасности)
originalname: encodeURIComponent(filename),
2025-11-20 22:07:37 +00:00
uploadedAt: new Date().toISOString()
}
}
});
await upload.done();
// Вернуть URL файла
const fileUrl = getFileUrl(objectName);
log('info', 'Файл загружен в MinIO через S3', {
objectName,
2025-12-02 22:31:02 +00:00
originalName: filename,
2025-11-20 22:07:37 +00:00
size: buffer.length,
url: fileUrl
});
return fileUrl;
} catch (error) {
2025-12-02 22:31:02 +00:00
log('error', 'Ошибка загрузки файла в MinIO', {
error: error.message,
filename: filename
});
2025-11-20 22:07:37 +00:00
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) {
2025-12-02 22:31:02 +00:00
if (!s3Client || !fileUrls || !fileUrls.length) {
2025-11-20 22:07:37 +00:00
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
};