/* eslint-disable @typescript-eslint/no-shadow */ import { S3Client, DeleteObjectCommand, ObjectCannedACL } from '@aws-sdk/client-s3' import { Upload } from '@aws-sdk/lib-storage' import type { FileUploadCallback, FileDeleteCallback } from './types' import type { S3Config } from '@config/index' let cachedClient: S3Client | null = null let cachedConfig: S3Config | null = null const getS3Client = (config: S3Config): S3Client => { if (cachedClient) { if ( cachedConfig?.AWS_ENDPOINT !== config.AWS_ENDPOINT || cachedConfig?.AWS_ACCESS_KEY_ID !== config.AWS_ACCESS_KEY_ID || cachedConfig?.AWS_SECRET_ACCESS_KEY !== config.AWS_SECRET_ACCESS_KEY ) { throw new Error('S3Client singleton was created with different credentials') } return cachedClient } const { AWS_ENDPOINT, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY } = config cachedClient = new S3Client({ credentials: { accessKeyId: AWS_ACCESS_KEY_ID, secretAccessKey: AWS_SECRET_ACCESS_KEY, }, endpoint: AWS_ENDPOINT, forcePathStyle: true, }) cachedConfig = config return cachedClient } export const s3Service = (config: S3Config, prefix: string) => { const { AWS_BUCKET: Bucket } = config const s3 = getS3Client(config) const uploadFile: FileUploadCallback = async ({ createReadStream, uniqueFilename, mimetype }) => { const s3Location = prefix.length > 0 ? `${prefix}/${uniqueFilename}` : uniqueFilename const params = { Bucket, Key: s3Location, ACL: ObjectCannedACL.public_read, CacheControl: 'public, max-age=604800', // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment ContentType: mimetype, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call Body: createReadStream(), } const command = new Upload({ client: s3, params }) const data = await command.done() let { Location: location } = data if (!location) { throw new Error('File upload did not return `Location`') } if (!location.startsWith('https://') && !location.startsWith('http://')) { // Ensure the location has a protocol. Hetzner sometimes does not return a protocol in the location. location = `https://${location}` } return location } const deleteFile: FileDeleteCallback = async (url) => { let { pathname } = new URL(url, 'http://example.org') // dummy domain to avoid invalid URL error pathname = pathname.substring(1) // remove first character '/' const prefix = `${Bucket}/` if (pathname.startsWith(prefix)) { pathname = pathname.slice(prefix.length) } const params = { Bucket, Key: pathname, } await s3.send(new DeleteObjectCommand(params)) } return { uploadFile, deleteFile, } }