blob: fdceebcf78c55e9c18b8424917beed75bcf88810 [file] [log] [blame]
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +02001import { marked } from 'marked';
Copybara854996b2021-09-07 19:36:02 +00002import 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) => {
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +020073 return text.replace(/[<>"']/g, (s) => {
Copybara854996b2021-09-07 19:36:02 +000074 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 = {
Copybara854996b2021-09-07 19:36:02 +0000110 link(href, title, text) {
111 // Overrides default link rendering by adding icon and destination on hover.
112 // TODO(crbug.com/monorail/9316): Add shared-styles/MD_STYLES to all
113 // components that consume the markdown renderer.
114 let linkIcon;
115 let tooltipText;
Adrià Vilanova Martínez2d5457a2022-01-13 13:25:39 +0100116 if (isValidHttpUrl(href) || isEmailLink(href)) {
Copybara854996b2021-09-07 19:36:02 +0000117 linkIcon = `<span class="material-icons link">link</span>`;
118 tooltipText = `Link destination: ${href}`;
119 } else {
120 linkIcon = `<span class="material-icons link_off">link_off</span>`;
121 tooltipText = `Link may be malformed: ${href}`;
122 }
123 const tooltip = `<span class="tooltip">${tooltipText}</span>`;
124 return `<span class="annotated-link"><a href=${href} ` +
125 `title=${title ? title : ''}>${linkIcon}${text}</a>${tooltip}</span>`;
126 },
127};
128
129marked.use({renderer, headerIds: false});
130
131/**
132 * Renders Markdown content into HTML.
133 * @param {string} raw Content to be intepretted as Markdown.
134 * @return {string} Rendered content in HTML format.
135 */
136export const renderMarkdown = (raw) => {
137 // TODO(crbug.com/monorail/9310): Add commentReferences, projectName,
138 // and revisionUrlFormat to use in conjunction with Marked's lexer for
139 // autolinking.
140 // TODO(crbug.com/monorail/9310): Integrate autolink
141 const preprocessed = replaceBoldTag(raw);
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200142 const escaped = escapeHtml(preprocessed);
143 const converted = marked(escaped);
Copybara854996b2021-09-07 19:36:02 +0000144 const sanitized = DOMPurify.sanitize(converted, SANITIZE_OPTIONS);
145 return sanitized.toString();
146};