parsers.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  1. /**
  2. * @module:headless-shared-parsers
  3. */
  4. import sizzle from 'sizzle';
  5. import _converse from './_converse.js';
  6. import api from './api/index.js';
  7. import dayjs from 'dayjs';
  8. import log from '../log.js';
  9. import { Strophe } from 'strophe.js';
  10. import { decodeHTMLEntities } from '../utils/html.js';
  11. import { getAttributes } from '../utils/stanza.js';
  12. import { rejectMessage } from './actions.js';
  13. import { XFORM_TYPE_MAP, XFORM_VALIDATE_TYPE_MAP } from './constants.js';
  14. const { NS } = Strophe;
  15. export class StanzaParseError extends Error {
  16. /**
  17. * @param {string} message
  18. * @param {Element} stanza
  19. */
  20. constructor (message, stanza) {
  21. super(message);
  22. this.name = 'StanzaParseError';
  23. this.stanza = stanza;
  24. }
  25. }
  26. /**
  27. * Extract the XEP-0359 stanza IDs from the passed in stanza
  28. * and return a map containing them.
  29. * @param {Element} stanza - The message stanza
  30. * @param {Element} original_stanza - The encapsulating stanza which contains
  31. * the message stanza.
  32. * @returns {Object}
  33. */
  34. export function getStanzaIDs (stanza, original_stanza) {
  35. const attrs = {};
  36. // Store generic stanza ids
  37. const sids = sizzle(`stanza-id[xmlns="${Strophe.NS.SID}"]`, stanza);
  38. const sid_attrs = sids.reduce((acc, s) => {
  39. acc[`stanza_id ${s.getAttribute('by')}`] = s.getAttribute('id');
  40. return acc;
  41. }, {});
  42. Object.assign(attrs, sid_attrs);
  43. // Store the archive id
  44. const result = sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop();
  45. if (result) {
  46. const bare_jid = _converse.session.get('bare_jid');
  47. const by_jid = original_stanza.getAttribute('from') || bare_jid;
  48. attrs[`stanza_id ${by_jid}`] = result.getAttribute('id');
  49. }
  50. // Store the origin id
  51. const origin_id = sizzle(`origin-id[xmlns="${Strophe.NS.SID}"]`, stanza).pop();
  52. if (origin_id) {
  53. attrs['origin_id'] = origin_id.getAttribute('id');
  54. }
  55. return attrs;
  56. }
  57. /**
  58. * @param {Element} stanza
  59. * @returns {import('./types').EncryptionAttrs}
  60. */
  61. export function getEncryptionAttributes (stanza) {
  62. const eme_tag = sizzle(`encryption[xmlns="${Strophe.NS.EME}"]`, stanza).pop();
  63. const namespace = eme_tag?.getAttribute('namespace');
  64. const attrs = {};
  65. if (namespace) {
  66. attrs.is_encrypted = true;
  67. attrs.encryption_namespace = namespace;
  68. } else if (sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).pop()) {
  69. attrs.is_encrypted = true;
  70. attrs.encryption_namespace = Strophe.NS.OMEMO;
  71. }
  72. return attrs;
  73. }
  74. /**
  75. * @param {Element} stanza - The message stanza
  76. * @param {Element} original_stanza - The original stanza, that contains the
  77. * message stanza, if it was contained, otherwise it's the message stanza itself.
  78. * @returns {Object}
  79. */
  80. export function getRetractionAttributes (stanza, original_stanza) {
  81. const fastening = sizzle(`> apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop();
  82. if (fastening) {
  83. const applies_to_id = fastening.getAttribute('id');
  84. const retracted = sizzle(`> retract[xmlns="${Strophe.NS.RETRACT}"]`, fastening).pop();
  85. if (retracted) {
  86. const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
  87. const time = delay ? dayjs(delay.getAttribute('stamp')).toISOString() : new Date().toISOString();
  88. return {
  89. 'editable': false,
  90. 'retracted': time,
  91. 'retracted_id': applies_to_id
  92. };
  93. }
  94. } else {
  95. const tombstone = sizzle(`> retracted[xmlns="${Strophe.NS.RETRACT}"]`, stanza).pop();
  96. if (tombstone) {
  97. return {
  98. 'editable': false,
  99. 'is_tombstone': true,
  100. 'retracted': tombstone.getAttribute('stamp')
  101. };
  102. }
  103. }
  104. return {};
  105. }
  106. /**
  107. * @param {Element} stanza
  108. * @param {Element} original_stanza
  109. */
  110. export function getCorrectionAttributes (stanza, original_stanza) {
  111. const el = sizzle(`replace[xmlns="${Strophe.NS.MESSAGE_CORRECT}"]`, stanza).pop();
  112. if (el) {
  113. const replace_id = el.getAttribute('id');
  114. if (replace_id) {
  115. const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
  116. const time = delay ? dayjs(delay.getAttribute('stamp')).toISOString() : new Date().toISOString();
  117. return {
  118. replace_id,
  119. 'edited': time
  120. };
  121. }
  122. }
  123. return {};
  124. }
  125. /**
  126. * @param {Element} stanza
  127. */
  128. export function getOpenGraphMetadata (stanza) {
  129. const fastening = sizzle(`> apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop();
  130. if (fastening) {
  131. const applies_to_id = fastening.getAttribute('id');
  132. const meta = sizzle(`> meta[xmlns="${Strophe.NS.XHTML}"]`, fastening);
  133. if (meta.length) {
  134. const msg_limit = api.settings.get('message_limit');
  135. const data = meta.reduce((acc, el) => {
  136. const property = el.getAttribute('property');
  137. if (property) {
  138. let value = decodeHTMLEntities(el.getAttribute('content') || '');
  139. if (msg_limit && property === 'og:description' && value.length >= msg_limit) {
  140. value = `${value.slice(0, msg_limit)}${decodeHTMLEntities('…')}`;
  141. }
  142. acc[property] = value;
  143. }
  144. return acc;
  145. }, {
  146. 'ogp_for_id': applies_to_id,
  147. });
  148. if ("og:description" in data || "og:title" in data || "og:image" in data) {
  149. return data;
  150. }
  151. }
  152. }
  153. return {};
  154. }
  155. /**
  156. * @param {Element} stanza
  157. */
  158. export function getSpoilerAttributes (stanza) {
  159. const spoiler = sizzle(`spoiler[xmlns="${Strophe.NS.SPOILER}"]`, stanza).pop();
  160. return {
  161. 'is_spoiler': !!spoiler,
  162. 'spoiler_hint': spoiler?.textContent
  163. };
  164. }
  165. /**
  166. * @param {Element} stanza
  167. */
  168. export function getOutOfBandAttributes (stanza) {
  169. const xform = sizzle(`x[xmlns="${Strophe.NS.OUTOFBAND}"]`, stanza).pop();
  170. if (xform) {
  171. return {
  172. 'oob_url': xform.querySelector('url')?.textContent,
  173. 'oob_desc': xform.querySelector('desc')?.textContent
  174. };
  175. }
  176. return {};
  177. }
  178. /**
  179. * Returns the human readable error message contained in a `groupchat` message stanza of type `error`.
  180. * @param {Element} stanza - The message stanza
  181. */
  182. export function getErrorAttributes (stanza) {
  183. if (stanza.getAttribute('type') === 'error') {
  184. const error = stanza.querySelector('error');
  185. const text = sizzle(`text[xmlns="${Strophe.NS.STANZAS}"]`, error).pop();
  186. return {
  187. 'is_error': true,
  188. 'error_text': text?.textContent,
  189. 'error_type': error.getAttribute('type'),
  190. 'error_condition': error.firstElementChild.nodeName
  191. };
  192. }
  193. return {};
  194. }
  195. /**
  196. * Given a message stanza, find and return any XEP-0372 references
  197. * @param {Element} stanza - The message stanza
  198. * @returns {import('./types').XEP372Reference[]}
  199. */
  200. export function getReferences (stanza) {
  201. return sizzle(`reference[xmlns="${Strophe.NS.REFERENCE}"]`, stanza).map(ref => {
  202. const anchor = ref.getAttribute('anchor');
  203. const text = stanza.querySelector(anchor ? `#${anchor}` : 'body')?.textContent;
  204. if (!text) {
  205. log.warn(`Could not find referenced text for ${ref}`);
  206. return null;
  207. }
  208. const begin = Number(ref.getAttribute('begin'));
  209. const end = Number(ref.getAttribute('end'));
  210. return {
  211. begin, end,
  212. type: ref.getAttribute('type'),
  213. value: text.slice(begin, end),
  214. uri: ref.getAttribute('uri')
  215. };
  216. }).filter(r => r);
  217. }
  218. /**
  219. * @param {Element} stanza
  220. */
  221. export function getReceiptId (stanza) {
  222. const receipt = sizzle(`received[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).pop();
  223. return receipt?.getAttribute('id');
  224. }
  225. /**
  226. * Determines whether the passed in stanza is a XEP-0280 Carbon
  227. * @param {Element} stanza - The message stanza
  228. * @returns {Boolean}
  229. */
  230. export function isCarbon (stanza) {
  231. const xmlns = Strophe.NS.CARBONS;
  232. return (
  233. sizzle(`message > received[xmlns="${xmlns}"]`, stanza).length > 0 ||
  234. sizzle(`message > sent[xmlns="${xmlns}"]`, stanza).length > 0
  235. );
  236. }
  237. /**
  238. * Returns the XEP-0085 chat state contained in a message stanza
  239. * @param {Element} stanza - The message stanza
  240. */
  241. export function getChatState (stanza) {
  242. return sizzle(
  243. `
  244. composing[xmlns="${NS.CHATSTATES}"],
  245. paused[xmlns="${NS.CHATSTATES}"],
  246. inactive[xmlns="${NS.CHATSTATES}"],
  247. active[xmlns="${NS.CHATSTATES}"],
  248. gone[xmlns="${NS.CHATSTATES}"]`,
  249. stanza
  250. ).pop()?.nodeName;
  251. }
  252. /**
  253. * @param {Element} stanza
  254. * @param {Object} attrs
  255. */
  256. export function isValidReceiptRequest (stanza, attrs) {
  257. return (
  258. attrs.sender !== 'me' &&
  259. !attrs.is_carbon &&
  260. !attrs.is_archived &&
  261. sizzle(`request[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).length
  262. );
  263. }
  264. /**
  265. * Check whether the passed-in stanza is a forwarded message that is "bare",
  266. * i.e. it's not forwarded as part of a larger protocol, like MAM.
  267. * @param { Element } stanza
  268. */
  269. export function throwErrorIfInvalidForward (stanza) {
  270. const bare_forward = sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length;
  271. if (bare_forward) {
  272. rejectMessage(stanza, 'Forwarded messages not part of an encapsulating protocol are not supported');
  273. const from_jid = stanza.getAttribute('from');
  274. throw new StanzaParseError(`Ignoring unencapsulated forwarded message from ${from_jid}`, stanza);
  275. }
  276. }
  277. /**
  278. * Determines whether the passed in stanza is a XEP-0333 Chat Marker
  279. * @method getChatMarker
  280. * @param {Element} stanza - The message stanza
  281. * @returns {Element}
  282. */
  283. export function getChatMarker (stanza) {
  284. // If we receive more than one marker (which shouldn't happen), we take
  285. // the highest level of acknowledgement.
  286. return sizzle(`
  287. acknowledged[xmlns="${Strophe.NS.MARKERS}"],
  288. displayed[xmlns="${Strophe.NS.MARKERS}"],
  289. received[xmlns="${Strophe.NS.MARKERS}"]`,
  290. stanza
  291. ).pop();
  292. }
  293. /**
  294. * @param {Element} stanza
  295. * @returns {boolean}
  296. */
  297. export function isHeadline (stanza) {
  298. return stanza.getAttribute('type') === 'headline';
  299. }
  300. /**
  301. * @param {Element} stanza
  302. * @returns {Promise<boolean>}
  303. */
  304. export async function isMUCPrivateMessage (stanza) {
  305. const bare_jid = Strophe.getBareJidFromJid(stanza.getAttribute('from'));
  306. return !!(await api.rooms.get(bare_jid));
  307. }
  308. /**
  309. * @param {Element} stanza
  310. * @returns {boolean}
  311. */
  312. export function isServerMessage (stanza) {
  313. if (sizzle(`mentions[xmlns="${Strophe.NS.MENTIONS}"]`, stanza).pop()) {
  314. return false;
  315. }
  316. const from_jid = stanza.getAttribute('from');
  317. if (stanza.getAttribute('type') !== 'error' && from_jid && !from_jid.includes('@')) {
  318. // Some servers (e.g. Prosody) don't set the stanza
  319. // type to "headline" when sending server messages.
  320. // For now we check if an @ signal is included, and if not,
  321. // we assume it's a headline stanza.
  322. return true;
  323. }
  324. return false;
  325. }
  326. /**
  327. * Determines whether the passed in stanza is a XEP-0313 MAM stanza
  328. * @method isArchived
  329. * @param {Element} original_stanza - The message stanza
  330. * @returns {boolean}
  331. */
  332. export function isArchived (original_stanza) {
  333. return !!sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop();
  334. }
  335. /**
  336. * @param {Element} field
  337. * @param {boolean} readonly
  338. * @param {Element} stanza
  339. * @return {import('./types').XFormField}
  340. */
  341. function parseXFormField(field, readonly, stanza) {
  342. const v = field.getAttribute('var');
  343. const label = field.getAttribute('label') || '';
  344. const type = field.getAttribute('type');
  345. const desc = field.querySelector('desc')?.textContent;
  346. const result = { readonly, desc };
  347. if (type === 'list-single' || type === 'list-multi') {
  348. const values = Array.from(field.querySelectorAll(':scope > value')).map((el) => el?.textContent);
  349. const options = Array.from(field.querySelectorAll(':scope > option')).map(
  350. (/** @type {HTMLElement} */ option) => {
  351. const value = option.querySelector('value')?.textContent;
  352. return {
  353. value,
  354. label: option.getAttribute('label'),
  355. selected: values.includes(value),
  356. required: !!field.querySelector('required'),
  357. ...result,
  358. };
  359. }
  360. );
  361. return {
  362. type,
  363. options,
  364. label: field.getAttribute('label'),
  365. var: v,
  366. required: !!field.querySelector('required'),
  367. ...result,
  368. };
  369. } else if (type === 'fixed') {
  370. const text = field.querySelector('value')?.textContent;
  371. return { text, label, type, var: v, ...result };
  372. } else if (type === 'jid-multi') {
  373. return {
  374. type,
  375. var: v,
  376. label,
  377. value: field.querySelector('value')?.textContent,
  378. required: !!field.querySelector('required'),
  379. ...result,
  380. };
  381. } else if (type === 'boolean') {
  382. const value = field.querySelector('value')?.textContent;
  383. return {
  384. type,
  385. var: v,
  386. label,
  387. checked: ((value === '1' || value === 'true') && true) || false,
  388. ...result,
  389. };
  390. } else if (v === 'url') {
  391. return {
  392. var: v,
  393. label,
  394. value: field.querySelector('value')?.textContent,
  395. ...result,
  396. };
  397. } else if (v === 'username') {
  398. return {
  399. var: v,
  400. label,
  401. value: field.querySelector('value')?.textContent,
  402. required: !!field.querySelector('required'),
  403. type: getInputType(field),
  404. ...result,
  405. };
  406. } else if (v === 'password') {
  407. return {
  408. var: v,
  409. label,
  410. value: field.querySelector('value')?.textContent,
  411. required: !!field.querySelector('required'),
  412. ...result,
  413. };
  414. } else if (v === 'ocr') { // Captcha
  415. const uri = field.querySelector('uri');
  416. const el = sizzle('data[cid="' + uri.textContent.replace(/^cid:/, '') + '"]', stanza)[0];
  417. return {
  418. label: field.getAttribute('label'),
  419. var: v,
  420. uri: {
  421. type: uri.getAttribute('type'),
  422. data: el?.textContent,
  423. },
  424. required: !!field.querySelector('required'),
  425. ...result,
  426. };
  427. } else {
  428. return {
  429. label,
  430. var: v,
  431. required: !!field.querySelector('required'),
  432. value: field.querySelector('value')?.textContent,
  433. type: getInputType(field),
  434. ...result,
  435. };
  436. }
  437. }
  438. /**
  439. * @param {Element} field
  440. */
  441. export function getInputType(field) {
  442. const type = XFORM_TYPE_MAP[field.getAttribute('type')]
  443. if (type == 'text') {
  444. const datatypes = field.getElementsByTagNameNS("http://jabber.org/protocol/xdata-validate", "validate");
  445. if (datatypes.length === 1) {
  446. const datatype = datatypes[0].getAttribute("datatype");
  447. return XFORM_VALIDATE_TYPE_MAP[datatype] || type;
  448. }
  449. }
  450. return type;
  451. }
  452. /**
  453. * @param {Element} stanza
  454. * @returns {import('./types').XForm}
  455. */
  456. export function parseXForm(stanza) {
  457. const xs = sizzle(`x[xmlns="${Strophe.NS.XFORM}"]`, stanza);
  458. if (xs.length > 1) {
  459. log.error(stanza);
  460. throw new Error('Invalid stanza');
  461. } else if (xs.length === 0) {
  462. return null;
  463. }
  464. const x = xs[0];
  465. const type = /** @type {import('./types').XFormResponseType} */ (x.getAttribute('type'));
  466. const result = {
  467. type,
  468. title: x.querySelector('title')?.textContent,
  469. };
  470. if (type === 'result') {
  471. const reported = x.querySelector(':scope > reported');
  472. if (reported) {
  473. const reported_fields = reported ? Array.from(reported.querySelectorAll(':scope > field')) : [];
  474. const items = Array.from(x.querySelectorAll(':scope > item'));
  475. return /** @type {import('./types').XForm} */({
  476. ...result,
  477. reported: /** @type {import('./types').XFormReportedField[]} */ (reported_fields.map(getAttributes)),
  478. items: items.map((item) => {
  479. return Array.from(item.querySelectorAll('field')).map((field) => {
  480. return /** @type {import('./types').XFormResultItemField} */ ({
  481. ...getAttributes(field),
  482. value: field.querySelector('value')?.textContent ?? '',
  483. });
  484. });
  485. }),
  486. });
  487. }
  488. return {
  489. ...result,
  490. fields: Array.from(x.querySelectorAll('field')).map((field) => parseXFormField(field, true, stanza)),
  491. };
  492. } else if (type === 'form') {
  493. return {
  494. ...result,
  495. instructions: x.querySelector('instructions')?.textContent,
  496. fields: Array.from(x.querySelectorAll('field')).map((field) => parseXFormField(field, false, stanza)),
  497. };
  498. } else {
  499. throw new Error(`Invalid type in XForm response stanza: ${type}`);
  500. }
  501. }