359 lines
10 KiB
JavaScript
359 lines
10 KiB
JavaScript
|
|
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
|
|||
|
|
};
|
|||
|
|
|