Files
hair-keeper/src/server/minio.ts
liuyh 5020bd1532 feat: Hair Keeper v1.1.0 版本更新
本次更新包含以下主要改进:

## 新功能
- 添加quickstart.sh脚本帮助用户快速使用模板项目
- 添加simple_deploy.sh便于部署
- 新增院系管理功能(DeptAdmin),支持增删改查院系管理员信息
- 用户可以在header中切换管理的院系
- 添加zustand全局状态管理
- 添加DEFAULT_USER_PASSWORD环境变量,作为创建用户时的默认密码
- 添加p-limit库和DB_PARALLEL_LIMIT环境变量控制数据库批次操作并发数

## 安全修复
- 修复Next.js CVE-2025-66478漏洞
- 限制只有超级管理员才能创建超级管理员用户

## 开发环境优化
- 开发终端兼容云端环境
- MinIO客户端直传兼容云端环境
- 开发容器增加vim和Claude Code插件
- 编程代理改用Claude
- docker-compose.yml添加全局name属性

## Bug修复与代码优化
- 删除用户时级联删除SelectionLog
- 手机端关闭侧边栏后刷新页面延迟调整(300ms=>350ms)
- instrumentation.ts移至src内部以适配生产环境
- 删除部分引发类型错误的无用代码
- 优化quickstart.sh远程仓库推送相关配置

## 文件变更
- 新增49个文件,修改多个配置和源代码文件
- 重构用户管理模块目录结构

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-23 16:58:55 +08:00

374 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
/**
* 获取客户端访问的基础 URL
* 优先使用 MINIO_SERVER_URL公网地址否则使用内部地址
*/
function getClientBaseUrl(): string {
if (process.env.MINIO_SERVER_URL) {
return process.env.MINIO_SERVER_URL;
}
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}`;
}
/**
* 替换预签名 URL 中的内部地址为客户端可访问的地址
*/
function replaceUrlBase(originalUrl: string): string {
const clientBase = getClientBaseUrl();
const url = new URL(originalUrl);
const clientUrl = new URL(clientBase);
url.protocol = clientUrl.protocol;
url.hostname = clientUrl.hostname;
url.port = clientUrl.port; // 空字符串表示使用协议默认端口
return url.toString();
}
// 桶初始化标志
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: replaceUrlBase(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: replaceUrlBase(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 {
return `${getClientBaseUrl()}/${BUCKET_NAME}/${objectName}`;
}