index.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  1. import debounce from 'lodash-es/debounce';
  2. import log from "../../log.js";
  3. import sizzle from 'sizzle';
  4. import _converse from '../_converse.js';
  5. import { ANONYMOUS, BOSH_WAIT, LOGOUT } from '../../shared/constants.js';
  6. import { CONNECTION_STATUS } from '../constants';
  7. import { Strophe } from 'strophe.js';
  8. import { clearSession, tearDown } from "../../utils/session.js";
  9. import { getOpenPromise } from '@converse/openpromise';
  10. import { setUserJID, } from '../../utils/init.js';
  11. const i = Object.keys(Strophe.Status).reduce((max, k) => Math.max(max, Strophe.Status[k]), 0);
  12. Strophe.Status.RECONNECTING = i + 1;
  13. /**
  14. * The Connection class manages the connection to the XMPP server. It's
  15. * agnostic concerning the underlying protocol (i.e. websocket, long-polling
  16. * via BOSH or websocket inside a shared worker).
  17. */
  18. export class Connection extends Strophe.Connection {
  19. constructor (service, options) {
  20. super(service, options);
  21. // For new sessions, we need to send out a presence stanza to notify
  22. // the server/network that we're online.
  23. // When re-attaching to an existing session we don't need to again send out a presence stanza,
  24. // because it's as if "we never left" (see onConnectStatusChanged).
  25. this.send_initial_presence = true;
  26. this.debouncedReconnect = debounce(this.reconnect, 3000);
  27. }
  28. /** @param {Element} body */
  29. xmlInput (body) {
  30. log.debug(body.outerHTML, 'color: darkgoldenrod');
  31. }
  32. /** @param {Element} body */
  33. xmlOutput (body) {
  34. log.debug(body.outerHTML, 'color: darkcyan');
  35. }
  36. async bind () {
  37. const { api } = _converse;
  38. /**
  39. * Synchronous event triggered before we send an IQ to bind the user's
  40. * JID resource for this session.
  41. * @event _converse#beforeResourceBinding
  42. */
  43. await api.trigger('beforeResourceBinding', {'synchronous': true});
  44. super.bind();
  45. }
  46. async onDomainDiscovered (response) {
  47. const { api } = _converse;
  48. const text = await response.text();
  49. const xrd = (new DOMParser()).parseFromString(text, "text/xml").firstElementChild;
  50. if (xrd.nodeName != "XRD" || xrd.namespaceURI != "http://docs.oasis-open.org/ns/xri/xrd-1.0") {
  51. return log.info("Could not discover XEP-0156 connection methods");
  52. }
  53. const bosh_links = sizzle(`Link[rel="urn:xmpp:alt-connections:xbosh"]`, xrd);
  54. const ws_links = sizzle(`Link[rel="urn:xmpp:alt-connections:websocket"]`, xrd);
  55. const bosh_methods = bosh_links.map(el => el.getAttribute('href')).filter(uri => uri.startsWith('https:'));
  56. const ws_methods = ws_links.map(el => el.getAttribute('href')).filter(uri => uri.startsWith('wss:'));
  57. if (bosh_methods.length === 0 && ws_methods.length === 0) {
  58. log.info("Neither BOSH nor WebSocket connection methods have been specified with XEP-0156.");
  59. } else {
  60. // TODO: support multiple endpoints
  61. api.settings.set("websocket_url", ws_methods.pop());
  62. api.settings.set('bosh_service_url', bosh_methods.pop());
  63. this.service = api.settings.get("websocket_url") || api.settings.get('bosh_service_url');
  64. this.setProtocol();
  65. }
  66. }
  67. /**
  68. * Adds support for XEP-0156 by quering the XMPP server for alternate
  69. * connection methods. This allows users to use the websocket or BOSH
  70. * connection of their own XMPP server instead of a proxy provided by the
  71. * host of Converse.js.
  72. * @method Connnection.discoverConnectionMethods
  73. * @param {string} domain
  74. */
  75. async discoverConnectionMethods (domain) {
  76. // Use XEP-0156 to check whether this host advertises websocket or BOSH connection methods.
  77. const options = {
  78. 'mode': /** @type {RequestMode} */('cors'),
  79. 'headers': {
  80. 'Accept': 'application/xrd+xml, text/xml'
  81. }
  82. };
  83. const url = `https://${domain}/.well-known/host-meta`;
  84. let response;
  85. try {
  86. response = await fetch(url, options);
  87. } catch (e) {
  88. log.error(`Failed to discover alternative connection methods at ${url}`);
  89. log.error(e);
  90. return;
  91. }
  92. if (response.status >= 200 && response.status < 400) {
  93. await this.onDomainDiscovered(response);
  94. } else {
  95. log.info("Could not discover XEP-0156 connection methods");
  96. }
  97. }
  98. /**
  99. * Establish a new XMPP session by logging in with the supplied JID and
  100. * password.
  101. * @method Connnection.connect
  102. * @param {String} jid
  103. * @param {String} password
  104. * @param {Function} callback
  105. */
  106. async connect (jid, password, callback) {
  107. const { api } = _converse;
  108. if (api.settings.get("discover_connection_methods")) {
  109. const domain = Strophe.getDomainFromJid(jid);
  110. await this.discoverConnectionMethods(domain);
  111. }
  112. if (!api.settings.get('bosh_service_url') && !api.settings.get("websocket_url")) {
  113. // If we don't have a connection URL, we show an input for the user
  114. // to manually provide it.
  115. api.settings.set('show_connection_url_input', true);
  116. }
  117. super.connect(jid, password, callback || this.onConnectStatusChanged, BOSH_WAIT);
  118. }
  119. /**
  120. * @param {string} reason
  121. */
  122. disconnect(reason) {
  123. super.disconnect(reason);
  124. this.send_initial_presence = true;
  125. }
  126. /**
  127. * Switch to a different transport if a service URL is available for it.
  128. *
  129. * When reconnecting with a new transport, we call setUserJID
  130. * so that a new resource is generated, to avoid multiple
  131. * server-side sessions with the same resource.
  132. *
  133. * We also call `_proto._doDisconnect` so that connection event handlers
  134. * for the old transport are removed.
  135. */
  136. async switchTransport () {
  137. const { api } = _converse;
  138. const bare_jid = _converse.session.get('bare_jid');
  139. if (api.connection.isType('websocket') && api.settings.get('bosh_service_url')) {
  140. await setUserJID(bare_jid);
  141. this._proto._doDisconnect();
  142. this._proto = new Strophe.Bosh(this);
  143. this.service = api.settings.get('bosh_service_url');
  144. } else if (api.connection.isType('bosh') && api.settings.get("websocket_url")) {
  145. if (api.settings.get("authentication") === ANONYMOUS) {
  146. // When reconnecting anonymously, we need to connect with only
  147. // the domain, not the full JID that we had in our previous
  148. // (now failed) session.
  149. await setUserJID(api.settings.get("jid"));
  150. } else {
  151. await setUserJID(bare_jid);
  152. }
  153. this._proto._doDisconnect();
  154. this._proto = new Strophe.Websocket(this);
  155. this.service = api.settings.get("websocket_url");
  156. }
  157. }
  158. async reconnect () {
  159. const { api } = _converse;
  160. log.debug('RECONNECTING: the connection has dropped, attempting to reconnect.');
  161. this.reconnecting = true;
  162. await tearDown(_converse);
  163. const conn_status = _converse.state.connfeedback.get('connection_status');
  164. if (conn_status === Strophe.Status.CONNFAIL) {
  165. this.switchTransport();
  166. } else if (conn_status === Strophe.Status.AUTHFAIL && api.settings.get("authentication") === ANONYMOUS) {
  167. // When reconnecting anonymously, we need to connect with only
  168. // the domain, not the full JID that we had in our previous
  169. // (now failed) session.
  170. await setUserJID(api.settings.get("jid"));
  171. }
  172. /**
  173. * Triggered when the connection has dropped, but Converse will attempt
  174. * to reconnect again.
  175. * @event _converse#will-reconnect
  176. */
  177. api.trigger('will-reconnect');
  178. if (api.settings.get("authentication") === ANONYMOUS) {
  179. await clearSession(_converse);
  180. }
  181. const jid = _converse.session.get('jid');
  182. return api.user.login(jid);
  183. }
  184. /**
  185. * Called as soon as a new connection has been established, either
  186. * by logging in or by attaching to an existing BOSH session.
  187. * @method Connection.onConnected
  188. * @param {Boolean} [reconnecting] - Whether Converse.js reconnected from an earlier dropped session.
  189. */
  190. async onConnected (reconnecting) {
  191. const { api } = _converse;
  192. delete this.reconnecting;
  193. this.flush(); // Solves problem of returned PubSub BOSH response not received by browser
  194. await setUserJID(this.jid);
  195. // Save the current JID in persistent storage so that we can attempt to
  196. // recreate the session from SCRAM keys
  197. if (_converse.state.config.get('trusted')) {
  198. const bare_jid = _converse.session.get('bare_jid');
  199. localStorage.setItem('conversejs-session-jid', bare_jid);
  200. }
  201. /**
  202. * Synchronous event triggered after we've sent an IQ to bind the
  203. * user's JID resource for this session.
  204. * @event _converse#afterResourceBinding
  205. */
  206. await api.trigger('afterResourceBinding', reconnecting, {'synchronous': true});
  207. if (reconnecting) {
  208. /**
  209. * After the connection has dropped and converse.js has reconnected.
  210. * Any Strophe stanza handlers (as registered via `converse.listen.stanza`) will
  211. * have to be registered anew.
  212. * @event _converse#reconnected
  213. * @example _converse.api.listen.on('reconnected', () => { ... });
  214. */
  215. api.trigger('reconnected');
  216. } else {
  217. /**
  218. * Triggered after the connection has been established and Converse
  219. * has got all its ducks in a row.
  220. * @event _converse#initialized
  221. */
  222. api.trigger('connected');
  223. }
  224. }
  225. /**
  226. * Used to keep track of why we got disconnected, so that we can
  227. * decide on what the next appropriate action is (in onDisconnected)
  228. * @method Connection.setDisconnectionCause
  229. * @param {Number|'logout'} [cause] - The status number as received from Strophe.
  230. * @param {String} [reason] - An optional user-facing message as to why
  231. * there was a disconnection.
  232. * @param {Boolean} [override] - An optional flag to replace any previous
  233. * disconnection cause and reason.
  234. */
  235. setDisconnectionCause (cause, reason, override) {
  236. if (cause === undefined) {
  237. delete this.disconnection_cause;
  238. delete this.disconnection_reason;
  239. } else if (this.disconnection_cause === undefined || override) {
  240. this.disconnection_cause = cause;
  241. this.disconnection_reason = reason;
  242. }
  243. }
  244. /**
  245. * @param {Number} [status] - The status number as received from Strophe.
  246. * @param {String} [message] - An optional user-facing message
  247. */
  248. setConnectionStatus (status, message) {
  249. this.status = status;
  250. _converse.state.connfeedback.set({'connection_status': status, message });
  251. }
  252. async finishDisconnection () {
  253. const { api } = _converse;
  254. // Properly tear down the session so that it's possible to manually connect again.
  255. log.debug('DISCONNECTED');
  256. delete this.reconnecting;
  257. this.reset();
  258. tearDown(_converse);
  259. await clearSession(_converse);
  260. api.connection.destroy();
  261. /**
  262. * Triggered after converse.js has disconnected from the XMPP server.
  263. * @event _converse#disconnected
  264. * @memberOf _converse
  265. * @example _converse.api.listen.on('disconnected', () => { ... });
  266. */
  267. api.trigger('disconnected');
  268. }
  269. /**
  270. * Gets called once strophe's status reaches Strophe.Status.DISCONNECTED.
  271. * Will either start a teardown process for converse.js or attempt
  272. * to reconnect.
  273. * @method onDisconnected
  274. */
  275. onDisconnected () {
  276. const { api } = _converse;
  277. if (api.settings.get("auto_reconnect")) {
  278. const reason = this.disconnection_reason;
  279. if (this.disconnection_cause === Strophe.Status.AUTHFAIL) {
  280. if (api.settings.get("credentials_url") || api.settings.get("authentication") === ANONYMOUS) {
  281. // If `credentials_url` is set, we reconnect, because we might
  282. // be receiving expirable tokens from the credentials_url.
  283. //
  284. // If `authentication` is anonymous, we reconnect because we
  285. // might have tried to attach with stale BOSH session tokens
  286. // or with a cached JID and password
  287. return api.connection.reconnect();
  288. } else {
  289. return this.finishDisconnection();
  290. }
  291. } else if (this.status === Strophe.Status.CONNECTING) {
  292. // Don't try to reconnect if we were never connected to begin
  293. // with, otherwise an infinite loop can occur (e.g. when the
  294. // BOSH service URL returns a 404).
  295. const { __ } = _converse;
  296. this.setConnectionStatus(
  297. Strophe.Status.CONNFAIL,
  298. __('An error occurred while connecting to the chat server.')
  299. );
  300. return this.finishDisconnection();
  301. } else if (
  302. this.disconnection_cause === LOGOUT ||
  303. reason === Strophe.ErrorCondition.NO_AUTH_MECH ||
  304. reason === "host-unknown" ||
  305. reason === "remote-connection-failed"
  306. ) {
  307. return this.finishDisconnection();
  308. }
  309. api.connection.reconnect();
  310. } else {
  311. return this.finishDisconnection();
  312. }
  313. }
  314. /**
  315. * Callback method called by Strophe as the Connection goes
  316. * through various states while establishing or tearing down a
  317. * connection.
  318. * @param {Number} status
  319. * @param {String} message
  320. */
  321. onConnectStatusChanged (status, message) {
  322. const { __ } = _converse;
  323. log.debug(`Status changed to: ${CONNECTION_STATUS[status]}`);
  324. if (status === Strophe.Status.ATTACHFAIL) {
  325. this.setConnectionStatus(status);
  326. this.worker_attach_promise?.resolve(false);
  327. } else if (status === Strophe.Status.CONNECTED || status === Strophe.Status.ATTACHED) {
  328. if (this.worker_attach_promise?.isResolved && this.status === Strophe.Status.ATTACHED) {
  329. // A different tab must have attached, so nothing to do for us here.
  330. return;
  331. }
  332. this.setConnectionStatus(status);
  333. this.worker_attach_promise?.resolve(true);
  334. this.setDisconnectionCause();
  335. if (this.reconnecting) {
  336. log.debug(status === Strophe.Status.CONNECTED ? 'Reconnected' : 'Reattached');
  337. this.onConnected(true);
  338. } else {
  339. log.debug(status === Strophe.Status.CONNECTED ? 'Connected' : 'Attached');
  340. if (this.restored) {
  341. // No need to send an initial presence stanza when
  342. // we're restoring an existing session.
  343. this.send_initial_presence = false;
  344. }
  345. this.onConnected();
  346. }
  347. } else if (status === Strophe.Status.DISCONNECTED) {
  348. this.setDisconnectionCause(status, message);
  349. this.onDisconnected();
  350. } else if (status === Strophe.Status.BINDREQUIRED) {
  351. this.bind();
  352. } else if (status === Strophe.Status.ERROR) {
  353. this.setConnectionStatus(
  354. status,
  355. __('An error occurred while connecting to the chat server.')
  356. );
  357. } else if (status === Strophe.Status.CONNECTING) {
  358. this.setConnectionStatus(status);
  359. } else if (status === Strophe.Status.AUTHENTICATING) {
  360. this.setConnectionStatus(status);
  361. } else if (status === Strophe.Status.AUTHFAIL) {
  362. if (!message) {
  363. message = __('Your XMPP address and/or password is incorrect. Please try again.');
  364. }
  365. this.setConnectionStatus(status, message);
  366. this.setDisconnectionCause(status, message, true);
  367. this.onDisconnected();
  368. } else if (status === Strophe.Status.CONNFAIL) {
  369. let feedback = message;
  370. if (message === "host-unknown" || message == "remote-connection-failed") {
  371. feedback = __("Sorry, we could not connect to the XMPP host with domain: %1$s",
  372. `\"${Strophe.getDomainFromJid(this.jid)}\"`);
  373. } else if (message !== undefined && message === Strophe?.ErrorCondition?.NO_AUTH_MECH) {
  374. feedback = __("The XMPP server did not offer a supported authentication mechanism");
  375. }
  376. this.setConnectionStatus(status, feedback);
  377. this.setDisconnectionCause(status, message);
  378. } else if (status === Strophe.Status.DISCONNECTING) {
  379. this.setDisconnectionCause(status, message);
  380. }
  381. }
  382. /**
  383. * @param {string} type
  384. */
  385. isType (type) {
  386. if (type.toLowerCase() === 'websocket') {
  387. return this._proto instanceof Strophe.Websocket;
  388. } else if (type.toLowerCase() === 'bosh') {
  389. return Strophe.Bosh && this._proto instanceof Strophe.Bosh;
  390. }
  391. }
  392. hasResumed () {
  393. const { api } = _converse;
  394. if (api.settings.get("connection_options")?.worker || this.isType('bosh')) {
  395. return _converse.state.connfeedback.get('connection_status') === Strophe.Status.ATTACHED;
  396. } else {
  397. // Not binding means that the session was resumed.
  398. return !this.do_bind;
  399. }
  400. }
  401. restoreWorkerSession () {
  402. this.attach(this.onConnectStatusChanged);
  403. this.worker_attach_promise = getOpenPromise();
  404. return this.worker_attach_promise;
  405. }
  406. }
  407. /**
  408. * The MockConnection class is used during testing, to mock an XMPP connection.
  409. * @class
  410. */
  411. export class MockConnection extends Connection {
  412. /**
  413. * @param {string} service - The BOSH or WebSocket service URL.
  414. * @param {import('strophe.js/src/types/connection').ConnectionOptions} options - The configuration options
  415. */
  416. constructor (service, options) {
  417. super(service, options);
  418. this.sent_stanzas = [];
  419. this.IQ_stanzas = [];
  420. this.IQ_ids = [];
  421. this.features = Strophe.xmlHtmlNode(
  422. '<stream:features xmlns:stream="http://etherx.jabber.org/streams" xmlns="jabber:client">'+
  423. '<ver xmlns="urn:xmpp:features:rosterver"/>'+
  424. '<csi xmlns="urn:xmpp:csi:0"/>'+
  425. '<this xmlns="http://jabber.org/protocol/caps" ver="UwBpfJpEt3IoLYfWma/o/p3FFRo=" hash="sha-1" node="http://prosody.im"/>'+
  426. '<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">'+
  427. '<required/>'+
  428. '</bind>'+
  429. `<sm xmlns='urn:xmpp:sm:3'/>`+
  430. '<session xmlns="urn:ietf:params:xml:ns:xmpp-session">'+
  431. '<optional/>'+
  432. '</session>'+
  433. '</stream:features>').firstElementChild;
  434. // @ts-ignore
  435. this._proto._processRequest = () => {};
  436. this._proto._disconnect = () => this._onDisconnectTimeout();
  437. // eslint-disable-next-line @typescript-eslint/no-empty-function
  438. this._proto._onDisconnectTimeout = () => {};
  439. this._proto._connect = () => {
  440. this.connected = true;
  441. this.mock = true;
  442. this.jid = 'romeo@montague.lit/orchard';
  443. this._changeConnectStatus(Strophe.Status.BINDREQUIRED);
  444. }
  445. }
  446. _processRequest () { // eslint-disable-line class-methods-use-this
  447. // Don't attempt to send out stanzas
  448. }
  449. sendIQ (iq, callback, errback) {
  450. iq = iq.tree?.() ?? iq;
  451. this.IQ_stanzas.push(iq);
  452. const id = super.sendIQ(iq, callback, errback);
  453. this.IQ_ids.push(id);
  454. return id;
  455. }
  456. send (stanza) {
  457. stanza = stanza.tree?.() ?? stanza;
  458. this.sent_stanzas.push(stanza);
  459. return super.send(stanza);
  460. }
  461. async bind () {
  462. const { api } = _converse;
  463. await api.trigger('beforeResourceBinding', {'synchronous': true});
  464. this.authenticated = true;
  465. this._changeConnectStatus(Strophe.Status.CONNECTED);
  466. }
  467. }