import { EventEmitter } from 'node:events' import { homedir } from 'node:os' import path from 'node:path' import { manageConfigPath } from '@core/datastore/dirs' import windowManager from 'apis/app/window/windowManager' import fs from 'fs-extra' import { get, set, unset } from 'lodash-es' import { IWindowList } from '~/constants' import API from '~/manage/apis/api' import ManageDB from '~/manage/datastore/db' import { formatError, isInputConfigValid } from '~/manage/utils/common' import { deletionError } from '~/manage/utils/deleteObjects' import { invalidateDogecloudTokens } from '~/manage/utils/dogeAPI' import { ManageLogger } from '~/manage/utils/logger' import { type DeleteResult, type DeleteTarget, emptyDeleteResult, failedDeletion, isWithinFolder, mergeDeleteResults, } from '../../shared/deletion' import { isListingRequest, listingChannels, type ListingRequest } from '../../shared/listing' import { runListingRequest } from './listingRequest' import { transferScheduler } from './transferScheduler' export class ManageApi extends EventEmitter implements IManageApiType { private _config!: Partial private db!: ManageDB currentPicBed: string configPath: string baseDir!: string logger: ManageLogger currentPicBedConfig: IPicBedMangeConfig private readonly ALL_CLIENTS = [ 'tcyun', 'aliyun', 'qiniu', 'upyun', 'smms', 'github', 'imgur', 's3plist', 'webdavplist', 'local', 'sftp', ] private readonly CLOUD_STORAGE_CLIENTS = ['tcyun', 'aliyun', 'qiniu', 's3plist'] private readonly BASIC_API_CLIENTS = ['tcyun', 'aliyun', 'qiniu', 'github', 'imgur', 's3plist'] private readonly FOLDER_SUPPORT_CLIENTS = [ 'tcyun', 'aliyun', 'qiniu', 'upyun', 'github', 's3plist', 'webdavplist', 'local', 'sftp', ] constructor(currentPicBed: string = '') { super() this.currentPicBed = currentPicBed || 'placeholder' this.configPath = manageConfigPath() this.initConfigPath() this.logger = new ManageLogger(this) this.initconfig() this.currentPicBedConfig = this.getPicBedConfig(this.currentPicBed) } getMsgParam(method: string) { return { class: 'ManageApi', method, picbedName: this.currentPicBedConfig?.picBedName, } } errorMsg(err: any, param: IStringKeyMap) { this.logger.error(formatError(err, param)) } private readonly clientFactories = { aliyun: () => API.AliyunApi(this.currentPicBedConfig.accessKeyId, this.currentPicBedConfig.accessKeySecret, this.logger), github: () => API.GithubApi( this.currentPicBedConfig.token, this.currentPicBedConfig.githubUsername, this.currentPicBedConfig.proxy, this.logger, ), imgur: () => API.ImgurApi( this.currentPicBedConfig.imgurUserName, this.currentPicBedConfig.accessToken, this.currentPicBedConfig.proxy, this.logger, ), local: () => API.LocalApi(this.logger), qiniu: () => API.QiniuApi(this.currentPicBedConfig.accessKey, this.currentPicBedConfig.secretKey, this.logger), smms: () => API.SmmsApi(this.currentPicBedConfig.token, this.logger), s3plist: () => API.S3plistApi( this.currentPicBedConfig.accessKeyId, this.currentPicBedConfig.secretAccessKey, this.currentPicBedConfig.endpoint, this.currentPicBedConfig.sslEnabled, this.currentPicBedConfig.s3ForcePathStyle, this.currentPicBedConfig.proxy, this.logger, this.currentPicBedConfig.dogeCloudSupport || false, this.currentPicBedConfig.bucketName || '', this.currentPicBedConfig.region || '', this.currentPicBedConfig.customUrl || '', ), sftp: () => API.SftpApi( this.currentPicBedConfig.host, this.currentPicBedConfig.port, this.currentPicBedConfig.username, this.currentPicBedConfig.password, this.currentPicBedConfig.privateKey, this.currentPicBedConfig.passphrase, this.currentPicBedConfig.fileMode, this.currentPicBedConfig.dirMode, this.logger, ), tcyun: () => API.TcyunApi(this.currentPicBedConfig.secretId, this.currentPicBedConfig.secretKey, this.logger), upyun: () => API.UpyunApi( this.currentPicBedConfig.bucketName, this.currentPicBedConfig.operator, this.currentPicBedConfig.password, this.logger, this.currentPicBedConfig.antiLeechToken, this.currentPicBedConfig.expireTime, ), webdavplist: () => API.WebdavplistApi( this.currentPicBedConfig.endpoint, this.currentPicBedConfig.username, this.currentPicBedConfig.password, this.currentPicBedConfig.sslEnabled, this.currentPicBedConfig.proxy, this.currentPicBedConfig.authType, this.logger, ), } async createClient() { const factory = this.clientFactories[this.currentPicBedConfig.picBedName as keyof typeof this.clientFactories] return factory ? factory() : ({} as any) } private async executeWithClient( supportedProviders: string[], method: string, operation: (client: any) => Promise, defaultValue: T, options: { rethrowErrors?: boolean } = {}, ): Promise { if (!supportedProviders.includes(this.currentPicBedConfig.picBedName)) { return defaultValue } try { const client = await this.createClient() return await operation(client) } catch (error: any) { this.errorMsg(error, this.getMsgParam(method)) if (options.rethrowErrors) throw error return defaultValue } } private async executeListing( param: IStringKeyMap | undefined, kind: ListingRequest['kind'], method: string, stream = false, ) { if (!isListingRequest(param) || param.accountId !== this.currentPicBed || param.kind !== kind) { throw new Error('Invalid listing request identity') } const { requestId, accountId, provider, bucketName, prefix } = param const request: ListingRequest = { requestId, accountId, provider, bucketName, prefix, kind } const window = stream ? windowManager.get(IWindowList.SETTING_WINDOW) : undefined return runListingRequest( request, async listing => { if (stream && (!window || window.webContents.isDestroyed())) throw new Error('Listing window is unavailable') if (provider !== this.currentPicBedConfig?.picBedName) throw new Error('Listing account is unavailable') if (kind === 'buckets') return { fullList: await this.listBuckets(), success: true } const client = await this.createClient() if (typeof client[method] !== 'function') throw new Error('Unsupported listing operation') return client[method](param, listing) }, stream ? result => window?.webContents.send(listingChannels(kind).result, result) : undefined, error => this.errorMsg(error, this.getMsgParam(method)), { sender: window?.webContents }, ) } private getPicBedConfig(picBedName: string): IPicBedMangeConfig { return this.getConfig(`picBed.${picBedName}`) } private initConfigPath(): void { if (this.configPath === '') { this.configPath = `${homedir()}/.piclist/manage.json` } if (path.extname(this.configPath).toUpperCase() !== '.JSON') { this.configPath = '' throw Error('The configuration file only supports JSON format.') } this.baseDir = path.dirname(this.configPath) const exist = fs.pathExistsSync(this.configPath) if (!exist) { fs.ensureFileSync(this.configPath) } } private initconfig(): void { this.db = new ManageDB(this) this._config = this.db.read(true) as IManageConfigType } getConfig(name?: string): T { if (!name) { return this._config as unknown as T } return get(this._config, name) } saveConfig(config: IStringKeyMap): void { if (!isInputConfigValid(config)) { this.logger.warn('the format of config is invalid, please provide object') return } this.db.saveConfig(config) this.setConfig(config) this.configureTransfers() } removeConfig(key: string, propName: string): void { if (!key || !propName) { return } this.db.unset(key, propName) this.unsetConfig(key, propName) } setConfig(config: IStringKeyMap): void { if (!isInputConfigValid(config)) { this.logger.warn('the format of config is invalid, please provide object') return } this.updateDogecloudConfig(() => { Object.keys(config).forEach((name: string) => { set(this._config, name, config[name]) }) }) } unsetConfig(key: string, propName: string): void { if (!key || !propName) return this.updateDogecloudConfig(() => unset(this.getConfig(key), propName)) } private updateDogecloudConfig(update: () => void): void { const previous = Object.entries(this._config.picBed || {}) .filter(([, config]) => config?.picBedName === 's3plist' && config.dogeCloudSupport) .map(([name, config]) => ({ name, accessKey: config.accessKeyId, secretKey: config.secretAccessKey })) update() for (const { name, accessKey, secretKey } of previous) { const current = this.getPicBedConfig(name) if ( current?.picBedName !== 's3plist' || !current.dogeCloudSupport || current.accessKeyId !== accessKey || current.secretAccessKey !== secretKey ) { invalidateDogecloudTokens(accessKey, secretKey) } } } async getBucketList(param?: IStringKeyMap): Promise { return this.executeListing(param, 'buckets', 'getBucketList') } private async listBuckets(): Promise { const staticBuckets = { upyun: [{ Name: this.currentPicBedConfig.bucketName, Location: 'upyun', CreationDate: new Date().toISOString() }], smms: [{ Name: 'smms', Location: 'smms', CreationDate: new Date().toISOString() }], webdavplist: [{ Name: 'webdav', Location: 'webdav', CreationDate: new Date().toISOString() }], local: [{ Name: 'local', Location: 'local', CreationDate: new Date().toISOString() }], sftp: [{ Name: 'sftp', Location: 'sftp', CreationDate: new Date().toISOString() }], } const staticResult = staticBuckets[this.currentPicBedConfig.picBedName as keyof typeof staticBuckets] if (staticResult) return staticResult if (!this.BASIC_API_CLIENTS.includes(this.currentPicBedConfig.picBedName)) { throw new Error('Unsupported listing operation') } const client = await this.createClient() return client.getBucketList() } async getBucketInfo(param?: IStringKeyMap | undefined): Promise { console.log(param) return {} } async getBucketDomain(param: IStringKeyMap): Promise { const staticDomains = { upyun: [this.currentPicBedConfig.customUrl], smms: ['https://s.ee'], imgur: ['https://imgur.com'], } const staticResult = staticDomains[this.currentPicBedConfig.picBedName as keyof typeof staticDomains] if (staticResult) return staticResult const supportedClients = ['tcyun', 'aliyun', 'qiniu', 'github'] return this.executeWithClient(supportedClients, 'getBucketDomain', client => client.getBucketDomain(param), []) } async createBucket(param?: IStringKeyMap): Promise { return this.executeWithClient( this.CLOUD_STORAGE_CLIENTS, 'createBucket', client => client.createBucket(param!), false, ) } async deleteBucket(_?: IStringKeyMap): Promise { return false } async getOperatorList(_?: IStringKeyMap): Promise { return [] } async addOperator(_?: IStringKeyMap): Promise { return false } async deleteOperator(_?: IStringKeyMap): Promise { return false } async getBucketAclPolicy(_?: IStringKeyMap): Promise { return {} } async setBucketAclPolicy(param?: IStringKeyMap): Promise { if (this.currentPicBedConfig.picBedName !== 'qiniu') return false return this.executeWithClient(['qiniu'], 'setBucketAclPolicy', client => client.setBucketAclPolicy(param!), false) } async getBucketListRecursively(param?: IStringKeyMap): Promise { return this.executeListing(param, 'download', 'getBucketListRecursively', true) } /** * 后台更新bucket文件列表 * @param param * @returns */ async getBucketListBackstage(param?: IStringKeyMap): Promise { return this.executeListing(param, 'files', 'getBucketListBackstage', true) } /** * 获取文件夹列表 * 结果统一进行格式化 文件夹提取到最前 * key: 完整路径 * fileName: 文件名 * formatedTime: 格式化时间 * isDir: 是否是文件夹 * fileSize: 文件大小 **/ async getBucketFileList(param?: IStringKeyMap): Promise { return this.executeListing(param, 'files', 'getBucketFileList') } async deleteBucketFile(param?: IStringKeyMap): Promise { return this.deleteBucketItems({ ...param, items: [{ key: param?.key, isDir: false, DeleteHash: param?.DeleteHash }], }) } async deleteBucketFolder(param?: IStringKeyMap): Promise { return this.deleteBucketItems({ ...param, items: [{ key: param?.key, isDir: true }] }) } async deleteBucketItems(param: IStringKeyMap): Promise { const targets = param.items as DeleteTarget[] if ( !Array.isArray(targets) || targets.some(item => typeof item?.key !== 'string' || !item.key || typeof item.isDir !== 'boolean') ) { throw new Error('Invalid deletion targets') } const uniqueTargets = [...new Map(targets.map(item => [`${item.isDir}:${item.key}`, item])).values()] const folders = uniqueTargets.filter(item => item.isDir) // A selected folder already covers selected descendants; do not delete them twice. const items = uniqueTargets.filter( item => !folders.some(parent => parent !== item && isWithinFolder(item.key, parent.key)), ) const provider = this.currentPicBedConfig.picBedName if (!this.ALL_CLIENTS.includes(provider)) return failedDeletion(items, 'Unsupported provider') let client: any try { client = await this.createClient() } catch (error) { return failedDeletion(items, deletionError(error)) } const results: DeleteResult[] = [] const cloud = this.CLOUD_STORAGE_CLIENTS.includes(provider) const files = items.filter(item => !item.isDir) if (cloud && files.length > 0) { try { results.push(await client.deleteBucketFiles({ ...param, keys: files.map(item => item.key) })) } catch (error) { results.push(failedDeletion(files, deletionError(error))) } } for (const item of items.filter(item => item.isDir || !cloud)) { try { if (item.isDir && !this.FOLDER_SUPPORT_CLIENTS.includes(provider)) { results.push(failedDeletion([item], 'Folder deletion is unsupported')) continue } const result = await client[item.isDir ? 'deleteBucketFolder' : 'deleteBucketFile']({ ...param, ...item }) if (cloud) results.push(result) else if (result === true) { results.push({ ...emptyDeleteResult(), ...(item.isDir ? { deletedFolders: [item.key] } : { deleted: [item.key] }), }) } else results.push(failedDeletion([item])) } catch (error) { results.push(failedDeletion([item], deletionError(error))) } } return mergeDeleteResults(results) } async renameBucketFile(param?: IStringKeyMap): Promise { const supportedClients = ['tcyun', 'aliyun', 'qiniu', 'upyun', 's3plist', 'webdavplist', 'local', 'sftp'] return this.executeWithClient( supportedClients, 'renameBucketFile', client => client.renameBucketFile(param!), false, ) } async downloadBucketFile(param?: IStringKeyMap): Promise { return this.executeWithClient( this.ALL_CLIENTS, 'downloadBucketFile', client => client.downloadBucketFile(param!), false, ) } async copyMoveBucketFile(_?: IStringKeyMap): Promise { return false } async createBucketFolder(param?: IStringKeyMap): Promise { return this.executeWithClient( this.FOLDER_SUPPORT_CLIENTS, 'createBucketFolder', client => client.createBucketFolder(param!), false, ) } async uploadBucketFile(param?: IStringKeyMap): Promise { this.configureTransfers() return this.executeWithClient( this.ALL_CLIENTS, 'uploadBucketFile', client => client.uploadBucketFile({ ...param, accountId: this.currentPicBed }), false, ) } private configureTransfers(): void { const settings = this.getConfig>('settings') || {} transferScheduler.configure({ globalConcurrency: settings.uploadConcurrency, accountConcurrency: settings.uploadAccountConcurrency, memoryBytes: settings.uploadMemoryMB === undefined ? undefined : settings.uploadMemoryMB * 1024 * 1024, multipartConcurrency: settings.uploadMultipartConcurrency, }) } async getPreSignedUrl(param?: IStringKeyMap): Promise { const supportedClients = ['tcyun', 'aliyun', 'qiniu', 'github', 's3plist', 'webdavplist'] return this.executeWithClient( supportedClients, 'getPreSignedUrl', client => client.getPreSignedUrl(param!), 'error', ) } }