index.js 21 KB

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