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