UNPKG

@metamask/snaps-utils

Version:
451 lines 17.5 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.serialiseJsx = exports.walkJsx = exports.getJsxChildren = exports.hasChildren = exports.getTotalTextLength = exports.validateJsxElements = exports.validateAssetSelector = exports.validateTextLinks = exports.validateLink = exports.getJsxElementFromComponent = exports.getTextChildren = void 0; const jsx_runtime_1 = require("@metamask/snaps-sdk/jsx-runtime"); const snaps_sdk_1 = require("@metamask/snaps-sdk"); const jsx_1 = require("@metamask/snaps-sdk/jsx"); const utils_1 = require("@metamask/utils"); const marked_1 = require("marked"); const url_1 = require("./url.cjs"); const MAX_TEXT_LENGTH = 50000; // 50 kb const ALLOWED_PROTOCOLS = ['https:', 'mailto:', 'metamask:']; /** * Get the button variant from a legacy button component variant. * * @param variant - The legacy button component variant. * @returns The button variant. */ function getButtonVariant(variant) { switch (variant) { case 'primary': return 'primary'; case 'secondary': return 'destructive'; default: return undefined; } } /** * Get the children of a JSX element. If there is only one child, the child is * returned directly. Otherwise, the children are returned as an array. * * @param elements - The JSX elements. * @returns The child or children. */ function getChildren(elements) { if (elements.length === 1) { return elements[0]; } return elements; } /** * Get the text of a link token. * * @param token - The link token. * @returns The text of the link token. */ function getLinkText(token) { if (token.tokens && token.tokens.length > 0) { return getChildren(token.tokens.flatMap(getTextChildFromToken)); } return token.href; } /** * Get the text child from a list of markdown tokens. * * @param tokens - The markdown tokens. * @returns The text child. */ function getTextChildFromTokens(tokens) { return getChildren(tokens.flatMap(getTextChildFromToken)); } /** * Get the text child from a markdown token. * * @param token - The markdown token. * @returns The text child. */ function getTextChildFromToken(token) { switch (token.type) { case 'link': { return (0, jsx_runtime_1.jsx)(jsx_1.Link, { href: token.href, children: getLinkText(token) }); } case 'text': return token.text; case 'strong': return ((0, jsx_runtime_1.jsx)(jsx_1.Bold, { children: getTextChildFromTokens( // Due to the way `marked` is typed, `token.tokens` can be // `undefined`, but it's a required field of `Tokens.Bold`, so we // can safely cast it to `Token[]`. token.tokens) })); case 'em': return ((0, jsx_runtime_1.jsx)(jsx_1.Italic, { children: getTextChildFromTokens( // Due to the way `marked` is typed, `token.tokens` can be // `undefined`, but it's a required field of `Tokens.Bold`, so we // can safely cast it to `Token[]`. token.tokens) })); default: return null; } } /** * Get all text children from a markdown string. * * @param value - The markdown string. * @returns The text children. */ function getTextChildren(value) { const rootTokens = (0, marked_1.lexer)(value, { gfm: false }); const children = []; // TODO: Either fix this lint violation or explain why it's necessary to // ignore. // eslint-disable-next-line @typescript-eslint/no-floating-promises (0, marked_1.walkTokens)(rootTokens, (token) => { if (token.type === 'paragraph') { if (children.length > 0) { children.push('\n\n'); } const { tokens } = token; // We do not need to consider nesting deeper than 1 level here and we can therefore cast. children.push(...tokens.flatMap(getTextChildFromToken)); } }); return children.filter((child) => child !== null); } exports.getTextChildren = getTextChildren; /** * Validate the text size of a component. The text size is the total length of * all text in the component. * * @param component - The component to validate. * @throws An error if the text size exceeds the maximum allowed size. */ function validateComponentTextSize(component) { const textSize = getTotalTextLength(component); (0, utils_1.assert)(textSize <= MAX_TEXT_LENGTH, `The text in a Snap UI may not be larger than ${MAX_TEXT_LENGTH / 1000} kB.`); } /** * Get a JSX element from a legacy UI component. This supports all legacy UI * components, and maps them to their JSX equivalents where possible. * * This function validates the text size of the component, but does not validate * the total size. The total size of the component should be validated before * calling this function. * * @param legacyComponent - The legacy UI component. * @returns The JSX element. */ function getJsxElementFromComponent(legacyComponent) { validateComponentTextSize(legacyComponent); /** * Get the JSX element for a component. This function is recursive and will * call itself for child components. * * @param component - The component to convert to a JSX element. * @returns The JSX element. */ function getElement(component) { switch (component.type) { case snaps_sdk_1.NodeType.Address: return (0, jsx_runtime_1.jsx)(jsx_1.Address, { address: component.value }); case snaps_sdk_1.NodeType.Button: return ((0, jsx_runtime_1.jsx)(jsx_1.Button, { name: component.name, variant: getButtonVariant(component.variant), type: component.buttonType, children: component.value })); case snaps_sdk_1.NodeType.Copyable: return ((0, jsx_runtime_1.jsx)(jsx_1.Copyable, { value: component.value, sensitive: component.sensitive })); case snaps_sdk_1.NodeType.Divider: return (0, jsx_runtime_1.jsx)(jsx_1.Divider, {}); case snaps_sdk_1.NodeType.Form: return ((0, jsx_runtime_1.jsx)(jsx_1.Form, { name: component.name, children: getChildren(component.children.map(getElement)) })); case snaps_sdk_1.NodeType.Heading: return (0, jsx_runtime_1.jsx)(jsx_1.Heading, { children: component.value }); case snaps_sdk_1.NodeType.Image: // `Image` supports `alt`, but the legacy `Image` component does not. return (0, jsx_runtime_1.jsx)(jsx_1.Image, { src: component.value }); case snaps_sdk_1.NodeType.Input: return ((0, jsx_runtime_1.jsx)(jsx_1.Field, { label: component.label, error: component.error, children: (0, jsx_runtime_1.jsx)(jsx_1.Input, { name: component.name, type: component.inputType, value: component.value, placeholder: component.placeholder }) })); case snaps_sdk_1.NodeType.Panel: // `Panel` is renamed to `Box` in JSX. return ((0, jsx_runtime_1.jsx)(jsx_1.Box, { children: getChildren(component.children.map(getElement)) })); case snaps_sdk_1.NodeType.Row: return ((0, jsx_runtime_1.jsx)(jsx_1.Row, { label: component.label, variant: component.variant, children: getElement(component.value) })); case snaps_sdk_1.NodeType.Spinner: return (0, jsx_runtime_1.jsx)(jsx_1.Spinner, {}); case snaps_sdk_1.NodeType.Text: return (0, jsx_runtime_1.jsx)(jsx_1.Text, { children: getChildren(getTextChildren(component.value)) }); /* istanbul ignore next 2 */ default: return (0, utils_1.assertExhaustive)(component); } } return getElement(legacyComponent); } exports.getJsxElementFromComponent = getJsxElementFromComponent; /** * Extract all links from a Markdown text string using the `marked` lexer. * * @param text - The markdown text string. * @returns A list of URLs linked to in the string. */ function getMarkdownLinks(text) { const tokens = (0, marked_1.lexer)(text, { gfm: false }); const links = []; // Walk the lexed tokens and collect all link tokens // TODO: Either fix this lint violation or explain why it's necessary to // ignore. // eslint-disable-next-line @typescript-eslint/no-floating-promises (0, marked_1.walkTokens)(tokens, (token) => { if (token.type === 'link') { links.push(token); } }); return links; } /** * Validate a link against the phishing list. * * @param link - The link to validate. * @param isOnPhishingList - The function that checks the link against the * phishing list. * @param getSnap - The function that returns a snap if installed, undefined otherwise. * @throws If the link is invalid. */ function validateLink(link, isOnPhishingList, getSnap) { try { const url = new URL(link); (0, utils_1.assert)(ALLOWED_PROTOCOLS.includes(url.protocol), `Protocol must be one of: ${ALLOWED_PROTOCOLS.join(', ')}.`); if (url.protocol === 'metamask:') { const linkData = (0, url_1.parseMetaMaskUrl)(link); if (linkData.snapId) { (0, utils_1.assert)(getSnap(linkData.snapId), 'The Snap being navigated to is not installed.'); } } else if (url.protocol === 'mailto:') { const emails = url.pathname.split(','); for (const email of emails) { const hostname = email.split('@')[1]; (0, utils_1.assert)(!hostname.includes(':')); const href = `https://${hostname}`; (0, utils_1.assert)(!isOnPhishingList(href), 'The specified URL is not allowed.'); } return; } (0, utils_1.assert)(!isOnPhishingList(url.href), 'The specified URL is not allowed.'); } catch (error) { throw new Error(`Invalid URL: ${error?.code === 'ERR_ASSERTION' ? error.message : 'Unable to parse URL.'}`); } } exports.validateLink = validateLink; /** * Search for Markdown links in a string and checks them against the phishing * list. * * @param text - The text to verify. * @param isOnPhishingList - The function that checks the link against the * phishing list. * @param getSnap - The function that returns a snap if installed, undefined otherwise. * @throws If the text contains a link that is not allowed. */ function validateTextLinks(text, isOnPhishingList, getSnap) { const links = getMarkdownLinks(text); for (const link of links) { validateLink(link.href, isOnPhishingList, getSnap); } } exports.validateTextLinks = validateTextLinks; /** * Validate the asset selector component. * * @param address - The address of the account to pull the assets from. * @param getAccountByAddress - A function to get an account by its address. * * @throws If the asset selector is invalid. */ function validateAssetSelector(address, getAccountByAddress) { const account = getAccountByAddress(address); (0, utils_1.assert)(account, `Could not find account for address: ${address}`); } exports.validateAssetSelector = validateAssetSelector; /** * Walk a JSX tree and validate elements. * This function validates Links and AssetSelectors. * * @param node - The JSX node to walk. * @param hooks - The hooks to use for validation. * @param hooks.isOnPhishingList - The function that checks the link against the * phishing list. * @param hooks.getSnap - The function that returns a snap if installed, undefined otherwise. * @param hooks.getAccountByAddress - The function that returns an account by address. */ function validateJsxElements(node, { isOnPhishingList, getSnap, getAccountByAddress, }) { walkJsx(node, (childNode) => { switch (childNode.type) { case 'Link': validateLink(childNode.props.href, isOnPhishingList, getSnap); break; case 'AssetSelector': validateAssetSelector( // We assume that the address part of the CAIP-10 account ID are the same, as // that is already validated in the struct. childNode.props.addresses[0], getAccountByAddress); break; default: break; } }); } exports.validateJsxElements = validateJsxElements; /** * Calculate the total length of all text in the component. * * @param component - A custom UI component. * @returns The total length of all text components in the component. */ function getTotalTextLength(component) { const { type } = component; switch (type) { case snaps_sdk_1.NodeType.Panel: return component.children.reduce((sum, node) => sum + getTotalTextLength(node), 0); case snaps_sdk_1.NodeType.Row: return getTotalTextLength(component.value); case snaps_sdk_1.NodeType.Text: return component.value.length; default: return 0; } } exports.getTotalTextLength = getTotalTextLength; /** * Check if a JSX element has children. * * @param element - A JSX element. * @returns `true` if the element has children, `false` otherwise. */ function hasChildren(element) { return (0, utils_1.hasProperty)(element.props, 'children'); } exports.hasChildren = hasChildren; /** * Filter a JSX child to remove `null`, `undefined`, plain booleans, and empty * strings. * * @param child - The JSX child to filter. * @returns `true` if the child is not `null`, `undefined`, a plain boolean, or * an empty string, `false` otherwise. */ function filterJsxChild(child) { return Boolean(child) && child !== true; } /** * Get the children of a JSX element as an array. If the element has only one * child, the child is returned as an array. * * @param element - A JSX element. * @returns The children of the element. */ function getJsxChildren(element) { if (hasChildren(element)) { if (Array.isArray(element.props.children)) { // @ts-expect-error - Each member of the union type has signatures, but // none of those signatures are compatible with each other. return element.props.children.filter(filterJsxChild).flat(Infinity); } if (element.props.children) { return [element.props.children]; } } return []; } exports.getJsxChildren = getJsxChildren; /** * Walk a JSX tree and call a callback on each node. * * @param node - The JSX node to walk. * @param callback - The callback to call on each node. * @param depth - The current depth in the JSX tree for a walk. * @returns The result of the callback, if any. */ function walkJsx(node, callback, depth = 0) { if (Array.isArray(node)) { for (const child of node) { const childResult = walkJsx(child, callback, depth); if (childResult !== undefined) { return childResult; } } return undefined; } const result = callback(node, depth); if (result !== undefined) { return result; } if ((0, utils_1.hasProperty)(node, 'props') && (0, utils_1.isPlainObject)(node.props) && (0, utils_1.hasProperty)(node.props, 'children')) { const children = getJsxChildren(node); for (const child of children) { if ((0, utils_1.isPlainObject)(child)) { const childResult = walkJsx(child, callback, depth + 1); if (childResult !== undefined) { return childResult; } } } } return undefined; } exports.walkJsx = walkJsx; /** * Serialise a JSX prop to a string. * * @param prop - The JSX prop. * @returns The serialised JSX prop. */ function serialiseProp(prop) { if (typeof prop === 'string') { return `"${prop}"`; } return `{${JSON.stringify(prop)}}`; } /** * Serialise JSX props to a string. * * @param props - The JSX props. * @returns The serialised JSX props. */ function serialiseProps(props) { return Object.entries(props) .filter(([key]) => key !== 'children') .sort(([a], [b]) => a.localeCompare(b)) .map(([key, value]) => ` ${key}=${serialiseProp(value)}`) .join(''); } /** * Serialise a JSX node to a string. * * @param node - The JSX node. * @param indentation - The indentation level. Defaults to `0`. This should not * be set by the caller, as it is used for recursion. * @returns The serialised JSX node. */ function serialiseJsx(node, indentation = 0) { if (Array.isArray(node)) { return node.map((child) => serialiseJsx(child, indentation)).join(''); } const indent = ' '.repeat(indentation); if (typeof node === 'string') { return `${indent}${node}\n`; } if (!node) { return ''; } const { type, props } = node; const trailingNewline = indentation > 0 ? '\n' : ''; if ((0, utils_1.hasProperty)(props, 'children')) { const children = serialiseJsx(props.children, indentation + 1); return `${indent}<${type}${serialiseProps(props)}>\n${children}${indent}</${type}>${trailingNewline}`; } return `${indent}<${type}${serialiseProps(props)} />${trailingNewline}`; } exports.serialiseJsx = serialiseJsx; //# sourceMappingURL=ui.cjs.map