blob: b9ec97356599a4d667f57957daaa9dc537c9363b [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001import marked from 'marked';
2import DOMPurify from 'dompurify';
3
4/** @type {Set} Projects that default Markdown rendering to true. */
Adrià Vilanova Martínez89e40882021-09-07 01:29:07 +02005export const DEFAULT_MD_PROJECTS = new Set(['vulnz', 'vulnz-old', 'twpowertools', 'translateselectedtext']);
Copybara854996b2021-09-07 19:36:02 +00006
7/** @type {Set} Projects that allow users to opt into Markdown rendering. */
8export const AVAILABLE_MD_PROJECTS = new Set([...DEFAULT_MD_PROJECTS]);
9
10/** @type {Set} Authors whose comments will not be rendered as Markdown. */
11const BLOCKLIST = new Set(['sheriffbot@sheriffbot-1182.iam.gserviceaccount.com',
12 'sheriff-o-matic@appspot.gserviceaccount.com',
13 'sheriff-o-matic-staging@appspot.gserviceaccount.com',
14 'bugdroid1@chromium.org',
15 'bugdroid@chops-service-accounts.iam.gserviceaccount.com',
16 'gitwatcher-staging.google.com@appspot.gserviceaccount.com',
17 'gitwatcher.google.com@appspot.gserviceaccount.com']);
18
19/**
20 * Determines whether content should be rendered as Markdown.
21 * @param {string} options.project Project this content belongs to.
22 * @param {number} options.author User who authored this content.
23 * @param {boolean} options.enabled Per-issue override to force Markdown.
24 * @param {Array<string>} options.availableProjects List of opted in projects.
25 * @return {boolean} Whether this content should be rendered as Markdown.
26 */
27export const shouldRenderMarkdown = ({
28 project, author, enabled = true, availableProjects = AVAILABLE_MD_PROJECTS
29} = {}) => {
30 if (author in BLOCKLIST) {
31 return false;
32 } else if (!enabled) {
33 return false;
34 } else if (availableProjects.has(project)) {
35 return true;
36 }
37 return false;
38};
39
40/** @const {Object} Options for DOMPurify sanitizer */
41const SANITIZE_OPTIONS = Object.freeze({
42 RETURN_TRUSTED_TYPE: true,
43 FORBID_TAGS: ['style'],
44 FORBID_ATTR: ['style', 'autoplay'],
45});
46
47/**
48 * Replaces bold HTML tags in comment with Markdown equivalent.
49 * @param {string} raw Comment string as stored in database.
50 * @return {string} Comment string after b tags are placed by Markdown bolding.
51 */
52const replaceBoldTag = (raw) => {
53 return raw.replace(/<b>|<\/b>/g, '**');
54};
55
56/** @const {Object} Basic HTML character escape mapping */
57const HTML_ESCAPE_MAP = Object.freeze({
58 '&': '&amp;',
59 '<': '&lt;',
60 '>': '&gt;',
61 '"': '&quot;',
62 '\'': '&#39;',
63 '/': '&#x2F;',
64 '`': '&#x60;',
65 '=': '&#x3D;',
66});
67
68/**
69 * Escapes HTML characters, used to render HTML blocks in Markdown. This
70 * alleviates security flaws but is not the primary security barrier, that is
71 * handled by DOMPurify.
72 * @param {string} text Content that looks to Marked parser to contain HTML.
73 * @return {string} Same text content after escaping HTML characters.
74 */
75const escapeHtml = (text) => {
76 return text.replace(/[&<>"'`=\/]/g, (s) => {
77 return HTML_ESCAPE_MAP[s];
78 });
79};
80
81/**
82* Checks to see if input string is a valid HTTP link.
83 * @param {string} string
84 * @return {boolean} Whether input string is a valid HTTP(s) link.
85 */
86const isValidHttpUrl = (string) => {
87 let url;
88
89 try {
90 url = new URL(string);
91 } catch (_exception) {
92 return false;
93 }
94
95 return url.protocol === 'http:' || url.protocol === 'https:';
96};
97
98/**
99 * Renderer option for Marked.
100 * See https://marked.js.org/using_pro#renderer on how to use renderer.
101 * @type {Object}
102 */
103const renderer = {
104 html(text) {
105 // Do not render HTML, instead escape HTML and render as plaintext.
106 return escapeHtml(text);
107 },
108 link(href, title, text) {
109 // Overrides default link rendering by adding icon and destination on hover.
110 // TODO(crbug.com/monorail/9316): Add shared-styles/MD_STYLES to all
111 // components that consume the markdown renderer.
112 let linkIcon;
113 let tooltipText;
114 if (isValidHttpUrl(href)) {
115 linkIcon = `<span class="material-icons link">link</span>`;
116 tooltipText = `Link destination: ${href}`;
117 } else {
118 linkIcon = `<span class="material-icons link_off">link_off</span>`;
119 tooltipText = `Link may be malformed: ${href}`;
120 }
121 const tooltip = `<span class="tooltip">${tooltipText}</span>`;
122 return `<span class="annotated-link"><a href=${href} ` +
123 `title=${title ? title : ''}>${linkIcon}${text}</a>${tooltip}</span>`;
124 },
125};
126
127marked.use({renderer, headerIds: false});
128
129/**
130 * Renders Markdown content into HTML.
131 * @param {string} raw Content to be intepretted as Markdown.
132 * @return {string} Rendered content in HTML format.
133 */
134export const renderMarkdown = (raw) => {
135 // TODO(crbug.com/monorail/9310): Add commentReferences, projectName,
136 // and revisionUrlFormat to use in conjunction with Marked's lexer for
137 // autolinking.
138 // TODO(crbug.com/monorail/9310): Integrate autolink
139 const preprocessed = replaceBoldTag(raw);
140 const converted = marked(preprocessed);
141 const sanitized = DOMPurify.sanitize(converted, SANITIZE_OPTIONS);
142 return sanitized.toString();
143};