From 2415438d889b934a309b634dd83720dd38f055f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Mon, 22 Jan 2024 13:13:09 +0100 Subject: [PATCH] Fix MM56373 (#25946) * Fix MM56373 * Fix typo --------- Co-authored-by: Mattermost Build --- .../src/utils/markdown/renderer.test.tsx | 34 +++++ .../channels/src/utils/markdown/renderer.tsx | 57 +++++--- .../src/utils/text_formatting.test.ts | 84 +++++++++++ webapp/channels/src/utils/text_formatting.tsx | 133 ++++++++++-------- 4 files changed, 231 insertions(+), 77 deletions(-) create mode 100644 webapp/channels/src/utils/markdown/renderer.test.tsx diff --git a/webapp/channels/src/utils/markdown/renderer.test.tsx b/webapp/channels/src/utils/markdown/renderer.test.tsx new file mode 100644 index 0000000000..ee687c3144 --- /dev/null +++ b/webapp/channels/src/utils/markdown/renderer.test.tsx @@ -0,0 +1,34 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Renderer from './renderer'; + +describe('code', () => { + test('too many tokens result in no search rendering', () => { + const renderer = new Renderer({}, {searchPatterns: [{pattern: new RegExp('\\b()(foo)\\b', 'gi'), term: 'foo'}]}); + let originalString = 'foo '.repeat(501); + let result = renderer.code(originalString, ''); + + expect(result.includes('post-code__search-highlighting')).toBeTruthy(); + + originalString = originalString.repeat(2); + result = renderer.code(originalString, ''); + + expect(result.includes('post-code__search-highlighting')).toBeFalsy(); + }); +}); + +describe('codespan', () => { + test('too many tokens result in no search rendering', () => { + const renderer = new Renderer({}, {searchPatterns: [{pattern: new RegExp('\\b()(foo)\\b', 'gi'), term: 'foo'}]}); + let originalString = 'foo '.repeat(501); + let result = renderer.codespan(originalString); + + expect(result.includes('search-highlight')).toBeTruthy(); + + originalString = originalString.repeat(2); + result = renderer.codespan(originalString); + + expect(result.includes('search-highlight')).toBeFalsy(); + }); +}); diff --git a/webapp/channels/src/utils/markdown/renderer.tsx b/webapp/channels/src/utils/markdown/renderer.tsx index 4465382994..dba82f795f 100644 --- a/webapp/channels/src/utils/markdown/renderer.tsx +++ b/webapp/channels/src/utils/markdown/renderer.tsx @@ -40,23 +40,29 @@ export default class Renderer extends marked.Renderer { let searchedContent = ''; if (this.formattingOptions.searchPatterns) { - const tokens = new Map(); + try { + const tokens = new TextFormatting.Tokens(); - let searched = TextFormatting.sanitizeHtml(code); - searched = TextFormatting.highlightSearchTerms( - searched, - tokens, - this.formattingOptions.searchPatterns, - ); - - if (tokens.size > 0) { - searched = TextFormatting.replaceTokens(searched, tokens); - - searchedContent = ( - '
' + - searched + - '
' + let searched = TextFormatting.sanitizeHtml(code); + searched = TextFormatting.highlightSearchTerms( + searched, + tokens, + this.formattingOptions.searchPatterns, ); + + if (tokens.size > 0) { + searched = TextFormatting.replaceTokens(searched, tokens); + + searchedContent = ( + '
' + + searched + + '
' + ); + } + } catch (error) { + if (!TextFormatting.isFormatTokenLimitError(error)) { + throw error; + } } } @@ -69,13 +75,20 @@ export default class Renderer extends marked.Renderer { let output = text; if (this.formattingOptions.searchPatterns) { - const tokens = new Map(); - output = TextFormatting.highlightSearchTerms( - output, - tokens, - this.formattingOptions.searchPatterns, - ); - output = TextFormatting.replaceTokens(output, tokens); + try { + const tokens = new TextFormatting.Tokens(); + output = TextFormatting.highlightSearchTerms( + output, + tokens, + this.formattingOptions.searchPatterns, + ); + output = TextFormatting.replaceTokens(output, tokens); + } catch (error) { + if (!TextFormatting.isFormatTokenLimitError(error)) { + throw error; + } + output = text; + } } return ( diff --git a/webapp/channels/src/utils/text_formatting.test.ts b/webapp/channels/src/utils/text_formatting.test.ts index fc7e18a18d..4635bf8dcf 100644 --- a/webapp/channels/src/utils/text_formatting.test.ts +++ b/webapp/channels/src/utils/text_formatting.test.ts @@ -17,11 +17,64 @@ import { highlightWithoutNotificationKeywords, parseSearchTerms, autolinkChannelMentions, + Tokens, + isFormatTokenLimitError, + doFormatText, + replaceTokens, } from 'utils/text_formatting'; import type {ChannelNamesMap} from 'utils/text_formatting'; const emptyEmojiMap = new EmojiMap(new Map()); +describe('tokens', () => { + test('should throw an error when too many elements are added to the map', () => { + const tokens = new Tokens(); + const testValue = {value: 'test', originalText: 'test'}; + for (let i = 0; i < 999; i++) { + tokens.set(`${i}`, testValue); + } + expect(() => tokens.set('999', testValue)).not.toThrow(); + expect(() => tokens.set('0', testValue)).not.toThrow(); + expect(() => tokens.set('1000', testValue)).toThrow('maximum number of tokens reached'); + }); +}); + +describe('isFormatTokenLimitError', () => { + const ttcc = [ + { + name: 'undefined', + error: undefined, + expected: false, + }, + { + name: 'string', + error: 'some error', + expected: false, + }, + { + name: 'object', + error: {someProperty: 'foo'}, + expected: false, + }, + { + name: 'other error', + error: new Error('foo'), + expected: false, + }, + { + name: 'correct error', + error: new Error('maximum number of tokens reached'), + expected: true, + }, + ]; + + for (const tc of ttcc) { + test(`should return ${tc.expected} when the error is ${tc.name}`, () => { + expect(isFormatTokenLimitError(tc.error)).toEqual(tc.expected); + }); + } +}); + describe('formatText', () => { test('jumbo emoji should be able to handle up to 3 spaces before the emoji character', () => { const emoji = ':)'; @@ -492,3 +545,34 @@ describe('parseSearchTerms', () => { }); } }); + +describe('doFormatText', () => { + test('too many tokens results in returning the same input string', () => { + let originalText = '@sysadmin '.repeat(501); + let result = doFormatText(originalText, {atMentions: true}, emptyEmojiMap); + expect(result).not.toEqual(originalText); + + originalText = originalText.repeat(2); + result = doFormatText(originalText, {atMentions: true}, emptyEmojiMap); + expect(result).toEqual(originalText); + }); +}); + +describe('replaceTokens', () => { + describe('properly escape especial replace patterns', () => { + const ttcc = [ + 'foo$&foo', + 'foo$`foo', + 'foo$\'foo', + ]; + + for (const tc of ttcc) { + test(tc, () => { + const tokens = new Tokens([['$alias$', {originalText: 'foo', value: tc}]]); + + const result = replaceTokens('$alias$', tokens); + expect(result).toEqual(tc); + }); + } + }); +}); diff --git a/webapp/channels/src/utils/text_formatting.tsx b/webapp/channels/src/utils/text_formatting.tsx index 7bf4f57030..6c5940142b 100644 --- a/webapp/channels/src/utils/text_formatting.tsx +++ b/webapp/channels/src/utils/text_formatting.tsx @@ -20,6 +20,13 @@ const AT_MENTION_PATTERN = /(?:\B|\b_+)@([a-z0-9.\-_]+)/gi; const UNICODE_EMOJI_REGEX = emojiRegex(); const htmlEmojiPattern = /^

\s*(?:]*>|]*>[^<]*<\/span>\s*|[^<]*<\/span>\s*)+<\/p>$/; +const FORMAT_TOKEN_LIMIT = 1000; +const FORMAT_TOKEN_LIMIT_ERROR = 'maximum number of tokens reached'; + +export function isFormatTokenLimitError(error: unknown) { + return Boolean(error && typeof error === 'object' && 'message' in error && error.message === FORMAT_TOKEN_LIMIT_ERROR); +} + // Performs formatting of user posts including highlighting mentions and search terms and converting urls, hashtags, // @mentions and ~channels to links by taking a user's message and returning a string of formatted html. Also takes // a number of options as part of the second parameter: @@ -30,11 +37,6 @@ export type ChannelNamesMap = { } | string; }; -export type Tokens = Map< -string, -{ value: string; originalText: string; hashtag?: string } ->; - export type SearchPattern = { pattern: RegExp; term: string; @@ -202,6 +204,16 @@ export interface TextFormattingOptionsBase { export type TextFormattingOptions = Partial; +export class Tokens extends Map { + set(key: string, value: {value: string; originalText: string; hashtag?: string}) { + super.set(key, value); + if (this.size > FORMAT_TOKEN_LIMIT) { + throw new Error(FORMAT_TOKEN_LIMIT_ERROR); + } + return this; + } +} + const DEFAULT_OPTIONS: TextFormattingOptions = { mentionHighlight: true, disableGroupHighlight: false, @@ -328,57 +340,64 @@ export function formatText( export function doFormatText(text: string, options: TextFormattingOptions, emojiMap: EmojiMap): string { let output = text; - const tokens = new Map(); + const tokens = new Tokens(); - // replace important words and phrases with tokens - if (options.atMentions) { - output = autolinkAtMentions(output, tokens); + try { + // replace important words and phrases with tokens + if (options.atMentions) { + output = autolinkAtMentions(output, tokens); + } + + if (options.atSumOfMembersMentions) { + output = autoLinkSumOfMembersMentions(output, tokens); + } + + if (options.atPlanMentions) { + output = autoPlanMentions(output, tokens); + } + + if (options.channelNamesMap) { + output = autolinkChannelMentions( + output, + tokens, + options.channelNamesMap, + options.team, + ); + } + + output = autolinkEmails(output, tokens); + output = autolinkHashtags(output, tokens, options.minimumHashtagLength); + + if (!('emoticons' in options) || options.emoticons) { + output = Emoticons.handleEmoticons(output, tokens); + } + + if (options.searchPatterns) { + output = highlightSearchTerms(output, tokens, options.searchPatterns); + } + + if (!('mentionHighlight' in options) || options.mentionHighlight) { + output = highlightCurrentMentions(output, tokens, options.mentionKeys); + } + + if (options.highlightKeys && options.highlightKeys.length > 0) { + output = highlightWithoutNotificationKeywords(output, tokens, options.highlightKeys); + } + + if (!('emoticons' in options) || options.emoticons) { + output = handleUnicodeEmoji(output, emojiMap, UNICODE_EMOJI_REGEX); + } + + // reinsert tokens with formatted versions of the important words and phrases + output = replaceTokens(output, tokens) || text; + return output; + } catch (error) { + if (isFormatTokenLimitError(error)) { + return text; + } + + throw error; } - - if (options.atSumOfMembersMentions) { - output = autoLinkSumOfMembersMentions(output, tokens); - } - - if (options.atPlanMentions) { - output = autoPlanMentions(output, tokens); - } - - if (options.channelNamesMap) { - output = autolinkChannelMentions( - output, - tokens, - options.channelNamesMap, - options.team, - ); - } - - output = autolinkEmails(output, tokens); - output = autolinkHashtags(output, tokens, options.minimumHashtagLength); - - if (!('emoticons' in options) || options.emoticons) { - output = Emoticons.handleEmoticons(output, tokens); - } - - if (options.searchPatterns) { - output = highlightSearchTerms(output, tokens, options.searchPatterns); - } - - if (!('mentionHighlight' in options) || options.mentionHighlight) { - output = highlightCurrentMentions(output, tokens, options.mentionKeys); - } - - if (options.highlightKeys && options.highlightKeys.length > 0) { - output = highlightWithoutNotificationKeywords(output, tokens, options.highlightKeys); - } - - if (!('emoticons' in options) || options.emoticons) { - output = handleUnicodeEmoji(output, emojiMap, UNICODE_EMOJI_REGEX); - } - - // reinsert tokens with formatted versions of the important words and phrases - output = replaceTokens(output, tokens); - - return output; } export function sanitizeHtml(text: string): string { @@ -618,6 +637,10 @@ export function escapeRegex(text?: string): string { return text?.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') || ''; } +export function escapeReplaceSpecialPatterns(text?: string): string { + return text?.replace(/[$]/g, '$$$$') || ''; +} + const htmlEntities = { '&': '&', '<': '<', @@ -1022,7 +1045,7 @@ export function replaceTokens(text: string, tokens: Tokens): string { for (let i = aliases.length - 1; i >= 0; i--) { const alias = aliases[i]; const token = tokens.get(alias); - output = output.replace(alias, token ? token.value : ''); + output = output.replace(alias, escapeReplaceSpecialPatterns(token?.value || '')); } return output;