Hair Keeper v1.0.0:一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑
This commit is contained in:
351
src/server/minio.ts
Normal file
351
src/server/minio.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import 'server-only'
|
||||
import { Client } from 'minio';
|
||||
|
||||
// 初始化 MinIO 客户端
|
||||
export const minioClient = new Client({
|
||||
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
|
||||
port: parseInt(process.env.MINIO_API_PORT || '9000'),
|
||||
useSSL: process.env.MINIO_USE_SSL === 'true',
|
||||
accessKey: process.env.MINIO_ROOT_USER || '',
|
||||
secretKey: process.env.MINIO_ROOT_PASSWORD || '',
|
||||
});
|
||||
|
||||
export const BUCKET_NAME = process.env.MINIO_BUCKET || 'app-files';
|
||||
|
||||
// 桶初始化标志
|
||||
let bucketInitialized = false;
|
||||
|
||||
/**
|
||||
* 检查并初始化存储桶
|
||||
* 如果桶不存在则创建,并设置为公开读取策略(可根据需求调整)
|
||||
*/
|
||||
export async function ensureBucketExists(): Promise<void> {
|
||||
if (bucketInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const exists = await minioClient.bucketExists(BUCKET_NAME);
|
||||
|
||||
if (!exists) {
|
||||
await minioClient.makeBucket(BUCKET_NAME, 'cn-beijing-1');
|
||||
console.log(`MinIO bucket '${BUCKET_NAME}' created successfully`);
|
||||
|
||||
// 设置桶策略:默认私有,但 /public 路径下的文件公开可读
|
||||
const policy = {
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Principal: { AWS: ['*'] },
|
||||
Action: ['s3:GetObject'],
|
||||
Resource: [`arn:aws:s3:::${BUCKET_NAME}/public/*`],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await minioClient.setBucketPolicy(BUCKET_NAME, JSON.stringify(policy));
|
||||
console.log(`MinIO bucket '${BUCKET_NAME}' policy set: private by default, public read for /public path`);
|
||||
}
|
||||
|
||||
bucketInitialized = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to ensure bucket exists:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预签名 POST 策略配置
|
||||
*/
|
||||
export interface PresignedPostPolicyOptions {
|
||||
/** 对象名称前缀(不含文件名) public开头是可以公开GetObject */
|
||||
prefix: string;
|
||||
/** 文件名(可选,如果指定则只允许上传该文件名) */
|
||||
fileName?: string;
|
||||
/** 过期时间(秒),默认 1 小时 */
|
||||
expirySeconds?: number;
|
||||
/** 最大文件大小(字节),默认 100MB */
|
||||
maxSize?: number;
|
||||
/** 允许的文件类型(MIME类型数组),默认允许所有类型 */
|
||||
allowedContentType?: string;
|
||||
/** 是否允许上传原始文件名元信息,默认 true */
|
||||
allowOriginalFilename?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预签名 POST 策略结果
|
||||
*/
|
||||
export interface PresignedPostPolicyResult {
|
||||
/** POST 请求的 URL */
|
||||
postURL: string;
|
||||
/** POST 请求需要的表单字段 */
|
||||
formData: Record<string, string>;
|
||||
/** 对象名称(完整路径) */
|
||||
objectName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成预签名的 POST 策略(用于客户端直传)
|
||||
* 支持单文件上传(指定fileName)或批量上传(不指定fileName,只校验前缀)
|
||||
* 如果客户端无法上传,通过 mc admin trace <ALIAS> --verbose --all 排查minio存储桶那边的详细日志
|
||||
*/
|
||||
export async function generatePresignedPostPolicy(
|
||||
options: PresignedPostPolicyOptions
|
||||
): Promise<PresignedPostPolicyResult> {
|
||||
await ensureBucketExists();
|
||||
|
||||
const {
|
||||
prefix,
|
||||
fileName,
|
||||
expirySeconds = 3600,
|
||||
maxSize = 100 * 1024 * 1024, // 默认 100MB
|
||||
allowedContentType,
|
||||
allowOriginalFilename = true
|
||||
} = options;
|
||||
|
||||
try {
|
||||
const policy = minioClient.newPostPolicy();
|
||||
|
||||
// 设置过期时间
|
||||
const expiryDate = new Date();
|
||||
expiryDate.setSeconds(expiryDate.getSeconds() + expirySeconds);
|
||||
policy.setExpires(expiryDate);
|
||||
|
||||
// 设置存储桶
|
||||
policy.setBucket(BUCKET_NAME);
|
||||
|
||||
// 构建完整的对象名称
|
||||
const objectName = fileName ? `${prefix}/${fileName}` : prefix;
|
||||
|
||||
// 如果指定了文件名,则精确匹配;否则只匹配前缀
|
||||
if (fileName) {
|
||||
policy.setKey(objectName);
|
||||
} else {
|
||||
policy.setKeyStartsWith(prefix);
|
||||
}
|
||||
|
||||
// 设置文件大小限制
|
||||
policy.setContentLengthRange(1, maxSize);
|
||||
|
||||
// 设置允许的内容类型(支持精确匹配和通配符)
|
||||
if (allowedContentType) {
|
||||
// 判断是否为通配符模式(如 'image/*')
|
||||
if (allowedContentType.endsWith('/*')) {
|
||||
// 将通配符模式转换为前缀(如 'image/*' -> 'image/')
|
||||
const prefix = allowedContentType.slice(0, -1);
|
||||
policy.setContentTypeStartsWith(prefix);
|
||||
} else {
|
||||
// 精确匹配
|
||||
policy.setContentType(allowedContentType);
|
||||
}
|
||||
}
|
||||
|
||||
if (allowOriginalFilename) {
|
||||
// 允许客户端设置 x-amz-meta-original-filename 元信息
|
||||
policy.policy.conditions.push(['starts-with', '$x-amz-meta-original-filename', ''])
|
||||
}
|
||||
|
||||
// 生成预签名 POST 数据
|
||||
const presignedData = await minioClient.presignedPostPolicy(policy);
|
||||
|
||||
return {
|
||||
postURL: presignedData.postURL,
|
||||
formData: presignedData.formData,
|
||||
objectName,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to generate presigned POST policy:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预签名 GET 对象配置
|
||||
*/
|
||||
export interface PresignedGetObjectOptions {
|
||||
/** 对象名称(文件路径) */
|
||||
objectName: string;
|
||||
/** 过期时间(秒),默认 1 小时 */
|
||||
expirySeconds?: number;
|
||||
/** 自定义响应头 */
|
||||
responseHeaders?: {
|
||||
/** 下载时的文件名,默认使用对象的 x-amz-meta-original-filename 元信息 */
|
||||
'response-content-disposition'?: string;
|
||||
/** 内容类型 */
|
||||
'response-content-type'?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 预签名 GET 对象结果
|
||||
*/
|
||||
export interface PresignedGetObjectResult {
|
||||
/** 预签名的 GET URL */
|
||||
url: string;
|
||||
/** 过期时间(秒) */
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成预签名的 GET URL(用于下载对象)
|
||||
* 文件名默认使用对象的 x-amz-meta-original-filename 元信息
|
||||
*/
|
||||
export async function generatePresignedGetObject(
|
||||
options: PresignedGetObjectOptions
|
||||
): Promise<PresignedGetObjectResult> {
|
||||
await ensureBucketExists();
|
||||
|
||||
const {
|
||||
objectName,
|
||||
expirySeconds = 3600,
|
||||
responseHeaders = {},
|
||||
} = options;
|
||||
|
||||
try {
|
||||
// 如果没有指定响应头,尝试从对象元数据中获取
|
||||
if (!responseHeaders['response-content-disposition'] || !responseHeaders['response-content-type']) {
|
||||
try {
|
||||
const metadata = await minioClient.statObject(BUCKET_NAME, objectName);
|
||||
|
||||
// 设置 Content-Disposition(文件名)
|
||||
if (!responseHeaders['response-content-disposition']) {
|
||||
const originalFilename = metadata.metaData?.['original-filename'];
|
||||
if (originalFilename) {
|
||||
// 使用 attachment 强制下载,并设置文件名
|
||||
responseHeaders['response-content-disposition'] =
|
||||
`attachment; filename*=UTF-8''${originalFilename}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 设置 Content-Type
|
||||
if (!responseHeaders['response-content-type'] && metadata.metaData?.['content-type']) {
|
||||
responseHeaders['response-content-type'] = metadata.metaData['content-type'];
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果获取元数据失败,继续使用默认行为
|
||||
console.warn('Failed to get object metadata:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 生成预签名 URL
|
||||
const url = await minioClient.presignedGetObject(
|
||||
BUCKET_NAME,
|
||||
objectName,
|
||||
expirySeconds,
|
||||
responseHeaders
|
||||
);
|
||||
|
||||
return {
|
||||
url,
|
||||
expiresIn: expirySeconds,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to generate presigned GET URL:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除对象
|
||||
* @param objectName 对象名称(文件路径)
|
||||
*/
|
||||
export async function deleteObject(objectName: string): Promise<void> {
|
||||
await ensureBucketExists();
|
||||
|
||||
try {
|
||||
await minioClient.removeObject(BUCKET_NAME, objectName);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete object:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除对象
|
||||
* @param objectNames 对象名称数组
|
||||
*/
|
||||
export async function deleteBatchObjects(objectNames: string[]): Promise<void> {
|
||||
await ensureBucketExists();
|
||||
|
||||
try {
|
||||
await minioClient.removeObjects(BUCKET_NAME, objectNames);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete batch objects:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查对象是否存在
|
||||
* @param objectName 对象名称(文件路径)
|
||||
* @returns 是否存在
|
||||
*/
|
||||
export async function objectExists(objectName: string): Promise<boolean> {
|
||||
await ensureBucketExists();
|
||||
|
||||
try {
|
||||
await minioClient.statObject(BUCKET_NAME, objectName);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量检查对象是否存在
|
||||
* @param objectNames 对象名称数组
|
||||
* @returns 对象存在状态的映射表 { objectName: boolean }
|
||||
*/
|
||||
export async function batchObjectExists(
|
||||
objectNames: string[]
|
||||
): Promise<Record<string, boolean>> {
|
||||
await ensureBucketExists();
|
||||
|
||||
const results: Record<string, boolean> = {};
|
||||
|
||||
// 并发检查所有对象
|
||||
await Promise.all(
|
||||
objectNames.map(async (objectName) => {
|
||||
try {
|
||||
await minioClient.statObject(BUCKET_NAME, objectName);
|
||||
results[objectName] = true;
|
||||
} catch (error) {
|
||||
results[objectName] = false;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取对象元数据
|
||||
* @param objectName 对象名称(文件路径)
|
||||
* @returns 对象元数据
|
||||
*/
|
||||
export async function getObjectMetadata(objectName: string) {
|
||||
await ensureBucketExists();
|
||||
|
||||
try {
|
||||
const stat = await minioClient.statObject(BUCKET_NAME, objectName);
|
||||
return stat;
|
||||
} catch (error) {
|
||||
console.error('Failed to get object metadata:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成公开访问的 URL(需要桶策略支持公开读取)
|
||||
* @param objectName 对象名称(文件路径)
|
||||
* @returns 公开访问 URL
|
||||
*/
|
||||
export function getPublicUrl(objectName: string): string {
|
||||
const protocol = process.env.MINIO_USE_SSL === 'true' ? 'https' : 'http';
|
||||
const endpoint = process.env.MINIO_ENDPOINT || 'localhost';
|
||||
const port = process.env.MINIO_API_PORT || '9000';
|
||||
|
||||
return `${protocol}://${endpoint}:${port}/${BUCKET_NAME}/${objectName}`;
|
||||
}
|
||||
Reference in New Issue
Block a user