summaryrefslogtreecommitdiffstats
path: root/comm/chat/protocols/xmpp
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-21 11:44:51 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-21 11:44:51 +0000
commit9e3c08db40b8916968b9f30096c7be3f00ce9647 (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /comm/chat/protocols/xmpp
parentInitial commit. (diff)
downloadthunderbird-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')
-rw-r--r--comm/chat/protocols/xmpp/.eslintrc.js12
-rw-r--r--comm/chat/protocols/xmpp/components.conf15
-rw-r--r--comm/chat/protocols/xmpp/icons/prpl-jabber-32.pngbin0 -> 1725 bytes
-rw-r--r--comm/chat/protocols/xmpp/icons/prpl-jabber-48.pngbin0 -> 2536 bytes
-rw-r--r--comm/chat/protocols/xmpp/icons/prpl-jabber.pngbin0 -> 768 bytes
-rw-r--r--comm/chat/protocols/xmpp/jar.mn5
-rw-r--r--comm/chat/protocols/xmpp/lib/README.md6
-rw-r--r--comm/chat/protocols/xmpp/lib/moz.build8
-rw-r--r--comm/chat/protocols/xmpp/lib/sax/LICENSE41
-rw-r--r--comm/chat/protocols/xmpp/lib/sax/sax.js1648
-rw-r--r--comm/chat/protocols/xmpp/moz.build26
-rw-r--r--comm/chat/protocols/xmpp/sax.sys.mjs7
-rw-r--r--comm/chat/protocols/xmpp/test/test_authmechs.js160
-rw-r--r--comm/chat/protocols/xmpp/test/test_dnsSrv.js112
-rw-r--r--comm/chat/protocols/xmpp/test/test_parseJidAndNormalization.js104
-rw-r--r--comm/chat/protocols/xmpp/test/test_parseVCard.js139
-rw-r--r--comm/chat/protocols/xmpp/test/test_saslPrep.js66
-rw-r--r--comm/chat/protocols/xmpp/test/test_xmppParser.js135
-rw-r--r--comm/chat/protocols/xmpp/test/test_xmppXml.js103
-rw-r--r--comm/chat/protocols/xmpp/test/xpcshell.ini11
-rw-r--r--comm/chat/protocols/xmpp/xmpp-authmechs.sys.mjs561
-rw-r--r--comm/chat/protocols/xmpp/xmpp-base.sys.mjs3421
-rw-r--r--comm/chat/protocols/xmpp/xmpp-commands.sys.mjs347
-rw-r--r--comm/chat/protocols/xmpp/xmpp-session.sys.mjs764
-rw-r--r--comm/chat/protocols/xmpp/xmpp-xml.sys.mjs508
-rw-r--r--comm/chat/protocols/xmpp/xmpp.sys.mjs106
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
new file mode 100644
index 0000000000..98897f75fb
--- /dev/null
+++ b/comm/chat/protocols/xmpp/icons/prpl-jabber-32.png
Binary files differ
diff --git a/comm/chat/protocols/xmpp/icons/prpl-jabber-48.png b/comm/chat/protocols/xmpp/icons/prpl-jabber-48.png
new file mode 100644
index 0000000000..805820c565
--- /dev/null
+++ b/comm/chat/protocols/xmpp/icons/prpl-jabber-48.png
Binary files differ
diff --git a/comm/chat/protocols/xmpp/icons/prpl-jabber.png b/comm/chat/protocols/xmpp/icons/prpl-jabber.png
new file mode 100644
index 0000000000..bb04c6e6df
--- /dev/null
+++ b/comm/chat/protocols/xmpp/icons/prpl-jabber.png
Binary files differ
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++, // &amp 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="&quot;"
+ ATTRIB_VALUE_ENTITY_U: S++, // <foo bar=&quot
+ 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;
+ },
+};