86 lines
2.7 KiB
TypeScript

import { S3Client, DeleteObjectCommand, ObjectCannedACL } from '@aws-sdk/client-s3'
import { Upload } from '@aws-sdk/lib-storage'
import type { S3Config } from '@config/index'
import { FileUploadCallback, FileDeleteCallback } from './types'
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,
}
}