2026-01-20 16:51:42 +07:00
|
|
|
import { Client } from 'minio';
|
|
|
|
|
import { config } from './index';
|
|
|
|
|
import { logger } from './logger';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* MinIO Client Configuration
|
|
|
|
|
* Used for uploading videos and attachments to S3-compatible storage
|
|
|
|
|
*/
|
|
|
|
|
export const minioClient = new Client({
|
|
|
|
|
endPoint: new URL(config.s3.endpoint).hostname,
|
|
|
|
|
port: parseInt(new URL(config.s3.endpoint).port) || (config.s3.useSSL ? 443 : 9000),
|
|
|
|
|
useSSL: config.s3.useSSL,
|
|
|
|
|
accessKey: config.s3.accessKey,
|
|
|
|
|
secretKey: config.s3.secretKey,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Ensure bucket exists, create if not
|
|
|
|
|
*/
|
|
|
|
|
export async function ensureBucketExists(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const exists = await minioClient.bucketExists(config.s3.bucket);
|
|
|
|
|
if (!exists) {
|
|
|
|
|
await minioClient.makeBucket(config.s3.bucket, 'us-east-1');
|
|
|
|
|
logger.info(`Bucket '${config.s3.bucket}' created successfully`);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`Error ensuring bucket exists: ${error}`);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Generate a unique file path for storage
|
|
|
|
|
*/
|
|
|
|
|
export function generateFilePath(
|
|
|
|
|
courseId: number,
|
|
|
|
|
lessonId: number,
|
|
|
|
|
fileType: 'video' | 'attachment',
|
|
|
|
|
originalFilename: string
|
|
|
|
|
): string {
|
|
|
|
|
const timestamp = Date.now();
|
|
|
|
|
const uniqueId = Math.random().toString(36).substring(2, 15);
|
|
|
|
|
const extension = originalFilename.split('.').pop() || '';
|
|
|
|
|
const safeFilename = `${timestamp}-${uniqueId}.${extension}`;
|
|
|
|
|
|
|
|
|
|
if (fileType === 'video') {
|
|
|
|
|
return `courses/${courseId}/lessons/${lessonId}/video/${safeFilename}`;
|
|
|
|
|
}
|
|
|
|
|
return `courses/${courseId}/lessons/${lessonId}/attachments/${safeFilename}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Upload a file to MinIO
|
|
|
|
|
*/
|
|
|
|
|
export async function uploadFile(
|
|
|
|
|
filePath: string,
|
|
|
|
|
fileBuffer: Buffer,
|
|
|
|
|
mimeType: string
|
|
|
|
|
): Promise<string> {
|
|
|
|
|
try {
|
|
|
|
|
await ensureBucketExists();
|
|
|
|
|
|
|
|
|
|
await minioClient.putObject(
|
|
|
|
|
config.s3.bucket,
|
|
|
|
|
filePath,
|
|
|
|
|
fileBuffer,
|
|
|
|
|
fileBuffer.length,
|
|
|
|
|
{ 'Content-Type': mimeType }
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
logger.info(`File uploaded successfully: ${filePath}`);
|
|
|
|
|
return filePath;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`Error uploading file: ${error}`);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Delete a file from MinIO
|
|
|
|
|
*/
|
|
|
|
|
export async function deleteFile(filePath: string): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
await minioClient.removeObject(config.s3.bucket, filePath);
|
|
|
|
|
logger.info(`File deleted successfully: ${filePath}`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`Error deleting file: ${error}`);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get a presigned URL for file access
|
|
|
|
|
*/
|
|
|
|
|
export async function getPresignedUrl(
|
|
|
|
|
filePath: string,
|
|
|
|
|
expirySeconds: number = 3600
|
|
|
|
|
): Promise<string> {
|
|
|
|
|
try {
|
|
|
|
|
const url = await minioClient.presignedGetObject(
|
|
|
|
|
config.s3.bucket,
|
|
|
|
|
filePath,
|
|
|
|
|
expirySeconds
|
|
|
|
|
);
|
|
|
|
|
return url;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`Error generating presigned URL: ${error}`);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-22 15:56:56 +07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* List all objects in a path prefix
|
|
|
|
|
*/
|
|
|
|
|
export async function listObjects(prefix: string): Promise<{ name: string; size: number; lastModified: Date }[]> {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const objects: { name: string; size: number; lastModified: Date }[] = [];
|
|
|
|
|
const stream = minioClient.listObjectsV2(config.s3.bucket, prefix, true);
|
|
|
|
|
|
|
|
|
|
stream.on('data', (obj) => {
|
|
|
|
|
if (obj.name) {
|
|
|
|
|
objects.push({
|
|
|
|
|
name: obj.name,
|
|
|
|
|
size: obj.size || 0,
|
|
|
|
|
lastModified: obj.lastModified || new Date(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
stream.on('error', (err) => {
|
|
|
|
|
logger.error(`Error listing objects: ${err}`);
|
|
|
|
|
reject(err);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
stream.on('end', () => {
|
|
|
|
|
resolve(objects);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* List folders in a path prefix
|
|
|
|
|
* Uses listObjectsV2 with recursive=false to get folder prefixes
|
|
|
|
|
*/
|
|
|
|
|
export async function listFolders(prefix: string): Promise<string[]> {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const folders = new Set<string>();
|
|
|
|
|
const stream = minioClient.listObjectsV2(config.s3.bucket, prefix, false);
|
|
|
|
|
|
|
|
|
|
stream.on('data', (obj: any) => {
|
|
|
|
|
if (obj.prefix) {
|
|
|
|
|
// Direct folder prefix
|
|
|
|
|
folders.add(obj.prefix);
|
|
|
|
|
} else if (obj.name) {
|
|
|
|
|
// Extract folder part from file path
|
|
|
|
|
const path = obj.name.replace(prefix, '');
|
|
|
|
|
const folder = path.split('/')[0];
|
|
|
|
|
|
|
|
|
|
if (folder && path.includes('/')) {
|
|
|
|
|
folders.add(prefix + folder + '/');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
stream.on('error', (err) => {
|
|
|
|
|
logger.error(`Error listing folders: ${err}`);
|
|
|
|
|
reject(err);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
stream.on('end', () => {
|
|
|
|
|
resolve(Array.from(folders));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get attachments folder path for a lesson
|
|
|
|
|
*/
|
|
|
|
|
export function getAttachmentsFolder(courseId: number, lessonId: number): string {
|
|
|
|
|
return `courses/${courseId}/lessons/${lessonId}/attachments/`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get video folder path for a lesson
|
|
|
|
|
*/
|
|
|
|
|
export function getVideoFolder(courseId: number, lessonId: number): string {
|
|
|
|
|
return `courses/${courseId}/lessons/${lessonId}/video/`;
|
|
|
|
|
}
|