diff --git a/packages/backend/src/core/BunnyService.ts b/packages/backend/src/core/BunnyService.ts new file mode 100644 index 0000000000..d7d174aa2e --- /dev/null +++ b/packages/backend/src/core/BunnyService.ts @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: marie and sharkey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as https from 'node:https'; +import * as fs from 'node:fs'; +import { Readable } from 'node:stream'; +import { finished } from 'node:stream/promises'; +import { Injectable } from '@nestjs/common'; +import type { MiMeta } from '@/models/Meta.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { bindThis } from '@/decorators.js'; + +@Injectable() +export class BunnyService { + constructor( + private httpRequestService: HttpRequestService, + ) { + } + + @bindThis + public getBunnyInfo(meta: MiMeta) { + return { + endpoint: meta.objectStorageEndpoint ?? undefined, + accessKey: meta.objectStorageSecretKey ?? '', + zone: meta.objectStorageBucket ?? undefined, + prefix: meta.objectStoragePrefix ?? '', + }; + } + + @bindThis + public async upload(meta: MiMeta, path: string, input: fs.ReadStream | Buffer) { + const client = this.getBunnyInfo(meta); + + // Required to convert the buffer from webpulic and thumbnail to a ReadableStream for PUT + const data = Buffer.isBuffer(input) ? Readable.from(input) : input; + + const options = { + method: 'PUT', + host: client.endpoint, + path: `/${client.zone}/${path}`, + headers: { + AccessKey: client.accessKey, + 'Content-Type': 'application/octet-stream', + }, + }; + + const req = https.request(options); + + req.on('error', (error) => { + console.error(error); + }); + + data.pipe(req).on('finish', () => { + data.destroy(); + }); + + // wait till stream gets destroyed upon finish of piping to prevent the UI from showing the upload as success wait too early + await finished(data); + } + + @bindThis + public delete(meta: MiMeta, file: string) { + const client = this.getBunnyInfo(meta); + return this.httpRequestService.send(`https://${client.endpoint}/${client.zone}/${file}`, { method: 'DELETE', headers: { AccessKey: client.accessKey } }); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 997d81facc..b12703138d 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -61,6 +61,7 @@ import { ReactionsBufferingService } from './ReactionsBufferingService.js'; import { RelayService } from './RelayService.js'; import { RoleService } from './RoleService.js'; import { S3Service } from './S3Service.js'; +import { BunnyService } from './BunnyService.js'; import { SignupService } from './SignupService.js'; import { WebAuthnService } from './WebAuthnService.js'; import { UserBlockingService } from './UserBlockingService.js'; @@ -208,6 +209,7 @@ const $ReactionsBufferingService: Provider = { provide: 'ReactionsBufferingServi const $RelayService: Provider = { provide: 'RelayService', useExisting: RelayService }; const $RoleService: Provider = { provide: 'RoleService', useExisting: RoleService }; const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service }; +const $BunnyService: Provider = { provide: 'BunnyService', useExisting: BunnyService }; const $SignupService: Provider = { provide: 'SignupService', useExisting: SignupService }; const $WebAuthnService: Provider = { provide: 'WebAuthnService', useExisting: WebAuthnService }; const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService }; @@ -367,6 +369,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp RelayService, RoleService, S3Service, + BunnyService, SignupService, WebAuthnService, UserBlockingService, @@ -522,6 +525,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $RelayService, $RoleService, $S3Service, + $BunnyService, $SignupService, $WebAuthnService, $UserBlockingService, @@ -678,6 +682,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp RelayService, RoleService, S3Service, + BunnyService, SignupService, WebAuthnService, UserBlockingService, @@ -832,6 +837,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $RelayService, $RoleService, $S3Service, + $BunnyService, $SignupService, $WebAuthnService, $UserBlockingService, diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index a65059b417..3d53a94b43 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -44,6 +44,7 @@ import { correctFilename } from '@/misc/correct-filename.js'; import { isMimeImage } from '@/misc/is-mime-image.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { BunnyService } from '@/core/BunnyService.js'; type AddFileArgs = { /** User who wish to add file */ @@ -121,6 +122,7 @@ export class DriveService { private downloadService: DownloadService, private internalStorageService: InternalStorageService, private s3Service: S3Service, + private bunnyService: BunnyService, private imageProcessingService: ImageProcessingService, private videoProcessingService: VideoProcessingService, private globalEventService: GlobalEventService, @@ -405,20 +407,24 @@ export class DriveService { ); if (this.meta.objectStorageSetPublicRead) params.ACL = 'public-read'; - await this.s3Service.upload(this.meta, params) - .then( - result => { - if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput - this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`); - } else { // AbortMultipartUploadCommandOutput - this.registerLogger.error(`Upload Result Aborted: key = ${key}, filename = ${filename}`); - } - }) - .catch( - err => { - this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}`, err); - }, - ); + if (this.meta.objectStorageAccessKey) { + await this.s3Service.upload(this.meta, params) + .then( + result => { + if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput + this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`); + } else { // AbortMultipartUploadCommandOutput + this.registerLogger.error(`Upload Result Aborted: key = ${key}, filename = ${filename}`); + } + }) + .catch( + err => { + this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}`, err); + }, + ); + } else { + await this.bunnyService.upload(this.meta, key, stream); + } } // Expire oldest file (without avatar or banner) of remote user @@ -814,8 +820,11 @@ export class DriveService { Bucket: this.meta.objectStorageBucket, Key: key, } as DeleteObjectCommandInput; - - await this.s3Service.delete(this.meta, param); + if (this.meta.objectStorageAccessKey) { + await this.s3Service.delete(this.meta, param); + } else { + await this.bunnyService.delete(this.meta, key); + } } catch (err: any) { if (err.name === 'NoSuchKey') { this.deleteLogger.warn(`The object storage had no such key to delete: ${key}. Skipping this.`, err as Error);