diff --git a/backend/src/db/factories.ts b/backend/src/db/factories.ts index eef970aba..ebdc4a868 100644 --- a/backend/src/db/factories.ts +++ b/backend/src/db/factories.ts @@ -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({ diff --git a/backend/src/db/models/File.ts b/backend/src/db/models/File.ts new file mode 100644 index 000000000..a247d1ac9 --- /dev/null +++ b/backend/src/db/models/File.ts @@ -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() }, +} diff --git a/backend/src/db/models/Image.ts b/backend/src/db/models/Image.ts index b46342c18..3a9ef0001 100644 --- a/backend/src/db/models/Image.ts +++ b/backend/src/db/models/Image.ts @@ -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() }, } diff --git a/backend/src/db/models/index.ts b/backend/src/db/models/index.ts index 6bbdab338..c4c3065df 100644 --- a/backend/src/db/models/index.ts +++ b/backend/src/db/models/index.ts @@ -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, diff --git a/backend/src/db/seed.ts b/backend/src/db/seed.ts index 68bf0467e..558425ec9 100644 --- a/backend/src/db/seed.ts +++ b/backend/src/db/seed.ts @@ -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(), diff --git a/backend/src/emails/sendChatMessageMail.spec.ts b/backend/src/emails/sendChatMessageMail.spec.ts index 45835bbc3..17bb58a7e 100644 --- a/backend/src/emails/sendChatMessageMail.spec.ts +++ b/backend/src/emails/sendChatMessageMail.spec.ts @@ -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 = { diff --git a/backend/src/emails/sendEmailVerification.spec.ts b/backend/src/emails/sendEmailVerification.spec.ts index 0863dd9db..f36f81694 100644 --- a/backend/src/emails/sendEmailVerification.spec.ts +++ b/backend/src/emails/sendEmailVerification.spec.ts @@ -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', () => { diff --git a/backend/src/emails/sendNotificationMail.spec.ts b/backend/src/emails/sendNotificationMail.spec.ts index fee641e2e..efb71b788 100644 --- a/backend/src/emails/sendNotificationMail.spec.ts +++ b/backend/src/emails/sendNotificationMail.spec.ts @@ -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', () => { diff --git a/backend/src/emails/sendRegistrationMail.spec.ts b/backend/src/emails/sendRegistrationMail.spec.ts index ea66771c2..11ac0b59b 100644 --- a/backend/src/emails/sendRegistrationMail.spec.ts +++ b/backend/src/emails/sendRegistrationMail.spec.ts @@ -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', () => { diff --git a/backend/src/emails/sendResetPasswordMail.spec.ts b/backend/src/emails/sendResetPasswordMail.spec.ts index e37af2e7b..d49bbe7db 100644 --- a/backend/src/emails/sendResetPasswordMail.spec.ts +++ b/backend/src/emails/sendResetPasswordMail.spec.ts @@ -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', () => { diff --git a/backend/src/emails/sendWrongEmail.spec.ts b/backend/src/emails/sendWrongEmail.spec.ts index 854d935f9..e55a70ad4 100644 --- a/backend/src/emails/sendWrongEmail.spec.ts +++ b/backend/src/emails/sendWrongEmail.spec.ts @@ -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', () => { diff --git a/backend/src/graphql/queries/CreateMessage.ts b/backend/src/graphql/queries/CreateMessage.ts new file mode 100644 index 000000000..1282c5d87 --- /dev/null +++ b/backend/src/graphql/queries/CreateMessage.ts @@ -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 + } + } + } +` diff --git a/backend/src/graphql/queries/MarkMessagesAsSeen.ts b/backend/src/graphql/queries/MarkMessagesAsSeen.ts new file mode 100644 index 000000000..f6a6418ba --- /dev/null +++ b/backend/src/graphql/queries/MarkMessagesAsSeen.ts @@ -0,0 +1,7 @@ +import gql from 'graphql-tag' + +export const MarkMessagesAsSeen = gql` + mutation ($messageIds: [String!]) { + MarkMessagesAsSeen(messageIds: $messageIds) + } +` diff --git a/backend/src/graphql/queries/Message.ts b/backend/src/graphql/queries/Message.ts new file mode 100644 index 000000000..459584b98 --- /dev/null +++ b/backend/src/graphql/queries/Message.ts @@ -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 + } + } + } +` diff --git a/backend/src/graphql/queries/createMessageMutation.ts b/backend/src/graphql/queries/createMessageMutation.ts deleted file mode 100644 index e8c6fc7b8..000000000 --- a/backend/src/graphql/queries/createMessageMutation.ts +++ /dev/null @@ -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 - } - } - ` -} diff --git a/backend/src/graphql/queries/markMessagesAsSeen.ts b/backend/src/graphql/queries/markMessagesAsSeen.ts deleted file mode 100644 index 9081c5def..000000000 --- a/backend/src/graphql/queries/markMessagesAsSeen.ts +++ /dev/null @@ -1,9 +0,0 @@ -import gql from 'graphql-tag' - -export const markMessagesAsSeen = () => { - return gql` - mutation ($messageIds: [String!]) { - MarkMessagesAsSeen(messageIds: $messageIds) - } - ` -} diff --git a/backend/src/graphql/queries/messageQuery.ts b/backend/src/graphql/queries/messageQuery.ts deleted file mode 100644 index 791851121..000000000 --- a/backend/src/graphql/queries/messageQuery.ts +++ /dev/null @@ -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 - } - } - ` -} diff --git a/backend/src/graphql/resolvers/attachments/attachments.spec.ts b/backend/src/graphql/resolvers/attachments/attachments.spec.ts new file mode 100644 index 000000000..68321423f --- /dev/null +++ b/backend/src/graphql/resolvers/attachments/attachments.spec.ts @@ -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('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( + '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'), + ) + }) + }) +}) diff --git a/backend/src/graphql/resolvers/attachments/attachments.ts b/backend/src/graphql/resolvers/attachments/attachments.ts new file mode 100644 index 000000000..c52b485c2 --- /dev/null +++ b/backend/src/graphql/resolvers/attachments/attachments.ts @@ -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 + +export type FileUploadCallback = ( + upload: Pick & { uniqueFilename: string }, +) => Promise +export interface DeleteAttachmentsOpts { + transaction?: Transaction +} + +export interface AddAttachmentOpts { + transaction?: Transaction +} + +export interface FileInput { + upload?: Promise + 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 + + add: ( + resource: { id: string }, + relationshipType: 'ATTACHMENT', + file: FileInput, + fileAttributes?: object, + opts?: AddAttachmentOpts, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) => Promise +} + +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 +} diff --git a/backend/src/graphql/resolvers/images/imagesLocal.spec.ts b/backend/src/graphql/resolvers/images/imagesLocal.spec.ts index 257a24e78..4fe459699 100644 --- a/backend/src/graphql/resolvers/images/imagesLocal.spec.ts +++ b/backend/src/graphql/resolvers/images/imagesLocal.spec.ts @@ -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('Image', {}, undefined) diff --git a/backend/src/graphql/resolvers/images/imagesLocal.ts b/backend/src/graphql/resolvers/images/imagesLocal.ts index c9f575777..671259c17 100644 --- a/backend/src/graphql/resolvers/images/imagesLocal.ts +++ b/backend/src/graphql/resolvers/images/imagesLocal.ts @@ -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) diff --git a/backend/src/graphql/resolvers/images/imagesS3.spec.ts b/backend/src/graphql/resolvers/images/imagesS3.spec.ts index 2bedec3cd..431a49196 100644 --- a/backend/src/graphql/resolvers/images/imagesS3.spec.ts +++ b/backend/src/graphql/resolvers/images/imagesS3.spec.ts @@ -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('Image', {}, undefined) diff --git a/backend/src/graphql/resolvers/images/imagesS3.ts b/backend/src/graphql/resolvers/images/imagesS3.ts index 66c4a0a69..6f7ebd4e1 100644 --- a/backend/src/graphql/resolvers/images/imagesS3.ts +++ b/backend/src/graphql/resolvers/images/imagesS3.ts @@ -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) diff --git a/backend/src/graphql/resolvers/images/sanitizeRelationshipTypes.ts b/backend/src/graphql/resolvers/images/sanitizeRelationshipTypes.ts deleted file mode 100644 index a6b984a13..000000000 --- a/backend/src/graphql/resolvers/images/sanitizeRelationshipTypes.ts +++ /dev/null @@ -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}`) - } -} diff --git a/backend/src/graphql/resolvers/messages.spec.ts b/backend/src/graphql/resolvers/messages.spec.ts index 81799fdf1..f0479432b 100644 --- a/backend/src/graphql/resolvers/messages.spec.ts +++ b/backend/src/graphql/resolvers/messages.spec.ts @@ -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, }, diff --git a/backend/src/graphql/resolvers/messages.ts b/backend/src/graphql/resolvers/messages.ts index 6a5a59d27..bc831c671 100644 --- a/backend/src/graphql/resolvers/messages.ts +++ b/backend/src/graphql/resolvers/messages.ts @@ -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)', + }, }), }, } diff --git a/backend/src/graphql/resolvers/rooms.spec.ts b/backend/src/graphql/resolvers/rooms.spec.ts index 9a226a2f8..449fa89b7 100644 --- a/backend/src/graphql/resolvers/rooms.spec.ts +++ b/backend/src/graphql/resolvers/rooms.spec.ts @@ -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', diff --git a/backend/src/graphql/types/type/File.gql b/backend/src/graphql/types/type/File.gql new file mode 100644 index 000000000..fa0b8f53c --- /dev/null +++ b/backend/src/graphql/types/type/File.gql @@ -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, +} diff --git a/backend/src/graphql/types/type/Message.gql b/backend/src/graphql/types/type/Message.gql index 16e458151..a687a5fe9 100644 --- a/backend/src/graphql/types/type/Message.gql +++ b/backend/src/graphql/types/type/Message.gql @@ -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 diff --git a/backend/src/middleware/notifications/notificationsMiddleware.spec.ts b/backend/src/middleware/notifications/notificationsMiddleware.spec.ts index 7b51cec25..cf004ea52 100644 --- a/backend/src/middleware/notifications/notificationsMiddleware.spec.ts +++ b/backend/src/middleware/notifications/notificationsMiddleware.spec.ts @@ -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', diff --git a/docker-compose.override.yml b/docker-compose.override.yml index a9e957dca..9bd71dc7e 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -41,6 +41,7 @@ services: - AWS_REGION=local - AWS_BUCKET=ocelot - S3_PUBLIC_GATEWAY=http:/localhost:9000 + - DEBUG=neo4j-graphql-js volumes: - ./backend:/app diff --git a/webapp/components/Chat/Chat.vue b/webapp/components/Chat/Chat.vue index 3a52982a5..8f38ad1d8 100644 --- a/webapp/components/Chat/Chat.vue +++ b/webapp/components/Chat/Chat.vue @@ -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)" >
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(' ', '-') diff --git a/webapp/graphql/Messages.js b/webapp/graphql/Messages.js index ffa2760f9..8d6262803 100644 --- a/webapp/graphql/Messages.js +++ b/webapp/graphql/Messages.js @@ -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 + } } } `