mirror of
https://github.com/IT4Change/Ocelot-Social.git
synced 2025-12-12 23:35:52 +00:00
refactor(backend): remove obsolete code (#8752)
We kept this code for backwards compatibility but since we already deployed S3 to our kubernetes cluster and we're using it locally, let's remove this code. It will also make it easier to implement the image resize service as it reduces the total amount of code to maintain. Co-authored-by: Ulf Gebhardt <ulf.gebhardt@webcraft-media.de>
This commit is contained in:
parent
588e9bee8d
commit
3b2b3f0014
@ -32,27 +32,6 @@ const environment = {
|
||||
LOG_LEVEL: 'DEBUG',
|
||||
}
|
||||
|
||||
const required = {
|
||||
MAPBOX_TOKEN: env.MAPBOX_TOKEN,
|
||||
JWT_SECRET: env.JWT_SECRET,
|
||||
PRIVATE_KEY_PASSPHRASE: env.PRIVATE_KEY_PASSPHRASE,
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/53050575
|
||||
type NoUndefinedField<T> = { [P in keyof T]-?: NoUndefinedField<NonNullable<T[P]>> }
|
||||
|
||||
function assertRequiredConfig(
|
||||
conf: typeof required,
|
||||
): asserts conf is NoUndefinedField<typeof required> {
|
||||
Object.entries(conf).forEach(([key, value]) => {
|
||||
if (!value) {
|
||||
throw new Error(`ERROR: "${key}" env variable is missing.`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
assertRequiredConfig(required)
|
||||
|
||||
const server = {
|
||||
CLIENT_URI: env.CLIENT_URI ?? 'http://localhost:3000',
|
||||
GRAPHQL_URI: env.GRAPHQL_URI ?? 'http://localhost:4000',
|
||||
@ -112,33 +91,34 @@ const redis = {
|
||||
REDIS_PASSWORD: env.REDIS_PASSWORD,
|
||||
}
|
||||
|
||||
const s3 = {
|
||||
const required = {
|
||||
AWS_ACCESS_KEY_ID: env.AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY: env.AWS_SECRET_ACCESS_KEY,
|
||||
AWS_ENDPOINT: env.AWS_ENDPOINT,
|
||||
AWS_REGION: env.AWS_REGION,
|
||||
AWS_BUCKET: env.AWS_BUCKET,
|
||||
S3_PUBLIC_GATEWAY: env.S3_PUBLIC_GATEWAY,
|
||||
|
||||
MAPBOX_TOKEN: env.MAPBOX_TOKEN,
|
||||
JWT_SECRET: env.JWT_SECRET,
|
||||
PRIVATE_KEY_PASSPHRASE: env.PRIVATE_KEY_PASSPHRASE,
|
||||
}
|
||||
|
||||
export interface S3Configured {
|
||||
AWS_ACCESS_KEY_ID: string
|
||||
AWS_SECRET_ACCESS_KEY: string
|
||||
AWS_ENDPOINT: string
|
||||
AWS_REGION: string
|
||||
AWS_BUCKET: string
|
||||
S3_PUBLIC_GATEWAY: string | undefined
|
||||
const S3_PUBLIC_GATEWAY = env.S3_PUBLIC_GATEWAY
|
||||
|
||||
// https://stackoverflow.com/a/53050575
|
||||
type NoUndefinedField<T> = { [P in keyof T]-?: NoUndefinedField<NonNullable<T[P]>> }
|
||||
|
||||
function assertRequiredConfig(
|
||||
conf: typeof required,
|
||||
): asserts conf is NoUndefinedField<typeof required> {
|
||||
Object.entries(conf).forEach(([key, value]) => {
|
||||
if (!value) {
|
||||
throw new Error(`ERROR: "${key}" env variable is missing.`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const isS3configured = (config: typeof s3): config is S3Configured => {
|
||||
return !!(
|
||||
config.AWS_ACCESS_KEY_ID &&
|
||||
config.AWS_SECRET_ACCESS_KEY &&
|
||||
config.AWS_ENDPOINT &&
|
||||
config.AWS_REGION &&
|
||||
config.AWS_BUCKET
|
||||
)
|
||||
}
|
||||
assertRequiredConfig(required)
|
||||
|
||||
const options = {
|
||||
EMAIL_DEFAULT_SENDER: env.EMAIL_DEFAULT_SENDER,
|
||||
@ -169,12 +149,21 @@ const CONFIG = {
|
||||
...neo4j,
|
||||
...sentry,
|
||||
...redis,
|
||||
...s3,
|
||||
...options,
|
||||
...language,
|
||||
S3_PUBLIC_GATEWAY,
|
||||
}
|
||||
|
||||
export type Config = typeof CONFIG
|
||||
export type S3Config = Pick<
|
||||
Config,
|
||||
| 'AWS_ACCESS_KEY_ID'
|
||||
| 'AWS_SECRET_ACCESS_KEY'
|
||||
| 'AWS_ENDPOINT'
|
||||
| 'AWS_REGION'
|
||||
| 'AWS_BUCKET'
|
||||
| 'S3_PUBLIC_GATEWAY'
|
||||
>
|
||||
export default CONFIG
|
||||
|
||||
export { nodemailerTransportOptions }
|
||||
|
||||
@ -16,7 +16,7 @@ import { CreateMessage } from '@graphql/queries/CreateMessage'
|
||||
import { createRoomMutation } from '@graphql/queries/createRoomMutation'
|
||||
import type { ApolloTestSetup } from '@root/test/helpers'
|
||||
import { createApolloTestSetup } from '@root/test/helpers'
|
||||
import type { S3Configured } from '@src/config'
|
||||
import type { S3Config } from '@src/config'
|
||||
|
||||
import { attachments } from './attachments'
|
||||
|
||||
@ -37,7 +37,7 @@ const UploadMock = {
|
||||
|
||||
;(Upload as unknown as jest.Mock).mockImplementation(() => UploadMock)
|
||||
|
||||
const config: S3Configured = {
|
||||
const config: S3Config = {
|
||||
AWS_ACCESS_KEY_ID: 'AWS_ACCESS_KEY_ID',
|
||||
AWS_SECRET_ACCESS_KEY: 'AWS_SECRET_ACCESS_KEY',
|
||||
AWS_BUCKET: 'AWS_BUCKET',
|
||||
|
||||
@ -4,7 +4,7 @@ import { UserInputError } from 'apollo-server-express'
|
||||
import slug from 'slug'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
import { isS3configured, S3Configured } from '@config/index'
|
||||
import type { S3Config } from '@config/index'
|
||||
import { wrapTransaction } from '@graphql/resolvers/images/wrapTransaction'
|
||||
import { s3Service } from '@src/uploads/s3Service'
|
||||
|
||||
@ -54,11 +54,7 @@ export interface Attachments {
|
||||
) => Promise<any>
|
||||
}
|
||||
|
||||
export const attachments = (config: S3Configured) => {
|
||||
if (!isS3configured(config)) {
|
||||
throw new Error('S3 not configured')
|
||||
}
|
||||
|
||||
export const attachments = (config: S3Config) => {
|
||||
const s3 = s3Service(config, 'attachments')
|
||||
|
||||
const del: Attachments['del'] = async (resource, relationshipType, opts = {}) => {
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import { isS3configured } from '@config/index'
|
||||
import type { Context } from '@src/context'
|
||||
import type { FileDeleteCallback, FileUploadCallback } from '@src/uploads/types'
|
||||
|
||||
import { images as imagesLocal } from './imagesLocal'
|
||||
import { images as imagesS3 } from './imagesS3'
|
||||
|
||||
import type { FileUpload } from 'graphql-upload'
|
||||
@ -52,5 +50,4 @@ export interface Images {
|
||||
) => Promise<any>
|
||||
}
|
||||
|
||||
export const images = (config: Context['config']) =>
|
||||
isS3configured(config) ? imagesS3(config) : imagesLocal
|
||||
export const images = (config: Context['config']) => imagesS3(config)
|
||||
|
||||
@ -1,364 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/require-await */
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/restrict-template-expressions */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable promise/prefer-await-to-callbacks */
|
||||
import { UserInputError } from 'apollo-server'
|
||||
|
||||
import Factory, { cleanDatabase } from '@db/factories'
|
||||
import { getNeode, getDriver } from '@db/neo4j'
|
||||
|
||||
import { images } from './imagesLocal'
|
||||
|
||||
import type { ImageInput } from './images'
|
||||
import type { FileUpload } from 'graphql-upload'
|
||||
|
||||
const driver = getDriver()
|
||||
const neode = getNeode()
|
||||
const uuid = '[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}'
|
||||
let uploadCallback
|
||||
let deleteCallback
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanDatabase()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanDatabase()
|
||||
await driver.close()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
uploadCallback = jest.fn(({ uniqueFilename }) => `/uploads/${uniqueFilename}`)
|
||||
deleteCallback = jest.fn()
|
||||
})
|
||||
|
||||
// TODO: avoid database clean after each test in the future if possible for performance and flakyness reasons by filling the database step by step, see issue https://github.com/Ocelot-Social-Community/Ocelot-Social/issues/4543
|
||||
afterEach(async () => {
|
||||
await cleanDatabase()
|
||||
})
|
||||
|
||||
describe('deleteImage', () => {
|
||||
const { deleteImage } = images
|
||||
|
||||
describe('given a resource with an image', () => {
|
||||
let user: { id: string }
|
||||
beforeEach(async () => {
|
||||
const u = await Factory.build(
|
||||
'user',
|
||||
{},
|
||||
{
|
||||
avatar: Factory.build('image', {
|
||||
url: 'http://localhost/some/avatar/url/',
|
||||
alt: 'This is the avatar image of a user',
|
||||
}),
|
||||
},
|
||||
)
|
||||
user = await u.toJson()
|
||||
})
|
||||
|
||||
it('deletes `Image` node', async () => {
|
||||
await expect(neode.all('Image')).resolves.toHaveLength(1)
|
||||
await deleteImage(user, 'AVATAR_IMAGE', { deleteCallback })
|
||||
await expect(neode.all('Image')).resolves.toHaveLength(0)
|
||||
})
|
||||
|
||||
it('calls deleteCallback', async () => {
|
||||
const u = await Factory.build('user')
|
||||
user = await u.toJson()
|
||||
await deleteImage(user, 'AVATAR_IMAGE', { deleteCallback })
|
||||
expect(deleteCallback).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('given a transaction parameter', () => {
|
||||
it('executes cypher statements within the transaction', async () => {
|
||||
const session = driver.session()
|
||||
let someString: string
|
||||
try {
|
||||
someString = await session.writeTransaction(async (transaction) => {
|
||||
await deleteImage(user, 'AVATAR_IMAGE', {
|
||||
deleteCallback,
|
||||
transaction,
|
||||
})
|
||||
const txResult = await transaction.run('RETURN "Hello" as result')
|
||||
const [result] = txResult.records.map((record) => record.get('result'))
|
||||
return result
|
||||
})
|
||||
} finally {
|
||||
await session.close()
|
||||
}
|
||||
await expect(neode.all('Image')).resolves.toHaveLength(0)
|
||||
expect(someString).toEqual('Hello')
|
||||
})
|
||||
|
||||
it('rolls back the transaction in case of errors', async () => {
|
||||
await expect(neode.all('Image')).resolves.toHaveLength(1)
|
||||
const session = driver.session()
|
||||
try {
|
||||
await session.writeTransaction(async (transaction) => {
|
||||
await deleteImage(user, 'AVATAR_IMAGE', {
|
||||
deleteCallback,
|
||||
transaction,
|
||||
})
|
||||
throw new Error('Ouch!')
|
||||
})
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all
|
||||
} catch (err) {
|
||||
// nothing has been deleted
|
||||
await expect(neode.all('Image')).resolves.toHaveLength(1)
|
||||
// all good
|
||||
} finally {
|
||||
await session.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeImage', () => {
|
||||
const { mergeImage } = images
|
||||
let imageInput: ImageInput
|
||||
let post: { id: string }
|
||||
beforeEach(() => {
|
||||
imageInput = {
|
||||
alt: 'A description of the new image',
|
||||
}
|
||||
})
|
||||
|
||||
describe('given image.upload', () => {
|
||||
beforeEach(() => {
|
||||
const createReadStream: FileUpload['createReadStream'] = (() => ({
|
||||
pipe: () => ({
|
||||
on: (_, callback) => callback(),
|
||||
}),
|
||||
})) as unknown as FileUpload['createReadStream']
|
||||
imageInput = {
|
||||
...imageInput,
|
||||
upload: Promise.resolve({
|
||||
filename: 'image.jpg',
|
||||
mimetype: 'image/jpeg',
|
||||
encoding: '7bit',
|
||||
createReadStream,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('on existing resource', () => {
|
||||
beforeEach(async () => {
|
||||
const p = await Factory.build(
|
||||
'post',
|
||||
{ id: 'p99' },
|
||||
{
|
||||
author: Factory.build('user', {}, { avatar: null }),
|
||||
image: null,
|
||||
},
|
||||
)
|
||||
post = await p.toJson()
|
||||
})
|
||||
|
||||
it('returns new image', async () => {
|
||||
await expect(
|
||||
mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback }),
|
||||
).resolves.toMatchObject({
|
||||
url: expect.any(String),
|
||||
alt: 'A description of the new image',
|
||||
})
|
||||
})
|
||||
|
||||
it('calls upload callback', async () => {
|
||||
await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback })
|
||||
expect(uploadCallback).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates `:Image` node', async () => {
|
||||
await expect(neode.all('Image')).resolves.toHaveLength(0)
|
||||
await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback })
|
||||
await expect(neode.all('Image')).resolves.toHaveLength(1)
|
||||
})
|
||||
|
||||
it('creates a url safe name', async () => {
|
||||
if (!imageInput.upload) {
|
||||
throw new Error('Test imageInput was not setup correctly.')
|
||||
}
|
||||
const upload = await imageInput.upload
|
||||
upload.filename = '/path/to/awkward?/ file-location/?foo- bar-avatar.jpg'
|
||||
imageInput.upload = Promise.resolve(upload)
|
||||
await expect(
|
||||
mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback }),
|
||||
).resolves.toMatchObject({
|
||||
url: expect.stringMatching(new RegExp(`^/uploads/${uuid}-foo-bar-avatar.jpg`)),
|
||||
})
|
||||
})
|
||||
|
||||
it('connects resource with image via given image type', async () => {
|
||||
await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback })
|
||||
const result = await neode.cypher(
|
||||
`MATCH(p:Post {id: "p99"})-[:HERO_IMAGE]->(i:Image) RETURN i,p`,
|
||||
{},
|
||||
)
|
||||
post = neode.hydrateFirst<{ id: string }>(result, 'p', neode.model('Post')).properties()
|
||||
const image = neode.hydrateFirst(result, 'i', neode.model('Image'))
|
||||
expect(post).toBeTruthy()
|
||||
expect(image).toBeTruthy()
|
||||
})
|
||||
|
||||
it('sets metadata', async () => {
|
||||
await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback })
|
||||
const image = await neode.first<typeof Image>('Image', {}, undefined)
|
||||
await expect(image.toJson()).resolves.toMatchObject({
|
||||
alt: 'A description of the new image',
|
||||
createdAt: expect.any(String),
|
||||
url: expect.any(String),
|
||||
})
|
||||
})
|
||||
|
||||
describe('given a transaction parameter', () => {
|
||||
it('executes cypher statements within the transaction', async () => {
|
||||
const session = driver.session()
|
||||
try {
|
||||
await session.writeTransaction(async (transaction) => {
|
||||
const image = await mergeImage(post, 'HERO_IMAGE', imageInput, {
|
||||
uploadCallback,
|
||||
deleteCallback,
|
||||
transaction,
|
||||
})
|
||||
return transaction.run(
|
||||
`
|
||||
MATCH(image:Image {url: $image.url})
|
||||
SET image.alt = 'This alt text gets overwritten'
|
||||
RETURN image {.*}
|
||||
`,
|
||||
{ image },
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
await session.close()
|
||||
}
|
||||
const image = await neode.first<typeof Image>(
|
||||
'Image',
|
||||
{ alt: 'This alt text gets overwritten' },
|
||||
undefined,
|
||||
)
|
||||
await expect(image.toJson()).resolves.toMatchObject({
|
||||
alt: 'This alt text gets overwritten',
|
||||
})
|
||||
})
|
||||
|
||||
it('rolls back the transaction in case of errors', async () => {
|
||||
const session = driver.session()
|
||||
try {
|
||||
await session.writeTransaction(async (transaction) => {
|
||||
const image = await mergeImage(post, 'HERO_IMAGE', imageInput, {
|
||||
uploadCallback,
|
||||
deleteCallback,
|
||||
transaction,
|
||||
})
|
||||
return transaction.run('Ooops invalid cypher!', { image })
|
||||
})
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all
|
||||
} catch (err) {
|
||||
// nothing has been created
|
||||
await expect(neode.all('Image')).resolves.toHaveLength(0)
|
||||
// all good
|
||||
} finally {
|
||||
await session.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('if resource has an image already', () => {
|
||||
beforeEach(async () => {
|
||||
const [post, image] = await Promise.all([
|
||||
neode.find('Post', 'p99'),
|
||||
Factory.build('image'),
|
||||
])
|
||||
await post.relateTo(image, 'image')
|
||||
})
|
||||
|
||||
it('calls deleteCallback', async () => {
|
||||
await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback })
|
||||
expect(deleteCallback).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls uploadCallback', async () => {
|
||||
await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback })
|
||||
expect(uploadCallback).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates metadata of existing image node', async () => {
|
||||
await expect(neode.all('Image')).resolves.toHaveLength(1)
|
||||
await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback })
|
||||
await expect(neode.all('Image')).resolves.toHaveLength(1)
|
||||
const image = await neode.first<typeof Image>('Image', {}, undefined)
|
||||
await expect(image.toJson()).resolves.toMatchObject({
|
||||
alt: 'A description of the new image',
|
||||
createdAt: expect.any(String),
|
||||
url: expect.any(String),
|
||||
// TODO
|
||||
// width:
|
||||
// height:
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('without image.upload', () => {
|
||||
it('throws UserInputError', async () => {
|
||||
const p = await Factory.build('post', { id: 'p99' }, { image: null })
|
||||
post = await p.toJson()
|
||||
await expect(mergeImage(post, 'HERO_IMAGE', imageInput)).rejects.toEqual(
|
||||
new UserInputError('Cannot find image for given resource'),
|
||||
)
|
||||
})
|
||||
|
||||
describe('if resource has an image already', () => {
|
||||
beforeEach(async () => {
|
||||
const p = await Factory.build(
|
||||
'post',
|
||||
{
|
||||
id: 'p99',
|
||||
},
|
||||
{
|
||||
author: Factory.build(
|
||||
'user',
|
||||
{},
|
||||
{
|
||||
avatar: null,
|
||||
},
|
||||
),
|
||||
image: Factory.build('image', {
|
||||
alt: 'This is the previous, not updated image',
|
||||
url: 'http://localhost/some/original/url',
|
||||
}),
|
||||
},
|
||||
)
|
||||
post = await p.toJson()
|
||||
})
|
||||
|
||||
it('does not call deleteCallback', async () => {
|
||||
await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback })
|
||||
expect(deleteCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call uploadCallback', async () => {
|
||||
await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback })
|
||||
expect(uploadCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates metadata', async () => {
|
||||
await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback })
|
||||
const images = await neode.all('Image')
|
||||
expect(images).toHaveLength(1)
|
||||
await expect(images.first().toJson()).resolves.toMatchObject({
|
||||
createdAt: expect.any(String),
|
||||
url: expect.any(String),
|
||||
alt: 'A description of the new image',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,133 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/require-await */
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
/* eslint-disable promise/avoid-new */
|
||||
/* eslint-disable security/detect-non-literal-fs-filename */
|
||||
|
||||
import { existsSync, unlinkSync, createWriteStream } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
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 } from './images'
|
||||
import type { FileUpload } from 'graphql-upload'
|
||||
|
||||
const deleteImage: Images['deleteImage'] = async (resource, relationshipType, opts = {}) => {
|
||||
const { transaction, deleteCallback } = opts
|
||||
if (!transaction) return wrapTransaction(deleteImage, [resource, relationshipType], opts)
|
||||
const txResult = await transaction.run(
|
||||
`
|
||||
MATCH (resource {id: $resource.id})-[rel:${relationshipType}]->(image:Image)
|
||||
WITH image, image {.*} as imageProps
|
||||
DETACH DELETE image
|
||||
RETURN imageProps
|
||||
`,
|
||||
{ resource },
|
||||
)
|
||||
const [image] = txResult.records.map((record) => record.get('imageProps'))
|
||||
// This behaviour differs from `mergeImage`. If you call `mergeImage`
|
||||
// with metadata for an image that does not exist, it's an indicator
|
||||
// of an error (so throw an error). If we bulk delete an image, it
|
||||
// could very well be that there is no image for the resource.
|
||||
if (image) deleteImageFile(image, deleteCallback)
|
||||
return image
|
||||
}
|
||||
|
||||
const mergeImage: Images['mergeImage'] = async (
|
||||
resource,
|
||||
relationshipType,
|
||||
imageInput,
|
||||
opts = {},
|
||||
) => {
|
||||
if (typeof imageInput === 'undefined') return
|
||||
if (imageInput === null) return deleteImage(resource, relationshipType, opts)
|
||||
const { transaction, uploadCallback, deleteCallback } = opts
|
||||
if (!transaction)
|
||||
return wrapTransaction(mergeImage, [resource, relationshipType, imageInput], opts)
|
||||
|
||||
let txResult
|
||||
txResult = await transaction.run(
|
||||
`
|
||||
MATCH (resource {id: $resource.id})-[:${relationshipType}]->(image:Image)
|
||||
RETURN image {.*}
|
||||
`,
|
||||
{ resource },
|
||||
)
|
||||
const [existingImage] = txResult.records.map((record) => record.get('image'))
|
||||
const { upload } = imageInput
|
||||
if (!(existingImage || upload)) throw new UserInputError('Cannot find image for given resource')
|
||||
if (existingImage && upload) deleteImageFile(existingImage, deleteCallback)
|
||||
const url = await uploadImageFile(upload, uploadCallback)
|
||||
const { alt, sensitive, aspectRatio, type } = imageInput
|
||||
const image = { alt, sensitive, aspectRatio, url, type }
|
||||
txResult = await transaction.run(
|
||||
`
|
||||
MATCH (resource {id: $resource.id})
|
||||
MERGE (resource)-[:${relationshipType}]->(image:Image)
|
||||
ON CREATE SET image.createdAt = toString(datetime())
|
||||
ON MATCH SET image.updatedAt = toString(datetime())
|
||||
SET image += $image
|
||||
RETURN image {.*}
|
||||
`,
|
||||
{ resource, image },
|
||||
)
|
||||
const [mergedImage] = txResult.records.map((record) => record.get('image'))
|
||||
return mergedImage
|
||||
}
|
||||
|
||||
const localFileDelete: FileDeleteCallback = async (url) => {
|
||||
const location = `public${url}`
|
||||
// eslint-disable-next-line n/no-sync
|
||||
if (existsSync(location)) unlinkSync(location)
|
||||
}
|
||||
|
||||
const deleteImageFile = (image, deleteCallback: FileDeleteCallback | undefined) => {
|
||||
if (!deleteCallback) {
|
||||
deleteCallback = localFileDelete
|
||||
}
|
||||
const { url } = image
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
deleteCallback(url)
|
||||
return url
|
||||
}
|
||||
|
||||
const uploadImageFile = async (
|
||||
upload: Promise<FileUpload> | undefined,
|
||||
uploadCallback: FileUploadCallback | undefined,
|
||||
) => {
|
||||
if (!upload) return undefined
|
||||
if (!uploadCallback) {
|
||||
uploadCallback = localFileUpload
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const { createReadStream, filename, mimetype } = await upload
|
||||
const { name, ext } = path.parse(filename)
|
||||
const uniqueFilename = `${uuid()}-${slug(name)}${ext}`
|
||||
return uploadCallback({ createReadStream, uniqueFilename, mimetype })
|
||||
}
|
||||
|
||||
const localFileUpload: FileUploadCallback = ({ createReadStream, uniqueFilename }) => {
|
||||
const destination = `/uploads/${uniqueFilename}`
|
||||
return new Promise((resolve, reject) =>
|
||||
createReadStream().pipe(
|
||||
createWriteStream(`public${destination}`)
|
||||
.on('finish', () => resolve(destination))
|
||||
.on('error', (error) => reject(error)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export const images: Images = {
|
||||
deleteImage,
|
||||
mergeImage,
|
||||
}
|
||||
@ -9,7 +9,7 @@ import { UserInputError } from 'apollo-server'
|
||||
|
||||
import Factory, { cleanDatabase } from '@db/factories'
|
||||
import { getNeode, getDriver } from '@db/neo4j'
|
||||
import type { S3Configured } from '@src/config'
|
||||
import type { S3Config } from '@src/config'
|
||||
|
||||
import { images } from './imagesS3'
|
||||
|
||||
@ -41,7 +41,7 @@ const driver = getDriver()
|
||||
const neode = getNeode()
|
||||
const uuid = '[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}'
|
||||
|
||||
const config: S3Configured = {
|
||||
const config: S3Config = {
|
||||
AWS_ACCESS_KEY_ID: 'AWS_ACCESS_KEY_ID',
|
||||
AWS_SECRET_ACCESS_KEY: 'AWS_SECRET_ACCESS_KEY',
|
||||
AWS_BUCKET: 'AWS_BUCKET',
|
||||
|
||||
@ -5,14 +5,14 @@ import { FileUpload } from 'graphql-upload'
|
||||
import slug from 'slug'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
import type { S3Configured } from '@config/index'
|
||||
import type { S3Config } from '@config/index'
|
||||
import { s3Service } from '@src/uploads/s3Service'
|
||||
|
||||
import { wrapTransaction } from './wrapTransaction'
|
||||
|
||||
import type { Image, Images } from './images'
|
||||
|
||||
export const images = (config: S3Configured) => {
|
||||
export const images = (config: S3Config) => {
|
||||
const s3 = s3Service(config, 'original')
|
||||
|
||||
const deleteImage: Images['deleteImage'] = async (resource, relationshipType, opts = {}) => {
|
||||
@ -89,9 +89,9 @@ export const images = (config: S3Configured) => {
|
||||
return await s3.uploadFile({ ...upload, uniqueFilename })
|
||||
}
|
||||
|
||||
const images: Images = {
|
||||
const images = {
|
||||
deleteImage,
|
||||
mergeImage,
|
||||
}
|
||||
} satisfies Images
|
||||
return images
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
import { withFilter } from 'graphql-subscriptions'
|
||||
import { neo4jgraphql } from 'neo4j-graphql-js'
|
||||
|
||||
import CONFIG, { isS3configured } from '@config/index'
|
||||
import CONFIG from '@config/index'
|
||||
import { CHAT_MESSAGE_ADDED } from '@constants/subscriptions'
|
||||
|
||||
import { attachments } from './attachments/attachments'
|
||||
@ -125,19 +125,17 @@ export default {
|
||||
|
||||
const atns: File[] = []
|
||||
|
||||
if (isS3configured(CONFIG)) {
|
||||
for await (const file of files) {
|
||||
const atn = await attachments(CONFIG).add(
|
||||
message,
|
||||
'ATTACHMENT',
|
||||
file,
|
||||
{},
|
||||
{
|
||||
transaction,
|
||||
},
|
||||
)
|
||||
atns.push(atn)
|
||||
}
|
||||
for await (const file of files) {
|
||||
const atn = await attachments(CONFIG).add(
|
||||
message,
|
||||
'ATTACHMENT',
|
||||
file,
|
||||
{},
|
||||
{
|
||||
transaction,
|
||||
},
|
||||
)
|
||||
atns.push(atn)
|
||||
}
|
||||
|
||||
return { ...message, files: atns }
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { S3Client, DeleteObjectCommand, ObjectCannedACL } from '@aws-sdk/client-s3'
|
||||
import { Upload } from '@aws-sdk/lib-storage'
|
||||
|
||||
import type { S3Configured } from '@config/index'
|
||||
import type { S3Config } from '@config/index'
|
||||
|
||||
import { FileUploadCallback, FileDeleteCallback } from './types'
|
||||
|
||||
export const s3Service = (config: S3Configured, prefix: string) => {
|
||||
export const s3Service = (config: S3Config, prefix: string) => {
|
||||
const { AWS_BUCKET: Bucket } = config
|
||||
|
||||
const { AWS_ENDPOINT, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, S3_PUBLIC_GATEWAY } = config
|
||||
|
||||
@ -37,11 +37,12 @@ export const TEST_CONFIG = {
|
||||
REDIS_PORT: undefined,
|
||||
REDIS_PASSWORD: undefined,
|
||||
|
||||
AWS_ACCESS_KEY_ID: '',
|
||||
AWS_SECRET_ACCESS_KEY: '',
|
||||
AWS_ENDPOINT: '',
|
||||
AWS_REGION: '',
|
||||
AWS_BUCKET: '',
|
||||
AWS_ACCESS_KEY_ID: 'minio',
|
||||
AWS_SECRET_ACCESS_KEY: '12341234',
|
||||
AWS_ENDPOINT: 'http:/minio:9000',
|
||||
AWS_REGION: 'local',
|
||||
AWS_BUCKET: 'ocelot',
|
||||
|
||||
S3_PUBLIC_GATEWAY: undefined,
|
||||
|
||||
EMAIL_DEFAULT_SENDER: '',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user