UNPKG

strophe.js

Version:

Strophe.js is an XMPP library for JavaScript

687 lines (628 loc) 20.5 kB
import log from './log'; import { ElementType, PARSE_ERROR_NS, XHTML } from './constants'; export type XHTMLAttrs = 'a' | 'blockquote' | 'br' | 'cite' | 'em' | 'img' | 'li' | 'ol' | 'p' | 'span' | 'strong' | 'ul' | 'body'; export interface CookieValue { value: string; domain?: string; path?: string; expires?: string; } export type Cookies = Record<string, string | CookieValue>; /** * Takes a string and turns it into an XML Element. * @param string * @param throwErrorIfInvalidNS * @returns */ export function toElement(string: string, throwErrorIfInvalidNS?: boolean): Element { const doc = xmlHtmlNode(string); const parserError = getParserError(doc); if (parserError) { throw new Error(`Parser Error: ${parserError}`); } const node = getFirstElementChild(doc)!; if ( ['message', 'iq', 'presence'].includes(node.nodeName.toLowerCase()) && node.namespaceURI !== 'jabber:client' && node.namespaceURI !== 'jabber:server' ) { const err_msg = `Invalid namespaceURI ${node.namespaceURI}`; if (throwErrorIfInvalidNS) { throw new Error(err_msg); } else { log.error(err_msg); } } return node; } /** * Properly logs an error to the console * @param e */ export function handleError(e: Error): void { if (typeof e.stack !== 'undefined') { log.fatal(e.stack); } log.fatal('error: ' + e.message); } /** * @param str * @returns */ export function utf16to8(str: string): string { let out = ''; const len = str.length; for (let i = 0; i < len; i++) { const c = str.charCodeAt(i); if (c >= 0x0000 && c <= 0x007f) { out += str.charAt(i); } else if (c > 0x07ff) { out += String.fromCharCode(0xe0 | ((c >> 12) & 0x0f)); out += String.fromCharCode(0x80 | ((c >> 6) & 0x3f)); out += String.fromCharCode(0x80 | ((c >> 0) & 0x3f)); } else { out += String.fromCharCode(0xc0 | ((c >> 6) & 0x1f)); out += String.fromCharCode(0x80 | ((c >> 0) & 0x3f)); } } return out; } /** * @param x * @param y * @returns */ export function xorArrayBuffers(x: ArrayBuffer, y: ArrayBuffer): ArrayBuffer { const xIntArray = new Uint8Array(x); const yIntArray = new Uint8Array(y); const zIntArray = new Uint8Array(x.byteLength); for (let i = 0; i < x.byteLength; i++) { zIntArray[i] = xIntArray[i] ^ yIntArray[i]; } return zIntArray.buffer; } /** * @param buffer * @returns */ export function arrayBufToBase64(buffer: ArrayBuffer): string { let binary = ''; const bytes = new Uint8Array(buffer); const len = bytes.byteLength; for (let i = 0; i < len; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary); } /** * @param str * @returns */ export function base64ToArrayBuf(str: string): ArrayBuffer { return Uint8Array.from(atob(str), (c) => c.charCodeAt(0))?.buffer!; } /** * @param str * @returns */ export function stringToArrayBuf(str: string): ArrayBuffer { const bytes = new TextEncoder().encode(str); return bytes.buffer; } /** * @param cookies */ export function addCookies(cookies?: Cookies): void { if (typeof document === 'undefined') { log.error(`addCookies: not adding any cookies, since there's no document object`); return; } const cookieMap = cookies || {}; for (const cookieName in cookieMap) { if (Object.prototype.hasOwnProperty.call(cookieMap, cookieName)) { let expires = ''; let domain = ''; let path = ''; const cookieObj = cookieMap[cookieName]; const isObj = typeof cookieObj === 'object'; const cookieValue = escape(unescape(isObj ? (cookieObj as CookieValue).value : (cookieObj as string))); if (isObj) { const cv = cookieObj as CookieValue; expires = cv.expires ? ';expires=' + cv.expires : ''; domain = cv.domain ? ';domain=' + cv.domain : ''; path = cv.path ? ';path=' + cv.path : ''; } document.cookie = cookieName + '=' + cookieValue + expires + domain + path; } } } let _xmlGenerator: Document | null = null; /** * Get the DOM document to generate elements. * @returns The currently used DOM document. */ export function xmlGenerator(): Document { if (!_xmlGenerator) { _xmlGenerator = document.implementation.createDocument('jabber:client', 'strophe', null); } return _xmlGenerator; } /** * Creates an XML DOM text node. * Provides a cross implementation version of document.createTextNode. * @param text - The content of the text node. * @returns A new XML DOM text node. */ export function xmlTextNode(text: string): Text { return xmlGenerator().createTextNode(text); } /** * @param stanza * @returns */ export function stripWhitespace(stanza: Element): Element { const childNodes = Array.from(stanza.childNodes); if (childNodes.length === 1 && childNodes[0].nodeType === ElementType.TEXT) { return stanza; } childNodes.forEach((node) => { if (node.nodeName.toLowerCase() === 'body') { return; } if (node.nodeType === ElementType.TEXT && !/\S/.test(node.nodeValue)) { stanza.removeChild(node); } else if (node.nodeType === ElementType.NORMAL) { stripWhitespace(node as Element); } }); return stanza; } /** * Creates an XML DOM node. * @param text - The contents of the XML element. * @returns */ export function xmlHtmlNode(text: string): XMLDocument { const parser = new DOMParser(); return parser.parseFromString(text, 'text/xml') as XMLDocument; } /** * @param doc * @returns */ export function getParserError(doc: XMLDocument): string | null { const el = doc.firstElementChild?.nodeName === 'parsererror' ? doc.firstElementChild : doc.getElementsByTagNameNS(PARSE_ERROR_NS, 'parsererror')[0]; return el?.nodeName === 'parsererror' ? el?.textContent : null; } /** * @param el * @returns */ export function getFirstElementChild(el: XMLDocument): Element | null { if (el.firstElementChild) return el.firstElementChild; let node: Node | undefined; let i = 0; const nodes = el.childNodes; while ((node = nodes[i++])) { if (node.nodeType === 1) return node as Element; } return null; } type XmlElementAttrs = Array<Array<string>> | Record<string, string | number> | string | number; /** * Create an XML DOM element. * * This function creates an XML DOM element correctly across all * implementations. Note that these are not HTML DOM elements, which * aren't appropriate for XMPP stanzas. * * @param name - The name for the element. * @param attrs * An optional array or object containing * key/value pairs to use as element attributes. * The object should be in the format `{'key': 'value'}`. * The array should have the format `[['key1', 'value1'], ['key2', 'value2']]`. * @param text - The text child data for the element. * * @returns A new XML DOM element. */ export function xmlElement(name: string, attrs?: XmlElementAttrs, text?: string | number): Element | null { if (!name) return null; const node = xmlGenerator().createElement(name); if (text && (typeof text === 'string' || typeof text === 'number')) { node.appendChild(xmlTextNode(text.toString())); } else if (typeof attrs === 'string' || typeof attrs === 'number') { node.appendChild(xmlTextNode(attrs.toString())); return node; } if (!attrs) { return node; } else if (Array.isArray(attrs)) { for (const attr of attrs) { if (Array.isArray(attr)) { if (attr[0] != null && attr[1] != null) { node.setAttribute(attr[0], attr[1]); } } } } else if (typeof attrs === 'object') { for (const k of Object.keys(attrs)) { if (k && attrs[k] != null) { node.setAttribute(k, attrs[k].toString()); } } } return node; } /** * Utility method to determine whether a tag is allowed * in the XHTML_IM namespace. * * XHTML tag names are case sensitive and must be lower case. * @method Strophe.XHTML.validTag * @param tag */ export function validTag(tag: string): boolean { for (let i = 0; i < XHTML.tags.length; i++) { if (tag === XHTML.tags[i]) { return true; } } return false; } /** * Utility method to determine whether an attribute is allowed * as recommended per XEP-0071 * * XHTML attribute names are case sensitive and must be lower case. * @method Strophe.XHTML.validAttribute * @param tag * @param attribute */ export function validAttribute(tag: string, attribute: string): boolean { const attrs = XHTML.attributes[tag as XHTMLAttrs]; if (attrs?.length > 0) { for (let i = 0; i < attrs.length; i++) { if (attribute === attrs[i]) { return true; } } } return false; } /** * @method Strophe.XHTML.validCSS * @param style */ export function validCSS(style: string): boolean { for (let i = 0; i < XHTML.css.length; i++) { if (style === XHTML.css[i]) { return true; } } return false; } /** * Copy an HTML DOM Element into an XML DOM. * This function copies a DOM element and all its descendants and returns * the new copy. * @param elem - A DOM element. * @returns A new, copied DOM element tree. */ function createFromHtmlElement(elem: HTMLElement): Node { let el: Node; const tag = elem.nodeName.toLowerCase(); if (validTag(tag)) { try { el = xmlElement(tag)!; if (tag in XHTML.attributes) { const attrs = XHTML.attributes[tag as XHTMLAttrs]; for (let i = 0; i < attrs.length; i++) { const attribute = attrs[i]; let value = elem.getAttribute(attribute); if (typeof value === 'undefined' || value === null || value === '') { continue; } if (attribute === 'style' && typeof value === 'object') { value = (value as CSSStyleDeclaration).cssText ?? value; } if (attribute === 'style') { const css: string[] = []; const cssAttrs = value.split(';'); for (let j = 0; j < cssAttrs.length; j++) { const attr = cssAttrs[j].split(':'); const cssName = attr[0].replace(/^\s*/, '').replace(/\s*$/, '').toLowerCase(); if (validCSS(cssName)) { const cssValue = attr[1].replace(/^\s*/, '').replace(/\s*$/, ''); css.push(cssName + ': ' + cssValue); } } if (css.length > 0) { value = css.join('; '); (el as Element).setAttribute(attribute, value); } } else { (el as Element).setAttribute(attribute, value); } } for (let i = 0; i < elem.childNodes.length; i++) { (el as Element).appendChild(createHtml(elem.childNodes[i])!); } } } catch (_e) { el = xmlTextNode(''); } } else { el = xmlGenerator().createDocumentFragment(); for (let i = 0; i < elem.childNodes.length; i++) { (el as DocumentFragment).appendChild(createHtml(elem.childNodes[i])!); } } return el; } /** * Copy an HTML DOM Node into an XML DOM. * This function copies a DOM element and all its descendants and returns * the new copy. * @method Strophe.createHtml * @param node - A DOM element. * @returns A new, copied DOM element tree. */ export function createHtml(node: Node): Node | undefined { if (node.nodeType === ElementType.NORMAL) { return createFromHtmlElement(node as HTMLElement); } else if (node.nodeType === ElementType.FRAGMENT) { const el = xmlGenerator().createDocumentFragment(); for (let i = 0; i < node.childNodes.length; i++) { el.appendChild(createHtml(node.childNodes[i])!); } return el; } else if (node.nodeType === ElementType.TEXT) { return xmlTextNode(node.nodeValue); } } /** * Copy an XML DOM element. * * This function copies a DOM element and all its descendants and returns * the new copy. * @method Strophe.copyElement * @param node - A DOM element. * @returns A new, copied DOM element tree. */ export function copyElement(node: Node): Element | Text | undefined { let out: Element | Text | undefined; if (node.nodeType === ElementType.NORMAL) { const el = node as Element; out = xmlElement(el.tagName)!; for (let i = 0; i < el.attributes.length; i++) { out.setAttribute(el.attributes[i].nodeName, el.attributes[i].value); } for (let i = 0; i < el.childNodes.length; i++) { out.appendChild(copyElement(el.childNodes[i])!); } } else if (node.nodeType === ElementType.TEXT) { out = xmlGenerator().createTextNode(node.nodeValue); } return out; } /** * Excapes invalid xml characters. * @method Strophe.xmlescape * @param text - text to escape. * @returns Escaped text. */ export function xmlescape(text: string): string { return text .replace(/\&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/'/g, '&apos;') .replace(/"/g, '&quot;'); } /** * Unexcapes invalid xml characters. * @method Strophe.xmlunescape * @param text - text to unescape. * @returns Unescaped text. */ export function xmlunescape(text: string): string { text = text.replace(/\&amp;/g, '&'); text = text.replace(/&lt;/g, '<'); text = text.replace(/&gt;/g, '>'); text = text.replace(/&apos;/g, "'"); text = text.replace(/&quot;/g, '"'); return text; } /** * Map a function over some or all child elements of a given element. * * This is a small convenience function for mapping a function over * some or all of the children of an element. If elemName is null, all * children will be passed to the function, otherwise only children * whose tag names match elemName will be passed. * * @method Strophe.forEachChild * @param elem - The element to operate on. * @param elemName - The child element tag name filter. * @param func - The function to apply to each child. This * function should take a single argument, a DOM element. */ export function forEachChild(elem: Element, elemName: string, func: (child: Element) => void): void { for (let i = 0; i < elem.childNodes.length; i++) { const childNode = elem.childNodes[i]; if (childNode.nodeType === ElementType.NORMAL && (!elemName || isTagEqual(childNode as Element, elemName))) { func(childNode as Element); } } } /** * Compare an element's tag name with a string. * This function is case sensitive. * @method Strophe.isTagEqual * @param el - A DOM element. * @param name - The element name. * @returns * true if the element's tag name matches _el_, and false * otherwise. */ export function isTagEqual(el: Element, name: string): boolean { return el.tagName === name; } /** * Return the XML namespace of an element. * * Prefers the serialized `xmlns` attribute and falls back to the DOM * `namespaceURI`, because the two diverge depending on how the element was * built and neither is reliable on its own: * * - Locally-built stanzas (`$iq`, `stx`, {@link Builder}) are created with * `createElement` and carry their namespace only in the `xmlns` attribute; * their `namespaceURI` is null. * - Stanzas received over the XEP-0114 component transport are built with * `createElementNS` and carry their namespace only on `namespaceURI`; the * redundant `xmlns` attribute is omitted. * - WebSocket / BOSH stanzas parsed by `DOMParser` carry both, except on * child elements that inherit the default namespace without redeclaring it * (those have only `namespaceURI`). * * Checking both is the transport-agnostic way to read an element's namespace. * * @method Strophe.getNamespace * @param elem - The element whose namespace is wanted. * @returns The namespace URI, or null if the element has none. */ export function getNamespace(elem: Element): string | null { return elem.getAttribute('xmlns') || elem.namespaceURI; } /** * Get the concatenation of all text children of an element. * @method Strophe.getText * @param elem - A DOM element. * @returns A String with the concatenated text of all text element children. */ export function getText(elem: Node | null): string | null { if (!elem) return null; let str = ''; if (!elem.childNodes.length && elem.nodeType === ElementType.TEXT) { str += elem.nodeValue; } for (const child of elem.childNodes) { if (child.nodeType === ElementType.TEXT) { str += child.nodeValue; } } return xmlescape(str); } /** * Escape the node part (also called local part) of a JID. * @method Strophe.escapeNode * @param node - A node (or local part). * @returns An escaped node (or local part). */ export function escapeNode(node: unknown): unknown { if (typeof node !== 'string') { return node; } return node .replace(/^\s+|\s+$/g, '') .replace(/\\/g, '\\5c') .replace(/ /g, '\\20') .replace(/\"/g, '\\22') .replace(/\&/g, '\\26') .replace(/\'/g, '\\27') .replace(/\//g, '\\2f') .replace(/:/g, '\\3a') .replace(/</g, '\\3c') .replace(/>/g, '\\3e') .replace(/@/g, '\\40'); } /** * Unescape a node part (also called local part) of a JID. * @method Strophe.unescapeNode * @param node - A node (or local part). * @returns An unescaped node (or local part). */ export function unescapeNode(node: unknown): unknown { if (typeof node !== 'string') { return node; } return node .replace(/\\20/g, ' ') .replace(/\\22/g, '"') .replace(/\\26/g, '&') .replace(/\\27/g, "'") .replace(/\\2f/g, '/') .replace(/\\3a/g, ':') .replace(/\\3c/g, '<') .replace(/\\3e/g, '>') .replace(/\\40/g, '@') .replace(/\\5c/g, '\\'); } /** * Get the node portion of a JID String. * @method Strophe.getNodeFromJid * @param jid - A JID. * @returns A String containing the node. */ export function getNodeFromJid(jid: string): string | null { if (jid.indexOf('@') < 0) { return null; } return jid.split('@')[0]; } /** * Get the domain portion of a JID String. * @method Strophe.getDomainFromJid * @param jid - A JID. * @returns A String containing the domain. */ export function getDomainFromJid(jid: string): string | null { const bare = getBareJidFromJid(jid); if (bare!.indexOf('@') < 0) { return bare; } else { const parts = bare!.split('@'); parts.splice(0, 1); return parts.join('@'); } } /** * Get the resource portion of a JID String. * @method Strophe.getResourceFromJid * @param jid - A JID. * @returns A String containing the resource. */ export function getResourceFromJid(jid: string): string | null { if (!jid) { return null; } const s = jid.split('/'); if (s.length < 2) { return null; } s.splice(0, 1); return s.join('/'); } /** * Get the bare JID from a JID String. * @method Strophe.getBareJidFromJid * @param jid - A JID. * @returns A String containing the bare JID. */ export function getBareJidFromJid(jid: string): string | null { return jid ? jid.split('/')[0] : null; } const utils = { utf16to8, xorArrayBuffers, arrayBufToBase64, base64ToArrayBuf, stringToArrayBuf, addCookies, }; export { utils as default };