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:
Robert Schäfer 2025-07-14 14:28:07 +07:00 committed by GitHub
parent 588e9bee8d
commit 3b2b3f0014
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 60 additions and 576 deletions

View File

@ -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 }

View File

@ -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',

View File

@ -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 = {}) => {

View File

@ -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)

View File

@ -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',
})
})
})
})
})

View File

@ -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,
}

View File

@ -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',

View File

@ -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
}

View File

@ -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 }

View File

@ -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

View File

@ -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: '',