Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion apps/meteor/client/components/MarkdownText.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/';

Expand Down Expand Up @@ -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(<MarkdownText content={longContent} variant='document' />, {
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(<MarkdownText content={content} variant='document' />, {
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(<MarkdownText content={content} variant='document' />, {
wrapper: mockAppRoot().build(),
});

const parsedText = screen.getByText('hi');
expect(parsedText).toBeInTheDocument();
expect(parsedText.tagName).toBe('STRONG');
});
});
211 changes: 19 additions & 192 deletions apps/meteor/client/components/MarkdownText.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,197 +14,26 @@ type MarkdownTextParams = {
withTruncatedText: boolean;
} & ComponentProps<typeof Box>;

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 `<a href="${href || ''}">${text}</a>`;
};
const paragraphMarked = (text: string): string => text;
const brMarked = (): string => ' ';
const listItemMarked = (text: string): string => {
const cleanText = text.replace(/<p.*?>|<\/p>/gi, '');
return `<li>${cleanText}</li>`;
};
const horizontalRuleMarked = (): string => '';
const codeMarked = (code: string, language: string | undefined, _isEscaped: boolean): string => {
if (language) {
return `<pre><code class="language-${language}">${code} </code></pre>`;
}
return `<pre><code>${code} </code></pre>`;
};
const codespanMarked = (code: string): string => {
return `<code>${code.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')}</code>`;
};

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<MarkdownTextParams>;

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 (
<Box withTruncatedText={withTruncatedText} {...boxProps}>
{content}
</Box>
);
}

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 ? (
<Box
dangerouslySetInnerHTML={{ __html }}
return (
<MarkdownTextInner
content={content}
withTruncatedText={withTruncatedText}
withRichContent={variant === 'inlineWithoutBreaks' ? 'inlineWithoutBreaks' : true}
{...props}
variant={variant}
preserveHtml={preserveHtml}
parseEmoji={parseEmoji}
{...boxProps}
/>
) : null;
);
};

export default MarkdownText;
Loading
Loading