Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .env.template
Original file line number Diff line number Diff line change
@@ -1,2 +1,10 @@
PORT=3000
AUTH_TOKEN=xxx
AUTH_TOKEN=xxx

# MinIO 文件存储配置
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=files
90 changes: 87 additions & 3 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@
"comlink": "^4.4.2",
"date-fns": "^4.1.0",
"express": "^5.1.0",
"minio": "^8.0.5",
"nanoid": "^5.1.5",
"swagger-ui-express": "^5.0.1",
"uuid": "^11.1.0",
"zod": "^3.24.3"
},
"devDependencies": {
Expand Down
35 changes: 35 additions & 0 deletions src/s3/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { z } from 'zod';

export type FileConfig = {
maxFileSize: number; // 文件大小限制(字节)
retentionDays: number; // 保留天数(由 MinIO 生命周期策略自动管理)
endpoint: string; // MinIO endpoint
port?: number; // MinIO port
useSSL: boolean; // 是否使用SSL
accessKey: string; // MinIO access key
secretKey: string; // MinIO secret key
bucket: string; // 存储桶名称
};

// 默认配置(动态从环境变量读取)
export const defaultFileConfig: FileConfig = {
maxFileSize: process.env.MAX_FILE_SIZE ? parseInt(process.env.MAX_FILE_SIZE) : 20 * 1024 * 1024, // 默认 20MB
retentionDays: process.env.RETENTION_DAYS ? parseInt(process.env.RETENTION_DAYS) : 15, // 默认保留15天
endpoint: process.env.MINIO_ENDPOINT || 'localhost',
port: process.env.MINIO_PORT ? parseInt(process.env.MINIO_PORT) : 9000,
useSSL: process.env.MINIO_USE_SSL === 'true',
accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin',
bucket: process.env.MINIO_BUCKET || 'files'
};

export const FileMetadataSchema = z.object({
fileId: z.string(),
originalFilename: z.string(),
contentType: z.string(),
size: z.number(),
uploadTime: z.date(),
accessUrl: z.string()
});

export type FileMetadata = z.infer<typeof FileMetadataSchema>;
236 changes: 236 additions & 0 deletions src/s3/controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import * as Minio from 'minio';
import { randomBytes } from 'crypto';
import { defaultFileConfig, type FileConfig, type FileMetadata } from './config';
import { addLog } from '@/utils/log';
import * as fs from 'fs';
import * as path from 'path';
import { z } from 'zod';

// ================================
// 1. 类型定义和验证
// ================================

export const FileInputSchema = z
.object({
url: z.string().url('Invalid URL format').optional(),
path: z.string().min(1, 'File path cannot be empty').optional(),
data: z.string().min(1, 'Base64 data cannot be empty').optional(),
buffer: z.instanceof(Buffer, { message: 'Buffer is required' }).optional(),
filename: z.string().optional()
})
.refine(
(data) => {
const inputMethods = [data.url, data.path, data.data, data.buffer].filter(Boolean);
return inputMethods.length === 1 && (!(data.data || data.buffer) || data.filename);
},
{
message: 'Provide exactly one input method. Filename required for base64/buffer inputs.'
}
);

export type FileInput = z.infer<typeof FileInputSchema>;

// ================================
// 2. 文件服务主类
// ================================

export class FileService {
private minioClient: Minio.Client;
private config: FileConfig;

// ================================
// 2.1 初始化相关
// ================================

constructor(config?: Partial<FileConfig>) {
this.config = { ...defaultFileConfig, ...config };

this.minioClient = new Minio.Client({
endPoint: this.config.endpoint,
port: this.config.port,
useSSL: this.config.useSSL,
accessKey: this.config.accessKey,
secretKey: this.config.secretKey
});
}

// ================================
// 2.2 连接和存储桶管理
// ================================

// ================================
// 2.3 文件处理工具方法
// ================================

private generateFileId(): string {
return randomBytes(16).toString('hex');
}

private generateAccessUrl(fileId: string, filename: string): string {
const protocol = this.config.useSSL ? 'https' : 'http';
const port =
this.config.port && this.config.port !== (this.config.useSSL ? 443 : 80)
? `:${this.config.port}`
: '';
return `${protocol}://${this.config.endpoint}${port}/${this.config.bucket}/${fileId}/${encodeURIComponent(filename)}`;
}

// 根据文件扩展名推断真实的 Content-Type(用于返回给用户)
private inferContentType(filename: string): string {
const ext = path.extname(filename).toLowerCase();
const mimeMap: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.pdf': 'application/pdf',
'.txt': 'text/plain',
'.json': 'application/json',
'.csv': 'text/csv',
'.zip': 'application/zip',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.doc': 'application/msword',
'.xls': 'application/vnd.ms-excel',
'.ppt': 'application/vnd.ms-powerpoint'
};

return mimeMap[ext] || 'application/octet-stream';
}

// ================================
// 2.4 核心上传方法
// ================================

async uploadFile(fileBuffer: Buffer, originalFilename: string): Promise<FileMetadata> {
if (fileBuffer.length > this.config.maxFileSize) {
return Promise.reject(
new Error(`File size ${fileBuffer.length} exceeds limit ${this.config.maxFileSize}`)
);
}

const fileId = this.generateFileId();
const objectName = `${fileId}/${originalFilename}`;
const uploadTime = new Date();

// 推断文件的真实 Content-Type(用于返回给用户)
const realContentType = this.inferContentType(originalFilename);

try {
await this.minioClient.putObject(
this.config.bucket,
objectName,
fileBuffer,
fileBuffer.length,
{
'Content-Type': 'application/octet-stream', // 强制下载,不预览
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个会导致覆盖原来的 content type 么

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

好像有 attachment 就好了吧

'Content-Disposition': `attachment; filename="${encodeURIComponent(originalFilename)}"`,
'x-amz-meta-original-filename': encodeURIComponent(originalFilename),
'x-amz-meta-upload-time': uploadTime.toISOString()
}
);

const metadata: FileMetadata = {
fileId,
originalFilename,
contentType: realContentType, // 返回真实的 Content-Type
size: fileBuffer.length,
uploadTime,
accessUrl: this.generateAccessUrl(fileId, originalFilename)
};

return metadata;
} catch (error) {
addLog.error('Failed to upload file:', error);
return Promise.reject(error);
}
}

async uploadFileAdvanced(input: FileInput): Promise<FileMetadata> {
const validatedInput = FileInputSchema.parse(input);

try {
const { buffer, filename } = await (async () => {
if (validatedInput.url) return await this.handleNetworkFile(validatedInput);
if (validatedInput.path) return await this.handleLocalFile(validatedInput);
if (validatedInput.data) return this.handleBase64File(validatedInput);
if (validatedInput.buffer) return this.handleBufferFile(validatedInput);
return Promise.reject(new Error('No valid input method provided'));
})();

const metadata = await this.uploadFile(buffer, filename);
return metadata;
} catch (error) {
if (error instanceof z.ZodError) {
return Promise.reject(
new Error(`Invalid input: ${error.errors.map((e) => e.message).join(', ')}`)
);
}
addLog.error(`Upload failed:`, error);
return Promise.reject(error);
}
}

// ================================
// 2.5 多种输入方式文件处理
// ================================

private async handleNetworkFile(input: FileInput): Promise<{ buffer: Buffer; filename: string }> {
const response = await fetch(input.url!);
if (!response.ok)
return Promise.reject(
new Error(`Download failed: ${response.status} ${response.statusText}`)
);

const buffer = Buffer.from(await response.arrayBuffer());
const filename = (() => {
if (input.filename) return input.filename;

const urlFilename = path.basename(new URL(input.url!).pathname) || 'downloaded_file';

// 如果文件名没有扩展名,使用默认扩展名
if (!path.extname(urlFilename)) {
return urlFilename + '.bin'; // 默认扩展名
}

return urlFilename;
})();

return { buffer, filename };
}

private async handleLocalFile(input: FileInput): Promise<{ buffer: Buffer; filename: string }> {
if (!fs.existsSync(input.path!))
return Promise.reject(new Error(`File not found: ${input.path}`));

const buffer = await fs.promises.readFile(input.path!);
const filename = input.filename || path.basename(input.path!);

return { buffer, filename };
}

private handleBase64File(input: FileInput): {
buffer: Buffer;
filename: string;
} {
const base64Data = (() => {
const data = input.data!;
return data.includes(',') ? data.split(',')[1] : data; // Remove data URL prefix if present
})();

return {
buffer: Buffer.from(base64Data, 'base64'),
filename: input.filename!
};
}

private handleBufferFile(input: FileInput): {
buffer: Buffer;
filename: string;
} {
return { buffer: input.buffer!, filename: input.filename! };
}
}
12 changes: 8 additions & 4 deletions src/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import { z } from 'zod';
import { addLog } from '@/utils/log';
import { isProd } from '@/constants';
import type { Worker2MainMessageType } from './type';

import { FileService } from '@/s3/controller';
import { defaultFileConfig } from '@/s3/config';
const fileService = new FileService({
...defaultFileConfig
});
type WorkerQueueItem = {
id: string;
worker: Worker;
Expand Down Expand Up @@ -184,7 +188,7 @@ export async function dispatchWithNewWorker(data: {

const resolvePromise = new Promise<z.infer<typeof ToolCallbackReturnSchema>>(
(resolve, reject) => {
worker.on('message', ({ type, data }: Worker2MainMessageType) => {
worker.on('message', async ({ type, data }: Worker2MainMessageType) => {
if (type === 'success') {
resolve(data);
worker.terminate();
Expand All @@ -198,10 +202,10 @@ export async function dispatchWithNewWorker(data: {
};
addLog[msg.type](`Tool run: `, msg.args);
} else if (type === 'uploadFile') {
// TODO upload
const result = await fileService.uploadFileAdvanced(data);
worker.postMessage({
type: 'uploadFileResponse',
data: null // TODO: response
data: result
});
}
});
Expand Down
7 changes: 4 additions & 3 deletions src/worker/type.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import z from 'zod';

import { FileInputSchema } from '@/s3/controller';
import { FileMetadataSchema } from '@/s3/config';
/**
* Worker --> Main Thread
*/
export const Worker2MainMessageSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('uploadFile'),
data: z.any() // minio upload file params
data: FileInputSchema
}),
z.object({
type: z.literal('log'),
Expand Down Expand Up @@ -40,7 +41,7 @@ export const Main2WorkerMessageSchema = z.discriminatedUnion('type', [
}),
z.object({
type: z.literal('uploadFileResponse'),
data: z.any() // minio upload file response
data: FileMetadataSchema
})
]);

Expand Down
10 changes: 6 additions & 4 deletions src/worker/utils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { FileMetadata } from '@/s3/config';
import type { FileInput } from '@/s3/controller';
import { parentPort } from 'worker_threads';

export const uploadFile = async (data: any) => {
return new Promise((resolve, reject) => {
global.uploadFileResponseFn = (res: any) => {
export const uploadFile = async (data: FileInput) => {
return new Promise<FileMetadata>((resolve, reject) => {
global.uploadFileResponseFn = (res: FileMetadata) => {
resolve(res);
};
parentPort?.postMessage({
Expand All @@ -14,7 +16,7 @@ export const uploadFile = async (data: any) => {

declare global {
// eslint-disable-next-line no-var
var uploadFileResponseFn: (data: any) => void | undefined;
var uploadFileResponseFn: (data: FileMetadata) => void | undefined;
}

export {};
4 changes: 2 additions & 2 deletions src/worker/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ parentPort?.on('message', async (params: Main2WorkerMessageType) => {
});
}
try {
const result = tool?.cb(data.inputs, data.systemVar);

const result = await tool?.cb(data.inputs, data.systemVar);
console.log(result, 'result');
parentPort?.postMessage({
type: 'success',
data: result
Expand Down
Loading