index.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521
  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.info(`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. (callback || this.onConnectStatusChanged.bind(this))(
  117. Strophe.Status.DISCONNECTED,
  118. __('Could not automatically determine a connection URL')
  119. );
  120. return;
  121. }
  122. super.connect(jid, password, callback || this.onConnectStatusChanged, BOSH_WAIT);
  123. }
  124. /**
  125. * @param {string} reason
  126. */
  127. disconnect(reason) {
  128. super.disconnect(reason);
  129. this.send_initial_presence = true;
  130. }
  131. /**
  132. * Switch to a different transport if a service URL is available for it.
  133. *
  134. * When reconnecting with a new transport, we call setUserJID
  135. * so that a new resource is generated, to avoid multiple
  136. * server-side sessions with the same resource.
  137. *
  138. * We also call `_proto._doDisconnect` so that connection event handlers
  139. * for the old transport are removed.
  140. */
  141. async switchTransport () {
  142. const { api } = _converse;
  143. const bare_jid = _converse.session.get('bare_jid');
  144. if (api.connection.isType('websocket') && api.settings.get('bosh_service_url')) {
  145. await setUserJID(bare_jid);
  146. this._proto._doDisconnect();
  147. this._proto = new Strophe.Bosh(this);
  148. this.service = api.settings.get('bosh_service_url');
  149. } else if (api.connection.isType('bosh') && api.settings.get("websocket_url")) {
  150. if (api.settings.get("authentication") === ANONYMOUS) {
  151. // When reconnecting anonymously, we need to connect with only
  152. // the domain, not the full JID that we had in our previous
  153. // (now failed) session.
  154. await setUserJID(api.settings.get("jid"));
  155. } else {
  156. await setUserJID(bare_jid);
  157. }
  158. this._proto._doDisconnect();
  159. this._proto = new Strophe.Websocket(this);
  160. this.service = api.settings.get("websocket_url");
  161. }
  162. }
  163. async reconnect () {
  164. const { api } = _converse;
  165. log.debug('RECONNECTING: the connection has dropped, attempting to reconnect.');
  166. this.reconnecting = true;
  167. await tearDown(_converse);
  168. const conn_status = _converse.state.connfeedback.get('connection_status');
  169. if (conn_status === Strophe.Status.CONNFAIL) {
  170. this.switchTransport();
  171. } else if (conn_status === Strophe.Status.AUTHFAIL && api.settings.get("authentication") === ANONYMOUS) {
  172. // When reconnecting anonymously, we need to connect with only
  173. // the domain, not the full JID that we had in our previous
  174. // (now failed) session.
  175. await setUserJID(api.settings.get("jid"));
  176. }
  177. /**
  178. * Triggered when the connection has dropped, but Converse will attempt
  179. * to reconnect again.
  180. * @event _converse#will-reconnect
  181. */
  182. api.trigger('will-reconnect');
  183. if (api.settings.get("authentication") === ANONYMOUS) {
  184. await clearSession(_converse);
  185. }
  186. const jid = _converse.session.get('jid');
  187. return api.user.login(jid);
  188. }
  189. /**
  190. * Called as soon as a new connection has been established, either
  191. * by logging in or by attaching to an existing BOSH session.
  192. * @method Connection.onConnected
  193. * @param {Boolean} [reconnecting] - Whether Converse.js reconnected from an earlier dropped session.
  194. */
  195. async onConnected (reconnecting) {
  196. const { api } = _converse;
  197. delete this.reconnecting;
  198. this.flush(); // Solves problem of returned PubSub BOSH response not received by browser
  199. await setUserJID(this.jid);
  200. // Save the current JID in persistent storage so that we can attempt to
  201. // recreate the session from SCRAM keys
  202. if (_converse.state.config.get('trusted')) {
  203. const bare_jid = _converse.session.get('bare_jid');
  204. localStorage.setItem('conversejs-session-jid', bare_jid);
  205. }
  206. /**
  207. * Synchronous event triggered after we've sent an IQ to bind the
  208. * user's JID resource for this session.
  209. * @event _converse#afterResourceBinding
  210. */
  211. await api.trigger('afterResourceBinding', reconnecting, {'synchronous': true});
  212. if (reconnecting) {
  213. /**
  214. * After the connection has dropped and converse.js has reconnected.
  215. * Any Strophe stanza handlers (as registered via `converse.listen.stanza`) will
  216. * have to be registered anew.
  217. * @event _converse#reconnected
  218. * @example _converse.api.listen.on('reconnected', () => { ... });
  219. */
  220. api.trigger('reconnected');
  221. } else {
  222. /**
  223. * Triggered after the connection has been established and Converse
  224. * has got all its ducks in a row.
  225. * @event _converse#initialized
  226. */
  227. api.trigger('connected');
  228. }
  229. }
  230. /**
  231. * Used to keep track of why we got disconnected, so that we can
  232. * decide on what the next appropriate action is (in onDisconnected)
  233. * @method Connection.setDisconnectionCause
  234. * @param {Number|'logout'} [cause] - The status number as received from Strophe.
  235. * @param {String} [reason] - An optional user-facing message as to why
  236. * there was a disconnection.
  237. * @param {Boolean} [override] - An optional flag to replace any previous
  238. * disconnection cause and reason.
  239. */
  240. setDisconnectionCause (cause, reason, override) {
  241. if (cause === undefined) {
  242. delete this.disconnection_cause;
  243. delete this.disconnection_reason;
  244. } else if (this.disconnection_cause === undefined || override) {
  245. this.disconnection_cause = cause;
  246. this.disconnection_reason = reason;
  247. }
  248. }
  249. /**
  250. * @param {Number} [status] - The status number as received from Strophe.
  251. * @param {String} [message] - An optional user-facing message
  252. */
  253. setConnectionStatus (status, message) {
  254. this.status = status;
  255. _converse.state.connfeedback.set({'connection_status': status, message });
  256. }
  257. async finishDisconnection () {
  258. const { api } = _converse;
  259. // Properly tear down the session so that it's possible to manually connect again.
  260. log.debug('DISCONNECTED');
  261. delete this.reconnecting;
  262. this.reset();
  263. tearDown(_converse);
  264. await clearSession(_converse);
  265. api.connection.destroy();
  266. /**
  267. * Triggered after converse.js has disconnected from the XMPP server.
  268. * @event _converse#disconnected
  269. * @memberOf _converse
  270. * @example _converse.api.listen.on('disconnected', () => { ... });
  271. */
  272. api.trigger('disconnected');
  273. }
  274. /**
  275. * Gets called once strophe's status reaches Strophe.Status.DISCONNECTED.
  276. * Will either start a teardown process for converse.js or attempt
  277. * to reconnect.
  278. * @method onDisconnected
  279. */
  280. onDisconnected () {
  281. const { api } = _converse;
  282. if (api.settings.get("auto_reconnect")) {
  283. const reason = this.disconnection_reason;
  284. if (this.disconnection_cause === Strophe.Status.AUTHFAIL) {
  285. if (api.settings.get("credentials_url") || api.settings.get("authentication") === ANONYMOUS) {
  286. // If `credentials_url` is set, we reconnect, because we might
  287. // be receiving expirable tokens from the credentials_url.
  288. //
  289. // If `authentication` is anonymous, we reconnect because we
  290. // might have tried to attach with stale BOSH session tokens
  291. // or with a cached JID and password
  292. return api.connection.reconnect();
  293. } else {
  294. return this.finishDisconnection();
  295. }
  296. } else if (this.status === Strophe.Status.CONNECTING) {
  297. // Don't try to reconnect if we were never connected to begin
  298. // with, otherwise an infinite loop can occur (e.g. when the
  299. // BOSH service URL returns a 404).
  300. const { __ } = _converse;
  301. this.setConnectionStatus(
  302. Strophe.Status.CONNFAIL,
  303. __('An error occurred while connecting to the chat server.')
  304. );
  305. return this.finishDisconnection();
  306. } else if (
  307. this.disconnection_cause === LOGOUT ||
  308. reason === Strophe.ErrorCondition.NO_AUTH_MECH ||
  309. reason === "host-unknown" ||
  310. reason === "remote-connection-failed"
  311. ) {
  312. return this.finishDisconnection();
  313. }
  314. api.connection.reconnect();
  315. } else {
  316. return this.finishDisconnection();
  317. }
  318. }
  319. /**
  320. * Callback method called by Strophe as the Connection goes
  321. * through various states while establishing or tearing down a
  322. * connection.
  323. * @param {Number} status
  324. * @param {String} [message]
  325. */
  326. onConnectStatusChanged (status, message) {
  327. const { __ } = _converse;
  328. log.debug(`Status changed to: ${CONNECTION_STATUS[status]}`);
  329. if (status === Strophe.Status.ATTACHFAIL) {
  330. this.setConnectionStatus(status);
  331. this.worker_attach_promise?.resolve(false);
  332. } else if (status === Strophe.Status.CONNECTED || status === Strophe.Status.ATTACHED) {
  333. if (this.worker_attach_promise?.isResolved && this.status === Strophe.Status.ATTACHED) {
  334. // A different tab must have attached, so nothing to do for us here.
  335. return;
  336. }
  337. this.setConnectionStatus(status);
  338. this.worker_attach_promise?.resolve(true);
  339. this.setDisconnectionCause();
  340. if (this.reconnecting) {
  341. log.debug(status === Strophe.Status.CONNECTED ? 'Reconnected' : 'Reattached');
  342. this.onConnected(true);
  343. } else {
  344. log.debug(status === Strophe.Status.CONNECTED ? 'Connected' : 'Attached');
  345. if (this.restored) {
  346. // No need to send an initial presence stanza when
  347. // we're restoring an existing session.
  348. this.send_initial_presence = false;
  349. }
  350. this.onConnected();
  351. }
  352. } else if (status === Strophe.Status.DISCONNECTED) {
  353. this.setDisconnectionCause(status, message);
  354. this.onDisconnected();
  355. } else if (status === Strophe.Status.BINDREQUIRED) {
  356. this.bind();
  357. } else if (status === Strophe.Status.ERROR) {
  358. this.setConnectionStatus(
  359. status,
  360. __('An error occurred while connecting to the chat server.')
  361. );
  362. } else if (status === Strophe.Status.CONNECTING) {
  363. this.setConnectionStatus(status);
  364. } else if (status === Strophe.Status.AUTHENTICATING) {
  365. this.setConnectionStatus(status);
  366. } else if (status === Strophe.Status.AUTHFAIL) {
  367. if (!message) {
  368. message = __('Your XMPP address and/or password is incorrect. Please try again.');
  369. }
  370. this.setConnectionStatus(status, message);
  371. this.setDisconnectionCause(status, message, true);
  372. this.onDisconnected();
  373. } else if (status === Strophe.Status.CONNFAIL) {
  374. let feedback = message;
  375. if (message === "host-unknown" || message == "remote-connection-failed") {
  376. feedback = __("We could not connect to %1$s, is your XMPP address correct?",
  377. Strophe.getDomainFromJid(this.jid));
  378. } else if (message !== undefined && message === Strophe?.ErrorCondition?.NO_AUTH_MECH) {
  379. feedback = __("The XMPP server did not offer a supported authentication mechanism");
  380. }
  381. this.setConnectionStatus(status, feedback);
  382. this.setDisconnectionCause(status, message);
  383. } else if (status === Strophe.Status.DISCONNECTING) {
  384. this.setDisconnectionCause(status, message);
  385. }
  386. }
  387. /**
  388. * @param {string} type
  389. */
  390. isType (type) {
  391. if (type.toLowerCase() === 'websocket') {
  392. return this._proto instanceof Strophe.Websocket;
  393. } else if (type.toLowerCase() === 'bosh') {
  394. return Strophe.Bosh && this._proto instanceof Strophe.Bosh;
  395. }
  396. }
  397. hasResumed () {
  398. const { api } = _converse;
  399. if (api.settings.get("connection_options")?.worker || this.isType('bosh')) {
  400. return _converse.state.connfeedback.get('connection_status') === Strophe.Status.ATTACHED;
  401. } else {
  402. // Not binding means that the session was resumed.
  403. return !this.do_bind;
  404. }
  405. }
  406. restoreWorkerSession () {
  407. this.attach(this.onConnectStatusChanged);
  408. this.worker_attach_promise = getOpenPromise();
  409. return this.worker_attach_promise;
  410. }
  411. }
  412. /**
  413. * The MockConnection class is used during testing, to mock an XMPP connection.
  414. * @class
  415. */
  416. export class MockConnection extends Connection {
  417. /**
  418. * @param {string} service - The BOSH or WebSocket service URL.
  419. * @param {import('strophe.js/src/types/connection').ConnectionOptions} options - The configuration options
  420. */
  421. constructor (service, options) {
  422. super(service, options);
  423. this.sent_stanzas = [];
  424. this.IQ_stanzas = [];
  425. this.IQ_ids = [];
  426. this.features = Strophe.xmlHtmlNode(
  427. '<stream:features xmlns:stream="http://etherx.jabber.org/streams" xmlns="jabber:client">'+
  428. '<ver xmlns="urn:xmpp:features:rosterver"/>'+
  429. '<csi xmlns="urn:xmpp:csi:0"/>'+
  430. '<this xmlns="http://jabber.org/protocol/caps" ver="UwBpfJpEt3IoLYfWma/o/p3FFRo=" hash="sha-1" node="http://prosody.im"/>'+
  431. '<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">'+
  432. '<required/>'+
  433. '</bind>'+
  434. `<sm xmlns='urn:xmpp:sm:3'/>`+
  435. '<session xmlns="urn:ietf:params:xml:ns:xmpp-session">'+
  436. '<optional/>'+
  437. '</session>'+
  438. '</stream:features>').firstElementChild;
  439. // @ts-ignore
  440. this._proto._processRequest = () => {};
  441. this._proto._disconnect = () => this._onDisconnectTimeout();
  442. // eslint-disable-next-line @typescript-eslint/no-empty-function
  443. this._proto._onDisconnectTimeout = () => {};
  444. this._proto._connect = () => {
  445. this.connected = true;
  446. this.mock = true;
  447. this.jid = 'romeo@montague.lit/orchard';
  448. this._changeConnectStatus(Strophe.Status.BINDREQUIRED);
  449. }
  450. }
  451. _processRequest () { // eslint-disable-line class-methods-use-this
  452. // Don't attempt to send out stanzas
  453. }
  454. sendIQ (iq, callback, errback) {
  455. iq = iq.tree?.() ?? iq;
  456. this.IQ_stanzas.push(iq);
  457. const id = super.sendIQ(iq, callback, errback);
  458. this.IQ_ids.push(id);
  459. return id;
  460. }
  461. send (stanza) {
  462. stanza = stanza.tree?.() ?? stanza;
  463. this.sent_stanzas.push(stanza);
  464. return super.send(stanza);
  465. }
  466. async bind () {
  467. const { api } = _converse;
  468. await api.trigger('beforeResourceBinding', {'synchronous': true});
  469. this.authenticated = true;
  470. this._changeConnectStatus(Strophe.Status.CONNECTED);
  471. }
  472. }