import { toValue, type MaybeRef } from 'vue'; import xss from 'xss'; import { ALLOWED_HTML_ATTRIBUTES, ALLOWED_HTML_TAGS } from './constants/sanitization'; /* Constants and utility functions that help in HTML, CSS and DOM manipulation */ // `xss` is CJS with no `exports` map, so Node's lexer cannot see these as named // exports and `import { ... }` throws at link time under native ESM. const { escapeAttrValue, escapeHtml } = xss as unknown as { escapeAttrValue: (str: string) => string; escapeHtml: (str: string) => string; }; /** * Escapes HTML entities in a string to prevent HTML injection. * This should be used for user input that will be displayed in HTML contexts. * Unlike sanitizeHtml, this completely escapes all HTML entities. */ export { escapeHtml }; export function sanitizeHtml(dirtyHtml: string) { const sanitizedHtml = xss(dirtyHtml, { onTagAttr: (tag, name, value) => { if (tag === 'img' && name === 'src') { // Only allow http requests to supported image files from the `static` directory const isImageFile = value.split('#')[0].match(/\.(jpeg|jpg|gif|png|webp)$/) !== null; const isStaticImageFile = isImageFile && value.startsWith('/static/'); if (!value.startsWith('https://') && !isStaticImageFile) { return ''; } } if (ALLOWED_HTML_ATTRIBUTES.includes(name) || name.startsWith('data-')) { // href is allowed but we allow only https and relative URLs if (name === 'href' && !value.match(/^https?:\/\//) && !value.startsWith('/')) { return ''; } return `${name}="${escapeAttrValue(value)}"`; } return; // Return nothing, means keep the default handling measure }, onTag: (tag) => { if (!ALLOWED_HTML_TAGS.includes(tag)) return ''; return; }, }); return sanitizedHtml; } const SAFE_OPEN_PROTOCOLS = ['http:', 'https:']; /** * Opens a URL in a new tab, but only when it resolves to an http(s) protocol. * Persisted, untrusted values (e.g. resource locator URLs) can carry schemes * like `javascript:` or `data:`, so anything else is dropped. Always opens with * `noopener,noreferrer` so the new tab cannot reach back to the opener window. */ export function openSafeUrl(url: string): void { let protocol: string; try { protocol = new URL(url, window.location.origin).protocol; } catch { return; } if (SAFE_OPEN_PROTOCOLS.includes(protocol)) { window.open(url, '_blank', 'noopener,noreferrer'); } } /** * Checks if the input is a string and sanitizes it by removing or escaping harmful characters, * returning the original input if it's not a string. */ export const sanitizeIfString = (message: T): string | T => { if (typeof message === 'string') { return sanitizeHtml(message); } return message; }; export const capitalizeFirstLetter = (text: string): string => { return text.charAt(0).toUpperCase() + text.slice(1); }; export const getBannerRowHeight = async (): Promise => { return await new Promise((resolve) => { setTimeout(() => { resolve(document.getElementById('banners')?.clientHeight ?? 0); }, 0); }); }; export function isOutsideSelected(el: HTMLElement | null) { const selection = document.getSelection(); if (!selection?.anchorNode || !selection.focusNode || !el) { return false; } return ( !el.contains(selection.anchorNode) && !el.contains(selection.focusNode) && (selection.anchorNode !== selection.focusNode || selection.anchorOffset !== selection.focusOffset) ); } let scrollbarWidth: number | undefined; export function getScrollbarWidth() { if (scrollbarWidth !== undefined) { return scrollbarWidth; } const outer = document.createElement('div'); const inner = document.createElement('div'); outer.style.visibility = 'hidden'; outer.style.overflow = 'scroll'; document.body.appendChild(outer); outer.appendChild(inner); scrollbarWidth = outer.offsetWidth - inner.offsetWidth; outer.parentElement?.removeChild(outer); return scrollbarWidth; } export function isEventTargetContainedBy( eventTarget: EventTarget | null | undefined, maybeContainer: MaybeRef, ): boolean { return !!(eventTarget instanceof Node && toValue(maybeContainer)?.contains(eventTarget)); }