278fd931fb
* chore: import opendal * feat: 添加S3备份支持及相关设置界面 - 在IpcChannel中新增S3备份相关IPC事件,支持备份、恢复、 列表、删除文件及连接检测 - 在ipc主进程注册对应的S3备份处理函数,集成backupManager - 新增S3设置页面,支持配置Endpoint、Region、Bucket、AccessKey等 参数,并提供同步和备份策略的UI控制 - 删除未使用的RemoteStorage.ts,简化代码库 提升备份功能的灵活性,支持S3作为远程存储目标 * feat(S3 Backup): 完善S3备份功能 - 支持自动备份 - 优化设置前端 - 优化备份恢复代码 * feat(i18n): add S3 storage translations * feat(settings): 优化数据设置页面和S3设置页面UI * feat(settings): optimize S3 settings state structure and update usage * refactor: simplify S3 backup and restore modal logic * feat(s3 backup): improve S3 settings defaults and modal props * fix(i18n): optimize S3 access key translations * feat(backup): optimize logging and progress reporting * fix(settings): set S3 maxBackups as unlimited by default * chore(package): restore opendal dependency in package.json * feat(backup): migrate S3 Backup dependency from opendal to aws-sdk * refactor(backup): simplify S3 config handling and partial updates * refactor(backup): update Nutstore sync state to use RemoteSyncState * feat(store): add migration 120 to initialize missing s3 settings * feat(settings): add tooltip and help link for S3 storage * fix(s3settings): disable backup button until all fields are set --------- Co-authored-by: suyao <sy20010504@gmail.com>
184 lines
5.2 KiB
TypeScript
184 lines
5.2 KiB
TypeScript
import {
|
|
DeleteObjectCommand,
|
|
GetObjectCommand,
|
|
HeadBucketCommand,
|
|
ListObjectsV2Command,
|
|
PutObjectCommand,
|
|
S3Client
|
|
} from '@aws-sdk/client-s3'
|
|
import type { S3Config } from '@types'
|
|
import Logger from 'electron-log'
|
|
import * as net from 'net'
|
|
import { Readable } from 'stream'
|
|
|
|
/**
|
|
* 将可读流转换为 Buffer
|
|
*/
|
|
function streamToBuffer(stream: Readable): Promise<Buffer> {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks: Buffer[] = []
|
|
stream.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))
|
|
stream.on('error', reject)
|
|
stream.on('end', () => resolve(Buffer.concat(chunks)))
|
|
})
|
|
}
|
|
|
|
// 需要使用 Virtual Host-Style 的服务商域名后缀白名单
|
|
const VIRTUAL_HOST_SUFFIXES = ['aliyuncs.com', 'myqcloud.com']
|
|
|
|
/**
|
|
* 使用 AWS SDK v3 的简单 S3 封装,兼容之前 RemoteStorage 的最常用接口。
|
|
*/
|
|
export default class S3Storage {
|
|
private client: S3Client
|
|
private bucket: string
|
|
private root: string
|
|
|
|
constructor(config: S3Config) {
|
|
const { endpoint, region, accessKeyId, secretAccessKey, bucket, root } = config
|
|
|
|
const usePathStyle = (() => {
|
|
if (!endpoint) return false
|
|
|
|
try {
|
|
const { hostname } = new URL(endpoint)
|
|
|
|
if (hostname === 'localhost' || net.isIP(hostname) !== 0) {
|
|
return true
|
|
}
|
|
|
|
const isInWhiteList = VIRTUAL_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix))
|
|
return !isInWhiteList
|
|
} catch (e) {
|
|
Logger.warn('[S3Storage] Failed to parse endpoint, fallback to Path-Style:', endpoint, e)
|
|
return true
|
|
}
|
|
})()
|
|
|
|
this.client = new S3Client({
|
|
region,
|
|
endpoint: endpoint || undefined,
|
|
credentials: {
|
|
accessKeyId: accessKeyId,
|
|
secretAccessKey: secretAccessKey
|
|
},
|
|
forcePathStyle: usePathStyle
|
|
})
|
|
|
|
this.bucket = bucket
|
|
this.root = root?.replace(/^\/+/g, '').replace(/\/+$/g, '') || ''
|
|
|
|
this.putFileContents = this.putFileContents.bind(this)
|
|
this.getFileContents = this.getFileContents.bind(this)
|
|
this.deleteFile = this.deleteFile.bind(this)
|
|
this.listFiles = this.listFiles.bind(this)
|
|
this.checkConnection = this.checkConnection.bind(this)
|
|
}
|
|
|
|
/**
|
|
* 内部辅助方法,用来拼接带 root 的对象 key
|
|
*/
|
|
private buildKey(key: string): string {
|
|
if (!this.root) return key
|
|
return key.startsWith(`${this.root}/`) ? key : `${this.root}/${key}`
|
|
}
|
|
|
|
async putFileContents(key: string, data: Buffer | string) {
|
|
try {
|
|
const contentType = key.endsWith('.zip') ? 'application/zip' : 'application/octet-stream'
|
|
|
|
return await this.client.send(
|
|
new PutObjectCommand({
|
|
Bucket: this.bucket,
|
|
Key: this.buildKey(key),
|
|
Body: data,
|
|
ContentType: contentType
|
|
})
|
|
)
|
|
} catch (error) {
|
|
Logger.error('[S3Storage] Error putting object:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async getFileContents(key: string): Promise<Buffer> {
|
|
try {
|
|
const res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: this.buildKey(key) }))
|
|
if (!res.Body || !(res.Body instanceof Readable)) {
|
|
throw new Error('Empty body received from S3')
|
|
}
|
|
return await streamToBuffer(res.Body as Readable)
|
|
} catch (error) {
|
|
Logger.error('[S3Storage] Error getting object:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async deleteFile(key: string) {
|
|
try {
|
|
const keyWithRoot = this.buildKey(key)
|
|
const variations = new Set([keyWithRoot, key.replace(/^\//, '')])
|
|
for (const k of variations) {
|
|
try {
|
|
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: k }))
|
|
} catch {
|
|
// 忽略删除失败
|
|
}
|
|
}
|
|
} catch (error) {
|
|
Logger.error('[S3Storage] Error deleting object:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 列举指定前缀下的对象,默认列举全部。
|
|
*/
|
|
async listFiles(prefix = ''): Promise<Array<{ key: string; lastModified?: string; size: number }>> {
|
|
const files: Array<{ key: string; lastModified?: string; size: number }> = []
|
|
let continuationToken: string | undefined
|
|
const fullPrefix = this.buildKey(prefix)
|
|
|
|
try {
|
|
do {
|
|
const res = await this.client.send(
|
|
new ListObjectsV2Command({
|
|
Bucket: this.bucket,
|
|
Prefix: fullPrefix === '' ? undefined : fullPrefix,
|
|
ContinuationToken: continuationToken
|
|
})
|
|
)
|
|
|
|
res.Contents?.forEach((obj) => {
|
|
if (!obj.Key) return
|
|
files.push({
|
|
key: obj.Key,
|
|
lastModified: obj.LastModified?.toISOString(),
|
|
size: obj.Size ?? 0
|
|
})
|
|
})
|
|
|
|
continuationToken = res.IsTruncated ? res.NextContinuationToken : undefined
|
|
} while (continuationToken)
|
|
|
|
return files
|
|
} catch (error) {
|
|
Logger.error('[S3Storage] Error listing objects:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 尝试调用 HeadBucket 判断凭证/网络是否可用
|
|
*/
|
|
async checkConnection() {
|
|
try {
|
|
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }))
|
|
return true
|
|
} catch (error) {
|
|
Logger.error('[S3Storage] Error checking connection:', error)
|
|
throw error
|
|
}
|
|
}
|
|
}
|