341 lines
8.4 KiB
TypeScript
341 lines
8.4 KiB
TypeScript
import * as Minio from "minio";
|
||
|
||
// MinIO 客户端配置
|
||
const useSSL = process.env.MINIO_USE_SSL === "true";
|
||
const port = Number(process.env.MINIO_PORT) || 9000;
|
||
|
||
// 当使用标准HTTPS端口(443)或HTTP端口(80)时,MinIO客户端不需要指定端口
|
||
const shouldOmitPort = (useSSL && port === 443) || (!useSSL && port === 80);
|
||
|
||
const minioClient = new Minio.Client({
|
||
endPoint: process.env.MINIO_ENDPOINT || "localhost",
|
||
...(shouldOmitPort ? {} : { port }),
|
||
useSSL,
|
||
accessKey: process.env.MINIO_ACCESS_KEY || "",
|
||
secretKey: process.env.MINIO_SECRET_KEY || "",
|
||
pathStyle: true, // 使用路径风格,对反向代理更友好
|
||
});
|
||
|
||
const BUCKET_NAME = process.env.MINIO_BUCKET_NAME || "home-page";
|
||
|
||
/**
|
||
* 初始化 MinIO Bucket(确保 bucket 存在)
|
||
*/
|
||
export async function initBucket(): Promise<void> {
|
||
try {
|
||
const exists = await minioClient.bucketExists(BUCKET_NAME);
|
||
if (!exists) {
|
||
await minioClient.makeBucket(BUCKET_NAME, "us-east-1");
|
||
console.log(`Bucket ${BUCKET_NAME} created successfully`);
|
||
}
|
||
// 设置公共读取策略(可选)
|
||
const policy = {
|
||
Version: "2012-10-17",
|
||
Statement: [
|
||
{
|
||
Effect: "Allow",
|
||
Principal: { AWS: ["*"] },
|
||
Action: ["s3:GetObject"],
|
||
Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`],
|
||
},
|
||
],
|
||
};
|
||
await minioClient.setBucketPolicy(BUCKET_NAME, JSON.stringify(policy));
|
||
} catch (error) {
|
||
console.error("Error initializing bucket:", error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 上传文件到 MinIO
|
||
* @param file - File 对象或 Buffer
|
||
* @param path - 文件存储路径(如: 'avatars/user123.jpg' 或 'posts/2024/image.png')
|
||
* @param metadata - 可选的元数据
|
||
* @returns 文件 path = args.path
|
||
*/
|
||
export async function uploadFile(
|
||
file: File | Buffer,
|
||
path: string,
|
||
metadata?: Record<string, string>,
|
||
): Promise<string> {
|
||
try {
|
||
await initBucket();
|
||
|
||
let buffer: Buffer;
|
||
let contentType: string;
|
||
|
||
if (file instanceof File) {
|
||
buffer = Buffer.from(await file.arrayBuffer());
|
||
contentType = file.type || "application/octet-stream";
|
||
} else {
|
||
buffer = file;
|
||
contentType = metadata?.["Content-Type"] || "application/octet-stream";
|
||
}
|
||
|
||
const metaData = {
|
||
"Content-Type": contentType,
|
||
...metadata,
|
||
};
|
||
|
||
await minioClient.putObject(
|
||
BUCKET_NAME,
|
||
path,
|
||
buffer,
|
||
buffer.length,
|
||
metaData,
|
||
);
|
||
|
||
return path;
|
||
} catch (error) {
|
||
console.error("Error uploading file:", error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取文件的公共访问URL
|
||
* @param path - 文件路径
|
||
* @returns 文件的访问URL
|
||
*/
|
||
export function getFileUrl(path: string): string {
|
||
const endpoint = process.env.MINIO_PUBLIC_DOMAIN;
|
||
|
||
return `${endpoint}/${BUCKET_NAME}/${path}`;
|
||
}
|
||
|
||
/**
|
||
* 下载文件
|
||
* @param path - 文件路径
|
||
* @returns 文件的 Buffer
|
||
*/
|
||
export async function downloadFile(path: string): Promise<Buffer> {
|
||
try {
|
||
const chunks: Buffer[] = [];
|
||
const stream = await minioClient.getObject(BUCKET_NAME, path);
|
||
|
||
return new Promise((resolve, reject) => {
|
||
stream.on("data", (chunk) => chunks.push(chunk));
|
||
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
||
stream.on("error", reject);
|
||
});
|
||
} catch (error) {
|
||
console.error("Error downloading file:", error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取文件流
|
||
* @param path - 文件路径
|
||
* @returns 文件流
|
||
*/
|
||
export async function getFileStream(
|
||
path: string,
|
||
): Promise<NodeJS.ReadableStream> {
|
||
try {
|
||
return await minioClient.getObject(BUCKET_NAME, path);
|
||
} catch (error) {
|
||
console.error("Error getting file stream:", error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除文件
|
||
* @param path - 文件路径
|
||
*/
|
||
export async function deleteFile(path: string): Promise<void> {
|
||
try {
|
||
await minioClient.removeObject(BUCKET_NAME, path);
|
||
} catch (error) {
|
||
console.error("Error deleting file:", error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 批量删除文件
|
||
* @param paths - 文件路径数组
|
||
*/
|
||
export async function deleteFiles(paths: string[]): Promise<void> {
|
||
try {
|
||
await minioClient.removeObjects(BUCKET_NAME, paths);
|
||
} catch (error) {
|
||
console.error("Error deleting files:", error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查文件是否存在
|
||
* @param path - 文件路径
|
||
* @returns 文件是否存在
|
||
*/
|
||
export async function fileExists(path: string): Promise<boolean> {
|
||
try {
|
||
await minioClient.statObject(BUCKET_NAME, path);
|
||
return true;
|
||
} catch (error) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取文件信息
|
||
* @param path - 文件路径
|
||
* @returns 文件元数据
|
||
*/
|
||
export async function getFileInfo(path: string): Promise<Minio.BucketItemStat> {
|
||
try {
|
||
return await minioClient.statObject(BUCKET_NAME, path);
|
||
} catch (error) {
|
||
console.error("Error getting file info:", error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 列出指定路径下的所有文件
|
||
* @param prefix - 路径前缀(如: 'avatars/' 或 'posts/2024/')
|
||
* @param recursive - 是否递归列出子目录
|
||
* @returns 文件列表
|
||
*/
|
||
export async function listFiles(
|
||
prefix: string = "",
|
||
recursive: boolean = false,
|
||
): Promise<(Minio.BucketItem & { endpoint: string })[]> {
|
||
try {
|
||
const files: (Minio.BucketItem & { endpoint: string })[] = [];
|
||
const stream = minioClient.listObjects(BUCKET_NAME, prefix, recursive);
|
||
|
||
return new Promise((resolve, reject) => {
|
||
stream.on("data", (obj) => {
|
||
if (obj.name) {
|
||
files.push({
|
||
endpoint: `${process.env.MINIO_PUBLIC_DOMAIN}/${BUCKET_NAME}`,
|
||
...obj,
|
||
} as Minio.BucketItem & { endpoint: string });
|
||
}
|
||
});
|
||
stream.on("end", () => resolve(files));
|
||
stream.on("error", reject);
|
||
});
|
||
} catch (error) {
|
||
console.error("Error listing files:", error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 复制文件
|
||
* @param sourcePath - 源文件路径
|
||
* @param destPath - 目标文件路径
|
||
*/
|
||
export async function copyFile(
|
||
sourcePath: string,
|
||
destPath: string,
|
||
): Promise<void> {
|
||
try {
|
||
const conds = new Minio.CopyConditions();
|
||
await minioClient.copyObject(
|
||
BUCKET_NAME,
|
||
destPath,
|
||
`/${BUCKET_NAME}/${sourcePath}`,
|
||
conds,
|
||
);
|
||
} catch (error) {
|
||
console.error("Error copying file:", error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成唯一文件名
|
||
* @param originalName - 原始文件名
|
||
* @param prefix - 路径前缀(如: 'avatars/' 或 'posts/2024/')
|
||
* @returns 带路径的唯一文件名
|
||
*/
|
||
export function generateUniqueFileName(
|
||
originalName: string,
|
||
prefix: string = "",
|
||
): string {
|
||
const timestamp = Date.now();
|
||
const random = Math.random().toString(36).substring(2, 8);
|
||
const ext = originalName.split(".").pop();
|
||
const nameWithoutExt = originalName
|
||
.replace(`.${ext}`, "")
|
||
.replace(/[^a-zA-Z0-9]/g, "_");
|
||
|
||
const fileName = `${nameWithoutExt}_${timestamp}_${random}.${ext}`;
|
||
|
||
return prefix ? `${prefix.replace(/\/$/, "")}/${fileName}` : fileName;
|
||
}
|
||
|
||
/**
|
||
* 获取文件扩展名
|
||
* @param filename - 文件名
|
||
* @returns 扩展名(不含点)
|
||
*/
|
||
export function getFileExtension(filename: string): string {
|
||
return filename.split(".").pop() || "";
|
||
}
|
||
|
||
/**
|
||
* 验证文件类型
|
||
* @param filename - 文件名
|
||
* @param allowedTypes - 允许的扩展名数组(如: ['jpg', 'png', 'gif'])
|
||
* @returns 是否为允许的类型
|
||
*/
|
||
export function validateFileType(
|
||
filename: string,
|
||
allowedTypes: string[],
|
||
): boolean {
|
||
const ext = getFileExtension(filename).toLowerCase();
|
||
return allowedTypes.map((t) => t.toLowerCase()).includes(ext);
|
||
}
|
||
|
||
/**
|
||
* 验证文件大小
|
||
* @param size - 文件大小(字节)
|
||
* @param maxSize - 最大大小(字节)
|
||
* @returns 是否在允许的大小范围内
|
||
*/
|
||
export function validateFileSize(size: number, maxSize: number): boolean {
|
||
return size <= maxSize;
|
||
}
|
||
|
||
/**
|
||
* 格式化文件大小
|
||
* @param bytes - 字节数
|
||
* @returns 格式化后的文件大小
|
||
*/
|
||
export function formatFileSize(bytes: number): string {
|
||
if (bytes === 0) return "0 B";
|
||
|
||
const k = 1024;
|
||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
|
||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||
}
|
||
|
||
export default {
|
||
initBucket,
|
||
uploadFile,
|
||
getFileUrl,
|
||
downloadFile,
|
||
getFileStream,
|
||
deleteFile,
|
||
deleteFiles,
|
||
fileExists,
|
||
getFileInfo,
|
||
listFiles,
|
||
copyFile,
|
||
generateUniqueFileName,
|
||
getFileExtension,
|
||
validateFileType,
|
||
validateFileSize,
|
||
formatFileSize,
|
||
};
|