From e8aa7d21a8fa08f6b4c6e4e7323e2a572e200a1e Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Tue, 17 Mar 2026 15:30:00 +0800 Subject: [PATCH] feat(rustfs): add RustFS object storage service for report persistence Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/main/services/rustfs/index.ts | 6 + src/main/services/rustfs/rustfs-service.ts | 376 +++++++++++++++++++++ 2 files changed, 382 insertions(+) create mode 100644 src/main/services/rustfs/index.ts create mode 100644 src/main/services/rustfs/rustfs-service.ts diff --git a/src/main/services/rustfs/index.ts b/src/main/services/rustfs/index.ts new file mode 100644 index 0000000..ff4ce4c --- /dev/null +++ b/src/main/services/rustfs/index.ts @@ -0,0 +1,6 @@ +/** + * RustFS Service Module + */ + +export { RustfsService } from './rustfs-service' +export type { UploadResult, DownloadResult, RustfsServiceOptions } from './rustfs-service' diff --git a/src/main/services/rustfs/rustfs-service.ts b/src/main/services/rustfs/rustfs-service.ts new file mode 100644 index 0000000..a9a556e --- /dev/null +++ b/src/main/services/rustfs/rustfs-service.ts @@ -0,0 +1,376 @@ +/** + * RustFS Service + * + * S3-compatible object storage service for persisting reports and files + * Uses AWS SDK for S3 protocol compatibility + */ + +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + DeleteObjectCommand, + ListObjectsV2Command, + type PutObjectCommandInput, + type GetObjectCommandInput, + type DeleteObjectCommandInput +} from '@aws-sdk/client-s3' +import { createLogger } from '../logger' +import type { RustfsConfig } from '../../types/config.schema' +import * as fs from 'fs' +import * as path from 'path' + +const log = createLogger('RustfsService') + +export interface UploadResult { + success: boolean + key: string + etag?: string + error?: string +} + +export interface DownloadResult { + success: boolean + content: Buffer + error?: string +} + +export interface RustfsServiceOptions { + config: RustfsConfig +} + +export class RustfsService { + private client: S3Client + private config: RustfsConfig + + constructor(options: RustfsServiceOptions) { + const { config } = options + + this.config = config + + // Configure S3 client for RustFS + // RustFS is fully compatible with S3 protocol + this.client = new S3Client({ + region: config.region || 'us-east-1', + endpoint: config.endpoint, + credentials: { + accessKeyId: config.accessKey, + secretAccessKey: config.secretKey + }, + forcePathStyle: true // Required for some S3-compatible services + }) + + log.info('RustFS service initialized', { + endpoint: config.endpoint, + bucket: config.bucket, + region: config.region + }) + } + + /** + * Upload a file to RustFS + * @param filePath - Local file path to upload + * @param key - Object key (path) in the bucket + * @param contentType - Optional MIME type + */ + async uploadFile(filePath: string, key: string, contentType?: string): Promise { + try { + // Validate configuration + if (!this.config.enabled) { + return { + success: false, + key, + error: 'RustFS is not enabled in configuration' + } + } + + // Check if file exists + if (!fs.existsSync(filePath)) { + return { + success: false, + key, + error: `File not found: ${filePath}` + } + } + + // Read file content + const fileContent = await fs.promises.readFile(filePath) + + // Determine content type + const mimeType = contentType || this.getMimeType(filePath) || 'application/octet-stream' + + log.info('Uploading file to RustFS', { + filePath, + key, + contentType: mimeType, + size: fileContent.length + }) + + const input: PutObjectCommandInput = { + Bucket: this.config.bucket, + Key: key, + Body: fileContent, + ContentType: mimeType + } + + const command = new PutObjectCommand(input) + const response = await this.client.send(command) + + log.info('File uploaded successfully', { + key, + etag: response.ETag + }) + + return { + success: true, + key, + etag: response.ETag + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown upload error' + log.error('Failed to upload file to RustFS', { + filePath, + key, + error: errorMessage + }) + + return { + success: false, + key, + error: errorMessage + } + } + } + + /** + * Upload a string content directly to RustFS + * @param content - String content to upload + * @param key - Object key (path) in the bucket + * @param contentType - Optional MIME type + */ + async uploadString(content: string, key: string, contentType?: string): Promise { + try { + if (!this.config.enabled) { + return { + success: false, + key, + error: 'RustFS is not enabled in configuration' + } + } + + const mimeType = contentType || 'text/plain; charset=utf-8' + + log.info('Uploading string content to RustFS', { + key, + contentType: mimeType, + size: content.length + }) + + const input: PutObjectCommandInput = { + Bucket: this.config.bucket, + Key: key, + Body: Buffer.from(content, 'utf-8'), + ContentType: mimeType + } + + const command = new PutObjectCommand(input) + const response = await this.client.send(command) + + log.info('String content uploaded successfully', { + key, + etag: response.ETag + }) + + return { + success: true, + key, + etag: response.ETag + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown upload error' + log.error('Failed to upload string to RustFS', { + key, + error: errorMessage + }) + + return { + success: false, + key, + error: errorMessage + } + } + } + + /** + * Download a file from RustFS + * @param key - Object key (path) in the bucket + */ + async downloadFile(key: string): Promise { + try { + if (!this.config.enabled) { + return { + success: false, + content: Buffer.alloc(0), + error: 'RustFS is not enabled in configuration' + } + } + + log.info('Downloading file from RustFS', { key }) + + const input: GetObjectCommandInput = { + Bucket: this.config.bucket, + Key: key + } + + const command = new GetObjectCommand(input) + const response = await this.client.send(command) + + const chunks: Buffer[] = [] + for await (const chunk of response.Body as any) { + chunks.push(Buffer.from(chunk)) + } + + const content = Buffer.concat(chunks) + + log.info('File downloaded successfully', { + key, + size: content.length + }) + + return { + success: true, + content + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown download error' + log.error('Failed to download file from RustFS', { + key, + error: errorMessage + }) + + return { + success: false, + content: Buffer.alloc(0), + error: errorMessage + } + } + } + + /** + * Delete a file from RustFS + * @param key - Object key (path) in the bucket + */ + async deleteFile(key: string): Promise<{ success: boolean; error?: string }> { + try { + if (!this.config.enabled) { + return { + success: false, + error: 'RustFS is not enabled in configuration' + } + } + + log.info('Deleting file from RustFS', { key }) + + const input: DeleteObjectCommandInput = { + Bucket: this.config.bucket, + Key: key + } + + const command = new DeleteObjectCommand(input) + await this.client.send(command) + + log.info('File deleted successfully', { key }) + + return { + success: true + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown delete error' + log.error('Failed to delete file from RustFS', { + key, + error: errorMessage + }) + + return { + success: false, + error: errorMessage + } + } + } + + /** + * Generate a storage key for cleaner reports + * @param reportFileName - Original report file name + * @param username - Username who generated the report + */ + generateReportKey(reportFileName: string, username: string): string { + // Organize reports by user for easy access + // Format: reports/cleaner/{username}/{filename} + return `reports/cleaner/${username}/${reportFileName}` + } + + /** + * Get MIME type based on file extension + */ + private getMimeType(filePath: string): string | null { + const ext = path.extname(filePath).toLowerCase() + const mimeTypes: Record = { + '.md': 'text/markdown; charset=utf-8', + '.txt': 'text/plain; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.xls': 'application/vnd.ms-excel', + '.csv': 'text/csv; charset=utf-8', + '.pdf': 'application/pdf', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif' + } + return mimeTypes[ext] || null + } + + /** + * Test connection to RustFS + */ + async testConnection(): Promise<{ + success: boolean + message: string + error?: string + }> { + try { + log.info('Testing RustFS connection', { + endpoint: this.config.endpoint, + bucket: this.config.bucket + }) + + // Try to list objects in the bucket (head bucket operation) + const input = { + Bucket: this.config.bucket, + Prefix: '', + MaxKeys: 1 + } + + const command = new ListObjectsV2Command(input) + await this.client.send(command) + + log.info('RustFS connection test successful') + + return { + success: true, + message: '连接成功' + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown connection error' + log.error('RustFS connection test failed', { + error: errorMessage + }) + + return { + success: false, + message: '连接失败', + error: errorMessage + } + } + } +}