diff --git a/.changeset/chat-get-message-by-file-id.md b/.changeset/chat-get-message-by-file-id.md new file mode 100644 index 0000000000000..a95b94c6ec6be --- /dev/null +++ b/.changeset/chat-get-message-by-file-id.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Adds a `GET /v1/chat.getMessageByFileId` endpoint that returns the message a file was sent in, given the file id diff --git a/apps/meteor/server/api/v1/chat.ts b/apps/meteor/server/api/v1/chat.ts index 6c7cbaa97cb1d..6fe4663b1edec 100644 --- a/apps/meteor/server/api/v1/chat.ts +++ b/apps/meteor/server/api/v1/chat.ts @@ -11,6 +11,7 @@ import { isChatDeleteProps, isChatSyncMessagesProps, isChatGetMessageProps, + isChatGetMessageByFileIdProps, isChatPostMessageProps, isChatSearchProps, isChatSendMessageProps, @@ -25,6 +26,8 @@ import { isChatGetStarredMessagesProps, isChatGetDiscussionsProps, validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + validateNotFoundErrorResponse, validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/tools'; @@ -791,6 +794,45 @@ const chatEndpoints = API.v1 }); }, ) + .get( + 'chat.getMessageByFileId', + { + authRequired: true, + query: isChatGetMessageByFileIdProps, + response: { + 200: ajv.compile<{ message: IMessage }>({ + type: 'object', + properties: { + message: { $ref: '#/components/schemas/IMessage' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['message', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + }, + }, + async function action() { + const msg = await Messages.getMessageByFileId(this.queryParams.fileId); + + if (!msg?.rid) { + return API.v1.notFound(); + } + + if (!(await canAccessRoomIdAsync(msg.rid, this.userId))) { + return API.v1.forbidden(); + } + + const [message] = await normalizeMessagesForUser([msg], this.userId); + + return API.v1.success({ + message, + }); + }, + ) .post( 'chat.postMessage', { diff --git a/apps/meteor/tests/end-to-end/api/chat.ts b/apps/meteor/tests/end-to-end/api/chat.ts index b7a609b72a3e9..9c21b29149336 100644 --- a/apps/meteor/tests/end-to-end/api/chat.ts +++ b/apps/meteor/tests/end-to-end/api/chat.ts @@ -935,6 +935,98 @@ describe('[Chat]', () => { }); }); + describe('/chat.getMessageByFileId', () => { + let fileRoom: IRoom; + let fileId: string; + let fileMessageId: IMessage['_id']; + let otherUser: TestUser; + let otherUserCredentials: Credentials; + + before(async () => { + // private, so the access check is meaningful: any user can preview a public channel + fileRoom = (await createRoom({ type: 'p', name: `chat-file-message-id-${Date.now()}` })).body.group; + + otherUser = await createUser(); + otherUserCredentials = await login(otherUser.username, password); + + await request + .post(api(`rooms.media/${fileRoom._id}`)) + .set(credentials) + .attach('file', imgURL) + .expect(200) + .expect((res: Response) => { + fileId = res.body.file._id; + }); + + await request + .post(api(`rooms.mediaConfirm/${fileRoom._id}/${fileId}`)) + .set(credentials) + .expect(200) + .expect((res: Response) => { + fileMessageId = res.body.message._id; + }); + }); + + after(() => Promise.all([deleteRoom({ type: 'p', roomId: fileRoom._id }), deleteUser(otherUser)])); + + it('should return the message the file was sent in', async () => { + const res = await request + .get(api('chat.getMessageByFileId')) + .set(credentials) + .query({ fileId }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('message._id', fileMessageId); + expect(res.body).to.have.nested.property('message.rid', fileRoom._id); + expect(res.body).to.have.nested.property('message.files[0]._id', fileId); + }); + + it("should fail when 'fileId' is not sent", async () => { + const res = await request.get(api('chat.getMessageByFileId')).set(credentials).expect('Content-Type', 'application/json').expect(400); + + expect(res.body).to.have.property('success', false); + }); + + it("should fail when 'fileId' is empty", async () => { + const res = await request + .get(api('chat.getMessageByFileId')) + .set(credentials) + .query({ fileId: '' }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + + it('should return 404 when no message references the file', async () => { + const res = await request + .get(api('chat.getMessageByFileId')) + .set(credentials) + .query({ fileId: 'invalid-file-id' }) + .expect('Content-Type', 'application/json') + .expect(404); + + expect(res.body).to.have.property('success', false); + }); + + it('should not return the message to a user without access to the room', async () => { + const res = await request + .get(api('chat.getMessageByFileId')) + .set(otherUserCredentials) + .query({ fileId }) + .expect('Content-Type', 'application/json') + .expect(403); + + expect(res.body).to.have.property('success', false); + }); + + it('should fail when not authenticated', async () => { + await request.get(api('chat.getMessageByFileId')).query({ fileId }).expect(401); + }); + }); + describe('/chat.sendMessage', () => { it("should throw an error when the required param 'rid' is not sent", async () => { const res = await request diff --git a/packages/rest-typings/src/v1/chat.ts b/packages/rest-typings/src/v1/chat.ts index 8e300fe4bfc06..e38d090432772 100644 --- a/packages/rest-typings/src/v1/chat.ts +++ b/packages/rest-typings/src/v1/chat.ts @@ -99,6 +99,26 @@ const ChatGetMessageSchema = { export const isChatGetMessageProps = ajv.compile(ChatGetMessageSchema); +type ChatGetMessageByFileId = { + fileId: string; +}; + +const ChatGetMessageByFileIdSchema = { + type: 'object', + description: 'Fetch the message a file was sent in.', + properties: { + fileId: { + type: 'string', + minLength: 1, + description: 'The file id.', + }, + }, + required: ['fileId'], + additionalProperties: false, +}; + +export const isChatGetMessageByFileIdProps = ajv.compile(ChatGetMessageByFileIdSchema); + type ChatGetDiscussions = PaginatedRequest<{ roomId: IRoom['_id']; text?: string; @@ -939,6 +959,11 @@ export type ChatEndpoints = { message: IMessage; }; }; + '/v1/chat.getMessageByFileId': { + GET: (params: ChatGetMessageByFileId) => { + message: IMessage; + }; + }; '/v1/chat.reportMessage': { POST: (params: ChatReportMessage) => void; };