From b5eea12d493d6b732d753ce0dec0965b62f40691 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Fri, 24 Apr 2020 17:33:32 +0200 Subject: [PATCH] Refactor so that message attributes are parsed early It's better to parse an incoming message stanza early, than to have all kinds of methods throughout the codebase that does querySelector etc. Firstly, it allows us to catch and report errors and malicious stanzas early on. It also simplifies programming because you don't need to try and remember how to properly parse a stanza, all the work is done upfront for you. --- spec/autocomplete.js | 2 +- spec/headline.js | 16 +- spec/mam.js | 8 +- spec/messages.js | 21 +- spec/minchats.js | 2 +- spec/muc.js | 40 +- spec/muc_messages.js | 72 ++- spec/notification.js | 11 +- spec/retractions.js | 24 +- spec/roomslist.js | 6 +- src/converse-notification.js | 3 +- src/headless/converse-chat.js | 208 +++---- src/headless/converse-headlines.js | 11 +- src/headless/converse-mam.js | 101 ++-- src/headless/converse-muc.js | 106 ++-- src/headless/utils/core.js | 30 +- src/headless/utils/stanza.js | 847 ++++++++++++++++++++--------- webpack.html | 2 +- 18 files changed, 837 insertions(+), 673 deletions(-) diff --git a/spec/autocomplete.js b/spec/autocomplete.js index c2bd2bfef..d85263139 100644 --- a/spec/autocomplete.js +++ b/spec/autocomplete.js @@ -37,7 +37,7 @@ describe("The nickname autocomplete feature", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t('Hello world').tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); // Test that pressing @ brings up all options const textarea = view.el.querySelector('textarea.chat-textarea'); diff --git a/spec/headline.js b/spec/headline.js index 57600b5a0..0163e4bc1 100644 --- a/spec/headline.js +++ b/spec/headline.js @@ -6,7 +6,7 @@ describe("A headlines box", function () { mock.initConverse( ['rosterGroupsFetched', 'chatBoxesFetched'], {}, function (done, _converse) { - const { u, $msg} = converse.env; + const { $msg } = converse.env; /* XMPP spam message: * * SORRY FOR THIS ADVERT * */ - sinon.spy(u, 'isHeadlineMessage'); const stanza = $msg({ 'type': 'headline', 'from': 'notify.example.com', @@ -65,9 +60,6 @@ describe("A headlines box", function () { _converse.connection._dataRecv(mock.createRequest(stanza)); await u.waitUntil(() => _converse.chatboxviews.keys().includes('notify.example.com')); - expect(u.isHeadlineMessage.called).toBeTruthy(); - expect(u.isHeadlineMessage.returned(true)).toBeTruthy(); - u.isHeadlineMessage.restore(); // unwraps const view = _converse.chatboxviews.get('notify.example.com'); expect(view.model.get('show_avatar')).toBeFalsy(); expect(view.el.querySelector('img.avatar')).toBe(null); @@ -155,9 +147,8 @@ describe("A headlines box", function () { mock.initConverse( ['rosterGroupsFetched', 'chatBoxesFetched'], {}, function (done, _converse) { - const { u, $msg, _ } = converse.env; + const { $msg, _ } = converse.env; _converse.allow_non_roster_messaging = false; - sinon.spy(u, 'isHeadlineMessage'); const stanza = $msg({ 'type': 'headline', 'from': 'andre5114@jabber.snc.ru/Spark', @@ -168,9 +159,6 @@ describe("A headlines box", function () { .c('body').t('Здравствуйте друзья'); _converse.connection._dataRecv(mock.createRequest(stanza)); expect(_.without('controlbox', _converse.chatboxviews.keys()).length).toBe(0); - expect(u.isHeadlineMessage.called).toBeTruthy(); - expect(u.isHeadlineMessage.returned(true)).toBeTruthy(); - u.isHeadlineMessage.restore(); // unwraps done(); })); }); diff --git a/spec/mam.js b/spec/mam.js index 4ff3a6312..39e66192f 100644 --- a/spec/mam.js +++ b/spec/mam.js @@ -294,7 +294,7 @@ describe("Message Archive Management", function () { `); spyOn(view.model, 'getDuplicateMessage').and.callThrough(); spyOn(view.model, 'updateMessage').and.callThrough(); - view.model.queueMessage(stanza); + view.model.handleMAMResult({ 'messages': [stanza] }); await u.waitUntil(() => view.model.getDuplicateMessage.calls.count()); expect(view.model.getDuplicateMessage.calls.count()).toBe(1); const result = view.model.getDuplicateMessage.calls.all()[0].returnValue @@ -338,7 +338,7 @@ describe("Message Archive Management", function () { `); spyOn(view.model, 'getDuplicateMessage').and.callThrough(); - view.model.queueMessage(stanza); + view.model.handleMAMResult({ 'messages': [stanza] }); await u.waitUntil(() => view.model.getDuplicateMessage.calls.count()); expect(view.model.getDuplicateMessage.calls.count()).toBe(1); const result = await view.model.getDuplicateMessage.calls.all()[0].returnValue @@ -368,7 +368,7 @@ describe("Message Archive Management", function () { `); - view.model.queueMessage(stanza); + view.model.handleMAMResult({ 'messages': [stanza] }); await u.waitUntil(() => view.content.querySelectorAll('.chat-msg').length); expect(view.content.querySelectorAll('.chat-msg').length).toBe(1); @@ -388,7 +388,7 @@ describe("Message Archive Management", function () { `); spyOn(view.model, 'getDuplicateMessage').and.callThrough(); - view.model.queueMessage(stanza); + view.model.handleMAMResult({ 'messages': [stanza] }); await u.waitUntil(() => view.model.getDuplicateMessage.calls.count()); expect(view.model.getDuplicateMessage.calls.count()).toBe(1); const result = await view.model.getDuplicateMessage.calls.all()[0].returnValue diff --git a/spec/messages.js b/spec/messages.js index 63ed946a9..adb605aab 100644 --- a/spec/messages.js +++ b/spec/messages.js @@ -512,9 +512,8 @@ describe("A Chat Message", function () { // Ideally we wouldn't have to filter out headline // messages, but Prosody gives them the wrong 'type' :( - sinon.spy(converse.env.log, 'info'); + spyOn(converse.env.log, 'info'); sinon.spy(_converse.api.chatboxes, 'get'); - sinon.spy(u, 'isHeadlineMessage'); const msg = $msg({ from: 'montague.lit', to: _converse.bare_jid, @@ -522,16 +521,12 @@ describe("A Chat Message", function () { id: u.getUniqueId() }).c('body').t("This headline message will not be shown").tree(); await _converse.handleMessageStanza(msg); - expect(converse.env.log.info.calledWith( - "handleMessageStanza: Ignoring incoming headline message from JID: montague.lit" - )).toBeTruthy(); - expect(u.isHeadlineMessage.called).toBeTruthy(); - expect(u.isHeadlineMessage.returned(true)).toBeTruthy(); + expect(converse.env.log.info).toHaveBeenCalledWith( + "handleMessageStanza: Ignoring incoming server message from JID: montague.lit" + ); expect(_converse.api.chatboxes.get.called).toBeFalsy(); // Remove sinon spies - converse.env.log.info.restore(); _converse.api.chatboxes.get.restore(); - u.isHeadlineMessage.restore(); done(); })); @@ -1561,7 +1556,7 @@ describe("A Chat Message", function () { * */ const error_txt = 'Server-to-server connection failed: Connecting failed: connection timeout'; - const sender_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; let fullname = _converse.xmppstatus.get('fullname'); // eslint-disable-line no-unused-vars fullname = _.isEmpty(fullname) ? _converse.bare_jid: fullname; await _converse.api.chats.open(sender_jid) @@ -1757,7 +1752,7 @@ describe("A Chat Message", function () { await mock.waitForRoster(_converse, 'current'); await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length) // Send a message from a different resource - spyOn(converse.env.log, 'info'); + spyOn(converse.env.log, 'error'); spyOn(_converse.api.chatboxes, 'create').and.callThrough(); _converse.filter_by_resource = true; const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; @@ -1770,8 +1765,8 @@ describe("A Chat Message", function () { .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree(); await _converse.handleMessageStanza(msg); - expect(converse.env.log.info).toHaveBeenCalledWith( - "handleMessageStanza: Ignoring incoming message intended for a different resource: romeo@montague.lit/some-other-resource", + expect(converse.env.log.error.calls.all().pop().args[0]).toBe( + "Ignoring incoming message intended for a different resource: romeo@montague.lit/some-other-resource", ); expect(_converse.api.chatboxes.create).not.toHaveBeenCalled(); _converse.filter_by_resource = false; diff --git a/spec/minchats.js b/spec/minchats.js index 953ef4ff8..99203264f 100644 --- a/spec/minchats.js +++ b/spec/minchats.js @@ -156,7 +156,7 @@ describe("The Minimized Chats Widget", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t(message).tree(); - view.model.queueMessage(msg); + view.model.handleMessageStanza(msg); await u.waitUntil(() => view.model.messages.length); expect(u.isVisible(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count'))).toBeTruthy(); expect(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count').textContent).toBe('1'); diff --git a/spec/muc.js b/spec/muc.js index 496403b31..8932542cb 100644 --- a/spec/muc.js +++ b/spec/muc.js @@ -518,8 +518,7 @@ describe("Groupchats", function () { _converse.connection._dataRecv(mock.createRequest(stanza)); const view = _converse.chatboxviews.get('jdev@conference.jabber.org'); await new Promise(resolve => view.model.once('change:subject', resolve)); - - const head_desc = await u.waitUntil(() => view.el.querySelector('.chat-head__desc')); + const head_desc = await u.waitUntil(() => view.el.querySelector('.chat-head__desc'), 1000); expect(head_desc?.textContent.trim()).toBe(text); stanza = u.toStanza( @@ -701,7 +700,7 @@ describe("Groupchats", function () { 'type': 'groupchat' }).c('body').t(message).tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); spyOn(view.model, 'clearMessages').and.callThrough(); await view.model.close(); @@ -732,7 +731,7 @@ describe("Groupchats", function () { 'type': 'groupchat' }).c('body').t(message).tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await u.waitUntil(() => view.el.querySelector('.chat-msg__text a')); view.el.querySelector('.chat-msg__text a').click(); await u.waitUntil(() => _converse.chatboxes.length === 3) @@ -1269,7 +1268,7 @@ describe("Groupchats", function () { 'type': 'groupchat' }).c('body').t('Some message').tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await u.waitUntil(() => sizzle('.chat-msg:last .chat-msg__text', view.content).pop()); let stanza = u.toStanza( @@ -1318,7 +1317,7 @@ describe("Groupchats", function () { 'to': 'romeo@montague.lit', 'type': 'groupchat' }).c('body').t(message).tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await u.waitUntil(() => sizzle('.chat-msg:last .chat-msg__text', view.content).pop()); expect(_.includes(view.el.querySelector('.chat-msg__author').textContent, '**Dyon van de Wege')).toBeTruthy(); expect(view.el.querySelector('.chat-msg__text').textContent.trim()).toBe('is tired'); @@ -1330,7 +1329,7 @@ describe("Groupchats", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t(message).tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 2); expect(sizzle('.chat-msg__author:last', view.el).pop().textContent.includes('**Romeo Montague')).toBeTruthy(); expect(sizzle('.chat-msg__text:last', view.el).pop().textContent.trim()).toBe('is as well'); @@ -2016,7 +2015,7 @@ describe("Groupchats", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t(text); - await view.model.queueMessage(message.nodeTree); + await view.model.handleMessageStanza(message.nodeTree); await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length); expect(view.content.querySelectorAll('.chat-msg').length).toBe(1); expect(view.content.querySelector('.chat-msg__text').textContent.trim()).toBe(text); @@ -2061,7 +2060,7 @@ describe("Groupchats", function () { by="lounge@montague.lit"/> `); - await view.model.queueMessage(stanza); + await view.model.handleMessageStanza(stanza); expect(view.content.querySelectorAll('.chat-msg').length).toBe(1); expect(sizzle('.chat-msg__text:last').pop().textContent.trim()).toBe(text); expect(view.model.messages.length).toBe(1); @@ -2083,7 +2082,7 @@ describe("Groupchats", function () { const promises = []; for (let i=0; i<20; i++) { promises.push( - view.model.queueMessage( + view.model.handleMessageStanza( $msg({ from: 'lounge@montague.lit/someone', to: 'romeo@montague.lit.com', @@ -2096,7 +2095,7 @@ describe("Groupchats", function () { // Give enough time for `markScrolled` to have been called setTimeout(async () => { view.content.scrollTop = 0; - await view.model.queueMessage( + await view.model.handleMessageStanza( $msg({ from: 'lounge@montague.lit/someone', to: 'romeo@montague.lit.com', @@ -4863,8 +4862,7 @@ describe("Groupchats", function () { view.model.set({'minimized': true}); const nick = mock.chatroom_names[0]; - - await view.model.queueMessage($msg({ + await view.model.handleMessageStanza($msg({ from: muc_jid+'/'+nick, id: u.getUniqueId(), to: 'romeo@montague.lit', @@ -4875,7 +4873,7 @@ describe("Groupchats", function () { expect(roomspanel.el.querySelectorAll('.msgs-indicator').length).toBe(1); expect(roomspanel.el.querySelector('.msgs-indicator').textContent.trim()).toBe('1'); - await view.model.queueMessage($msg({ + await view.model.handleMessageStanza($msg({ 'from': muc_jid+'/'+nick, 'id': u.getUniqueId(), 'to': 'romeo@montague.lit', @@ -5027,7 +5025,7 @@ describe("Groupchats", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === 'newguy and nomorenicks are typing'); // state for a different occupant @@ -5037,7 +5035,7 @@ describe("Groupchats", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === 'newguy, nomorenicks and majortom are typing'); // state for a different occupant @@ -5047,7 +5045,7 @@ describe("Groupchats", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === 'newguy, nomorenicks and others are typing'); // Check that new messages appear under the chat state notifications @@ -5057,7 +5055,7 @@ describe("Groupchats", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t('hello world').tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await new Promise(resolve => view.once('messageInserted', resolve)); const messages = view.el.querySelectorAll('.message'); @@ -5146,7 +5144,7 @@ describe("Groupchats", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent); expect(view.el.querySelector('.chat-content__notifications').textContent.trim()).toBe('newguy is typing'); @@ -5157,7 +5155,7 @@ describe("Groupchats", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() == 'newguy and nomorenicks are typing'); @@ -5168,7 +5166,7 @@ describe("Groupchats", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() == 'nomorenicks is typing\nnewguy has stopped typing'); done(); })); diff --git a/spec/muc_messages.js b/spec/muc_messages.js index ad99451b0..a2b7c7c90 100644 --- a/spec/muc_messages.js +++ b/spec/muc_messages.js @@ -104,14 +104,11 @@ describe("A Groupchat Message", function () { `); const view = _converse.api.chatviews.get(muc_jid); spyOn(view.model, 'onMessage').and.callThrough(); - - - await view.model.queueMessage(received_stanza); - spyOn(converse.env.log, 'warn'); + spyOn(converse.env.log, 'error'); _converse.connection._dataRecv(mock.createRequest(received_stanza)); - await u.waitUntil(() => view.model.onMessage.calls.count()); - expect(converse.env.log.warn).toHaveBeenCalledWith( - 'onMessage: Ignoring unencapsulated forwarded groupchat message' + await u.waitUntil(() => view.model.onMessage.calls.count() === 1); + expect(converse.env.log.error).toHaveBeenCalledWith( + `Ignoring unencapsulated forwarded message from ${muc_jid}/mallory` ); expect(view.el.querySelectorAll('.chat-msg').length).toBe(0); expect(view.model.messages.length).toBe(0); @@ -137,7 +134,7 @@ describe("A Groupchat Message", function () { }).c('body').t(message) .c('active', {'xmlns': "http://jabber.org/protocol/chatstates"}) .tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await new Promise(resolve => view.once('messageInserted', resolve)); expect(view.el.querySelector('.chat-msg')).not.toBe(null); done(); @@ -160,7 +157,7 @@ describe("A Groupchat Message", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t(message).tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await new Promise(resolve => view.once('messageInserted', resolve)); expect(u.hasClass('mentioned', view.el.querySelector('.chat-msg'))).toBeTruthy(); done(); @@ -182,7 +179,7 @@ describe("A Groupchat Message", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t('First message').tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1); msg = $msg({ @@ -191,7 +188,7 @@ describe("A Groupchat Message", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t('Another message').tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 2); expect(view.model.messages.length).toBe(2); done(); @@ -237,7 +234,7 @@ describe("A Groupchat Message", function () { `); spyOn(view.model, 'updateMessage'); - await view.model.queueMessage(stanza); + view.model.handleMAMResult({ 'messages': [stanza] }); await u.waitUntil(() => view.model.getDuplicateMessage.calls.count() === 2); result = await view.model.getDuplicateMessage.calls.all()[1].returnValue; expect(result instanceof _converse.Message).toBe(true); @@ -342,11 +339,10 @@ describe("A Groupchat Message", function () { 'type': 'groupchat' }).c('body').t('I am groot').tree(); const view = _converse.api.chatviews.get(muc_jid); - spyOn(converse.env.log, 'warn'); - await view.model.queueMessage(msg); - expect(converse.env.log.warn).toHaveBeenCalledWith( - 'onMessage: Ignoring XEP-0280 "groupchat" message carbon, '+ - 'according to the XEP groupchat messages SHOULD NOT be carbon copied' + spyOn(converse.env.log, 'error'); + await view.model.handleMAMResult({ 'messages': [msg] }); + expect(converse.env.log.error).toHaveBeenCalledWith( + 'Invalid Stanza: MUC messages SHOULD NOT be XEP-0280 carbon copied' ); expect(view.el.querySelectorAll('.chat-msg').length).toBe(0); expect(view.model.messages.length).toBe(0); @@ -367,7 +363,7 @@ describe("A Groupchat Message", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t('I wrote this message!').tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length); expect(view.model.messages.last().occupant.get('affiliation')).toBe('owner'); expect(view.model.messages.last().occupant.get('role')).toBe('moderator'); @@ -393,7 +389,7 @@ describe("A Groupchat Message", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t('Another message!').tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await new Promise(resolve => view.once('messageInserted', resolve)); expect(view.model.messages.last().occupant.get('affiliation')).toBe('member'); expect(view.model.messages.last().occupant.get('role')).toBe('participant'); @@ -430,7 +426,7 @@ describe("A Groupchat Message", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t('Message from someone not in the MUC right now').tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await new Promise(resolve => view.once('messageInserted', resolve)); expect(view.model.messages.last().occupant).toBeUndefined(); // Check that there's a new "add" event handler, for when the occupant appears. @@ -495,7 +491,7 @@ describe("A Groupchat Message", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t('I wrote this message!').tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); expect(view.model.messages.last().get('sender')).toBe('me'); done(); })); @@ -520,7 +516,7 @@ describe("A Groupchat Message", function () { }).tree(); _converse.connection._dataRecv(mock.createRequest(stanza)); const msg_id = u.getUniqueId(); - await view.model.queueMessage($msg({ + await view.model.handleMessageStanza($msg({ 'from': 'lounge@montague.lit/newguy', 'to': _converse.connection.jid, 'type': 'groupchat', @@ -532,7 +528,7 @@ describe("A Groupchat Message", function () { expect(view.el.querySelector('.chat-msg__text').textContent) .toBe('But soft, what light through yonder airlock breaks?'); - await view.model.queueMessage($msg({ + await view.model.handleMessageStanza($msg({ 'from': 'lounge@montague.lit/newguy', 'to': _converse.connection.jid, 'type': 'groupchat', @@ -544,7 +540,7 @@ describe("A Groupchat Message", function () { expect(view.el.querySelectorAll('.chat-msg').length).toBe(1); expect(view.el.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1); - await view.model.queueMessage($msg({ + await view.model.handleMessageStanza($msg({ 'from': 'lounge@montague.lit/newguy', 'to': _converse.connection.jid, 'type': 'groupchat', @@ -641,7 +637,7 @@ describe("A Groupchat Message", function () { expect(u.hasClass('correcting', view.el.querySelector('.chat-msg'))).toBe(false); // Check that messages from other users are skipped - await view.model.queueMessage($msg({ + await view.model.handleMessageStanza($msg({ 'from': muc_jid+'/someone-else', 'id': u.getUniqueId(), 'to': 'romeo@montague.lit', @@ -703,7 +699,7 @@ describe("A Groupchat Message", function () { by="lounge@montague.lit"/> `); - await view.model.queueMessage(stanza); + await view.model.handleMessageStanza(stanza); await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500); expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0); expect(view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length).toBe(1); @@ -776,13 +772,11 @@ describe("A Groupchat Message", function () { `); - spyOn(_converse.api, "trigger").and.callThrough(); - spyOn(stanza_utils, "isReceipt").and.callThrough(); + spyOn(stanza_utils, "parseMUCMessage").and.callThrough(); _converse.connection._dataRecv(mock.createRequest(stanza)); - await u.waitUntil(() => stanza_utils.isReceipt.calls.count() === 1); + await u.waitUntil(() => stanza_utils.parseMUCMessage.calls.count() === 1); expect(view.el.querySelectorAll('.chat-msg').length).toBe(1); expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0); - expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object)); done(); })); @@ -814,9 +808,9 @@ describe("A Groupchat Message", function () { `); const stanza_utils = converse.env.stanza_utils; - spyOn(stanza_utils, "isChatMarker").and.callThrough(); + spyOn(stanza_utils, "getChatMarker").and.callThrough(); _converse.connection._dataRecv(mock.createRequest(stanza)); - await u.waitUntil(() => stanza_utils.isChatMarker.calls.count() === 1); + await u.waitUntil(() => stanza_utils.getChatMarker.calls.count() === 1); expect(view.el.querySelectorAll('.chat-msg').length).toBe(1); expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0); @@ -826,7 +820,7 @@ describe("A Groupchat Message", function () { `); _converse.connection._dataRecv(mock.createRequest(stanza)); - await u.waitUntil(() => stanza_utils.isChatMarker.calls.count() === 2); + await u.waitUntil(() => stanza_utils.getChatMarker.calls.count() === 2); expect(view.el.querySelectorAll('.chat-msg').length).toBe(1); expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0); @@ -837,7 +831,7 @@ describe("A Groupchat Message", function () { `); _converse.connection._dataRecv(mock.createRequest(stanza)); - await u.waitUntil(() => stanza_utils.isChatMarker.calls.count() === 3); + await u.waitUntil(() => stanza_utils.getChatMarker.calls.count() === 3); expect(view.el.querySelectorAll('.chat-msg').length).toBe(1); expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0); @@ -848,8 +842,8 @@ describe("A Groupchat Message", function () { `); _converse.connection._dataRecv(mock.createRequest(stanza)); - await u.waitUntil(() => stanza_utils.isChatMarker.calls.count() === 4); - expect(view.el.querySelectorAll('.chat-msg').length).toBe(2); + await u.waitUntil(() => stanza_utils.getChatMarker.calls.count() === 4); + await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 2); expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0); done(); })); @@ -887,7 +881,7 @@ describe("A Groupchat Message", function () { .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'6', 'end':'10', 'type':'mention', 'uri':'xmpp:z3r0@montague.lit'}).up() .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'11', 'end':'14', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).up() .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'15', 'end':'23', 'type':'mention', 'uri':'xmpp:mr.robot@montague.lit'}).nodeTree; - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); const message = await u.waitUntil(() => view.el.querySelector('.chat-msg__text')); expect(message.classList.length).toEqual(1); expect(message.innerHTML).toBe( @@ -928,7 +922,7 @@ describe("A Groupchat Message", function () { .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'7', 'end':'11', 'type':'mention', 'uri':'xmpp:z3r0@montague.lit'}).up() .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'12', 'end':'15', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).up() .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'16', 'end':'24', 'type':'mention', 'uri':'xmpp:mr.robot@montague.lit'}).nodeTree; - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); const message = await u.waitUntil(() => view.el.querySelector('.chat-msg__text')); expect(message.classList.length).toEqual(1); expect(message.innerHTML).toBe( @@ -972,7 +966,7 @@ describe("A Groupchat Message", function () { type="groupchat"> Boo! `); - await view.model.queueMessage(stanza); + await view.model.handleMessageStanza(stanza); // Run a few unit tests for the parseTextForReferences method let [text, references] = view.model.parseTextForReferences('hello z3r0') diff --git a/spec/notification.js b/spec/notification.js index b17c68b75..6733b61cf 100644 --- a/spec/notification.js +++ b/spec/notification.js @@ -95,10 +95,7 @@ describe("Notifications", function () { await u.waitUntil(() => _converse.chatboxviews.keys().length); const view = _converse.chatboxviews.get('notify.example.com'); await new Promise(resolve => view.once('messageInserted', resolve)); - expect( - _.includes(_converse.chatboxviews.keys(), - 'notify.example.com') - ).toBeTruthy(); + expect(_converse.chatboxviews.keys().includes('notify.example.com')).toBeTruthy(); expect(_converse.showMessageNotification).toHaveBeenCalled(); done(); })); @@ -175,7 +172,7 @@ describe("Notifications", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t(text); - await view.model.queueMessage(message.nodeTree); + await view.model.handleMessageStanza(message.nodeTree); await u.waitUntil(() => _converse.playSoundNotification.calls.count()); expect(_converse.playSoundNotification).toHaveBeenCalled(); @@ -186,7 +183,7 @@ describe("Notifications", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t(text); - await view.model.queueMessage(message.nodeTree); + await view.model.handleMessageStanza(message.nodeTree); expect(_converse.playSoundNotification, 1); _converse.play_sounds = false; @@ -197,7 +194,7 @@ describe("Notifications", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t(text); - await view.model.queueMessage(message.nodeTree); + await view.model.handleMessageStanza(message.nodeTree); expect(_converse.playSoundNotification, 1); _converse.play_sounds = false; done(); diff --git a/spec/retractions.js b/spec/retractions.js index 53d869b9b..35844072c 100644 --- a/spec/retractions.js +++ b/spec/retractions.js @@ -19,7 +19,7 @@ async function sendAndThenRetractMessage (_converse, view) { by="lounge@montague.lit"/> `); - await view.model.queueMessage(reflection_stanza); + await view.model.handleMessageStanza(reflection_stanza); await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500); const retract_button = await u.waitUntil(() => view.el.querySelector('.chat-msg__content .chat-msg__action-retract')); @@ -52,7 +52,7 @@ describe("Message Retractions", function () { `); const view = _converse.api.chatviews.get(muc_jid); - await view.model.queueMessage(received_stanza); + await view.model.handleMessageStanza(received_stanza); await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1); expect(view.model.messages.at(0).get('retracted')).toBeFalsy(); expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy(); @@ -394,7 +394,7 @@ describe("Message Retractions", function () { `); const view = _converse.api.chatviews.get(muc_jid); - await view.model.queueMessage(received_stanza); + await view.model.handleMessageStanza(received_stanza); await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1); expect(view.model.messages.at(0).get('retracted')).toBeFalsy(); expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy(); @@ -440,7 +440,7 @@ describe("Message Retractions", function () { `); - await view.model.queueMessage(received_stanza); + await view.model.handleMessageStanza(received_stanza); await u.waitUntil(() => view.model.messages.length === 1); expect(view.model.messages.at(0).get('retracted')).toBeFalsy(); @@ -498,7 +498,7 @@ describe("Message Retractions", function () { `); - await view.model.queueMessage(retraction); + await view.model.handleMessageStanza(retraction); expect(view.model.messages.length).toBe(1); expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason); @@ -524,7 +524,7 @@ describe("Message Retractions", function () { `); - await view.model.queueMessage(received_stanza); + await view.model.handleMessageStanza(received_stanza); await u.waitUntil(() => view.el.querySelector('.chat-msg__content')); expect(view.el.querySelector('.chat-msg__content .chat-msg__action-retract')).toBe(null); const result = await view.model.canModerateMessages(); @@ -551,7 +551,7 @@ describe("Message Retractions", function () { `); - await view.model.queueMessage(received_stanza); + await view.model.handleMessageStanza(received_stanza); await u.waitUntil(() => view.model.messages.length === 1); expect(view.model.messages.length).toBe(1); @@ -579,7 +579,7 @@ describe("Message Retractions", function () { `); - await view.model.queueMessage(retraction); + await view.model.handleMessageStanza(retraction); await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1); expect(view.model.messages.length).toBe(1); @@ -778,7 +778,7 @@ describe("Message Retractions", function () { by="lounge@montague.lit"/> `); - await view.model.queueMessage(reflection_stanza); + await view.model.handleMessageStanza(reflection_stanza); await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500); expect(view.model.messages.length).toBe(1); expect(view.model.messages.at(0).get('editable')).toBe(true); @@ -794,7 +794,7 @@ describe("Message Retractions", function () { `); - await view.model.queueMessage(retraction); + await view.model.handleMessageStanza(retraction); expect(view.model.messages.length).toBe(1); expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason); @@ -830,7 +830,7 @@ describe("Message Retractions", function () { by="lounge@montague.lit"/> `); - await view.model.queueMessage(reflection_stanza); + await view.model.handleMessageStanza(reflection_stanza); await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500); expect(view.model.messages.length).toBe(1); expect(view.model.messages.at(0).get('editable')).toBe(true); @@ -879,7 +879,7 @@ describe("Message Retractions", function () { `); - await view.model.queueMessage(retraction); + await view.model.handleMessageStanza(retraction); expect(view.model.messages.length).toBe(1); expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); expect(view.model.messages.at(0).get('moderation_reason')).toBe(undefined); diff --git a/spec/roomslist.js b/spec/roomslist.js index 300084212..2457d9e69 100644 --- a/spec/roomslist.js +++ b/spec/roomslist.js @@ -289,7 +289,7 @@ describe("A groupchat shown in the groupchats list", function () { const view = _converse.chatboxviews.get(room_jid); view.model.set({'minimized': true}); const nick = mock.chatroom_names[0]; - await view.model.queueMessage( + await view.model.handleMessageStanza( $msg({ from: room_jid+'/'+nick, id: u.getUniqueId(), @@ -303,7 +303,7 @@ describe("A groupchat shown in the groupchats list", function () { expect(Array.from(room_el.classList).includes('unread-msgs')).toBeTruthy(); // If the user is mentioned, the counter also gets updated - await view.model.queueMessage( + await view.model.handleMessageStanza( $msg({ from: room_jid+'/'+nick, id: u.getUniqueId(), @@ -316,7 +316,7 @@ describe("A groupchat shown in the groupchats list", function () { expect(indicator_el.textContent).toBe('1'); spyOn(view.model, 'incrementUnreadMsgCounter').and.callThrough(); - await view.model.queueMessage( + await view.model.handleMessageStanza( $msg({ from: room_jid+'/'+nick, id: u.getUniqueId(), diff --git a/src/converse-notification.js b/src/converse-notification.js index 125ac2567..6f9dd34a1 100644 --- a/src/converse-notification.js +++ b/src/converse-notification.js @@ -5,6 +5,7 @@ */ import { converse } from "@converse/headless/converse-core"; import log from "@converse/headless/log"; +import st from "@converse/headless/utils/stanza"; const { Strophe, sizzle } = converse.env; const u = converse.env.utils; @@ -79,7 +80,7 @@ converse.plugins.add('converse-notification', { return false; } else if (message.getAttribute('type') === 'groupchat') { return _converse.shouldNotifyOfGroupMessage(message); - } else if (u.isHeadlineMessage(_converse, message)) { + } else if (st.isHeadline(message)) { // We want to show notifications for headline messages. return _converse.isMessageToHiddenChat(message); } diff --git a/src/headless/converse-chat.js b/src/headless/converse-chat.js index 814165a76..d54ed0b8a 100644 --- a/src/headless/converse-chat.js +++ b/src/headless/converse-chat.js @@ -397,6 +397,12 @@ converse.plugins.add('converse-chat', { return this.messages.fetched; }, + async handleErrormessageStanza (stanza) { + if (await this.shouldShowErrorMessage(stanza)) { + this.createMessage(await st.parseMessage(stanza, _converse)); + } + }, + /** * Queue an incoming `chat` message stanza for processing. * @async @@ -404,25 +410,29 @@ converse.plugins.add('converse-chat', { * @method _converse.ChatRoom#queueMessage * @param { XMLElement } stanza - The message stanza. */ - queueMessage (stanza, original_stanza, from_jid) { + queueMessage (attrs) { this.msg_chain = (this.msg_chain || this.messages.fetched); - this.msg_chain = this.msg_chain.then(() => this.onMessage(stanza, original_stanza, from_jid)); + this.msg_chain = this.msg_chain.then(() => this.onMessage(attrs)); return this.msg_chain; }, - async onMessage (stanza, original_stanza, from_jid) { - const attrs = await st.parseMessage(stanza, original_stanza, this, _converse); + async onMessage (attrs) { + attrs = await attrs; + if (u.isErrorObject(attrs)) { + attrs.stanza && log.error(attrs.stanza); + return log.error(attrs.message); + } + // TODO: move to OMEMO + attrs = attrs.encrypted ? await this.decrypt(attrs) : attrs; const message = this.getDuplicateMessage(attrs); if (message) { - this.updateMessage(message, original_stanza); + this.updateMessage(message, attrs); } else if ( - !this.handleReceipt (stanza, original_stanza, from_jid) && - !this.handleChatMarker(stanza, from_jid) + !this.handleReceipt(attrs) && + !this.handleChatMarker(attrs) && + !(await this.handleRetraction(attrs)) ) { - if (await this.handleRetraction(attrs)) { - return; - } - this.setEditable(attrs, attrs.time, stanza); + this.setEditable(attrs, attrs.time); if (attrs['chat_state'] && attrs.sender === 'them') { this.notifications.set('chat_state', attrs.chat_state); @@ -525,17 +535,16 @@ converse.plugins.add('converse-chat', { } }, - getUpdatedMessageAttributes (message, stanza) { // eslint-disable-line no-unused-vars - return { - 'is_archived': st.isArchived(stanza), - } + getUpdatedMessageAttributes (message, attrs) { + // Filter the attrs object, restricting it to only the `is_archived` key. + return (({ is_archived }) => ({ is_archived }))(attrs) }, - updateMessage (message, stanza) { + updateMessage (message, attrs) { // Overridden in converse-muc and converse-mam - const attrs = this.getUpdatedMessageAttributes(message, stanza); + const new_attrs = this.getUpdatedMessageAttributes(message, attrs); if (attrs) { - message.save(attrs); + message.save(new_attrs); } }, @@ -682,10 +691,10 @@ converse.plugins.add('converse-chat', { * message or `undefined` if not applicable. */ handleCorrection (attrs) { - if (!attrs.replaced_id || !attrs.from) { + if (!attrs.replace_id || !attrs.from) { return; } - const message = this.messages.findWhere({'msgid': attrs.replaced_id, 'from': attrs.from}); + const message = this.messages.findWhere({'msgid': attrs.replace_id, 'from': attrs.from}); if (!message) { return; } @@ -797,35 +806,23 @@ converse.plugins.add('converse-chat', { api.send(stanza); }, - handleChatMarker (stanza, from_jid) { - const to_bare_jid = Strophe.getBareJidFromJid(stanza.getAttribute('to')); + handleChatMarker (attrs) { + const to_bare_jid = Strophe.getBareJidFromJid(attrs.to); if (to_bare_jid !== _converse.bare_jid) { return false; } - const markers = sizzle(`[xmlns="${Strophe.NS.MARKERS}"]`, stanza); - if (markers.length === 0) { - return false; - } else if (markers.length > 1) { - log.error('handleChatMarker: Ignoring incoming stanza with multiple message markers'); - log.error(stanza); - return false; - } else { - const marker = markers.pop(); - if (marker.nodeName === 'markable') { - if (this.contact && !u.isMAMMessage(stanza) && !u.isCarbonMessage(stanza)) { - this.sendMarker(from_jid, stanza.getAttribute('id'), 'received'); - } - return false; - } else { - const msgid = marker && marker.getAttribute('id'), - message = msgid && this.messages.findWhere({msgid}), - field_name = `marker_${marker.nodeName}`; - - if (message && !message.get(field_name)) { - message.save({field_name: (new Date()).toISOString()}); - } - return true; + if (attrs.is_markable) { + if (this.contact && !attrs.is_mam && !attrs.is_carbon) { + this.sendMarker(attrs.from, attrs.msgid, 'received'); } + return false; + } else if (attrs.marker_id) { + const message = this.messages.findWhere({'msgid': attrs.marker_id}); + const field_name = `marker_${attrs.marker}`; + if (message && !message.get(field_name)) { + message.save({field_name: (new Date()).toISOString()}); + } + return true; } }, @@ -840,18 +837,12 @@ converse.plugins.add('converse-chat', { api.send(receipt_stanza); }, - handleReceipt (stanza, original_stanza, from_jid) { - const is_me = Strophe.getBareJidFromJid(from_jid) === _converse.bare_jid; - const requests_receipt = sizzle(`request[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).pop() !== undefined; - if (requests_receipt && !is_me && !u.isCarbonMessage(stanza) && !u.isMAMMessage(original_stanza)) { - this.sendReceiptStanza(from_jid, stanza.getAttribute('id')); - } - const to_bare_jid = Strophe.getBareJidFromJid(stanza.getAttribute('to')); - if (to_bare_jid === _converse.bare_jid) { - const receipt = sizzle(`received[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).pop(); - if (receipt) { - const msgid = receipt && receipt.getAttribute('id'), - message = msgid && this.messages.findWhere({msgid}); + handleReceipt (attrs) { + if (attrs.sender === 'them') { + if (attrs.is_receipt_request) { + this.sendReceiptStanza(attrs.from, attrs.msgid); + } else if (attrs.receipt_id) { + const message = this.messages.findWhere({'msgid': attrs.receipt_id}); if (message && !message.get('received')) { message.save({'received': (new Date()).toISOString()}); } @@ -945,8 +936,8 @@ converse.plugins.add('converse-chat', { * @param { Object } attrs An object containing message attributes. * @param { String } send_time - time when the message was sent */ - setEditable (attrs, send_time, stanza) { - if (stanza && u.isHeadlineMessage(_converse, stanza)) { + setEditable (attrs, send_time) { + if (attrs.is_headline) { return; } if (u.isEmptyMessage(attrs) || attrs.sender !== 'me') { @@ -1124,37 +1115,13 @@ converse.plugins.add('converse-chat', { }); - function rejectMessage (stanza, text) { - // Reject an incoming message by replying with an error message of type "cancel". - api.send( - $msg({ - 'to': stanza.getAttribute('from'), - 'type': 'error', - 'id': stanza.getAttribute('id') - }).c('error', {'type': 'cancel'}) - .c('not-allowed', {xmlns:"urn:ietf:params:xml:ns:xmpp-stanzas"}).up() - .c('text', {xmlns:"urn:ietf:params:xml:ns:xmpp-stanzas"}).t(text) - ); - log.warn(`Rejecting message stanza with the following reason: ${text}`); - log.warn(stanza); - } - - async function handleErrorMessage (stanza) { const from_jid = Strophe.getBareJidFromJid(stanza.getAttribute('from')); if (utils.isSameBareJID(from_jid, _converse.bare_jid)) { return; } const chatbox = await api.chatboxes.get(from_jid); - if (!chatbox) { - return; - } - const should_show = await chatbox.shouldShowErrorMessage(stanza); - if (!should_show) { - return; - } - const attrs = await st.parseMessage(stanza, stanza, chatbox, _converse); - await chatbox.createMessage(attrs); + chatbox?.handleErrormessageStanza(stanza); } @@ -1162,77 +1129,30 @@ converse.plugins.add('converse-chat', { * Handler method for all incoming single-user chat "message" stanzas. * @private * @method _converse#handleMessageStanza - * @param { XMLElement } stanza - The incoming message stanza + * @param { MessageAttributes } attrs - The message attributes */ _converse.handleMessageStanza = async function (stanza) { - const original_stanza = stanza; - let to_jid = stanza.getAttribute('to'); - const to_resource = Strophe.getResourceFromJid(to_jid); - - if (api.settings.get('filter_by_resource') && (to_resource && to_resource !== _converse.resource)) { - return log.info(`handleMessageStanza: Ignoring incoming message intended for a different resource: ${to_jid}`); - } else if (utils.isHeadlineMessage(_converse, stanza)) { - // XXX: Prosody sends headline messages with the - // wrong type ('chat'), so we need to filter them out here. - return log.info(`handleMessageStanza: Ignoring incoming headline message from JID: ${stanza.getAttribute('from')}`); + if (st.isServerMessage(stanza)) { + // Prosody sends headline messages with type `chat`, so we need to filter them out here. + const from = stanza.getAttribute('from'); + return log.info(`handleMessageStanza: Ignoring incoming server message from JID: ${from}`); } - - const bare_forward = sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length; - if (bare_forward) { - return rejectMessage( - stanza, - 'Forwarded messages not part of an encapsulating protocol are not supported' - ); + const attrs = await st.parseMessage(stanza, _converse); + if (u.isErrorObject(attrs)) { + attrs.stanza && log.error(attrs.stanza); + return log.error(attrs.message); } - let from_jid = stanza.getAttribute('from') || _converse.bare_jid; - if (u.isCarbonMessage(stanza)) { - if (from_jid === _converse.bare_jid) { - const selector = `[xmlns="${Strophe.NS.CARBONS}"] > forwarded[xmlns="${Strophe.NS.FORWARD}"] > message`; - stanza = sizzle(selector, stanza).pop(); - to_jid = stanza.getAttribute('to'); - from_jid = stanza.getAttribute('from'); - } else { - // Prevent message forging via carbons: https://xmpp.org/extensions/xep-0280.html#security - return rejectMessage(stanza, 'Rejecting carbon from invalid JID'); - } - } - - if (u.isMAMMessage(stanza)) { - if (from_jid === _converse.bare_jid) { - const selector = `[xmlns="${Strophe.NS.MAM}"] > forwarded[xmlns="${Strophe.NS.FORWARD}"] > message`; - stanza = sizzle(selector, stanza).pop(); - to_jid = stanza.getAttribute('to'); - from_jid = stanza.getAttribute('from'); - } else { - return log.warn(`handleMessageStanza: Ignoring alleged MAM message from ${stanza.getAttribute('from')}`); - } - } - - const from_bare_jid = Strophe.getBareJidFromJid(from_jid); - const is_me = from_bare_jid === _converse.bare_jid; - if (is_me && to_jid === null) { - return log.error(`Don't know how to handle message stanza without 'to' attribute. ${stanza.outerHTML}`); - } - const contact_jid = is_me ? Strophe.getBareJidFromJid(to_jid) : from_bare_jid; - const contact = await api.contacts.get(contact_jid); - if (contact === undefined && !api.settings.get("allow_non_roster_messaging")) { - log.error(`Blocking messaging with a JID not in our roster because allow_non_roster_messaging is false.`); - return log.error(stanza); - } - // Get chat box, but only create when the message has something to show to the user - const has_body = sizzle(`body, encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).length > 0; - const roster_nick = contact?.attributes?.nickname; - const chatbox = await api.chats.get(contact_jid, {'nickname': roster_nick}, has_body); - chatbox && await chatbox.queueMessage(stanza, original_stanza, from_jid); + const has_body = !!sizzle(`body, encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).length; + const chatbox = await api.chats.get(attrs.contact_jid, {'nickname': attrs.nick }, has_body); + chatbox && await chatbox.queueMessage(attrs); /** * Triggered when a message stanza is been received and processed. * @event _converse#message * @type { object } - * @property { _converse.ChatBox | _converse.ChatRoom } chatbox * @property { XMLElement } stanza * @example _converse.api.listen.on('message', obj => { ... }); */ - api.trigger('message', {'stanza': original_stanza, 'chatbox': chatbox}); + api.trigger('message', {'stanza': stanza}); } diff --git a/src/headless/converse-headlines.js b/src/headless/converse-headlines.js index 3e8d7f32b..1590df0dc 100644 --- a/src/headless/converse-headlines.js +++ b/src/headless/converse-headlines.js @@ -7,8 +7,6 @@ import { isString } from "lodash"; import { converse } from "@converse/headless/converse-core"; import st from "./utils/stanza"; -const u = converse.env.utils; - converse.plugins.add('converse-headlines', { /* Plugin dependencies are other plugins which might be @@ -83,7 +81,7 @@ converse.plugins.add('converse-headlines', { async function onHeadlineMessage (stanza) { // Handler method for all incoming messages of type "headline". - if (u.isHeadlineMessage(_converse, stanza)) { + if (st.isHeadline(stanza) || st.isServerMessage(stanza)) { const from_jid = stanza.getAttribute('from'); if (from_jid.includes('@') && !_converse.roster.get(from_jid) && @@ -100,7 +98,7 @@ converse.plugins.add('converse-headlines', { 'type': _converse.HEADLINES_TYPE, 'from': from_jid }); - const attrs = await st.parseMessage(stanza, stanza, chatbox, _converse); + const attrs = await st.parseMessage(stanza, _converse); await chatbox.createMessage(attrs); api.trigger('message', {'chatbox': chatbox, 'stanza': stanza}); } @@ -109,10 +107,7 @@ converse.plugins.add('converse-headlines', { /************************ BEGIN Event Handlers ************************/ function registerHeadlineHandler () { - _converse.connection.addHandler(message => { - onHeadlineMessage(message); - return true - }, null, 'message'); + _converse.connection.addHandler(message => (onHeadlineMessage(message) || true), null, 'message'); } api.listen.on('connected', registerHeadlineHandler); api.listen.on('reconnected', registerHeadlineHandler); diff --git a/src/headless/converse-mam.js b/src/headless/converse-mam.js index 6f91bd24e..2324580ed 100644 --- a/src/headless/converse-mam.js +++ b/src/headless/converse-mam.js @@ -11,9 +11,11 @@ import { intersection, pick } from 'lodash' import { converse } from "./converse-core"; import log from "./log"; import sizzle from "sizzle"; +import st from "./utils/stanza"; let _converse; const { Strophe, $iq, dayjs } = converse.env; +const { NS } = Strophe; const u = converse.env.utils; // XEP-0313 Message Archive Management @@ -47,6 +49,26 @@ const MAMEnabledChat = { } }, + async handleMAMResult (result, query, options, page_direction) { + const is_muc = this.get('type') === _converse.CHATROOMS_TYPE; + result.messages = result.messages.map( + s => (is_muc ? st.parseMUCMessage(s, this, _converse) : st.parseMessage(s, _converse)) + ); + + /** + * Synchronous event which allows listeners to first do some + * work based on the MAM result before calling the handlers here. + * @event _converse#MAMResult + */ + await api.trigger('MAMResult', result, query, {'synchronous': true}); + + result.messages.forEach(m => this.queueMessage(m)); + if (result.error) { + result.error.retry = () => this.fetchArchivedMessages(options, page_direction); + this.createMessageFromError(result.error); + } + }, + /** * Fetch XEP-0313 archived messages based on the passed in criteria. * @private @@ -64,55 +86,34 @@ const MAMEnabledChat = { * @param { string } [options.with] - The JID of the entity with * which messages were exchanged. * @param { boolean } [options.groupchat] - True if archive in groupchat. - * @param { boolean } [page] - Whether this function should recursively - * page through the entire result set if a limited number of results - * were returned. + * @param { ('forwards'|'backwards')} [page_direction] - Determines whether this function should + * recursively page through the entire result set if a limited number of results were returned. */ - async fetchArchivedMessages (options={}, page) { + async fetchArchivedMessages (options={}, page_direction) { if (this.disable_mam) { return; } - const is_groupchat = this.get('type') === _converse.CHATROOMS_TYPE; - const mam_jid = is_groupchat ? this.get('jid') : _converse.bare_jid; - if (!(await api.disco.supports(Strophe.NS.MAM, mam_jid))) { + const is_muc = this.get('type') === _converse.CHATROOMS_TYPE; + const mam_jid = is_muc ? this.get('jid') : _converse.bare_jid; + if (!(await api.disco.supports(NS.MAM, mam_jid))) { return; } - const msg_handler = is_groupchat ? s => this.queueMessage(s) : s => _converse.handleMessageStanza(s); - const query = Object.assign({ - 'groupchat': is_groupchat, - 'max': api.settings.get('archived_messages_page_size'), - 'with': this.get('jid'), - }, options); + 'groupchat': is_muc, + 'max': api.settings.get('archived_messages_page_size'), + 'with': this.get('jid'), + }, options); const result = await api.archive.query(query); - /** - * *Hook* which allows plugins to inspect and potentially modify the result of a MAM query - * from {@link MAMEnabledChat.fetchArchivedMessages}. - * @event _converse#MAMResult - */ - api.hook('MAMResult', this, { result, query }); + await this.handleMAMResult(result, query, options, page_direction); - for (const message of result.messages) { - try { - await msg_handler(message); - } catch (e) { - log.error(e); - } - } - - if (result.error) { - result.error.retry = () => this.fetchArchivedMessages(options, page); - this.createMessageFromError(result.error); - } - - if (page && result.rsm) { - if (page === 'forwards') { + if (page_direction && result.rsm) { + if (page_direction === 'forwards') { options = result.rsm.next(api.settings.get('archived_messages_page_size'), options.before); - } else if (page === 'backwards') { + } else if (page_direction === 'backwards') { options = result.rsm.previous(api.settings.get('archived_messages_page_size'), options.after); } - return this.fetchArchivedMessages(options, page); + return this.fetchArchivedMessages(options, page_direction); } else { // TODO: Add a special kind of message which will // render as a link to fetch further messages, either @@ -162,12 +163,12 @@ converse.plugins.add('converse-mam', { * Per JID preferences will be set in chat boxes, so it'll * probbaly be handled elsewhere in any case. */ - const preference = sizzle(`prefs[xmlns="${Strophe.NS.MAM}"]`, iq).pop(); + const preference = sizzle(`prefs[xmlns="${NS.MAM}"]`, iq).pop(); const default_pref = preference.getAttribute('default'); if (default_pref !== api.settings.get('message_archiving')) { const stanza = $iq({'type': 'set'}) .c('prefs', { - 'xmlns':Strophe.NS.MAM, + 'xmlns':NS.MAM, 'default':api.settings.get('message_archiving') }); Array.from(preference.children).forEach(child => stanza.cnode(child).up()); @@ -185,11 +186,11 @@ converse.plugins.add('converse-mam', { function getMAMPrefsFromFeature (feature) { const prefs = feature.get('preferences') || {}; - if (feature.get('var') !== Strophe.NS.MAM || api.settings.get('message_archiving') === undefined) { + if (feature.get('var') !== NS.MAM || api.settings.get('message_archiving') === undefined) { return; } if (prefs['default'] !== api.settings.get('message_archiving')) { - api.sendIQ($iq({'type': 'get'}).c('prefs', {'xmlns': Strophe.NS.MAM})) + api.sendIQ($iq({'type': 'get'}).c('prefs', {'xmlns': NS.MAM})) .then(iq => _converse.onMAMPreferences(iq, feature)) .catch(_converse.onMAMError); } @@ -207,7 +208,7 @@ converse.plugins.add('converse-mam', { } /************************ BEGIN Event Handlers ************************/ - api.listen.on('addClientFeatures', () => api.disco.own.features.add(Strophe.NS.MAM)); + api.listen.on('addClientFeatures', () => api.disco.own.features.add(NS.MAM)); api.listen.on('serviceDiscovered', getMAMPrefsFromFeature); api.listen.on('chatRoomViewInitialized', view => { if (_converse.muc_show_logs_before_join) { @@ -437,18 +438,18 @@ converse.plugins.add('converse-mam', { } const jid = attrs.to || _converse.bare_jid; - const supported = await api.disco.supports(Strophe.NS.MAM, jid); + const supported = await api.disco.supports(NS.MAM, jid); if (!supported) { - log.warn(`Did not fetch MAM archive for ${jid} because it doesn't support ${Strophe.NS.MAM}`); + log.warn(`Did not fetch MAM archive for ${jid} because it doesn't support ${NS.MAM}`); return {'messages': []}; } const queryid = u.getUniqueId(); - const stanza = $iq(attrs).c('query', {'xmlns':Strophe.NS.MAM, 'queryid':queryid}); + const stanza = $iq(attrs).c('query', {'xmlns':NS.MAM, 'queryid':queryid}); if (options) { - stanza.c('x', {'xmlns':Strophe.NS.XFORM, 'type': 'submit'}) + stanza.c('x', {'xmlns':NS.XFORM, 'type': 'submit'}) .c('field', {'var':'FORM_TYPE', 'type': 'hidden'}) - .c('value').t(Strophe.NS.MAM).up().up(); + .c('value').t(NS.MAM).up().up(); if (options['with'] && !options.groupchat) { stanza.c('field', {'var':'with'}).c('value') @@ -474,7 +475,7 @@ converse.plugins.add('converse-mam', { const messages = []; const message_handler = _converse.connection.addHandler(stanza => { - const result = sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, stanza).pop(); + const result = sizzle(`message > result[xmlns="${NS.MAM}"]`, stanza).pop(); if (result === undefined || result.getAttribute('queryid') !== queryid) { return true; } @@ -490,7 +491,7 @@ converse.plugins.add('converse-mam', { } messages.push(stanza); return true; - }, Strophe.NS.MAM); + }, NS.MAM); let error; const iq_result = await api.sendIQ(stanza, api.settings.get('message_archiving_timeout'), false) @@ -508,9 +509,9 @@ converse.plugins.add('converse-mam', { _converse.connection.deleteHandler(message_handler); let rsm; - const fin = iq_result && sizzle(`fin[xmlns="${Strophe.NS.MAM}"]`, iq_result).pop(); + const fin = iq_result && sizzle(`fin[xmlns="${NS.MAM}"]`, iq_result).pop(); if (fin && [null, 'false'].includes(fin.getAttribute('complete'))) { - const set = sizzle(`set[xmlns="${Strophe.NS.RSM}"]`, fin).pop(); + const set = sizzle(`set[xmlns="${NS.RSM}"]`, fin).pop(); if (set) { rsm = new _converse.RSM({'xml': set}); Object.assign(rsm, Object.assign(pick(options, [...MAM_ATTRIBUTES, ..._converse.RSM_ATTRIBUTES]), rsm)); diff --git a/src/headless/converse-muc.js b/src/headless/converse-muc.js index 4b0b4acf6..674a1011a 100644 --- a/src/headless/converse-muc.js +++ b/src/headless/converse-muc.js @@ -608,6 +608,26 @@ converse.plugins.add('converse-muc', { } }, + async handleErrormessageStanza (stanza) { + if (await this.shouldShowErrorMessage(stanza)) { + this.createMessage(await st.parseMUCMessage(stanza, this, _converse)); + } + }, + + async handleMessageStanza (stanza) { + if (st.isArchived(stanza)) { + // MAM messages are handled in converse-mam. + // We shouldn't get MAM messages here because + // they shouldn't have a `type` attribute. + return log.warn(`Received a MAM message with type "groupchat"`); + } + api.trigger('message', {'stanza': stanza}); + this.createInfoMessages(stanza); + this.fetchFeaturesIfConfigurationChanged(stanza); + const attrs = await st.parseMUCMessage(stanza, this, _converse); + return attrs && this.queueMessage(attrs); + }, + registerHandlers () { // Register presence and message handlers for this groupchat const room_jid = this.get('jid'); @@ -618,17 +638,9 @@ converse.plugins.add('converse-muc', { {'ignoreNamespaceFragment': true, 'matchBareFromJid': true} ); - this.message_handler = _converse.connection.addHandler(stanza => { - if (sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, stanza).pop()) { - // MAM messages are handled in converse-mam. - // We shouldn't get MAM messages here because - // they shouldn't have a `type` attribute. - log.warn(`received a mam message with type "chat".`); - return true; - } - this.queueMessage(stanza); - return true; - }, null, 'message', 'groupchat', null, room_jid, + this.message_handler = _converse.connection.addHandler( + stanza => (!!this.handleMessageStanza(stanza) || true), + null, 'message', 'groupchat', null, room_jid, {'matchBareFromJid': true} ); @@ -1650,7 +1662,7 @@ converse.plugins.add('converse-muc', { * @private * @method _converse.ChatRoom#handleSubjectChange * @param { object } attrs - Attributes representing a received - * message, as returned by {@link st.parseMessage} + * message, as returned by {@link st.parseMUCMessage} */ async handleSubjectChange (attrs) { if (isString(attrs.subject) && !attrs.thread && !attrs.message) { @@ -1703,8 +1715,7 @@ converse.plugins.add('converse-muc', { * @param { Object } attrs - The message attributes */ ignorableCSN (attrs) { - const is_csn = u.isOnlyChatStateNotification(attrs); - return is_csn && (attrs.is_delayed || this.isOwnMessage(attrs)); + return attrs.chat_state && !attrs.body && (attrs.is_delayed || this.isOwnMessage(attrs)); }, @@ -1729,21 +1740,17 @@ converse.plugins.add('converse-muc', { }, - getUpdatedMessageAttributes (message, stanza) { + getUpdatedMessageAttributes (message, attrs) { // Overridden in converse-muc and converse-mam - const attrs = _converse.ChatBox.prototype.getUpdatedMessageAttributes.call(this, message, stanza); - if (this.isOwnMessage(message)) { - const stanza_id = sizzle(`stanza-id[xmlns="${Strophe.NS.SID}"]`, stanza).pop(); - const by_jid = stanza_id ? stanza_id.getAttribute('by') : undefined; - if (by_jid) { - const key = `stanza_id ${by_jid}`; - attrs[key] = stanza_id.getAttribute('id'); - } + const new_attrs = _converse.ChatBox.prototype.getUpdatedMessageAttributes.call(this, message, attrs); + if (this.isOwnMessage(attrs)) { + const stanza_id_keys = Object.keys(attrs).filter(k => k.startsWith('stanza_id')); + Object.assign(new_attrs, pick(attrs, stanza_id_keys)); if (!message.get('received')) { - attrs.received = (new Date()).toISOString(); + new_attrs.received = (new Date()).toISOString(); } } - return attrs; + return new_attrs; }, /** @@ -1808,7 +1815,7 @@ converse.plugins.add('converse-muc', { * @private * @method _converse.ChatRoom#findDanglingModeration * @param { object } attrs - Attributes representing a received - * message, as returned by {@link st.parseMessage} + * message, as returned by {@link st.parseMUCMessage} * @returns { _converse.ChatRoomMessage } */ findDanglingModeration (attrs) { @@ -1839,7 +1846,7 @@ converse.plugins.add('converse-muc', { * @private * @method _converse.ChatRoom#handleModeration * @param { object } attrs - Attributes representing a received - * message, as returned by {@link st.parseMessage} + * message, as returned by {@link st.parseMUCMessage} * @returns { Boolean } Returns `true` or `false` depending on * whether a message was moderated or not. */ @@ -1954,47 +1961,25 @@ converse.plugins.add('converse-muc', { * should be called. * @private * @method _converse.ChatRoom#onMessage - * @param { XMLElement } stanza - The message stanza. + * @param { MessageAttributes } attrs - The message attributes */ - async onMessage (stanza) { - if (sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length) { - return log.warn('onMessage: Ignoring unencapsulated forwarded groupchat message'); + async onMessage (attrs) { + if (u.isErrorObject(attrs)) { + attrs.stanza && log.error(attrs.stanza); + return log.error(attrs.message); } - if (u.isCarbonMessage(stanza)) { - return log.warn( - 'onMessage: Ignoring XEP-0280 "groupchat" message carbon, '+ - 'according to the XEP groupchat messages SHOULD NOT be carbon copied' - ); - } - const original_stanza = stanza; - if (u.isMAMMessage(stanza)) { - if (original_stanza.getAttribute('from') === this.get('jid')) { - const selector = `[xmlns="${Strophe.NS.MAM}"] > forwarded[xmlns="${Strophe.NS.FORWARD}"] > message`; - stanza = sizzle(selector, stanza).pop(); - } else { - return log.warn(`onMessage: Ignoring alleged MAM groupchat message from ${stanza.getAttribute('from')}`); - } - } - await this.createInfoMessages(stanza); - this.fetchFeaturesIfConfigurationChanged(stanza); - - const attrs = await st.parseMessage(stanza, original_stanza, this, _converse); + // TODO: move to OMEMO + attrs = attrs.encrypted ? await this.decrypt(attrs) : attrs; const message = this.getDuplicateMessage(attrs); if (message) { - this.updateMessage(message, original_stanza); + return this.updateMessage(message, attrs); + } else if (attrs.is_receipt_request || attrs.is_marker || this.ignorableCSN(attrs)) { + return; } - if (message || - st.isReceipt(stanza) || - st.isChatMarker(stanza) || - this.ignorableCSN(attrs)) { - return api.trigger('message', {'stanza': original_stanza}); - } - if (await this.handleRetraction(attrs) || await this.handleModeration(attrs) || await this.handleSubjectChange(attrs)) { - this.removeNotification(attrs.nick, ['composing', 'paused']); - return api.trigger('message', {'stanza': original_stanza}); + return this.removeNotification(attrs.nick, ['composing', 'paused']); } this.setEditable(attrs, attrs.time); @@ -2006,7 +1991,6 @@ converse.plugins.add('converse-muc', { this.removeNotification(attrs.nick, ['composing', 'paused']); this.incrementUnreadMsgCounter(msg); } - api.trigger('message', {'stanza': original_stanza, 'chatbox': this}); }, handleModifyError(pres) { diff --git a/src/headless/utils/core.js b/src/headless/utils/core.js index 17debc132..e56ca1897 100644 --- a/src/headless/utils/core.js +++ b/src/headless/utils/core.js @@ -42,16 +42,6 @@ u.toStanza = function (string) { return node.firstElementChild; } -u.isMAMMessage = function (stanza) { - return sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, stanza).length > 0; -} - -u.isCarbonMessage = function (stanza) { - const xmlns = Strophe.NS.CARBONS; - return sizzle(`message > received[xmlns="${xmlns}"]`, stanza).length > 0 || - sizzle(`message > sent[xmlns="${xmlns}"]`, stanza).length > 0; -} - u.getLongestSubstring = function (string, candidates) { function reducer (accumulator, current_value) { if (string.startsWith(current_value)) { @@ -142,6 +132,7 @@ u.isEmptyMessage = function (attrs) { !attrs['message']; }; +//TODO: Remove u.isOnlyChatStateNotification = function (msg) { if (msg instanceof Element) { // See XEP-0085 Chat State Notification @@ -174,25 +165,6 @@ u.isChatRoom = function (model) { return model && (model.get('type') === 'chatroom'); } -u.isHeadlineMessage = function (_converse, message) { - const from_jid = message.getAttribute('from'); - if (message.getAttribute('type') === 'headline') { - return true; - } - const chatbox = _converse.chatboxes.get(Strophe.getBareJidFromJid(from_jid)); - if (u.isChatRoom(chatbox)) { - return false; - } - if (message.getAttribute('type') !== 'error' && from_jid && !from_jid.includes('@')) { - // Some servers (I'm looking at you Prosody) don't set the message - // type to "headline" when sending server messages. For now we - // check if an @ signal is included, and if not, we assume it's - // a headline message. - return true; - } - return false; -}; - u.isErrorObject = function (o) { return o instanceof Error; } diff --git a/src/headless/utils/stanza.js b/src/headless/utils/stanza.js index 5a1d2b809..f9168f150 100644 --- a/src/headless/utils/stanza.js +++ b/src/headless/utils/stanza.js @@ -1,42 +1,16 @@ import * as strophe from 'strophe.js/src/core'; -import { propertyOf } from "lodash"; import dayjs from 'dayjs'; -import log from '@converse/headless/log'; import sizzle from 'sizzle'; import u from '@converse/headless/utils/core'; +import log from "../log"; +import { __ } from '@converse/headless/i18n'; +import { api } from "@converse/headless/converse-core"; const Strophe = strophe.default.Strophe; +const $msg = strophe.default.$msg; +const { NS } = Strophe; -function getSenderAttributes (stanza, chatbox, _converse) { - if (u.isChatRoom(chatbox)) { - const from = stanza.getAttribute('from'); - const nick = Strophe.unescapeNode(Strophe.getResourceFromJid(from)); - return { - 'from': from, - 'from_muc': Strophe.getBareJidFromJid(from), - 'nick': nick, - 'sender': nick === chatbox.get('nick') ? 'me': 'them', - 'received': (new Date()).toISOString(), - } - } else { - const from = Strophe.getBareJidFromJid(stanza.getAttribute('from')); - if (from === _converse.bare_jid) { - return { - from, - 'sender': 'me', - 'fullname': _converse.xmppstatus.get('fullname') - } - } else { - return { - from, - 'sender': 'them', - 'fullname': chatbox.get('fullname') - } - } - } -} - function getSpoilerAttributes (stanza) { const spoiler = sizzle(`spoiler[xmlns="${Strophe.NS.SPOILER}"]`, stanza).pop(); return { @@ -59,14 +33,14 @@ function getOutOfBandAttributes (stanza) { function getCorrectionAttributes (stanza, original_stanza) { const el = sizzle(`replace[xmlns="${Strophe.NS.MESSAGE_CORRECT}"]`, stanza).pop(); if (el) { - const replaced_id = el.getAttribute('id'); - const msgid = replaced_id; - if (replaced_id) { + const replace_id = el.getAttribute('id'); + const msgid = replace_id; + if (replace_id) { const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop(); const time = delay ? dayjs(delay.getAttribute('stamp')).toISOString() : (new Date()).toISOString(); return { msgid, - replaced_id, + replace_id, 'edited': time } } @@ -74,64 +48,325 @@ function getCorrectionAttributes (stanza, original_stanza) { return {}; } -function getEncryptionAttributes (stanza, original_stanza, attrs, chatbox, _converse) { - const encrypted = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, original_stanza).pop(); + +function getEncryptionAttributes (stanza, _converse) { + const encrypted = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).pop(); if (!encrypted || !_converse.config.get('trusted')) { - return attrs; + return {}; } const device_id = _converse.omemo_store?.get('device_id'); const key = device_id && sizzle(`key[rid="${device_id}"]`, encrypted).pop(); if (key) { const header = encrypted.querySelector('header'); - attrs['is_encrypted'] = true; - attrs['encrypted'] = { - 'device_id': header.getAttribute('sid'), - 'iv': header.querySelector('iv').textContent, - 'key': key.textContent, - 'payload': encrypted.querySelector('payload')?.textContent || null, - 'prekey': ['true', '1'].includes(key.getAttribute('prekey')) + return { + 'is_encrypted': true, + 'encrypted': { + 'device_id': header.getAttribute('sid'), + 'iv': header.querySelector('iv').textContent, + 'key': key.textContent, + 'payload': encrypted.querySelector('payload')?.textContent || null, + 'prekey': ['true', '1'].includes(key.getAttribute('prekey')) + } + } + } + return {}; +} + + +function isReceiptRequest (stanza, attrs) { + return ( + attrs.sender !== 'me' && + !attrs.is_carbon && + !attrs.is_mam && + sizzle(`request[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).length + ); +} + + +function getReceiptId (stanza) { + const receipt = sizzle(`received[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).pop(); + return receipt?.getAttribute('id'); +} + +/** + * Returns the XEP-0085 chat state contained in a message stanza + * @private + * @param { XMLElement } stanza - The message stanza + */ +function getChatState (stanza) { + return sizzle(` + composing[xmlns="${NS.CHATSTATES}"], + paused[xmlns="${NS.CHATSTATES}"], + inactive[xmlns="${NS.CHATSTATES}"], + active[xmlns="${NS.CHATSTATES}"], + gone[xmlns="${NS.CHATSTATES}"]`, stanza).pop()?.nodeName; +} + +/** + * Determines whether the passed in stanza is a XEP-0280 Carbon + * @private + * @param { XMLElement } stanza - The message stanza + * @returns { Boolean } + */ +function isCarbon (stanza) { + const xmlns = Strophe.NS.CARBONS; + return sizzle(`message > received[xmlns="${xmlns}"]`, stanza).length > 0 || + sizzle(`message > sent[xmlns="${xmlns}"]`, stanza).length > 0; +} + +/** + * Extract the XEP-0359 stanza IDs from the passed in stanza + * and return a map containing them. + * @private + * @param { XMLElement } stanza - The message stanza + * @returns { Object } + */ +function getStanzaIDs (stanza, original_stanza) { + const attrs = {}; + // Store generic stanza ids + const sids = sizzle(`stanza-id[xmlns="${Strophe.NS.SID}"]`, stanza); + const sid_attrs = sids.reduce((acc, s) => { + acc[`stanza_id ${s.getAttribute('by')}`] = s.getAttribute('id'); + return acc; + }, {}); + Object.assign(attrs, sid_attrs); + + // Store the archive id + const result = sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop(); + if (result) { + const by_jid = original_stanza.getAttribute('from'); + if (by_jid) { + attrs[`stanza_id ${by_jid}`] = result.getAttribute('id'); + } else { + attrs[`stanza_id`] = result.getAttribute('id'); + } + } + + // Store the origin id + const origin_id = sizzle(`origin-id[xmlns="${Strophe.NS.SID}"]`, stanza).pop(); + if (origin_id) { + attrs['origin_id'] = origin_id.getAttribute('id'); + } + return attrs; +} + +/** + * @private + * @param { XMLElement } stanza - The message stanza + * @param { XMLElement } original_stanza - The original stanza, that contains the + * message stanza, if it was contained, otherwise it's the message stanza itself. + * @returns { Object } + */ +function getModerationAttributes (stanza) { + const fastening = sizzle(`apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop(); + if (fastening) { + const applies_to_id = fastening.getAttribute('id'); + const moderated = sizzle(`moderated[xmlns="${Strophe.NS.MODERATE}"]`, fastening).pop(); + if (moderated) { + const retracted = sizzle(`retract[xmlns="${Strophe.NS.RETRACT}"]`, moderated).pop(); + if (retracted) { + return { + 'editable': false, + 'moderated': 'retracted', + 'moderated_by': moderated.getAttribute('by'), + 'moderated_id': applies_to_id, + 'moderation_reason': moderated.querySelector('reason')?.textContent + } + } } - // Returns a promise - return chatbox.decrypt(attrs); } else { - return attrs; + const tombstone = sizzle(`> moderated[xmlns="${Strophe.NS.MODERATE}"]`, stanza).pop(); + if (tombstone) { + const retracted = sizzle(`retracted[xmlns="${Strophe.NS.RETRACT}"]`, tombstone).pop(); + if (retracted) { + return { + 'editable': false, + 'is_tombstone': true, + 'moderated_by': tombstone.getAttribute('by'), + 'retracted': tombstone.getAttribute('stamp'), + 'moderation_reason': tombstone.querySelector('reason')?.textContent + + } + } + } + } + return {}; +} + + +/** + * @private + * @param { XMLElement } stanza - The message stanza + * @param { XMLElement } original_stanza - The original stanza, that contains the + * message stanza, if it was contained, otherwise it's the message stanza itself. + * @returns { Object } + */ +function getRetractionAttributes (stanza, original_stanza) { + const fastening = sizzle(`> apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop(); + if (fastening) { + const applies_to_id = fastening.getAttribute('id'); + const retracted = sizzle(`> retract[xmlns="${Strophe.NS.RETRACT}"]`, fastening).pop(); + if (retracted) { + const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop(); + const time = delay ? dayjs(delay.getAttribute('stamp')).toISOString() : (new Date()).toISOString(); + return { + 'editable': false, + 'retracted': time, + 'retracted_id': applies_to_id + } + } + } else { + const tombstone = sizzle(`> retracted[xmlns="${Strophe.NS.RETRACT}"]`, stanza).pop(); + if (tombstone) { + return { + 'editable': false, + 'is_tombstone': true, + 'retracted': tombstone.getAttribute('stamp') + } + } + } + return {}; +} + +function getReferences (stanza) { + const text = stanza.querySelector('body')?.textContent; + return sizzle(`reference[xmlns="${Strophe.NS.REFERENCE}"]`, stanza).map(ref => { + const begin = ref.getAttribute('begin'); + const end = ref.getAttribute('end'); + return { + 'begin': begin, + 'end': end, + 'type': ref.getAttribute('type'), + 'value': text.slice(begin, end), + 'uri': ref.getAttribute('uri') + }; + }); +} + +/** + * Returns the human readable error message contained in an message stanza of type 'error'. + * @private + * @param { XMLElement } stanza - The message stanza + */ +function getErrorMessage (stanza) { + if (stanza.getAttribute('type') === 'error') { + const error = stanza.querySelector('error'); + return error.querySelector('text')?.textContent || + __('Sorry, an error occurred:') + ' ' + error.innerHTML; + } +} + + +function rejectMessage (stanza, text) { + // Reject an incoming message by replying with an error message of type "cancel". + api.send( + $msg({ + 'to': stanza.getAttribute('from'), + 'type': 'error', + 'id': stanza.getAttribute('id') + }).c('error', {'type': 'cancel'}) + .c('not-allowed', {xmlns:"urn:ietf:params:xml:ns:xmpp-stanzas"}).up() + .c('text', {xmlns:"urn:ietf:params:xml:ns:xmpp-stanzas"}).t(text) + ); + log.warn(`Rejecting message stanza with the following reason: ${text}`); + log.warn(stanza); +} + + +/** + * Returns the human readable error message contained in a `groupchat` message stanza of type `error`. + * @private + * @param { XMLElement } stanza - The message stanza + */ +function getMUCErrorMessage (stanza) { + if (stanza.getAttribute('type') === 'error') { + const forbidden = sizzle(`error forbidden[xmlns="${Strophe.NS.STANZAS}"]`, stanza).pop(); + if (forbidden) { + const msg = __("Your message was not delivered because you weren't allowed to send it."); + const text = sizzle(`error text[xmlns="${Strophe.NS.STANZAS}"]`, stanza).pop(); + const server_msg = text ? __('The message from the server is: "%1$s"', text.textContent) : ''; + return server_msg ? `${msg} ${server_msg}` : msg; + } else if (sizzle(`not-acceptable[xmlns="${Strophe.NS.STANZAS}"]`, stanza).length) { + return __("Your message was not delivered because you're not present in the groupchat."); + } + } +} + + +class StanzaParseError extends Error { + constructor (message, stanza) { + super(message, stanza); + this.name = 'StanzaParseError'; + this.stanza = stanza; + } +} + + +function rejectUnencapsulatedForward (stanza) { + const bare_forward = sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length; + if (bare_forward) { + rejectMessage( + stanza, + 'Forwarded messages not part of an encapsulating protocol are not supported' + ); + const from_jid = stanza.getAttribute('from'); + return new StanzaParseError(`Ignoring unencapsulated forwarded message from ${from_jid}`, stanza); } } /** - * The stanza utils object. Contains utility functions related to stanza - * processing. - * @namespace stanza_utils + * The stanza utils object. Contains utility functions related to stanza processing. + * @namespace st */ -const stanza_utils = { +const st = { - isReceipt (stanza) { - return sizzle(`received[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).length > 0; + isHeadline (stanza) { + return stanza.getAttribute('type') === 'headline'; }, - isChatMarker (stanza) { - return sizzle( - `received[xmlns="${Strophe.NS.MARKERS}"], - displayed[xmlns="${Strophe.NS.MARKERS}"], - acknowledged[xmlns="${Strophe.NS.MARKERS}"]`, stanza).length > 0; + isServerMessage (stanza) { + const from_jid = stanza.getAttribute('from'); + if (stanza.getAttribute('type') !== 'error' && from_jid && !from_jid.includes('@')) { + // Some servers (e.g. Prosody) don't set the stanza + // type to "headline" when sending server messages. + // For now we check if an @ signal is included, and if not, + // we assume it's a headline stanza. + return true; + } + return false; }, /** - * Determines whether the passed in stanza represents a XEP-0313 MAM stanza + * Determines whether the passed in stanza is a XEP-0333 Chat Marker * @private - * @method stanza_utils#isArchived + * @method st#getChatMarker + * @param { XMLElement } stanza - The message stanza + * @returns { Boolean } + */ + getChatMarker (stanza) { + // If we receive more than one marker (which shouldn't happen), we take + // the highest level of acknowledgement. + return sizzle(` + acknowledged[xmlns="${Strophe.NS.MARKERS}"], + displayed[xmlns="${Strophe.NS.MARKERS}"], + received[xmlns="${Strophe.NS.MARKERS}"]`, stanza).pop(); + }, + + /** + * Determines whether the passed in stanza is a XEP-0313 MAM stanza + * @private + * @method st#isArchived * @param { XMLElement } stanza - The message stanza * @returns { Boolean } */ isArchived (original_stanza) { - return !!sizzle(`result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop(); + return !!sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop(); }, /** * Returns an object containing all attribute names and values for a particular element. - * @private - * @method stanza_utils#getAttributes + * @method st#getAttributes * @param { XMLElement } stanza * @returns { Object } */ @@ -142,235 +377,319 @@ const stanza_utils = { }, {}); }, - /** - * Extract the XEP-0359 stanza IDs from the passed in stanza - * and return a map containing them. - * @private - * @method stanza_utils#getStanzaIDs - * @param { XMLElement } stanza - The message stanza - * @returns { Object } - */ - getStanzaIDs (stanza, original_stanza) { - const attrs = {}; - // Store generic stanza ids - const sids = sizzle(`stanza-id[xmlns="${Strophe.NS.SID}"]`, stanza); - const sid_attrs = sids.reduce((acc, s) => { - acc[`stanza_id ${s.getAttribute('by')}`] = s.getAttribute('id'); - return acc; - }, {}); - Object.assign(attrs, sid_attrs); - - // Store the archive id - const result = sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop(); - if (result) { - const by_jid = original_stanza.getAttribute('from'); - attrs[`stanza_id ${by_jid}`] = result.getAttribute('id'); - } - - // Store the origin id - const origin_id = sizzle(`origin-id[xmlns="${Strophe.NS.SID}"]`, stanza).pop(); - if (origin_id) { - attrs['origin_id'] = origin_id.getAttribute('id'); - } - return attrs; - }, - - /** @method stanza_utils#getModerationAttributes - * @param { XMLElement } stanza - The message stanza - * @param { XMLElement } original_stanza - The original stanza, that contains the - * message stanza, if it was contained, otherwise it's the message stanza itself. - * @param { _converse.ChatRoom } room - The MUC in which the moderation stanza is received. - * @returns { Object } - */ - getModerationAttributes (stanza, original_stanza, room) { - const fastening = sizzle(`apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop(); - if (fastening) { - const applies_to_id = fastening.getAttribute('id'); - const moderated = sizzle(`moderated[xmlns="${Strophe.NS.MODERATE}"]`, fastening).pop(); - if (moderated) { - const retracted = sizzle(`retract[xmlns="${Strophe.NS.RETRACT}"]`, moderated).pop(); - if (retracted) { - const from = stanza.getAttribute('from'); - if (from !== room.get('jid')) { - log.warn("getModerationAttributes: ignore moderation stanza that's not from the MUC!"); - log.error(original_stanza); - return {}; - } - return { - 'editable': false, - 'moderated': 'retracted', - 'moderated_by': moderated.getAttribute('by'), - 'moderated_id': applies_to_id, - 'moderation_reason': moderated.querySelector('reason')?.textContent - } - } - } - } else { - const tombstone = sizzle(`> moderated[xmlns="${Strophe.NS.MODERATE}"]`, stanza).pop(); - if (tombstone) { - const retracted = sizzle(`retracted[xmlns="${Strophe.NS.RETRACT}"]`, tombstone).pop(); - if (retracted) { - return { - 'editable': false, - 'is_tombstone': true, - 'moderated_by': tombstone.getAttribute('by'), - 'retracted': tombstone.getAttribute('stamp'), - 'moderation_reason': tombstone.querySelector('reason')?.textContent - - } - } - } - } - return {}; - }, - /** - * @method stanza_utils#getRetractionAttributes + * Parses a passed in message stanza and returns an object of attributes. + * @method st#parseMessage * @param { XMLElement } stanza - The message stanza - * @param { XMLElement } original_stanza - The original stanza, that contains the - * message stanza, if it was contained, otherwise it's the message stanza itself. - * @returns { Object } - */ - getRetractionAttributes (stanza, original_stanza) { - const fastening = sizzle(`> apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop(); - if (fastening) { - const applies_to_id = fastening.getAttribute('id'); - const retracted = sizzle(`> retract[xmlns="${Strophe.NS.RETRACT}"]`, fastening).pop(); - if (retracted) { - const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop(); - const time = delay ? dayjs(delay.getAttribute('stamp')).toISOString() : (new Date()).toISOString(); - return { - 'editable': false, - 'retracted': time, - 'retracted_id': applies_to_id - } - } - } else { - const tombstone = sizzle(`> retracted[xmlns="${Strophe.NS.RETRACT}"]`, stanza).pop(); - if (tombstone) { - return { - 'editable': false, - 'is_tombstone': true, - 'retracted': tombstone.getAttribute('stamp') - } - } - } - return {}; - }, - - getReferences (stanza) { - const text = propertyOf(stanza.querySelector('body'))('textContent'); - return sizzle(`reference[xmlns="${Strophe.NS.REFERENCE}"]`, stanza).map(ref => { - const begin = ref.getAttribute('begin'); - const end = ref.getAttribute('end'); - return { - 'begin': begin, - 'end': end, - 'type': ref.getAttribute('type'), - 'value': text.slice(begin, end), - 'uri': ref.getAttribute('uri') - }; - }); - }, - - getErrorMessage (stanza, is_muc, _converse) { - const { __ } = _converse; - if (is_muc) { - const forbidden = sizzle(`error forbidden[xmlns="${Strophe.NS.STANZAS}"]`, stanza).pop(); - if (forbidden) { - const msg = __("Your message was not delivered because you weren't allowed to send it."); - const text = sizzle(`error text[xmlns="${Strophe.NS.STANZAS}"]`, stanza).pop(); - const server_msg = text ? __('The message from the server is: "%1$s"', text.textContent) : ''; - return server_msg ? `${msg} ${server_msg}` : msg; - } else if (sizzle(`not-acceptable[xmlns="${Strophe.NS.STANZAS}"]`, stanza).length) { - return __("Your message was not delivered because you're not present in the groupchat."); - } - } - const error = stanza.querySelector('error'); - return propertyOf(error.querySelector('text'))('textContent') || - __('Sorry, an error occurred:') + ' ' + error.innerHTML; - }, - - /** - * Given a message stanza, return the text contained in its body. - * @private - * @method stanza_utils#getMessageBody - * @param { XMLElement } stanza - * @param { Boolean } is_muc * @param { _converse } _converse + * @returns { (MessageAttributes|Error) } */ - getMessageBody (stanza, is_muc, _converse) { - const type = stanza.getAttribute('type'); - if (type === 'error') { - return stanza_utils.getErrorMessage(stanza, is_muc, _converse); - } else { - const body = stanza.querySelector('body'); - if (body) { - return body.textContent.trim(); + async parseMessage (stanza, _converse) { + const err = rejectUnencapsulatedForward(stanza); + if (err) { + return err; + } + + let to_jid = stanza.getAttribute('to'); + const to_resource = Strophe.getResourceFromJid(to_jid); + if (api.settings.get('filter_by_resource') && (to_resource && to_resource !== _converse.resource)) { + return new StanzaParseError(`Ignoring incoming message intended for a different resource: ${to_jid}`, stanza); + } + + let from_jid = stanza.getAttribute('from') || _converse.bare_jid; + if (isCarbon(stanza)) { + if (from_jid === _converse.bare_jid) { + const selector = `[xmlns="${Strophe.NS.CARBONS}"] > forwarded[xmlns="${Strophe.NS.FORWARD}"] > message`; + stanza = sizzle(selector, stanza).pop(); + to_jid = stanza.getAttribute('to'); + from_jid = stanza.getAttribute('from'); + } else { + // Prevent message forging via carbons: https://xmpp.org/extensions/xep-0280.html#security + rejectMessage(stanza, 'Rejecting carbon from invalid JID'); + return new StanzaParseError(`Rejecting carbon from invalid JID ${to_jid}`, stanza); } } - }, - getChatState (stanza) { - return stanza.getElementsByTagName('composing').length && 'composing' || - stanza.getElementsByTagName('paused').length && 'paused' || - stanza.getElementsByTagName('inactive').length && 'inactive' || - stanza.getElementsByTagName('active').length && 'active' || - stanza.getElementsByTagName('gone').length && 'gone'; + if (st.isArchived(stanza)) { + if (from_jid === _converse.bare_jid) { + const selector = `[xmlns="${Strophe.NS.MAM}"] > forwarded[xmlns="${Strophe.NS.FORWARD}"] > message`; + stanza = sizzle(selector, stanza).pop(); + to_jid = stanza.getAttribute('to'); + from_jid = stanza.getAttribute('from'); + } else { + return new StanzaParseError(`Invalid Stanza: alleged MAM message from ${stanza.getAttribute('from')}`, stanza); + } + } + + const from_bare_jid = Strophe.getBareJidFromJid(from_jid); + const is_me = from_bare_jid === _converse.bare_jid; + if (is_me && to_jid === null) { + return new StanzaParseError( + `Don't know how to handle message stanza without 'to' attribute. ${stanza.outerHTML}`, + stanza + ); + } + + + const is_headline = st.isHeadline(stanza); + const is_server_message = st.isServerMessage(stanza); + let contact, contact_jid; + if (!is_headline && !is_server_message) { + contact_jid = is_me ? Strophe.getBareJidFromJid(to_jid) : from_bare_jid; + contact = await api.contacts.get(contact_jid); + if (contact === undefined && !api.settings.get("allow_non_roster_messaging")) { + log.error(stanza); + return new StanzaParseError( + `Blocking messaging with a JID not in our roster because allow_non_roster_messaging is false.`, + stanza + ); + } + } + /** + * @typedef { Object } MessageAttributes + * The object which {@link st.parseMessage} returns + * @property { ('me'|'them') } sender - Whether the message was sent by the current user or someone else + * @property { Array } references - A list of objects representing XEP-0372 references + * @property { Boolean } editable - Is this message editable via XEP-0308? + * @property { Boolean } is_archived - Is this message from a XEP-0313 MAM archive? + * @property { Boolean } is_carbon - Is this message a XEP-0280 Carbon? + * @property { Boolean } is_delayed - Was delivery of this message was delayed as per XEP-0203? + * @property { Boolean } is_encrypted - Is this message XEP-0384 encrypted? + * @property { Boolean } is_headline - Is this a "headline" message? + * @property { Boolean } is_markable - Can this message be marked with a XEP-0333 chat marker? + * @property { Boolean } is_marker - Is this message a XEP-0333 Chat Marker? + * @property { Boolean } is_only_emojis - Does the message body contain only emojis? + * @property { Boolean } is_receipt_request - Does this message request a XEP-0184 receipt? + * @property { Boolean } is_spoiler - Is this a XEP-0382 spoiler message? + * @property { Boolean } is_tombstone - Is this a XEP-0424 tombstone? + * @property { Object } encrypted - XEP-0384 encryption payload attributes + * @property { String } body - The contents of the tag of the message stanza + * @property { String } chat_state - The XEP-0085 chat state notification contained in this message + * @property { String } contact_jid - The JID of the other person or entity + * @property { String } edit - An ISO8601 string recording the time that the message was edited per XEP-0308 + * @property { String } error - The error message, in case it's an error stanza + * @property { String } from - The sender JID + * @property { String } fullname - The full name of the sender + * @property { String } marker - The XEP-0333 Chat Marker value + * @property { String } marker_id - The `id` attribute of a XEP-0333 chat marker + * @property { String } msgid - The root `id` attribute of the stanza + * @property { String } nick - The roster nickname of the sender + * @property { String } oob_desc - The description of the XEP-0066 out of band data + * @property { String } oob_url - The URL of the XEP-0066 out of band data + * @property { String } origin_id - The XEP-0359 Origin ID + * @property { String } receipt_id - The `id` attribute of a XEP-0184 element + * @property { String } received - An ISO8601 string recording the time that the message was received + * @property { String } replace_id - The `id` attribute of a XEP-0308 element + * @property { String } retracted - An ISO8601 string recording the time that the message was retracted + * @property { String } retracted_id - The `id` attribute of a XEP-424 element + * @property { String } spoiler_hint The XEP-0382 spoiler hint + * @property { String } stanza_id - The XEP-0359 Stanza ID. Note: the key is actualy `stanza_id ${by_jid}` and there can be multiple. + * @property { String } subject - The element value + * @property { String } thread - The element value + * @property { String } time - The time (in ISO8601 format), either given by the XEP-0203 element, or of receipt. + * @property { String } to - The recipient JID + * @property { String } type - The type of message + */ + const original_stanza = stanza; + const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop(); + const marker = st.getChatMarker(stanza); + const now = (new Date()).toISOString(); + let attrs = Object.assign({ + contact_jid, + is_headline, + is_server_message, + 'body': stanza.querySelector('body')?.textContent?.trim(), + 'chat_state': getChatState(stanza), + 'error': getErrorMessage(stanza), + 'from': Strophe.getBareJidFromJid(stanza.getAttribute('from')), + 'is_archived': st.isArchived(original_stanza), + 'is_carbon': isCarbon(original_stanza), + 'is_delayed': !!delay, + 'is_markable': !!sizzle(`markable[xmlns="${Strophe.NS.MARKERS}"]`, stanza).length, + 'is_marker': !!marker, + 'marker_id': marker && marker.getAttribute('id'), + 'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'), + 'nick': contact?.attributes?.nickname, + 'receipt_id': getReceiptId(stanza), + 'received': (new Date()).toISOString(), + 'references': getReferences(stanza), + 'sender': is_me ? 'me' : 'them', + 'subject': stanza.querySelector('subject')?.textContent, + 'thread': stanza.querySelector('thread')?.textContent, + 'time': delay ? dayjs(delay.getAttribute('stamp')).toISOString() : now, + 'to': stanza.getAttribute('to'), + 'type': stanza.getAttribute('type') + }, + getOutOfBandAttributes(stanza), + getSpoilerAttributes(stanza), + getCorrectionAttributes(stanza, original_stanza), + getStanzaIDs(stanza, original_stanza), + getRetractionAttributes(stanza, original_stanza), + getEncryptionAttributes(stanza, _converse) + ); + + if (attrs.is_archived) { + const from = original_stanza.getAttribute('from'); + if (from && contact_jid && from !== contact_jid) { + return new StanzaParseError(`Invalid Stanza: Forged MAM message from ${from}`, stanza); + } + } + attrs = Object.assign({ + 'message': attrs.body || attrs.error, // TODO: Remove and use body and error attributes instead + 'is_only_emojis': attrs.body ? u.isOnlyEmojis(attrs.body) : false, + 'is_receipt_request': isReceiptRequest(stanza, attrs) + }, attrs); + + // We prefer to use one of the XEP-0359 unique and stable stanza IDs + // as the Model id, to avoid duplicates. + attrs['id'] = attrs['origin_id'] || attrs[`stanza_id ${(attrs.from)}`] || u.getUniqueId(); + return attrs; }, /** * Parses a passed in message stanza and returns an object of attributes. - * @private - * @method stanza_utils#parseMessage + * @method st#parseMUCMessage * @param { XMLElement } stanza - The message stanza * @param { XMLElement } original_stanza - The original stanza, that contains the * message stanza, if it was contained, otherwise it's the message stanza itself. - * @param { _converse.ChatBox|_converse.ChatRoom } chatbox + * @param { _converse.ChatRoom } chatbox * @param { _converse } _converse - * @returns { Object } + * @returns { (MUCMessageAttributes|Error) } */ - async parseMessage (stanza, original_stanza, chatbox, _converse) { - const is_muc = u.isChatRoom(chatbox); - let attrs = Object.assign( - stanza_utils.getStanzaIDs(stanza, original_stanza), - stanza_utils.getRetractionAttributes(stanza, original_stanza), - is_muc ? stanza_utils.getModerationAttributes(stanza, original_stanza, chatbox) : {}, - ); - const text = stanza_utils.getMessageBody(stanza, is_muc, _converse) || undefined; + parseMUCMessage (stanza, chatbox, _converse) { + const err = rejectUnencapsulatedForward(stanza); + if (err) { + return err; + } + + const selector = `[xmlns="${NS.MAM}"] > forwarded[xmlns="${NS.FORWARD}"] > message`; + const original_stanza = stanza; + stanza = sizzle(selector, stanza).pop() || stanza; + + if (sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length) { + return new StanzaParseError( + `Invalid Stanza: Forged MAM groupchat message from ${stanza.getAttribute('from')}`, + stanza + ); + } const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop(); - attrs = Object.assign( - { - 'chat_state': stanza_utils.getChatState(stanza), - 'is_archived': stanza_utils.isArchived(original_stanza), + const from = stanza.getAttribute('from'); + const marker = st.getChatMarker(stanza); + const now = (new Date()).toISOString(); + /** + * @typedef { Object } MUCMessageAttributes + * The object which {@link st.parseMUCMessage} returns + * @property { ('me'|'them') } sender - Whether the message was sent by the current user or someone else + * @property { Array } references - A list of objects representing XEP-0372 references + * @property { Boolean } editable - Is this message editable via XEP-0308? + * @property { Boolean } is_archived - Is this message from a XEP-0313 MAM archive? + * @property { Boolean } is_carbon - Is this message a XEP-0280 Carbon? + * @property { Boolean } is_delayed - Was delivery of this message was delayed as per XEP-0203? + * @property { Boolean } is_encrypted - Is this message XEP-0384 encrypted? + * @property { Boolean } is_headline - Is this a "headline" message? + * @property { Boolean } is_markable - Can this message be marked with a XEP-0333 chat marker? + * @property { Boolean } is_marker - Is this message a XEP-0333 Chat Marker? + * @property { Boolean } is_only_emojis - Does the message body contain only emojis? + * @property { Boolean } is_receipt_request - Does this message request a XEP-0184 receipt? + * @property { Boolean } is_spoiler - Is this a XEP-0382 spoiler message? + * @property { Boolean } is_tombstone - Is this a XEP-0424 tombstone? + * @property { Object } encrypted - XEP-0384 encryption payload attributes + * @property { String } body - The contents of the tag of the message stanza + * @property { String } chat_state - The XEP-0085 chat state notification contained in this message + * @property { String } edit - An ISO8601 string recording the time that the message was edited per XEP-0308 + * @property { String } error - The error message, in case it's an error stanza + * @property { String } from - The sender JID + * @property { String } from_muc - The JID of the MUC from which this message was sent + * @property { String } fullname - The full name of the sender + * @property { String } marker - The XEP-0333 Chat Marker value + * @property { String } marker_id - The `id` attribute of a XEP-0333 chat marker + * @property { String } moderated - The type of XEP-0425 moderation (if any) that was applied + * @property { String } moderated_by - The JID of the user that moderated this message + * @property { String } moderated_id - The XEP-0359 Stanza ID of the message that this one moderates + * @property { String } moderation_reason - The reason provided why this message moderates another + * @property { String } msgid - The root `id` attribute of the stanza + * @property { String } nick - The MUC nickname of the sender + * @property { String } oob_desc - The description of the XEP-0066 out of band data + * @property { String } oob_url - The URL of the XEP-0066 out of band data + * @property { String } origin_id - The XEP-0359 Origin ID + * @property { String } receipt_id - The `id` attribute of a XEP-0184 element + * @property { String } received - An ISO8601 string recording the time that the message was received + * @property { String } replace_id - The `id` attribute of a XEP-0308 element + * @property { String } retracted - An ISO8601 string recording the time that the message was retracted + * @property { String } retracted_id - The `id` attribute of a XEP-424 element + * @property { String } spoiler_hint The XEP-0382 spoiler hint + * @property { String } stanza_id - The XEP-0359 Stanza ID. Note: the key is actualy `stanza_id ${by_jid}` and there can be multiple. + * @property { String } subject - The element value + * @property { String } thread - The element value + * @property { String } time - The time (in ISO8601 format), either given by the XEP-0203 element, or of receipt. + * @property { String } to - The recipient JID + * @property { String } type - The type of message + */ + let attrs = Object.assign({ + from, + 'body': stanza.querySelector('body')?.textContent?.trim(), + 'chat_state': getChatState(stanza), + 'error': getMUCErrorMessage(stanza), + 'from_muc': Strophe.getBareJidFromJid(from), + 'is_archived': st.isArchived(original_stanza), + 'is_carbon': isCarbon(original_stanza), 'is_delayed': !!delay, - 'is_only_emojis': text ? u.isOnlyEmojis(text) : false, - 'message': text, + 'is_headline': st.isHeadline(stanza), + 'is_markable': !!sizzle(`markable[xmlns="${Strophe.NS.MARKERS}"]`, stanza).length, + 'is_marker': !!marker, + 'marker_id': marker && marker.getAttribute('id'), 'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'), - 'references': stanza_utils.getReferences(stanza), - 'subject': propertyOf(stanza.querySelector('subject'))('textContent'), - 'thread': propertyOf(stanza.querySelector('thread'))('textContent'), - 'time': delay ? dayjs(delay.getAttribute('stamp')).toISOString() : (new Date()).toISOString(), - 'type': stanza.getAttribute('type') + 'nick': Strophe.unescapeNode(Strophe.getResourceFromJid(from)), + 'receipt_id': getReceiptId(stanza), + 'received': (new Date()).toISOString(), + 'references': getReferences(stanza), + 'subject': stanza.querySelector('subject')?.textContent, + 'thread': stanza.querySelector('thread')?.textContent, + 'time': delay ? dayjs(delay.getAttribute('stamp')).toISOString() : now, + 'to': stanza.getAttribute('to'), + 'type': stanza.getAttribute('type'), }, - attrs, - getSenderAttributes(stanza, chatbox, _converse), getOutOfBandAttributes(stanza), getSpoilerAttributes(stanza), getCorrectionAttributes(stanza, original_stanza), - ) - attrs = await getEncryptionAttributes(stanza, original_stanza, attrs, chatbox, _converse) - // We prefer to use one of the XEP-0359 unique and stable stanza IDs - // as the Model id, to avoid duplicates. + getStanzaIDs(stanza, original_stanza), + getRetractionAttributes(stanza, original_stanza), + getModerationAttributes(stanza), + getEncryptionAttributes(stanza, _converse) + ); + + attrs = Object.assign({ + 'is_only_emojis': attrs.body ? u.isOnlyEmojis(attrs.body) : false, + 'is_receipt_request': isReceiptRequest(stanza, attrs), + 'message': attrs.body || attrs.error, // TODO: Remove and use body and error attributes instead + 'sender': attrs.nick === chatbox.get('nick') ? 'me': 'them', + }, attrs); + + if (attrs.is_archived && original_stanza.getAttribute('from') !== attrs.from_muc) { + return new StanzaParseError( + `Invalid Stanza: Forged MAM message from ${original_stanza.getAttribute('from')}`, + stanza + ); + } else if (attrs.is_archived && original_stanza.getAttribute('from') !== chatbox.get('jid')) { + return new StanzaParseError( + `Invalid Stanza: Forged MAM groupchat message from ${stanza.getAttribute('from')}`, + stanza + ); + } else if (attrs.is_carbon) { + return new StanzaParseError( + "Invalid Stanza: MUC messages SHOULD NOT be XEP-0280 carbon copied", + stanza + ); + } + // We prefer to use one of the XEP-0359 unique and stable stanza IDs as the Model id, to avoid duplicates. attrs['id'] = attrs['origin_id'] || attrs[`stanza_id ${(attrs.from_muc || attrs.from)}`] || u.getUniqueId(); return attrs; }, /** * Parses a passed in MUC presence stanza and returns an object of attributes. - * @private - * @method stanza_utils#parseMUCPresence + * @method st#parseMUCPresence * @param { XMLElement } stanza - The presence stanza * @returns { Object } */ @@ -414,4 +733,4 @@ const stanza_utils = { } } -export default stanza_utils; +export default st; diff --git a/webpack.html b/webpack.html index 07cee0dec..eb7cb2d4b 100644 --- a/webpack.html +++ b/webpack.html @@ -34,7 +34,7 @@ websocket_url: 'ws://chat.example.org:5380/xmpp-websocket', // bosh_service_url: 'http://chat.example.org:5280/http-bind', muc_show_logs_before_join: true, - whitelisted_plugins: ['converse-debug'], + whitelisted_plugins: ['converse-debug', 'converse-batched-probe'], });