diff --git a/apps/meteor/client/components/MarkdownText.spec.tsx b/apps/meteor/client/components/MarkdownText.spec.tsx index a59c53cc14012..b4e6ddc92a147 100644 --- a/apps/meteor/client/components/MarkdownText.spec.tsx +++ b/apps/meteor/client/components/MarkdownText.spec.tsx @@ -3,8 +3,12 @@ import { render, screen } from '@testing-library/react'; import dompurify from 'dompurify'; import MarkdownText, { supportedURISchemes } from './MarkdownText'; - import '@testing-library/jest-dom'; +import { getMarkdownParserLimit } from '../lib/getMarkdownParserLimit'; + +jest.mock('../lib/getMarkdownParserLimit'); + +const getMarkdownParserLimitMock = jest.mocked(getMarkdownParserLimit); const MOCKED_BASE_URI = 'http://localhost/'; @@ -499,3 +503,49 @@ describe('DOMPurify hook registration', () => { addHookSpy.mockRestore(); }); }); + +describe('parser limit handling', () => { + beforeEach(() => { + getMarkdownParserLimitMock.mockClear(); + }); + + it('should render plain text without parsing when content exceeds the limit', () => { + getMarkdownParserLimitMock.mockReturnValue(5); + + const longContent = '**this should not be bold** because it exceeds the limit'; + render(, { + wrapper: mockAppRoot().build(), + }); + + const element = screen.getByText(longContent); + expect(element).toBeInTheDocument(); + expect(element.tagName).not.toBe('STRONG'); + }); + + it('should render parsed markdown when content is within the limit', () => { + getMarkdownParserLimitMock.mockReturnValue(Infinity); + + const content = '**bold text**'; + render(, { + wrapper: mockAppRoot().build(), + }); + + const boldText = screen.getByText('bold text'); + expect(boldText).toBeInTheDocument(); + expect(boldText.tagName).toBe('STRONG'); + }); + + it('should render parsed markdown when content length equals the limit', () => { + const content = '**hi**'; + + getMarkdownParserLimitMock.mockReturnValue(content.length); + + render(, { + wrapper: mockAppRoot().build(), + }); + + const parsedText = screen.getByText('hi'); + expect(parsedText).toBeInTheDocument(); + expect(parsedText.tagName).toBe('STRONG'); + }); +}); diff --git a/apps/meteor/client/components/MarkdownText.tsx b/apps/meteor/client/components/MarkdownText.tsx index faefcf806d54b..55dd93a8c5e27 100644 --- a/apps/meteor/client/components/MarkdownText.tsx +++ b/apps/meteor/client/components/MarkdownText.tsx @@ -1,12 +1,10 @@ import { Box } from '@rocket.chat/fuselage'; -import { isExternal, getBaseURI } from '@rocket.chat/ui-client'; -import dompurify from 'dompurify'; -import { marked } from 'marked'; import type { ComponentProps } from 'react'; -import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { renderMessageEmoji } from '../lib/utils/renderMessageEmoji'; +import MarkdownTextInner from './MarkdownTextInner'; +import { getMarkdownParserLimit } from '../lib/getMarkdownParserLimit'; + +export { supportedURISchemes } from './MarkdownTextInner'; type MarkdownTextParams = { content: string; @@ -16,197 +14,26 @@ type MarkdownTextParams = { withTruncatedText: boolean; } & ComponentProps; -const documentRenderer = new marked.Renderer(); -const inlineRenderer = new marked.Renderer(); -const inlineWithoutBreaks = new marked.Renderer(); - -const walkTokens = (token: marked.Token) => { - const boldPattern = /^\*[^*]+\*$|^\*\*[^*]+\*\*$/; - const italicPattern = /^__(?=\S)([\s\S]*?\S)__(?!_)|^_(?=\S)([\s\S]*?\S)_(?!_)/; - if (boldPattern.test(token.raw) && token.type === 'em') { - token.type = 'strong' as 'em'; - } else if (italicPattern.test(token.raw) && token.type === 'strong') { - token.type = 'em' as 'strong'; - } -}; - -marked.use({ walkTokens }); - -const linkMarked = (href: string | null, _title: string | null, text: string): string => { - return `${text}`; -}; -const paragraphMarked = (text: string): string => text; -const brMarked = (): string => ' '; -const listItemMarked = (text: string): string => { - const cleanText = text.replace(/|<\/p>/gi, ''); - return `
  • ${cleanText}
  • `; -}; -const horizontalRuleMarked = (): string => ''; -const codeMarked = (code: string, language: string | undefined, _isEscaped: boolean): string => { - if (language) { - return `
    ${code} 
    `; - } - return `
    ${code} 
    `; -}; -const codespanMarked = (code: string): string => { - return `${code.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&')}`; -}; - -documentRenderer.link = linkMarked; -documentRenderer.listitem = listItemMarked; -documentRenderer.code = codeMarked; -documentRenderer.codespan = codespanMarked; - -inlineRenderer.link = linkMarked; -inlineRenderer.paragraph = paragraphMarked; -inlineRenderer.listitem = listItemMarked; -inlineRenderer.hr = horizontalRuleMarked; - -inlineWithoutBreaks.link = linkMarked; -inlineWithoutBreaks.paragraph = paragraphMarked; -inlineWithoutBreaks.br = brMarked; -inlineWithoutBreaks.image = brMarked; -inlineWithoutBreaks.code = paragraphMarked; -inlineWithoutBreaks.codespan = paragraphMarked; -inlineWithoutBreaks.listitem = listItemMarked; -inlineWithoutBreaks.hr = horizontalRuleMarked; - -const defaultOptions = { - gfm: true, - headerIds: false, -}; - -const options = { - ...defaultOptions, - breaks: true, - renderer: documentRenderer, -}; - -const inlineOptions = { - ...defaultOptions, - renderer: inlineRenderer, -}; - -const inlineWithoutBreaksOptions = { - ...defaultOptions, - renderer: inlineWithoutBreaks, -}; - -const getRegexp = (supportedURISchemes: string[]): RegExp => { - const schemes = supportedURISchemes.join('|'); - - return new RegExp(`^(${schemes}):`, 'im'); -}; - type MarkdownTextProps = Partial; -export const supportedURISchemes = ['http', 'https', 'notes', 'ftp', 'ftps', 'tel', 'mailto', 'sms', 'cid']; - -const isElement = (node: Node): node is Element => node.nodeType === Node.ELEMENT_NODE; -const isLinkElement = (node: Node): node is HTMLAnchorElement => isElement(node) && node.tagName.toLowerCase() === 'a'; - -// Generate a unique token at runtime to prevent enumeration attacks -// This token marks internal links that need translation -const INTERNAL_LINK_TOKEN = `__INTERNAL_LINK_TITLE_${Math.random().toString(36).substring(2, 15)}__`; - -// Register the DOMPurify hook once at module level to prevent memory leaks -// This hook will be shared by all MarkdownText component instances -dompurify.addHook('afterSanitizeAttributes', (node) => { - if (!isLinkElement(node)) { - return; - } - - const href = node.getAttribute('href') || ''; - const isExternalLink = isExternal(href); - const isMailto = href.startsWith('mailto:'); - - // Set appropriate attributes based on link type - if (isExternalLink || isMailto) { - node.setAttribute('rel', 'nofollow noopener noreferrer'); - // Enforcing external links to open in new tabs is critical to assure users never navigate away from the chat - // This attribute must be preserved to guarantee users maintain their chat context - node.setAttribute('target', '_blank'); - } - - // Set appropriate title based on link type - if (isMailto) { - // For mailto links, use the email address as the title for better user experience - // Example: for href "mailto:user@example.com" the title would be "mailto:user@example.com" - node.setAttribute('title', href); - } else if (isExternalLink) { - // For external links, set an empty title to prevent tooltips - // This reduces visual clutter and lets users see the URL in the browser's status bar instead - node.setAttribute('title', ''); - } else { - // For internal links, use a token that will be replaced with translated text in the component - // This allows us to use the contextualized translation function - const relativePath = href.replace(getBaseURI(), ''); - node.setAttribute('title', `${INTERNAL_LINK_TOKEN}${relativePath}`); - } -}); - -const MarkdownText = ({ - content, - variant = 'document', - withTruncatedText = false, - preserveHtml = false, - parseEmoji = false, - ...props -}: MarkdownTextProps) => { - const sanitizer = dompurify.sanitize; - const { t } = useTranslation(); - let markedOptions: marked.MarkedOptions; - - switch (variant) { - case 'inline': - markedOptions = inlineOptions; - break; - case 'inlineWithoutBreaks': - markedOptions = inlineWithoutBreaksOptions; - break; - case 'document': - default: - markedOptions = options; +const MarkdownText = ({ content, withTruncatedText = false, variant, preserveHtml, parseEmoji, ...boxProps }: MarkdownTextProps) => { + if (content && content.length > getMarkdownParserLimit()) { + return ( + + {content} + + ); } - - const __html = useMemo(() => { - const html = ((): any => { - if (content && typeof content === 'string') { - const markedHtml = /inline/.test(variant) - ? marked.parseInline(new Option(content).innerHTML, markedOptions) - : marked.parse(new Option(content).innerHTML, markedOptions); - - if (parseEmoji) { - // We are using the old emoji parser here. This could come - // with additional processing use, but is the workaround available right now. - // Should be replaced in the future with the new parser. - return renderMessageEmoji(markedHtml); - } - - return markedHtml; - } - })(); - - const sanitizedHtml = preserveHtml - ? html - : html && sanitizer(html, { ADD_ATTR: ['target'], ALLOWED_URI_REGEXP: getRegexp(supportedURISchemes) }); - - // Replace internal link tokens with contextualized translations - if (sanitizedHtml && typeof sanitizedHtml === 'string') { - return sanitizedHtml.replace(new RegExp(`${INTERNAL_LINK_TOKEN}([^"]*)`, 'g'), (_, href) => t('Go_to_href', { href })); - } - - return sanitizedHtml; - }, [preserveHtml, sanitizer, content, variant, markedOptions, parseEmoji, t]); - - return __html ? ( - - ) : null; + ); }; export default MarkdownText; diff --git a/apps/meteor/client/components/MarkdownTextInner.tsx b/apps/meteor/client/components/MarkdownTextInner.tsx new file mode 100644 index 0000000000000..5bec747a555fe --- /dev/null +++ b/apps/meteor/client/components/MarkdownTextInner.tsx @@ -0,0 +1,212 @@ +import { Box } from '@rocket.chat/fuselage'; +import { isExternal, getBaseURI } from '@rocket.chat/ui-client'; +import dompurify from 'dompurify'; +import { marked } from 'marked'; +import type { ComponentProps } from 'react'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { renderMessageEmoji } from '../lib/utils/renderMessageEmoji'; + +type MarkdownTextParams = { + content: string; + variant: 'inline' | 'inlineWithoutBreaks' | 'document'; + preserveHtml: boolean; + parseEmoji: boolean; + withTruncatedText: boolean; +} & ComponentProps; + +const documentRenderer = new marked.Renderer(); +const inlineRenderer = new marked.Renderer(); +const inlineWithoutBreaks = new marked.Renderer(); + +const walkTokens = (token: marked.Token) => { + const boldPattern = /^\*[^*]+\*$|^\*\*[^*]+\*\*$/; + const italicPattern = /^__(?=\S)([\s\S]*?\S)__(?!_)|^_(?=\S)([\s\S]*?\S)_(?!_)/; + if (boldPattern.test(token.raw) && token.type === 'em') { + token.type = 'strong' as 'em'; + } else if (italicPattern.test(token.raw) && token.type === 'strong') { + token.type = 'em' as 'strong'; + } +}; + +marked.use({ walkTokens }); + +const linkMarked = (href: string | null, _title: string | null, text: string): string => { + return `${text}`; +}; +const paragraphMarked = (text: string): string => text; +const brMarked = (): string => ' '; +const listItemMarked = (text: string): string => { + const cleanText = text.replace(/|<\/p>/gi, ''); + return `
  • ${cleanText}
  • `; +}; +const horizontalRuleMarked = (): string => ''; +const codeMarked = (code: string, language: string | undefined, _isEscaped: boolean): string => { + if (language) { + return `
    ${code} 
    `; + } + return `
    ${code} 
    `; +}; +const codespanMarked = (code: string): string => { + return `${code.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&')}`; +}; + +documentRenderer.link = linkMarked; +documentRenderer.listitem = listItemMarked; +documentRenderer.code = codeMarked; +documentRenderer.codespan = codespanMarked; + +inlineRenderer.link = linkMarked; +inlineRenderer.paragraph = paragraphMarked; +inlineRenderer.listitem = listItemMarked; +inlineRenderer.hr = horizontalRuleMarked; + +inlineWithoutBreaks.link = linkMarked; +inlineWithoutBreaks.paragraph = paragraphMarked; +inlineWithoutBreaks.br = brMarked; +inlineWithoutBreaks.image = brMarked; +inlineWithoutBreaks.code = paragraphMarked; +inlineWithoutBreaks.codespan = paragraphMarked; +inlineWithoutBreaks.listitem = listItemMarked; +inlineWithoutBreaks.hr = horizontalRuleMarked; + +const defaultOptions = { + gfm: true, + headerIds: false, +}; + +const options = { + ...defaultOptions, + breaks: true, + renderer: documentRenderer, +}; + +const inlineOptions = { + ...defaultOptions, + renderer: inlineRenderer, +}; + +const inlineWithoutBreaksOptions = { + ...defaultOptions, + renderer: inlineWithoutBreaks, +}; + +const getRegexp = (supportedURISchemes: string[]): RegExp => { + const schemes = supportedURISchemes.join('|'); + + return new RegExp(`^(${schemes}):`, 'im'); +}; + +export type MarkdownTextInnerProps = Partial; + +export const supportedURISchemes = ['http', 'https', 'notes', 'ftp', 'ftps', 'tel', 'mailto', 'sms', 'cid']; + +const isElement = (node: Node): node is Element => node.nodeType === Node.ELEMENT_NODE; +const isLinkElement = (node: Node): node is HTMLAnchorElement => isElement(node) && node.tagName.toLowerCase() === 'a'; + +// Generate a unique token at runtime to prevent enumeration attacks +// This token marks internal links that need translation +const INTERNAL_LINK_TOKEN = `__INTERNAL_LINK_TITLE_${Math.random().toString(36).substring(2, 15)}__`; + +// Register the DOMPurify hook once at module level to prevent memory leaks +// This hook will be shared by all MarkdownText component instances +dompurify.addHook('afterSanitizeAttributes', (node) => { + if (!isLinkElement(node)) { + return; + } + + const href = node.getAttribute('href') || ''; + const isExternalLink = isExternal(href); + const isMailto = href.startsWith('mailto:'); + + // Set appropriate attributes based on link type + if (isExternalLink || isMailto) { + node.setAttribute('rel', 'nofollow noopener noreferrer'); + // Enforcing external links to open in new tabs is critical to assure users never navigate away from the chat + // This attribute must be preserved to guarantee users maintain their chat context + node.setAttribute('target', '_blank'); + } + + // Set appropriate title based on link type + if (isMailto) { + // For mailto links, use the email address as the title for better user experience + // Example: for href "mailto:user@example.com" the title would be "mailto:user@example.com" + node.setAttribute('title', href); + } else if (isExternalLink) { + // For external links, set an empty title to prevent tooltips + // This reduces visual clutter and lets users see the URL in the browser's status bar instead + node.setAttribute('title', ''); + } else { + // For internal links, use a token that will be replaced with translated text in the component + // This allows us to use the contextualized translation function + const relativePath = href.replace(getBaseURI(), ''); + node.setAttribute('title', `${INTERNAL_LINK_TOKEN}${relativePath}`); + } +}); + +const MarkdownTextInner = ({ + content, + variant = 'document', + withTruncatedText = false, + preserveHtml = false, + parseEmoji = false, + ...boxProps +}: MarkdownTextInnerProps) => { + const sanitizer = dompurify.sanitize; + const { t } = useTranslation(); + let markedOptions: marked.MarkedOptions; + + switch (variant) { + case 'inline': + markedOptions = inlineOptions; + break; + case 'inlineWithoutBreaks': + markedOptions = inlineWithoutBreaksOptions; + break; + case 'document': + default: + markedOptions = options; + } + + const __html = useMemo(() => { + const html = ((): any => { + if (content && typeof content === 'string') { + const markedHtml = /inline/.test(variant) + ? marked.parseInline(new Option(content).innerHTML, markedOptions) + : marked.parse(new Option(content).innerHTML, markedOptions); + + if (parseEmoji) { + // We are using the old emoji parser here. This could come + // with additional processing use, but is the workaround available right now. + // Should be replaced in the future with the new parser. + return renderMessageEmoji(markedHtml); + } + + return markedHtml; + } + })(); + + const sanitizedHtml = preserveHtml + ? html + : html && sanitizer(html, { ADD_ATTR: ['target'], ALLOWED_URI_REGEXP: getRegexp(supportedURISchemes) }); + + // Replace internal link tokens with contextualized translations + if (sanitizedHtml && typeof sanitizedHtml === 'string') { + return sanitizedHtml.replace(new RegExp(`${INTERNAL_LINK_TOKEN}([^"]*)`, 'g'), (_, href) => t('Go_to_href', { href })); + } + + return sanitizedHtml; + }, [preserveHtml, sanitizer, content, variant, markedOptions, parseEmoji, t]); + + return __html ? ( + + ) : null; +}; + +export default MarkdownTextInner; diff --git a/apps/meteor/client/lib/getMarkdownParserLimit.spec.ts b/apps/meteor/client/lib/getMarkdownParserLimit.spec.ts new file mode 100644 index 0000000000000..c5c527deb5cfc --- /dev/null +++ b/apps/meteor/client/lib/getMarkdownParserLimit.spec.ts @@ -0,0 +1,67 @@ +import { getMarkdownParserLimit } from './getMarkdownParserLimit'; +import { getPageMeta } from './getPageMeta'; + +jest.mock('./getPageMeta'); + +const getPageMetaMock = jest.mocked(getPageMeta); + +let mockDefaultLimit = 0; + +jest.mock('../../lib/constants', () => ({ + get MESSAGE_MAX_PARSE_LENGTH_DEFAULT() { + return mockDefaultLimit; + }, +})); + +describe('getMarkdownParserLimit', () => { + beforeEach(() => { + getPageMetaMock.mockClear(); + mockDefaultLimit = 0; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should return Infinity when meta tag is null and default constant is 0', () => { + getPageMetaMock.mockReturnValue(null); + mockDefaultLimit = 0; + + expect(getMarkdownParserLimit()).toBe(Infinity); + }); + + it('should return the default constant when meta tag is null and constant > 0', () => { + getPageMetaMock.mockReturnValue(null); + mockDefaultLimit = 5000; + + expect(getMarkdownParserLimit()).toBe(5000); + }); + + it('should return the parsed meta value when it is a valid positive number', () => { + getPageMetaMock.mockReturnValue('10000'); + mockDefaultLimit = 0; + + expect(getMarkdownParserLimit()).toBe(10000); + }); + + it('should return Infinity when meta value is "0"', () => { + getPageMetaMock.mockReturnValue('0'); + mockDefaultLimit = 0; + + expect(getMarkdownParserLimit()).toBe(Infinity); + }); + + it('should return Infinity when meta value is a negative number', () => { + getPageMetaMock.mockReturnValue('-5'); + mockDefaultLimit = 0; + + expect(getMarkdownParserLimit()).toBe(Infinity); + }); + + it('should return defaultValue when meta value is not a finite number (NaN)', () => { + getPageMetaMock.mockReturnValue('abc'); + mockDefaultLimit = 0; + + expect(getMarkdownParserLimit()).toBe(Infinity); + }); +}); diff --git a/apps/meteor/client/lib/getMarkdownParserLimit.ts b/apps/meteor/client/lib/getMarkdownParserLimit.ts new file mode 100644 index 0000000000000..ecb807398431b --- /dev/null +++ b/apps/meteor/client/lib/getMarkdownParserLimit.ts @@ -0,0 +1,17 @@ +import { getPageMeta } from './getPageMeta'; +import { MESSAGE_MAX_PARSE_LENGTH_DEFAULT } from '../../lib/constants'; + +export const getMarkdownParserLimit = (): number => { + const value = getPageMeta('rc-message-parser-max-length'); + + const defaultValue = MESSAGE_MAX_PARSE_LENGTH_DEFAULT > 0 ? MESSAGE_MAX_PARSE_LENGTH_DEFAULT : Infinity; + if (value === null) return defaultValue; + + const parsed = parseInt(value, 10); + + if (!Number.isFinite(parsed)) { + return defaultValue; + } + + return parsed > 0 ? parsed : Infinity; +}; diff --git a/apps/meteor/client/lib/getPageMeta.spec.ts b/apps/meteor/client/lib/getPageMeta.spec.ts new file mode 100644 index 0000000000000..8fd4051bff916 --- /dev/null +++ b/apps/meteor/client/lib/getPageMeta.spec.ts @@ -0,0 +1,38 @@ +import { getPageMeta } from './getPageMeta'; + +describe('getPageMeta', () => { + beforeEach(() => { + document.head.innerHTML = ''; + }); + + it('should return the content attribute of the meta tag', () => { + const meta = document.createElement('meta'); + meta.setAttribute('name', 'test-meta'); + meta.setAttribute('content', '12345'); + document.head.appendChild(meta); + + const result = getPageMeta('test-meta'); + expect(result).toBe('12345'); + }); + + it('should return null when the meta tag does not exist', () => { + const result = getPageMeta('missing-meta-tag'); + expect(result).toBeNull(); + }); + + it('should cache the result and return from cache on subsequent calls', () => { + const meta = document.createElement('meta'); + meta.setAttribute('name', 'cached-meta'); + meta.setAttribute('content', 'cached-value'); + document.head.appendChild(meta); + + const first = getPageMeta('cached-meta'); + // Remove the meta tag + document.head.removeChild(meta); + // Should still return cached value + const second = getPageMeta('cached-meta'); + + expect(first).toBe('cached-value'); + expect(second).toBe('cached-value'); + }); +}); diff --git a/apps/meteor/client/lib/getPageMeta.ts b/apps/meteor/client/lib/getPageMeta.ts new file mode 100644 index 0000000000000..02469afee9ae3 --- /dev/null +++ b/apps/meteor/client/lib/getPageMeta.ts @@ -0,0 +1,13 @@ +const MetaCache = new Map(); +export const getPageMeta = (name: string): string | null => { + if (typeof window === 'undefined') return null; + const cached = MetaCache.get(name); + if (cached !== undefined) return cached; + try { + const value = window.document?.querySelector(`meta[name="${name}"]`)?.getAttribute('content') ?? null; + MetaCache.set(name, value); + return value; + } catch { + return null; + } +}; diff --git a/apps/meteor/client/lib/normalizeThreadMessage.spec.tsx b/apps/meteor/client/lib/normalizeThreadMessage.spec.tsx new file mode 100644 index 0000000000000..b1ceabeea3312 --- /dev/null +++ b/apps/meteor/client/lib/normalizeThreadMessage.spec.tsx @@ -0,0 +1,76 @@ +import { parse } from '@rocket.chat/message-parser'; +import { render } from '@testing-library/react'; + +import { getMarkdownParserLimit } from './getMarkdownParserLimit'; +import { normalizeThreadMessage } from './normalizeThreadMessage'; +import { filterMarkdown } from '../../app/markdown/lib/markdown'; + +jest.mock('./getMarkdownParserLimit'); +jest.mock('../../app/markdown/lib/markdown'); +jest.mock('@rocket.chat/message-parser'); + +const mockedGetMarkdownParserLimit = jest.mocked(getMarkdownParserLimit); +const mockedFilterMarkdown = jest.mocked(filterMarkdown); +const mockedParse = jest.mocked(parse); + +describe('normalizeThreadMessage', () => { + beforeEach(() => { + mockedGetMarkdownParserLimit.mockReturnValue(Infinity); + mockedFilterMarkdown.mockImplementation((text) => text); + mockedParse.mockImplementation((text) => [{ type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: text }] }] as any); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should parse message through filterMarkdown and parse when within limit', () => { + const message = { msg: 'Hello world', mentions: [], attachments: [] }; + normalizeThreadMessage(message); + + expect(mockedFilterMarkdown).toHaveBeenCalledWith('Hello world'); + expect(mockedParse).toHaveBeenCalledWith('Hello world', { emoticons: true }); + }); + + it('should skip filterMarkdown and parse when message exceeds limit', () => { + mockedGetMarkdownParserLimit.mockReturnValue(5); + + const message = { msg: 'This message is longer than the limit', mentions: [], attachments: [] }; + const result = normalizeThreadMessage(message); + + expect(mockedFilterMarkdown).not.toHaveBeenCalled(); + expect(mockedParse).not.toHaveBeenCalled(); + + // Still renders (as plain text AST through Markup) + expect(result).not.toBeNull(); + const { container } = render(<>{result}); + expect(container.textContent).toContain('This message is longer than the limit'); + }); + + it('should return null when msg is empty and no attachments', () => { + const message = { msg: '', mentions: [], attachments: undefined } as any; + expect(normalizeThreadMessage(message)).toBeNull(); + }); + + it('should return attachment title when msg is empty but attachment has title', () => { + const message = { msg: '', mentions: [], attachments: [{ title: 'file.pdf' }] } as any; + const result = normalizeThreadMessage(message); + + const { container } = render(<>{result}); + expect(container.textContent).toBe('file.pdf'); + }); + + it('should return null when msg is empty and attachments have no title', () => { + const message = { msg: '', mentions: [], attachments: [{ description: 'desc' }] } as any; + expect(normalizeThreadMessage(message)).toBeNull(); + }); + + it('should return null when parse throws an error', () => { + mockedParse.mockImplementation(() => { + throw new Error('parse error'); + }); + + const message = { msg: 'test', mentions: [], attachments: [] }; + expect(normalizeThreadMessage(message)).toBeNull(); + }); +}); diff --git a/apps/meteor/client/lib/normalizeThreadMessage.tsx b/apps/meteor/client/lib/normalizeThreadMessage.tsx index 7f13803d2f80a..61f1ae289a05b 100644 --- a/apps/meteor/client/lib/normalizeThreadMessage.tsx +++ b/apps/meteor/client/lib/normalizeThreadMessage.tsx @@ -1,17 +1,32 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { Markup } from '@rocket.chat/gazzodown'; import { parse } from '@rocket.chat/message-parser'; +import type { Root } from '@rocket.chat/message-parser'; import type { ReactElement } from 'react'; +import { getMarkdownParserLimit } from './getMarkdownParserLimit'; import { filterMarkdown } from '../../app/markdown/lib/markdown'; import GazzodownText from '../components/GazzodownText'; +const tryParseWithLimit = (text: string): Root | undefined => { + if (text.length > getMarkdownParserLimit()) { + return [{ type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: text }] }] as Root; + } + + const filtered = filterMarkdown(text); + + try { + return parse(filtered, { emoticons: true }); + } catch { + return undefined; + } +}; + export function normalizeThreadMessage({ ...message }: Readonly>): ReactElement | null { if (message.msg) { - message.msg = filterMarkdown(message.msg); delete message.mentions; - const tokens = message.msg ? parse(message.msg, { emoticons: true }) : undefined; + const tokens = tryParseWithLimit(message.msg); if (!tokens) { return null; diff --git a/apps/meteor/client/lib/parseMessageTextToAstMarkdown.spec.ts b/apps/meteor/client/lib/parseMessageTextToAstMarkdown.spec.ts index 0105608949b7f..059f4e6e1bc07 100644 --- a/apps/meteor/client/lib/parseMessageTextToAstMarkdown.spec.ts +++ b/apps/meteor/client/lib/parseMessageTextToAstMarkdown.spec.ts @@ -1,8 +1,13 @@ import type { IMessage, ITranslatedMessage } from '@rocket.chat/core-typings'; import type { Options, Root } from '@rocket.chat/message-parser'; +import { getMarkdownParserLimit } from './getMarkdownParserLimit'; import { parseMessageAttachments, parseMessageTextToAstMarkdown } from './parseMessageTextToAstMarkdown'; +jest.mock('./getMarkdownParserLimit'); + +const mockedGetMarkdownParserLimit = jest.mocked(getMarkdownParserLimit); + describe('parseMessageTextToAstMarkdown', () => { const date = new Date('2021-10-27T00:00:00.000Z'); @@ -451,3 +456,88 @@ describe('parseMessageAttachments', () => { }); }); }); + +describe('parser limit handling', () => { + const parseOptions: Options = { + colors: true, + emoticons: true, + katex: { + dollarSyntax: true, + parenthesisSyntax: true, + }, + }; + + const date = new Date('2021-10-27T00:00:00.000Z'); + + const baseMessage: IMessage = { + ts: date, + u: { + _id: 'userId', + name: 'userName', + username: 'userName', + }, + msg: 'message **bold** _italic_ and ~strike~', + rid: 'roomId', + _id: 'messageId', + _updatedAt: date, + urls: [], + }; + + const autoTranslateOptions = { + autoTranslateEnabled: false, + showAutoTranslate: () => false, + }; + + beforeEach(() => { + mockedGetMarkdownParserLimit.mockReturnValue(Infinity); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should return plain text AST when message exceeds parser limit', () => { + mockedGetMarkdownParserLimit.mockReturnValue(10); + + const result = parseMessageTextToAstMarkdown(baseMessage, parseOptions, autoTranslateOptions); + + expect(result.md).toStrictEqual([{ type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: baseMessage.msg }] }]); + }); + + it('should return plain text AST for attachment text exceeding parser limit', () => { + mockedGetMarkdownParserLimit.mockReturnValue(10); + + const messageWithAttachment = { + ...baseMessage, + msg: 'short', + attachments: [{ text: 'this attachment text is definitely longer than the limit' }], + }; + + const result = parseMessageTextToAstMarkdown(messageWithAttachment, parseOptions, autoTranslateOptions); + + expect(result.attachments?.[0].md).toStrictEqual([ + { type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: 'this attachment text is definitely longer than the limit' }] }, + ]); + }); + + it('should parse normally when message is within the limit', () => { + mockedGetMarkdownParserLimit.mockReturnValue(Infinity); + + const result = parseMessageTextToAstMarkdown(baseMessage, parseOptions, autoTranslateOptions); + + // Parsed result has BOLD, ITALIC, STRIKE — not just PLAIN_TEXT + expect(result.md).not.toStrictEqual([{ type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: baseMessage.msg }] }]); + expect(result.md[0].type).toBe('PARAGRAPH'); + }); + + it('should return existing md when message already has md and is not translated', () => { + mockedGetMarkdownParserLimit.mockReturnValue(10); + + const existingMd: Root = [{ type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: 'pre-parsed' }] }]; + const messageWithMd = { ...baseMessage, md: existingMd }; + + const result = parseMessageTextToAstMarkdown(messageWithMd, parseOptions, autoTranslateOptions); + + expect(result.md).toBe(existingMd); + }); +}); diff --git a/apps/meteor/client/lib/parseMessageTextToAstMarkdown.ts b/apps/meteor/client/lib/parseMessageTextToAstMarkdown.ts index 55d393cee38ed..1f8573dcbea54 100644 --- a/apps/meteor/client/lib/parseMessageTextToAstMarkdown.ts +++ b/apps/meteor/client/lib/parseMessageTextToAstMarkdown.ts @@ -3,6 +3,7 @@ import { isE2EEMessage, isQuoteAttachment, isTranslatedAttachment, isTranslatedM import type { Options, Root } from '@rocket.chat/message-parser'; import { parse } from '@rocket.chat/message-parser'; +import { getMarkdownParserLimit } from './getMarkdownParserLimit'; import type { AutoTranslateOptions } from '../views/room/MessageList/hooks/useAutoTranslate'; import { isParsedMessage } from '../views/room/MessageList/lib/isParsedMessage'; @@ -113,6 +114,11 @@ const textToMessageToken = (textOrRoot: string | Root, parseOptions: Options): R if (isParsedMessage(textOrRoot)) { return textOrRoot; } + + if (textOrRoot.length > getMarkdownParserLimit()) { + return [{ type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: textOrRoot }] }]; + } + const parsedMessage = parse(textOrRoot, parseOptions); const parsedMessageCleaned = parsedMessage[0].type !== 'LINE_BREAK' ? parsedMessage : (parsedMessage.slice(1) as Root); diff --git a/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessage.tsx b/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessage.tsx index 4f8d7d27cf74c..374f348baa4a1 100644 --- a/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessage.tsx +++ b/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessage.tsx @@ -117,9 +117,9 @@ const ContactHistoryMessage = ({ message, sequential, isNewDay, showUserAvatar } )} {!!quotes?.length && } - {!message.blocks && message.md && ( + {!message.blocks && ( - + {message.md ? : message.msg} )} {message.blocks && }