diff --git a/lib/package.json b/lib/package.json index 6fbe0691a..9c7ed3f47 100644 --- a/lib/package.json +++ b/lib/package.json @@ -1,74 +1,75 @@ { "name": "lib", "version": "0.0.1", "type": "module", "private": true, "license": "BSD-3-Clause", "scripts": { "clean": "rm -rf node_modules/", "test": "jest" }, "devDependencies": { "@babel/core": "^7.13.14", "@babel/plugin-proposal-class-properties": "^7.13.0", "@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8", "@babel/plugin-proposal-object-rest-spread": "^7.13.8", "@babel/plugin-proposal-optional-chaining": "^7.13.12", "@babel/plugin-transform-runtime": "^7.13.10", "@babel/preset-env": "^7.13.12", "@babel/preset-flow": "^7.13.13", "@babel/preset-react": "^7.13.13", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.10", "babel-jest": "^26.6.3", "babel-loader": "^9.1.2", "buffer": "^6.0.3", "clean-webpack-plugin": "^4.0.0", "css-loader": "^6.7.3", "css-minimizer-webpack-plugin": "^4.2.2", "flow-bin": "^0.202.1", "flow-typed": "^3.2.1", "mini-css-extract-plugin": "^2.7.2", "react-refresh": "^0.14.0", "style-loader": "^3.3.1", "terser-webpack-plugin": "^5.3.6", "webpack": "^5.76.0" }, "dependencies": { "@rainbow-me/rainbowkit": "^1.1.1", "dateformat": "^3.0.3", "emoji-regex": "^10.2.1", "eth-ens-namehash": "^2.0.8", "ethers": "^5.7.2", "fast-json-stable-stringify": "^2.0.0", "file-type": "^12.3.0", "focus-trap-react": "^10.1.4", + "idna-uts46-hx": "^2.3.1", "invariant": "^2.2.4", "just-clone": "^3.2.1", "lodash": "^4.17.21", "react": "18.1.0", "react-icomoon": "^2.5.7", "react-redux": "^7.1.1", "redux-persist": "^6.0.0", "reselect": "^4.0.0", "reselect-map": "^1.0.5", "simple-markdown": "^0.7.2", "siwe": "^1.1.6", "string-hash": "^1.1.3", "tcomb": "^3.2.29", "tinycolor2": "^1.4.1", "tokenize-text": "^1.1.3", "util-inspect": "^0.1.8", "utils-copy-error": "^1.0.1", "uuid": "^3.4.0", "viem": "^1.15.4", "wagmi": "^1.4.3" }, "jest": { "transform": { "\\.js$": "babel-jest" }, "transformIgnorePatterns": [ "/node_modules/(?!@babel/runtime)" ] } } diff --git a/lib/shared/markdown.js b/lib/shared/markdown.js index 081a649ca..17629f114 100644 --- a/lib/shared/markdown.js +++ b/lib/shared/markdown.js @@ -1,376 +1,379 @@ // @flow import invariant from 'invariant'; import { markdownUserMentionRegex, decodeChatMentionText, } from './mention-utils.js'; import type { MinimallyEncodedResolvedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import type { ResolvedThreadInfo, ChatMentionCandidates, RelativeMemberInfo, } from '../types/thread-types.js'; // simple-markdown types export type State = { key?: string | number | void, inline?: ?boolean, [string]: any, }; export type Parser = (source: string, state?: ?State) => Array; export type Capture = | (Array & { +index: number, ... }) | (Array & { +index?: number, ... }); export type SingleASTNode = { type: string, [string]: any, }; export type ASTNode = SingleASTNode | Array; type UnTypedASTNode = { [string]: any, ... }; type MatchFunction = { regex?: RegExp, ... } & (( source: string, state: State, prevCapture: string, ) => ?Capture); export type ReactElement = React$Element; type ReactElements = React$Node; export type Output = (node: ASTNode, state?: ?State) => Result; type ArrayNodeOutput = ( node: Array, nestedOutput: Output, state: State, ) => Result; type ArrayRule = { +react?: ArrayNodeOutput, +html?: ArrayNodeOutput, +[string]: ArrayNodeOutput, }; type ParseFunction = ( capture: Capture, nestedParse: Parser, state: State, ) => UnTypedASTNode | ASTNode; type ParserRule = { +order: number, +match: MatchFunction, +quality?: (capture: Capture, state: State, prevCapture: string) => number, +parse: ParseFunction, ... }; export type ParserRules = { +Array?: ArrayRule, +[type: string]: ParserRule, ... }; const paragraphRegex: RegExp = /^((?:[^\n]*)(?:\n|$))/; const paragraphStripTrailingNewlineRegex: RegExp = /^([^\n]*)(?:\n|$)/; const headingRegex: RegExp = /^ *(#{1,6}) ([^\n]+?)#* *(?![^\n])/; const headingStripFollowingNewlineRegex: RegExp = /^ *(#{1,6}) ([^\n]+?)#* *(?:\n|$)/; const fenceRegex: RegExp = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*?\n)\1(?:\n|$)/; const fenceStripTrailingNewlineRegex: RegExp = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*?)\n\1(?:\n|$)/; const codeBlockRegex: RegExp = /^(?: {4}[^\n]*\n*?)+(?!\n* {4}[^\n])(?:\n|$)/; const codeBlockStripTrailingNewlineRegex: RegExp = /^((?: {4}[^\n]*\n*?)+)(?!\n* {4}[^\n])(?:\n|$)/; const urlRegex: RegExp = /^(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/i; export type JSONCapture = Array & { +json: Object, +index?: void, ... }; function jsonMatch(source: string): ?JSONCapture { if (!source.startsWith('{')) { return null; } let jsonString = ''; let counter = 0; for (let i = 0; i < source.length; i++) { const char = source[i]; jsonString += char; if (char === '{') { counter++; } else if (char === '}') { counter--; } if (counter === 0) { break; } } if (counter !== 0) { return null; } let json; try { json = JSON.parse(jsonString); } catch { return null; } if (!json || typeof json !== 'object') { return null; } return { ...([jsonString]: any), json }; } function jsonPrint(capture: JSONCapture): string { return JSON.stringify(capture.json, null, ' '); } const listRegex = /^( *)([*+-]|\d+\.) ([\s\S]+?)(?:\n{2}|\s*\n*$)/; const listItemRegex = /^( *)([*+-]|\d+\.) [^\n]*(?:\n(?!\1(?:[*+-]|\d+\.) )[^\n]*)*(\n|$)/gm; const listItemPrefixRegex = /^( *)([*+-]|\d+\.) /; const listLookBehindRegex = /(?:^|\n)( *)$/; function matchList(source: string, state: State): RegExp$matchResult | null { if (state.inline) { return null; } const prevCaptureStr = state.prevCapture ? state.prevCapture[0] : ''; const isStartOfLineCapture = listLookBehindRegex.exec(prevCaptureStr); if (!isStartOfLineCapture) { return null; } const fullSource = isStartOfLineCapture[1] + source; return listRegex.exec(fullSource); } // We've defined our own parse function for lists because simple-markdown // handles newlines differently. Outside of that our implementation is fairly // similar. For more details about list parsing works, take a look at the // comments in the simple-markdown package function parseList( capture: Capture, parse: Parser, state: State, ): UnTypedASTNode { const bullet = capture[2]; const ordered = bullet.length > 1; const start = ordered ? Number(bullet) : undefined; const items = capture[0].match(listItemRegex); let itemContent = null; if (items) { itemContent = items.map((item: string) => { const prefixCapture = listItemPrefixRegex.exec(item); const space = prefixCapture ? prefixCapture[0].length : 0; const spaceRegex = new RegExp('^ {1,' + space + '}', 'gm'); const content: string = item .replace(spaceRegex, '') .replace(listItemPrefixRegex, ''); // We're handling this different than simple-markdown - // each item is a paragraph return parse(content, state); }); } return { ordered: ordered, start: start, items: itemContent, }; } function createMemberMapForUserMentions( members: $ReadOnlyArray, ): $ReadOnlyMap { const membersMap = new Map(); members.forEach(member => { if (member.role && member.username) { membersMap.set(member.username.toLowerCase(), member.id); } }); return membersMap; } function matchUserMentions( membersMap: $ReadOnlyMap, ): MatchFunction { const match = (source: string, state: State) => { if (!state.inline) { return null; } const result = markdownUserMentionRegex.exec(source); if (!result) { return null; } const username = result[2]; invariant(username, 'markdownMentionRegex should match two capture groups'); if (!membersMap.has(username.toLowerCase())) { return null; } return result; }; match.regex = markdownUserMentionRegex; return match; } type ParsedUserMention = { +content: string, +userID: string, }; function parseUserMentions( membersMap: $ReadOnlyMap, capture: Capture, ): ParsedUserMention { const memberUsername = capture[2]; const memberID = membersMap.get(memberUsername.toLowerCase()); invariant(memberID, 'memberID should be set'); return { content: capture[0], userID: memberID, }; } function parseChatMention( chatMentionCandidates: ChatMentionCandidates, capture: Capture, ): { threadInfo: ?ResolvedThreadInfo | ?MinimallyEncodedResolvedThreadInfo, content: string, hasAccessToChat: boolean, } { const threadInfo = chatMentionCandidates[capture[3]]; const threadName = threadInfo?.uiName ?? decodeChatMentionText(capture[4]); const content = `${capture[1]}@${threadName}`; return { threadInfo, content, hasAccessToChat: !!threadInfo, }; } const blockQuoteRegex: RegExp = /^( *>[^\n]+(?:\n[^\n]+)*)(?:\n|$)/; const blockQuoteStripFollowingNewlineRegex: RegExp = /^( *>[^\n]+(?:\n[^\n]+)*)(?:\n|$){2}/; const maxNestedQuotations = 5; // Custom match and parse functions implementation for block quotes // to allow us to specify quotes parsing depth // to avoid too many recursive calls and e.g. app crash function matchBlockQuote(quoteRegex: RegExp): MatchFunction { return (source: string, state: State) => { if ( state.inline || (state?.quotationsDepth && state.quotationsDepth >= maxNestedQuotations) ) { return null; } return quoteRegex.exec(source); }; } function parseBlockQuote( capture: Capture, parse: Parser, state: State, ): UnTypedASTNode { const content = capture[1].replace(/^ *> ?/gm, ''); const currentQuotationsDepth = state?.quotationsDepth ?? 0; return { content: parse(content, { ...state, quotationsDepth: currentQuotationsDepth + 1, }), }; } const spoilerRegex: RegExp = /^\|\|([^\n]+?)\|\|/g; const replaceSpoilerRegex: RegExp = /\|\|(.+?)\|\|/g; const spoilerReplacement: string = '⬛⬛⬛'; const stripSpoilersFromNotifications = (text: string): string => text.replace(replaceSpoilerRegex, spoilerReplacement); function stripSpoilersFromMarkdownAST(ast: SingleASTNode[]): SingleASTNode[] { // Either takes top-level AST, or array of nodes under an items node (list) return ast.map(replaceSpoilersFromMarkdownAST); } function replaceSpoilersFromMarkdownAST(node: SingleASTNode): SingleASTNode { const { content, items, type } = node; if (typeof content === 'string') { // Base case (leaf node) return node; } else if (type === 'spoiler') { // The actual point of this function: replacing the spoilers return { type: 'text', content: spoilerReplacement, }; } else if (content) { // Common case... most nodes nest children with content // If content isn't a string, it should be an array return { ...node, content: stripSpoilersFromMarkdownAST(content), }; } else if (items) { // Special case for lists, which has a nested array of arrays within items return { ...node, items: items.map(stripSpoilersFromMarkdownAST), }; } throw new Error( `unexpected Markdown node of type ${type} with no content or items`, ); } +const ensRegex: RegExp = /^.{3,}\.eth$/; + export { paragraphRegex, paragraphStripTrailingNewlineRegex, urlRegex, blockQuoteRegex, blockQuoteStripFollowingNewlineRegex, headingRegex, headingStripFollowingNewlineRegex, codeBlockRegex, codeBlockStripTrailingNewlineRegex, fenceRegex, fenceStripTrailingNewlineRegex, spoilerRegex, matchBlockQuote, parseBlockQuote, jsonMatch, jsonPrint, matchList, parseList, createMemberMapForUserMentions, matchUserMentions, parseUserMentions, stripSpoilersFromNotifications, stripSpoilersFromMarkdownAST, parseChatMention, + ensRegex, }; diff --git a/lib/utils/ens-helpers.js b/lib/utils/ens-helpers.js index c892865ef..2248c9173 100644 --- a/lib/utils/ens-helpers.js +++ b/lib/utils/ens-helpers.js @@ -1,73 +1,103 @@ // @flow +import uts46 from 'idna-uts46-hx'; + import { ENSCache } from './ens-cache.js'; import { getETHAddressForUserInfo } from '../shared/account-utils.js'; +import { ensRegex } from '../shared/markdown.js'; type BaseUserInfo = { +username?: ?string, ... }; export type GetENSNames = ( users: $ReadOnlyArray, ) => Promise; async function getENSNames( ensCache: ENSCache, users: $ReadOnlyArray, ): Promise { const info = users.map(user => { if (!user) { return user; } const ethAddress = getETHAddressForUserInfo(user); const cachedResult = ethAddress ? ensCache.getCachedNameForAddress(ethAddress) : null; return { input: user, ethAddress, cachedResult, }; }); const needFetch = info .map(user => { if (!user) { return null; } const { ethAddress, cachedResult } = user; if (cachedResult || !ethAddress) { return null; } return ethAddress; }) .filter(Boolean); const ensNames = new Map(); if (needFetch.length > 0) { const results = await ensCache.getNamesForAddresses(needFetch); for (let i = 0; i < needFetch.length; i++) { const ethAddress = needFetch[i]; const result = results[i]; if (result) { ensNames.set(ethAddress, result); } } } return info.map(user => { if (!user) { return user; } const { input, ethAddress, cachedResult } = user; if (cachedResult) { return { ...input, username: cachedResult }; } else if (!ethAddress) { return input; } const ensName = ensNames.get(ethAddress); if (ensName) { return { ...input, username: ensName }; } return input; }); } -export { getENSNames }; +function isValidENSName(name: string): boolean { + // While the Ethereum spec allows for more flexibility in ENS names + // (see https://eips.ethereum.org/EIPS/eip-137#name-syntax), we want to only + // perform lookups on names that adhere to two specific rules: + // 1. TLD should be .eth + // 2. SLD should be at least three characters in length + // Here, we enforce these rules and also use a library similar to the one + // recommended by the Ethereum spec to perform the 'heavy lifting' of + // making sure the name adheres to all of the specific limitations. + try { + // Our specific rules on TLDs and SLDs + const match = name.match(ensRegex); + if (!match) { + return false; + } + + // Ethereum spec guidelines (throws an error if invalid) + uts46.toAscii(name, { + transitional: false, + useStd3ASCII: true, + }); + return true; + } catch (e) { + return false; + } +} + +export { getENSNames, isValidENSName }; diff --git a/lib/utils/ens-helpers.test.js b/lib/utils/ens-helpers.test.js new file mode 100644 index 000000000..568d6a70b --- /dev/null +++ b/lib/utils/ens-helpers.test.js @@ -0,0 +1,54 @@ +// @flow + +import { isValidENSName } from './ens-helpers.js'; + +describe('it should correctly validate ENS names', () => { + it('should match all valid typical ENS names', () => { + expect(isValidENSName('foo.eth')).toBe(true); + expect(isValidENSName('jack.eth')).toBe(true); + expect(isValidENSName('thisuserhasareallylongname.eth')).toBe(true); + expect(isValidENSName('hello-world.eth')).toBe(true); + }); + + it('should match all valid ENS names with numbers', () => { + expect(isValidENSName('foo123.eth')).toBe(true); + expect(isValidENSName('123foo.eth')).toBe(true); + expect(isValidENSName('123foo123.eth')).toBe(true); + }); + + it('should match all valid ENS names with unicode characters', () => { + expect(isValidENSName('föǒ.eth')).toBe(true); + expect(isValidENSName('hëllø.eth')).toBe(true); + }); + + it('should match one-character emoji SLDs that are made up of 3 characters', () => { + expect(isValidENSName('💂‍♂️.eth')).toBe(true); + expect(isValidENSName('🕵️‍♂️.eth')).toBe(true); + expect(isValidENSName('👨‍🚀.eth')).toBe(true); + }); + + it('should not match one-character emoji SLDs that are made up of less than 3 characters', () => { + expect(isValidENSName('🏀.eth')).toBe(false); + expect(isValidENSName('🎃.eth')).toBe(false); + }); + + it('should not match any SLDs less than 3 characters', () => { + expect(isValidENSName('fo.eth')).toBe(false); + expect(isValidENSName('f.eth')).toBe(false); + expect(isValidENSName('')).toBe(false); + expect(isValidENSName('a.eth')).toBe(false); + expect(isValidENSName('ö.eth')).toBe(false); + }); + + it('should not match any TLDs other than .eth', () => { + expect(isValidENSName('foo.com')).toBe(false); + expect(isValidENSName('foo.')).toBe(false); + expect(isValidENSName('foo')).toBe(false); + }); + + it('should not match any names with special characters', () => { + expect(isValidENSName('foo.eth!')).toBe(false); + expect(isValidENSName('foo.eth#')).toBe(false); + expect(isValidENSName('foo$.eth')).toBe(false); + }); +});