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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
6
src/main/services/rustfs/index.ts
Normal file
6
src/main/services/rustfs/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* RustFS Service Module
|
||||
*/
|
||||
|
||||
export { RustfsService } from './rustfs-service'
|
||||
export type { UploadResult, DownloadResult, RustfsServiceOptions } from './rustfs-service'
|
||||
376
src/main/services/rustfs/rustfs-service.ts
Normal file
376
src/main/services/rustfs/rustfs-service.ts
Normal file
@@ -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<UploadResult> {
|
||||
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<UploadResult> {
|
||||
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<DownloadResult> {
|
||||
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<string, string> = {
|
||||
'.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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user