Commit d1260af4 authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch '216642-display-youtube-iframes' into 'master'

Preview youtube videos on the Static Site Editor

See merge request gitlab-org/gitlab!39756
parents cd6219aa b3e2856d
...@@ -444,3 +444,15 @@ export function getHTTPProtocol(url) { ...@@ -444,3 +444,15 @@ export function getHTTPProtocol(url) {
export function stripPathTail(path = '') { export function stripPathTail(path = '') {
return path.replace(/[^/]+$/, ''); return path.replace(/[^/]+$/, '');
} }
export function getURLOrigin(url) {
if (!url) {
return window.location.origin;
}
try {
return new URL(url).origin;
} catch (e) {
return null;
}
}
...@@ -15,7 +15,7 @@ const markPrefix = `${marker}-${Date.now()}`; ...@@ -15,7 +15,7 @@ const markPrefix = `${marker}-${Date.now()}`;
const reHelpers = { const reHelpers = {
template: `.| |\\t|\\n(?!(\\n|${markPrefix}))`, template: `.| |\\t|\\n(?!(\\n|${markPrefix}))`,
openTag: '<[a-zA-Z]+.*?>', openTag: '<(?!iframe)[a-zA-Z]+.*?>',
closeTag: '</.+>', closeTag: '</.+>',
}; };
const reTemplated = new RegExp(`(^${wrapPrefix}(${reHelpers.template})+?${wrapPostfix}$)`, 'gm'); const reTemplated = new RegExp(`(^${wrapPrefix}(${reHelpers.template})+?${wrapPostfix}$)`, 'gm');
......
...@@ -4,6 +4,8 @@ export const CUSTOM_EVENTS = { ...@@ -4,6 +4,8 @@ export const CUSTOM_EVENTS = {
openAddImageModal: 'gl_openAddImageModal', openAddImageModal: 'gl_openAddImageModal',
}; };
export const ALLOWED_VIDEO_ORIGINS = ['https://www.youtube.com'];
/* eslint-disable @gitlab/require-i18n-strings */ /* eslint-disable @gitlab/require-i18n-strings */
export const TOOLBAR_ITEM_CONFIGS = [ export const TOOLBAR_ITEM_CONFIGS = [
{ icon: 'heading', event: 'openHeadingSelect', classes: 'tui-heading', tooltip: __('Headings') }, { icon: 'heading', event: 'openHeadingSelect', classes: 'tui-heading', tooltip: __('Headings') },
......
...@@ -4,6 +4,7 @@ import ToolbarItem from '../toolbar_item.vue'; ...@@ -4,6 +4,7 @@ import ToolbarItem from '../toolbar_item.vue';
import buildHtmlToMarkdownRenderer from './build_html_to_markdown_renderer'; import buildHtmlToMarkdownRenderer from './build_html_to_markdown_renderer';
import buildCustomHTMLRenderer from './build_custom_renderer'; import buildCustomHTMLRenderer from './build_custom_renderer';
import { TOOLBAR_ITEM_CONFIGS } from '../constants'; import { TOOLBAR_ITEM_CONFIGS } from '../constants';
import sanitizeHTML from './sanitize_html';
const buildWrapper = propsData => { const buildWrapper = propsData => {
const instance = new Vue({ const instance = new Vue({
...@@ -62,5 +63,6 @@ export const getEditorOptions = externalOptions => { ...@@ -62,5 +63,6 @@ export const getEditorOptions = externalOptions => {
return defaults({ return defaults({
customHTMLRenderer: buildCustomHTMLRenderer(externalOptions?.customRenderers), customHTMLRenderer: buildCustomHTMLRenderer(externalOptions?.customRenderers),
toolbarItems: TOOLBAR_ITEM_CONFIGS.map(toolbarItem => generateToolbarItem(toolbarItem)), toolbarItems: TOOLBAR_ITEM_CONFIGS.map(toolbarItem => generateToolbarItem(toolbarItem)),
customHTMLSanitizer: html => sanitizeHTML(html),
}); });
}; };
import { buildUneditableHtmlAsTextTokens } from './build_uneditable_token'; import { buildUneditableHtmlAsTextTokens } from './build_uneditable_token';
import { ALLOWED_VIDEO_ORIGINS } from '../../constants';
import { getURLOrigin } from '~/lib/utils/url_utility';
const canRender = ({ type }) => { const isVideoFrame = html => {
return type === 'htmlBlock'; const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const {
children: { length },
} = doc;
const iframe = doc.querySelector('iframe');
const origin = iframe && getURLOrigin(iframe.getAttribute('src'));
return length === 1 && ALLOWED_VIDEO_ORIGINS.includes(origin);
};
const canRender = ({ type, literal }) => {
return type === 'htmlBlock' && !isVideoFrame(literal);
}; };
const render = node => buildUneditableHtmlAsTextTokens(node); const render = node => buildUneditableHtmlAsTextTokens(node);
......
import createSanitizer from 'dompurify';
import { ALLOWED_VIDEO_ORIGINS } from '../constants';
import { getURLOrigin } from '~/lib/utils/url_utility';
const sanitizer = createSanitizer(window);
const ADD_TAGS = ['iframe'];
sanitizer.addHook('uponSanitizeElement', node => {
if (node.tagName !== 'IFRAME') {
return;
}
const origin = getURLOrigin(node.getAttribute('src'));
if (!ALLOWED_VIDEO_ORIGINS.includes(origin)) {
node.remove();
}
});
const sanitize = content => sanitizer.sanitize(content, { ADD_TAGS });
export default sanitize;
---
title: Display youtube videos on the Static Site Editor
merge_request: 39756
author:
type: added
...@@ -814,4 +814,22 @@ describe('URL utility', () => { ...@@ -814,4 +814,22 @@ describe('URL utility', () => {
expect(urlUtils.stripPathTail(path)).toBe(expected); expect(urlUtils.stripPathTail(path)).toBe(expected);
}); });
}); });
describe('getURLOrigin', () => {
it('when no url passed, returns correct origin from window location', () => {
const origin = 'https://foo.bar';
setWindowLocation({ origin });
expect(urlUtils.getURLOrigin()).toBe(origin);
});
it.each`
url | expectation
${'not-a-url'} | ${null}
${'wss://example.com'} | ${'wss://example.com'}
${'https://foo.bar/foo/bar'} | ${'https://foo.bar'}
`('returns correct origin for $url', ({ url, expectation }) => {
expect(urlUtils.getURLOrigin(url)).toBe(expectation);
});
});
}); });
...@@ -39,6 +39,10 @@ Below this line is a codeblock of the same HTML that should be ignored and prese ...@@ -39,6 +39,10 @@ Below this line is a codeblock of the same HTML that should be ignored and prese
<p>Some paragraph...</p> <p>Some paragraph...</p>
</div> </div>
\`\`\` \`\`\`
Below this line is a iframe that should be ignored and preserved
<iframe></iframe>
`; `;
const sourceTemplated = `Below this line is a simple ERB (single-line erb block) example. const sourceTemplated = `Below this line is a simple ERB (single-line erb block) example.
...@@ -87,6 +91,10 @@ Below this line is a codeblock of the same HTML that should be ignored and prese ...@@ -87,6 +91,10 @@ Below this line is a codeblock of the same HTML that should be ignored and prese
<p>Some paragraph...</p> <p>Some paragraph...</p>
</div> </div>
\`\`\` \`\`\`
Below this line is a iframe that should be ignored and preserved
<iframe></iframe>
`; `;
it.each` it.each`
......
...@@ -9,9 +9,11 @@ import { ...@@ -9,9 +9,11 @@ import {
} from '~/vue_shared/components/rich_content_editor/services/editor_service'; } from '~/vue_shared/components/rich_content_editor/services/editor_service';
import buildHTMLToMarkdownRenderer from '~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer'; import buildHTMLToMarkdownRenderer from '~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer';
import buildCustomRenderer from '~/vue_shared/components/rich_content_editor/services/build_custom_renderer'; import buildCustomRenderer from '~/vue_shared/components/rich_content_editor/services/build_custom_renderer';
import sanitizeHTML from '~/vue_shared/components/rich_content_editor/services/sanitize_html';
jest.mock('~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer'); jest.mock('~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer');
jest.mock('~/vue_shared/components/rich_content_editor/services/build_custom_renderer'); jest.mock('~/vue_shared/components/rich_content_editor/services/build_custom_renderer');
jest.mock('~/vue_shared/components/rich_content_editor/services/sanitize_html');
describe('Editor Service', () => { describe('Editor Service', () => {
let mockInstance; let mockInstance;
...@@ -143,5 +145,14 @@ describe('Editor Service', () => { ...@@ -143,5 +145,14 @@ describe('Editor Service', () => {
getEditorOptions(externalOptions); getEditorOptions(externalOptions);
expect(buildCustomRenderer).toHaveBeenCalledWith(externalOptions.customRenderers); expect(buildCustomRenderer).toHaveBeenCalledWith(externalOptions.customRenderers);
}); });
it('uses the internal sanitizeHTML service for HTML sanitization', () => {
const options = getEditorOptions();
const html = '<div></div>';
options.customHTMLSanitizer(html);
expect(sanitizeHTML).toHaveBeenCalledWith(html);
});
}); });
}); });
import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_html_block'; import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_html_block';
import { buildUneditableHtmlAsTextTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; import { buildUneditableHtmlAsTextTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token';
import { normalTextNode } from './mock_data'; describe('rich_content_editor/services/renderers/render_html_block', () => {
const htmlBlockNode = {
literal: '<div><h1>Heading</h1><p>Paragraph.</p></div>',
type: 'htmlBlock',
};
const htmlBlockNode = {
firstChild: null,
literal: '<div><h1>Heading</h1><p>Paragraph.</p></div>',
type: 'htmlBlock',
};
describe('Render HTML renderer', () => {
describe('canRender', () => { describe('canRender', () => {
it('should return true when the argument is an html block', () => { it.each`
expect(renderer.canRender(htmlBlockNode)).toBe(true); input | result
}); ${htmlBlockNode} | ${true}
${{ literal: '<iframe></iframe>', type: 'htmlBlock' }} | ${true}
it('should return false when the argument is not an html block', () => { ${{ literal: '<iframe src="https://www.youtube.com"></iframe>', type: 'htmlBlock' }} | ${false}
expect(renderer.canRender(normalTextNode)).toBe(false); ${{ literal: '<iframe></iframe>', type: 'text' }} | ${false}
`('returns $result when input=$input', ({ input, result }) => {
expect(renderer.canRender(input)).toBe(result);
}); });
}); });
......
import sanitizeHTML from '~/vue_shared/components/rich_content_editor/services/sanitize_html';
describe('rich_content_editor/services/sanitize_html', () => {
it.each`
input | result
${'<iframe src="https://www.youtube.com"></iframe>'} | ${'<iframe src="https://www.youtube.com"></iframe>'}
${'<iframe src="https://gitlab.com"></iframe>'} | ${''}
`('removes iframes if the iframe source origin is not allowed', ({ input, result }) => {
expect(sanitizeHTML(input)).toBe(result);
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment