Fix MM56373 (#25946)
* Fix MM56373 * Fix typo --------- Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
b668c9b048
Коммит
2415438d88
34
webapp/channels/src/utils/markdown/renderer.test.tsx
Обычный файл
34
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,23 +40,29 @@ export default class Renderer extends marked.Renderer {
|
|||||||
let searchedContent = '';
|
let searchedContent = '';
|
||||||
|
|
||||||
if (this.formattingOptions.searchPatterns) {
|
if (this.formattingOptions.searchPatterns) {
|
||||||
const tokens = new Map();
|
try {
|
||||||
|
const tokens = new TextFormatting.Tokens();
|
||||||
|
|
||||||
let searched = TextFormatting.sanitizeHtml(code);
|
let searched = TextFormatting.sanitizeHtml(code);
|
||||||
searched = TextFormatting.highlightSearchTerms(
|
searched = TextFormatting.highlightSearchTerms(
|
||||||
searched,
|
searched,
|
||||||
tokens,
|
tokens,
|
||||||
this.formattingOptions.searchPatterns,
|
this.formattingOptions.searchPatterns,
|
||||||
);
|
|
||||||
|
|
||||||
if (tokens.size > 0) {
|
|
||||||
searched = TextFormatting.replaceTokens(searched, tokens);
|
|
||||||
|
|
||||||
searchedContent = (
|
|
||||||
'<div class="post-code__search-highlighting">' +
|
|
||||||
searched +
|
|
||||||
'</div>'
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (tokens.size > 0) {
|
||||||
|
searched = TextFormatting.replaceTokens(searched, tokens);
|
||||||
|
|
||||||
|
searchedContent = (
|
||||||
|
'<div class="post-code__search-highlighting">' +
|
||||||
|
searched +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!TextFormatting.isFormatTokenLimitError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,13 +75,20 @@ export default class Renderer extends marked.Renderer {
|
|||||||
let output = text;
|
let output = text;
|
||||||
|
|
||||||
if (this.formattingOptions.searchPatterns) {
|
if (this.formattingOptions.searchPatterns) {
|
||||||
const tokens = new Map();
|
try {
|
||||||
output = TextFormatting.highlightSearchTerms(
|
const tokens = new TextFormatting.Tokens();
|
||||||
output,
|
output = TextFormatting.highlightSearchTerms(
|
||||||
tokens,
|
output,
|
||||||
this.formattingOptions.searchPatterns,
|
tokens,
|
||||||
);
|
this.formattingOptions.searchPatterns,
|
||||||
output = TextFormatting.replaceTokens(output, tokens);
|
);
|
||||||
|
output = TextFormatting.replaceTokens(output, tokens);
|
||||||
|
} catch (error) {
|
||||||
|
if (!TextFormatting.isFormatTokenLimitError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
output = text;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -17,11 +17,64 @@ import {
|
|||||||
highlightWithoutNotificationKeywords,
|
highlightWithoutNotificationKeywords,
|
||||||
parseSearchTerms,
|
parseSearchTerms,
|
||||||
autolinkChannelMentions,
|
autolinkChannelMentions,
|
||||||
|
Tokens,
|
||||||
|
isFormatTokenLimitError,
|
||||||
|
doFormatText,
|
||||||
|
replaceTokens,
|
||||||
} from 'utils/text_formatting';
|
} from 'utils/text_formatting';
|
||||||
import type {ChannelNamesMap} from 'utils/text_formatting';
|
import type {ChannelNamesMap} from 'utils/text_formatting';
|
||||||
|
|
||||||
const emptyEmojiMap = new EmojiMap(new Map());
|
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', () => {
|
describe('formatText', () => {
|
||||||
test('jumbo emoji should be able to handle up to 3 spaces before the emoji character', () => {
|
test('jumbo emoji should be able to handle up to 3 spaces before the emoji character', () => {
|
||||||
const emoji = ':)';
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ const AT_MENTION_PATTERN = /(?:\B|\b_+)@([a-z0-9.\-_]+)/gi;
|
|||||||
const UNICODE_EMOJI_REGEX = emojiRegex();
|
const UNICODE_EMOJI_REGEX = emojiRegex();
|
||||||
const htmlEmojiPattern = /^<p>\s*(?:<img class="emoticon"[^>]*>|<span data-emoticon[^>]*>[^<]*<\/span>\s*|<span class="emoticon emoticon--unicode">[^<]*<\/span>\s*)+<\/p>$/;
|
const htmlEmojiPattern = /^<p>\s*(?:<img class="emoticon"[^>]*>|<span data-emoticon[^>]*>[^<]*<\/span>\s*|<span class="emoticon emoticon--unicode">[^<]*<\/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,
|
// 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
|
// @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:
|
// a number of options as part of the second parameter:
|
||||||
@@ -30,11 +37,6 @@ export type ChannelNamesMap = {
|
|||||||
} | string;
|
} | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Tokens = Map<
|
|
||||||
string,
|
|
||||||
{ value: string; originalText: string; hashtag?: string }
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type SearchPattern = {
|
export type SearchPattern = {
|
||||||
pattern: RegExp;
|
pattern: RegExp;
|
||||||
term: string;
|
term: string;
|
||||||
@@ -202,6 +204,16 @@ export interface TextFormattingOptionsBase {
|
|||||||
|
|
||||||
export type TextFormattingOptions = Partial<TextFormattingOptionsBase>;
|
export type TextFormattingOptions = Partial<TextFormattingOptionsBase>;
|
||||||
|
|
||||||
|
export class Tokens extends Map<string, {value: string; originalText: string; hashtag?: string}> {
|
||||||
|
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 = {
|
const DEFAULT_OPTIONS: TextFormattingOptions = {
|
||||||
mentionHighlight: true,
|
mentionHighlight: true,
|
||||||
disableGroupHighlight: false,
|
disableGroupHighlight: false,
|
||||||
@@ -328,57 +340,64 @@ export function formatText(
|
|||||||
export function doFormatText(text: string, options: TextFormattingOptions, emojiMap: EmojiMap): string {
|
export function doFormatText(text: string, options: TextFormattingOptions, emojiMap: EmojiMap): string {
|
||||||
let output = text;
|
let output = text;
|
||||||
|
|
||||||
const tokens = new Map();
|
const tokens = new Tokens();
|
||||||
|
|
||||||
// replace important words and phrases with tokens
|
try {
|
||||||
if (options.atMentions) {
|
// replace important words and phrases with tokens
|
||||||
output = autolinkAtMentions(output, 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 {
|
export function sanitizeHtml(text: string): string {
|
||||||
@@ -618,6 +637,10 @@ export function escapeRegex(text?: string): string {
|
|||||||
return text?.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') || '';
|
return text?.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function escapeReplaceSpecialPatterns(text?: string): string {
|
||||||
|
return text?.replace(/[$]/g, '$$$$') || '';
|
||||||
|
}
|
||||||
|
|
||||||
const htmlEntities = {
|
const htmlEntities = {
|
||||||
'&': '&',
|
'&': '&',
|
||||||
'<': '<',
|
'<': '<',
|
||||||
@@ -1022,7 +1045,7 @@ export function replaceTokens(text: string, tokens: Tokens): string {
|
|||||||
for (let i = aliases.length - 1; i >= 0; i--) {
|
for (let i = aliases.length - 1; i >= 0; i--) {
|
||||||
const alias = aliases[i];
|
const alias = aliases[i];
|
||||||
const token = tokens.get(alias);
|
const token = tokens.get(alias);
|
||||||
output = output.replace(alias, token ? token.value : '');
|
output = output.replace(alias, escapeReplaceSpecialPatterns(token?.value || ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user