diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-21 11:44:51 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-21 11:44:51 +0000 |
commit | 9e3c08db40b8916968b9f30096c7be3f00ce9647 (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /comm/chat/protocols/xmpp | |
parent | Initial commit. (diff) | |
download | thunderbird-upstream.tar.xz thunderbird-upstream.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'comm/chat/protocols/xmpp')
26 files changed, 8305 insertions, 0 deletions
diff --git a/comm/chat/protocols/xmpp/.eslintrc.js b/comm/chat/protocols/xmpp/.eslintrc.js new file mode 100644 index 0000000000..66953c7e25 --- /dev/null +++ b/comm/chat/protocols/xmpp/.eslintrc.js @@ -0,0 +1,12 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +module.exports = { + rules: { + // The following rules will not be enabled currently. + complexity: "off", + }, +}; diff --git a/comm/chat/protocols/xmpp/components.conf b/comm/chat/protocols/xmpp/components.conf new file mode 100644 index 0000000000..83943f1a34 --- /dev/null +++ b/comm/chat/protocols/xmpp/components.conf @@ -0,0 +1,15 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Classes = [ + { + 'cid': '{dde786d1-6f59-43d0-9bc8-b505a757fb30}', + 'contract_ids': ['@mozilla.org/chat/xmpp;1'], + 'esModule': 'resource:///modules/xmpp.sys.mjs', + 'constructor': 'XMPPProtocol', + 'categories': {'im-protocol-plugin': 'prpl-jabber'}, + }, +] diff --git a/comm/chat/protocols/xmpp/icons/prpl-jabber-32.png b/comm/chat/protocols/xmpp/icons/prpl-jabber-32.png Binary files differnew file mode 100644 index 0000000000..98897f75fb --- /dev/null +++ b/comm/chat/protocols/xmpp/icons/prpl-jabber-32.png diff --git a/comm/chat/protocols/xmpp/icons/prpl-jabber-48.png b/comm/chat/protocols/xmpp/icons/prpl-jabber-48.png Binary files differnew file mode 100644 index 0000000000..805820c565 --- /dev/null +++ b/comm/chat/protocols/xmpp/icons/prpl-jabber-48.png diff --git a/comm/chat/protocols/xmpp/icons/prpl-jabber.png b/comm/chat/protocols/xmpp/icons/prpl-jabber.png Binary files differnew file mode 100644 index 0000000000..bb04c6e6df --- /dev/null +++ b/comm/chat/protocols/xmpp/icons/prpl-jabber.png diff --git a/comm/chat/protocols/xmpp/jar.mn b/comm/chat/protocols/xmpp/jar.mn new file mode 100644 index 0000000000..1f7ac54abc --- /dev/null +++ b/comm/chat/protocols/xmpp/jar.mn @@ -0,0 +1,5 @@ +chat.jar: +% skin prpl-jabber classic/1.0 %skin/classic/prpl/xmpp/ + skin/classic/prpl/xmpp/icon32.png (icons/prpl-jabber-32.png) + skin/classic/prpl/xmpp/icon48.png (icons/prpl-jabber-48.png) + skin/classic/prpl/xmpp/icon.png (icons/prpl-jabber.png) diff --git a/comm/chat/protocols/xmpp/lib/README.md b/comm/chat/protocols/xmpp/lib/README.md new file mode 100644 index 0000000000..c813b5d070 --- /dev/null +++ b/comm/chat/protocols/xmpp/lib/README.md @@ -0,0 +1,6 @@ +This directory contains sax-js from https://github.com/isaacs/sax-js. Current version is v1.2.4. + +## Updating sax-js + +1. Download a release version from https://github.com/isaacs/sax-js. +2. Copy `lib/sax.js` from the git repo to `sax/sax.js` in this directory. diff --git a/comm/chat/protocols/xmpp/lib/moz.build b/comm/chat/protocols/xmpp/lib/moz.build new file mode 100644 index 0000000000..b4f1787145 --- /dev/null +++ b/comm/chat/protocols/xmpp/lib/moz.build @@ -0,0 +1,8 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +EXTRA_JS_MODULES.sax += [ + "sax/sax.js", +] diff --git a/comm/chat/protocols/xmpp/lib/sax/LICENSE b/comm/chat/protocols/xmpp/lib/sax/LICENSE new file mode 100644 index 0000000000..ccffa082c9 --- /dev/null +++ b/comm/chat/protocols/xmpp/lib/sax/LICENSE @@ -0,0 +1,41 @@ +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +==== + +`String.fromCodePoint` by Mathias Bynens used according to terms of MIT +License, as follows: + + Copyright Mathias Bynens <https://mathiasbynens.be/> + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/comm/chat/protocols/xmpp/lib/sax/sax.js b/comm/chat/protocols/xmpp/lib/sax/sax.js new file mode 100644 index 0000000000..564d8d4235 --- /dev/null +++ b/comm/chat/protocols/xmpp/lib/sax/sax.js @@ -0,0 +1,1648 @@ +/* This program is made available under an ISC-style license. */ +(function(sax) { + // wrapper for non-node envs + sax.parser = function(strict, opt) { + return new SAXParser(strict, opt); + }; + sax.SAXParser = SAXParser; + sax.SAXStream = SAXStream; + sax.createStream = createStream; + + // When we pass the MAX_BUFFER_LENGTH position, start checking for buffer overruns. + // When we check, schedule the next check for MAX_BUFFER_LENGTH - (max(buffer lengths)), + // since that's the earliest that a buffer overrun could occur. This way, checks are + // as rare as required, but as often as necessary to ensure never crossing this bound. + // Furthermore, buffers are only tested at most once per write(), so passing a very + // large string into write() might have undesirable effects, but this is manageable by + // the caller, so it is assumed to be safe. Thus, a call to write() may, in the extreme + // edge case, result in creating at most one complete copy of the string passed in. + // Set to Infinity to have unlimited buffers. + sax.MAX_BUFFER_LENGTH = 64 * 1024; + + var buffers = [ + "comment", + "sgmlDecl", + "textNode", + "tagName", + "doctype", + "procInstName", + "procInstBody", + "entity", + "attribName", + "attribValue", + "cdata", + "script", + ]; + + sax.EVENTS = [ + "text", + "processinginstruction", + "sgmldeclaration", + "doctype", + "comment", + "opentagstart", + "attribute", + "opentag", + "closetag", + "opencdata", + "cdata", + "closecdata", + "error", + "end", + "ready", + "script", + "opennamespace", + "closenamespace", + ]; + + function SAXParser(strict, opt) { + if (!(this instanceof SAXParser)) { + return new SAXParser(strict, opt); + } + + var parser = this; + clearBuffers(parser); + parser.q = parser.c = ""; + parser.bufferCheckPosition = sax.MAX_BUFFER_LENGTH; + parser.opt = opt || {}; + parser.opt.lowercase = parser.opt.lowercase || parser.opt.lowercasetags; + parser.looseCase = parser.opt.lowercase ? "toLowerCase" : "toUpperCase"; + parser.tags = []; + parser.closed = parser.closedRoot = parser.sawRoot = false; + parser.tag = parser.error = null; + parser.strict = !!strict; + parser.noscript = !!(strict || parser.opt.noscript); + parser.state = S.BEGIN; + parser.strictEntities = parser.opt.strictEntities; + parser.ENTITIES = parser.strictEntities + ? Object.create(sax.XML_ENTITIES) + : Object.create(sax.ENTITIES); + parser.attribList = []; + + // namespaces form a prototype chain. + // it always points at the current tag, + // which protos to its parent tag. + if (parser.opt.xmlns) { + parser.ns = Object.create(rootNS); + } + + // mostly just for error reporting + parser.trackPosition = parser.opt.position !== false; + if (parser.trackPosition) { + parser.position = parser.line = parser.column = 0; + } + emit(parser, "onready"); + } + + if (!Object.create) { + Object.create = function(o) { + function F() {} + F.prototype = o; + var newf = new F(); + return newf; + }; + } + + if (!Object.keys) { + Object.keys = function(o) { + var a = []; + for (var i in o) { + if (o.hasOwnProperty(i)) { + a.push(i); + } + } + return a; + }; + } + + function checkBufferLength(parser) { + var maxAllowed = Math.max(sax.MAX_BUFFER_LENGTH, 10); + var maxActual = 0; + for (var i = 0, l = buffers.length; i < l; i++) { + var len = parser[buffers[i]].length; + if (len > maxAllowed) { + // Text/cdata nodes can get big, and since they're buffered, + // we can get here under normal conditions. + // Avoid issues by emitting the text node now, + // so at least it won't get any bigger. + switch (buffers[i]) { + case "textNode": + closeText(parser); + break; + + case "cdata": + emitNode(parser, "oncdata", parser.cdata); + parser.cdata = ""; + break; + + case "script": + emitNode(parser, "onscript", parser.script); + parser.script = ""; + break; + + default: + error(parser, "Max buffer length exceeded: " + buffers[i]); + } + } + maxActual = Math.max(maxActual, len); + } + // schedule the next check for the earliest possible buffer overrun. + var m = sax.MAX_BUFFER_LENGTH - maxActual; + parser.bufferCheckPosition = m + parser.position; + } + + function clearBuffers(parser) { + for (var i = 0, l = buffers.length; i < l; i++) { + parser[buffers[i]] = ""; + } + } + + function flushBuffers(parser) { + closeText(parser); + if (parser.cdata !== "") { + emitNode(parser, "oncdata", parser.cdata); + parser.cdata = ""; + } + if (parser.script !== "") { + emitNode(parser, "onscript", parser.script); + parser.script = ""; + } + } + + SAXParser.prototype = { + end() { + end(this); + }, + write, + resume() { + this.error = null; + return this; + }, + close() { + return this.write(null); + }, + flush() { + flushBuffers(this); + }, + }; + + var Stream; + try { + Stream = require("stream").Stream; + } catch (ex) { + Stream = function() {}; + } + + var streamWraps = sax.EVENTS.filter(function(ev) { + return ev !== "error" && ev !== "end"; + }); + + function createStream(strict, opt) { + return new SAXStream(strict, opt); + } + + function SAXStream(strict, opt) { + if (!(this instanceof SAXStream)) { + return new SAXStream(strict, opt); + } + + Stream.apply(this); + + this._parser = new SAXParser(strict, opt); + this.writable = true; + this.readable = true; + + var me = this; + + this._parser.onend = function() { + me.emit("end"); + }; + + this._parser.onerror = function(er) { + me.emit("error", er); + + // if didn't throw, then means error was handled. + // go ahead and clear error, so we can write again. + me._parser.error = null; + }; + + this._decoder = null; + + streamWraps.forEach(function(ev) { + Object.defineProperty(me, "on" + ev, { + get() { + return me._parser["on" + ev]; + }, + set(h) { + if (!h) { + me.removeAllListeners(ev); + me._parser["on" + ev] = h; + return h; + } + me.on(ev, h); + }, + enumerable: true, + configurable: false, + }); + }); + } + + SAXStream.prototype = Object.create(Stream.prototype, { + constructor: { + value: SAXStream, + }, + }); + + SAXStream.prototype.write = function(data) { + if ( + typeof Buffer === "function" && + typeof Buffer.isBuffer === "function" && + Buffer.isBuffer(data) + ) { + if (!this._decoder) { + var SD = require("string_decoder").StringDecoder; + this._decoder = new SD("utf8"); + } + data = this._decoder.write(data); + } + + this._parser.write(data.toString()); + this.emit("data", data); + return true; + }; + + SAXStream.prototype.end = function(chunk) { + if (chunk && chunk.length) { + this.write(chunk); + } + this._parser.end(); + return true; + }; + + SAXStream.prototype.on = function(ev, handler) { + var me = this; + if (!me._parser["on" + ev] && streamWraps.indexOf(ev) !== -1) { + me._parser["on" + ev] = function() { + var args = + arguments.length === 1 + ? [arguments[0]] + : Array.apply(null, arguments); + args.splice(0, 0, ev); + me.emit.apply(me, args); + }; + } + + return Stream.prototype.on.call(me, ev, handler); + }; + + // this really needs to be replaced with character classes. + // XML allows all manner of ridiculous numbers and digits. + var CDATA = "[CDATA["; + var DOCTYPE = "DOCTYPE"; + var XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace"; + var XMLNS_NAMESPACE = "http://www.w3.org/2000/xmlns/"; + var rootNS = { xml: XML_NAMESPACE, xmlns: XMLNS_NAMESPACE }; + + // http://www.w3.org/TR/REC-xml/#NT-NameStartChar + // This implementation works on strings, a single character at a time + // as such, it cannot ever support astral-plane characters (10000-EFFFF) + // without a significant breaking change to either this parser, or the + // JavaScript language. Implementation of an emoji-capable xml parser + // is left as an exercise for the reader. + var nameStart = /[:_A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD]/; + + var nameBody = /[:_A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\u00B7\u0300-\u036F\u203F-\u2040.\d-]/; + + var entityStart = /[#:_A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD]/; + var entityBody = /[#:_A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\u00B7\u0300-\u036F\u203F-\u2040.\d-]/; + + function isWhitespace(c) { + return c === " " || c === "\n" || c === "\r" || c === "\t"; + } + + function isQuote(c) { + return c === '"' || c === "'"; + } + + function isAttribEnd(c) { + return c === ">" || isWhitespace(c); + } + + function isMatch(regex, c) { + return regex.test(c); + } + + function notMatch(regex, c) { + return !isMatch(regex, c); + } + + var S = 0; + sax.STATE = { + BEGIN: S++, // leading byte order mark or whitespace + BEGIN_WHITESPACE: S++, // leading whitespace + TEXT: S++, // general stuff + TEXT_ENTITY: S++, // & and such. + OPEN_WAKA: S++, // < + SGML_DECL: S++, // <!BLARG + SGML_DECL_QUOTED: S++, // <!BLARG foo "bar + DOCTYPE: S++, // <!DOCTYPE + DOCTYPE_QUOTED: S++, // <!DOCTYPE "//blah + DOCTYPE_DTD: S++, // <!DOCTYPE "//blah" [ ... + DOCTYPE_DTD_QUOTED: S++, // <!DOCTYPE "//blah" [ "foo + COMMENT_STARTING: S++, // <!- + COMMENT: S++, // <!-- + COMMENT_ENDING: S++, // <!-- blah - + COMMENT_ENDED: S++, // <!-- blah -- + CDATA: S++, // <![CDATA[ something + CDATA_ENDING: S++, // ] + CDATA_ENDING_2: S++, // ]] + PROC_INST: S++, // <?hi + PROC_INST_BODY: S++, // <?hi there + PROC_INST_ENDING: S++, // <?hi "there" ? + OPEN_TAG: S++, // <strong + OPEN_TAG_SLASH: S++, // <strong / + ATTRIB: S++, // <a + ATTRIB_NAME: S++, // <a foo + ATTRIB_NAME_SAW_WHITE: S++, // <a foo _ + ATTRIB_VALUE: S++, // <a foo= + ATTRIB_VALUE_QUOTED: S++, // <a foo="bar + ATTRIB_VALUE_CLOSED: S++, // <a foo="bar" + ATTRIB_VALUE_UNQUOTED: S++, // <a foo=bar + ATTRIB_VALUE_ENTITY_Q: S++, // <foo bar=""" + ATTRIB_VALUE_ENTITY_U: S++, // <foo bar=" + CLOSE_TAG: S++, // </a + CLOSE_TAG_SAW_WHITE: S++, // </a > + SCRIPT: S++, // <script> ... + SCRIPT_ENDING: S++, // <script> ... < + }; + + sax.XML_ENTITIES = { + amp: "&", + gt: ">", + lt: "<", + quot: '"', + apos: "'", + }; + + sax.ENTITIES = { + amp: "&", + gt: ">", + lt: "<", + quot: '"', + apos: "'", + AElig: 198, + Aacute: 193, + Acirc: 194, + Agrave: 192, + Aring: 197, + Atilde: 195, + Auml: 196, + Ccedil: 199, + ETH: 208, + Eacute: 201, + Ecirc: 202, + Egrave: 200, + Euml: 203, + Iacute: 205, + Icirc: 206, + Igrave: 204, + Iuml: 207, + Ntilde: 209, + Oacute: 211, + Ocirc: 212, + Ograve: 210, + Oslash: 216, + Otilde: 213, + Ouml: 214, + THORN: 222, + Uacute: 218, + Ucirc: 219, + Ugrave: 217, + Uuml: 220, + Yacute: 221, + aacute: 225, + acirc: 226, + aelig: 230, + agrave: 224, + aring: 229, + atilde: 227, + auml: 228, + ccedil: 231, + eacute: 233, + ecirc: 234, + egrave: 232, + eth: 240, + euml: 235, + iacute: 237, + icirc: 238, + igrave: 236, + iuml: 239, + ntilde: 241, + oacute: 243, + ocirc: 244, + ograve: 242, + oslash: 248, + otilde: 245, + ouml: 246, + szlig: 223, + thorn: 254, + uacute: 250, + ucirc: 251, + ugrave: 249, + uuml: 252, + yacute: 253, + yuml: 255, + copy: 169, + reg: 174, + nbsp: 160, + iexcl: 161, + cent: 162, + pound: 163, + curren: 164, + yen: 165, + brvbar: 166, + sect: 167, + uml: 168, + ordf: 170, + laquo: 171, + not: 172, + shy: 173, + macr: 175, + deg: 176, + plusmn: 177, + sup1: 185, + sup2: 178, + sup3: 179, + acute: 180, + micro: 181, + para: 182, + middot: 183, + cedil: 184, + ordm: 186, + raquo: 187, + frac14: 188, + frac12: 189, + frac34: 190, + iquest: 191, + times: 215, + divide: 247, + OElig: 338, + oelig: 339, + Scaron: 352, + scaron: 353, + Yuml: 376, + fnof: 402, + circ: 710, + tilde: 732, + Alpha: 913, + Beta: 914, + Gamma: 915, + Delta: 916, + Epsilon: 917, + Zeta: 918, + Eta: 919, + Theta: 920, + Iota: 921, + Kappa: 922, + Lambda: 923, + Mu: 924, + Nu: 925, + Xi: 926, + Omicron: 927, + Pi: 928, + Rho: 929, + Sigma: 931, + Tau: 932, + Upsilon: 933, + Phi: 934, + Chi: 935, + Psi: 936, + Omega: 937, + alpha: 945, + beta: 946, + gamma: 947, + delta: 948, + epsilon: 949, + zeta: 950, + eta: 951, + theta: 952, + iota: 953, + kappa: 954, + lambda: 955, + mu: 956, + nu: 957, + xi: 958, + omicron: 959, + pi: 960, + rho: 961, + sigmaf: 962, + sigma: 963, + tau: 964, + upsilon: 965, + phi: 966, + chi: 967, + psi: 968, + omega: 969, + thetasym: 977, + upsih: 978, + piv: 982, + ensp: 8194, + emsp: 8195, + thinsp: 8201, + zwnj: 8204, + zwj: 8205, + lrm: 8206, + rlm: 8207, + ndash: 8211, + mdash: 8212, + lsquo: 8216, + rsquo: 8217, + sbquo: 8218, + ldquo: 8220, + rdquo: 8221, + bdquo: 8222, + dagger: 8224, + Dagger: 8225, + bull: 8226, + hellip: 8230, + permil: 8240, + prime: 8242, + Prime: 8243, + lsaquo: 8249, + rsaquo: 8250, + oline: 8254, + frasl: 8260, + euro: 8364, + image: 8465, + weierp: 8472, + real: 8476, + trade: 8482, + alefsym: 8501, + larr: 8592, + uarr: 8593, + rarr: 8594, + darr: 8595, + harr: 8596, + crarr: 8629, + lArr: 8656, + uArr: 8657, + rArr: 8658, + dArr: 8659, + hArr: 8660, + forall: 8704, + part: 8706, + exist: 8707, + empty: 8709, + nabla: 8711, + isin: 8712, + notin: 8713, + ni: 8715, + prod: 8719, + sum: 8721, + minus: 8722, + lowast: 8727, + radic: 8730, + prop: 8733, + infin: 8734, + ang: 8736, + and: 8743, + or: 8744, + cap: 8745, + cup: 8746, + int: 8747, + there4: 8756, + sim: 8764, + cong: 8773, + asymp: 8776, + ne: 8800, + equiv: 8801, + le: 8804, + ge: 8805, + sub: 8834, + sup: 8835, + nsub: 8836, + sube: 8838, + supe: 8839, + oplus: 8853, + otimes: 8855, + perp: 8869, + sdot: 8901, + lceil: 8968, + rceil: 8969, + lfloor: 8970, + rfloor: 8971, + lang: 9001, + rang: 9002, + loz: 9674, + spades: 9824, + clubs: 9827, + hearts: 9829, + diams: 9830, + }; + + Object.keys(sax.ENTITIES).forEach(function(key) { + var e = sax.ENTITIES[key]; + var s = typeof e === "number" ? String.fromCharCode(e) : e; + sax.ENTITIES[key] = s; + }); + + for (var s in sax.STATE) { + sax.STATE[sax.STATE[s]] = s; + } + + // shorthand + S = sax.STATE; + + function emit(parser, event, data) { + parser[event] && parser[event](data); + } + + function emitNode(parser, nodeType, data) { + if (parser.textNode) { + closeText(parser); + } + emit(parser, nodeType, data); + } + + function closeText(parser) { + parser.textNode = textopts(parser.opt, parser.textNode); + if (parser.textNode) { + emit(parser, "ontext", parser.textNode); + } + parser.textNode = ""; + } + + function textopts(opt, text) { + if (opt.trim) { + text = text.trim(); + } + if (opt.normalize) { + text = text.replace(/\s+/g, " "); + } + return text; + } + + function error(parser, er) { + closeText(parser); + if (parser.trackPosition) { + er += + "\nLine: " + + parser.line + + "\nColumn: " + + parser.column + + "\nChar: " + + parser.c; + } + er = new Error(er); + parser.error = er; + emit(parser, "onerror", er); + return parser; + } + + function end(parser) { + if (parser.sawRoot && !parser.closedRoot) { + strictFail(parser, "Unclosed root tag"); + } + if ( + parser.state !== S.BEGIN && + parser.state !== S.BEGIN_WHITESPACE && + parser.state !== S.TEXT + ) { + error(parser, "Unexpected end"); + } + closeText(parser); + parser.c = ""; + parser.closed = true; + emit(parser, "onend"); + SAXParser.call(parser, parser.strict, parser.opt); + return parser; + } + + function strictFail(parser, message) { + if (typeof parser !== "object" || !(parser instanceof SAXParser)) { + throw new Error("bad call to strictFail"); + } + if (parser.strict) { + error(parser, message); + } + } + + function newTag(parser) { + if (!parser.strict) { + parser.tagName = parser.tagName[parser.looseCase](); + } + var parent = parser.tags[parser.tags.length - 1] || parser; + var tag = (parser.tag = { name: parser.tagName, attributes: {} }); + + // will be overridden if tag contails an xmlns="foo" or xmlns:foo="bar" + if (parser.opt.xmlns) { + tag.ns = parent.ns; + } + parser.attribList.length = 0; + emitNode(parser, "onopentagstart", tag); + } + + function qname(name, attribute) { + var i = name.indexOf(":"); + var qualName = i < 0 ? ["", name] : name.split(":"); + var prefix = qualName[0]; + var local = qualName[1]; + + // <x "xmlns"="http://foo"> + if (attribute && name === "xmlns") { + prefix = "xmlns"; + local = ""; + } + + return { prefix, local }; + } + + function attrib(parser) { + if (!parser.strict) { + parser.attribName = parser.attribName[parser.looseCase](); + } + + if ( + parser.attribList.indexOf(parser.attribName) !== -1 || + parser.tag.attributes.hasOwnProperty(parser.attribName) + ) { + parser.attribName = parser.attribValue = ""; + return; + } + + if (parser.opt.xmlns) { + var qn = qname(parser.attribName, true); + var prefix = qn.prefix; + var local = qn.local; + + if (prefix === "xmlns") { + // namespace binding attribute. push the binding into scope + if (local === "xml" && parser.attribValue !== XML_NAMESPACE) { + strictFail( + parser, + "xml: prefix must be bound to " + + XML_NAMESPACE + + "\n" + + "Actual: " + + parser.attribValue + ); + } else if ( + local === "xmlns" && + parser.attribValue !== XMLNS_NAMESPACE + ) { + strictFail( + parser, + "xmlns: prefix must be bound to " + + XMLNS_NAMESPACE + + "\n" + + "Actual: " + + parser.attribValue + ); + } else { + var tag = parser.tag; + var parent = parser.tags[parser.tags.length - 1] || parser; + if (tag.ns === parent.ns) { + tag.ns = Object.create(parent.ns); + } + tag.ns[local] = parser.attribValue; + } + } + + // defer onattribute events until all attributes have been seen + // so any new bindings can take effect. preserve attribute order + // so deferred events can be emitted in document order + parser.attribList.push([parser.attribName, parser.attribValue]); + } else { + // in non-xmlns mode, we can emit the event right away + parser.tag.attributes[parser.attribName] = parser.attribValue; + emitNode(parser, "onattribute", { + name: parser.attribName, + value: parser.attribValue, + }); + } + + parser.attribName = parser.attribValue = ""; + } + + function openTag(parser, selfClosing) { + if (parser.opt.xmlns) { + // emit namespace binding events + var tag = parser.tag; + + // add namespace info to tag + var qn = qname(parser.tagName); + tag.prefix = qn.prefix; + tag.local = qn.local; + tag.uri = tag.ns[qn.prefix] || ""; + + if (tag.prefix && !tag.uri) { + strictFail( + parser, + "Unbound namespace prefix: " + JSON.stringify(parser.tagName) + ); + tag.uri = qn.prefix; + } + + var parent = parser.tags[parser.tags.length - 1] || parser; + if (tag.ns && parent.ns !== tag.ns) { + Object.keys(tag.ns).forEach(function(p) { + emitNode(parser, "onopennamespace", { + prefix: p, + uri: tag.ns[p], + }); + }); + } + + // handle deferred onattribute events + // Note: do not apply default ns to attributes: + // http://www.w3.org/TR/REC-xml-names/#defaulting + for (var i = 0, l = parser.attribList.length; i < l; i++) { + var nv = parser.attribList[i]; + var name = nv[0]; + var value = nv[1]; + var qualName = qname(name, true); + var prefix = qualName.prefix; + var local = qualName.local; + var uri = prefix === "" ? "" : tag.ns[prefix] || ""; + var a = { + name, + value, + prefix, + local, + uri, + }; + + // if there's any attributes with an undefined namespace, + // then fail on them now. + if (prefix && prefix !== "xmlns" && !uri) { + strictFail( + parser, + "Unbound namespace prefix: " + JSON.stringify(prefix) + ); + a.uri = prefix; + } + parser.tag.attributes[name] = a; + emitNode(parser, "onattribute", a); + } + parser.attribList.length = 0; + } + + parser.tag.isSelfClosing = !!selfClosing; + + // process the tag + parser.sawRoot = true; + parser.tags.push(parser.tag); + emitNode(parser, "onopentag", parser.tag); + if (!selfClosing) { + // special case for <script> in non-strict mode. + if (!parser.noscript && parser.tagName.toLowerCase() === "script") { + parser.state = S.SCRIPT; + } else { + parser.state = S.TEXT; + } + parser.tag = null; + parser.tagName = ""; + } + parser.attribName = parser.attribValue = ""; + parser.attribList.length = 0; + } + + function closeTag(parser) { + if (!parser.tagName) { + strictFail(parser, "Weird empty close tag."); + parser.textNode += "</>"; + parser.state = S.TEXT; + return; + } + + if (parser.script) { + if (parser.tagName !== "script") { + parser.script += "</" + parser.tagName + ">"; + parser.tagName = ""; + parser.state = S.SCRIPT; + return; + } + emitNode(parser, "onscript", parser.script); + parser.script = ""; + } + + // first make sure that the closing tag actually exists. + // <a><b></c></b></a> will close everything, otherwise. + var t = parser.tags.length; + var tagName = parser.tagName; + if (!parser.strict) { + tagName = tagName[parser.looseCase](); + } + var closeTo = tagName; + while (t--) { + var close = parser.tags[t]; + if (close.name !== closeTo) { + // fail the first time in strict mode + strictFail(parser, "Unexpected close tag"); + } else { + break; + } + } + + // didn't find it. we already failed for strict, so just abort. + if (t < 0) { + strictFail(parser, "Unmatched closing tag: " + parser.tagName); + parser.textNode += "</" + parser.tagName + ">"; + parser.state = S.TEXT; + return; + } + parser.tagName = tagName; + var s = parser.tags.length; + while (s-- > t) { + var tag = (parser.tag = parser.tags.pop()); + parser.tagName = parser.tag.name; + emitNode(parser, "onclosetag", parser.tagName); + + var x = {}; + for (var i in tag.ns) { + x[i] = tag.ns[i]; + } + + var parent = parser.tags[parser.tags.length - 1] || parser; + if (parser.opt.xmlns && tag.ns !== parent.ns) { + // remove namespace bindings introduced by tag + Object.keys(tag.ns).forEach(function(p) { + var n = tag.ns[p]; + emitNode(parser, "onclosenamespace", { prefix: p, uri: n }); + }); + } + } + if (t === 0) { + parser.closedRoot = true; + } + parser.tagName = parser.attribValue = parser.attribName = ""; + parser.attribList.length = 0; + parser.state = S.TEXT; + } + + function parseEntity(parser) { + var entity = parser.entity; + var entityLC = entity.toLowerCase(); + var num; + var numStr = ""; + + if (parser.ENTITIES[entity]) { + return parser.ENTITIES[entity]; + } + if (parser.ENTITIES[entityLC]) { + return parser.ENTITIES[entityLC]; + } + entity = entityLC; + if (entity.charAt(0) === "#") { + if (entity.charAt(1) === "x") { + entity = entity.slice(2); + num = parseInt(entity, 16); + numStr = num.toString(16); + } else { + entity = entity.slice(1); + num = parseInt(entity, 10); + numStr = num.toString(10); + } + } + entity = entity.replace(/^0+/, ""); + if (isNaN(num) || numStr.toLowerCase() !== entity) { + strictFail(parser, "Invalid character entity"); + return "&" + parser.entity + ";"; + } + + return String.fromCodePoint(num); + } + + function beginWhiteSpace(parser, c) { + if (c === "<") { + parser.state = S.OPEN_WAKA; + parser.startTagPosition = parser.position; + } else if (!isWhitespace(c)) { + // have to process this as a text node. + // weird, but happens. + strictFail(parser, "Non-whitespace before first tag."); + parser.textNode = c; + parser.state = S.TEXT; + } + } + + function charAt(chunk, i) { + var result = ""; + if (i < chunk.length) { + result = chunk.charAt(i); + } + return result; + } + + function write(chunk) { + var parser = this; + if (this.error) { + throw this.error; + } + if (parser.closed) { + return error( + parser, + "Cannot write after close. Assign an onready handler." + ); + } + if (chunk === null) { + return end(parser); + } + if (typeof chunk === "object") { + chunk = chunk.toString(); + } + var i = 0; + var c = ""; + while (true) { + c = charAt(chunk, i++); + parser.c = c; + + if (!c) { + break; + } + + if (parser.trackPosition) { + parser.position++; + if (c === "\n") { + parser.line++; + parser.column = 0; + } else { + parser.column++; + } + } + + switch (parser.state) { + case S.BEGIN: + parser.state = S.BEGIN_WHITESPACE; + if (c === "\uFEFF") { + continue; + } + beginWhiteSpace(parser, c); + continue; + + case S.BEGIN_WHITESPACE: + beginWhiteSpace(parser, c); + continue; + + case S.TEXT: + if (parser.sawRoot && !parser.closedRoot) { + var starti = i - 1; + while (c && c !== "<" && c !== "&") { + c = charAt(chunk, i++); + if (c && parser.trackPosition) { + parser.position++; + if (c === "\n") { + parser.line++; + parser.column = 0; + } else { + parser.column++; + } + } + } + parser.textNode += chunk.substring(starti, i - 1); + } + if ( + c === "<" && + !(parser.sawRoot && parser.closedRoot && !parser.strict) + ) { + parser.state = S.OPEN_WAKA; + parser.startTagPosition = parser.position; + } else { + if (!isWhitespace(c) && (!parser.sawRoot || parser.closedRoot)) { + strictFail(parser, "Text data outside of root node."); + } + if (c === "&") { + parser.state = S.TEXT_ENTITY; + } else { + parser.textNode += c; + } + } + continue; + + case S.SCRIPT: + // only non-strict + if (c === "<") { + parser.state = S.SCRIPT_ENDING; + } else { + parser.script += c; + } + continue; + + case S.SCRIPT_ENDING: + if (c === "/") { + parser.state = S.CLOSE_TAG; + } else { + parser.script += "<" + c; + parser.state = S.SCRIPT; + } + continue; + + case S.OPEN_WAKA: + // either a /, ?, !, or text is coming next. + if (c === "!") { + parser.state = S.SGML_DECL; + parser.sgmlDecl = ""; + } else if (isWhitespace(c)) { + // wait for it... + } else if (isMatch(nameStart, c)) { + parser.state = S.OPEN_TAG; + parser.tagName = c; + } else if (c === "/") { + parser.state = S.CLOSE_TAG; + parser.tagName = ""; + } else if (c === "?") { + parser.state = S.PROC_INST; + parser.procInstName = parser.procInstBody = ""; + } else { + strictFail(parser, "Unencoded <"); + // if there was some whitespace, then add that in. + if (parser.startTagPosition + 1 < parser.position) { + var pad = parser.position - parser.startTagPosition; + c = new Array(pad).join(" ") + c; + } + parser.textNode += "<" + c; + parser.state = S.TEXT; + } + continue; + + case S.SGML_DECL: + if ((parser.sgmlDecl + c).toUpperCase() === CDATA) { + emitNode(parser, "onopencdata"); + parser.state = S.CDATA; + parser.sgmlDecl = ""; + parser.cdata = ""; + } else if (parser.sgmlDecl + c === "--") { + parser.state = S.COMMENT; + parser.comment = ""; + parser.sgmlDecl = ""; + } else if ((parser.sgmlDecl + c).toUpperCase() === DOCTYPE) { + parser.state = S.DOCTYPE; + if (parser.doctype || parser.sawRoot) { + strictFail(parser, "Inappropriately located doctype declaration"); + } + parser.doctype = ""; + parser.sgmlDecl = ""; + } else if (c === ">") { + emitNode(parser, "onsgmldeclaration", parser.sgmlDecl); + parser.sgmlDecl = ""; + parser.state = S.TEXT; + } else if (isQuote(c)) { + parser.state = S.SGML_DECL_QUOTED; + parser.sgmlDecl += c; + } else { + parser.sgmlDecl += c; + } + continue; + + case S.SGML_DECL_QUOTED: + if (c === parser.q) { + parser.state = S.SGML_DECL; + parser.q = ""; + } + parser.sgmlDecl += c; + continue; + + case S.DOCTYPE: + if (c === ">") { + parser.state = S.TEXT; + emitNode(parser, "ondoctype", parser.doctype); + parser.doctype = true; // just remember that we saw it. + } else { + parser.doctype += c; + if (c === "[") { + parser.state = S.DOCTYPE_DTD; + } else if (isQuote(c)) { + parser.state = S.DOCTYPE_QUOTED; + parser.q = c; + } + } + continue; + + case S.DOCTYPE_QUOTED: + parser.doctype += c; + if (c === parser.q) { + parser.q = ""; + parser.state = S.DOCTYPE; + } + continue; + + case S.DOCTYPE_DTD: + parser.doctype += c; + if (c === "]") { + parser.state = S.DOCTYPE; + } else if (isQuote(c)) { + parser.state = S.DOCTYPE_DTD_QUOTED; + parser.q = c; + } + continue; + + case S.DOCTYPE_DTD_QUOTED: + parser.doctype += c; + if (c === parser.q) { + parser.state = S.DOCTYPE_DTD; + parser.q = ""; + } + continue; + + case S.COMMENT: + if (c === "-") { + parser.state = S.COMMENT_ENDING; + } else { + parser.comment += c; + } + continue; + + case S.COMMENT_ENDING: + if (c === "-") { + parser.state = S.COMMENT_ENDED; + parser.comment = textopts(parser.opt, parser.comment); + if (parser.comment) { + emitNode(parser, "oncomment", parser.comment); + } + parser.comment = ""; + } else { + parser.comment += "-" + c; + parser.state = S.COMMENT; + } + continue; + + case S.COMMENT_ENDED: + if (c !== ">") { + strictFail(parser, "Malformed comment"); + // allow <!-- blah -- bloo --> in non-strict mode, + // which is a comment of " blah -- bloo " + parser.comment += "--" + c; + parser.state = S.COMMENT; + } else { + parser.state = S.TEXT; + } + continue; + + case S.CDATA: + if (c === "]") { + parser.state = S.CDATA_ENDING; + } else { + parser.cdata += c; + } + continue; + + case S.CDATA_ENDING: + if (c === "]") { + parser.state = S.CDATA_ENDING_2; + } else { + parser.cdata += "]" + c; + parser.state = S.CDATA; + } + continue; + + case S.CDATA_ENDING_2: + if (c === ">") { + if (parser.cdata) { + emitNode(parser, "oncdata", parser.cdata); + } + emitNode(parser, "onclosecdata"); + parser.cdata = ""; + parser.state = S.TEXT; + } else if (c === "]") { + parser.cdata += "]"; + } else { + parser.cdata += "]]" + c; + parser.state = S.CDATA; + } + continue; + + case S.PROC_INST: + if (c === "?") { + parser.state = S.PROC_INST_ENDING; + } else if (isWhitespace(c)) { + parser.state = S.PROC_INST_BODY; + } else { + parser.procInstName += c; + } + continue; + + case S.PROC_INST_BODY: + if (!parser.procInstBody && isWhitespace(c)) { + continue; + } else if (c === "?") { + parser.state = S.PROC_INST_ENDING; + } else { + parser.procInstBody += c; + } + continue; + + case S.PROC_INST_ENDING: + if (c === ">") { + emitNode(parser, "onprocessinginstruction", { + name: parser.procInstName, + body: parser.procInstBody, + }); + parser.procInstName = parser.procInstBody = ""; + parser.state = S.TEXT; + } else { + parser.procInstBody += "?" + c; + parser.state = S.PROC_INST_BODY; + } + continue; + + case S.OPEN_TAG: + if (isMatch(nameBody, c)) { + parser.tagName += c; + } else { + newTag(parser); + if (c === ">") { + openTag(parser); + } else if (c === "/") { + parser.state = S.OPEN_TAG_SLASH; + } else { + if (!isWhitespace(c)) { + strictFail(parser, "Invalid character in tag name"); + } + parser.state = S.ATTRIB; + } + } + continue; + + case S.OPEN_TAG_SLASH: + if (c === ">") { + openTag(parser, true); + closeTag(parser); + } else { + strictFail( + parser, + "Forward-slash in opening tag not followed by >" + ); + parser.state = S.ATTRIB; + } + continue; + + case S.ATTRIB: + // haven't read the attribute name yet. + if (isWhitespace(c)) { + continue; + } else if (c === ">") { + openTag(parser); + } else if (c === "/") { + parser.state = S.OPEN_TAG_SLASH; + } else if (isMatch(nameStart, c)) { + parser.attribName = c; + parser.attribValue = ""; + parser.state = S.ATTRIB_NAME; + } else { + strictFail(parser, "Invalid attribute name"); + } + continue; + + case S.ATTRIB_NAME: + if (c === "=") { + parser.state = S.ATTRIB_VALUE; + } else if (c === ">") { + strictFail(parser, "Attribute without value"); + parser.attribValue = parser.attribName; + attrib(parser); + openTag(parser); + } else if (isWhitespace(c)) { + parser.state = S.ATTRIB_NAME_SAW_WHITE; + } else if (isMatch(nameBody, c)) { + parser.attribName += c; + } else { + strictFail(parser, "Invalid attribute name"); + } + continue; + + case S.ATTRIB_NAME_SAW_WHITE: + if (c === "=") { + parser.state = S.ATTRIB_VALUE; + } else if (isWhitespace(c)) { + continue; + } else { + strictFail(parser, "Attribute without value"); + parser.tag.attributes[parser.attribName] = ""; + parser.attribValue = ""; + emitNode(parser, "onattribute", { + name: parser.attribName, + value: "", + }); + parser.attribName = ""; + if (c === ">") { + openTag(parser); + } else if (isMatch(nameStart, c)) { + parser.attribName = c; + parser.state = S.ATTRIB_NAME; + } else { + strictFail(parser, "Invalid attribute name"); + parser.state = S.ATTRIB; + } + } + continue; + + case S.ATTRIB_VALUE: + if (isWhitespace(c)) { + continue; + } else if (isQuote(c)) { + parser.q = c; + parser.state = S.ATTRIB_VALUE_QUOTED; + } else { + strictFail(parser, "Unquoted attribute value"); + parser.state = S.ATTRIB_VALUE_UNQUOTED; + parser.attribValue = c; + } + continue; + + case S.ATTRIB_VALUE_QUOTED: + if (c !== parser.q) { + if (c === "&") { + parser.state = S.ATTRIB_VALUE_ENTITY_Q; + } else { + parser.attribValue += c; + } + continue; + } + attrib(parser); + parser.q = ""; + parser.state = S.ATTRIB_VALUE_CLOSED; + continue; + + case S.ATTRIB_VALUE_CLOSED: + if (isWhitespace(c)) { + parser.state = S.ATTRIB; + } else if (c === ">") { + openTag(parser); + } else if (c === "/") { + parser.state = S.OPEN_TAG_SLASH; + } else if (isMatch(nameStart, c)) { + strictFail(parser, "No whitespace between attributes"); + parser.attribName = c; + parser.attribValue = ""; + parser.state = S.ATTRIB_NAME; + } else { + strictFail(parser, "Invalid attribute name"); + } + continue; + + case S.ATTRIB_VALUE_UNQUOTED: + if (!isAttribEnd(c)) { + if (c === "&") { + parser.state = S.ATTRIB_VALUE_ENTITY_U; + } else { + parser.attribValue += c; + } + continue; + } + attrib(parser); + if (c === ">") { + openTag(parser); + } else { + parser.state = S.ATTRIB; + } + continue; + + case S.CLOSE_TAG: + if (!parser.tagName) { + if (isWhitespace(c)) { + continue; + } else if (notMatch(nameStart, c)) { + if (parser.script) { + parser.script += "</" + c; + parser.state = S.SCRIPT; + } else { + strictFail(parser, "Invalid tagname in closing tag."); + } + } else { + parser.tagName = c; + } + } else if (c === ">") { + closeTag(parser); + } else if (isMatch(nameBody, c)) { + parser.tagName += c; + } else if (parser.script) { + parser.script += "</" + parser.tagName; + parser.tagName = ""; + parser.state = S.SCRIPT; + } else { + if (!isWhitespace(c)) { + strictFail(parser, "Invalid tagname in closing tag"); + } + parser.state = S.CLOSE_TAG_SAW_WHITE; + } + continue; + + case S.CLOSE_TAG_SAW_WHITE: + if (isWhitespace(c)) { + continue; + } + if (c === ">") { + closeTag(parser); + } else { + strictFail(parser, "Invalid characters in closing tag"); + } + continue; + + case S.TEXT_ENTITY: + case S.ATTRIB_VALUE_ENTITY_Q: + case S.ATTRIB_VALUE_ENTITY_U: + var returnState; + var buffer; + switch (parser.state) { + case S.TEXT_ENTITY: + returnState = S.TEXT; + buffer = "textNode"; + break; + + case S.ATTRIB_VALUE_ENTITY_Q: + returnState = S.ATTRIB_VALUE_QUOTED; + buffer = "attribValue"; + break; + + case S.ATTRIB_VALUE_ENTITY_U: + returnState = S.ATTRIB_VALUE_UNQUOTED; + buffer = "attribValue"; + break; + } + + if (c === ";") { + parser[buffer] += parseEntity(parser); + parser.entity = ""; + parser.state = returnState; + } else if ( + isMatch(parser.entity.length ? entityBody : entityStart, c) + ) { + parser.entity += c; + } else { + strictFail(parser, "Invalid character in entity name"); + parser[buffer] += "&" + parser.entity + c; + parser.entity = ""; + parser.state = returnState; + } + + continue; + + default: + throw new Error(parser, "Unknown state: " + parser.state); + } + } // while + + if (parser.position >= parser.bufferCheckPosition) { + checkBufferLength(parser); + } + return parser; + } + + /*! http://mths.be/fromcodepoint v0.1.0 by @mathias */ + /* istanbul ignore next */ + if (!String.fromCodePoint) { + (function() { + var stringFromCharCode = String.fromCharCode; + var floor = Math.floor; + var fromCodePoint = function() { + var MAX_SIZE = 0x4000; + var codeUnits = []; + var highSurrogate; + var lowSurrogate; + var index = -1; + var length = arguments.length; + if (!length) { + return ""; + } + var result = ""; + while (++index < length) { + var codePoint = Number(arguments[index]); + if ( + !isFinite(codePoint) || // `NaN`, `+Infinity`, or `-Infinity` + codePoint < 0 || // not a valid Unicode code point + codePoint > 0x10ffff || // not a valid Unicode code point + floor(codePoint) !== codePoint // not an integer + ) { + throw RangeError("Invalid code point: " + codePoint); + } + if (codePoint <= 0xffff) { + // BMP code point + codeUnits.push(codePoint); + } else { + // Astral code point; split in surrogate halves + // http://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae + codePoint -= 0x10000; + highSurrogate = (codePoint >> 10) + 0xd800; + lowSurrogate = (codePoint % 0x400) + 0xdc00; + codeUnits.push(highSurrogate, lowSurrogate); + } + if (index + 1 === length || codeUnits.length > MAX_SIZE) { + result += stringFromCharCode.apply(null, codeUnits); + codeUnits.length = 0; + } + } + return result; + }; + /* istanbul ignore next */ + if (Object.defineProperty) { + Object.defineProperty(String, "fromCodePoint", { + value: fromCodePoint, + configurable: true, + writable: true, + }); + } else { + String.fromCodePoint = fromCodePoint; + } + })(); + } +})(typeof exports === "undefined" ? (this.sax = {}) : exports); diff --git a/comm/chat/protocols/xmpp/moz.build b/comm/chat/protocols/xmpp/moz.build new file mode 100644 index 0000000000..56140aeef6 --- /dev/null +++ b/comm/chat/protocols/xmpp/moz.build @@ -0,0 +1,26 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell.ini"] + +DIRS += [ + "lib", +] + +EXTRA_JS_MODULES += [ + "sax.sys.mjs", + "xmpp-authmechs.sys.mjs", + "xmpp-base.sys.mjs", + "xmpp-commands.sys.mjs", + "xmpp-session.sys.mjs", + "xmpp-xml.sys.mjs", + "xmpp.sys.mjs", +] + +JAR_MANIFESTS += ["jar.mn"] + +XPCOM_MANIFESTS += [ + "components.conf", +] diff --git a/comm/chat/protocols/xmpp/sax.sys.mjs b/comm/chat/protocols/xmpp/sax.sys.mjs new file mode 100644 index 0000000000..3098c6df79 --- /dev/null +++ b/comm/chat/protocols/xmpp/sax.sys.mjs @@ -0,0 +1,7 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +let scope = {}; +Services.scriptloader.loadSubScript("resource:///modules/sax/sax.js", scope); +export var SAX = scope.sax; diff --git a/comm/chat/protocols/xmpp/test/test_authmechs.js b/comm/chat/protocols/xmpp/test/test_authmechs.js new file mode 100644 index 0000000000..f935026dbc --- /dev/null +++ b/comm/chat/protocols/xmpp/test/test_authmechs.js @@ -0,0 +1,160 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var { XMPPAuthMechanisms } = ChromeUtils.importESModule( + "resource:///modules/xmpp-authmechs.sys.mjs" +); +var { Stanza } = ChromeUtils.importESModule( + "resource:///modules/xmpp-xml.sys.mjs" +); + +/* + * Test PLAIN using the examples given in section 6 of RFC 6120. + */ +add_task(async function testPlain() { + const username = "juliet"; + const password = "r0m30myr0m30"; + + let mech = XMPPAuthMechanisms.PLAIN(username, password, undefined); + + // Send the initiation message. + let result = mech.next(); + ok(!result.done); + let value = await Promise.resolve(result.value); + + // Check the algorithm. + equal(value.send.attributes.mechanism, "PLAIN"); + // Check the PLAIN content. + equal(value.send.children[0].text, "AGp1bGlldAByMG0zMG15cjBtMzA="); + + // Receive the success. + let response = Stanza.node("success", Stanza.NS.sasl); + result = mech.next(response); + ok(result.done); + // There is no final value. + equal(result.value, undefined); +}); + +/* + * Test SCRAM-SHA-1 using the examples given in section 5 of RFC 5802. + * + * Full test vectors of intermediate values are available at: + * https://wiki.xmpp.org/web/SASL_and_SCRAM-SHA-1 + */ +add_task(async function testScramSha1() { + const username = "user"; + const password = "pencil"; + + // Use a constant value for the nonce. + const nonce = "fyko+d2lbbFgONRv9qkxdawL"; + + let mech = XMPPAuthMechanisms["SCRAM-SHA-1"]( + username, + password, + undefined, + nonce + ); + + // Send the client-first-message. + let result = mech.next(); + ok(!result.done); + let value = await Promise.resolve(result.value); + + // Check the algorithm. + equal(value.send.attributes.mechanism, "SCRAM-SHA-1"); + // Check the SCRAM content. + equal( + atob(value.send.children[0].text), + "n,,n=user,r=fyko+d2lbbFgONRv9qkxdawL" + ); + + // Receive the server-first-message and send the client-final-message. + let response = Stanza.node( + "challenge", + Stanza.NS.sasl, + null, + btoa( + "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096" + ) + ); + result = mech.next(response); + ok(!result.done); + value = await Promise.resolve(result.value); + + // Check the SCRAM content. + equal( + atob(value.send.children[0].text), + "c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,p=v0X8v3Bz2T0CJGbJQyF0X+HI4Ts=" + ); + + // Receive the server-final-message. + response = Stanza.node( + "success", + Stanza.NS.sasl, + null, + btoa("v=rmF9pqV8S7suAoZWja4dJRkFsKQ=") + ); + result = mech.next(response); + ok(result.done); + // There is no final value. + equal(result.value, undefined); +}); + +/* + * Test SCRAM-SHA-256 using the examples given in section 3 of RFC 7677. + */ +add_task(async function testScramSha256() { + const username = "user"; + const password = "pencil"; + + // Use a constant value for the nonce. + const nonce = "rOprNGfwEbeRWgbNEkqO"; + + let mech = XMPPAuthMechanisms["SCRAM-SHA-256"]( + username, + password, + undefined, + nonce + ); + + // Send the client-first-message. + let result = mech.next(); + ok(!result.done); + let value = await Promise.resolve(result.value); + + // Check the algorithm. + equal(value.send.attributes.mechanism, "SCRAM-SHA-256"); + // Check the SCRAM content. + equal(atob(value.send.children[0].text), "n,,n=user,r=rOprNGfwEbeRWgbNEkqO"); + + // Receive the server-first-message and send the client-final-message. + let response = Stanza.node( + "challenge", + Stanza.NS.sasl, + null, + btoa( + "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096" + ) + ); + result = mech.next(response); + ok(!result.done); + value = await Promise.resolve(result.value); + + // Check the SCRAM content. + equal( + atob(value.send.children[0].text), + "c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=" + ); + + // Receive the server-final-message. + response = Stanza.node( + "success", + Stanza.NS.sasl, + null, + btoa("v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=") + ); + result = mech.next(response); + ok(result.done); + // There is no final value. + equal(result.value, undefined); +}); diff --git a/comm/chat/protocols/xmpp/test/test_dnsSrv.js b/comm/chat/protocols/xmpp/test/test_dnsSrv.js new file mode 100644 index 0000000000..37f1b6b052 --- /dev/null +++ b/comm/chat/protocols/xmpp/test/test_dnsSrv.js @@ -0,0 +1,112 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var { XMPPAccountPrototype } = ChromeUtils.importESModule( + "resource:///modules/xmpp-base.sys.mjs" +); +var { XMPPSession } = ChromeUtils.importESModule( + "resource:///modules/xmpp-session.sys.mjs" +); +var { SRVRecord } = ChromeUtils.import("resource:///modules/DNS.jsm"); + +function FakeXMPPSession() {} +FakeXMPPSession.prototype = { + __proto__: XMPPSession.prototype, + _account: { __proto__: XMPPAccountPrototype }, + _host: null, + _port: 0, + connect( + aHostOrigin, + aPortOrigin, + aSecurity, + aProxy, + aHost = aHostOrigin, + aPort = aPortOrigin + ) {}, + _connectNextRecord() { + this.isConnectNextRecord = true; + }, + + // Used to indicate that method _connectNextRecord is called or not. + isConnectNextRecord: false, + + LOG(aMsg) {}, + WARN(aMsg) {}, +}; + +var TEST_DATA = [ + { + // Test sorting based on priority and weight. + input: [ + new SRVRecord(20, 0, "xmpp.instantbird.com", 5222), + new SRVRecord(5, 0, "xmpp1.instantbird.com", 5222), + new SRVRecord(10, 0, "xmpp2.instantbird.com", 5222), + new SRVRecord(0, 0, "xmpp3.instantbird.com", 5222), + new SRVRecord(15, 0, "xmpp4.instantbird.com", 5222), + ], + output: [ + new SRVRecord(0, 0, "xmpp3.instantbird.com", 5222), + new SRVRecord(5, 0, "xmpp1.instantbird.com", 5222), + new SRVRecord(10, 0, "xmpp2.instantbird.com", 5222), + new SRVRecord(15, 0, "xmpp4.instantbird.com", 5222), + new SRVRecord(20, 0, "xmpp.instantbird.com", 5222), + ], + isConnectNextRecord: true, + }, + { + input: [ + new SRVRecord(5, 30, "xmpp5.instantbird.com", 5222), + new SRVRecord(5, 0, "xmpp1.instantbird.com", 5222), + new SRVRecord(10, 60, "xmpp2.instantbird.com", 5222), + new SRVRecord(5, 10, "xmpp3.instantbird.com", 5222), + new SRVRecord(20, 10, "xmpp.instantbird.com", 5222), + new SRVRecord(15, 0, "xmpp4.instantbird.com", 5222), + ], + output: [ + new SRVRecord(5, 30, "xmpp5.instantbird.com", 5222), + new SRVRecord(5, 10, "xmpp3.instantbird.com", 5222), + new SRVRecord(5, 0, "xmpp1.instantbird.com", 5222), + new SRVRecord(10, 60, "xmpp2.instantbird.com", 5222), + new SRVRecord(15, 0, "xmpp4.instantbird.com", 5222), + new SRVRecord(20, 10, "xmpp.instantbird.com", 5222), + ], + isConnectNextRecord: true, + }, + + // Tests no SRV records are found. + { + input: [], + output: [], + isConnectNextRecord: false, + }, + + // Tests XMPP is not supported if the result is one record with target ".". + { + input: [new SRVRecord(5, 30, ".", 5222)], + output: XMPPSession.prototype.SRV_ERROR_XMPP_NOT_SUPPORTED, + isConnectNextRecord: false, + }, + { + input: [new SRVRecord(5, 30, "xmpp.instantbird.com", 5222)], + output: [new SRVRecord(5, 30, "xmpp.instantbird.com", 5222)], + isConnectNextRecord: true, + }, +]; + +function run_test() { + for (let currentQuery of TEST_DATA) { + let session = new FakeXMPPSession(); + try { + session._handleSrvQuery(currentQuery.input); + equal(session._srvRecords.length, currentQuery.output.length); + for (let index = 0; index < session._srvRecords.length; index++) { + deepEqual(session._srvRecords[index], currentQuery.output[index]); + } + } catch (e) { + equal(e, currentQuery.output); + } + equal(session.isConnectNextRecord, currentQuery.isConnectNextRecord); + } + + run_next_test(); +} diff --git a/comm/chat/protocols/xmpp/test/test_parseJidAndNormalization.js b/comm/chat/protocols/xmpp/test/test_parseJidAndNormalization.js new file mode 100644 index 0000000000..f041d2356b --- /dev/null +++ b/comm/chat/protocols/xmpp/test/test_parseJidAndNormalization.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var { XMPPAccountPrototype } = ChromeUtils.importESModule( + "resource:///modules/xmpp-base.sys.mjs" +); + +var TEST_DATA = { + "abdelrhman@instantbird": { + node: "abdelrhman", + domain: "instantbird", + jid: "abdelrhman@instantbird", + normalized: "abdelrhman@instantbird", + }, + " room@instantbird/abdelrhman ": { + node: "room", + domain: "instantbird", + resource: "abdelrhman", + jid: "room@instantbird/abdelrhman", + normalized: "room@instantbird", + }, + "room@instantbird/@bdelrhman": { + node: "room", + domain: "instantbird", + resource: "@bdelrhman", + jid: "room@instantbird/@bdelrhman", + normalized: "room@instantbird", + }, + "room@instantbird/abdelrhm\u0061\u0308n": { + node: "room", + domain: "instantbird", + resource: "abdelrhm\u0061\u0308n", + jid: "room@instantbird/abdelrhm\u0061\u0308n", + normalized: "room@instantbird", + }, + "Room@Instantbird/Abdelrhman": { + node: "room", + domain: "instantbird", + resource: "Abdelrhman", + jid: "room@instantbird/Abdelrhman", + normalized: "room@instantbird", + }, + "Abdelrhman@instantbird/Instant bird": { + node: "abdelrhman", + domain: "instantbird", + resource: "Instant bird", + jid: "abdelrhman@instantbird/Instant bird", + normalized: "abdelrhman@instantbird", + }, + "abdelrhman@host/instant/Bird": { + node: "abdelrhman", + domain: "host", + resource: "instant/Bird", + jid: "abdelrhman@host/instant/Bird", + normalized: "abdelrhman@host", + }, + instantbird: { + domain: "instantbird", + jid: "instantbird", + normalized: "instantbird", + }, +}; + +function testParseJID() { + for (let currentJID in TEST_DATA) { + let jid = XMPPAccountPrototype._parseJID(currentJID); + equal(jid.node, TEST_DATA[currentJID].node); + equal(jid.domain, TEST_DATA[currentJID].domain); + equal(jid.resource, TEST_DATA[currentJID].resource); + equal(jid.jid, TEST_DATA[currentJID].jid); + } + + run_next_test(); +} + +function testNormalize() { + for (let currentJID in TEST_DATA) { + equal( + XMPPAccountPrototype.normalize(currentJID), + TEST_DATA[currentJID].normalized + ); + } + + run_next_test(); +} + +function testNormalizeFullJid() { + for (let currentJID in TEST_DATA) { + equal( + XMPPAccountPrototype.normalizeFullJid(currentJID), + TEST_DATA[currentJID].jid + ); + } + + run_next_test(); +} + +function run_test() { + add_test(testParseJID); + add_test(testNormalize); + add_test(testNormalizeFullJid); + + run_next_test(); +} diff --git a/comm/chat/protocols/xmpp/test/test_parseVCard.js b/comm/chat/protocols/xmpp/test/test_parseVCard.js new file mode 100644 index 0000000000..08155218de --- /dev/null +++ b/comm/chat/protocols/xmpp/test/test_parseVCard.js @@ -0,0 +1,139 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var { XMPPAccountPrototype } = ChromeUtils.importESModule( + "resource:///modules/xmpp-base.sys.mjs" +); +var { XMPPParser } = ChromeUtils.importESModule( + "resource:///modules/xmpp-xml.sys.mjs" +); + +/* + * Open an input stream, instantiate an XMPP parser, and feed the input string + * into it. Then assert that the resulting vCard matches the expected result. + */ +function _test_vcard(aInput, aExpectedResult) { + let listener = { + onXMLError(aError, aException) { + // Ensure that no errors happen. + ok(false, aError + " - " + aException); + }, + LOG(aString) {}, + onXmppStanza(aStanza) { + // This is a simplified stanza parser that assumes inputs are vCards. + let vCard = aStanza.getElement(["vCard"]); + deepEqual(XMPPAccountPrototype.parseVCard(vCard), aExpectedResult); + }, + }; + let parser = new XMPPParser(listener); + parser.onDataAvailable(aInput); + parser.destroy(); +} + +/* + * Test parsing of the example vCard from XEP-0054 section 3.1, example 2. + */ +function test_standard_vcard() { + const standard_vcard = + "<iq xmlns='jabber:client'\ + id='v1'\ + to='stpeter@jabber.org/roundabout'\ + type='result'>\ + <vCard xmlns='vcard-temp'>\ + <FN>Peter Saint-Andre</FN>\ + <N>\ + <FAMILY>Saint-Andre</FAMILY>\ + <GIVEN>Peter</GIVEN>\ + <MIDDLE/>\ + </N>\ + <NICKNAME>stpeter</NICKNAME>\ + <URL>http://www.xmpp.org/xsf/people/stpeter.shtml</URL>\ + <BDAY>1966-08-06</BDAY>\ + <ORG>\ + <ORGNAME>XMPP Standards Foundation</ORGNAME>\ + <ORGUNIT/>\ + </ORG>\ + <TITLE>Executive Director</TITLE>\ + <ROLE>Patron Saint</ROLE>\ + <TEL><WORK/><VOICE/><NUMBER>303-308-3282</NUMBER></TEL>\ + <TEL><WORK/><FAX/><NUMBER/></TEL>\ + <TEL><WORK/><MSG/><NUMBER/></TEL>\ + <ADR>\ + <WORK/>\ + <EXTADD>Suite 600</EXTADD>\ + <STREET>1899 Wynkoop Street</STREET>\ + <LOCALITY>Denver</LOCALITY>\ + <REGION>CO</REGION>\ + <PCODE>80202</PCODE>\ + <CTRY>USA</CTRY>\ + </ADR>\ + <TEL><HOME/><VOICE/><NUMBER>303-555-1212</NUMBER></TEL>\ + <TEL><HOME/><FAX/><NUMBER/></TEL>\ + <TEL><HOME/><MSG/><NUMBER/></TEL>\ + <ADR>\ + <HOME/>\ + <EXTADD/>\ + <STREET/>\ + <LOCALITY>Denver</LOCALITY>\ + <REGION>CO</REGION>\ + <PCODE>80209</PCODE>\ + <CTRY>USA</CTRY>\ + </ADR>\ + <EMAIL><INTERNET/><PREF/><USERID>stpeter@jabber.org</USERID></EMAIL>\ + <JABBERID>stpeter@jabber.org</JABBERID>\ + <DESC>\ + More information about me is located on my\ + personal website: http://www.saint-andre.com/\ + </DESC>\ + </vCard>\ +</iq>"; + + const expectedResult = { + fullName: "Peter Saint-Andre", + // Name is not parsed. + nickname: "stpeter", + // URL is not parsed. + birthday: "1966-08-06", + organization: "XMPP Standards Foundation", + title: "Executive Director", + // Role is not parsed. + // This only pulls the *last* telephone number. + telephone: "303-555-1212", + // Part of the address is parsed. + locality: "Denver", + country: "USA", + email: "stpeter@jabber.org", + userName: "stpeter@jabber.org", // Jabber ID. + // Description is not parsed. + }; + + _test_vcard(standard_vcard, expectedResult); + + run_next_test(); +} + +/* + * Test parsing of the example empty vCard from XEP-0054 section 3.1, example + * 4. This can be used instead of returning an error stanza. + */ +function test_empty_vcard() { + const empty_vcard = + "<iq xmlns='jabber:client'\ + id='v1'\ + to='stpeter@jabber.org/roundabout'\ + type='result'>\ + <vCard xmlns='vcard-temp'/>\ +</iq>"; + + // There should be no properties. + _test_vcard(empty_vcard, {}); + + run_next_test(); +} + +function run_test() { + add_test(test_standard_vcard); + add_test(test_empty_vcard); + + run_next_test(); +} diff --git a/comm/chat/protocols/xmpp/test/test_saslPrep.js b/comm/chat/protocols/xmpp/test/test_saslPrep.js new file mode 100644 index 0000000000..b2d0a1f147 --- /dev/null +++ b/comm/chat/protocols/xmpp/test/test_saslPrep.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var { saslPrep } = ChromeUtils.importESModule( + "resource:///modules/xmpp-authmechs.sys.mjs" +); + +// RFC 4013 3.Examples +var TEST_DATA = [ + { + // SOFT HYPHEN mapped to nothing. + input: "I\u00adX", + output: "IX", + isError: false, + }, + { + // No transformation. + input: "user", + output: "user", + isError: false, + }, + { + // Case preserved, will not match #2. + input: "USER", + output: "USER", + isError: false, + }, + { + // Output is NFKC, input in ISO 8859-1. + input: "\u00aa", + output: "a", + isError: false, + }, + { + // Output is NFKC, will match #1. + input: "\u2168", + output: "IX", + isError: false, + }, + { + // Error - prohibited character. + input: "\u0007", + output: "", + isError: true, + }, + { + // Error - bidirectional check. + input: "\u0627\u0031", + output: "", + isError: true, + }, +]; + +function run_test() { + for (let current of TEST_DATA) { + try { + let result = saslPrep(current.input); + equal(current.isError, false); + equal(result, current.output); + } catch (e) { + equal(current.isError, true); + } + } + + run_next_test(); +} diff --git a/comm/chat/protocols/xmpp/test/test_xmppParser.js b/comm/chat/protocols/xmpp/test/test_xmppParser.js new file mode 100644 index 0000000000..c18304a544 --- /dev/null +++ b/comm/chat/protocols/xmpp/test/test_xmppParser.js @@ -0,0 +1,135 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var { XMPPParser } = ChromeUtils.importESModule( + "resource:///modules/xmpp-xml.sys.mjs" +); + +let expectedResult = + '<presence xmlns="jabber:client" from="chat@example.com/Étienne" to="user@example.com/Thunderbird" \ +xml:lang="en" id="5ed0ae8b7051fa6169037da4e2a1ded6"><c xmlns="http://jabber.org/protocol/caps" \ +ver="ZyB1liM9c9GvKOnvl61+5ScWcqw=" node="https://example.com" hash="sha-1"/><x \ +xmlns="vcard-temp:x:update"><photo xmlns="vcard-temp:x:update"/></x><idle xmlns="urn:xmpp:idle:1" \ +since="2021-04-13T11:52:16.538713+00:00"/><occupant-id xmlns="urn:xmpp:occupant-id:0" \ +id="wNZPCZIVQ51D/heZQpOHi0ZgHXAEQonNPaLdyzLxHWs="/><x xmlns="http://jabber.org/protocol/muc#user"><item \ +xmlns="http://jabber.org/protocol/muc#user" jid="example@example.com/client" affiliation="member" \ +role="participant"/></x></presence>'; +let byteVersion = new TextEncoder().encode(expectedResult); +let utf8Input = Array.from(byteVersion, byte => String.fromCharCode(byte)).join( + "" +); + +var TEST_DATA = [ + { + input: + '<message xmlns="jabber:client" from="juliet@capulet.example/balcony" \ +to="romeo@montague.example/garden" type="chat">\ +<body>What man art thou that, thus bescreen"d in night, so stumblest on my \ +counsel?</body>\ +</message>', + output: + '<message xmlns="jabber:client" \ +from="juliet@capulet.example/balcony" to="romeo@montague.example/garden" \ +type="chat"><body xmlns="jabber:client">What man art thou that, thus \ +bescreen"d in night, so stumblest on my counsel?</body>\ +</message>', + isError: false, + description: "Message stanza with body element", + }, + { + input: + '<message xmlns="jabber:client" from="romeo@montague.example" \ +to="romeo@montague.example/home" type="chat">\ +<received xmlns="urn:xmpp:carbons:2">\ +<forwarded xmlns="urn:xmpp:forward:0">\ +<message xmlns="jabber:client" from="juliet@capulet.example/balcony" \ +to="romeo@montague.example/garden" type="chat">\ +<body>What man art thou that, thus bescreen"d in night, so stumblest on my \ +counsel?</body>\ +<thread>0e3141cd80894871a68e6fe6b1ec56fa</thread>\ +</message>\ +</forwarded>\ +</received>\ +</message>', + output: + '<message xmlns="jabber:client" from="romeo@montague.example" \ +to="romeo@montague.example/home" type="chat">\ +<received xmlns="urn:xmpp:carbons:2"><forwarded xmlns="urn:xmpp:forward:0">\ +<message xmlns="jabber:client" from="juliet@capulet.example/balcony" \ +to="romeo@montague.example/garden" type="chat">\ +<body xmlns="jabber:client">What man art thou that, thus bescreen"d in night, \ +so stumblest on my counsel?</body>\ +<thread xmlns="jabber:client">0e3141cd80894871a68e6fe6b1ec56fa</thread>\ +</message>\ +</forwarded>\ +</received>\ +</message>', + isError: false, + description: "Forwarded copy of message carbons", + }, + { + input: + '<message xmlns="jabber:client" from="juliet@capulet.example/balcony" \ +to="romeo@montague.example/garden" type="chat">\ +<body>What man art thou that, thus bescreen"d in night, so stumblest on my \ +counsel?\ +</message>', + output: "", + isError: true, + description: "No closing of body tag", + }, + { + input: + '<message xmlns="http://etherx.jabber.org/streams" from="juliet@capulet.example/balcony" \ +to="romeo@montague.example/garden" type="chat">\ +<body>What man art thou that, thus bescreen"d in night, so stumblest on my \ +counsel?</body>\ +</message>', + output: "", + isError: true, + description: "Invalid namespace of top-level element", + }, + { + input: + '<field xmlns="jabber:x:data" type="fixed">\ +<value>What man art thou that, thus bescreen"d in night, so stumblest on my \ +counsel?</value>\ +</field>', + output: "", + isError: true, + description: "Invalid top-level element", + }, + { + input: utf8Input, + output: expectedResult, + isError: false, + description: "UTF-8 encoded content from socket", + }, +]; + +function testXMPPParser() { + for (let current of TEST_DATA) { + let listener = { + onXMLError(aString) { + ok(current.isError, aString + " - " + current.description); + }, + LOG(aString) {}, + startLegacyAuth() {}, + onXmppStanza(aStanza) { + equal(current.output, aStanza.getXML(), current.description); + ok(!current.isError, current.description); + }, + }; + let parser = new XMPPParser(listener); + parser.onDataAvailable(current.input); + parser.destroy(); + } + + run_next_test(); +} + +function run_test() { + add_test(testXMPPParser); + + run_next_test(); +} diff --git a/comm/chat/protocols/xmpp/test/test_xmppXml.js b/comm/chat/protocols/xmpp/test/test_xmppXml.js new file mode 100644 index 0000000000..1b6ea9a175 --- /dev/null +++ b/comm/chat/protocols/xmpp/test/test_xmppXml.js @@ -0,0 +1,103 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var { Stanza } = ChromeUtils.importESModule( + "resource:///modules/xmpp-xml.sys.mjs" +); + +var TEST_DATA = [ + { + input: { + name: "message", + namespace: Stanza.NS.client, + attributes: { + jid: "user@domain", + type: null, + }, + data: [], + }, + XmlOutput: '<message xmlns="jabber:client" jid="user@domain"/>', + stringOutput: '<message xmlns="jabber:client" jid="user@domain"/>\n', + isError: false, + description: "Ignore attribute with null value", + }, + { + input: { + name: "message", + namespace: Stanza.NS.client, + attributes: { + jid: "user@domain", + type: undefined, + }, + data: [], + }, + XmlOutput: '<message xmlns="jabber:client" jid="user@domain"/>', + stringOutput: '<message xmlns="jabber:client" jid="user@domain"/>\n', + isError: false, + description: "Ignore attribute with undefined value", + }, + { + input: { + name: "message", + namespace: undefined, + attributes: {}, + data: [], + }, + XmlOutput: "<message/>", + stringOutput: "<message/>\n", + isError: false, + description: "Ignore namespace with undefined value", + }, + { + input: { + name: undefined, + attributes: {}, + data: [], + }, + XmlOutput: "", + stringOutput: "", + isError: true, + description: "Node must have a name", + }, + { + input: { + name: "message", + attributes: {}, + data: "test message", + }, + XmlOutput: "<message>test message</message>", + stringOutput: "<message>\n test message\n</message>\n", + isError: false, + description: "Node with text content", + }, +]; + +function testXMLNode() { + for (let current of TEST_DATA) { + try { + let result = Stanza.node( + current.input.name, + current.input.namespace, + current.input.attributes, + current.input.data + ); + equal(result.getXML(), current.XmlOutput, current.description); + equal( + result.convertToString(), + current.stringOutput, + current.description + ); + equal(current.isError, false); + } catch (e) { + equal(current.isError, true, current.description); + } + } + + run_next_test(); +} + +function run_test() { + add_test(testXMLNode); + + run_next_test(); +} diff --git a/comm/chat/protocols/xmpp/test/xpcshell.ini b/comm/chat/protocols/xmpp/test/xpcshell.ini new file mode 100644 index 0000000000..a4cb9534b8 --- /dev/null +++ b/comm/chat/protocols/xmpp/test/xpcshell.ini @@ -0,0 +1,11 @@ +[DEFAULT] +head = +tail = + +[test_authmechs.js] +[test_dnsSrv.js] +[test_parseJidAndNormalization.js] +[test_saslPrep.js] +[test_parseVCard.js] +[test_xmppParser.js] +[test_xmppXml.js] diff --git a/comm/chat/protocols/xmpp/xmpp-authmechs.sys.mjs b/comm/chat/protocols/xmpp/xmpp-authmechs.sys.mjs new file mode 100644 index 0000000000..7517d6f6aa --- /dev/null +++ b/comm/chat/protocols/xmpp/xmpp-authmechs.sys.mjs @@ -0,0 +1,561 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// This module exports XMPPAuthMechanisms, an object containing the supported +// SASL authentication mechanisms. Each authentication mechanism is a generator +// function which takes the following parameters: +// +// * The provided username (JID node), +// * The password +// * The user's domain (again from the JID). +// +// The generator should yield objects (or Promises which resolve to objects) +// with two properties: +// +// * send: The next XML stanza to send. +// * log: The plaintext content to log (instead of the stanza, which likely +// contains sensitive information). +// +// Alternately the object can have an error property which causes the account +// to disconnect with an ERROR_AUTHENTICATION_FAILED error. +// +// The response stanza from the server is sent to the generator each time it +// yields. Once the authentication negotiation is complete the generator should +// return. +// +// By default the PLAIN, SCRAM-SHA-1, and SCRAM-SHA-256 mechanisms are supported. +// +// As this is only used by XMPPSession, it may seem like an internal detail of +// the XMPP implementation, but exporting it is valuable for testing purposes. + +import { CommonUtils } from "resource://services-common/utils.sys.mjs"; +import { CryptoUtils } from "resource://services-crypto/utils.sys.mjs"; +import { Stanza } from "resource:///modules/xmpp-xml.sys.mjs"; + +// Handle PLAIN authorization mechanism. +function* PlainAuth(aUsername, aPassword, aDomain) { + let data = "\0" + aUsername + "\0" + aPassword; + + // btoa for Unicode, see https://developer.mozilla.org/en-US/docs/DOM/window.btoa + let base64Data = btoa(unescape(encodeURIComponent(data))); + + let stanza = yield { + send: Stanza.node( + "auth", + Stanza.NS.sasl, + { mechanism: "PLAIN" }, + base64Data + ), + log: '<auth mechanism:="PLAIN"/> (base64 encoded username and password not logged)', + }; + + if (stanza.localName != "success") { + throw new Error("Didn't receive the expected auth success stanza."); + } +} + +// Handle SCRAM-SHA-1 authorization mechanism. +const RFC3454 = { + A1: "\u0221|[\u0234-\u024f]|[\u02ae-\u02af]|[\u02ef-\u02ff]|\ +[\u0350-\u035f]|[\u0370-\u0373]|[\u0376-\u0379]|[\u037b-\u037d]|\ +[\u037f-\u0383]|\u038b|\u038d|\u03a2|\u03cf|[\u03f7-\u03ff]|\u0487|\ +\u04cf|[\u04f6-\u04f7]|[\u04fa-\u04ff]|[\u0510-\u0530]|\ +[\u0557-\u0558]|\u0560|\u0588|[\u058b-\u0590]|\u05a2|\u05ba|\ +[\u05c5-\u05cf]|[\u05eb-\u05ef]|[\u05f5-\u060b]|[\u060d-\u061a]|\ +[\u061c-\u061e]|\u0620|[\u063b-\u063f]|[\u0656-\u065f]|\ +[\u06ee-\u06ef]|\u06ff|\u070e|[\u072d-\u072f]|[\u074b-\u077f]|\ +[\u07b2-\u0900]|\u0904|[\u093a-\u093b]|[\u094e-\u094f]|\ +[\u0955-\u0957]|[\u0971-\u0980]|\u0984|[\u098d-\u098e]|\ +[\u0991-\u0992]|\u09a9|\u09b1|[\u09b3-\u09b5]|[\u09ba-\u09bb]|\u09bd|\ +[\u09c5-\u09c6]|[\u09c9-\u09ca]|[\u09ce-\u09d6]|[\u09d8-\u09db]|\ +\u09de|[\u09e4-\u09e5]|[\u09fb-\u0a01]|[\u0a03-\u0a04]|\ +[\u0a0b-\u0a0e]|[\u0a11-\u0a12]|\u0a29|\u0a31|\u0a34|\u0a37|\ +[\u0a3a-\u0a3b]|\u0a3d|[\u0a43-\u0a46]|[\u0a49-\u0a4a]|\ +[\u0a4e-\u0a58]|\u0a5d|[\u0a5f-\u0a65]|[\u0a75-\u0a80]|\u0a84|\u0a8c|\ +\u0a8e|\u0a92|\u0aa9|\u0ab1|\u0ab4|[\u0aba-\u0abb]|\u0ac6|\u0aca|\ +[\u0ace-\u0acf]|[\u0ad1-\u0adf]|[\u0ae1-\u0ae5]|[\u0af0-\u0b00]|\ +\u0b04|[\u0b0d-\u0b0e]|[\u0b11-\u0b12]|\u0b29|\u0b31|[\u0b34-\u0b35]|\ +[\u0b3a-\u0b3b]|[\u0b44-\u0b46]|[\u0b49-\u0b4a]|[\u0b4e-\u0b55]|\ +[\u0b58-\u0b5b]|\u0b5e|[\u0b62-\u0b65]|[\u0b71-\u0b81]|\u0b84|\ +[\u0b8b-\u0b8d]|\u0b91|[\u0b96-\u0b98]|\u0b9b|\u0b9d|[\u0ba0-\u0ba2]|\ +[\u0ba5-\u0ba7]|[\u0bab-\u0bad]|\u0bb6|[\u0bba-\u0bbd]|\ +[\u0bc3-\u0bc5]|\u0bc9|[\u0bce-\u0bd6]|[\u0bd8-\u0be6]|\ +[\u0bf3-\u0c00]|\u0c04|\u0c0d|\u0c11|\u0c29|\u0c34|[\u0c3a-\u0c3d]|\ +\u0c45|\u0c49|[\u0c4e-\u0c54]|[\u0c57-\u0c5f]|[\u0c62-\u0c65]|\ +[\u0c70-\u0c81]|\u0c84|\u0c8d|\u0c91|\u0ca9|\u0cb4|[\u0cba-\u0cbd]|\ +\u0cc5|\u0cc9|[\u0cce-\u0cd4]|[\u0cd7-\u0cdd]|\u0cdf|[\u0ce2-\u0ce5]|\ +[\u0cf0-\u0d01]|\u0d04|\u0d0d|\u0d11|\u0d29|[\u0d3a-\u0d3d]|\ +[\u0d44-\u0d45]|\u0d49|[\u0d4e-\u0d56]|[\u0d58-\u0d5f]|\ +[\u0d62-\u0d65]|[\u0d70-\u0d81]|\u0d84|[\u0d97-\u0d99]|\u0db2|\u0dbc|\ +[\u0dbe-\u0dbf]|[\u0dc7-\u0dc9]|[\u0dcb-\u0dce]|\u0dd5|\u0dd7|\ +[\u0de0-\u0df1]|[\u0df5-\u0e00]|[\u0e3b-\u0e3e]|[\u0e5c-\u0e80]|\ +\u0e83|[\u0e85-\u0e86]|\u0e89|[\u0e8b-\u0e8c]|[\u0e8e-\u0e93]|\u0e98|\ +\u0ea0|\u0ea4|\u0ea6|[\u0ea8-\u0ea9]|\u0eac|\u0eba|[\u0ebe-\u0ebf]|\ +\u0ec5|\u0ec7|[\u0ece-\u0ecf]|[\u0eda-\u0edb]|[\u0ede-\u0eff]|\u0f48|\ +[\u0f6b-\u0f70]|[\u0f8c-\u0f8f]|\u0f98|\u0fbd|[\u0fcd-\u0fce]|\ +[\u0fd0-\u0fff]|\u1022|\u1028|\u102b|[\u1033-\u1035]|[\u103a-\u103f]|\ +[\u105a-\u109f]|[\u10c6-\u10cf]|[\u10f9-\u10fa]|[\u10fc-\u10ff]|\ +[\u115a-\u115e]|[\u11a3-\u11a7]|[\u11fa-\u11ff]|\u1207|\u1247|\u1249|\ +[\u124e-\u124f]|\u1257|\u1259|[\u125e-\u125f]|\u1287|\u1289|\ +[\u128e-\u128f]|\u12af|\u12b1|[\u12b6-\u12b7]|\u12bf|\u12c1|\ +[\u12c6-\u12c7]|\u12cf|\u12d7|\u12ef|\u130f|\u1311|[\u1316-\u1317]|\ +\u131f|\u1347|[\u135b-\u1360]|[\u137d-\u139f]|[\u13f5-\u1400]|\ +[\u1677-\u167f]|[\u169d-\u169f]|[\u16f1-\u16ff]|\u170d|\ +[\u1715-\u171f]|[\u1737-\u173f]|[\u1754-\u175f]|\u176d|\u1771|\ +[\u1774-\u177f]|[\u17dd-\u17df]|[\u17ea-\u17ff]|\u180f|\ +[\u181a-\u181f]|[\u1878-\u187f]|[\u18aa-\u1dff]|[\u1e9c-\u1e9f]|\ +[\u1efa-\u1eff]|[\u1f16-\u1f17]|[\u1f1e-\u1f1f]|[\u1f46-\u1f47]|\ +[\u1f4e-\u1f4f]|\u1f58|\u1f5a|\u1f5c|\u1f5e|[\u1f7e-\u1f7f]|\u1fb5|\ +\u1fc5|[\u1fd4-\u1fd5]|\u1fdc|[\u1ff0-\u1ff1]|\u1ff5|\u1fff|\ +[\u2053-\u2056]|[\u2058-\u205e]|[\u2064-\u2069]|[\u2072-\u2073]|\ +[\u208f-\u209f]|[\u20b2-\u20cf]|[\u20eb-\u20ff]|[\u213b-\u213c]|\ +[\u214c-\u2152]|[\u2184-\u218f]|[\u23cf-\u23ff]|[\u2427-\u243f]|\ +[\u244b-\u245f]|\u24ff|[\u2614-\u2615]|\u2618|[\u267e-\u267f]|\ +[\u268a-\u2700]|\u2705|[\u270a-\u270b]|\u2728|\u274c|\u274e|\ +[\u2753-\u2755]|\u2757|[\u275f-\u2760]|[\u2795-\u2797]|\u27b0|\ +[\u27bf-\u27cf]|[\u27ec-\u27ef]|[\u2b00-\u2e7f]|\u2e9a|\ +[\u2ef4-\u2eff]|[\u2fd6-\u2fef]|[\u2ffc-\u2fff]|\u3040|\ +[\u3097-\u3098]|[\u3100-\u3104]|[\u312d-\u3130]|\u318f|\ +[\u31b8-\u31ef]|[\u321d-\u321f]|[\u3244-\u3250]|[\u327c-\u327e]|\ +[\u32cc-\u32cf]|\u32ff|[\u3377-\u337a]|[\u33de-\u33df]|\u33ff|\ +[\u4db6-\u4dff]|[\u9fa6-\u9fff]|[\ua48d-\ua48f]|[\ua4c7-\uabff]|\ +[\ud7a4-\ud7ff]|[\ufa2e-\ufa2f]|[\ufa6b-\ufaff]|[\ufb07-\ufb12]|\ +[\ufb18-\ufb1c]|\ufb37|\ufb3d|\ufb3f|\ufb42|\ufb45|[\ufbb2-\ufbd2]|\ +[\ufd40-\ufd4f]|[\ufd90-\ufd91]|[\ufdc8-\ufdcf]|[\ufdfd-\ufdff]|\ +[\ufe10-\ufe1f]|[\ufe24-\ufe2f]|[\ufe47-\ufe48]|\ufe53|\ufe67|\ +[\ufe6c-\ufe6f]|\ufe75|[\ufefd-\ufefe]|\uff00|[\uffbf-\uffc1]|\ +[\uffc8-\uffc9]|[\uffd0-\uffd1]|[\uffd8-\uffd9]|[\uffdd-\uffdf]|\ +\uffe7|[\uffef-\ufff8]|[\u{10000}-\u{102ff}]|\u{1031f}|\ +[\u{10324}-\u{1032f}]|[\u{1034b}-\u{103ff}]|[\u{10426}-\u{10427}]|\ +[\u{1044e}-\u{1cfff}]|[\u{1d0f6}-\u{1d0ff}]|[\u{1d127}-\u{1d129}]|\ +[\u{1d1de}-\u{1d3ff}]|\u{1d455}|\u{1d49d}|[\u{1d4a0}-\u{1d4a1}]|\ +[\u{1d4a3}-\u{1d4a4}]|[\u{1d4a7}-\u{1d4a8}]|\u{1d4ad}|\u{1d4ba}|\ +\u{1d4bc}|\u{1d4c1}|\u{1d4c4}|\u{1d506}|[\u{1d50b}-\u{1d50c}]|\ +\u{1d515}|\u{1d51d}|\u{1d53a}|\u{1d53f}|\u{1d545}|\ +[\u{1d547}-\u{1d549}]|\u{1d551}|[\u{1d6a4}-\u{1d6a7}]|\ +[\u{1d7ca}-\u{1d7cd}]|[\u{1d800}-\u{1fffd}]|[\u{2a6d7}-\u{2f7ff}]|\ +[\u{2fa1e}-\u{2fffd}]|[\u{30000}-\u{3fffd}]|[\u{40000}-\u{4fffd}]|\ +[\u{50000}-\u{5fffd}]|[\u{60000}-\u{6fffd}]|[\u{70000}-\u{7fffd}]|\ +[\u{80000}-\u{8fffd}]|[\u{90000}-\u{9fffd}]|[\u{a0000}-\u{afffd}]|\ +[\u{b0000}-\u{bfffd}]|[\u{c0000}-\u{cfffd}]|[\u{d0000}-\u{dfffd}]|\ +\u{e0000}|[\u{e0002}-\u{e001f}]|[\u{e0080}-\u{efffd}]", + B1: "\u00ad|\u034f|\u1806|[\u180b-\u180d]|[\u200b-\u200d]|\u2060|\ +[\ufe00-\ufe0f]|\ufeff", + C12: "\u00a0|\u1680|[\u2000-\u200b]|\u202f|\u205f|\u3000", + C21: "[\u0000-\u001f]|\u007f", + C22: "[\u0080-\u009f]|\u06dd|\u070f|\u180e|\u200c|\u200d|\u2028|\u2029|\ +[\u2060-\u2063]|[\u206a-\u206f]|\ufeff|[\ufff9-\ufffc]", + C3: "[\ue000-\uf8ff]|[\u{f0000}-\u{ffffd}]|[\u{100000}-\u{10fffd}]", + C4: "[\ufdd0-\ufdef]|[\ufffe-\uffff]|[\u{1fffe}-\u{1ffff}]|\ +[\u{2fffe}-\u{2ffff}]|[\u{3fffe}-\u{3ffff}]|[\u{4fffe}-\u{4ffff}]|\ +[\u{5fffe}-\u{5ffff}]|[\u{6fffe}-\u{6ffff}]|[\u{7fffe}-\u{7ffff}]|\ +[\u{8fffe}-\u{8ffff}]|[\u{9fffe}-\u{9ffff}]|[\u{afffe}-\u{affff}]|\ +[\u{bfffe}-\u{bffff}]|[\u{cfffe}-\u{cffff}]|[\u{dfffe}-\u{dffff}]|\ +[\u{efffe}-\u{effff}]|[\u{ffffe}-\u{fffff}]|[\u{10fffe}-\u{10ffff}]", + C5: "[\ud800-\udfff]", + C6: "\ufff9|[\ufffa-\ufffd]", + C7: "[\u2ff0-\u2ffb]", + C8: "\u0340|\u0341|\u200e|\u200f|[\u202a-\u202e]|[\u206a-\u206f]", + C9: "\u{e0001}|[\u{e0020}-\u{e007f}]", + D1: "\u05be|\u05c0|\u05c3|[\u05d0-\u05ea]|[\u05f0-\u05f4]|\u061b|\u061f|\ +[\u0621-\u063a]|[\u0640-\u064a]|[\u066d-\u066f]|[\u0671-\u06d5]|\ +\u06dd|[\u06e5-\u06e6]|[\u06fa-\u06fe]|[\u0700-\u070d]|\u0710|\ +[\u0712-\u072c]|[\u0780-\u07a5]|\u07b1|\u200f|\ufb1d|[\ufb1f-\ufb28]|\ +[\ufb2a-\ufb36]|[\ufb38-\ufb3c]|\ufb3e|[\ufb40-\ufb41]|\ +[\ufb43-\ufb44]|[\ufb46-\ufbb1]|[\ufbd3-\ufd3d]|[\ufd50-\ufd8f]|\ +[\ufd92-\ufdc7]|[\ufdf0-\ufdfc]|[\ufe70-\ufe74]|[\ufe76-\ufefc]", + D2: "[\u0041-\u005a]|[\u0061-\u007a]|\u00aa|\u00b5|\u00ba|[\u00c0-\u00d6]|\ +[\u00d8-\u00f6]|[\u00f8-\u0220]|[\u0222-\u0233]|[\u0250-\u02ad]|\ +[\u02b0-\u02b8]|[\u02bb-\u02c1]|[\u02d0-\u02d1]|[\u02e0-\u02e4]|\ +\u02ee|\u037a|\u0386|[\u0388-\u038a]|\u038c|[\u038e-\u03a1]|\ +[\u03a3-\u03ce]|[\u03d0-\u03f5]|[\u0400-\u0482]|[\u048a-\u04ce]|\ +[\u04d0-\u04f5]|[\u04f8-\u04f9]|[\u0500-\u050f]|[\u0531-\u0556]|\ +[\u0559-\u055f]|[\u0561-\u0587]|\u0589|\u0903|[\u0905-\u0939]|\ +[\u093d-\u0940]|[\u0949-\u094c]|\u0950|[\u0958-\u0961]|\ +[\u0964-\u0970]|[\u0982-\u0983]|[\u0985-\u098c]|[\u098f-\u0990]|\ +[\u0993-\u09a8]|[\u09aa-\u09b0]|\u09b2|[\u09b6-\u09b9]|\ +[\u09be-\u09c0]|[\u09c7-\u09c8]|[\u09cb-\u09cc]|\u09d7|\ +[\u09dc-\u09dd]|[\u09df-\u09e1]|[\u09e6-\u09f1]|[\u09f4-\u09fa]|\ +[\u0a05-\u0a0a]|[\u0a0f-\u0a10]|[\u0a13-\u0a28]|[\u0a2a-\u0a30]|\ +[\u0a32-\u0a33]|[\u0a35-\u0a36]|[\u0a38-\u0a39]|[\u0a3e-\u0a40]|\ +[\u0a59-\u0a5c]|\u0a5e|[\u0a66-\u0a6f]|[\u0a72-\u0a74]|\u0a83|\ +[\u0a85-\u0a8b]|\u0a8d|[\u0a8f-\u0a91]|[\u0a93-\u0aa8]|\ +[\u0aaa-\u0ab0]|[\u0ab2-\u0ab3]|[\u0ab5-\u0ab9]|[\u0abd-\u0ac0]|\ +\u0ac9|[\u0acb-\u0acc]|\u0ad0|\u0ae0|[\u0ae6-\u0aef]|[\u0b02-\u0b03]|\ +[\u0b05-\u0b0c]|[\u0b0f-\u0b10]|[\u0b13-\u0b28]|[\u0b2a-\u0b30]|\ +[\u0b32-\u0b33]|[\u0b36-\u0b39]|[\u0b3d-\u0b3e]|\u0b40|\ +[\u0b47-\u0b48]|[\u0b4b-\u0b4c]|\u0b57|[\u0b5c-\u0b5d]|\ +[\u0b5f-\u0b61]|[\u0b66-\u0b70]|\u0b83|[\u0b85-\u0b8a]|\ +[\u0b8e-\u0b90]|[\u0b92-\u0b95]|[\u0b99-\u0b9a]|\u0b9c|\ +[\u0b9e-\u0b9f]|[\u0ba3-\u0ba4]|[\u0ba8-\u0baa]|[\u0bae-\u0bb5]|\ +[\u0bb7-\u0bb9]|[\u0bbe-\u0bbf]|[\u0bc1-\u0bc2]|[\u0bc6-\u0bc8]|\ +[\u0bca-\u0bcc]|\u0bd7|[\u0be7-\u0bf2]|[\u0c01-\u0c03]|\ +[\u0c05-\u0c0c]|[\u0c0e-\u0c10]|[\u0c12-\u0c28]|[\u0c2a-\u0c33]|\ +[\u0c35-\u0c39]|[\u0c41-\u0c44]|[\u0c60-\u0c61]|[\u0c66-\u0c6f]|\ +[\u0c82-\u0c83]|[\u0c85-\u0c8c]|[\u0c8e-\u0c90]|[\u0c92-\u0ca8]|\ +[\u0caa-\u0cb3]|[\u0cb5-\u0cb9]|\u0cbe|[\u0cc0-\u0cc4]|\ +[\u0cc7-\u0cc8]|[\u0cca-\u0ccb]|[\u0cd5-\u0cd6]|\u0cde|\ +[\u0ce0-\u0ce1]|[\u0ce6-\u0cef]|[\u0d02-\u0d03]|[\u0d05-\u0d0c]|\ +[\u0d0e-\u0d10]|[\u0d12-\u0d28]|[\u0d2a-\u0d39]|[\u0d3e-\u0d40]|\ +[\u0d46-\u0d48]|[\u0d4a-\u0d4c]|\u0d57|[\u0d60-\u0d61]|\ +[\u0d66-\u0d6f]|[\u0d82-\u0d83]|[\u0d85-\u0d96]|[\u0d9a-\u0db1]|\ +[\u0db3-\u0dbb]|\u0dbd|[\u0dc0-\u0dc6]|[\u0dcf-\u0dd1]|\ +[\u0dd8-\u0ddf]|[\u0df2-\u0df4]|[\u0e01-\u0e30]|[\u0e32-\u0e33]|\ +[\u0e40-\u0e46]|[\u0e4f-\u0e5b]|[\u0e81-\u0e82]|\u0e84|\ +[\u0e87-\u0e88]|\u0e8a|\u0e8d|[\u0e94-\u0e97]|[\u0e99-\u0e9f]|\ +[\u0ea1-\u0ea3]|\u0ea5|\u0ea7|[\u0eaa-\u0eab]|[\u0ead-\u0eb0]|\ +[\u0eb2-\u0eb3]|\u0ebd|[\u0ec0-\u0ec4]|\u0ec6|[\u0ed0-\u0ed9]|\ +[\u0edc-\u0edd]|[\u0f00-\u0f17]|[\u0f1a-\u0f34]|\u0f36|\u0f38|\ +[\u0f3e-\u0f47]|[\u0f49-\u0f6a]|\u0f7f|\u0f85|[\u0f88-\u0f8b]|\ +[\u0fbe-\u0fc5]|[\u0fc7-\u0fcc]|\u0fcf|[\u1000-\u1021]|\ +[\u1023-\u1027]|[\u1029-\u102a]|\u102c|\u1031|\u1038|[\u1040-\u1057]|\ +[\u10a0-\u10c5]|[\u10d0-\u10f8]|\u10fb|[\u1100-\u1159]|\ +[\u115f-\u11a2]|[\u11a8-\u11f9]|[\u1200-\u1206]|[\u1208-\u1246]|\ +\u1248|[\u124a-\u124d]|[\u1250-\u1256]|\u1258|[\u125a-\u125d]|\ +[\u1260-\u1286]|\u1288|[\u128a-\u128d]|[\u1290-\u12ae]|\u12b0|\ +[\u12b2-\u12b5]|[\u12b8-\u12be]|\u12c0|[\u12c2-\u12c5]|\ +[\u12c8-\u12ce]|[\u12d0-\u12d6]|[\u12d8-\u12ee]|[\u12f0-\u130e]|\ +\u1310|[\u1312-\u1315]|[\u1318-\u131e]|[\u1320-\u1346]|\ +[\u1348-\u135a]|[\u1361-\u137c]|[\u13a0-\u13f4]|[\u1401-\u1676]|\ +[\u1681-\u169a]|[\u16a0-\u16f0]|[\u1700-\u170c]|[\u170e-\u1711]|\ +[\u1720-\u1731]|[\u1735-\u1736]|[\u1740-\u1751]|[\u1760-\u176c]|\ +[\u176e-\u1770]|[\u1780-\u17b6]|[\u17be-\u17c5]|[\u17c7-\u17c8]|\ +[\u17d4-\u17da]|\u17dc|[\u17e0-\u17e9]|[\u1810-\u1819]|\ +[\u1820-\u1877]|[\u1880-\u18a8]|[\u1e00-\u1e9b]|[\u1ea0-\u1ef9]|\ +[\u1f00-\u1f15]|[\u1f18-\u1f1d]|[\u1f20-\u1f45]|[\u1f48-\u1f4d]|\ +[\u1f50-\u1f57]|\u1f59|\u1f5b|\u1f5d|[\u1f5f-\u1f7d]|[\u1f80-\u1fb4]|\ +[\u1fb6-\u1fbc]|\u1fbe|[\u1fc2-\u1fc4]|[\u1fc6-\u1fcc]|\ +[\u1fd0-\u1fd3]|[\u1fd6-\u1fdb]|[\u1fe0-\u1fec]|[\u1ff2-\u1ff4]|\ +[\u1ff6-\u1ffc]|\u200e|\u2071|\u207f|\u2102|\u2107|[\u210a-\u2113]|\ +\u2115|[\u2119-\u211d]|\u2124|\u2126|\u2128|[\u212a-\u212d]|\ +[\u212f-\u2131]|[\u2133-\u2139]|[\u213d-\u213f]|[\u2145-\u2149]|\ +[\u2160-\u2183]|[\u2336-\u237a]|\u2395|[\u249c-\u24e9]|\ +[\u3005-\u3007]|[\u3021-\u3029]|[\u3031-\u3035]|[\u3038-\u303c]|\ +[\u3041-\u3096]|[\u309d-\u309f]|[\u30a1-\u30fa]|[\u30fc-\u30ff]|\ +[\u3105-\u312c]|[\u3131-\u318e]|[\u3190-\u31b7]|[\u31f0-\u321c]|\ +[\u3220-\u3243]|[\u3260-\u327b]|[\u327f-\u32b0]|[\u32c0-\u32cb]|\ +[\u32d0-\u32fe]|[\u3300-\u3376]|[\u337b-\u33dd]|[\u33e0-\u33fe]|\ +[\u3400-\u4db5]|[\u4e00-\u9fa5]|[\ua000-\ua48c]|[\uac00-\ud7a3]|\ +[\ud800-\ufa2d]|[\ufa30-\ufa6a]|[\ufb00-\ufb06]|[\ufb13-\ufb17]|\ +[\uff21-\uff3a]|[\uff41-\uff5a]|[\uff66-\uffbe]|[\uffc2-\uffc7]|\ +[\uffca-\uffcf]|[\uffd2-\uffd7]|[\uffda-\uffdc]|[\u{10300}-\u{1031e}]|\ +[\u{10320}-\u{10323}]|[\u{10330}-\u{1034a}]|[\u{10400}-\u{10425}]|\ +[\u{10428}-\u{1044d}]|[\u{1d000}-\u{1d0f5}]|[\u{1d100}-\u{1d126}]|\ +[\u{1d12a}-\u{1d166}]|[\u{1d16a}-\u{1d172}]|[\u{1d183}-\u{1d184}]|\ +[\u{1d18c}-\u{1d1a9}]|[\u{1d1ae}-\u{1d1dd}]|[\u{1d400}-\u{1d454}]|\ +[\u{1d456}-\u{1d49c}]|[\u{1d49e}-\u{1d49f}]|\u{1d4a2}|\ +[\u{1d4a5}-\u{1d4a6}]|[\u{1d4a9}-\u{1d4ac}]|[\u{1d4ae}-\u{1d4b9}]|\ +\u{1d4bb}|[\u{1d4bd}-\u{1d4c0}]|[\u{1d4c2}-\u{1d4c3}]|\ +[\u{1d4c5}-\u{1d505}]|[\u{1d507}-\u{1d50a}]|[\u{1d50d}-\u{1d514}]|\ +[\u{1d516}-\u{1d51c}]|[\u{1d51e}-\u{1d539}]|[\u{1d53b}-\u{1d53e}]|\ +[\u{1d540}-\u{1d544}]|\u{1d546}|[\u{1d54a}-\u{1d550}]|\ +[\u{1d552}-\u{1d6a3}]|[\u{1d6a8}-\u{1d7c9}]|[\u{20000}-\u{2a6d6}]|\ +[\u{2f800}-\u{2fa1d}]|[\u{f0000}-\u{ffffd}]|[\u{100000}-\u{10fffd}]", +}; + +// Generates a random nonce and returns a base64 encoded string. +// aLength in bytes. +function createNonce(aLength) { + // RFC 5802 (5.1): Printable ASCII except ",". + // We guarantee a valid nonce value using base64 encoding. + return btoa(CryptoUtils.generateRandomBytes(aLength)); +} + +// Parses the string of server's response (aChallenge) into an object. +function parseChallenge(aChallenge) { + let attributes = {}; + aChallenge.split(",").forEach(value => { + let match = /^(\w)=([\s\S]*)$/.exec(value); + if (match) { + attributes[match[1]] = match[2]; + } + }); + return attributes; +} + +// RFC 4013 and RFC 3454: Stringprep Profile for User Names and Passwords. +export function saslPrep(aString) { + // RFC 4013 2.1: non-ASCII space characters (RFC 3454 C.1.2) mapped to space. + let retVal = aString.replace(new RegExp(RFC3454.C12, "u"), " "); + + // RFC 4013 2.1: RFC 3454 3.1, B.1: Map certain codepoints to nothing. + retVal = retVal.replace(new RegExp(RFC3454.B1, "u"), ""); + + // RFC 4013 2.2 asks for Unicode normalization form KC, which corresponds to + // RFC 3454 B.2. + retVal = retVal.normalize("NFKC"); + + // RFC 4013 2.3: Prohibited Output and 2.5: Unassigned Code Points. + let matchStr = + RFC3454.C12 + + "|" + + RFC3454.C21 + + "|" + + RFC3454.C22 + + "|" + + RFC3454.C3 + + "|" + + RFC3454.C4 + + "|" + + RFC3454.C5 + + "|" + + RFC3454.C6 + + "|" + + RFC3454.C7 + + "|" + + RFC3454.C8 + + "|" + + RFC3454.C9 + + "|" + + RFC3454.A1; + let match = new RegExp(matchStr, "u").test(retVal); + if (match) { + throw new Error("String contains prohibited characters"); + } + + // RFC 4013 2.4: Bidirectional Characters. + let r = new RegExp(RFC3454.D1, "u").test(retVal); + let l = new RegExp(RFC3454.D2, "u").test(retVal); + if (l && r) { + throw new Error( + "String must not contain LCat and RandALCat characters together" + ); + } else if (r) { + let matchFirst = new RegExp("^(" + RFC3454.D1 + ")", "u").test(retVal); + let matchLast = new RegExp("(" + RFC3454.D1 + ")$", "u").test(retVal); + if (!matchFirst || !matchLast) { + throw new Error( + "A RandALCat character must be the first and the last character" + ); + } + } + + return retVal; +} + +// Converts aName to saslname. +function saslName(aName) { + // RFC 5802 (5.1): the client SHOULD prepare the username using the "SASLprep". + // The characters ’,’ or ’=’ in usernames are sent as ’=2C’ and + // ’=3D’ respectively. + let saslName = saslPrep(aName).replace(/=/g, "=3D").replace(/,/g, "=2C"); + if (!saslName) { + throw new Error("Name is not valid"); + } + + return saslName; +} + +// Converts aMessage to array of bytes then apply hashing. +function bytesAndHash(aMessage, aHash) { + let hasher = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + hasher.init(hasher[aHash]); + + return CryptoUtils.digestBytes(aMessage, hasher); +} + +/** + * PBKDF2 password stretching with hmac. + * + * This is a copy of CryptoUtils.pbkdf2Generate, but with an additional argument to take the hash type. + * + * @param {string} passphrase Passphrase as an octet string. + * @param {string} salt Salt as an octet string. + * @param {string} iterations Number of iterations, a positive integer. + * @param {string} len Desired output length in bytes. + * @param {string} hash The desired hash algorithm (e.g. SHA-1 or SHA-256). + * @returns {Uint8Array} + */ +async function pbkdf2Generate(passphrase, salt, iterations, len, hash) { + passphrase = CommonUtils.byteStringToArrayBuffer(passphrase); + salt = CommonUtils.byteStringToArrayBuffer(salt); + const key = await crypto.subtle.importKey( + "raw", + passphrase, + { name: "PBKDF2" }, + false, + ["deriveBits"] + ); + const output = await crypto.subtle.deriveBits( + { + name: "PBKDF2", + hash, + salt, + iterations, + }, + key, + len * 8 + ); + return new Uint8Array(output); +} + +/* + * Given hash functions return a generator to be used as an XMPP authentication + * mechanism. + * + * @param {string} aHashFunctionName The name of a hash, e.g. SHA-1 or SHA-256. + * @param {string} aDigestLength The length of a hash digest, e.g. 20 for SHA-1 or 32 for SHA-256. + */ +function generateScramAuth(aHashFunctionName, aDigestLength) { + function* scramAuth(aUsername, aPassword, aDomain, aNonce) { + // The hash function name, without the '-' in it (e.g. convert SHA-1 to SHA1). + const hashFunctionProp = aHashFunctionName.replace("-", ""); + + // RFC 5802 (5): SCRAM Authentication Exchange. + const gs2Header = "n,,"; + // If a hard-coded nonce was given (e.g. for testing), use it. + let cNonce = aNonce ? aNonce : createNonce(32); + + let clientFirstMessageBare = "n=" + saslName(aUsername) + ",r=" + cNonce; + let clientFirstMessage = gs2Header + clientFirstMessageBare; + + let receivedStanza = yield { + send: Stanza.node( + "auth", + Stanza.NS.sasl, + { mechanism: "SCRAM-" + aHashFunctionName }, + btoa(clientFirstMessage) + ), + }; + + if (receivedStanza.localName != "challenge") { + throw new Error("Not authorized"); + } + + // RFC 5802 (3): SCRAM Algorithm Overview. + let decodedChallenge = atob(receivedStanza.innerText); + + // Expected to contain the user’s iteration count (i) and the user’s + // salt (s), and the server appends its own nonce to the client-specified + // one (r). + let attributes = parseChallenge(decodedChallenge); + if (attributes.hasOwnProperty("e")) { + throw new Error("Authentication failed: " + attributes.e); + } else if ( + !attributes.hasOwnProperty("i") || + !attributes.hasOwnProperty("s") || + !attributes.hasOwnProperty("r") + ) { + throw new Error("Unexpected response: " + decodedChallenge); + } + if (!attributes.r.startsWith(cNonce)) { + throw new Error("Nonce is not correct"); + } + + let clientFinalMessageWithoutProof = + "c=" + btoa(gs2Header) + ",r=" + attributes.r; + + // The server signature is calculated below, but needs to escape back to the main scope. + let serverSignature; + + // Once the promise resolves, continue with the handshake. + receivedStanza = yield (async () => { + // SaltedPassword := Hi(Normalize(password), salt, i) + // Normalize using saslPrep. + // dkLen MUST be equal to the SHA digest size. + let saltedPassword = await pbkdf2Generate( + saslPrep(aPassword), + atob(attributes.s), + parseInt(attributes.i), + aDigestLength, + aHashFunctionName + ); + + // Calculate ClientProof. + + // ClientKey := HMAC(SaltedPassword, "Client Key") + let clientKeyBuffer = await CryptoUtils.hmac( + aHashFunctionName, + saltedPassword, + CommonUtils.byteStringToArrayBuffer("Client Key") + ); + let clientKey = CommonUtils.arrayBufferToByteString(clientKeyBuffer); + + // StoredKey := H(ClientKey) + let storedKey = bytesAndHash(clientKey, hashFunctionProp); + + let authMessage = CommonUtils.byteStringToArrayBuffer( + clientFirstMessageBare + + "," + + decodedChallenge + + "," + + clientFinalMessageWithoutProof + ); + + // ClientSignature := HMAC(StoredKey, AuthMessage) + let clientSignatureBuffer = await CryptoUtils.hmac( + aHashFunctionName, + CommonUtils.byteStringToArrayBuffer(storedKey), + authMessage + ); + let clientSignature = CommonUtils.arrayBufferToByteString( + clientSignatureBuffer + ); + // ClientProof := ClientKey XOR ClientSignature + let clientProof = CryptoUtils.xor(clientKey, clientSignature); + + // Calculate ServerSignature. + + // ServerKey := HMAC(SaltedPassword, "Server Key") + let serverKeyBuffer = await CryptoUtils.hmac( + aHashFunctionName, + saltedPassword, + CommonUtils.byteStringToArrayBuffer("Server Key") + ); + + // ServerSignature := HMAC(ServerKey, AuthMessage) + let serverSignatureBuffer = await CryptoUtils.hmac( + aHashFunctionName, + serverKeyBuffer, + authMessage + ); + serverSignature = CommonUtils.arrayBufferToByteString( + serverSignatureBuffer + ); + + let clientFinalMessage = + clientFinalMessageWithoutProof + ",p=" + btoa(clientProof); + + return { + send: Stanza.node( + "response", + Stanza.NS.sasl, + null, + btoa(clientFinalMessage) + ), + log: "<response/> (base64 encoded SCRAM response containing password not logged)", + }; + })(); + + // Only check server signature if we succeed to authenticate. + if (receivedStanza.localName != "success") { + throw new Error("Didn't receive the expected auth success stanza."); + } + + let decodedResponse = atob(receivedStanza.innerText); + + // Expected to contain a base64-encoded ServerSignature (v). + attributes = parseChallenge(decodedResponse); + if (!attributes.hasOwnProperty("v")) { + throw new Error("Unexpected response: " + decodedResponse); + } + + // Compare ServerSignature with our ServerSignature which we calculated in + // _generateResponse. + let serverSignatureResponse = atob(attributes.v); + if (serverSignature != serverSignatureResponse) { + throw new Error("Server signature does not match"); + } + } + + return scramAuth; +} + +export var XMPPAuthMechanisms = { + PLAIN: PlainAuth, + "SCRAM-SHA-1": generateScramAuth("SHA-1", 20), + "SCRAM-SHA-256": generateScramAuth("SHA-256", 32), +}; diff --git a/comm/chat/protocols/xmpp/xmpp-base.sys.mjs b/comm/chat/protocols/xmpp/xmpp-base.sys.mjs new file mode 100644 index 0000000000..f7e0ccd98e --- /dev/null +++ b/comm/chat/protocols/xmpp/xmpp-base.sys.mjs @@ -0,0 +1,3421 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs"; + +import { IMServices } from "resource:///modules/IMServices.sys.mjs"; +import { Status } from "resource:///modules/imStatusUtils.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { + executeSoon, + nsSimpleEnumerator, + EmptyEnumerator, + ClassInfo, + l10nHelper, +} from "resource:///modules/imXPCOMUtils.sys.mjs"; +import { + GenericAccountPrototype, + GenericAccountBuddyPrototype, + GenericConvIMPrototype, + GenericConvChatPrototype, + GenericConversationPrototype, + TooltipInfo, +} from "resource:///modules/jsProtoHelper.sys.mjs"; +import { NormalizedMap } from "resource:///modules/NormalizedMap.sys.mjs"; +import { + Stanza, + SupportedFeatures, +} from "resource:///modules/xmpp-xml.sys.mjs"; +import { XMPPSession } from "resource:///modules/xmpp-session.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", +}); +ChromeUtils.defineModuleGetter( + lazy, + "NetUtil", + "resource://gre/modules/NetUtil.jsm" +); +XPCOMUtils.defineLazyServiceGetter( + lazy, + "imgTools", + "@mozilla.org/image/tools;1", + "imgITools" +); + +XPCOMUtils.defineLazyGetter(lazy, "_", () => + l10nHelper("chrome://chat/locale/xmpp.properties") +); + +XPCOMUtils.defineLazyGetter(lazy, "TXTToHTML", function () { + let cs = Cc["@mozilla.org/txttohtmlconv;1"].getService(Ci.mozITXTToHTMLConv); + return aTxt => cs.scanTXT(aTxt, cs.kEntities); +}); + +// Parses the status from a presence stanza into an object of statusType, +// statusText and idleSince. +function parseStatus(aStanza) { + let statusType = Ci.imIStatusInfo.STATUS_AVAILABLE; + let show = aStanza.getElement(["show"]); + if (show) { + show = show.innerText; + if (show == "away") { + statusType = Ci.imIStatusInfo.STATUS_AWAY; + } else if (show == "chat") { + statusType = Ci.imIStatusInfo.STATUS_AVAILABLE; // FIXME + } else if (show == "dnd") { + statusType = Ci.imIStatusInfo.STATUS_UNAVAILABLE; + } else if (show == "xa") { + statusType = Ci.imIStatusInfo.STATUS_IDLE; + } + } + + let idleSince = 0; + let date = _getDelay(aStanza); + if (date) { + idleSince = date.getTime(); + } + + let query = aStanza.getElement(["query"]); + if (query && query.uri == Stanza.NS.last) { + let now = Math.floor(Date.now() / 1000); + idleSince = now - parseInt(query.attributes.seconds, 10); + statusType = Ci.imIStatusInfo.STATUS_IDLE; + } + + let status = aStanza.getElement(["status"]); + status = status ? status.innerText : ""; + + return { statusType, statusText: status, idleSince }; +} + +// Returns a Date object for the delay value (stamp) in aStanza if it exists, +// otherwise returns undefined. +function _getDelay(aStanza) { + // XEP-0203: Delayed Delivery. + let date; + let delay = aStanza.getElement(["delay"]); + if (delay && delay.uri == Stanza.NS.delay) { + if (delay.attributes.stamp) { + date = new Date(delay.attributes.stamp); + } + } + if (date && isNaN(date.getTime())) { + return undefined; + } + + return date; +} + +// Writes aMsg in aConv as an outgoing message with optional date as the +// message may be sent from another client. +function _displaySentMsg(aConv, aMsg, aDate) { + let who; + if (aConv._account._connection) { + who = aConv._account._connection._jid.jid; + } + if (!who) { + who = aConv._account.name; + } + + let flags = { outgoing: true }; + flags._alias = aConv.account.alias || aConv.account.statusInfo.displayName; + + if (aDate) { + flags.time = aDate / 1000; + flags.delayed = true; + } + aConv.writeMessage(who, aMsg, flags); +} + +// The timespan after which we consider roomInfo to be stale. +var kListRefreshInterval = 12 * 60 * 60 * 1000; // 12 hours. + +/* This is an ordered list, used to determine chat buddy flags: + * index = member -> voiced + * moderator -> moderator + * admin -> admin + * owner -> founder + */ +var kRoles = [ + "outcast", + "visitor", + "participant", + "member", + "moderator", + "admin", + "owner", +]; + +function MUCParticipant(aNick, aJid, aPresenceStanza) { + this._jid = aJid; + this.name = aNick; + this.onPresenceStanza(aPresenceStanza); +} +MUCParticipant.prototype = { + __proto__: ClassInfo("prplIConvChatBuddy", "XMPP ConvChatBuddy object"), + + buddy: false, + + // The occupant jid of the participant which is of the form room@domain/nick. + _jid: null, + + // The real jid of the participant which is of the form local@domain/resource. + accountJid: null, + + statusType: null, + statusText: null, + get alias() { + return this.name; + }, + + role: 2, // "participant" by default + + // Called when a presence stanza is received for this participant. + onPresenceStanza(aStanza) { + let statusInfo = parseStatus(aStanza); + this.statusType = statusInfo.statusType; + this.statusText = statusInfo.statusText; + + let x = aStanza.children.filter( + child => child.localName == "x" && child.uri == Stanza.NS.muc_user + ); + if (x.length == 0) { + return; + } + + // XEP-0045 (7.2.3): We only expect a single <x/> element of this namespace, + // so we ignore any others. + x = x[0]; + + let item = x.getElement(["item"]); + if (!item) { + return; + } + + this.role = Math.max( + kRoles.indexOf(item.attributes.role), + kRoles.indexOf(item.attributes.affiliation) + ); + + let accountJid = item.attributes.jid; + if (accountJid) { + this.accountJid = accountJid; + } + }, + + get voiced() { + /* FIXME: The "voiced" role corresponds to users that can send messages to + * the room. If the chat is unmoderated, this should include everyone, not + * just members. */ + return this.role == kRoles.indexOf("member"); + }, + get moderator() { + return this.role == kRoles.indexOf("moderator"); + }, + get admin() { + return this.role == kRoles.indexOf("admin"); + }, + get founder() { + return this.role == kRoles.indexOf("owner"); + }, + typing: false, +}; + +// MUC (Multi-User Chat) +export var XMPPMUCConversationPrototype = { + __proto__: GenericConvChatPrototype, + // By default users are not in a MUC. + _left: true, + + // Tracks all received messages to avoid possible duplication if the server + // sends us the last few messages again when we rejoin a room. + _messageIds: new Set(), + + _init(aAccount, aJID, aNick) { + this._messageIds = new Set(); + GenericConvChatPrototype._init.call(this, aAccount, aJID, aNick); + }, + + _targetResource: "", + + // True while we are rejoining a room previously parted by the user. + _rejoined: false, + + get topic() { + return this._topic; + }, + set topic(aTopic) { + // XEP-0045 (8.1): Modifying the room subject. + let subject = Stanza.node("subject", null, null, aTopic.trim()); + let s = Stanza.message( + this.name, + null, + null, + { type: "groupchat" }, + subject + ); + let notAuthorized = lazy._( + "conversation.error.changeTopicFailedNotAuthorized" + ); + this._account.sendStanza( + s, + this._account.handleErrors( + { + forbidden: notAuthorized, + notAcceptable: notAuthorized, + itemNotFound: notAuthorized, + }, + this + ) + ); + }, + get topicSettable() { + return true; + }, + + /* Called when the user enters a chat message */ + dispatchMessage(aMsg, aAction = false) { + if (aAction) { + // XEP-0245: The /me Command. + // We need to prepend "/me " as the first four characters of the message + // body. + aMsg = "/me " + aMsg; + } + // XEP-0045 (7.4): Sending a message to all occupants in a room. + let s = Stanza.message(this.name, aMsg, null, { type: "groupchat" }); + let notInRoom = lazy._( + "conversation.error.sendFailedAsNotInRoom", + this.name, + aMsg + ); + this._account.sendStanza( + s, + this._account.handleErrors( + { + itemNotFound: notInRoom, + notAcceptable: notInRoom, + }, + this + ) + ); + }, + + /* Called by the account when a presence stanza is received for this muc */ + onPresenceStanza(aStanza) { + let from = aStanza.attributes.from; + let nick = this._account._parseJID(from).resource; + let jid = this._account.normalize(from); + let x = aStanza + .getElements(["x"]) + .find( + e => e.uri == Stanza.NS.muc_user || e.uri == Stanza.NS.vcard_update + ); + + // Check if the join failed. + if (this.left && aStanza.attributes.type == "error") { + let error = this._account.parseError(aStanza); + let message; + switch (error.condition) { + case "not-authorized": + case "registration-required": + // XEP-0045 (7.2.7): Members-Only Rooms. + message = lazy._("conversation.error.joinFailedNotAuthorized"); + break; + case "not-allowed": + message = lazy._("conversation.error.creationFailedNotAllowed"); + break; + case "remote-server-not-found": + message = lazy._( + "conversation.error.joinFailedRemoteServerNotFound", + this.name + ); + break; + case "forbidden": + // XEP-0045 (7.2.8): Banned users. + message = lazy._("conversation.error.joinForbidden", this.name); + break; + default: + message = lazy._("conversation.error.joinFailed", this.name); + this.ERROR("Failed to join MUC: " + aStanza.convertToString()); + break; + } + this.writeMessage(this.name, message, { system: true, error: true }); + this.joining = false; + return; + } + + if (!x) { + this.WARN( + "Received a MUC presence stanza without an x element or " + + "with a namespace we don't handle." + ); + return; + } + // Handle a MUC resource avatar + if ( + x.uri == Stanza.NS.vcard_update && + aStanza.attributes.from == this.normalizedName + ) { + let photo = aStanza.getElement(["x", "photo"]); + if (photo && photo.uri == Stanza.NS.vcard_update) { + let hash = photo.innerText; + if (hash && hash != this._photoHash) { + this._account._addVCardRequest(this.normalizedName); + } else if (!hash && this._photoHash) { + delete this._photoHash; + this.convIconFilename = ""; + } + } + return; + } + let codes = x.getElements(["status"]).map(elt => elt.attributes.code); + let item = x.getElement(["item"]); + + // Changes the nickname of a participant for this muc. + let changeNick = () => { + if (!item || !item.attributes.nick) { + this.WARN( + "Received a MUC presence code 303 or 210 stanza without an " + + "item element or a nick attribute." + ); + return; + } + let newNick = item.attributes.nick; + this.updateNick(nick, newNick, nick == this.nick); + }; + + if (aStanza.attributes.type == "unavailable") { + if (!this._participants.has(nick)) { + this.WARN( + "received unavailable presence for an unknown MUC participant: " + + from + ); + return; + } + if (codes.includes("303")) { + // XEP-0045 (7.6): Changing Nickname. + // Service Updates Nick for user. + changeNick(); + return; + } + if (item && item.attributes.role == "none") { + // XEP-0045: an occupant has left the room. + this.removeParticipant(nick); + + // Who caused the participant to leave the room. + let actor = item.getElement(["actor"]); + let actorNick = actor ? actor.attributes.nick : ""; + let isActor = actorNick ? ".actor" : ""; + + // Why the participant left. + let reasonNode = item.getElement(["reason"]); + let reason = reasonNode ? reasonNode.innerText : ""; + let isReason = reason ? ".reason" : ""; + + let isYou = nick == this.nick ? ".you" : ""; + let affectedNick = isYou ? "" : nick; + if (isYou) { + this.left = true; + } + + let message; + if (codes.includes("301")) { + // XEP-0045 (9.1): Banning a User. + message = "conversation.message.banned"; + } else if (codes.includes("307")) { + // XEP-0045 (8.2): Kicking an Occupant. + message = "conversation.message.kicked"; + } else if (codes.includes("322") || codes.includes("321")) { + // XEP-0045: Inform user that he or she is being removed from the + // room because the room has been changed to members-only and the + // user is not a member. + message = "conversation.message.removedNonMember"; + } else if (codes.includes("332")) { + // XEP-0045: Inform user that he or she is being removed from the + // room because the MUC service is being shut down. + message = "conversation.message.mucShutdown"; + + // The reason here just duplicates what's in the system message. + reason = isReason = ""; + } else { + // XEP-0045 (7.14): Received when the user parts a room. + message = "conversation.message.parted"; + + // The reason is in a status element in this case. + reasonNode = aStanza.getElement(["status"]); + reason = reasonNode ? reasonNode.innerText : ""; + isReason = reason ? ".reason" : ""; + } + + if (message) { + let messageID = message + isYou + isActor + isReason; + let params = [actorNick, affectedNick, reason].filter(s => s); + this.writeMessage(this.name, lazy._(messageID, ...params), { + system: true, + }); + } + } else { + this.WARN("Unhandled type==unavailable MUC presence stanza."); + } + return; + } + + if (codes.includes("201")) { + // XEP-0045 (10.1): Creating room. + // Service Acknowledges Room Creation + // and Room is awaiting configuration. + // XEP-0045 (10.1.2): Instant room. + let query = Stanza.node( + "query", + Stanza.NS.muc_owner, + null, + Stanza.node("x", Stanza.NS.xdata, { type: "submit" }) + ); + let s = Stanza.iq("set", null, jid, query); + this._account.sendStanza(s, aStanzaReceived => { + if (aStanzaReceived.attributes.type != "result") { + return false; + } + + // XEP-0045: Service Informs New Room Owner of Success + // for instant and reserved rooms. + this.left = false; + this.joining = false; + return true; + }); + } else if (codes.includes("210")) { + // XEP-0045 (7.6): Changing Nickname. + // Service modifies this user's nickname in accordance with local service + // policies. + changeNick(); + return; + } else if (codes.includes("110")) { + // XEP-0045: Room exists and joined successfully. + this.left = false; + this.joining = false; + // TODO (Bug 1172350): Implement Service Discovery Extensions (XEP-0128) to obtain + // configuration of this room. + } else if (codes.includes("104") && nick == this.name) { + // https://xmpp.org/extensions/inbox/muc-avatars.html (XEP-XXXX) + this._account._addVCardRequest(this.normalizedName); + } + + if (!this._participants.get(nick)) { + let participant = new MUCParticipant(nick, from, aStanza); + this._participants.set(nick, participant); + this.notifyObservers( + new nsSimpleEnumerator([participant]), + "chat-buddy-add" + ); + if (this.nick != nick && !this.joining) { + this.writeMessage( + this.name, + lazy._("conversation.message.join", nick), + { + system: true, + } + ); + } else if (this.nick == nick && this._rejoined) { + this.writeMessage(this.name, lazy._("conversation.message.rejoined"), { + system: true, + }); + this._rejoined = false; + } + } else { + this._participants.get(nick).onPresenceStanza(aStanza); + this.notifyObservers(this._participants.get(nick), "chat-buddy-update"); + } + }, + + /* Called by the account when a message is received for this muc */ + incomingMessage(aMsg, aStanza, aDate) { + let from = this._account._parseJID(aStanza.attributes.from).resource; + let id = aStanza.attributes.id; + let flags = {}; + if (!from) { + flags.system = true; + from = this.name; + } else if (aStanza.attributes.type == "error") { + aMsg = lazy._("conversation.error.notDelivered", aMsg); + flags.system = true; + flags.error = true; + } else if (from == this._nick) { + flags.outgoing = true; + } else { + flags.incoming = true; + } + if (aDate) { + flags.time = aDate / 1000; + flags.delayed = true; + } + if (id) { + // Checks if a message exists in conversation to avoid duplication. + if (this._messageIds.has(id)) { + return; + } + this._messageIds.add(id); + } + this.writeMessage(from, aMsg, flags); + }, + + getNormalizedChatBuddyName(aNick) { + return this._account.normalizeFullJid(this.name + "/" + aNick); + }, + + // Leaves MUC conversation. + part(aMsg = null) { + let s = Stanza.presence( + { to: this.name + "/" + this._nick, type: "unavailable" }, + aMsg ? Stanza.node("status", null, null, aMsg.trim()) : null + ); + this._account.sendStanza(s); + delete this.chatRoomFields; + }, + + // Invites a user to MUC conversation. + invite(aJID, aMsg = null) { + // XEP-0045 (7.8): Inviting Another User to a Room. + // XEP-0045 (7.8.2): Mediated Invitation. + let invite = Stanza.node( + "invite", + null, + { to: aJID }, + aMsg ? Stanza.node("reason", null, null, aMsg) : null + ); + let x = Stanza.node("x", Stanza.NS.muc_user, null, invite); + let s = Stanza.node("message", null, { to: this.name }, x); + this._account.sendStanza( + s, + this._account.handleErrors( + { + forbidden: lazy._("conversation.error.inviteFailedForbidden"), + // ejabberd uses error not-allowed to indicate that this account does not + // have the required privileges to invite users instead of forbidden error, + // and this is not mentioned in the spec (XEP-0045). + notAllowed: lazy._("conversation.error.inviteFailedForbidden"), + itemNotFound: lazy._("conversation.error.failedJIDNotFound", aJID), + }, + this + ) + ); + }, + + // Bans a participant from MUC conversation. + ban(aNickName, aMsg = null) { + // XEP-0045 (9.1): Banning a User. + let participant = this._participants.get(aNickName); + if (!participant) { + this.writeMessage( + this.name, + lazy._("conversation.error.nickNotInRoom", aNickName), + { system: true } + ); + return; + } + if (!participant.accountJid) { + this.writeMessage( + this.name, + lazy._("conversation.error.banCommandAnonymousRoom"), + { system: true } + ); + return; + } + + let attributes = { affiliation: "outcast", jid: participant.accountJid }; + let item = Stanza.node( + "item", + null, + attributes, + aMsg ? Stanza.node("reason", null, null, aMsg) : null + ); + let s = Stanza.iq( + "set", + null, + this.name, + Stanza.node("query", Stanza.NS.muc_admin, null, item) + ); + this._account.sendStanza(s, this._banKickHandler, this); + }, + + // Kicks a participant from MUC conversation. + kick(aNickName, aMsg = null) { + // XEP-0045 (8.2): Kicking an Occupant. + let attributes = { role: "none", nick: aNickName }; + let item = Stanza.node( + "item", + null, + attributes, + aMsg ? Stanza.node("reason", null, null, aMsg) : null + ); + let s = Stanza.iq( + "set", + null, + this.name, + Stanza.node("query", Stanza.NS.muc_admin, null, item) + ); + this._account.sendStanza(s, this._banKickHandler, this); + }, + + // Callback for ban and kick commands. + _banKickHandler(aStanza) { + return this._account._handleResult( + { + notAllowed: lazy._("conversation.error.banKickCommandNotAllowed"), + conflict: lazy._("conversation.error.banKickCommandConflict"), + }, + this + )(aStanza); + }, + + // Changes nick in MUC conversation to a new one. + setNick(aNewNick) { + // XEP-0045 (7.6): Changing Nickname. + let s = Stanza.presence({ to: this.name + "/" + aNewNick }, null); + this._account.sendStanza( + s, + this._account.handleErrors( + { + // XEP-0045 (7.6): Changing Nickname (example 53). + // TODO: We should discover if the user has a reserved nickname (maybe + // before joining a room), cf. XEP-0045 (7.12). + notAcceptable: lazy._( + "conversation.error.changeNickFailedNotAcceptable", + aNewNick + ), + // XEP-0045 (7.2.9): Nickname Conflict. + conflict: lazy._( + "conversation.error.changeNickFailedConflict", + aNewNick + ), + }, + this + ) + ); + }, + + // Called by the account when a message stanza is received for this muc and + // needs to be handled. + onMessageStanza(aStanza) { + let x = aStanza.getElement(["x"]); + let decline = x.getElement(["decline"]); + if (decline) { + // XEP-0045 (7.8): Inviting Another User to a Room. + // XEP-0045 (7.8.2): Mediated Invitation. + let invitee = decline.attributes.jid; + let reasonNode = decline.getElement(["reason"]); + let reason = reasonNode ? reasonNode.innerText : ""; + let msg; + if (reason) { + msg = lazy._( + "conversation.message.invitationDeclined.reason", + invitee, + reason + ); + } else { + msg = lazy._("conversation.message.invitationDeclined", invitee); + } + + this.writeMessage(this.name, msg, { system: true }); + } else { + this.WARN("Unhandled message stanza."); + } + }, + + /* Called when the user closed the conversation */ + close() { + if (!this.left) { + this.part(); + } + GenericConvChatPrototype.close.call(this); + }, + unInit() { + this._account.removeConversation(this.name); + GenericConvChatPrototype.unInit.call(this); + }, + + _photoHash: null, + _saveIcon(aPhotoNode) { + this._account._saveResourceIcon(aPhotoNode, this).then( + url => { + this.convIconFilename = url; + }, + error => { + this._account.WARN( + "Error while loading conversation icon for " + + this.normalizedName + + ": " + + error.message + ); + } + ); + }, +}; + +function XMPPMUCConversation(aAccount, aJID, aNick) { + this._init(aAccount, aJID, aNick); +} +XMPPMUCConversation.prototype = XMPPMUCConversationPrototype; + +/* Helper class for buddy conversations */ +export var XMPPConversationPrototype = { + __proto__: GenericConvIMPrototype, + + _typingTimer: null, + supportChatStateNotifications: true, + _typingState: "active", + + // Indicates that current conversation is with a MUC participant and the + // recipient jid (stored in the userName) is of the form room@domain/nick. + _isMucParticipant: false, + + get buddy() { + return this._account._buddies.get(this.name); + }, + get title() { + return this.contactDisplayName; + }, + get contactDisplayName() { + return this.buddy ? this.buddy.contactDisplayName : this.name; + }, + get userName() { + return this.buddy ? this.buddy.userName : this.name; + }, + + // Returns jid (room@domain/nick) if it is with a MUC participant, and the + // name of conversation otherwise. + get normalizedName() { + if (this._isMucParticipant) { + return this._account.normalizeFullJid(this.name); + } + return this._account.normalize(this.name); + }, + + // Used to avoid showing full jids in typing notifications. + get shortName() { + if (this.buddy) { + return this.buddy.contactDisplayName; + } + + let jid = this._account._parseJID(this.name); + if (!jid) { + return this.name; + } + + // Returns nick of the recipient if conversation is with a participant of + // a MUC we are in as jid of the recipient is of the form room@domain/nick. + if (this._isMucParticipant) { + return jid.resource; + } + + return jid.node; + }, + + get shouldSendTypingNotifications() { + return ( + this.supportChatStateNotifications && + Services.prefs.getBoolPref("purple.conversations.im.send_typing") + ); + }, + + /* Called when the user is typing a message + * aString - the currently typed message + * Returns the number of characters that can still be typed */ + sendTyping(aString) { + if (!this.shouldSendTypingNotifications) { + return Ci.prplIConversation.NO_TYPING_LIMIT; + } + + this._cancelTypingTimer(); + if (aString.length) { + this._typingTimer = setTimeout(this.finishedComposing.bind(this), 10000); + } + + this._setTypingState(aString.length ? "composing" : "active"); + + return Ci.prplIConversation.NO_TYPING_LIMIT; + }, + + finishedComposing() { + if (!this.shouldSendTypingNotifications) { + return; + } + + this._setTypingState("paused"); + }, + + _setTypingState(aNewState) { + if (this._typingState == aNewState) { + return; + } + + let s = Stanza.message(this.to, null, aNewState); + + // We don't care about errors in response to typing notifications + // (e.g. because the user has left the room when talking to a MUC + // participant). + this._account.sendStanza(s, () => true); + + this._typingState = aNewState; + }, + _cancelTypingTimer() { + if (this._typingTimer) { + clearTimeout(this._typingTimer); + delete this._typingTimer; + } + }, + + // Holds the resource of user that you are currently talking to, but if the + // user is a participant of a MUC we are in, holds the nick of user you are + // talking to. + _targetResource: "", + + get to() { + if (!this._targetResource || this._isMucParticipant) { + return this.userName; + } + return this.userName + "/" + this._targetResource; + }, + + /* Called when the user enters a chat message */ + dispatchMessage(aMsg, aAction = false) { + if (aAction) { + // XEP-0245: The /me Command. + // We need to prepend "/me " as the first four characters of the message + // body. + aMsg = "/me" + aMsg; + } + this._cancelTypingTimer(); + let cs = this.shouldSendTypingNotifications ? "active" : null; + let s = Stanza.message(this.to, aMsg, cs); + this._account.sendStanza(s); + _displaySentMsg(this, aMsg); + delete this._typingState; + }, + + // Invites the contact to a MUC room. + invite(aRoomJid, aPassword = null) { + // XEP-0045 (7.8): Inviting Another User to a Room. + // XEP-0045 (7.8.1) and XEP-0249: Direct Invitation. + let x = Stanza.node("x", Stanza.NS.conference, { + jid: aRoomJid, + password: aPassword, + }); + this._account.sendStanza(Stanza.node("message", null, { to: this.to }, x)); + }, + + // Query the user for its Software Version. + // XEP-0092: Software Version. + getVersion() { + // TODO: Use Service Discovery to determine if the user's client supports + // jabber:iq:version protocol. + + let s = Stanza.iq( + "get", + null, + this.to, + Stanza.node("query", Stanza.NS.version) + ); + this._account.sendStanza(s, aStanza => { + // TODO: handle other errors that can result from querying + // user for its software version. + if ( + this._account.handleErrors( + { + default: lazy._("conversation.error.version.unknown"), + }, + this + )(aStanza) + ) { + return; + } + + let query = aStanza.getElement(["query"]); + if (!query || query.uri != Stanza.NS.version) { + this.WARN( + "Received a response to version query which does not " + + "contain query element or 'jabber:iq:version' namespace." + ); + return; + } + + let name = query.getElement(["name"]); + let version = query.getElement(["version"]); + if (!name || !version) { + // XEP-0092: name and version elements are REQUIRED. + this.WARN( + "Received a response to version query which does not " + + "contain name or version." + ); + return; + } + + let messageID = "conversation.message.version"; + let params = [this.shortName, name.innerText, version.innerText]; + + // XEP-0092: os is OPTIONAL. + let os = query.getElement(["os"]); + if (os) { + params.push(os.innerText); + messageID += "WithOS"; + } + + this.writeMessage(this.name, lazy._(messageID, ...params), { + system: true, + }); + }); + }, + + /* Perform entity escaping before displaying the message. We assume incoming + messages have already been escaped, and will otherwise be filtered. */ + prepareForDisplaying(aMsg) { + if (aMsg.outgoing && !aMsg.system) { + aMsg.displayMessage = lazy.TXTToHTML(aMsg.displayMessage); + } + GenericConversationPrototype.prepareForDisplaying.apply(this, arguments); + }, + + /* Called by the account when a message is received from the buddy */ + incomingMessage(aMsg, aStanza, aDate) { + let from = aStanza.attributes.from; + this._targetResource = this._account._parseJID(from).resource; + let flags = {}; + let error = this._account.parseError(aStanza); + if (error) { + let norm = this._account.normalize(from); + let muc = this._account._mucs.get(norm); + + if (!aMsg) { + // Failed outgoing message. + switch (error.condition) { + case "remote-server-not-found": + aMsg = lazy._("conversation.error.remoteServerNotFound"); + break; + case "service-unavailable": + aMsg = lazy._( + "conversation.error.sendServiceUnavailable", + this.shortName + ); + break; + default: + aMsg = lazy._("conversation.error.unknownSendError"); + break; + } + } else if ( + this._isMucParticipant && + muc && + !muc.left && + error.condition == "item-not-found" + ) { + // XEP-0045 (7.5): MUC private messages. + // If we try to send to participant not in a room we are in. + aMsg = lazy._( + "conversation.error.sendFailedAsRecipientNotInRoom", + this._targetResource, + aMsg + ); + } else if ( + this._isMucParticipant && + (error.condition == "item-not-found" || + error.condition == "not-acceptable") + ) { + // If we left a room and try to send to a participant in it or the + // room is removed. + aMsg = lazy._( + "conversation.error.sendFailedAsNotInRoom", + this._account.normalize(from), + aMsg + ); + } else { + aMsg = lazy._("conversation.error.notDelivered", aMsg); + } + flags.system = true; + flags.error = true; + } else { + flags = { incoming: true, _alias: this.contactDisplayName }; + // XEP-0245: The /me Command. + if (aMsg.startsWith("/me ")) { + flags.action = true; + aMsg = aMsg.slice(4); + } + } + if (aDate) { + flags.time = aDate / 1000; + flags.delayed = true; + } + this.writeMessage(from, aMsg, flags); + }, + + /* Called when the user closed the conversation */ + close() { + // TODO send the stanza indicating we have left the conversation? + GenericConvIMPrototype.close.call(this); + }, + unInit() { + this._account.removeConversation(this.normalizedName); + GenericConvIMPrototype.unInit.call(this); + }, +}; + +// Creates XMPP conversation. +function XMPPConversation(aAccount, aNormalizedName, aMucParticipant) { + this._init(aAccount, aNormalizedName); + if (aMucParticipant) { + this._isMucParticipant = true; + } +} +XMPPConversation.prototype = XMPPConversationPrototype; + +/* Helper class for buddies */ +export var XMPPAccountBuddyPrototype = { + __proto__: GenericAccountBuddyPrototype, + + subscription: "none", + // Returns a list of TooltipInfo objects to be displayed when the user + // hovers over the buddy. + getTooltipInfo() { + if (!this._account.connected) { + return null; + } + + let tooltipInfo = []; + if (this._resources) { + for (let r in this._resources) { + let status = this._resources[r]; + let statusString = Status.toLabel(status.statusType); + if ( + status.statusType == Ci.imIStatusInfo.STATUS_IDLE && + status.idleSince + ) { + let now = Math.floor(Date.now() / 1000); + let valuesAndUnits = lazy.DownloadUtils.convertTimeUnits( + now - status.idleSince + ); + if (!valuesAndUnits[2]) { + valuesAndUnits.splice(2, 2); + } + statusString += " (" + valuesAndUnits.join(" ") + ")"; + } + if (status.statusText) { + statusString += " - " + status.statusText; + } + let label = r + ? lazy._("tooltip.status", r) + : lazy._("tooltip.statusNoResource"); + tooltipInfo.push(new TooltipInfo(label, statusString)); + } + } + + // The subscription value is interesting to display only in unusual cases. + if (this.subscription != "both") { + tooltipInfo.push( + new TooltipInfo(lazy._("tooltip.subscription"), this.subscription) + ); + } + + return tooltipInfo; + }, + + // _rosterAlias is the value stored in the roster on the XMPP + // server. For most servers we will be read/write. + _rosterAlias: "", + set rosterAlias(aNewAlias) { + let old = this.displayName; + this._rosterAlias = aNewAlias; + if (old != this.displayName) { + this._notifyObservers("display-name-changed", old); + } + }, + _vCardReceived: false, + // _vCardFormattedName is the display name the contact has set for + // himself in his vCard. It's read-only from our point of view. + _vCardFormattedName: "", + set vCardFormattedName(aNewFormattedName) { + let old = this.displayName; + this._vCardFormattedName = aNewFormattedName; + if (old != this.displayName) { + this._notifyObservers("display-name-changed", old); + } + }, + + // _serverAlias is set by jsProtoHelper to the value we cached in sqlite. + // Use it only if we have neither of the other two values; usually because + // we haven't connected to the server yet. + get serverAlias() { + return this._rosterAlias || this._vCardFormattedName || this._serverAlias; + }, + set serverAlias(aNewAlias) { + if (!this._rosterItem) { + this.ERROR( + "attempting to update the server alias of an account buddy " + + "for which we haven't received a roster item." + ); + return; + } + + let item = this._rosterItem; + if (aNewAlias) { + item.attributes.name = aNewAlias; + } else if ("name" in item.attributes) { + delete item.attributes.name; + } + + let s = Stanza.iq( + "set", + null, + null, + Stanza.node("query", Stanza.NS.roster, null, item) + ); + this._account.sendStanza(s); + + // If we are going to change the alias on the server, discard the cached + // value that we got from our local sqlite storage at startup. + delete this._serverAlias; + }, + + /* Display name of the buddy */ + get contactDisplayName() { + return this.buddy.contact.displayName || this.displayName; + }, + + get tag() { + return this._tag; + }, + set tag(aNewTag) { + let oldTag = this._tag; + if (oldTag.name == aNewTag.name) { + this.ERROR("attempting to set the tag to the same value"); + return; + } + + this._tag = aNewTag; + IMServices.contacts.accountBuddyMoved(this, oldTag, aNewTag); + + if (!this._rosterItem) { + this.ERROR( + "attempting to change the tag of an account buddy without roster item" + ); + return; + } + + let item = this._rosterItem; + let oldXML = item.getXML(); + // Remove the old tag if it was listed in the roster item. + item.children = item.children.filter( + c => c.qName != "group" || c.innerText != oldTag.name + ); + // Ensure the new tag is listed. + let newTagName = aNewTag.name; + if (!item.getChildren("group").some(g => g.innerText == newTagName)) { + item.addChild(Stanza.node("group", null, null, newTagName)); + } + // Avoid sending anything to the server if the roster item hasn't changed. + // It's possible that the roster item hasn't changed if the roster + // item had several groups and the user moved locally the contact + // to another group where it already was on the server. + if (item.getXML() == oldXML) { + return; + } + + let s = Stanza.iq( + "set", + null, + null, + Stanza.node("query", Stanza.NS.roster, null, item) + ); + this._account.sendStanza(s); + }, + + remove() { + if (!this._account.connected) { + return; + } + + let s = Stanza.iq( + "set", + null, + null, + Stanza.node( + "query", + Stanza.NS.roster, + null, + Stanza.node("item", null, { + jid: this.normalizedName, + subscription: "remove", + }) + ) + ); + this._account.sendStanza(s); + }, + + _photoHash: null, + _saveIcon(aPhotoNode) { + this._account._saveResourceIcon(aPhotoNode, this).then( + url => { + this.buddyIconFilename = url; + }, + error => { + this._account.WARN( + "Error loading buddy icon for " + + this.normalizedName + + ": " + + error.message + ); + } + ); + }, + + _preferredResource: undefined, + _resources: null, + onAccountDisconnected() { + delete this._preferredResource; + delete this._resources; + }, + // Called by the account when a presence stanza is received for this buddy. + onPresenceStanza(aStanza) { + let preferred = this._preferredResource; + + // Facebook chat's XMPP server doesn't send resources, let's + // replace undefined resources with empty resources. + let resource = + this._account._parseJID(aStanza.attributes.from).resource || ""; + + let type = aStanza.attributes.type; + + // Reset typing status if the buddy is in a conversation and becomes unavailable. + let conv = this._account._conv.get(this.normalizedName); + if (type == "unavailable" && conv) { + conv.updateTyping(Ci.prplIConvIM.NOT_TYPING, this.contactDisplayName); + } + + if (type == "unavailable" || type == "error") { + if (!this._resources || !(resource in this._resources)) { + // Ignore for already offline resources. + return; + } + delete this._resources[resource]; + if (preferred == resource) { + preferred = undefined; + } + } else { + let statusInfo = parseStatus(aStanza); + let priority = aStanza.getElement(["priority"]); + priority = priority ? parseInt(priority.innerText, 10) : 0; + + if (!this._resources) { + this._resources = {}; + } + this._resources[resource] = { + statusType: statusInfo.statusType, + statusText: statusInfo.statusText, + idleSince: statusInfo.idleSince, + priority, + stanza: aStanza, + }; + } + + let photo = aStanza.getElement(["x", "photo"]); + if (photo && photo.uri == Stanza.NS.vcard_update) { + let hash = photo.innerText; + if (hash && hash != this._photoHash) { + this._account._addVCardRequest(this.normalizedName); + } else if (!hash && this._photoHash) { + delete this._photoHash; + this.buddyIconFilename = ""; + } + } + + for (let r in this._resources) { + if ( + preferred === undefined || + this._resources[r].statusType > this._resources[preferred].statusType + ) { + // FIXME also compare priorities... + preferred = r; + } + } + if ( + preferred != undefined && + preferred == this._preferredResource && + resource != preferred + ) { + // The presence information change is only for an unused resource, + // only potential buddy tooltips need to be refreshed. + this._notifyObservers("status-detail-changed"); + return; + } + + // Presence info has changed enough that if we are having a + // conversation with one resource of this buddy, we should send + // the next message to all resources. + // FIXME: the test here isn't exactly right... + if ( + this._preferredResource != preferred && + this._account._conv.has(this.normalizedName) + ) { + delete this._account._conv.get(this.normalizedName)._targetResource; + } + + this._preferredResource = preferred; + if (preferred === undefined) { + let statusType = Ci.imIStatusInfo.STATUS_UNKNOWN; + if (type == "unavailable") { + statusType = Ci.imIStatusInfo.STATUS_OFFLINE; + } + this.setStatus(statusType, ""); + } else { + preferred = this._resources[preferred]; + this.setStatus(preferred.statusType, preferred.statusText); + } + }, + + /* Can send messages to buddies who appear offline */ + get canSendMessage() { + return this.account.connected; + }, + + /* Called when the user wants to chat with the buddy */ + createConversation() { + return this._account.createConversation(this.normalizedName); + }, +}; + +function XMPPAccountBuddy(aAccount, aBuddy, aTag, aUserName) { + this._init(aAccount, aBuddy, aTag, aUserName); +} +XMPPAccountBuddy.prototype = XMPPAccountBuddyPrototype; + +var XMPPRoomInfoPrototype = { + __proto__: ClassInfo("prplIRoomInfo", "XMPP RoomInfo Object"), + get topic() { + return ""; + }, + get participantCount() { + return Ci.prplIRoomInfo.NO_PARTICIPANT_COUNT; + }, + get chatRoomFieldValues() { + let roomJid = this._account._roomList.get(this.name); + return this._account.getChatRoomDefaultFieldValues(roomJid); + }, +}; +function XMPPRoomInfo(aName, aAccount) { + this.name = aName; + this._account = aAccount; +} +XMPPRoomInfo.prototype = XMPPRoomInfoPrototype; + +/* Helper class for account */ +export var XMPPAccountPrototype = { + __proto__: GenericAccountPrototype, + + _jid: null, // parsed Jabber ID: node, domain, resource + _connection: null, // XMPPSession socket + authMechanisms: null, // hook to let prpls tweak the list of auth mechanisms + + // Contains the domain of MUC service which is obtained using service + // discovery. + _mucService: null, + + // Maps room names to room jid. + _roomList: new Map(), + + // Callbacks used when roomInfo is available. + _roomInfoCallbacks: new Set(), + + // Determines if roomInfo that we have is expired or not. + _lastListTime: 0, + get isRoomInfoStale() { + return Date.now() - this._lastListTime > kListRefreshInterval; + }, + + // If true, we are waiting for replies. + _pendingList: false, + + // An array of jids for which we still need to request vCards. + _pendingVCardRequests: [], + + // XEP-0280: Message Carbons. + // If true, message carbons are currently enabled. + _isCarbonsEnabled: false, + + /* Generate unique id for a stanza. Using id and unique sid is defined in + * RFC 6120 (Section 8.2.3, 4.7.3). + */ + generateId: () => Services.uuid.generateUUID().toString().slice(1, -1), + + _init(aProtoInstance, aImAccount) { + GenericAccountPrototype._init.call(this, aProtoInstance, aImAccount); + + // Ongoing conversations. + // The keys of this._conv are assumed to be normalized like account@domain + // for normal conversations and like room@domain/nick for MUC participant + // convs. + this._conv = new NormalizedMap(this.normalizeFullJid.bind(this)); + + this._buddies = new NormalizedMap(this.normalize.bind(this)); + this._mucs = new NormalizedMap(this.normalize.bind(this)); + + this._pendingVCardRequests = []; + }, + + get canJoinChat() { + return true; + }, + chatRoomFields: { + room: { + get label() { + return lazy._("chatRoomField.room"); + }, + required: true, + }, + server: { + get label() { + return lazy._("chatRoomField.server"); + }, + required: true, + }, + nick: { + get label() { + return lazy._("chatRoomField.nick"); + }, + required: true, + }, + password: { + get label() { + return lazy._("chatRoomField.password"); + }, + isPassword: true, + }, + }, + parseDefaultChatName(aDefaultChatName) { + if (!aDefaultChatName) { + return { nick: this._jid.node }; + } + + let params = aDefaultChatName.trim().split(/\s+/); + let jid = this._parseJID(params[0]); + + // We swap node and domain as domain is required for parseJID, but node and + // resource are optional. In MUC join command, Node is required as it + // represents a room, but domain and resource are optional as we get muc + // domain from service discovery. + if (!jid.node && jid.domain) { + [jid.node, jid.domain] = [jid.domain, jid.node]; + } + + let chatFields = { + room: jid.node, + server: jid.domain || this._mucService, + nick: jid.resource || this._jid.node, + }; + if (params.length > 1) { + chatFields.password = params[1]; + } + return chatFields; + }, + getChatRoomDefaultFieldValues(aDefaultChatName) { + let rv = GenericAccountPrototype.getChatRoomDefaultFieldValues.call( + this, + aDefaultChatName + ); + if (!rv.values.nick) { + rv.values.nick = this._jid.node; + } + if (!rv.values.server && this._mucService) { + rv.values.server = this._mucService; + } + + return rv; + }, + + // XEP-0045: Requests joining room if it exists or + // creating room if it does not exist. + joinChat(aComponents) { + let jid = + aComponents.getValue("room") + "@" + aComponents.getValue("server"); + let nick = aComponents.getValue("nick"); + + let muc = this._mucs.get(jid); + if (muc) { + if (!muc.left) { + // We are already in this conversation. + return muc; + } else if (!muc.chatRoomFields) { + // We are rejoining a room that was parted by the user. + muc._rejoined = true; + } + } else { + muc = new this._MUCConversationConstructor(this, jid, nick); + this._mucs.set(jid, muc); + } + + // Store the prplIChatRoomFieldValues to enable later reconnections. + muc.chatRoomFields = aComponents; + muc.joining = true; + muc.removeAllParticipants(); + + let password = aComponents.getValue("password"); + let x = Stanza.node( + "x", + Stanza.NS.muc, + null, + password ? Stanza.node("password", null, null, password) : null + ); + let logString; + if (password) { + logString = + "<presence .../> (Stanza containing password to join MUC " + + jid + + "/" + + nick + + " not logged)"; + } + this.sendStanza( + Stanza.presence({ to: jid + "/" + nick }, x), + undefined, + undefined, + logString + ); + return muc; + }, + + _idleSince: 0, + observe(aSubject, aTopic, aData) { + if (aTopic == "idle-time-changed") { + let idleTime = parseInt(aData, 10); + if (idleTime) { + this._idleSince = Math.floor(Date.now() / 1000) - idleTime; + } else { + delete this._idleSince; + } + this._shouldSendPresenceForIdlenessChange = true; + executeSoon( + function () { + if ("_shouldSendPresenceForIdlenessChange" in this) { + this._sendPresence(); + } + }.bind(this) + ); + } else if (aTopic == "status-changed") { + this._sendPresence(); + } else if (aTopic == "user-icon-changed") { + delete this._cachedUserIcon; + this._forceUserIconUpdate = true; + this._sendVCard(); + } else if (aTopic == "user-display-name-changed") { + this._forceUserDisplayNameUpdate = true; + } + this._sendVCard(); + }, + + /* GenericAccountPrototype events */ + /* Connect to the server */ + connect() { + this._jid = this._parseJID(this.name); + + // For the resource, if the user has edited the option, always use that. + if (this.prefs.prefHasUserValue("resource")) { + let resource = this.getString("resource"); + + // this._jid needs to be updated. This value is however never used + // because while connected it's the jid of the session that's + // interesting. + this._jid = this._setJID(this._jid.domain, this._jid.node, resource); + } else if (this._jid.resource) { + // If there is a resource in the account name (inherited from libpurple), + // migrate it to the pref so it appears correctly in the advanced account + // options next time. + this.prefs.setStringPref("resource", this._jid.resource); + } + + this._connection = new XMPPSession( + this.getString("server") || this._jid.domain, + this.getInt("port") || 5222, + this.getString("connection_security"), + this._jid, + this.imAccount.password, + this + ); + }, + + remove() { + this._conv.forEach(conv => conv.close()); + this._mucs.forEach(muc => muc.close()); + this._buddies.forEach((buddy, jid) => this._forgetRosterItem(jid)); + }, + + unInit() { + if (this._connection) { + this._disconnect(undefined, undefined, true); + } + delete this._jid; + delete this._conv; + delete this._buddies; + delete this._mucs; + }, + + /* Disconnect from the server */ + disconnect() { + this._disconnect(); + }, + + addBuddy(aTag, aName) { + if (!this._connection) { + throw new Error("The account isn't connected"); + } + + let jid = this.normalize(aName); + if (!jid || !jid.includes("@")) { + throw new Error("Invalid username"); + } + + if (this._buddies.has(jid)) { + let subscription = this._buddies.get(jid).subscription; + if (subscription && (subscription == "both" || subscription == "to")) { + this.DEBUG("not re-adding an existing buddy"); + return; + } + } else { + let s = Stanza.iq( + "set", + null, + null, + Stanza.node( + "query", + Stanza.NS.roster, + null, + Stanza.node( + "item", + null, + { jid }, + Stanza.node("group", null, null, aTag.name) + ) + ) + ); + this.sendStanza( + s, + this._handleResult({ + default: aError => { + this.WARN( + "Unable to add a roster item due to " + aError + " error." + ); + }, + }) + ); + } + this.sendStanza(Stanza.presence({ to: jid, type: "subscribe" })); + }, + + /* Loads a buddy from the local storage. + * Called for each buddy locally stored before connecting + * to the server. */ + loadBuddy(aBuddy, aTag) { + let buddy = new this._accountBuddyConstructor(this, aBuddy, aTag); + this._buddies.set(buddy.normalizedName, buddy); + return buddy; + }, + + /* Replies to a buddy request in order to accept it or deny it. */ + replyToBuddyRequest(aReply, aRequest) { + if (!this._connection) { + return; + } + let s = Stanza.presence({ to: aRequest.userName, type: aReply }); + this.sendStanza(s); + this.removeBuddyRequest(aRequest); + }, + + requestBuddyInfo(aJid) { + if (!this.connected) { + Services.obs.notifyObservers(EmptyEnumerator, "user-info-received", aJid); + return; + } + + let userName; + let tooltipInfo = []; + let jid = this._parseJID(aJid); + let muc = this._mucs.get(jid.node + "@" + jid.domain); + let participant; + if (muc) { + participant = muc._participants.get(jid.resource); + if (participant) { + if (participant.accountJid) { + userName = participant.accountJid; + } + if (!muc.left) { + let statusType = participant.statusType; + let statusText = participant.statusText; + tooltipInfo.push( + new TooltipInfo(statusType, statusText, Ci.prplITooltipInfo.status) + ); + + if (participant.buddyIconFilename) { + tooltipInfo.push( + new TooltipInfo( + null, + participant.buddyIconFilename, + Ci.prplITooltipInfo.icon + ) + ); + } + } + } + } + Services.obs.notifyObservers( + new nsSimpleEnumerator(tooltipInfo), + "user-info-received", + aJid + ); + + let iq = Stanza.iq( + "get", + null, + aJid, + Stanza.node("vCard", Stanza.NS.vcard) + ); + this.sendStanza(iq, aStanza => { + let vCardInfo = {}; + let vCardNode = aStanza.getElement(["vCard"]); + + // In the case of an error response, we just notify the observers with + // what info we already have. + if (aStanza.attributes.type == "result" && vCardNode) { + vCardInfo = this.parseVCard(vCardNode); + } + + // The real jid of participant which is of the form local@domain/resource. + // We consider the jid is provided by server is more correct than jid is + // set by the user. + if (userName) { + vCardInfo.userName = userName; + } + + // vCard fields we want to display in the tooltip. + const kTooltipFields = [ + "userName", + "fullName", + "nickname", + "title", + "organization", + "email", + "birthday", + "locality", + "country", + "telephone", + ]; + + let tooltipInfo = []; + for (let field of kTooltipFields) { + if (vCardInfo.hasOwnProperty(field)) { + tooltipInfo.push( + new TooltipInfo(lazy._("tooltip." + field), vCardInfo[field]) + ); + } + } + if (vCardInfo.photo) { + let dataURI = this._getPhotoURI(vCardInfo.photo); + + // Store the photo URI for this participant. + if (participant) { + participant.buddyIconFilename = dataURI; + } + + tooltipInfo.push( + new TooltipInfo(null, dataURI, Ci.prplITooltipInfo.icon) + ); + } + Services.obs.notifyObservers( + new nsSimpleEnumerator(tooltipInfo), + "user-info-received", + aJid + ); + }); + }, + + // Parses the photo node of a received vCard if exists and returns string of + // data URI, otherwise returns null. + _getPhotoURI(aPhotoNode) { + if (!aPhotoNode) { + return null; + } + + let type = aPhotoNode.getElement(["TYPE"]); + let value = aPhotoNode.getElement(["BINVAL"]); + if (!type || !value) { + return null; + } + + return "data:" + type.innerText + ";base64," + value.innerText; + }, + + // Parses the vCard into the properties of the returned object. + parseVCard(aVCardNode) { + // XEP-0054: vcard-temp. + let aResult = {}; + for (let node of aVCardNode.children.filter( + child => child.type == "node" + )) { + let localName = node.localName; + let innerText = node.innerText; + if (innerText) { + if (localName == "FN") { + aResult.fullName = innerText; + } else if (localName == "NICKNAME") { + aResult.nickname = innerText; + } else if (localName == "TITLE") { + aResult.title = innerText; + } else if (localName == "BDAY") { + aResult.birthday = innerText; + } else if (localName == "JABBERID") { + aResult.userName = innerText; + } + } + if (localName == "ORG") { + let organization = node.getElement(["ORGNAME"]); + if (organization && organization.innerText) { + aResult.organization = organization.innerText; + } + } else if (localName == "EMAIL") { + let userID = node.getElement(["USERID"]); + if (userID && userID.innerText) { + aResult.email = userID.innerText; + } + } else if (localName == "ADR") { + let locality = node.getElement(["LOCALITY"]); + if (locality && locality.innerText) { + aResult.locality = locality.innerText; + } + + let country = node.getElement(["CTRY"]); + if (country && country.innerText) { + aResult.country = country.innerText; + } + } else if (localName == "PHOTO") { + aResult.photo = node; + } else if (localName == "TEL") { + let number = node.getElement(["NUMBER"]); + if (number && number.innerText) { + aResult.telephone = number.innerText; + } + } + // TODO: Parse the other fields of vCard and display it in system messages + // in response to /whois. + } + return aResult; + }, + + // Returns undefined if not an error stanza, and an object + // describing the error otherwise: + parseError(aStanza) { + if (aStanza.attributes.type != "error") { + return undefined; + } + + let retval = { stanza: aStanza }; + let error = aStanza.getElement(["error"]); + + // RFC 6120 Section 8.3.2: Type must be one of + // auth -- retry after providing credentials + // cancel -- do not retry (the error cannot be remedied) + // continue -- proceed (the condition was only a warning) + // modify -- retry after changing the data sent + // wait -- retry after waiting (the error is temporary). + retval.type = error.attributes.type; + + // RFC 6120 Section 8.3.3. + const kDefinedConditions = [ + "bad-request", + "conflict", + "feature-not-implemented", + "forbidden", + "gone", + "internal-server-error", + "item-not-found", + "jid-malformed", + "not-acceptable", + "not-allowed", + "not-authorized", + "policy-violation", + "recipient-unavailable", + "redirect", + "registration-required", + "remote-server-not-found", + "remote-server-timeout", + "resource-constraint", + "service-unavailable", + "subscription-required", + "undefined-condition", + "unexpected-request", + ]; + let condition = kDefinedConditions.find(c => error.getElement([c])); + if (!condition) { + // RFC 6120 Section 8.3.2. + this.WARN( + "Nonstandard or missing defined-condition element in error stanza." + ); + condition = "undefined-condition"; + } + retval.condition = condition; + + let errortext = error.getElement(["text"]); + if (errortext) { + retval.text = errortext.innerText; + } + + return retval; + }, + + // Returns an error-handling callback for use with sendStanza generated + // from aHandlers, an object defining the error handlers. + // If the stanza passed to the callback is an error stanza, it checks if + // aHandlers contains a property with the name of the defined condition + // of the error. + // * If the property is a function, it is called with the parsed error + // as its argument, bound to aThis (if provided). + // It should return true if the error was handled. + // * If the property is a string, it is displayed as a system message + // in the conversation given by aThis. + handleErrors(aHandlers, aThis) { + return aStanza => { + if (!aHandlers) { + return false; + } + + let error = this.parseError(aStanza); + if (!error) { + return false; + } + + let toCamelCase = aStr => { + // Turn defined condition string into a valid camelcase + // JS property name. + let capitalize = s => s[0].toUpperCase() + s.slice(1); + let uncapitalize = s => s[0].toLowerCase() + s.slice(1); + return uncapitalize(aStr.split("-").map(capitalize).join("")); + }; + let condition = toCamelCase(error.condition); + // Check if we have a handler property for this kind of error or a + // default handler. + if (!(condition in aHandlers) && !("default" in aHandlers)) { + return false; + } + + // Try to get the handler for condition, if we cannot get it, try to get + // the default handler. + let handler = aHandlers[condition]; + if (!handler) { + handler = aHandlers.default; + } + + if (typeof handler == "string") { + // The string is an error message to be displayed in the conversation. + if (!aThis || !aThis.writeMessage) { + this.ERROR( + "HandleErrors was passed an error message string, but " + + "no conversation to display it in:\n" + + handler + ); + return true; + } + aThis.writeMessage(aThis.name, handler, { system: true, error: true }); + return true; + } else if (typeof handler == "function") { + // If we're given a function, call this error handler. + return handler.call(aThis, error); + } + + // If this happens, there's a bug somewhere. + this.ERROR( + "HandleErrors was passed a handler for '" + + condition + + "'' which is neither a function nor a string." + ); + return false; + }; + }, + + // Returns a callback suitable for use in sendStanza, to handle type==result + // responses. aHandlers and aThis are passed on to handleErrors for error + // handling. + _handleResult(aHandlers, aThis) { + return aStanza => { + if (aStanza.attributes.type == "result") { + return true; + } + return this.handleErrors(aHandlers, aThis)(aStanza); + }; + }, + + /* XMPPSession events */ + + /* Called when the XMPP session is started */ + onConnection() { + // Request the roster. The account will be marked as connected when this is + // complete. + this.reportConnecting(lazy._("connection.downloadingRoster")); + let s = Stanza.iq( + "get", + null, + null, + Stanza.node("query", Stanza.NS.roster) + ); + this.sendStanza(s, this.onRoster, this); + + // XEP-0030 and XEP-0045 (6): Service Discovery. + // Queries Server for Associated Services. + let iq = Stanza.iq( + "get", + null, + this._jid.domain, + Stanza.node("query", Stanza.NS.disco_items) + ); + this.sendStanza(iq, this.onServiceDiscovery, this); + + // XEP-0030: Service Discovery Information Features. + iq = Stanza.iq( + "get", + null, + this._jid.domain, + Stanza.node("query", Stanza.NS.disco_info) + ); + this.sendStanza(iq, this.onServiceDiscoveryInfo, this); + }, + + /* Called whenever a stanza is received */ + onXmppStanza(aStanza) {}, + + /* Called when a iq stanza is received */ + onIQStanza(aStanza) { + let type = aStanza.attributes.type; + if (type == "set") { + for (let query of aStanza.getChildren("query")) { + if (query.uri != Stanza.NS.roster) { + continue; + } + + // RFC 6121 2.1.6 (Roster push): + // A receiving client MUST ignore the stanza unless it has no 'from' + // attribute (i.e., implicitly from the bare JID of the user's + // account) or it has a 'from' attribute whose value matches the + // user's bare JID <user@domainpart>. + let from = aStanza.attributes.from; + if (from && from != this._jid.node + "@" + this._jid.domain) { + this.WARN("Ignoring potentially spoofed roster push."); + return; + } + + for (let item of query.getChildren("item")) { + this._onRosterItem(item, true); + } + return; + } + } else if (type == "get") { + let id = aStanza.attributes.id; + let from = aStanza.attributes.from; + + // XEP-0199: XMPP server-to-client ping (XEP-0199) + let ping = aStanza.getElement(["ping"]); + if (ping && ping.uri == Stanza.NS.ping) { + this.sendStanza(Stanza.iq("result", id, from)); + return; + } + + let query = aStanza.getElement(["query"]); + if (query && query.uri == Stanza.NS.version) { + // XEP-0092: Software Version. + let children = []; + children.push(Stanza.node("name", null, null, Services.appinfo.name)); + children.push( + Stanza.node("version", null, null, Services.appinfo.version) + ); + let versionQuery = Stanza.node( + "query", + Stanza.NS.version, + null, + children + ); + this.sendStanza(Stanza.iq("result", id, from, versionQuery)); + return; + } + if (query && query.uri == Stanza.NS.disco_info) { + // XEP-0030: Service Discovery. + let children = []; + if (aStanza.attributes.node == Stanza.NS.muc_rooms) { + // XEP-0045 (6.7): Room query. + // TODO: Currently, we return an empty <query/> element, but we + // should return non-private rooms. + } else { + children = SupportedFeatures.map(feature => + Stanza.node("feature", null, { var: feature }) + ); + children.unshift( + Stanza.node("identity", null, { + category: "client", + type: "pc", + name: Services.appinfo.name, + }) + ); + } + let discoveryQuery = Stanza.node( + "query", + Stanza.NS.disco_info, + null, + children + ); + this.sendStanza(Stanza.iq("result", id, from, discoveryQuery)); + return; + } + } + this.WARN(`Unhandled IQ ${type} stanza.`); + if (type == "get" || type == "set") { + // RFC 6120 (section 8.2.3): An entity that receives an IQ request of + // type "get" or "set" MUST reply with an IQ response of type "result" + // or "error". + let id = aStanza.attributes.id; + let from = aStanza.attributes.from; + let condition = Stanza.node("service-unavailable", Stanza.NS.stanzas, { + type: "cancel", + }); + let error = Stanza.node("error", null, { type: "cancel" }, condition); + this.sendStanza(Stanza.iq("error", id, from, error)); + } + }, + + /* Called when a presence stanza is received */ + onPresenceStanza(aStanza) { + let from = aStanza.attributes.from; + this.DEBUG("Received presence stanza for " + from); + + let jid = this.normalize(from); + let type = aStanza.attributes.type; + if (type == "subscribe") { + this.addBuddyRequest( + jid, + this.replyToBuddyRequest.bind(this, "subscribed"), + this.replyToBuddyRequest.bind(this, "unsubscribed") + ); + } else if ( + type == "unsubscribe" || + type == "unsubscribed" || + type == "subscribed" + ) { + // Nothing useful to do for these presence stanzas, as we will also + // receive a roster push containing more or less the same information + } else if (this._buddies.has(jid)) { + this._buddies.get(jid).onPresenceStanza(aStanza); + } else if (this._mucs.has(jid)) { + this._mucs.get(jid).onPresenceStanza(aStanza); + } else if (jid != this.normalize(this._connection._jid.jid)) { + this.WARN("received presence stanza for unknown buddy " + from); + } else if ( + jid == this._jid.node + "@" + this._jid.domain && + this._connection._resource != this._parseJID(from).resource + ) { + // Ignore presence stanzas for another resource. + } else { + this.WARN("Unhandled presence stanza."); + } + }, + + // XEP-0030: Discovering services and their features that are supported by + // the server. + onServiceDiscovery(aStanza) { + let query = aStanza.getElement(["query"]); + if ( + aStanza.attributes.type != "result" || + !query || + query.uri != Stanza.NS.disco_items + ) { + this.LOG("Could not get services for this server: " + this._jid.domain); + return true; + } + + // Discovering the Features that are Supported by each service. + query.getElements(["item"]).forEach(item => { + let jid = item.attributes.jid; + if (!jid) { + return; + } + let iq = Stanza.iq( + "get", + null, + jid, + Stanza.node("query", Stanza.NS.disco_info) + ); + this.sendStanza(iq, receivedStanza => { + let query = receivedStanza.getElement(["query"]); + let from = receivedStanza.attributes.from; + if ( + aStanza.attributes.type != "result" || + !query || + query.uri != Stanza.NS.disco_info + ) { + this.LOG("Could not get features for this service: " + from); + return true; + } + let features = query + .getElements(["feature"]) + .map(elt => elt.attributes.var); + let identity = query.getElement(["identity"]); + if ( + identity && + identity.attributes.category == "conference" && + identity.attributes.type == "text" && + features.includes(Stanza.NS.muc) + ) { + // XEP-0045 (6.2): this feature is for a MUC Service. + // XEP-0045 (15.2): Service Discovery Category/Type. + this._mucService = from; + } + // TODO: Handle other services that are supported by XMPP through + // their features. + + return true; + }); + }); + return true; + }, + + // XEP-0030: Discovering Service Information and its features that are + // supported by the server. + onServiceDiscoveryInfo(aStanza) { + let query = aStanza.getElement(["query"]); + if ( + aStanza.attributes.type != "result" || + !query || + query.uri != Stanza.NS.disco_info + ) { + this.LOG("Could not get features for this server: " + this._jid.domain); + return true; + } + + let features = query + .getElements(["feature"]) + .map(elt => elt.attributes.var); + if (features.includes(Stanza.NS.carbons)) { + // XEP-0280: Message Carbons. + // Enabling Carbons on server, as it's disabled by default on server. + if (Services.prefs.getBoolPref("chat.xmpp.messageCarbons")) { + let iqStanza = Stanza.iq( + "set", + null, + null, + Stanza.node("enable", Stanza.NS.carbons) + ); + this.sendStanza(iqStanza, aStanza => { + let error = this.parseError(aStanza); + if (error) { + this.WARN( + "Unable to enable message carbons due to " + + error.condition + + " error." + ); + return true; + } + + let type = aStanza.attributes.type; + if (type != "result") { + this.WARN( + "Received unexpected stanza with " + + type + + " type " + + "while enabling message carbons." + ); + return true; + } + + this.LOG("Message carbons enabled."); + this._isCarbonsEnabled = true; + return true; + }); + } + } + // TODO: Handle other features that are supported by the server. + return true; + }, + + requestRoomInfo(aCallback) { + if (this._roomInfoCallbacks.has(aCallback)) { + return; + } + + if (this.isRoomInfoStale && !this._pendingList) { + this._roomList = new Map(); + this._lastListTime = Date.now(); + this._roomInfoCallback = aCallback; + this._pendingList = true; + + // XEP-0045 (6.3): Discovering Rooms. + let iq = Stanza.iq( + "get", + null, + this._mucService, + Stanza.node("query", Stanza.NS.disco_items) + ); + this.sendStanza(iq, this.onRoomDiscovery, this); + } else { + let rooms = [...this._roomList.keys()]; + aCallback.onRoomInfoAvailable(rooms, !this._pendingList); + } + + if (this._pendingList) { + this._roomInfoCallbacks.add(aCallback); + } + }, + + onRoomDiscovery(aStanza) { + let query = aStanza.getElement(["query"]); + if (!query || query.uri != Stanza.NS.disco_items) { + this.LOG("Could not get rooms for this server: " + this._jid.domain); + return; + } + + // XEP-0059: Result Set Management. + let set = query.getElement(["set"]); + let last = set ? set.getElement(["last"]) : null; + if (last) { + let iq = Stanza.iq( + "get", + null, + this._mucService, + Stanza.node("query", Stanza.NS.disco_items) + ); + this.sendStanza(iq, this.onRoomDiscovery, this); + } else { + this._pendingList = false; + } + + let rooms = []; + query.getElements(["item"]).forEach(item => { + let jid = this._parseJID(item.attributes.jid); + if (!jid) { + return; + } + + let name = item.attributes.name; + if (!name) { + name = jid.node ? jid.node : jid.jid; + } + + this._roomList.set(name, jid.jid); + rooms.push(name); + }); + + this._roomInfoCallback.onRoomInfoAvailable(rooms, !this._pendingList); + }, + + getRoomInfo(aName) { + return new XMPPRoomInfo(aName, this); + }, + + // Returns null if not an invitation stanza, and an object + // describing the invitation otherwise. + parseInvitation(aStanza) { + let x = aStanza.getElement(["x"]); + if (!x) { + return null; + } + let retVal = { + shouldDecline: false, + }; + + // XEP-0045. Direct Invitation (7.8.1) + // Described in XEP-0249. + // jid (chatroom) is required. + // Password, reason, continue and thread are optional. + if (x.uri == Stanza.NS.conference) { + if (!x.attributes.jid) { + this.WARN("Received an invitation with missing MUC jid."); + return null; + } + retVal.mucJid = this.normalize(x.attributes.jid); + retVal.from = this.normalize(aStanza.attributes.from); + retVal.password = x.attributes.password; + retVal.reason = x.attributes.reason; + retVal.continue = x.attributes.continue; + retVal.thread = x.attributes.thread; + return retVal; + } + + // XEP-0045. Mediated Invitation (7.8.2) + // Sent by the chatroom on behalf of someone in the chatroom. + // jid (chatroom) and from (inviter) are required. + // password and reason are optional. + if (x.uri == Stanza.NS.muc_user) { + let invite = x.getElement(["invite"]); + if (!invite || !invite.attributes.from) { + this.WARN("Received an invitation with missing MUC invite or from."); + return null; + } + retVal.mucJid = this.normalize(aStanza.attributes.from); + retVal.from = this.normalize(invite.attributes.from); + retVal.shouldDecline = true; + let continueElement = invite.getElement(["continue"]); + retVal.continue = !!continueElement; + if (continueElement) { + retVal.thread = continueElement.attributes.thread; + } + if (x.getElement(["password"])) { + retVal.password = x.getElement(["password"]).innerText; + } + if (invite.getElement(["reason"])) { + retVal.reason = invite.getElement(["reason"]).innerText; + } + return retVal; + } + + return null; + }, + + /* Called when a message stanza is received */ + onMessageStanza(aStanza) { + // XEP-0280: Message Carbons. + // Sending and Receiving Messages. + // Indicates that the forwarded message was sent or received. + let isSent = false; + let carbonStanza = + aStanza.getElement(["sent"]) || aStanza.getElement(["received"]); + if (carbonStanza) { + if (carbonStanza.uri != Stanza.NS.carbons) { + this.WARN( + "Received a forwarded message which does not '" + + Stanza.NS.carbons + + "' namespace." + ); + return; + } + + isSent = carbonStanza.localName == "sent"; + carbonStanza = carbonStanza.getElement(["forwarded", "message"]); + if (this._isCarbonsEnabled) { + aStanza = carbonStanza; + } else { + this.WARN( + "Received an unexpected forwarded message while message " + + "carbons are not enabled." + ); + return; + } + } + + // For forwarded sent messages, we need to use "to" attribute to + // get the right conversation as from in this case is this account. + let convJid = isSent ? aStanza.attributes.to : aStanza.attributes.from; + + let normConvJid = this.normalize(convJid); + let isMuc = this._mucs.has(normConvJid); + + let type = aStanza.attributes.type; + let x = aStanza.getElement(["x"]); + let body; + let b = aStanza.getElement(["body"]); + if (b) { + // If there's a <body> child we have more than just typing notifications. + // Prefer HTML (in <html><body>) and use plain text (<body>) as fallback. + let htmlBody = aStanza.getElement(["html", "body"]); + if (htmlBody) { + body = htmlBody.innerXML; + } else { + // Even if the message is in plain text, the prplIMessage + // should contain a string that's correctly escaped for + // insertion in an HTML document. + body = lazy.TXTToHTML(b.innerText); + } + } + + let subject = aStanza.getElement(["subject"]); + // Ignore subject when !isMuc. We're being permissive about subject changes + // in the comment below, so we need to be careful about where that makes + // sense. Psi+'s OTR plugin includes a subject and body in its message + // stanzas. + if (subject && isMuc) { + // XEP-0045 (7.2.16): Check for a subject element in the stanza and update + // the topic if it exists. + // We are breaking the spec because only a message that contains a + // <subject/> but no <body/> element shall be considered a subject change + // for MUC, but we ignore that to be compatible with ejabberd versions + // before 15.06. + let muc = this._mucs.get(normConvJid); + let nick = this._parseJID(convJid).resource; + // TODO There can be multiple subject elements with different xml:lang + // attributes. + muc.setTopic(subject.innerText, nick); + return; + } + + let invitation = this.parseInvitation(aStanza); + if (invitation) { + let messageID; + if (invitation.reason) { + messageID = "conversation.muc.invitationWithReason2"; + } else { + messageID = "conversation.muc.invitationWithoutReason"; + } + if (invitation.password) { + messageID += ".password"; + } + let params = [ + invitation.from, + invitation.mucJid, + invitation.password, + invitation.reason, + ].filter(s => s); + let message = lazy._(messageID, ...params); + + this.addChatRequest( + invitation.mucJid, + () => { + let chatRoomFields = this.getChatRoomDefaultFieldValues( + invitation.mucJid + ); + if (invitation.password) { + chatRoomFields.setValue("password", invitation.password); + } + let muc = this.joinChat(chatRoomFields); + muc.writeMessage(muc.name, message, { system: true }); + }, + (request, tryToDeny) => { + // Only mediated invitations (XEP-0045) can explicitly decline. + if (invitation.shouldDecline && tryToDeny) { + let decline = Stanza.node( + "decline", + null, + { from: invitation.from }, + null + ); + let x = Stanza.node("x", Stanza.NS.muc_user, null, decline); + let s = Stanza.node("message", null, { to: invitation.mucJid }, x); + this.sendStanza(s); + } + // Always show invite reason or password, even if the invite wasn't + // automatically declined based on the setting. + if (!request || invitation.reason || invitation.password) { + let conv = this.createConversation(invitation.from); + if (conv) { + conv.writeMessage(invitation.from, message, { system: true }); + } + } + } + ); + } + + if (body) { + let date = _getDelay(aStanza); + if ( + type == "groupchat" || + (type == "error" && isMuc && !this._conv.has(convJid)) + ) { + if (!isMuc) { + this.WARN( + "Received a groupchat message for unknown MUC " + normConvJid + ); + return; + } + let muc = this._mucs.get(normConvJid); + muc.incomingMessage(body, aStanza, date); + return; + } + + let conv = this.createConversation(convJid); + if (!conv) { + return; + } + + if (isSent) { + _displaySentMsg(conv, body, date); + return; + } + conv.incomingMessage(body, aStanza, date); + } else if (type == "error") { + let conv = this.createConversation(convJid); + if (conv) { + conv.incomingMessage(null, aStanza); + } + } else if (x && x.uri == Stanza.NS.muc_user) { + let muc = this._mucs.get(normConvJid); + if (!muc) { + this.WARN( + "Received a groupchat message for unknown MUC " + normConvJid + ); + return; + } + muc.onMessageStanza(aStanza); + return; + } + + // If this is a sent message carbon, the user is typing on another client. + if (isSent) { + return; + } + + // Don't create a conversation to only display the typing notifications. + if (!this._conv.has(normConvJid) && !this._conv.has(convJid)) { + return; + } + + // Ignore errors while delivering typing notifications. + if (type == "error") { + return; + } + + let typingState = Ci.prplIConvIM.NOT_TYPING; + let state; + let s = aStanza.getChildrenByNS(Stanza.NS.chatstates); + if (s.length > 0) { + state = s[0].localName; + } + if (state) { + this.DEBUG(state); + if (state == "composing") { + typingState = Ci.prplIConvIM.TYPING; + } else if (state == "paused") { + typingState = Ci.prplIConvIM.TYPED; + } + } + let convName = normConvJid; + + // If the bare JID is a MUC that we have joined, use the full JID as this + // is a private message to a MUC participant. + if (isMuc) { + convName = convJid; + } + + let conv = this._conv.get(convName); + if (!conv) { + return; + } + conv.updateTyping(typingState, conv.shortName); + conv.supportChatStateNotifications = !!state; + }, + + /** Called when there is an error in the XMPP session */ + onError(aError, aException) { + if (aError === null || aError === undefined) { + aError = Ci.prplIAccount.ERROR_OTHER_ERROR; + } + this._disconnect(aError, aException.toString()); + }, + + onVCard(aStanza) { + let jid = this._pendingVCardRequests.shift(); + this._requestNextVCard(); + if (!this._buddies.has(jid) && !this._mucs.has(jid)) { + this.WARN("Received a vCard for unknown buddy " + jid); + return; + } + + let vCard = aStanza.getElement(["vCard"]); + let error = this.parseError(aStanza); + if ( + (error && + (error.condition == "item-not-found" || + error.condition == "service-unavailable")) || + !vCard || + !vCard.children.length + ) { + this.LOG("No vCard exists (or the user does not exist) for " + jid); + return; + } else if (error) { + this.WARN("Received unexpected vCard error " + error.condition); + return; + } + + let stanzaJid = this.normalize(aStanza.attributes.from); + if (jid && jid != stanzaJid) { + this.ERROR( + "Received vCard for a different jid (" + + stanzaJid + + ") " + + "than the requested " + + jid + ); + } + + let foundFormattedName = false; + let vCardInfo = this.parseVCard(vCard); + if (this._mucs.has(jid)) { + const conv = this._mucs.get(jid); + if (vCardInfo.photo) { + conv._saveIcon(vCardInfo.photo); + } + return; + } + let buddy = this._buddies.get(jid); + if (vCardInfo.fullName) { + buddy.vCardFormattedName = vCardInfo.fullName; + foundFormattedName = true; + } + if (vCardInfo.photo) { + buddy._saveIcon(vCardInfo.photo); + } + if (!foundFormattedName && buddy._vCardFormattedName) { + buddy.vCardFormattedName = ""; + } + buddy._vCardReceived = true; + }, + + /** + * Save the icon for a resource to the local file system. + * + * @param photo - The vcard photo node representing the icon. + * @param {prplIChatBuddy|prplIConversation} resource - Resource the icon is for. + * @returns {Promise<string>} Resolves with the file:// URI to the local icon file. + */ + _saveResourceIcon(photo, resource) { + // Some servers seem to send a photo node without a type declared. + let type = photo.getElement(["TYPE"]); + if (!type) { + return Promise.reject(new Error("Missing image type")); + } + type = type.innerText; + const kExt = { + "image/gif": "gif", + "image/jpeg": "jpg", + "image/png": "png", + }; + if (!kExt.hasOwnProperty(type)) { + return Promise.reject(new Error("Unknown image type")); + } + + let content = "", + data = ""; + // Strip all characters not allowed in base64 before parsing. + let parseBase64 = aBase => atob(aBase.replace(/[^A-Za-z0-9\+\/\=]/g, "")); + for (let line of photo.getElement(["BINVAL"]).innerText.split("\n")) { + data += line; + // Mozilla's atob() doesn't handle padding with "=" or "==" + // unless it's at the end of the string, so we have to work around that. + if (line.endsWith("=")) { + content += parseBase64(data); + data = ""; + } + } + content += parseBase64(data); + + // Store a sha1 hash of the photo we have just received. + let ch = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + ch.init(ch.SHA1); + let dataArray = Object.keys(content).map(i => content.charCodeAt(i)); + ch.update(dataArray, dataArray.length); + let hash = ch.finish(false); + function toHexString(charCode) { + return charCode.toString(16).padStart(2, "0"); + } + resource._photoHash = Object.keys(hash) + .map(i => toHexString(hash.charCodeAt(i))) + .join(""); + + let istream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance( + Ci.nsIStringInputStream + ); + istream.setData(content, content.length); + + let fileName = resource._photoHash + "." + kExt[type]; + let file = lazy.FileUtils.getFile("ProfD", [ + "icons", + this.protocol.normalizedName, + this.normalizedName, + fileName, + ]); + let ostream = lazy.FileUtils.openSafeFileOutputStream(file); + return new Promise(resolve => { + lazy.NetUtil.asyncCopy(istream, ostream, rc => { + if (Components.isSuccessCode(rc)) { + resolve(Services.io.newFileURI(file).spec); + } + }); + }); + }, + + _requestNextVCard() { + if (!this._pendingVCardRequests.length) { + return; + } + let s = Stanza.iq( + "get", + null, + this._pendingVCardRequests[0], + Stanza.node("vCard", Stanza.NS.vcard) + ); + this.sendStanza(s, this.onVCard, this); + }, + + _addVCardRequest(aJID) { + let requestPending = !!this._pendingVCardRequests.length; + this._pendingVCardRequests.push(aJID); + if (!requestPending) { + this._requestNextVCard(); + } + }, + + // XEP-0029 (Section 2) and RFC 6122 (Section 2): The node and domain are + // lowercase, while resources are case sensitive and can contain spaces. + normalizeFullJid(aJID) { + return this._parseJID(aJID.trim()).jid; + }, + + // Standard normalization for XMPP removes the resource part of jids. + normalize(aJID) { + return aJID + .trim() + .split("/", 1)[0] // up to first slash + .toLowerCase(); + }, + + // RFC 6122 (Section 2): [ localpart "@" ] domainpart [ "/" resourcepart ] is + // the form of jid. + // Localpart is parsed as node and optional. + // Domainpart is parsed as domain and required. + // resourcepart is parsed as resource and optional. + _parseJID(aJid) { + let match = /^(?:([^"&'/:<>@]+)@)?([^@/<>'\"]+)(?:\/(.*))?$/.exec( + aJid.trim() + ); + if (!match) { + return null; + } + + let result = { + node: match[1], + domain: match[2].toLowerCase(), + resource: match[3], + }; + return this._setJID(result.domain, result.node, result.resource); + }, + + // Constructs jid as an object from domain, node and resource parts. + // The object has properties (node, domain, resource and jid). + // aDomain is required, but aNode and aResource are optional. + _setJID(aDomain, aNode = null, aResource = null) { + if (!aDomain) { + throw new Error("aDomain must have a value"); + } + + let result = { + node: aNode, + domain: aDomain.toLowerCase(), + resource: aResource, + }; + let jid = result.domain; + if (result.node) { + result.node = result.node.toLowerCase(); + jid = result.node + "@" + jid; + } + if (result.resource) { + jid += "/" + result.resource; + } + result.jid = jid; + return result; + }, + + _onRosterItem(aItem, aNotifyOfUpdates) { + let jid = aItem.attributes.jid; + if (!jid) { + this.WARN("Received a roster item without jid: " + aItem.getXML()); + return ""; + } + jid = this.normalize(jid); + + let subscription = ""; + if ("subscription" in aItem.attributes) { + subscription = aItem.attributes.subscription; + } + if (subscription == "remove") { + this._forgetRosterItem(jid); + return ""; + } + + let buddy; + if (this._buddies.has(jid)) { + buddy = this._buddies.get(jid); + let groups = aItem.getChildren("group"); + if (groups.length) { + // If the server specified at least one group, ensure the group we use + // as the account buddy's tag is still a group on the server... + let tagName = buddy.tag.name; + if (!groups.some(g => g.innerText == tagName)) { + // ... otherwise we need to move our account buddy to a new group. + tagName = groups[0].innerText; + if (tagName) { + // Should always be true, but check just in case... + let oldTag = buddy.tag; + buddy._tag = IMServices.tags.createTag(tagName); + IMServices.contacts.accountBuddyMoved(buddy, oldTag, buddy._tag); + } + } + } + } else { + let tag; + for (let group of aItem.getChildren("group")) { + let name = group.innerText; + if (name) { + tag = IMServices.tags.createTag(name); + break; // TODO we should create an accountBuddy per group, + // but this._buddies would probably not like that... + } + } + buddy = new this._accountBuddyConstructor( + this, + null, + tag || IMServices.tags.defaultTag, + jid + ); + } + + // We request the vCard only if we haven't received it yet and are + // subscribed to presence for that contact. + if ( + (subscription == "both" || subscription == "to") && + !buddy._vCardReceived + ) { + this._addVCardRequest(jid); + } + + let alias = "name" in aItem.attributes ? aItem.attributes.name : ""; + if (alias) { + if (aNotifyOfUpdates && this._buddies.has(jid)) { + buddy.rosterAlias = alias; + } else { + buddy._rosterAlias = alias; + } + } else if (buddy._rosterAlias) { + buddy.rosterAlias = ""; + } + + if (subscription) { + buddy.subscription = subscription; + } + if (!this._buddies.has(jid)) { + this._buddies.set(jid, buddy); + IMServices.contacts.accountBuddyAdded(buddy); + } else if (aNotifyOfUpdates) { + buddy._notifyObservers("status-detail-changed"); + } + + // Keep the xml nodes of the item so that we don't have to + // recreate them when changing something (eg. the alias) in it. + buddy._rosterItem = aItem; + + return jid; + }, + _forgetRosterItem(aJID) { + IMServices.contacts.accountBuddyRemoved(this._buddies.get(aJID)); + this._buddies.delete(aJID); + }, + + /* When the roster is received */ + onRoster(aStanza) { + // For the first element that is a roster stanza. + for (let qe of aStanza.getChildren("query")) { + if (qe.uri != Stanza.NS.roster) { + continue; + } + + // Find all the roster items in the new message. + let newRoster = new Set(); + for (let item of qe.getChildren("item")) { + let jid = this._onRosterItem(item); + if (jid) { + newRoster.add(jid); + } + } + // If an item was in the old roster, but not in the new, forget it. + for (let jid of this._buddies.keys()) { + if (!newRoster.has(jid)) { + this._forgetRosterItem(jid); + } + } + break; + } + + this._sendPresence(); + this._buddies.forEach(b => { + if (b.subscription == "both" || b.subscription == "to") { + b.setStatus(Ci.imIStatusInfo.STATUS_OFFLINE, ""); + } + }); + this.reportConnected(); + this._sendVCard(); + }, + + /* Public methods */ + + sendStanza(aStanza, aCallback, aThis, aLogString) { + return this._connection.sendStanza(aStanza, aCallback, aThis, aLogString); + }, + + // Variations of the XMPP protocol can change these default constructors: + _conversationConstructor: XMPPConversation, + _MUCConversationConstructor: XMPPMUCConversation, + _accountBuddyConstructor: XMPPAccountBuddy, + + /* Create a new conversation */ + createConversation(aName) { + let convName = this.normalize(aName); + + // Checks if conversation is with a participant of a MUC we are in. We do + // not want to strip the resource as it is of the form room@domain/nick. + let isMucParticipant = this._mucs.has(convName); + if (isMucParticipant) { + convName = this.normalizeFullJid(aName); + } + + // Checking that the aName can be parsed and is not broken. + let jid = this._parseJID(convName); + if ( + !jid || + !jid.domain || + (isMucParticipant && (!jid.node || !jid.resource)) + ) { + this.ERROR("Could not create conversation as jid is broken: " + convName); + throw new Error("Invalid JID"); + } + + if (!this._conv.has(convName)) { + this._conv.set( + convName, + new this._conversationConstructor(this, convName, isMucParticipant) + ); + } + + return this._conv.get(convName); + }, + + /* Remove an existing conversation */ + removeConversation(aNormalizedName) { + if (this._conv.has(aNormalizedName)) { + this._conv.delete(aNormalizedName); + } else if (this._mucs.has(aNormalizedName)) { + this._mucs.delete(aNormalizedName); + } + }, + + /* Private methods */ + + /** + * Disconnect from the server + * + * @param {number} aError - The error reason, passed to reportDisconnecting. + * @param {string} aErrorMessage - The error message, passed to reportDisconnecting. + * @param {boolean} aQuiet - True to avoid sending status change notifications + * during the uninitialization of the account. + */ + _disconnect( + aError = Ci.prplIAccount.NO_ERROR, + aErrorMessage = "", + aQuiet = false + ) { + if (!this._connection) { + return; + } + + this.reportDisconnecting(aError, aErrorMessage); + + this._buddies.forEach(b => { + if (!aQuiet) { + b.setStatus(Ci.imIStatusInfo.STATUS_UNKNOWN, ""); + } + b.onAccountDisconnected(); + }); + + this._mucs.forEach(muc => { + muc.joining = false; // In case we never finished joining. + muc.left = true; + }); + + this._connection.disconnect(); + delete this._connection; + + // We won't receive "user-icon-changed" notifications while the + // account isn't connected, so clear the cache to avoid keeping an + // obsolete icon. + delete this._cachedUserIcon; + // Also clear the cached user vCard, as we will want to redownload it + // after reconnecting. + delete this._userVCard; + + // Clear vCard requests. + this._pendingVCardRequests = []; + + this.reportDisconnected(); + }, + + /* Set the user status on the server */ + _sendPresence() { + delete this._shouldSendPresenceForIdlenessChange; + + if (!this._connection) { + return; + } + + let si = this.imAccount.statusInfo; + let statusType = si.statusType; + let show = ""; + if (statusType == Ci.imIStatusInfo.STATUS_UNAVAILABLE) { + show = "dnd"; + } else if ( + statusType == Ci.imIStatusInfo.STATUS_AWAY || + statusType == Ci.imIStatusInfo.STATUS_IDLE + ) { + show = "away"; + } + let children = []; + if (show) { + children.push(Stanza.node("show", null, null, show)); + } + let statusText = si.statusText; + if (statusText) { + children.push(Stanza.node("status", null, null, statusText)); + } + if (this._idleSince) { + let time = Math.floor(Date.now() / 1000) - this._idleSince; + children.push(Stanza.node("query", Stanza.NS.last, { seconds: time })); + } + if (this.prefs.prefHasUserValue("priority")) { + let priority = Math.max(-128, Math.min(127, this.getInt("priority"))); + if (priority) { + children.push(Stanza.node("priority", null, null, priority.toString())); + } + } + this.sendStanza( + Stanza.presence({ "xml:lang": "en" }, children), + aStanza => { + // As we are implicitly subscribed to our own presence (rfc6121#4), we + // will receive the presence stanza mirrored back to us. We don't need + // to do anything with this response. + return true; + } + ); + }, + + _downloadingUserVCard: false, + _downloadUserVCard() { + // If a download is already in progress, don't start another one. + if (this._downloadingUserVCard) { + return; + } + this._downloadingUserVCard = true; + let s = Stanza.iq("get", null, null, Stanza.node("vCard", Stanza.NS.vcard)); + this.sendStanza(s, this.onUserVCard, this); + }, + + onUserVCard(aStanza) { + delete this._downloadingUserVCard; + let userVCard = aStanza.getElement(["vCard"]) || null; + if (userVCard) { + // Strip any server-specific namespace off the incoming vcard + // before storing it. + this._userVCard = Stanza.node( + "vCard", + Stanza.NS.vcard, + null, + userVCard.children + ); + } + + // If a user icon exists in the vCard we received from the server, + // we need to ensure the line breaks in its binval are exactly the + // same as those we would include if we sent the icon, and that + // there isn't any other whitespace. + if (this._userVCard) { + let binval = this._userVCard.getElement(["PHOTO", "BINVAL"]); + if (binval && binval.children.length) { + binval = binval.children[0]; + binval.text = binval.text + .replace(/[^A-Za-z0-9\+\/\=]/g, "") + .replace(/.{74}/g, "$&\n"); + } + } else { + // Downloading the vCard failed. + if ( + this.handleErrors({ + itemNotFound: () => false, // OK, no vCard exists yet. + default: () => true, + })(aStanza) + ) { + this.WARN( + "Unexpected error retrieving the user's vcard, " + + "so we won't attempt to set it either." + ); + return; + } + // Set this so that we don't get into an infinite loop trying to download + // the vcard again. The check in sendVCard is for hasOwnProperty. + this._userVCard = null; + } + this._sendVCard(); + }, + + _cachingUserIcon: false, + _cacheUserIcon() { + if (this._cachingUserIcon) { + return; + } + + let userIcon = this.imAccount.statusInfo.getUserIcon(); + if (!userIcon) { + this._cachedUserIcon = null; + this._sendVCard(); + return; + } + + this._cachingUserIcon = true; + let channel = lazy.NetUtil.newChannel({ + uri: userIcon, + loadingPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + securityFlags: + Ci.nsILoadInfo.SEC_REQUIRE_SAME_ORIGIN_INHERITS_SEC_CONTEXT, + contentPolicyType: Ci.nsIContentPolicy.TYPE_IMAGE, + }); + lazy.NetUtil.asyncFetch(channel, (inputStream, resultCode) => { + if (!Components.isSuccessCode(resultCode)) { + return; + } + try { + let type = channel.contentType; + let buffer = lazy.NetUtil.readInputStreamToString( + inputStream, + inputStream.available() + ); + let readImage = lazy.imgTools.decodeImageFromBuffer( + buffer, + buffer.length, + type + ); + let scaledImage; + if (readImage.width <= 96 && readImage.height <= 96) { + scaledImage = lazy.imgTools.encodeImage(readImage, type); + } else { + if (type != "image/jpeg") { + type = "image/png"; + } + scaledImage = lazy.imgTools.encodeScaledImage( + readImage, + type, + 64, + 64 + ); + } + + let bstream = Cc["@mozilla.org/binaryinputstream;1"].createInstance( + Ci.nsIBinaryInputStream + ); + bstream.setInputStream(scaledImage); + + let data = bstream.readBytes(bstream.available()); + this._cachedUserIcon = { + type, + binval: btoa(data).replace(/.{74}/g, "$&\n"), + }; + } catch (e) { + console.error(e); + this._cachedUserIcon = null; + } + delete this._cachingUserIcon; + this._sendVCard(); + }); + }, + _sendVCard() { + if (!this._connection) { + return; + } + + // We have to download the user's existing vCard before updating it. + // This lets us preserve the fields that we don't change or don't know. + // Some servers may reject a new vCard if we don't do this first. + if (!this.hasOwnProperty("_userVCard")) { + // The download of the vCard is asynchronous and will call _sendVCard back + // when the user's vCard has been received. + this._downloadUserVCard(); + return; + } + + // Read the local user icon asynchronously from the disk. + // _cacheUserIcon will call _sendVCard back once the icon is ready. + if (!this.hasOwnProperty("_cachedUserIcon")) { + this._cacheUserIcon(); + return; + } + + // If the user currently doesn't have any vCard on the server or + // the download failed, an empty new one. + if (!this._userVCard) { + this._userVCard = Stanza.node("vCard", Stanza.NS.vcard); + } + + // Keep a serialized copy of the existing user vCard so that we + // can avoid resending identical data to the server. + let existingVCard = this._userVCard.getXML(); + + let fn = this._userVCard.getElement(["FN"]); + let displayName = this.imAccount.statusInfo.displayName; + if (displayName) { + // If a display name is set locally, update or add an FN field to the vCard. + if (!fn) { + this._userVCard.addChild( + Stanza.node("FN", Stanza.NS.vcard, null, displayName) + ); + } else if (fn.children.length) { + fn.children[0].text = displayName; + } else { + fn.addText(displayName); + } + } else if ("_forceUserDisplayNameUpdate" in this) { + // We remove a display name stored on the server without replacing + // it with a new value only if this _sendVCard call is the result of + // a user action. This is to avoid removing data from the server each + // time the user connects from a new profile. + this._userVCard.children = this._userVCard.children.filter( + n => n.qName != "FN" + ); + } + delete this._forceUserDisplayNameUpdate; + + if (this._cachedUserIcon) { + // If we have a local user icon, update or add it in the PHOTO field. + let photoChildren = [ + Stanza.node("TYPE", Stanza.NS.vcard, null, this._cachedUserIcon.type), + Stanza.node( + "BINVAL", + Stanza.NS.vcard, + null, + this._cachedUserIcon.binval + ), + ]; + let photo = this._userVCard.getElement(["PHOTO"]); + if (photo) { + photo.children = photoChildren; + } else { + this._userVCard.addChild( + Stanza.node("PHOTO", Stanza.NS.vcard, null, photoChildren) + ); + } + } else if ("_forceUserIconUpdate" in this) { + // Like for the display name, we remove a photo without + // replacing it only if the call is caused by a user action. + this._userVCard.children = this._userVCard.children.filter( + n => n.qName != "PHOTO" + ); + } + delete this._forceUserIconUpdate; + + // Send the vCard only if it has really changed. + // We handle the result response from the server (it does not require + // any further action). + if (this._userVCard.getXML() != existingVCard) { + this.sendStanza( + Stanza.iq("set", null, null, this._userVCard), + this._handleResult() + ); + } else { + this.LOG( + "Not sending the vCard because the server stored vCard is identical." + ); + } + }, +}; diff --git a/comm/chat/protocols/xmpp/xmpp-commands.sys.mjs b/comm/chat/protocols/xmpp/xmpp-commands.sys.mjs new file mode 100644 index 0000000000..fc02f3bc0e --- /dev/null +++ b/comm/chat/protocols/xmpp/xmpp-commands.sys.mjs @@ -0,0 +1,347 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "_", () => + l10nHelper("chrome://chat/locale/xmpp.properties") +); + +// Get conversation object. +function getConv(aConv) { + return aConv.wrappedJSObject; +} + +// Get account object. +function getAccount(aConv) { + return getConv(aConv)._account; +} + +function getMUC(aConv) { + let conv = getConv(aConv); + if (conv.left) { + conv.writeMessage( + conv.name, + lazy._("conversation.error.commandFailedNotInRoom"), + { system: true } + ); + return null; + } + return conv; +} + +// Trims the string and splits it in two parts on the first space +// if there is one. Returns the non-empty parts in an array. +function splitInput(aString) { + let params = aString.trim(); + if (!params) { + return []; + } + + let splitParams = []; + let offset = params.indexOf(" "); + if (offset != -1) { + splitParams.push(params.slice(0, offset)); + splitParams.push(params.slice(offset + 1).trimLeft()); + } else { + splitParams.push(params); + } + return splitParams; +} + +// Trims the string and splits it in two parts (The first part is a nickname +// and the second part is the rest of string) based on nicknames of current +// participants. Returns the non-empty parts in an array. +function splitByNick(aString, aConv) { + let params = aString.trim(); + if (!params) { + return []; + } + + // Match trimmed-string with the longest prefix of participant's nickname. + let nickName = ""; + for (let participant of aConv._participants.keys()) { + if ( + params.startsWith(participant + " ") && + participant.length > nickName.length + ) { + nickName = participant; + } + } + if (!nickName) { + let offset = params.indexOf(" "); + let expectedNickName = offset != -1 ? params.slice(0, offset) : params; + aConv.writeMessage( + aConv.name, + lazy._("conversation.error.nickNotInRoom", expectedNickName), + { system: true } + ); + return []; + } + + let splitParams = []; + splitParams.push(nickName); + + let msg = params.substring(nickName.length); + if (msg) { + splitParams.push(msg.trimLeft()); + } + return splitParams; +} + +// Splits aMsg in two entries and checks the first entry is a valid jid, then +// passes it to aConv.invite(). +// Returns false if aMsg is empty, otherwise returns true. +function invite(aMsg, aConv) { + let params = splitInput(aMsg); + if (!params.length) { + return false; + } + + // Check user's jid is valid. + let account = getAccount(aConv); + let jid = account._parseJID(params[0]); + if (!jid) { + aConv.writeMessage( + aConv.name, + lazy._("conversation.error.invalidJID", params[0]), + { system: true } + ); + return true; + } + + aConv.invite(...params); + return true; +} + +export var commands = [ + { + name: "join", + get helpString() { + return lazy._("command.join3", "join"); + }, + run(aMsg, aConv, aReturnedConv) { + let account = getAccount(aConv); + let params = aMsg.trim(); + let conv; + + if (!params) { + conv = getConv(aConv); + if (!conv.isChat) { + return false; + } + if (!conv.left) { + return true; + } + + // Rejoin the current conversation. If the conversation was explicitly + // parted by the user, chatRoomFields will have been deleted. + // Otherwise, make use of it. + if (conv.chatRoomFields) { + account.joinChat(conv.chatRoomFields); + return true; + } + + params = conv.name; + } + let chatRoomFields = account.getChatRoomDefaultFieldValues(params); + conv = account.joinChat(chatRoomFields); + + if (aReturnedConv) { + aReturnedConv.value = conv; + } + return true; + }, + }, + { + name: "part", + get helpString() { + return lazy._("command.part2", "part"); + }, + usageContext: Ci.imICommand.CMD_CONTEXT_CHAT, + run(aMsg, aConv) { + let conv = getConv(aConv); + if (!conv.left) { + conv.part(aMsg); + } + return true; + }, + }, + { + name: "topic", + get helpString() { + return lazy._("command.topic", "topic"); + }, + usageContext: Ci.imICommand.CMD_CONTEXT_CHAT, + run(aMsg, aConv) { + let conv = getMUC(aConv); + if (!conv) { + return true; + } + conv.topic = aMsg; + return true; + }, + }, + { + name: "ban", + get helpString() { + return lazy._("command.ban", "ban"); + }, + usageContext: Ci.imICommand.CMD_CONTEXT_CHAT, + run(aMsg, aConv) { + let params = splitInput(aMsg); + if (!params.length) { + return false; + } + + let conv = getMUC(aConv); + if (conv) { + conv.ban(...params); + } + return true; + }, + }, + { + name: "kick", + get helpString() { + return lazy._("command.kick", "kick"); + }, + usageContext: Ci.imICommand.CMD_CONTEXT_CHAT, + run(aMsg, aConv) { + let conv = getMUC(aConv); + if (!conv) { + return true; + } + + let params = splitByNick(aMsg, conv); + if (!params.length) { + return false; + } + conv.kick(...params); + return true; + }, + }, + { + name: "invite", + get helpString() { + return lazy._("command.invite", "invite"); + }, + usageContext: Ci.imICommand.CMD_CONTEXT_CHAT, + run(aMsg, aConv) { + let conv = getMUC(aConv); + if (!conv) { + return true; + } + + return invite(aMsg, conv); + }, + }, + { + name: "inviteto", + get helpString() { + return lazy._("command.inviteto", "inviteto"); + }, + usageContext: Ci.imICommand.CMD_CONTEXT_IM, + run: (aMsg, aConv) => invite(aMsg, getConv(aConv)), + }, + { + name: "me", + get helpString() { + return lazy._("command.me", "me"); + }, + usageContext: Ci.imICommand.CMD_CONTEXT_CHAT, + run(aMsg, aConv) { + let params = aMsg.trim(); + if (!params) { + return false; + } + + let conv = getConv(aConv); + conv.sendMsg(params, true); + + return true; + }, + }, + { + name: "nick", + get helpString() { + return lazy._("command.nick", "nick"); + }, + usageContext: Ci.imICommand.CMD_CONTEXT_CHAT, + run(aMsg, aConv) { + let params = aMsg.trim().split(/\s+/); + if (!params[0]) { + return false; + } + + let conv = getMUC(aConv); + if (conv) { + conv.setNick(params[0]); + } + return true; + }, + }, + { + name: "msg", + get helpString() { + return lazy._("command.msg", "msg"); + }, + usageContext: Ci.imICommand.CMD_CONTEXT_CHAT, + run(aMsg, aConv, aReturnedConv) { + let conv = getMUC(aConv); + if (!conv) { + return true; + } + + let params = splitByNick(aMsg, conv); + if (params.length != 2) { + return false; + } + let [nickName, msg] = params; + + let account = getAccount(aConv); + let privateConv = account.createConversation(conv.name + "/" + nickName); + if (!privateConv) { + return true; + } + privateConv.sendMsg(msg.trim()); + + if (aReturnedConv) { + aReturnedConv.value = privateConv; + } + return true; + }, + }, + { + name: "version", + get helpString() { + return lazy._("command.version", "version"); + }, + usageContext: Ci.imICommand.CMD_CONTEXT_IM, + run(aMsg, aConv, aReturnedConv) { + let conv = getConv(aConv); + if (conv.left) { + return true; + } + + // We do not have user's resource. + if (!conv._targetResource) { + conv.writeMessage( + conv.name, + lazy._("conversation.error.resourceNotAvailable", conv.shortName), + { + system: true, + } + ); + return true; + } + + conv.getVersion(); + return true; + }, + }, +]; diff --git a/comm/chat/protocols/xmpp/xmpp-session.sys.mjs b/comm/chat/protocols/xmpp/xmpp-session.sys.mjs new file mode 100644 index 0000000000..ca2fd4eebb --- /dev/null +++ b/comm/chat/protocols/xmpp/xmpp-session.sys.mjs @@ -0,0 +1,764 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { DNS } = ChromeUtils.import("resource:///modules/DNS.jsm"); +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs"; +import { Socket } from "resource:///modules/socket.sys.mjs"; +import { Stanza, XMPPParser } from "resource:///modules/xmpp-xml.sys.mjs"; +import { XMPPAuthMechanisms } from "resource:///modules/xmpp-authmechs.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "_", () => + l10nHelper("chrome://chat/locale/xmpp.properties") +); + +export function XMPPSession( + aHost, + aPort, + aSecurity, + aJID, + aPassword, + aAccount +) { + this._host = aHost; + this._port = aPort; + + this._connectionSecurity = aSecurity; + if (this._connectionSecurity == "old_ssl") { + this._security = ["ssl"]; + } else if (this._connectionSecurity != "none") { + this._security = [aPort == 5223 || aPort == 443 ? "ssl" : "starttls"]; + } + + if (!aJID.node) { + aAccount.reportDisconnecting( + Ci.prplIAccount.ERROR_INVALID_USERNAME, + lazy._("connection.error.invalidUsername") + ); + aAccount.reportDisconnected(); + return; + } + this._jid = aJID; + this._domain = aJID.domain; + this._password = aPassword; + this._account = aAccount; + this._resource = aJID.resource; + this._handlers = new Map(); + this._account.reportConnecting(); + + // The User has specified a certain server or port, so we should not do + // DNS SRV lookup or the preference of disabling DNS SRV part and use + // normal connect is set. + // RFC 6120 (Section 3.2.3): When Not to Use SRV. + if ( + Services.prefs.getBoolPref("chat.dns.srv.disable") || + this._account.prefs.prefHasUserValue("server") || + this._account.prefs.prefHasUserValue("port") + ) { + this.connect(this._host, this._port, this._security); + return; + } + + // RFC 6120 (Section 3.2.1): SRV lookup. + this._account.reportConnecting(lazy._("connection.srvLookup")); + DNS.srv("_xmpp-client._tcp." + this._host) + .then(aResult => this._handleSrvQuery(aResult)) + .catch(aError => { + if (aError === this.SRV_ERROR_XMPP_NOT_SUPPORTED) { + this.LOG("SRV: XMPP is not supported on this domain."); + + // RFC 6120 (Section 3.2.1) and RFC 2782 (Usage rules): Abort as the + // service is decidedly not available at this domain. + this._account.reportDisconnecting( + Ci.prplIAccount.ERROR_OTHER_ERROR, + lazy._("connection.error.XMPPNotSupported") + ); + this._account.reportDisconnected(); + return; + } + + this.ERROR("Error during SRV lookup:", aError); + + // Since we don't receive a response to SRV query, we SHOULD attempt the + // fallback process (use normal connect without SRV lookup). + this.connect(this._host, this._port, this._security); + }); +} + +XMPPSession.prototype = { + /* for the socket.jsm helper */ + __proto__: Socket, + connectTimeout: 60, + readWriteTimeout: 300, + + // Contains the remaining SRV records if we failed to connect the current one. + _srvRecords: [], + + sendPing() { + this.sendStanza( + Stanza.iq("get", null, null, Stanza.node("ping", Stanza.NS.ping)), + this.cancelDisconnectTimer, + this + ); + }, + _lastReceiveTime: 0, + _lastSendTime: 0, + checkPingTimer(aJustSentSomething = false) { + // Don't start a ping timer if we're not fully connected yet. + if (this.onXmppStanza != this.stanzaListeners.accountListening) { + return; + } + let now = Date.now(); + if (aJustSentSomething) { + this._lastSendTime = now; + } else { + this._lastReceiveTime = now; + } + // We only cancel the ping timer if we've both received and sent + // something in the last two minutes. This is because Openfire + // servers will disconnect us if we don't send anything for a + // couple of minutes. + if ( + Math.min(this._lastSendTime, this._lastReceiveTime) > + now - this.kTimeBeforePing + ) { + this.resetPingTimer(); + } + }, + + get DEBUG() { + return this._account.DEBUG; + }, + get LOG() { + return this._account.LOG; + }, + get WARN() { + return this._account.WARN; + }, + get ERROR() { + return this._account.ERROR; + }, + + _security: null, + _encrypted: false, + + // DNS SRV errors in XMPP. + SRV_ERROR_XMPP_NOT_SUPPORTED: -2, + + // Handles result of DNS SRV query and saves sorted results if it's OK in _srvRecords, + // otherwise throws error. + _handleSrvQuery(aResult) { + this.LOG("SRV lookup: " + JSON.stringify(aResult)); + if (aResult.length == 0) { + // RFC 6120 (Section 3.2.1) and RFC 2782 (Usage rules): No SRV records, + // try to login with the given domain name. + this.connect(this._host, this._port, this._security); + return; + } else if (aResult.length == 1 && aResult[0].host == ".") { + throw this.SRV_ERROR_XMPP_NOT_SUPPORTED; + } + + // Sort results: Lower priority is more preferred and higher weight is + // more preferred in equal priorities. + aResult.sort(function (a, b) { + return a.prio - b.prio || b.weight - a.weight; + }); + + this._srvRecords = aResult; + this._connectNextRecord(); + }, + + _connectNextRecord() { + if (!this._srvRecords.length) { + this.ERROR( + "_connectNextRecord is called and there are no more records " + + "to connect." + ); + return; + } + + let record = this._srvRecords.shift(); + + // RFC 3920 (Section 5.1): Certificates MUST be checked against the + // hostname as provided by the initiating entity (e.g. user). + this.connect( + this._domain, + this._port, + this._security, + null, + record.host, + record.port + ); + }, + + /* Disconnect from the server */ + disconnect() { + if (this.onXmppStanza == this.stanzaListeners.accountListening) { + this.send("</stream:stream>"); + } + delete this.onXmppStanza; + Socket.disconnect.call(this); + if (this._parser) { + this._parser.destroy(); + delete this._parser; + } + this.cancelDisconnectTimer(); + }, + + /* Report errors to the account */ + onError(aError, aException) { + // If we're trying to connect to SRV entries, then keep trying until a + // successful connection occurs or we run out of SRV entries to try. + if (this._srvRecords.length) { + this._connectNextRecord(); + return; + } + + this._account.onError(aError, aException); + }, + + /* Send a text message to the server */ + send(aMsg, aLogString) { + this.sendString(aMsg, "UTF-8", aLogString); + }, + + /* Send a stanza to the server. + * Can set a callback if required, which will be called when the server + * responds to the stanza with a stanza of the same id. The callback should + * return true if the stanza was handled, false if not. Note that an + * undefined return value is treated as true. + */ + sendStanza(aStanza, aCallback, aThis, aLogString) { + if (!aStanza.attributes.hasOwnProperty("id")) { + aStanza.attributes.id = this._account.generateId(); + } + if (aCallback) { + this._handlers.set(aStanza.attributes.id, aCallback.bind(aThis)); + } + this.send(aStanza.getXML(), aLogString); + this.checkPingTimer(true); + return aStanza.attributes.id; + }, + + /* This method handles callbacks for specific ids. */ + execHandler(aId, aStanza) { + let handler = this._handlers.get(aId); + if (!handler) { + return false; + } + let isHandled = handler(aStanza); + // Treat undefined return values as handled. + if (isHandled === undefined) { + isHandled = true; + } + this._handlers.delete(aId); + return isHandled; + }, + + /* Start the XMPP stream */ + startStream() { + if (this._parser) { + this._parser.destroy(); + } + this._parser = new XMPPParser(this); + this.send( + '<?xml version="1.0"?><stream:stream to="' + + this._domain + + '" xmlns="jabber:client" xmlns:stream="http://etherx.jabber.org/streams" version="1.0">' + ); + }, + + startSession() { + this.sendStanza( + Stanza.iq("set", null, null, Stanza.node("session", Stanza.NS.session)), + aStanza => aStanza.attributes.type == "result" + ); + this.onXmppStanza = this.stanzaListeners.sessionStarted; + }, + + /* XEP-0078: Non-SASL Authentication */ + startLegacyAuth() { + if (!this._encrypted && this._connectionSecurity == "require_tls") { + this.onError( + Ci.prplIAccount.ERROR_ENCRYPTION_ERROR, + lazy._("connection.error.startTLSNotSupported") + ); + return; + } + + this.onXmppStanza = this.stanzaListeners.legacyAuth; + let s = Stanza.iq( + "get", + null, + this._domain, + Stanza.node( + "query", + Stanza.NS.auth, + null, + Stanza.node("username", null, null, this._jid.node) + ) + ); + this.sendStanza(s); + }, + + // If aResource is null, it will request to bind a server-generated + // resourcepart, otherwise request to bind a client-submitted resourcepart. + _requestBind(aResource) { + let resourceNode = aResource + ? Stanza.node("resource", null, null, aResource) + : null; + this.sendStanza( + Stanza.iq( + "set", + null, + null, + Stanza.node("bind", Stanza.NS.bind, null, resourceNode) + ) + ); + }, + + /* Socket events */ + /* The connection is established */ + onConnection() { + if (this._security.includes("ssl")) { + this.onXmppStanza = this.stanzaListeners.startAuth; + this._encrypted = true; + } else { + this.onXmppStanza = this.stanzaListeners.initStream; + } + + // Clear SRV results since we have connected. + this._srvRecords = []; + + this._account.reportConnecting(lazy._("connection.initializingStream")); + this.startStream(); + }, + + /* When incoming data is available to be parsed */ + onDataReceived(aData) { + this.checkPingTimer(); + this._lastReceivedData = aData; + try { + this._parser.onDataAvailable(aData); + } catch (e) { + console.error(e); + this.onXMLError("parser-exception", e); + } + delete this._lastReceivedData; + }, + + /* The connection got disconnected without us closing it. */ + onConnectionClosed() { + this._networkError(lazy._("connection.error.serverClosedConnection")); + }, + onConnectionSecurityError(aTLSError, aNSSErrorMessage) { + let error = this._account.handleConnectionSecurityError(this); + this.onError(error, aNSSErrorMessage); + }, + onConnectionReset() { + this._networkError(lazy._("connection.error.resetByPeer")); + }, + onConnectionTimedOut() { + this._networkError(lazy._("connection.error.timedOut")); + }, + _networkError(aMessage) { + this.onError(Ci.prplIAccount.ERROR_NETWORK_ERROR, aMessage); + }, + + /* Methods called by the XMPPParser instance */ + onXMLError(aError, aException) { + if (aError == "parsing-characters") { + this.WARN(aError + ": " + aException + "\n" + this._lastReceivedData); + } else { + this.ERROR(aError + ": " + aException + "\n" + this._lastReceivedData); + } + if (aError != "parse-warning" && aError != "parsing-characters") { + this._networkError(lazy._("connection.error.receivedUnexpectedData")); + } + }, + + // All the functions in stanzaListeners are used as onXmppStanza + // implementations at various steps of establishing the session. + stanzaListeners: { + initStream(aStanza) { + if (aStanza.localName != "features") { + this.ERROR( + "Unexpected stanza " + aStanza.localName + ", expected 'features'" + ); + this._networkError(lazy._("connection.error.incorrectResponse")); + return; + } + + let starttls = aStanza.getElement(["starttls"]); + if (starttls && this._security.includes("starttls")) { + this._account.reportConnecting( + lazy._("connection.initializingEncryption") + ); + this.sendStanza(Stanza.node("starttls", Stanza.NS.tls)); + this.onXmppStanza = this.stanzaListeners.startTLS; + return; + } + if (starttls && starttls.children.some(c => c.localName == "required")) { + this.onError( + Ci.prplIAccount.ERROR_ENCRYPTION_ERROR, + lazy._("connection.error.startTLSRequired") + ); + return; + } + if (!starttls && this._connectionSecurity == "require_tls") { + this.onError( + Ci.prplIAccount.ERROR_ENCRYPTION_ERROR, + lazy._("connection.error.startTLSNotSupported") + ); + return; + } + + // If we aren't starting TLS, jump to the auth step. + this.onXmppStanza = this.stanzaListeners.startAuth; + this.onXmppStanza(aStanza); + }, + startTLS(aStanza) { + if (aStanza.localName != "proceed") { + this._networkError(lazy._("connection.error.failedToStartTLS")); + return; + } + + this.startTLS(); + this._encrypted = true; + this.startStream(); + this.onXmppStanza = this.stanzaListeners.startAuth; + }, + startAuth(aStanza) { + if (aStanza.localName != "features") { + this.ERROR( + "Unexpected stanza " + aStanza.localName + ", expected 'features'" + ); + this._networkError(lazy._("connection.error.incorrectResponse")); + return; + } + + let mechs = aStanza.getElement(["mechanisms"]); + if (!mechs) { + let auth = aStanza.getElement(["auth"]); + if (auth && auth.uri == Stanza.NS.auth_feature) { + this.startLegacyAuth(); + } else { + this._networkError(lazy._("connection.error.noAuthMec")); + } + return; + } + + // Select the auth mechanism we will use. PLAIN will be treated + // a bit differently as we want to avoid it over an unencrypted + // connection, except if the user has explicitly allowed that + // behavior. + let authMechanisms = this._account.authMechanisms || XMPPAuthMechanisms; + let selectedMech = ""; + let canUsePlain = false; + mechs = mechs.getChildren("mechanism"); + for (let m of mechs) { + let mech = m.innerText; + if (mech == "PLAIN" && !this._encrypted) { + // If PLAIN is proposed over an unencrypted connection, + // remember that it's a possibility but don't bother + // checking if the user allowed it until we have verified + // that nothing more secure is available. + canUsePlain = true; + } else if (authMechanisms.hasOwnProperty(mech)) { + selectedMech = mech; + break; + } + } + if (!selectedMech && canUsePlain) { + if (this._connectionSecurity == "allow_unencrypted_plain_auth") { + selectedMech = "PLAIN"; + } else { + this.onError( + Ci.prplIAccount.ERROR_AUTHENTICATION_IMPOSSIBLE, + lazy._("connection.error.notSendingPasswordInClear") + ); + return; + } + } + if (!selectedMech) { + this.onError( + Ci.prplIAccount.ERROR_AUTHENTICATION_IMPOSSIBLE, + lazy._("connection.error.noCompatibleAuthMec") + ); + return; + } + let authMec = authMechanisms[selectedMech]( + this._jid.node, + this._password, + this._domain + ); + this._password = null; + + this._account.reportConnecting(lazy._("connection.authenticating")); + this.onXmppStanza = this.stanzaListeners.authDialog.bind(this, authMec); + this.onXmppStanza(null); // the first auth step doesn't read anything + }, + authDialog(aAuthMec, aStanza) { + if (aStanza && aStanza.localName == "failure") { + let errorMsg = "authenticationFailure"; + if ( + aStanza.getElement(["not-authorized"]) || + aStanza.getElement(["bad-auth"]) + ) { + errorMsg = "notAuthorized"; + } + this.onError( + Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED, + lazy._("connection.error." + errorMsg) + ); + return; + } + + let result; + try { + result = aAuthMec.next(aStanza); + } catch (e) { + this.ERROR("Error in auth mechanism: " + e); + this.onError( + Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED, + lazy._("connection.error.authenticationFailure") + ); + return; + } + + // The authentication mechanism can yield a promise which must resolve + // before sending data. If it rejects, abort. + if (result.value) { + Promise.resolve(result.value).then( + value => { + // Send the XML stanza that is returned. + if (value.send) { + this.send(value.send.getXML(), value.log); + } + }, + e => { + this.ERROR("Error resolving auth mechanism result: " + e); + this.onError( + Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED, + lazy._("connection.error.authenticationFailure") + ); + } + ); + } + if (result.done) { + this.startStream(); + this.onXmppStanza = this.stanzaListeners.startBind; + } + }, + startBind(aStanza) { + if (!aStanza.getElement(["bind"])) { + this.ERROR("Unexpected lack of the bind feature"); + this._networkError(lazy._("connection.error.incorrectResponse")); + return; + } + + this._account.reportConnecting(lazy._("connection.gettingResource")); + this._requestBind(this._resource); + this.onXmppStanza = this.stanzaListeners.bindResult; + }, + bindResult(aStanza) { + if (aStanza.attributes.type == "error") { + let error = this._account.parseError(aStanza); + let message; + switch (error.condition) { + case "resource-constraint": + // RFC 6120 (7.6.2.1): Resource Constraint. + // The account has reached a limit on the number of simultaneous + // connected resources allowed. + message = "connection.error.failedMaxResourceLimit"; + break; + case "bad-request": + // RFC 6120 (7.7.2.1): Bad Request. + // The provided resourcepart cannot be processed by the server. + message = "connection.error.failedResourceNotValid"; + break; + case "conflict": + // RFC 6120 (7.7.2.2): Conflict. + // The provided resourcepart is already in use and the server + // disallowed the resource binding attempt. + this._requestBind(); + return; + default: + this.WARN(`Unhandled bind result error ${error.condition}.`); + message = "connection.error.failedToGetAResource"; + } + this._networkError(lazy._(message)); + return; + } + + let jid = aStanza.getElement(["bind", "jid"]); + if (!jid) { + this._networkError(lazy._("connection.error.failedToGetAResource")); + return; + } + jid = jid.innerText; + this.DEBUG("jid = " + jid); + this._jid = this._account._parseJID(jid); + this._resource = this._jid.resource; + this.startSession(); + }, + legacyAuth(aStanza) { + if (aStanza.attributes.type == "error") { + let error = aStanza.getElement(["error"]); + if (!error) { + this._networkError(lazy._("connection.error.incorrectResponse")); + return; + } + + let code = parseInt(error.attributes.code, 10); + if (code == 401) { + // Failed Authentication (Incorrect Credentials) + this.onError( + Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED, + lazy._("connection.error.notAuthorized") + ); + return; + } else if (code == 406) { + // Failed Authentication (Required Information Not Provided) + this.onError( + Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED, + lazy._("connection.error.authenticationFailure") + ); + return; + } + // else if (code == 409) { + // Failed Authentication (Resource Conflict) + // XXX Flo The spec in XEP-0078 defines this error code, but + // I've yet to find a server sending it. The server I tested + // with just closed the first connection when a second + // connection was attempted with the same resource. + // libpurple's jabber prpl doesn't support this code either. + // } + } + + if (aStanza.attributes.type != "result") { + this._networkError(lazy._("connection.error.incorrectResponse")); + return; + } + + if (aStanza.children.length == 0) { + // Success! + this._password = null; + this.startSession(); + return; + } + + let query = aStanza.getElement(["query"]); + let values = {}; + for (let c of query.children) { + values[c.qName] = c.innerText; + } + + if (!("username" in values) || !("resource" in values)) { + this._networkError(lazy._("connection.error.incorrectResponse")); + return; + } + + // If the resource is empty, we will fallback to brandShortName as + // resource is REQUIRED. + if (!this._resource) { + this._resource = Services.strings + .createBundle("chrome://branding/locale/brand.properties") + .GetStringFromName("brandShortName"); + this._jid = this._setJID( + this._jid.domain, + this._jid.node, + this._resource + ); + } + + let children = [ + Stanza.node("username", null, null, this._jid.node), + Stanza.node("resource", null, null, this._resource), + ]; + + let logString; + if ("digest" in values && this._streamId) { + let hashBase = this._streamId + this._password; + + let ch = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + ch.init(ch.SHA1); + // Non-US-ASCII characters MUST be encoded as UTF-8 since the + // SHA-1 hashing algorithm operates on byte arrays. + let data = [...new TextEncoder().encode(hashBase)]; + ch.update(data, data.length); + let hash = ch.finish(false); + let toHexString = charCode => ("0" + charCode.toString(16)).slice(-2); + let digest = Object.keys(hash) + .map(i => toHexString(hash.charCodeAt(i))) + .join(""); + + children.push(Stanza.node("digest", null, null, digest)); + logString = + "legacyAuth stanza containing SHA-1 hash of the password not logged"; + } else if ("password" in values) { + if ( + !this._encrypted && + this._connectionSecurity != "allow_unencrypted_plain_auth" + ) { + this.onError( + Ci.prplIAccount.ERROR_AUTHENTICATION_IMPOSSIBLE, + lazy._("connection.error.notSendingPasswordInClear") + ); + return; + } + children.push(Stanza.node("password", null, null, this._password)); + logString = "legacyAuth stanza containing password not logged"; + } else { + this.onError( + Ci.prplIAccount.ERROR_AUTHENTICATION_IMPOSSIBLE, + lazy._("connection.error.noCompatibleAuthMec") + ); + return; + } + + let s = Stanza.iq( + "set", + null, + this._domain, + Stanza.node("query", Stanza.NS.auth, null, children) + ); + this.sendStanza( + s, + undefined, + undefined, + `<iq type="set".../> (${logString})` + ); + }, + sessionStarted(aStanza) { + this.resetPingTimer(); + this._account.onConnection(); + this.LOG("Account successfully connected."); + this.onXmppStanza = this.stanzaListeners.accountListening; + }, + accountListening(aStanza) { + let id = aStanza.attributes.id; + if (id && this.execHandler(id, aStanza)) { + return; + } + + this._account.onXmppStanza(aStanza); + let name = aStanza.qName; + if (name == "presence") { + this._account.onPresenceStanza(aStanza); + } else if (name == "message") { + this._account.onMessageStanza(aStanza); + } else if (name == "iq") { + this._account.onIQStanza(aStanza); + } + }, + }, + onXmppStanza(aStanza) { + this.ERROR("should not be reached\n"); + }, +}; diff --git a/comm/chat/protocols/xmpp/xmpp-xml.sys.mjs b/comm/chat/protocols/xmpp/xmpp-xml.sys.mjs new file mode 100644 index 0000000000..9d8c4ca523 --- /dev/null +++ b/comm/chat/protocols/xmpp/xmpp-xml.sys.mjs @@ -0,0 +1,508 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { SAX } from "resource:///modules/sax.sys.mjs"; + +var NS = { + xml: "http://www.w3.org/XML/1998/namespace", + xhtml: "http://www.w3.org/1999/xhtml", + xhtml_im: "http://jabber.org/protocol/xhtml-im", + + // auth + client: "jabber:client", + streams: "http://etherx.jabber.org/streams", + stream: "urn:ietf:params:xml:ns:xmpp-streams", + sasl: "urn:ietf:params:xml:ns:xmpp-sasl", + tls: "urn:ietf:params:xml:ns:xmpp-tls", + bind: "urn:ietf:params:xml:ns:xmpp-bind", + session: "urn:ietf:params:xml:ns:xmpp-session", + auth: "jabber:iq:auth", + auth_feature: "http://jabber.org/features/iq-auth", + http_bind: "http://jabber.org/protocol/httpbind", + http_auth: "http://jabber.org/protocol/http-auth", + xbosh: "urn:xmpp:xbosh", + + private: "jabber:iq:private", + xdata: "jabber:x:data", + + // roster + roster: "jabber:iq:roster", + roster_versioning: "urn:xmpp:features:rosterver", + roster_delimiter: "roster:delimiter", + + // privacy lists + privacy: "jabber:iq:privacy", + + // discovering + disco_info: "http://jabber.org/protocol/disco#info", + disco_items: "http://jabber.org/protocol/disco#items", + caps: "http://jabber.org/protocol/caps", + + // addressing + address: "http://jabber.org/protocol/address", + + muc_user: "http://jabber.org/protocol/muc#user", + muc_owner: "http://jabber.org/protocol/muc#owner", + muc_admin: "http://jabber.org/protocol/muc#admin", + muc_rooms: "http://jabber.org/protocol/muc#rooms", + conference: "jabber:x:conference", + muc: "http://jabber.org/protocol/muc", + register: "jabber:iq:register", + delay: "urn:xmpp:delay", + delay_legacy: "jabber:x:delay", + bookmarks: "storage:bookmarks", + chatstates: "http://jabber.org/protocol/chatstates", + event: "jabber:x:event", + stanzas: "urn:ietf:params:xml:ns:xmpp-stanzas", + vcard: "vcard-temp", + vcard_update: "vcard-temp:x:update", + ping: "urn:xmpp:ping", + carbons: "urn:xmpp:carbons:2", + + geoloc: "http://jabber.org/protocol/geoloc", + geoloc_notify: "http://jabber.org/protocol/geoloc+notify", + mood: "http://jabber.org/protocol/mood", + tune: "http://jabber.org/protocol/tune", + nick: "http://jabber.org/protocol/nick", + nick_notify: "http://jabber.org/protocol/nick+notify", + activity: "http://jabber.org/protocol/activity", + rsm: "http://jabber.org/protocol/rsm", + last: "jabber:iq:last", + version: "jabber:iq:version", + avatar_data: "urn:xmpp:avatar:data", + avatar_data_notify: "urn:xmpp:avatar:data+notify", + avatar_metadata: "urn:xmpp:avatar:metadata", + avatar_metadata_notify: "urn:xmpp:avatar:metadata+notify", + pubsub: "http://jabber.org/protocol/pubsub", + pubsub_event: "http://jabber.org/protocol/pubsub#event", +}; + +var TOP_LEVEL_ELEMENTS = { + message: "jabber:client", + presence: "jabber:client", + iq: "jabber:client", + "stream:features": "http://etherx.jabber.org/streams", + proceed: "urn:ietf:params:xml:ns:xmpp-tls", + failure: [ + "urn:ietf:params:xml:ns:xmpp-tls", + "urn:ietf:params:xml:ns:xmpp-sasl", + ], + success: "urn:ietf:params:xml:ns:xmpp-sasl", + challenge: "urn:ietf:params:xml:ns:xmpp-sasl", + error: "urn:ietf:params:xml:ns:xmpp-streams", +}; + +// Features that we support in XMPP. +// Don't forget to add your new features here. +export var SupportedFeatures = [ + NS.chatstates, + NS.conference, + NS.disco_info, + NS.last, + NS.muc, + NS.ping, + NS.vcard, + NS.version, +]; + +/* Stanza Builder */ +export var Stanza = { + NS, + + /* Create a presence stanza */ + presence: (aAttr, aData) => Stanza.node("presence", null, aAttr, aData), + + /* Create a message stanza */ + message(aTo, aMsg, aState, aAttr = {}, aData = []) { + aAttr.to = aTo; + if (!("type" in aAttr)) { + aAttr.type = "chat"; + } + + if (aMsg) { + aData.push(Stanza.node("body", null, null, aMsg)); + } + + if (aState) { + aData.push(Stanza.node(aState, Stanza.NS.chatstates)); + } + + return Stanza.node("message", null, aAttr, aData); + }, + + /* Create a iq stanza */ + iq(aType, aId, aTo, aData) { + let attrs = { type: aType }; + if (aId) { + attrs.id = aId; + } + if (aTo) { + attrs.to = aTo; + } + return this.node("iq", null, attrs, aData); + }, + + /* Create a XML node */ + node(aName, aNs, aAttr, aData) { + let node = new XMLNode(null, aNs, aName, aName, aAttr); + if (aData) { + if (!Array.isArray(aData)) { + aData = [aData]; + } + for (let child of aData) { + node[typeof child == "string" ? "addText" : "addChild"](child); + } + } + + return node; + }, +}; + +/* Text node + * Contains a text */ +function TextNode(aText) { + this.text = aText; +} +TextNode.prototype = { + get type() { + return "text"; + }, + + append(aText) { + this.text += aText; + }, + + /* For debug purposes, returns an indented (unencoded) string */ + convertToString(aIndent) { + return aIndent + this.text + "\n"; + }, + + /* Returns the encoded XML */ + getXML() { + return Cc["@mozilla.org/txttohtmlconv;1"] + .getService(Ci.mozITXTToHTMLConv) + .scanTXT(this.text, Ci.mozITXTToHTMLConv.kEntities); + }, + + /* To read the unencoded data. */ + get innerText() { + return this.text; + }, +}; + +/* XML node */ +/* https://www.w3.org/TR/2008/REC-xml-20081126 */ +/* aUri is the namespace. */ +/* aLocalName must have value, otherwise throws. */ +/* aAttr is an object */ +/* Example: <f:a xmlns:f='g' d='1'> is parsed to + uri/namespace='g', localName='a', qName='f:a', attributes={d='1'} */ +function XMLNode( + aParentNode, + aUri, + aLocalName, + aQName = aLocalName, + aAttr = {} +) { + if (!aLocalName) { + throw new Error("aLocalName must have value"); + } + + this._parentNode = aParentNode; // Used only for parsing + this.uri = aUri; + this.localName = aLocalName; + this.qName = aQName; + this.attributes = {}; + this.children = []; + + for (let attributeName in aAttr) { + // Each attribute specification has a name and a value. + if (aAttr[attributeName]) { + this.attributes[attributeName] = aAttr[attributeName]; + } + } +} +XMLNode.prototype = { + get type() { + return "node"; + }, + + /* Add a new child node */ + addChild(aNode) { + this.children.push(aNode); + }, + + /* Add text node */ + addText(aText) { + let lastIndex = this.children.length - 1; + if (lastIndex >= 0 && this.children[lastIndex] instanceof TextNode) { + this.children[lastIndex].append(aText); + } else { + this.children.push(new TextNode(aText)); + } + }, + + /* Get child elements by namespace */ + getChildrenByNS(aNS) { + return this.children.filter(c => c.uri == aNS); + }, + + /* Get the first element anywhere inside the node (including child nodes) + that matches the query. + A query consists of an array of localNames. */ + getElement(aQuery) { + if (aQuery.length == 0) { + return this; + } + + let nq = aQuery.slice(1); + for (let child of this.children) { + if (child.type == "text" || child.localName != aQuery[0]) { + continue; + } + let n = child.getElement(nq); + if (n) { + return n; + } + } + + return null; + }, + + /* Get all elements of the node (including child nodes) that match the query. + A query consists of an array of localNames. */ + getElements(aQuery) { + if (aQuery.length == 0) { + return [this]; + } + + let c = this.getChildren(aQuery[0]); + let nq = aQuery.slice(1); + let res = []; + for (let child of c) { + let n = child.getElements(nq); + res = res.concat(n); + } + + return res; + }, + + /* Get immediate children by the node name */ + getChildren(aName) { + return this.children.filter(c => c.type != "text" && c.localName == aName); + }, + + // Test if the node is a stanza and its namespace is valid. + isXmppStanza() { + if (!TOP_LEVEL_ELEMENTS.hasOwnProperty(this.qName)) { + return false; + } + let ns = TOP_LEVEL_ELEMENTS[this.qName]; + return ns == this.uri || (Array.isArray(ns) && ns.includes(this.uri)); + }, + + /* Returns indented XML */ + convertToString(aIndent = "") { + let s = + aIndent + "<" + this.qName + this._getXmlns() + this._getAttributeText(); + let content = ""; + for (let child of this.children) { + content += child.convertToString(aIndent + " "); + } + return ( + s + + (content ? ">\n" + content + aIndent + "</" + this.qName : "/") + + ">\n" + ); + }, + + /* Returns the XML */ + getXML() { + let s = "<" + this.qName + this._getXmlns() + this._getAttributeText(); + let innerXML = this.innerXML; + return s + (innerXML ? ">" + innerXML + "</" + this.qName : "/") + ">"; + }, + + get innerXML() { + return this.children.map(c => c.getXML()).join(""); + }, + get innerText() { + return this.children.map(c => c.innerText).join(""); + }, + + /* Private methods */ + _getXmlns() { + return this.uri ? ' xmlns="' + this.uri + '"' : ""; + }, + _getAttributeText() { + let s = ""; + for (let name in this.attributes) { + s += " " + name + '="' + this.attributes[name] + '"'; + } + return s; + }, +}; + +export function XMPPParser(aListener) { + this._listener = aListener; + + // We only get tagName from onclosetag callback, but we need more, so save the + // opening tags. + let tagStack = []; + this._parser = SAX.parser(true, { xmlns: true, lowercase: true }); + this._parser.onopentag = node => { + if (this._parser.error) { + // sax-js doesn't stop on error, but we want to. + return; + } + let attrs = {}; + for (let [name, attr] of Object.entries(node.attributes)) { + if (name == "xmlns") { + continue; + } + attrs[name] = attr.value; + } + this.startElement(node.uri, node.local, node.name, attrs); + tagStack.push(node); + }; + this._parser.onclosetag = tagName => { + if (this._parser.error) { + return; + } + let node = tagStack.pop(); + if (tagName == node.name) { + this.endElement(node.uri, node.local, node.name); + } else { + this.error(`Unexpected </${tagName}>, expecting </${node.name}>`); + } + }; + this._parser.ontext = t => { + if (this._parser.error) { + return; + } + this.characters(t); + }; + this._parser.onerror = this.error; +} + +XMPPParser.prototype = { + _decoder: new TextDecoder(), + _destroyPending: false, + destroy() { + delete this._listener; + + try { + this._parser.close(); + } catch (e) {} + delete this._parser; + }, + + _logReceivedData(aData) { + this._listener.LOG("received:\n" + aData); + }, + /** + * Decodes the byte string to UTF-8 (via byte array) before feeding it to the + * SAXML parser. + * + * @param {string} data - Raw XML byte string. + */ + onDataAvailable(data) { + let bytes = new Uint8Array(data.length); + for (let i = 0; i < data.length; i++) { + bytes[i] = data.charCodeAt(i); + } + let utf8Data = this._decoder.decode(bytes); + this._parser.write(utf8Data); + }, + + startElement(aUri, aLocalName, aQName, aAttributes) { + if (aQName == "stream:stream") { + let node = new XMLNode(null, aUri, aLocalName, aQName, aAttributes); + // The node we created doesn't have children, but + // <stream:stream> isn't closed, so avoid displaying /> at the end. + this._logReceivedData(node.convertToString().slice(0, -3) + ">\n"); + + if ("_node" in this) { + this._listener.onXMLError( + "unexpected-stream-start", + "stream:stream inside an already started stream" + ); + return; + } + + this._listener._streamId = node.attributes.id; + if (!("version" in node.attributes)) { + this._listener.startLegacyAuth(); + } + + this._node = null; + return; + } + + let node = new XMLNode(this._node, aUri, aLocalName, aQName, aAttributes); + if (this._node) { + this._node.addChild(node); + } + + this._node = node; + }, + + characters(aCharacters) { + if (!this._node) { + // Ignore whitespace received on the stream to keep the connection alive. + if (aCharacters.trim()) { + this._listener.onXMLError( + "parsing-characters", + "No parent for characters: " + aCharacters + ); + } + return; + } + + this._node.addText(aCharacters); + }, + + endElement(aUri, aLocalName, aQName) { + if (aQName == "stream:stream") { + this._logReceivedData("</stream:stream>"); + delete this._node; + return; + } + + if (!this._node) { + this._listener.onXMLError( + "parsing-node", + "No parent for node : " + aLocalName + ); + return; + } + + // RFC 6120 (8): XML Stanzas. + // Checks if the node is the root and it's valid. + if (!this._node._parentNode) { + if (this._node.isXmppStanza()) { + this._logReceivedData(this._node.convertToString()); + try { + this._listener.onXmppStanza(this._node); + } catch (e) { + console.error(e); + dump(e + "\n"); + } + } else { + this._listener.onXMLError( + "parsing-node", + "Root node " + aLocalName + " is not valid." + ); + } + } + + this._node = this._node._parentNode; + }, + + error(aError) { + if (this._listener) { + this._listener.onXMLError("parse-error", aError); + } + }, +}; diff --git a/comm/chat/protocols/xmpp/xmpp.sys.mjs b/comm/chat/protocols/xmpp/xmpp.sys.mjs new file mode 100644 index 0000000000..08fdcd5629 --- /dev/null +++ b/comm/chat/protocols/xmpp/xmpp.sys.mjs @@ -0,0 +1,106 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs"; +import { GenericProtocolPrototype } from "resource:///modules/jsProtoHelper.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "_", () => + l10nHelper("chrome://chat/locale/xmpp.properties") +); +ChromeUtils.defineESModuleGetters(lazy, { + XMPPAccountPrototype: "resource:///modules/xmpp-base.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "XMPPAccount", () => { + function XMPPAccount(aProtoInstance, aImAccount) { + this._init(aProtoInstance, aImAccount); + } + XMPPAccount.prototype = lazy.XMPPAccountPrototype; + return XMPPAccount; +}); + +export function XMPPProtocol() { + this.commands = ChromeUtils.importESModule( + "resource:///modules/xmpp-commands.sys.mjs" + ).commands; + this.registerCommands(); +} + +XMPPProtocol.prototype = { + __proto__: GenericProtocolPrototype, + get normalizedName() { + return "jabber"; + }, + get name() { + return "XMPP"; + }, + get iconBaseURI() { + return "chrome://prpl-jabber/skin/"; + }, + getAccount(aImAccount) { + return new lazy.XMPPAccount(this, aImAccount); + }, + + usernameSplits: [ + { + get label() { + return lazy._("options.domain"); + }, + separator: "@", + defaultValue: "jabber.org", + }, + ], + + options: { + resource: { + get label() { + return lazy._("options.resource"); + }, + default: "", + }, + priority: { + get label() { + return lazy._("options.priority"); + }, + default: 0, + }, + connection_security: { + get label() { + return lazy._("options.connectionSecurity"); + }, + listValues: { + get require_tls() { + return lazy._("options.connectionSecurity.requireEncryption"); + }, + get opportunistic_tls() { + return lazy._("options.connectionSecurity.opportunisticTLS"); + }, + get allow_unencrypted_plain_auth() { + return lazy._("options.connectionSecurity.allowUnencryptedAuth"); + }, + // "old_ssl" and "none" are also supported, but not exposed in the UI. + // Any unknown value will fallback to the opportunistic_tls behavior. + }, + default: "require_tls", + }, + server: { + get label() { + return lazy._("options.connectServer"); + }, + default: "", + }, + port: { + get label() { + return lazy._("options.connectPort"); + }, + default: 5222, + }, + }, + get chatHasTopic() { + return true; + }, +}; |