feat(backend): file upload chat backend (#8657)

* Prepare image uploads in chat

* files instead of images

* Fix file type and query

* Add dummy data to resolver

* fix graphql types

* Fix file upload, remove unncessary code

* Re-add fetch

* Fix room order after sent message

* Update backend/src/graphql/queries/messageQuery.ts

* Move room to top of list when a message is received

* working prototype chat file upload

* remove console

* allow to upload all kinds of files

* multiple images

* revert changes in S3 Images

* tag mimetype

* accept any file

* lint fix

* remove snapshot flakyness

* remove whitelist test

* fix messages spec

* fix query

* more query fixes

* fix seed

* made message resolver tests independent

* lint

* started specc for attachments

* more tests & fixes

* fix empty room error

* remove console logs

* fix tests

* fix createRoom last Messsage error properly

* lint fixes

* reduce changeset

* simplify config check

* reduce changeset

* missing change

* allow speech capture

* Fix file download

* Implement proper download

---------

Co-authored-by: Maximilian Harz <maxharz@gmail.com>
This commit is contained in:
Ulf Gebhardt 2025-06-13 21:02:37 +02:00 committed by GitHub
parent e6d3e5132e
commit a0c205b379
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 965 additions and 159 deletions

View File

@ -80,6 +80,18 @@ Factory.define('image')
return neode.create('Image', buildObject)
})
Factory.define('file')
.attr('name', faker.lorem.slug)
.attr('type', 'image/jpeg')
.attr('url', null)
.after((buildObject, _options) => {
if (!buildObject.url) {
buildObject.url = faker.image.urlPicsumPhotos()
}
buildObject.url = uniqueImageUrl(buildObject.url)
return neode.create('File', buildObject)
})
Factory.define('basicUser')
.option('password', '1234')
.attrs({

View File

@ -0,0 +1,7 @@
export default {
url: { primary: true, type: 'string', uri: { allowRelative: true } },
name: { type: 'string' },
type: { type: 'string' },
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
updatedAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
}

View File

@ -5,4 +5,5 @@ export default {
aspectRatio: { type: 'float', default: 1.0 },
type: { type: 'string' },
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
updatedAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
}

View File

@ -8,6 +8,7 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
declare let Cypress: any | undefined
export default {
File: typeof Cypress !== 'undefined' ? require('./File') : require('./File').default,
Image: typeof Cypress !== 'undefined' ? require('./Image') : require('./Image').default,
Badge: typeof Cypress !== 'undefined' ? require('./Badge') : require('./Badge').default,
User: typeof Cypress !== 'undefined' ? require('./User') : require('./User').default,

View File

@ -12,7 +12,7 @@ import { categories } from '@constants/categories'
import { changeGroupMemberRoleMutation } from '@graphql/queries/changeGroupMemberRoleMutation'
import { createCommentMutation } from '@graphql/queries/createCommentMutation'
import { createGroupMutation } from '@graphql/queries/createGroupMutation'
import { createMessageMutation } from '@graphql/queries/createMessageMutation'
import { CreateMessage } from '@graphql/queries/CreateMessage'
import { createPostMutation } from '@graphql/queries/createPostMutation'
import { createRoomMutation } from '@graphql/queries/createRoomMutation'
import { joinGroupMutation } from '@graphql/queries/joinGroupMutation'
@ -1542,7 +1542,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
for (let i = 0; i < 30; i++) {
authenticatedUser = await huey.toJson()
await mutate({
mutation: createMessageMutation(),
mutation: CreateMessage,
variables: {
roomId: roomHueyPeter?.CreateRoom.id,
content: faker.lorem.sentence(),
@ -1550,7 +1550,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
})
authenticatedUser = await peterLustig.toJson()
await mutate({
mutation: createMessageMutation(),
mutation: CreateMessage,
variables: {
roomId: roomHueyPeter?.CreateRoom.id,
content: faker.lorem.sentence(),
@ -1568,7 +1568,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
for (let i = 0; i < 1000; i++) {
authenticatedUser = await huey.toJson()
await mutate({
mutation: createMessageMutation(),
mutation: CreateMessage,
variables: {
roomId: roomHueyJenny?.CreateRoom.id,
content: faker.lorem.sentence(),
@ -1576,7 +1576,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
})
authenticatedUser = await jennyRostock.toJson()
await mutate({
mutation: createMessageMutation(),
mutation: CreateMessage,
variables: {
roomId: roomHueyJenny?.CreateRoom.id,
content: faker.lorem.sentence(),
@ -1596,7 +1596,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
for (let i = 0; i < 29; i++) {
authenticatedUser = await jennyRostock.toJson()
await mutate({
mutation: createMessageMutation(),
mutation: CreateMessage,
variables: {
roomId: room?.CreateRoom.id,
content: faker.lorem.sentence(),
@ -1604,7 +1604,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
})
authenticatedUser = await user.toJson()
await mutate({
mutation: createMessageMutation(),
mutation: CreateMessage,
variables: {
roomId: room?.CreateRoom.id,
content: faker.lorem.sentence(),

View File

@ -1,3 +1,8 @@
import CONFIG from '@config/index'
CONFIG.SUPPORT_EMAIL = 'devops@ocelot.social'
// eslint-disable-next-line import/first
import { sendChatMessageMail } from './sendEmail'
const senderUser = {

View File

@ -1,3 +1,8 @@
import CONFIG from '@config/index'
CONFIG.SUPPORT_EMAIL = 'devops@ocelot.social'
// eslint-disable-next-line import/first
import { sendEmailVerification } from './sendEmail'
describe('sendEmailVerification', () => {

View File

@ -1,3 +1,8 @@
import CONFIG from '@config/index'
CONFIG.SUPPORT_EMAIL = 'devops@ocelot.social'
// eslint-disable-next-line import/first
import { sendNotificationMail } from './sendEmail'
describe('sendNotificationMail', () => {

View File

@ -1,3 +1,8 @@
import CONFIG from '@config/index'
CONFIG.SUPPORT_EMAIL = 'devops@ocelot.social'
// eslint-disable-next-line import/first
import { sendRegistrationMail } from './sendEmail'
describe('sendRegistrationMail', () => {

View File

@ -1,3 +1,8 @@
import CONFIG from '@config/index'
CONFIG.SUPPORT_EMAIL = 'devops@ocelot.social'
// eslint-disable-next-line import/first
import { sendResetPasswordMail } from './sendEmail'
describe('sendResetPasswordMail', () => {

View File

@ -1,3 +1,8 @@
import CONFIG from '@config/index'
CONFIG.SUPPORT_EMAIL = 'devops@ocelot.social'
// eslint-disable-next-line import/first
import { sendWrongEmail } from './sendEmail'
describe('sendWrongEmail', () => {

View File

@ -0,0 +1,22 @@
import gql from 'graphql-tag'
export const CreateMessage = gql`
mutation ($roomId: ID!, $content: String!, $files: [FileInput]) {
CreateMessage(roomId: $roomId, content: $content, files: $files) {
id
content
senderId
username
avatar
date
saved
distributed
seen
files {
url
name
type
}
}
}
`

View File

@ -0,0 +1,7 @@
import gql from 'graphql-tag'
export const MarkMessagesAsSeen = gql`
mutation ($messageIds: [String!]) {
MarkMessagesAsSeen(messageIds: $messageIds)
}
`

View File

@ -0,0 +1,27 @@
import gql from 'graphql-tag'
export const Message = gql`
query ($roomId: ID!, $first: Int, $offset: Int) {
Message(roomId: $roomId, first: $first, offset: $offset, orderBy: indexId_desc) {
_id
id
indexId
content
senderId
author {
id
}
username
avatar
date
saved
distributed
seen
files {
url
name
type
}
}
}
`

View File

@ -1,19 +0,0 @@
import gql from 'graphql-tag'
export const createMessageMutation = () => {
return gql`
mutation ($roomId: ID!, $content: String!) {
CreateMessage(roomId: $roomId, content: $content) {
id
content
senderId
username
avatar
date
saved
distributed
seen
}
}
`
}

View File

@ -1,9 +0,0 @@
import gql from 'graphql-tag'
export const markMessagesAsSeen = () => {
return gql`
mutation ($messageIds: [String!]) {
MarkMessagesAsSeen(messageIds: $messageIds)
}
`
}

View File

@ -1,24 +0,0 @@
import gql from 'graphql-tag'
export const messageQuery = () => {
return gql`
query ($roomId: ID!, $first: Int, $offset: Int) {
Message(roomId: $roomId, first: $first, offset: $offset, orderBy: indexId_desc) {
_id
id
indexId
content
senderId
author {
id
}
username
avatar
date
saved
distributed
seen
}
}
`
}

View File

@ -0,0 +1,350 @@
/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { ReadStream } from 'node:fs'
import { Readable } from 'node:stream'
import { S3Client } from '@aws-sdk/client-s3'
import { Upload } from '@aws-sdk/lib-storage'
import { UserInputError } from 'apollo-server'
import { createTestClient } from 'apollo-server-testing'
import databaseContext from '@context/database'
import Factory, { cleanDatabase } from '@db/factories'
import File from '@db/models/File'
import { CreateMessage } from '@graphql/queries/CreateMessage'
import { createRoomMutation } from '@graphql/queries/createRoomMutation'
import type { S3Configured } from '@src/config'
import createServer, { getContext } from '@src/server'
import { attachments } from './attachments'
import type { FileInput } from './attachments'
const s3SendMock = jest.fn()
jest.spyOn(S3Client.prototype, 'send').mockImplementation(s3SendMock)
jest.mock('@aws-sdk/lib-storage')
const UploadMock = {
done: () => {
return {
Location: 'http://your-objectstorage.com/bucket/',
}
},
}
;(Upload as unknown as jest.Mock).mockImplementation(() => UploadMock)
const config: S3Configured = {
AWS_ACCESS_KEY_ID: 'AWS_ACCESS_KEY_ID',
AWS_SECRET_ACCESS_KEY: 'AWS_SECRET_ACCESS_KEY',
AWS_BUCKET: 'AWS_BUCKET',
AWS_ENDPOINT: 'AWS_ENDPOINT',
AWS_REGION: 'AWS_REGION',
S3_PUBLIC_GATEWAY: undefined,
}
const database = databaseContext()
let authenticatedUser, server, mutate
beforeAll(async () => {
await cleanDatabase()
const contextUser = async (_req) => authenticatedUser
const context = getContext({ user: contextUser, database })
server = createServer({ context }).server
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
mutate = createTestClient(server).mutate
})
afterAll(async () => {
void server.stop()
void database.driver.close()
database.neode.close()
})
/* uploadCallback = jest.fn(
({ uniqueFilename }) => `http://your-objectstorage.com/bucket/${uniqueFilename}`,
)
*/
afterEach(async () => {
await cleanDatabase()
})
describe('delete Attachment', () => {
const { del: deleteAttachment } = attachments(config)
describe('given a resource with an attachment', () => {
let user: { id: string }
let chatPartner: { id: string }
let file: { id: string }
let message: { id: string }
beforeEach(async () => {
const u = await Factory.build('user')
user = await u.toJson()
const u2 = await Factory.build('user')
chatPartner = await u2.toJson()
authenticatedUser = user
const { data: room } = await mutate({
mutation: createRoomMutation(),
variables: {
userId: chatPartner.id,
},
})
const f = await Factory.build('file', {
url: 'http://localhost/some/file/url/',
name: 'This is a file attached to a message',
type: 'application/dummy',
})
file = await f.toJson()
const m = await mutate({
mutation: CreateMessage,
variables: {
roomId: room?.CreateRoom.id,
content: 'test messsage',
},
})
message = m.data.CreateMessage
await database.write({
query: `
MATCH (message:Message {id: $message.id}), (file:File{url: $file.url})
MERGE (message)-[:ATTACHMENT]->(file)
`,
variables: {
message,
file,
},
})
})
it('deletes `File` node', async () => {
await expect(database.neode.all('File')).resolves.toHaveLength(1)
await deleteAttachment(message, 'ATTACHMENT')
await expect(database.neode.all('File')).resolves.toHaveLength(0)
})
describe('given a transaction parameter', () => {
it('executes cypher statements within the transaction', async () => {
const session = database.driver.session()
let someString: string
try {
someString = await session.writeTransaction(async (transaction) => {
await deleteAttachment(message, 'ATTACHMENT', {
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(database.neode.all('File')).resolves.toHaveLength(0)
expect(someString).toEqual('Hello')
})
it('rolls back the transaction in case of errors', async () => {
await expect(database.neode.all('File')).resolves.toHaveLength(1)
const session = database.driver.session()
try {
await session.writeTransaction(async (transaction) => {
await deleteAttachment(message, 'ATTACHMENT', {
transaction,
})
throw new Error('Ouch!')
})
// eslint-disable-next-line no-catch-all/no-catch-all
} catch (err) {
// nothing has been deleted
await expect(database.neode.all('File')).resolves.toHaveLength(1)
// all good
} finally {
await session.close()
}
})
})
})
})
describe('add Attachment', () => {
const { add: addAttachment } = attachments(config)
let fileInput: FileInput
let post: { id: string }
beforeEach(() => {
fileInput = {
name: 'The name of the new attachment',
type: 'application/any',
}
})
describe('given file.upload', () => {
beforeEach(() => {
const file1 = Readable.from('file1')
fileInput = {
...fileInput,
// eslint-disable-next-line promise/avoid-new
upload: new Promise((resolve) =>
resolve({
createReadStream: () => file1 as ReadStream,
filename: 'file1',
encoding: '7bit',
mimetype: 'application/json',
}),
),
}
})
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 file', async () => {
await expect(addAttachment(post, 'ATTACHMENT', fileInput)).resolves.toMatchObject({
updatedAt: expect.any(String),
createdAt: expect.any(String),
name: 'The name of the new attachment',
type: 'application/any',
url: 'http://your-objectstorage.com/bucket/',
})
})
it('creates `:File` node', async () => {
await expect(database.neode.all('File')).resolves.toHaveLength(0)
await addAttachment(post, 'ATTACHMENT', fileInput)
await expect(database.neode.all('File')).resolves.toHaveLength(1)
})
describe('given a `S3_PUBLIC_GATEWAY` configuration', () => {
const { add: addAttachment } = attachments({
...config,
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 () => {
if (!fileInput.upload) {
throw new Error('Test imageInput was not setup correctly.')
}
const upload = await fileInput.upload
upload.filename = '/path/to/file-location/foo-bar-avatar.jpg'
fileInput.upload = Promise.resolve(upload)
await expect(addAttachment(post, 'ATTACHMENT', fileInput)).resolves.toMatchObject({
url: 'http://s3-public-gateway.com/bucket/',
})
})
})
it('connects resource with image via given image type', async () => {
await addAttachment(post, 'ATTACHMENT', fileInput)
const result = await database.neode.cypher(
`MATCH(p:Post {id: "p99"})-[:ATTACHMENT]->(f:File) RETURN f,p`,
{},
)
post = database.neode
.hydrateFirst<{ id: string }>(result, 'p', database.neode.model('Post'))
.properties()
const file = database.neode.hydrateFirst(result, 'f', database.neode.model('File'))
expect(post).toBeTruthy()
expect(file).toBeTruthy()
})
it('sets metadata', async () => {
await addAttachment(post, 'ATTACHMENT', fileInput)
const file = await database.neode.first<typeof File>('File', {}, undefined)
await expect(file.toJson()).resolves.toMatchObject({
name: 'The name of the new attachment',
type: 'application/any',
createdAt: expect.any(String),
updatedAt: expect.any(String),
url: expect.any(String),
})
})
describe('given a transaction parameter', () => {
it('executes cypher statements within the transaction', async () => {
const session = database.driver.session()
try {
await session.writeTransaction(async (transaction) => {
const file = await addAttachment(
post,
'ATTACHMENT',
fileInput,
{},
{
transaction,
},
)
return transaction.run(
`
MATCH(file:File {url: $file.url})
SET file.name = 'This name text gets overwritten'
RETURN file {.*}
`,
{ file },
)
})
} finally {
await session.close()
}
const file = await database.neode.first<typeof File>(
'File',
{ name: 'This name text gets overwritten' },
undefined,
)
await expect(file.toJson()).resolves.toMatchObject({
name: 'This name text gets overwritten',
})
})
it('rolls back the transaction in case of errors', async () => {
const session = database.driver.session()
try {
await session.writeTransaction(async (transaction) => {
const file = await addAttachment(post, 'ATTACHMENT', fileInput, {
transaction,
})
return transaction.run('Ooops invalid cypher!', { file })
})
// eslint-disable-next-line no-catch-all/no-catch-all
} catch (err) {
// nothing has been created
await expect(database.neode.all('File')).resolves.toHaveLength(0)
// all good
} finally {
await session.close()
}
})
})
})
})
describe('without image.upload', () => {
it('throws UserInputError', async () => {
const p = await Factory.build('post', { id: 'p99' }, { image: null })
post = await p.toJson()
await expect(addAttachment(post, 'ATTACHMENT', fileInput)).rejects.toEqual(
new UserInputError('Cannot find attachment for given resource'),
)
})
})
})

View File

@ -0,0 +1,173 @@
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 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 DeleteAttachmentsOpts {
transaction?: Transaction
}
export interface AddAttachmentOpts {
transaction?: Transaction
}
export interface FileInput {
upload?: Promise<FileUpload>
name: string
type: string
}
export interface File {
url: string
name: string
type: string
}
export interface Attachments {
del: (
resource: { id: string },
relationshipType: 'ATTACHMENT',
opts?: DeleteAttachmentsOpts,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<any>
add: (
resource: { id: string },
relationshipType: 'ATTACHMENT',
file: FileInput,
fileAttributes?: object,
opts?: AddAttachmentOpts,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<any>
}
export const attachments = (config: S3Configured) => {
if (!isS3configured(config)) {
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 del: Attachments['del'] = async (resource, relationshipType, opts = {}) => {
const { transaction } = opts
if (!transaction) return wrapTransaction(del, [resource, relationshipType], opts)
const txResult = await transaction.run(
`
MATCH (resource {id: $resource.id})-[rel:${relationshipType}]->(file:File)
WITH file, file {.*} as fileProps
DETACH DELETE file
RETURN fileProps
`,
{ resource },
)
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))
}
return file
}
const add: Attachments['add'] = async (
resource,
relationshipType,
fileInput,
fileAttributes = {},
opts = {},
) => {
const { transaction } = opts
if (!transaction)
return wrapTransaction(add, [resource, relationshipType, fileInput, fileAttributes], opts)
const { upload } = fileInput
if (!upload) throw new UserInputError('Cannot find attachment for given resource')
const uploadFile = await upload
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()
const { Location } = data
if (!Location) {
throw new Error('File upload did not return `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 { name, type } = fileInput
const file = { url, name, type, ...fileAttributes }
// const mimeType = uploadFile.mimetype.split('/')[0]
// const nodeType = `Mime${mimeType.replace(/^./, mimeType[0].toUpperCase())}`
// CREATE (file:${['File', nodeType].filter(Boolean).join(':')})
const txResult = await transaction.run(
`
MATCH (resource {id: $resource.id})
CREATE (file:File)
SET file.createdAt = toString(datetime())
SET file += $file
SET file.updatedAt = toString(datetime())
WITH resource, file
MERGE (resource)-[:${relationshipType}]->(file)
RETURN file {.*}
`,
{ resource, file /*, nodeType */ },
)
const [uploadedFile] = txResult.records.map((record) => record.get('file') as File)
return uploadedFile
}
const attachments: Attachments = {
del,
add,
}
return attachments
}

View File

@ -205,15 +205,6 @@ describe('mergeImage', () => {
expect(image).toBeTruthy()
})
it('whitelists relationship types', async () => {
await expect(
mergeImage(post, 'WHATEVER' as 'HERO_IMAGE', imageInput, {
uploadCallback,
deleteCallback,
}),
).rejects.toEqual(new Error('Unknown relationship type WHATEVER'))
})
it('sets metadata', async () => {
await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback })
const image = await neode.first<typeof Image>('Image', {}, undefined)

View File

@ -15,14 +15,12 @@ import { UserInputError } from 'apollo-server'
import slug from 'slug'
import { v4 as uuid } from 'uuid'
import { sanitizeRelationshipType } from './sanitizeRelationshipTypes'
import { wrapTransaction } from './wrapTransaction'
import type { Images, FileDeleteCallback, FileUploadCallback } from './images'
import type { FileUpload } from 'graphql-upload'
const deleteImage: Images['deleteImage'] = async (resource, relationshipType, opts = {}) => {
sanitizeRelationshipType(relationshipType)
const { transaction, deleteCallback } = opts
if (!transaction) return wrapTransaction(deleteImage, [resource, relationshipType], opts)
const txResult = await transaction.run(
@ -51,7 +49,6 @@ const mergeImage: Images['mergeImage'] = async (
) => {
if (typeof imageInput === 'undefined') return
if (imageInput === null) return deleteImage(resource, relationshipType, opts)
sanitizeRelationshipType(relationshipType)
const { transaction, uploadCallback, deleteCallback } = opts
if (!transaction)
return wrapTransaction(mergeImage, [resource, relationshipType, imageInput], opts)

View File

@ -239,15 +239,6 @@ describe('mergeImage', () => {
expect(image).toBeTruthy()
})
it('whitelists relationship types', async () => {
await expect(
mergeImage(post, 'WHATEVER' as 'HERO_IMAGE', imageInput, {
uploadCallback,
deleteCallback,
}),
).rejects.toEqual(new Error('Unknown relationship type WHATEVER'))
})
it('sets metadata', async () => {
await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback })
const image = await neode.first<typeof Image>('Image', {}, undefined)

View File

@ -8,7 +8,6 @@ import { v4 as uuid } from 'uuid'
import { S3Configured } from '@config/index'
import { sanitizeRelationshipType } from './sanitizeRelationshipTypes'
import { wrapTransaction } from './wrapTransaction'
import type { Image, Images, FileDeleteCallback, FileUploadCallback } from './images'
@ -29,7 +28,6 @@ export const images = (config: S3Configured) => {
})
const deleteImage: Images['deleteImage'] = async (resource, relationshipType, opts = {}) => {
sanitizeRelationshipType(relationshipType)
const { transaction, deleteCallback = s3Delete } = opts
if (!transaction) return wrapTransaction(deleteImage, [resource, relationshipType], opts)
const txResult = await transaction.run(
@ -60,7 +58,6 @@ export const images = (config: S3Configured) => {
) => {
if (typeof imageInput === 'undefined') return
if (imageInput === null) return deleteImage(resource, relationshipType, opts)
sanitizeRelationshipType(relationshipType)
const { transaction, uploadCallback, deleteCallback = s3Delete } = opts
if (!transaction)
return wrapTransaction(mergeImage, [resource, relationshipType, imageInput], opts)

View File

@ -1,9 +0,0 @@
export function sanitizeRelationshipType(
relationshipType: string,
): asserts relationshipType is 'HERO_IMAGE' | 'AVATAR_IMAGE' {
// Cypher query language does not allow to parameterize relationship types
// See: https://github.com/neo4j/neo4j/issues/340
if (!['HERO_IMAGE', 'AVATAR_IMAGE'].includes(relationshipType)) {
throw new Error(`Unknown relationship type ${relationshipType}`)
}
}

View File

@ -3,16 +3,19 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { Readable } from 'node:stream'
import { ApolloServer } from 'apollo-server-express'
import { createTestClient } from 'apollo-server-testing'
import { Upload } from 'graphql-upload/public/index'
import databaseContext from '@context/database'
import pubsubContext from '@context/pubsub'
import Factory, { cleanDatabase } from '@db/factories'
import { createMessageMutation } from '@graphql/queries/createMessageMutation'
import { CreateMessage } from '@graphql/queries/CreateMessage'
import { createRoomMutation } from '@graphql/queries/createRoomMutation'
import { markMessagesAsSeen } from '@graphql/queries/markMessagesAsSeen'
import { messageQuery } from '@graphql/queries/messageQuery'
import { MarkMessagesAsSeen } from '@graphql/queries/MarkMessagesAsSeen'
import { Message } from '@graphql/queries/Message'
import { roomQuery } from '@graphql/queries/roomQuery'
import createServer, { getContext } from '@src/server'
@ -39,6 +42,10 @@ beforeAll(async () => {
mutate = createTestClient(server).mutate
})
beforeEach(async () => {
await cleanDatabase()
})
afterAll(async () => {
await cleanDatabase()
void server.stop()
@ -49,7 +56,7 @@ afterAll(async () => {
describe('Message', () => {
let roomId: string
beforeAll(async () => {
beforeEach(async () => {
;[chattingUser, otherChattingUser, notChattingUser] = await Promise.all([
Factory.build('user', {
id: 'chatting-user',
@ -75,7 +82,7 @@ describe('Message', () => {
it('throws authorization error', async () => {
await expect(
mutate({
mutation: createMessageMutation(),
mutation: CreateMessage,
variables: {
roomId: 'some-id',
content: 'Some bla bla bla',
@ -96,7 +103,7 @@ describe('Message', () => {
it('returns null and does not publish subscription', async () => {
await expect(
mutate({
mutation: createMessageMutation(),
mutation: CreateMessage,
variables: {
roomId: 'some-id',
content: 'Some bla bla bla',
@ -113,7 +120,8 @@ describe('Message', () => {
})
describe('room exists', () => {
beforeAll(async () => {
beforeEach(async () => {
authenticatedUser = await chattingUser.toJson()
const room = await mutate({
mutation: createRoomMutation(),
variables: {
@ -127,7 +135,7 @@ describe('Message', () => {
it('returns the message', async () => {
await expect(
mutate({
mutation: createMessageMutation(),
mutation: CreateMessage,
variables: {
roomId,
content: 'Some nice message to other chatting user',
@ -151,6 +159,16 @@ describe('Message', () => {
})
})
beforeEach(async () => {
await mutate({
mutation: CreateMessage,
variables: {
roomId,
content: 'Some nice message to other chatting user',
},
})
})
describe('room is updated as well', () => {
it('has last message set', async () => {
const result = await query({ query: roomQuery() })
@ -210,15 +228,70 @@ describe('Message', () => {
})
})
describe('user sends files in room', () => {
const file1 = Readable.from('file1')
const upload1 = new Upload()
upload1.resolve({
createReadStream: () => file1,
stream: file1,
filename: 'file1',
encoding: '7bit',
mimetype: 'application/json',
})
const file2 = Readable.from('file2')
const upload2 = new Upload()
upload2.resolve({
createReadStream: () => file2,
stream: file2,
filename: 'file2',
encoding: '7bit',
mimetype: 'image/png',
})
it('returns the message', async () => {
await expect(
mutate({
mutation: CreateMessage,
variables: {
roomId,
content: 'Some files for other chatting user',
files: [
{ upload: upload1, name: 'test1', type: 'application/json' },
{ upload: upload2, name: 'test2', type: 'image/png' },
],
},
}),
).resolves.toMatchObject({
errors: undefined,
data: {
CreateMessage: {
id: expect.any(String),
content: 'Some files for other chatting user',
senderId: 'chatting-user',
username: 'Chatting User',
avatar: expect.any(String),
date: expect.any(String),
saved: true,
distributed: false,
seen: false,
files: expect.arrayContaining([
{ name: 'test1', type: 'application/json', url: expect.any(String) },
{ name: 'test2', type: 'image/png', url: expect.any(String) },
]),
},
},
})
})
})
describe('user does not chat in room', () => {
beforeAll(async () => {
beforeEach(async () => {
authenticatedUser = await notChattingUser.toJson()
})
it('returns null', async () => {
await expect(
mutate({
mutation: createMessageMutation(),
mutation: CreateMessage,
variables: {
roomId,
content: 'I have no access to this room!',
@ -245,7 +318,7 @@ describe('Message', () => {
it('throws authorization error', async () => {
await expect(
query({
query: messageQuery(),
query: Message,
variables: {
roomId: 'some-id',
},
@ -265,7 +338,7 @@ describe('Message', () => {
it('returns null', async () => {
await expect(
query({
query: messageQuery(),
query: Message,
variables: {
roomId: 'some-id',
},
@ -280,9 +353,28 @@ describe('Message', () => {
})
describe('room exists with authenticated user chatting', () => {
beforeEach(async () => {
authenticatedUser = await chattingUser.toJson()
const room = await mutate({
mutation: createRoomMutation(),
variables: {
userId: 'other-chatting-user',
},
})
roomId = room.data.CreateRoom.id
await mutate({
mutation: CreateMessage,
variables: {
roomId,
content: 'Some nice message to other chatting user',
},
})
})
it('returns the messages', async () => {
const result = await query({
query: messageQuery(),
query: Message,
variables: {
roomId,
},
@ -301,7 +393,7 @@ describe('Message', () => {
avatar: expect.any(String),
date: expect.any(String),
saved: true,
distributed: true,
distributed: false,
seen: false,
},
],
@ -310,9 +402,10 @@ describe('Message', () => {
})
describe('more messages', () => {
beforeAll(async () => {
beforeEach(async () => {
authenticatedUser = await otherChattingUser.toJson()
await mutate({
mutation: createMessageMutation(),
mutation: CreateMessage,
variables: {
roomId,
content: 'A nice response message to chatting user',
@ -320,7 +413,7 @@ describe('Message', () => {
})
authenticatedUser = await chattingUser.toJson()
await mutate({
mutation: createMessageMutation(),
mutation: CreateMessage,
variables: {
roomId,
content: 'And another nice message to other chatting user',
@ -331,7 +424,7 @@ describe('Message', () => {
it('returns the messages', async () => {
await expect(
query({
query: messageQuery(),
query: Message,
variables: {
roomId,
},
@ -349,7 +442,7 @@ describe('Message', () => {
avatar: expect.any(String),
date: expect.any(String),
saved: true,
distributed: true,
distributed: false,
seen: false,
}),
expect.objectContaining({
@ -384,7 +477,7 @@ describe('Message', () => {
it('returns the messages paginated', async () => {
await expect(
query({
query: messageQuery(),
query: Message,
variables: {
roomId,
first: 2,
@ -419,7 +512,7 @@ describe('Message', () => {
await expect(
query({
query: messageQuery(),
query: Message,
variables: {
roomId,
first: 2,
@ -454,7 +547,7 @@ describe('Message', () => {
it('returns null', async () => {
await expect(
query({
query: messageQuery(),
query: Message,
variables: {
roomId,
},
@ -479,7 +572,7 @@ describe('Message', () => {
it('throws authorization error', async () => {
await expect(
mutate({
mutation: markMessagesAsSeen(),
mutation: MarkMessagesAsSeen,
variables: {
messageIds: ['some-id'],
},
@ -492,10 +585,41 @@ describe('Message', () => {
describe('authenticated', () => {
const messageIds: string[] = []
beforeAll(async () => {
beforeEach(async () => {
authenticatedUser = await chattingUser.toJson()
const room = await mutate({
mutation: createRoomMutation(),
variables: {
userId: 'other-chatting-user',
},
})
roomId = room.data.CreateRoom.id
await mutate({
mutation: CreateMessage,
variables: {
roomId,
content: 'Some nice message to other chatting user',
},
})
authenticatedUser = await otherChattingUser.toJson()
await mutate({
mutation: CreateMessage,
variables: {
roomId,
content: 'A nice response message to chatting user',
},
})
authenticatedUser = await chattingUser.toJson()
await mutate({
mutation: CreateMessage,
variables: {
roomId,
content: 'And another nice message to other chatting user',
},
})
authenticatedUser = await otherChattingUser.toJson()
const msgs = await query({
query: messageQuery(),
query: Message,
variables: {
roomId,
},
@ -506,7 +630,7 @@ describe('Message', () => {
it('returns true', async () => {
await expect(
mutate({
mutation: markMessagesAsSeen(),
mutation: MarkMessagesAsSeen,
variables: {
messageIds,
},
@ -520,9 +644,15 @@ describe('Message', () => {
})
it('has seen prop set to true', async () => {
await mutate({
mutation: MarkMessagesAsSeen,
variables: {
messageIds,
},
})
await expect(
query({
query: messageQuery(),
query: Message,
variables: {
roomId,
},

View File

@ -7,8 +7,10 @@
import { withFilter } from 'graphql-subscriptions'
import { neo4jgraphql } from 'neo4j-graphql-js'
import CONFIG, { isS3configured } from '@config/index'
import { CHAT_MESSAGE_ADDED } from '@constants/subscriptions'
import { attachments } from './attachments/attachments'
import Resolver from './helpers/Resolver'
const setMessagesAsDistributed = async (undistributedMessagesIds, session) => {
@ -70,7 +72,7 @@ export default {
},
Mutation: {
CreateMessage: async (_parent, params, context, _resolveInfo) => {
const { roomId, content } = params
const { roomId, content, files = [] } = params
const {
user: { id: currentUserId },
} = context
@ -82,7 +84,7 @@ export default {
OPTIONAL MATCH (m:Message)-[:INSIDE]->(room)
OPTIONAL MATCH (room)<-[:CHATS_IN]-(recipientUser:User)
WHERE NOT recipientUser.id = $currentUserId
WITH MAX(m.indexId) as maxIndex, room, currentUser, image, recipientUser
WITH MAX(m.indexId) as maxIndex, room, currentUser, image, recipientUser
CREATE (currentUser)-[:CREATED]->(message:Message {
createdAt: toString(datetime()),
id: apoc.create.uuid(),
@ -116,7 +118,40 @@ export default {
return message
})
try {
return await writeTxResultPromise
// We cannot combine the query above with the attachments, since you need the resource for matching
const message = await writeTxResultPromise
// this is the case if the room doesn't exist - requires refactoring for implicit rooms
if (!message) {
return null
}
const session = context.driver.session()
const writeFilesPromise = session.writeTransaction(async (transaction) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const atns: any[] = []
if (!isS3configured(CONFIG)) {
return atns
}
for await (const file of files) {
const atn = await attachments(CONFIG).add(
message,
'ATTACHMENT',
file,
{},
{
transaction,
},
)
atns.push(atn)
}
return atns
})
const atns = await writeFilesPromise
return { ...message, files: atns }
} catch (error) {
throw new Error(error)
} finally {
@ -156,6 +191,9 @@ export default {
author: '<-[:CREATED]-(related:User)',
room: '-[:INSIDE]->(related:Room)',
},
hasMany: {
files: '-[:ATTACHMENT]-(related:File)',
},
}),
},
}

View File

@ -5,7 +5,7 @@ import { createTestClient } from 'apollo-server-testing'
import Factory, { cleanDatabase } from '@db/factories'
import { getNeode, getDriver } from '@db/neo4j'
import { createMessageMutation } from '@graphql/queries/createMessageMutation'
import { CreateMessage } from '@graphql/queries/CreateMessage'
import { createRoomMutation } from '@graphql/queries/createRoomMutation'
import { roomQuery } from '@graphql/queries/roomQuery'
import { unreadRoomsQuery } from '@graphql/queries/unreadRoomsQuery'
@ -327,21 +327,21 @@ describe('Room', () => {
})
otherRoomId = result.data.CreateRoom.roomId
await mutate({
mutation: createMessageMutation(),
mutation: CreateMessage,
variables: {
roomId: otherRoomId,
content: 'Message to not chatting user',
},
})
await mutate({
mutation: createMessageMutation(),
mutation: CreateMessage,
variables: {
roomId,
content: '1st message to other chatting user',
},
})
await mutate({
mutation: createMessageMutation(),
mutation: CreateMessage,
variables: {
roomId,
content: '2nd message to other chatting user',
@ -356,7 +356,7 @@ describe('Room', () => {
})
otherRoomId = result2.data.CreateRoom.roomId
await mutate({
mutation: createMessageMutation(),
mutation: CreateMessage,
variables: {
roomId: otherRoomId,
content: 'Other message to not chatting user',

View File

@ -0,0 +1,16 @@
type File {
url: ID!,
name: String,
#size: Int,
type: String,
#audio: Boolean,
#duration: Float,
#preview: String,
#progress: Int,
}
input FileInput {
upload: Upload,
name: String,
type: String,
}

View File

@ -12,8 +12,8 @@ type Message {
createdAt: String
updatedAt: String
content: String!
content: String
author: User! @relation(name: "CREATED", direction: "IN")
room: Room! @relation(name: "INSIDE", direction: "OUT")
@ -25,12 +25,14 @@ type Message {
saved: Boolean
distributed: Boolean
seen: Boolean
files: [File]! @relation(name: "ATTACHMENT", direction: "OUT")
}
type Mutation {
CreateMessage(
roomId: ID!
content: String!
content: String
files: [FileInput]
): Message
MarkMessagesAsSeen(messageIds: [String!]): Boolean

View File

@ -13,7 +13,7 @@ import pubsubContext from '@context/pubsub'
import Factory, { cleanDatabase } from '@db/factories'
import { changeGroupMemberRoleMutation } from '@graphql/queries/changeGroupMemberRoleMutation'
import { createGroupMutation } from '@graphql/queries/createGroupMutation'
import { createMessageMutation } from '@graphql/queries/createMessageMutation'
import { CreateMessage } from '@graphql/queries/CreateMessage'
import { createRoomMutation } from '@graphql/queries/createRoomMutation'
import { joinGroupMutation } from '@graphql/queries/joinGroupMutation'
import { leaveGroupMutation } from '@graphql/queries/leaveGroupMutation'
@ -918,7 +918,7 @@ describe('notifications', () => {
isUserOnlineMock = jest.fn().mockReturnValue(true)
await mutate({
mutation: createMessageMutation(),
mutation: CreateMessage,
variables: {
roomId,
content: 'Some nice message to chatReceiver',
@ -953,7 +953,7 @@ describe('notifications', () => {
isUserOnlineMock = jest.fn().mockReturnValue(false)
await mutate({
mutation: createMessageMutation(),
mutation: CreateMessage,
variables: {
roomId,
content: 'Some nice message to chatReceiver',
@ -1002,7 +1002,7 @@ describe('notifications', () => {
await chatReceiver.relateTo(chatSender, 'blocked')
await mutate({
mutation: createMessageMutation(),
mutation: CreateMessage,
variables: {
roomId,
content: 'Some nice message to chatReceiver',
@ -1022,7 +1022,7 @@ describe('notifications', () => {
await chatReceiver.relateTo(chatSender, 'muted')
await mutate({
mutation: createMessageMutation(),
mutation: CreateMessage,
variables: {
roomId,
content: 'Some nice message to chatReceiver',
@ -1042,7 +1042,7 @@ describe('notifications', () => {
await chatReceiver.update({ emailNotificationsChatMessage: false })
await mutate({
mutation: createMessageMutation(),
mutation: CreateMessage,
variables: {
roomId,
content: 'Some nice message to chatReceiver',

View File

@ -41,6 +41,7 @@ services:
- AWS_REGION=local
- AWS_BUCKET=ocelot
- S3_PUBLIC_GATEWAY=http:/localhost:9000
- DEBUG=neo4j-graphql-js
volumes:
- ./backend:/app

View File

@ -15,8 +15,9 @@
:room-actions="JSON.stringify(roomActions)"
:rooms-loaded="roomsLoaded"
:loading-rooms="loadingRooms"
show-files="false"
show-audio="false"
show-files="true"
show-audio="true"
capture-files="true"
:height="'calc(100dvh - 190px)'"
:styles="JSON.stringify(computedChatStyle)"
:show-footer="true"
@ -29,6 +30,7 @@
@add-room="toggleUserSearch"
@show-demo-options="showDemoOptions = $event"
@open-user-tag="redirectToUserProfile($event.detail[0])"
@open-file="openFile($event.detail[0].file.file)"
>
<div
v-if="selectedRoom && selectedRoom.roomId"
@ -356,6 +358,8 @@ export default {
changedRoom.lastMessage = data.chatMessageAdded
changedRoom.lastMessage.content = changedRoom.lastMessage.content.trim().substring(0, 30)
changedRoom.lastMessageAt = data.chatMessageAdded.date
// Move changed room to the top of the list
changedRoom.index = data.chatMessageAdded.date
changedRoom.unreadCount++
this.rooms[roomIndex] = changedRoom
if (data.chatMessageAdded.room.id === this.selectedRoom?.id) {
@ -365,31 +369,52 @@ export default {
}
},
async sendMessage(message) {
async sendMessage(messageDetails) {
const { roomId, content, files } = messageDetails
const hasFiles = files && files.length > 0
const filesToUpload = hasFiles
? files.map((file) => ({
upload: file.blob,
name: file.name,
type: file.type,
}))
: null
const mutationVariables = {
roomId,
content,
}
if (filesToUpload && filesToUpload.length > 0) {
mutationVariables.files = filesToUpload
}
try {
const {
data: { CreateMessage: createdMessage },
} = await this.$apollo.mutate({
const { data } = await this.$apollo.mutate({
mutation: createMessageMutation(),
variables: {
roomId: message.roomId,
content: message.content,
},
variables: mutationVariables,
})
const roomIndex = this.rooms.findIndex((r) => r.id === message.roomId)
const changedRoom = { ...this.rooms[roomIndex] }
changedRoom.lastMessage = createdMessage
changedRoom.lastMessage.content = changedRoom.lastMessage.content.trim().substring(0, 30)
// move current room to top (not 100% working)
// const rooms = [...this.rooms]
// rooms.splice(roomIndex,1)
// this.rooms = [changedRoom, ...rooms]
this.rooms[roomIndex] = changedRoom
const createdMessagePayload = data.CreateMessage
if (createdMessagePayload) {
const roomIndex = this.rooms.findIndex((r) => r.id === roomId)
if (roomIndex !== -1) {
const changedRoom = { ...this.rooms[roomIndex] }
changedRoom.lastMessage.content = createdMessagePayload.content.trim().substring(0, 30)
changedRoom.lastMessage.date = createdMessagePayload.date
// Move changed room to the top of the list
changedRoom.index = createdMessagePayload.date
this.rooms = [changedRoom, ...this.rooms.filter((r) => r.id !== roomId)]
}
}
} catch (error) {
this.$toast.error(error.message)
}
this.fetchMessages({
room: this.rooms.find((r) => r.roomId === message.roomId),
room: this.rooms.find((r) => r.roomId === messageDetails.roomId),
options: { refetch: true },
})
},
@ -414,7 +439,7 @@ export default {
...room.lastMessage,
content: room.lastMessage?.content?.trim().substring(0, 30),
}
: null,
: {},
users: room.users.map((u) => {
return { ...u, username: u.name, avatar: this.$filters.proxyApiUrl(u.avatar?.url) }
}),
@ -452,6 +477,30 @@ export default {
})
},
openFile: async function (file) {
if (!file || !file.url) return
/* To make the browser download the file instead of opening it, it needs to be
from the same origin or from local blob storage. So we fetch it first
and then create a download link from blob storage. */
const url = this.$filters.proxyApiUrl(file.url)
const download = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': file.type,
},
})
const blob = await download.blob()
const objectURL = window.URL.createObjectURL(blob)
const downloadLink = document.createElement('a')
downloadLink.href = objectURL
downloadLink.download = `${file.name}.${file.type.split('/')[1]}`
downloadLink.style.display = 'none'
document.body.appendChild(downloadLink)
downloadLink.click()
document.body.removeChild(downloadLink)
},
redirectToUserProfile({ user }) {
const userID = user.id
const userName = user.name.toLowerCase().replaceAll(' ', '-')

View File

@ -2,8 +2,8 @@ import gql from 'graphql-tag'
export const createMessageMutation = () => {
return gql`
mutation ($roomId: ID!, $content: String!) {
CreateMessage(roomId: $roomId, content: $content) {
mutation ($roomId: ID!, $content: String, $files: [FileInput]) {
CreateMessage(roomId: $roomId, content: $content, files: $files) {
#_id
id
indexId
@ -21,6 +21,13 @@ export const createMessageMutation = () => {
saved
distributed
seen
files {
url
name
#size
type
#preview
}
}
}
`
@ -47,6 +54,15 @@ export const messageQuery = () => {
saved
distributed
seen
files {
url
name
#size
type
#audio
#duration
#preview
}
}
}
`
@ -73,6 +89,15 @@ export const chatMessageAdded = () => {
saved
distributed
seen
files {
url
name
#size
type
#audio
#duration
#preview
}
}
}
`