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) {
export function stripPathTail(path = '') {
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()}`;
const reHelpers = {
template: `.| |\\t|\\n(?!(\\n|${markPrefix}))`,
openTag: '<[a-zA-Z]+.*?>',
openTag: '<(?!iframe)[a-zA-Z]+.*?>',
closeTag: '</.+>',
};
const reTemplated = new RegExp(`(^${wrapPrefix}(${reHelpers.template})+?${wrapPostfix}$)`, 'gm');
......
......@@ -4,6 +4,8 @@ export const CUSTOM_EVENTS = {
openAddImageModal: 'gl_openAddImageModal',
};
export const ALLOWED_VIDEO_ORIGINS = ['https://www.youtube.com'];
/* eslint-disable @gitlab/require-i18n-strings */
export const TOOLBAR_ITEM_CONFIGS = [
{ icon: 'heading', event: 'openHeadingSelect', classes: 'tui-heading', tooltip: __('Headings') },
......
......@@ -4,6 +4,7 @@ import ToolbarItem from '../toolbar_item.vue';
import buildHtmlToMarkdownRenderer from './build_html_to_markdown_renderer';
import buildCustomHTMLRenderer from './build_custom_renderer';
import { TOOLBAR_ITEM_CONFIGS } from '../constants';
import sanitizeHTML from './sanitize_html';
const buildWrapper = propsData => {
const instance = new Vue({
......@@ -62,5 +63,6 @@ export const getEditorOptions = externalOptions => {
return defaults({
customHTMLRenderer: buildCustomHTMLRenderer(externalOptions?.customRenderers),
toolbarItems: TOOLBAR_ITEM_CONFIGS.map(toolbarItem => generateToolbarItem(toolbarItem)),
customHTMLSanitizer: html => sanitizeHTML(html),
});
};
import { buildUneditableHtmlAsTextTokens } from './build_uneditable_token';
import { ALLOWED_VIDEO_ORIGINS } from '../../constants';
import { getURLOrigin } from '~/lib/utils/url_utility';
const canRender = ({ type }) => {
return type === 'htmlBlock';
const isVideoFrame = html => {
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);
......
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', () => {
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
<p>Some paragraph...</p>
</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.
......@@ -87,6 +91,10 @@ Below this line is a codeblock of the same HTML that should be ignored and prese
<p>Some paragraph...</p>
</div>
\`\`\`
Below this line is a iframe that should be ignored and preserved
<iframe></iframe>
`;
it.each`
......
......@@ -9,9 +9,11 @@ import {
} 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 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_custom_renderer');
jest.mock('~/vue_shared/components/rich_content_editor/services/sanitize_html');
describe('Editor Service', () => {
let mockInstance;
......@@ -143,5 +145,14 @@ describe('Editor Service', () => {
getEditorOptions(externalOptions);
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 { 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', () => {
it('should return true when the argument is an html block', () => {
expect(renderer.canRender(htmlBlockNode)).toBe(true);
});
it('should return false when the argument is not an html block', () => {
expect(renderer.canRender(normalTextNode)).toBe(false);
it.each`
input | result
${htmlBlockNode} | ${true}
${{ literal: '<iframe></iframe>', type: 'htmlBlock' }} | ${true}
${{ literal: '<iframe src="https://www.youtube.com"></iframe>', type: 'htmlBlock' }} | ${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