Răsfoiți Sursa

Merge pull request #178 from ckeditor/t/171

Implemented HtmlDataProcessor and BasicHtmlWriter.
Piotrek Koszuliński 10 ani în urmă
părinte
comite
30f8a7afbb

+ 29 - 0
packages/ckeditor5-engine/src/dataprocessor/basichtmlwriter.js

@@ -0,0 +1,29 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+/**
+ * Basic HTML writer, it uses the native `innerHTML` property for basic conversion
+ * from DocumentFragment to an HTML string.
+ *
+ * @class dataProcessor.BasicHtmlWriter
+ * @implements dataProcessor.HtmlWriter
+ */
+export default class BasicHtmlWriter {
+	/**
+	 * Returns HTML string created from DocumentFragment.
+	 *
+	 * @param {DocumentFragment} fragment
+	 * @returns {String}
+	 */
+	getHtml( fragment ) {
+		const doc = document.implementation.createHTMLDocument( '' );
+		const container = doc.createElement( 'div' );
+		container.appendChild( fragment );
+
+		return container.innerHTML;
+	}
+}

+ 28 - 0
packages/ckeditor5-engine/src/dataprocessor/dataprocessor.js

@@ -0,0 +1,28 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * DataProcessor interface. It should be implemented by actual DataProcessors.
+ * Each data processor implements a certain format of the data. E.g. `MarkdownDataProcessor` will convert the data
+ * (Markdown string) to a DocumentFragment and back.
+ *
+ * @interface dataProcessor.DataProcessor
+ */
+
+/**
+ * Converts a DocumentFragment to data.
+ *
+ * @method toData
+ * @param {DocumentFragment} fragment DocumentFragment to be processed.
+ * @returns {*}
+ */
+
+/**
+ * Converts data to a DocumentFragment.
+ *
+ * @method toDom
+ * @param {*} data Data to be processed.
+ * @returns {DocumentFragment}
+ */

+ 69 - 0
packages/ckeditor5-engine/src/dataprocessor/htmldataprocessor.js

@@ -0,0 +1,69 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import BasicHtmlWriter from './basichtmlwriter.js';
+
+/**
+ * HtmlDataProcessor class.
+ * This data processor implementation uses HTML as input/output data.
+ *
+ * @class dataProcessor.HtmlDataProcessor
+ * @implements dataProcessor.DataProcessor
+ */
+export default class HtmlDataProcessor {
+	/**
+	 * Creates a new instance of the HtmlDataProcessor class.
+	 *
+	 * @constructor
+	 */
+	constructor() {
+		/**
+		 * DOMParser instance used to parse HTML string to HTMLDocument.
+		 *
+		 * @private
+		 * @property {DOMParser}
+		 */
+		this._domParser = new DOMParser();
+
+		/**
+		 * BasicHtmlWriter instance used to convert DOM elements to HTML string.
+		 *
+		 * @private
+		 * @type {dataProcessor.BasicHtmlWriter}
+		 */
+		this._htmlWriter = new BasicHtmlWriter();
+	}
+
+	/**
+	 * Converts provided document fragment to data format - in this case HTML string.
+	 *
+	 * @param {DocumentFragment} fragment
+	 * @returns {String}
+	 */
+	toData( fragment ) {
+		return this._htmlWriter.getHtml( fragment );
+	}
+
+	/**
+	 * Converts HTML String to its DOM representation. Returns DocumentFragment, containing nodes parsed from
+	 * provided data.
+	 *
+	 * @param {String} data
+	 * @returns {DocumentFragment}
+	 */
+	toDom( data ) {
+		const document = this._domParser.parseFromString( data, 'text/html' );
+		const fragment = document.createDocumentFragment();
+		const nodes = document.body.childNodes;
+
+		while ( nodes.length > 0 ) {
+			fragment.appendChild( nodes[ 0 ] );
+		}
+
+		return fragment;
+	}
+}

+ 18 - 0
packages/ckeditor5-engine/src/dataprocessor/htmlwriter.js

@@ -0,0 +1,18 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * HTML writer interface.
+ *
+ * @interface dataProcessor.HtmlWriter
+ */
+
+/**
+ * Returns HTML string created from DocumentFragment.
+ *
+ * @method getHtml
+ * @param {DocumentFragment} fragment
+ * @returns {String}
+ */

+ 15 - 0
packages/ckeditor5-engine/tests/dataprocessor/_utils/xsstemplates.js

@@ -0,0 +1,15 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+export default {
+	'img onerror': '<p><img onerror="%xss%" src="produce404" /></p>',
+	'video onerror': '<p><video onerror="%xss%">foo</video></p>',
+	'video onerror + src': '<p><video onerror="%xss%" src="produce404">foo</video></p>',
+	'video with source onerror': '<p><video><source onerror="%xss%" /></video></p>',
+	'video with source onerror 2': '<p><video><source onerror="%xss%" src="produce404" /></video></p>',
+	'script': '<script>%xss%<\/script>'
+};

+ 55 - 0
packages/ckeditor5-engine/tests/dataprocessor/basichtmlwriter.js

@@ -0,0 +1,55 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import BasicHtmlWriter from '/ckeditor5/core/dataprocessor/basichtmlwriter.js';
+
+describe( 'BasicHtmlWriter', () => {
+	const basicHtmlWriter = new BasicHtmlWriter();
+
+	it( 'should return empty string when empty DocumentFragment is passed', () => {
+		const data = basicHtmlWriter.getHtml( document.createDocumentFragment() );
+		expect( data ).to.equal( '' );
+	} );
+
+	it( 'should create text from single text node', () => {
+		const text = 'foo bar';
+		const fragment = document.createDocumentFragment();
+		const textNode = document.createTextNode( text );
+		fragment.appendChild( textNode );
+
+		const data = basicHtmlWriter.getHtml( fragment );
+		expect( data ).to.equal( text );
+	} );
+
+	it( 'should return correct HTML from fragment with paragraph', () => {
+		const fragment = document.createDocumentFragment();
+		const paragraph = document.createElement( 'p' );
+		paragraph.textContent = 'foo bar';
+		fragment.appendChild( paragraph );
+
+		const data = basicHtmlWriter.getHtml( fragment );
+		expect( data ).to.equal( '<p>foo bar</p>' );
+	} );
+
+	it( 'should return correct HTML from fragment with multiple child nodes', () => {
+		const fragment = document.createDocumentFragment();
+		const text = document.createTextNode( 'foo bar' );
+		const paragraph = document.createElement( 'p' );
+		const div = document.createElement( 'div' );
+
+		paragraph.textContent = 'foo';
+		div.textContent = 'bar';
+
+		fragment.appendChild( text );
+		fragment.appendChild( paragraph );
+		fragment.appendChild( div );
+
+		const data = basicHtmlWriter.getHtml( fragment );
+
+		expect( data ).to.equal( 'foo bar<p>foo</p><div>bar</div>' );
+	} );
+} );

+ 84 - 0
packages/ckeditor5-engine/tests/dataprocessor/htmldataprocessor.js

@@ -0,0 +1,84 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import HtmlDataProcessor from '/ckeditor5/core/dataprocessor/htmldataprocessor.js';
+import xssTemplates from '_utils/xsstemplates.js';
+
+describe( 'HtmlDataProcessor', () => {
+	const dataProcessor = new HtmlDataProcessor();
+
+	describe( 'toDom', () => {
+		it( 'should return empty DocumentFragment when empty string is passed', () => {
+			const fragment = dataProcessor.toDom( '' );
+			expect( fragment ).to.be.an.instanceOf( DocumentFragment );
+			expect( fragment.childNodes.length ).to.equal( 0 );
+		} );
+
+		it( 'should convert HTML to DocumentFragment with single text node', () => {
+			const fragment = dataProcessor.toDom( 'foo bar' );
+			expect( fragment.childNodes.length ).to.equal( 1 );
+			expect( fragment.childNodes[ 0 ].nodeType ).to.equal( Node.TEXT_NODE );
+			expect( fragment.childNodes[ 0 ].textContent ).to.equal( 'foo bar' );
+		} );
+
+		it( 'should convert HTML to DocumentFragment with multiple child nodes', () => {
+			const fragment = dataProcessor.toDom( '<p>foo</p><p>bar</p>' );
+			expect( fragment.childNodes.length ).to.equal( 2 );
+			expect( fragment.childNodes[ 0 ].nodeType ).to.equal( Node.ELEMENT_NODE );
+			expect( fragment.childNodes[ 0 ].textContent ).to.equal( 'foo' );
+			expect( fragment.childNodes[ 1 ].nodeType ).to.equal( Node.ELEMENT_NODE );
+			expect( fragment.childNodes[ 1 ].textContent ).to.equal( 'bar' );
+		} );
+
+		it( 'should return only elements inside body tag', () => {
+			const fragment = dataProcessor.toDom( '<html><head></head><body><p>foo</p></body></html>' );
+			expect( fragment.childNodes.length ).to.equal( 1 );
+			expect( fragment.childNodes[ 0 ].textContent ).to.equal( 'foo' );
+			expect( fragment.childNodes[ 0 ].tagName.toLowerCase() ).to.equal( 'p' );
+		} );
+
+		it( 'should not add any additional nodes', () => {
+			const fragment = dataProcessor.toDom( 'foo <b>bar</b> text' );
+			expect( fragment.childNodes.length ).to.equal( 3 );
+			expect( fragment.childNodes[ 0 ].nodeType ).to.equal( Node.TEXT_NODE );
+			expect( fragment.childNodes[ 0 ].textContent ).to.equal( 'foo ' );
+			expect( fragment.childNodes[ 1 ].nodeType ).to.equal( Node.ELEMENT_NODE );
+			expect( fragment.childNodes[ 1 ].innerHTML ).to.equal( 'bar' );
+			expect( fragment.childNodes[ 2 ].nodeType ).to.equal( Node.TEXT_NODE );
+			expect( fragment.childNodes[ 2 ].textContent ).to.equal( ' text' );
+		} );
+
+		// Test against XSS attacks.
+		for ( const name in xssTemplates ) {
+			const input = xssTemplates[ name ].replace( /%xss%/g, 'testXss()' );
+
+			it( 'should prevent XSS attacks: ' + name, ( done ) => {
+				window.testXss = sinon.spy();
+				dataProcessor.toDom( input );
+
+				setTimeout( () => {
+					sinon.assert.notCalled( window.testXss );
+					done();
+				}, 10 );
+			} );
+		}
+	} );
+
+	describe( 'toData', () => {
+		it( 'should use HtmlWriter', () => {
+			const spy = sinon.spy( dataProcessor._htmlWriter, 'getHtml' );
+
+			const fragment = document.createDocumentFragment();
+			const paragraph = document.createElement( 'p' );
+			fragment.appendChild( paragraph );
+			dataProcessor.toData( fragment );
+
+			spy.restore();
+			sinon.assert.calledWithExactly( spy, fragment );
+		} );
+	} );
+} );