UNPKG

@serenity-is/corelib

Version:
255 lines (222 loc) 8.49 kB
import { Config } from "./config"; import { isArrayLike, isPromiseLike } from "./system"; const esc: Record<string, string> = { '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": "&#39;", '&': '&amp;', } function escFunc(a: string): string { return esc[a]; } /** * Html encodes a string (encodes single and double quotes, & (ampersand), > and < characters) * @param s String (or number etc.) to be HTML encoded */ export function htmlEncode(s: any): string { if (s == null) return ''; if (typeof s !== "string") s = "" + s; return s.replace(/[<>"'&]/g, escFunc) } /** * Toggles the class on the element handling spaces like addClass does. * @param el the element * @param cls the class to toggle * @param add if true, the class will be added, if false the class will be removed, otherwise it will be toggled. */ export function toggleClass(el: Element, cls: string, add?: boolean) { if (!el || cls == null || !cls.length) return; if (cls.indexOf(' ') < 0) { el.classList.toggle(cls, add); return; } var k = cls.split(' ').map(x => x.trim()).filter(x => x.length); for (var a of k) el.classList.toggle(a, add); } /** * Adds a CSS class to the specified element. * * @param el - The element to add the class to. * @param cls - The CSS class to add. * @returns A boolean value indicating whether the class was successfully added. */ export function addClass(el: Element, cls: string) { return toggleClass(el, cls, true); } /** * Removes a CSS class from an element. * * @param el - The element from which to remove the class. * @param cls - The CSS class to remove. * @returns A boolean indicating whether the class was successfully removed. */ export function removeClass(el: Element, cls: string) { return toggleClass(el, cls, false); } /** * Appends content like DOM nodes, string, number or an array of these to the parent node. * Undefined, null, false values are ignored. Promises are awaited. * @param parent Target parent element * @param child The content */ export function appendToNode(parent: ParentNode, child: any) { if (child == null || child === false) return; if (isArrayLike(child)) { for (var i = 0; i < child.length; i++) { appendToNode(parent, child[i]); } } else if (typeof child === "string") { parent.appendChild(document.createTextNode(child)); } else if (child instanceof Node) { parent.appendChild(child); } else if (isPromiseLike(child)) { const placeholder = parent.appendChild(document.createComment("Loading content...")); child.then(result => { const fragment = document.createDocumentFragment(); appendToNode(fragment, result); placeholder.parentElement?.replaceChild(fragment, placeholder); }, error => { placeholder.textContent = "Error loading content: " + error; throw error; }); } else { parent.append(child); } } // From https://pragmaticwebsecurity.com/articles/spasecurity/react-xss-part1 const SAFE_URL_PATTERN = /^(?:(?:https?|mailto|ftp|tel|file|sms):|[^&:/?#]*(?:[/?#]|$))/gi; /** A pattern that matches safe data URLs. It only matches image, video, and audio types. */ const DATA_URL_PATTERN = /^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[a-z0-9+\/]+=*$/i; export function sanitizeUrl(url: string): string { url = String(url).trim(); if (url === "null" || url.length === 0 || url === "about:blank") return "about:blank"; if (url.match(SAFE_URL_PATTERN) || url.match(DATA_URL_PATTERN)) return url; if (url === "javascript:void(0)" || url === "javascript:;") return url; return `unsafe:${url}`; } /** * Gets readonly state of an element. If the element is null, returns null. * It does not check for attached widgets. It returns true if the element has readonly class, * disabled attribute (select, radio, checkbox) or readonly attribute (other inputs). * @param el element */ export function getElementReadOnly(el: Element): boolean | null { if (el == null) return null; if (el.classList.contains('readonly')) return true; const type = el.getAttribute('type'); if (el.tagName == 'SELECT' || type === 'radio' || type === 'checkbox') return el.hasAttribute('disabled'); return el.hasAttribute('readonly'); } /** * Sets readonly class and disabled (for select, radio, checkbox) or readonly attribute (for other inputs) on given element. * It does not check for attached widgets. * @param el Element * @param value Readonly state */ export function setElementReadOnly(elements: Element | ArrayLike<Element>, value: boolean) { if (!elements) return; elements = isArrayLike(elements) ? elements : [elements]; for (var i = 0; i < elements.length; i++) { let el = elements[i]; if (!el) continue; var type = el.getAttribute('type'); el.classList.toggle('readonly', !!value); const attr = el.tagName == 'SELECT' || type === 'radio' || type === 'checkbox' ? 'disabled' : 'readonly'; value ? el.setAttribute(attr, attr) : el.removeAttribute(attr); } } /** * Parses a query string into an object. * @param s Query string to parse, if not specified, location.search will be used. * @return An object with key/value pairs from the query string. */ export function parseQueryString(s?: string): Record<string, string> { let qs: string; if (s === undefined) qs = location.search.substring(1, location.search.length); else qs = s || ''; let result: Record<string, string> = {}; let parts = qs.split('&'); for (let i = 0; i < parts.length; i++) { let part = parts[i]; if (!part.length) continue; let pair = parts[i].split('='); let name = decodeURIComponent(pair[0]); result[name] = (pair.length >= 2 ? decodeURIComponent(pair[1]) : name); } return result; } /** * Gets the return URL from the query string. * @param opt Options for getting the return URL. */ export function getReturnUrl(opt?: { /** Whether to only consider the query string. If true, the function will not check the default return URL. */ queryOnly?: boolean; /** Whether to ignore unsafe URLs. If true or null (default), the function will only return safe URLs. */ ignoreUnsafe?: boolean; /** The purpose of the return URL. This can be used to determine the default return URL if none is found in the query string. */ purpose?: string; }) { var q = parseQueryString(); var returnUrl = q['returnUrl'] || q['ReturnUrl'] || q["ReturnURL"] || q["returnURL"]; if (returnUrl && (!opt?.ignoreUnsafe && !/^\//.test(returnUrl))) return null; if (!returnUrl && !opt?.queryOnly) returnUrl = Config.defaultReturnUrl(opt?.purpose); return returnUrl; } /** * Escapes a CSS selector. * @param selector The CSS selector to escape. */ export function cssEscape(selector: string) { if (typeof CSS !== 'undefined' && typeof CSS.escape === "function") return CSS.escape(selector); var string = String(selector); var length = string.length; var index = -1; var codeUnit: number; var result = ''; var firstCodeUnit = string.charCodeAt(0); if (length == 1 && firstCodeUnit == 0x002D) return '\\' + string; while (++index < length) { codeUnit = string.charCodeAt(index); if (codeUnit == 0x0000) { result += '\uFFFD'; continue; } if ((codeUnit >= 0x0001 && codeUnit <= 0x001F) || codeUnit == 0x007F || (index == 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) || (index == 1 && codeUnit >= 0x0030 && codeUnit <= 0x0039 && firstCodeUnit == 0x002D) ) { result += '\\' + codeUnit.toString(16) + ' '; continue; } if (codeUnit >= 0x0080 || codeUnit == 0x002D || codeUnit == 0x005F || codeUnit >= 0x0030 && codeUnit <= 0x0039 || codeUnit >= 0x0041 && codeUnit <= 0x005A || codeUnit >= 0x0061 && codeUnit <= 0x007A ) { result += string.charAt(index); continue; } result += '\\' + string.charAt(index); } return result; }