diff options
Diffstat (limited to 'app/assets/javascripts/lib')
-rw-r--r-- | app/assets/javascripts/lib/dompurify.js | 37 | ||||
-rw-r--r-- | app/assets/javascripts/lib/gfm/index.js | 50 | ||||
-rw-r--r-- | app/assets/javascripts/lib/graphql.js | 1 | ||||
-rw-r--r-- | app/assets/javascripts/lib/markdown_it.js | 11 | ||||
-rw-r--r-- | app/assets/javascripts/lib/prosemirror_markdown_serializer.js | 4 | ||||
-rw-r--r-- | app/assets/javascripts/lib/utils/text_markdown.js | 242 | ||||
-rw-r--r-- | app/assets/javascripts/lib/utils/url_utility.js | 25 | ||||
-rw-r--r-- | app/assets/javascripts/lib/utils/yaml.js | 21 |
8 files changed, 339 insertions, 52 deletions
diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js index a01c6df0003..3e28ca2a0f7 100644 --- a/app/assets/javascripts/lib/dompurify.js +++ b/app/assets/javascripts/lib/dompurify.js @@ -33,6 +33,22 @@ const removeUnsafeHref = (node, attr) => { }; /** + * Appends 'noopener' & 'noreferrer' to rel + * attr values to prevent reverse tabnabbing. + * + * @param {String} rel + * @returns {String} + */ +const appendSecureRelValue = (rel) => { + const attributes = new Set(rel ? rel.toLowerCase().split(' ') : []); + + attributes.add('noopener'); + attributes.add('noreferrer'); + + return Array.from(attributes).join(' '); +}; + +/** * Sanitize icons' <use> tag attributes, to safely include * svgs such as in: * @@ -57,4 +73,25 @@ addHook('afterSanitizeAttributes', (node) => { } }); +const TEMPORARY_ATTRIBUTE = 'data-temp-href-target'; + +addHook('beforeSanitizeAttributes', (node) => { + if (node.tagName === 'A' && node.hasAttribute('target')) { + node.setAttribute(TEMPORARY_ATTRIBUTE, node.getAttribute('target')); + } +}); + +addHook('afterSanitizeAttributes', (node) => { + if (node.tagName === 'A' && node.hasAttribute(TEMPORARY_ATTRIBUTE)) { + node.setAttribute('target', node.getAttribute(TEMPORARY_ATTRIBUTE)); + node.removeAttribute(TEMPORARY_ATTRIBUTE); + if (node.getAttribute('target') === '_blank') { + const rel = node.getAttribute('rel'); + node.setAttribute('rel', appendSecureRelValue(rel)); + } + } +}); + export const sanitize = (val, config) => dompurifySanitize(val, { ...defaultConfig, ...config }); + +export { isValidAttribute } from 'dompurify'; diff --git a/app/assets/javascripts/lib/gfm/index.js b/app/assets/javascripts/lib/gfm/index.js index 92118c8929f..eaf653e9924 100644 --- a/app/assets/javascripts/lib/gfm/index.js +++ b/app/assets/javascripts/lib/gfm/index.js @@ -1,10 +1,15 @@ import { pick } from 'lodash'; +import normalize from 'mdurl/encode'; import { unified } from 'unified'; import remarkParse from 'remark-parse'; +import remarkFrontmatter from 'remark-frontmatter'; import remarkGfm from 'remark-gfm'; import remarkRehype, { all } from 'remark-rehype'; import rehypeRaw from 'rehype-raw'; +const skipFrontmatterHandler = (language) => (h, node) => + h(node.position, 'frontmatter', { language }, [{ type: 'text', value: node.value }]); + const skipRenderingHandlers = { footnoteReference: (h, node) => h(node.position, 'footnoteReference', { identifier: node.identifier, label: node.label }, []), @@ -19,12 +24,57 @@ const skipRenderingHandlers = { h(node.position, 'codeBlock', { language: node.lang, meta: node.meta }, [ { type: 'text', value: node.value }, ]), + definition: (h, node) => { + const title = node.title ? ` "${node.title}"` : ''; + + return h( + node.position, + 'referenceDefinition', + { identifier: node.identifier, url: node.url, title: node.title }, + [{ type: 'text', value: `[${node.identifier}]: ${node.url}${title}` }], + ); + }, + linkReference: (h, node) => { + const definition = h.definition(node.identifier); + + return h( + node.position, + 'a', + { + href: normalize(definition.url ?? ''), + identifier: node.identifier, + isReference: 'true', + title: definition.title, + }, + all(h, node), + ); + }, + imageReference: (h, node) => { + const definition = h.definition(node.identifier); + + return h( + node.position, + 'img', + { + src: normalize(definition.url ?? ''), + alt: node.alt, + identifier: node.identifier, + isReference: 'true', + title: definition.title, + }, + all(h, node), + ); + }, + toml: skipFrontmatterHandler('toml'), + yaml: skipFrontmatterHandler('yaml'), + json: skipFrontmatterHandler('json'), }; const createParser = ({ skipRendering = [] }) => { return unified() .use(remarkParse) .use(remarkGfm) + .use(remarkFrontmatter, ['yaml', 'toml', { type: 'json', marker: ';' }]) .use(remarkRehype, { allowDangerousHtml: true, handlers: { diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index cfcce234bfb..98e45f95b38 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -221,6 +221,7 @@ export default (resolvers = {}, config = {}) => { ac = new ApolloClient({ typeDefs, link: appLink, + connectToDevTools: process.env.NODE_ENV !== 'production', cache: new InMemoryCache({ ...cacheConfig, typePolicies: { diff --git a/app/assets/javascripts/lib/markdown_it.js b/app/assets/javascripts/lib/markdown_it.js new file mode 100644 index 00000000000..0b7a553737d --- /dev/null +++ b/app/assets/javascripts/lib/markdown_it.js @@ -0,0 +1,11 @@ +/** + * This module replaces markdown-it with an empty function. markdown-it + * is a dependency of the prosemirror-markdown package. prosemirror-markdown + * uses markdown-it to parse markdown and produce an AST. However, the + * features that use prosemirror-markdown in the GitLab application do not + * require markdown parsing. + * + * Replacing markdown-it with this empty function removes unnecessary javascript + * from the production builds. + */ +export default () => {}; diff --git a/app/assets/javascripts/lib/prosemirror_markdown_serializer.js b/app/assets/javascripts/lib/prosemirror_markdown_serializer.js index 6473683c3af..5e621ca3216 100644 --- a/app/assets/javascripts/lib/prosemirror_markdown_serializer.js +++ b/app/assets/javascripts/lib/prosemirror_markdown_serializer.js @@ -1,3 +1 @@ -// Import from `src/to_markdown` to avoid unnecessary bundling of unused libs -// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79859 -export * from 'prosemirror-markdown/src/to_markdown'; +export { MarkdownSerializer, defaultMarkdownSerializer } from 'prosemirror-markdown'; diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 243de48948c..9f4e12a3010 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -4,12 +4,14 @@ import Shortcuts from '~/behaviors/shortcuts/shortcuts'; import { insertText } from '~/lib/utils/common_utils'; const LINK_TAG_PATTERN = '[{text}](url)'; +const INDENT_CHAR = ' '; +const INDENT_LENGTH = 2; // at the start of a line, find any amount of whitespace followed by // a bullet point character (*+-) and an optional checkbox ([ ] [x]) // OR a number with a . after it and an optional checkbox ([ ] [x]) // followed by one or more whitespace characters -const LIST_LINE_HEAD_PATTERN = /^(?<indent>\s*)(?<leader>((?<isUl>[*+-])|(?<isOl>\d+\.))( \[([xX\s])\])?\s)(?<content>.)?/; +const LIST_LINE_HEAD_PATTERN = /^(?<indent>\s*)(?<leader>((?<isUl>[*+-])|(?<isOl>\d+\.))( \[([xX~\s])\])?\s)(?<content>.)?/; // detect a horizontal rule that might be mistaken for a list item (not full pattern for an <hr>) const HR_PATTERN = /^((\s{0,3}-+\s*-+\s*-+\s*[\s-]*)|(\s{0,3}\*+\s*\*+\s*\*+\s*[\s*]*))$/; @@ -24,33 +26,104 @@ function addBlockTags(blockTag, selected) { return `${blockTag}\n${selected}\n${blockTag}`; } -function lineBefore(text, textarea, trimNewlines = true) { - let split = text.substring(0, textarea.selectionStart); - - if (trimNewlines) { - split = split.trim(); - } +/** + * Returns the line of text that is before the first line + * of the current selection + * + * @param {String} text - the text of the targeted text area + * @param {Object} textArea - the targeted text area + * @returns {String} + */ +function lineBeforeSelection(text, textArea) { + let split = text.substring(0, textArea.selectionStart); split = split.split('\n'); - return split[split.length - 1]; -} + // Last item, at -1, is the line where the start of selection is. + // Line before selection is therefore at -2 + const lineBefore = split[split.length - 2]; -function lineAfter(text, textarea, trimNewlines = true) { - let split = text.substring(textarea.selectionEnd); + return lineBefore === undefined ? '' : lineBefore; +} - if (trimNewlines) { - split = split.trim(); - } else { - // remove possible leading newline to get at the real line - split = split.replace(/^\n/, ''); - } +/** + * Returns the line of text that is after the last line + * of the current selection + * + * @param {String} text - the text of the targeted text area + * @param {Object} textArea - the targeted text area + * @returns {String} + */ +function lineAfterSelection(text, textArea) { + let split = text.substring(textArea.selectionEnd); + // remove possible leading newline to get at the real line + split = split.replace(/^\n/, ''); split = split.split('\n'); return split[0]; } +/** + * Returns the text lines that encompass the current selection + * + * @param {Object} textArea - the targeted text area + * @returns {Object} + */ +function linesFromSelection(textArea) { + const text = textArea.value; + const { selectionStart, selectionEnd } = textArea; + + let startPos = text[selectionStart] === '\n' ? selectionStart - 1 : selectionStart; + startPos = text.lastIndexOf('\n', startPos) + 1; + + let endPos = selectionEnd === selectionStart ? selectionEnd : selectionEnd - 1; + endPos = text.indexOf('\n', endPos); + if (endPos < 0) endPos = text.length; + + const selectedRange = text.substring(startPos, endPos); + const lines = selectedRange.split('\n'); + + return { + lines, + selectionStart, + selectionEnd, + startPos, + endPos, + }; +} + +/** + * Set the selection of a textarea such that it maintains the + * previous selection before the lines were indented/outdented + * + * @param {Object} textArea - the targeted text area + * @param {Number} selectionStart - start position of original selection + * @param {Number} selectionEnd - end position of original selection + * @param {Number} lineStart - start pos of first line + * @param {Number} firstLineChange - number of characters changed on first line + * @param {Number} totalChanged - total number of characters changed + */ +function setNewSelectionRange( + textArea, + selectionStart, + selectionEnd, + lineStart, + firstLineChange, + totalChanged, +) { + let newStart = Math.max(lineStart, selectionStart + firstLineChange); + let newEnd = Math.max(lineStart, selectionEnd + totalChanged); + + if (selectionStart === selectionEnd) { + newEnd = newStart; + } else if (selectionStart === lineStart) { + newStart = lineStart; + } + + textArea.setSelectionRange(newStart, newEnd); +} + function convertMonacoSelectionToAceFormat(sel) { return { start: { @@ -93,7 +166,8 @@ function editorBlockTagText(text, blockTag, selected, editor) { function blockTagText(text, textArea, blockTag, selected) { const shouldRemoveBlock = - lineBefore(text, textArea) === blockTag && lineAfter(text, textArea) === blockTag; + lineBeforeSelection(text, textArea) === blockTag && + lineAfterSelection(text, textArea) === blockTag; if (shouldRemoveBlock) { // To remove the block tag we have to select the line before & after @@ -312,9 +386,100 @@ function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagCo }); } +/** + * Indents selected lines to the right by 2 spaces + * + * @param {Object} textArea - the targeted text area + */ +function indentLines(textArea) { + const { lines, selectionStart, selectionEnd, startPos, endPos } = linesFromSelection(textArea); + const shiftedLines = []; + let totalAdded = 0; + + textArea.setSelectionRange(startPos, endPos); + + lines.forEach((line) => { + line = INDENT_CHAR.repeat(INDENT_LENGTH) + line; + totalAdded += INDENT_LENGTH; + + shiftedLines.push(line); + }); + + const textToInsert = shiftedLines.join('\n'); + + insertText(textArea, textToInsert); + setNewSelectionRange(textArea, selectionStart, selectionEnd, startPos, INDENT_LENGTH, totalAdded); +} + +/** + * Outdents selected lines to the left by 2 spaces + * + * @param {Object} textArea - the targeted text area + */ +function outdentLines(textArea) { + const { lines, selectionStart, selectionEnd, startPos, endPos } = linesFromSelection(textArea); + const shiftedLines = []; + let totalRemoved = 0; + let removedFromFirstline = -1; + let removedFromLine = 0; + + textArea.setSelectionRange(startPos, endPos); + + lines.forEach((line) => { + removedFromLine = 0; + + if (line.length > 0) { + // need to count how many spaces are actually removed, so can't use `replace` + while (removedFromLine < INDENT_LENGTH && line[removedFromLine] === INDENT_CHAR) { + removedFromLine += 1; + } + + if (removedFromLine > 0) { + line = line.slice(removedFromLine); + totalRemoved += removedFromLine; + } + } + + if (removedFromFirstline === -1) removedFromFirstline = removedFromLine; + shiftedLines.push(line); + }); + + const textToInsert = shiftedLines.join('\n'); + + if (totalRemoved > 0) insertText(textArea, textToInsert); + + setNewSelectionRange( + textArea, + selectionStart, + selectionEnd, + startPos, + -removedFromFirstline, + -totalRemoved, + ); +} + +function handleIndentOutdent(e, textArea) { + if (e.altKey || e.ctrlKey || e.shiftKey) return; + if (!e.metaKey) return; + + switch (e.key) { + case ']': + e.preventDefault(); + indentLines(textArea); + break; + case '[': + e.preventDefault(); + outdentLines(textArea); + break; + default: + break; + } +} + /* eslint-disable @gitlab/require-i18n-strings */ function handleSurroundSelectedText(e, textArea) { if (!gon.markdown_surround_selection) return; + if (e.metaKey) return; if (textArea.selectionStart === textArea.selectionEnd) return; const keys = { @@ -348,13 +513,13 @@ function handleSurroundSelectedText(e, textArea) { /** * Returns the content for a new line following a list item. * - * @param {Object} result - regex match of the current line - * @param {Object?} nextLineResult - regex match of the next line + * @param {Object} listLineMatch - regex match of the current line + * @param {Object?} nextLineMatch - regex match of the next line * @returns string with the new list item */ -function continueOlText(result, nextLineResult) { - const { indent, leader } = result.groups; - const { indent: nextIndent, isOl: nextIsOl } = nextLineResult?.groups ?? {}; +function continueOlText(listLineMatch, nextLineMatch) { + const { indent, leader } = listLineMatch.groups; + const { indent: nextIndent, isOl: nextIsOl } = nextLineMatch?.groups ?? {}; const [numStr, postfix = ''] = leader.split('.'); @@ -368,20 +533,20 @@ function handleContinueList(e, textArea) { if (!(e.key === 'Enter')) return; if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; if (textArea.selectionStart !== textArea.selectionEnd) return; - // prevent unintended line breaks were inserted using Japanese IME on MacOS + // prevent unintended line breaks inserted using Japanese IME on MacOS if (compositioningNoteText) return; - const currentLine = lineBefore(textArea.value, textArea, false); - const result = currentLine.match(LIST_LINE_HEAD_PATTERN); + const firstSelectedLine = linesFromSelection(textArea).lines[0]; + const listLineMatch = firstSelectedLine.match(LIST_LINE_HEAD_PATTERN); - if (result) { - const { leader, indent, content, isOl } = result.groups; - const prevLineEmpty = !content; + if (listLineMatch) { + const { leader, indent, content, isOl } = listLineMatch.groups; + const emptyListItem = !content; - if (prevLineEmpty) { - // erase previous empty list item - select the text and allow the - // natural line feed erase the text - textArea.selectionStart = textArea.selectionStart - result[0].length; + if (emptyListItem) { + // erase empty list item - select the text and allow the + // natural line feed to erase the text + textArea.selectionStart = textArea.selectionStart - listLineMatch[0].length; return; } @@ -389,17 +554,17 @@ function handleContinueList(e, textArea) { // Behaviors specific to either `ol` or `ul` if (isOl) { - const nextLine = lineAfter(textArea.value, textArea, false); - const nextLineResult = nextLine.match(LIST_LINE_HEAD_PATTERN); + const nextLine = lineAfterSelection(textArea.value, textArea); + const nextLineMatch = nextLine.match(LIST_LINE_HEAD_PATTERN); - itemToInsert = continueOlText(result, nextLineResult); + itemToInsert = continueOlText(listLineMatch, nextLineMatch); } else { - if (currentLine.match(HR_PATTERN)) return; + if (firstSelectedLine.match(HR_PATTERN)) return; itemToInsert = `${indent}${leader}`; } - itemToInsert = itemToInsert.replace(/\[x\]/i, '[ ]'); + itemToInsert = itemToInsert.replace(/\[[x~]\]/i, '[ ]'); e.preventDefault(); @@ -419,6 +584,7 @@ export function keypressNoteText(e) { if ($(textArea).atwho?.('isSelecting')) return; + handleIndentOutdent(e, textArea); handleContinueList(e, textArea); handleSurroundSelectedText(e, textArea); } diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index ff60fd2aecb..ca90eee69c7 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -397,6 +397,7 @@ export function relativePathToAbsolute(path, basePath) { const absolute = isAbsolute(basePath); const base = absolute ? basePath : `file:///${basePath}`; const url = new URL(path, base); + url.pathname = url.pathname.replace(/\/\/+/g, '/'); return absolute ? url.href : decodeURIComponent(url.pathname); } @@ -668,3 +669,27 @@ export function constructWebIDEPath({ webIDEUrl(`/${sourceProjectFullPath}/merge_requests/${iid}`), ); } + +/** + * Examples + * + * http://gitlab.com => gitlab.com + * https://gitlab.com => gitlab.com + * + * @param {String} url + * @returns A url without a protocol / scheme + */ +export const removeUrlProtocol = (url) => url.replace(/^\w+:\/?\/?/, ''); + +/** + * Examples + * + * https://www.gitlab.com/path/ => https://www.gitlab.com/path + * https://www.gitlab.com/?query=search => https://www.gitlab.com?query=search + * https://www.gitlab.com/#fragment => https://www.gitlab.com#fragment + * + * @param {String} url + * @returns A URL that does not have a path that ends with slash + */ +export const removeLastSlashInUrlPath = (url) => + url.replace(/\/$/, '').replace(/\/(\?|#){1}([^/]*)$/, '$1$2'); diff --git a/app/assets/javascripts/lib/utils/yaml.js b/app/assets/javascripts/lib/utils/yaml.js index 9270d388342..48f34624140 100644 --- a/app/assets/javascripts/lib/utils/yaml.js +++ b/app/assets/javascripts/lib/utils/yaml.js @@ -16,18 +16,17 @@ function getPath(ancestry) { function getFirstChildNode(collection) { let firstChildKey; - let type; - switch (collection.constructor.name) { - case 'YAMLSeq': // eslint-disable-line @gitlab/require-i18n-strings - return collection.items.find((i) => isNode(i)); - case 'YAMLMap': // eslint-disable-line @gitlab/require-i18n-strings - firstChildKey = collection.items[0]?.key; - if (!firstChildKey) return undefined; - return isScalar(firstChildKey) ? firstChildKey : new Scalar(firstChildKey); - default: - type = collection.constructor?.name || typeof collection; - throw Error(`Cannot identify a child Node for type ${type}`); + if (isSeq(collection)) { + return collection.items.find((i) => isNode(i)); } + if (isMap(collection)) { + firstChildKey = collection.items[0]?.key; + if (!firstChildKey) return undefined; + return isScalar(firstChildKey) ? firstChildKey : new Scalar(firstChildKey); + } + throw Error( + `Cannot identify a child Node for Collection. Expecting a YAMLMap or a YAMLSeq. Got: ${collection}`, + ); } function moveMetaPropsToFirstChildNode(collection) { |