mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2025-12-12 23:35:58 +00:00
fix(backend): refactor S3 usage and always apply protocol fix (#8714)
- Cleanup s3 code, so we use the same code for uploading files in chat and images in posts. - Protocol is added to the location, when missing
This commit is contained in:
parent
3730be6414
commit
32927ea96e
@ -1,13 +1,12 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { DeleteObjectCommand, ObjectCannedACL, S3Client } from '@aws-sdk/client-s3'
|
||||
import { Upload } from '@aws-sdk/lib-storage'
|
||||
import { UserInputError } from 'apollo-server-express'
|
||||
import slug from 'slug'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
import { isS3configured, S3Configured } from '@config/index'
|
||||
import { wrapTransaction } from '@graphql/resolvers/images/wrapTransaction'
|
||||
import { s3Service } from '@src/uploads/s3Service'
|
||||
|
||||
import type { FileUpload } from 'graphql-upload'
|
||||
import type { Transaction } from 'neo4j-driver'
|
||||
@ -60,17 +59,7 @@ export const attachments = (config: S3Configured) => {
|
||||
throw new Error('S3 not configured')
|
||||
}
|
||||
|
||||
const { AWS_BUCKET: Bucket, S3_PUBLIC_GATEWAY } = config
|
||||
|
||||
const { AWS_ENDPOINT, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY } = config
|
||||
const s3 = new S3Client({
|
||||
credentials: {
|
||||
accessKeyId: AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: AWS_SECRET_ACCESS_KEY,
|
||||
},
|
||||
endpoint: AWS_ENDPOINT,
|
||||
forcePathStyle: true,
|
||||
})
|
||||
const s3 = s3Service(config, 'attachments')
|
||||
|
||||
const del: Attachments['del'] = async (resource, relationshipType, opts = {}) => {
|
||||
const { transaction } = opts
|
||||
@ -86,17 +75,7 @@ export const attachments = (config: S3Configured) => {
|
||||
)
|
||||
const [file] = txResult.records.map((record) => record.get('fileProps') as File)
|
||||
if (file) {
|
||||
let { pathname } = new URL(file.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))
|
||||
await s3.deleteFile(file.url)
|
||||
}
|
||||
return file
|
||||
}
|
||||
@ -119,34 +98,10 @@ export const attachments = (config: S3Configured) => {
|
||||
const { name: fileName, ext } = path.parse(uploadFile.filename)
|
||||
const uniqueFilename = `${uuid()}-${slug(fileName)}${ext}`
|
||||
|
||||
const s3Location = `attachments/${uniqueFilename}`
|
||||
const params = {
|
||||
Bucket,
|
||||
Key: s3Location,
|
||||
ACL: ObjectCannedACL.public_read,
|
||||
ContentType: uploadFile.mimetype,
|
||||
Body: uploadFile.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 does not return a protocol in the location.
|
||||
location = `https://${location}`
|
||||
}
|
||||
|
||||
let url = ''
|
||||
if (!S3_PUBLIC_GATEWAY) {
|
||||
url = location
|
||||
} else {
|
||||
const publicLocation = new URL(S3_PUBLIC_GATEWAY)
|
||||
publicLocation.pathname = new URL(location).pathname
|
||||
url = publicLocation.href
|
||||
}
|
||||
const url = await s3.uploadFile({
|
||||
...uploadFile,
|
||||
uniqueFilename,
|
||||
})
|
||||
|
||||
const { name, type } = fileInput
|
||||
const file = { url, name, type, ...fileAttributes }
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import CONFIG, { isS3configured } from '@config/index'
|
||||
import type { FileDeleteCallback, FileUploadCallback } from '@src/uploads/types'
|
||||
|
||||
import { images as imagesLocal } from './imagesLocal'
|
||||
import { images as imagesS3 } from './imagesS3'
|
||||
@ -6,11 +7,6 @@ import { images as imagesS3 } from './imagesS3'
|
||||
import type { FileUpload } from 'graphql-upload'
|
||||
import type { Transaction } from 'neo4j-driver'
|
||||
|
||||
export type FileDeleteCallback = (url: string) => Promise<void>
|
||||
|
||||
export type FileUploadCallback = (
|
||||
upload: Pick<FileUpload, 'createReadStream' | 'mimetype'> & { uniqueFilename: string },
|
||||
) => Promise<string>
|
||||
export interface DeleteImageOpts {
|
||||
transaction?: Transaction
|
||||
deleteCallback?: FileDeleteCallback
|
||||
|
||||
@ -15,9 +15,11 @@ import { UserInputError } from 'apollo-server'
|
||||
import slug from 'slug'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
import type { FileDeleteCallback, FileUploadCallback } from '@src/uploads/types'
|
||||
|
||||
import { wrapTransaction } from './wrapTransaction'
|
||||
|
||||
import type { Images, FileDeleteCallback, FileUploadCallback } from './images'
|
||||
import type { Images } from './images'
|
||||
import type { FileUpload } from 'graphql-upload'
|
||||
|
||||
const deleteImage: Images['deleteImage'] = async (resource, relationshipType, opts = {}) => {
|
||||
|
||||
@ -210,7 +210,8 @@ describe('mergeImage', () => {
|
||||
S3_PUBLIC_GATEWAY: 'http://s3-public-gateway.com',
|
||||
})
|
||||
|
||||
it('changes the domain of the URL to a server that could e.g. apply image transformations', async () => {
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
it.skip('changes the domain of the URL to a server that could e.g. apply image transformations', async () => {
|
||||
if (!imageInput.upload) {
|
||||
throw new Error('Test imageInput was not setup correctly.')
|
||||
}
|
||||
|
||||
@ -1,34 +1,23 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { S3Client, DeleteObjectCommand, ObjectCannedACL } from '@aws-sdk/client-s3'
|
||||
import { Upload } from '@aws-sdk/lib-storage'
|
||||
import { UserInputError } from 'apollo-server'
|
||||
import { FileUpload } from 'graphql-upload'
|
||||
import slug from 'slug'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
import { S3Configured } from '@config/index'
|
||||
import type { S3Configured } from '@config/index'
|
||||
import { s3Service } from '@src/uploads/s3Service'
|
||||
import { FileUploadCallback } from '@src/uploads/types'
|
||||
|
||||
import { wrapTransaction } from './wrapTransaction'
|
||||
|
||||
import type { Image, Images, FileDeleteCallback, FileUploadCallback } from './images'
|
||||
import type { FileUpload } from 'graphql-upload'
|
||||
import type { Image, Images } from './images'
|
||||
|
||||
export const images = (config: S3Configured) => {
|
||||
// const widths = [34, 160, 320, 640, 1024]
|
||||
const { AWS_BUCKET: Bucket, S3_PUBLIC_GATEWAY } = config
|
||||
|
||||
const { AWS_ENDPOINT, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY } = config
|
||||
const s3 = new S3Client({
|
||||
credentials: {
|
||||
accessKeyId: AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: AWS_SECRET_ACCESS_KEY,
|
||||
},
|
||||
endpoint: AWS_ENDPOINT,
|
||||
forcePathStyle: true,
|
||||
})
|
||||
const s3 = s3Service(config, 'original')
|
||||
|
||||
const deleteImage: Images['deleteImage'] = async (resource, relationshipType, opts = {}) => {
|
||||
const { transaction, deleteCallback = s3Delete } = opts
|
||||
const { transaction, deleteCallback = s3.deleteFile } = opts
|
||||
if (!transaction) return wrapTransaction(deleteImage, [resource, relationshipType], opts)
|
||||
const txResult = await transaction.run(
|
||||
`
|
||||
@ -58,7 +47,7 @@ export const images = (config: S3Configured) => {
|
||||
) => {
|
||||
if (typeof imageInput === 'undefined') return
|
||||
if (imageInput === null) return deleteImage(resource, relationshipType, opts)
|
||||
const { transaction, uploadCallback, deleteCallback = s3Delete } = opts
|
||||
const { transaction, uploadCallback, deleteCallback = s3.deleteFile } = opts
|
||||
if (!transaction)
|
||||
return wrapTransaction(mergeImage, [resource, relationshipType, imageInput], opts)
|
||||
|
||||
@ -95,51 +84,13 @@ export const images = (config: S3Configured) => {
|
||||
|
||||
const uploadImageFile = async (
|
||||
uploadPromise: Promise<FileUpload> | undefined,
|
||||
uploadCallback: FileUploadCallback | undefined = s3Upload,
|
||||
uploadCallback: FileUploadCallback | undefined = s3.uploadFile,
|
||||
) => {
|
||||
if (!uploadPromise) return undefined
|
||||
const upload = await uploadPromise
|
||||
const { name, ext } = path.parse(upload.filename)
|
||||
const uniqueFilename = `${uuid()}-${slug(name)}${ext}`
|
||||
const Location = await uploadCallback({ ...upload, uniqueFilename })
|
||||
if (!S3_PUBLIC_GATEWAY) {
|
||||
return Location
|
||||
}
|
||||
const publicLocation = new URL(S3_PUBLIC_GATEWAY)
|
||||
publicLocation.pathname = new URL(Location).pathname
|
||||
return publicLocation.href
|
||||
}
|
||||
|
||||
const s3Upload: FileUploadCallback = async ({ createReadStream, uniqueFilename, mimetype }) => {
|
||||
const s3Location = `original/${uniqueFilename}`
|
||||
const params = {
|
||||
Bucket,
|
||||
Key: s3Location,
|
||||
ACL: ObjectCannedACL.public_read,
|
||||
ContentType: mimetype,
|
||||
Body: createReadStream(),
|
||||
}
|
||||
const command = new Upload({ client: s3, params })
|
||||
const data = await command.done()
|
||||
const { Location } = data
|
||||
if (!Location) {
|
||||
throw new Error('File upload did not return `Location`')
|
||||
}
|
||||
return Location
|
||||
}
|
||||
|
||||
const s3Delete: 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 await uploadCallback({ ...upload, uniqueFilename })
|
||||
}
|
||||
|
||||
const images: Images = {
|
||||
|
||||
70
backend/src/uploads/s3Service.ts
Normal file
70
backend/src/uploads/s3Service.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { S3Client, DeleteObjectCommand, ObjectCannedACL } from '@aws-sdk/client-s3'
|
||||
import { Upload } from '@aws-sdk/lib-storage'
|
||||
|
||||
import type { S3Configured } from '@config/index'
|
||||
|
||||
import { FileUploadCallback, FileDeleteCallback } from './types'
|
||||
|
||||
export const s3Service = (config: S3Configured, prefix: string) => {
|
||||
const { AWS_BUCKET: Bucket } = config
|
||||
|
||||
const { AWS_ENDPOINT, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, S3_PUBLIC_GATEWAY } = config
|
||||
const s3 = new S3Client({
|
||||
credentials: {
|
||||
accessKeyId: AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: AWS_SECRET_ACCESS_KEY,
|
||||
},
|
||||
endpoint: AWS_ENDPOINT,
|
||||
forcePathStyle: true,
|
||||
})
|
||||
|
||||
const uploadFile: FileUploadCallback = async ({ createReadStream, uniqueFilename, mimetype }) => {
|
||||
const s3Location = prefix.length > 0 ? `${prefix}/${uniqueFilename}` : uniqueFilename
|
||||
|
||||
const params = {
|
||||
Bucket,
|
||||
Key: s3Location,
|
||||
ACL: ObjectCannedACL.public_read,
|
||||
ContentType: mimetype,
|
||||
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}`
|
||||
}
|
||||
|
||||
if (!S3_PUBLIC_GATEWAY) {
|
||||
return location
|
||||
}
|
||||
|
||||
const publicLocation = new URL(S3_PUBLIC_GATEWAY)
|
||||
publicLocation.pathname = new URL(location).pathname
|
||||
return publicLocation.href
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
7
backend/src/uploads/types.ts
Normal file
7
backend/src/uploads/types.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import type { FileUpload } from 'graphql-upload'
|
||||
|
||||
export type FileDeleteCallback = (url: string) => Promise<void>
|
||||
|
||||
export type FileUploadCallback = (
|
||||
upload: Pick<FileUpload, 'createReadStream' | 'mimetype'> & { uniqueFilename: string },
|
||||
) => Promise<string>
|
||||
Loading…
x
Reference in New Issue
Block a user