diff --git a/packages/ckeditor5-image/src/imageupload/utils.js b/packages/ckeditor5-image/src/imageupload/utils.js index 22cc43861a8..9230bb0992b 100644 --- a/packages/ckeditor5-image/src/imageupload/utils.js +++ b/packages/ckeditor5-image/src/imageupload/utils.js @@ -9,6 +9,8 @@ /* global fetch, File */ +import global from '@ckeditor/ckeditor5-utils/src/dom/global'; + /** * Creates a regular expression used to test for image files. * @@ -48,7 +50,14 @@ export function fetchLocalImage( image ) { resolve( file ); } ) - .catch( reject ); + .catch( err => { + // Fetch fails only, if it can't make a request due to a network failure or if anything prevented the request + // from completing, i.e. the Content Security Policy rules. It is not possible to detect the exact cause of failure, + // so we are just trying the fallback solution, if general TypeError is thrown. + return err && err.name === 'TypeError' ? + convertLocalImageOnCanvas( imageSrc ).then( resolve ).catch( reject ) : + reject( err ); + } ); } ); } @@ -82,3 +91,46 @@ function getImageMimeType( blob, src ) { return 'image/jpeg'; } } + +// Creates a promise that converts the image local source (Base64 or blob) to a blob using canvas and resolves +// with a `File` object. +// +// @param {String} imageSrc Image `src` attribute value. +// @returns {Promise.} A promise which resolves when an image source is converted to a `File` instance. +// It resolves with a `File` object. If there were any errors during file processing, the promise will be rejected. +function convertLocalImageOnCanvas( imageSrc ) { + return getBlobFromCanvas( imageSrc ).then( blob => { + const mimeType = getImageMimeType( blob, imageSrc ); + const ext = mimeType.replace( 'image/', '' ); + const filename = `image.${ ext }`; + + return new File( [ blob ], filename, { type: mimeType } ); + } ); +} + +// Creates a promise that resolves with a `Blob` object converted from the image source (Base64 or blob). +// +// @param {String} imageSrc Image `src` attribute value. +// @returns {Promise.} +function getBlobFromCanvas( imageSrc ) { + return new Promise( ( resolve, reject ) => { + const image = global.document.createElement( 'img' ); + + image.addEventListener( 'load', () => { + const canvas = global.document.createElement( 'canvas' ); + + canvas.width = image.width; + canvas.height = image.height; + + const ctx = canvas.getContext( '2d' ); + + ctx.drawImage( image, 0, 0 ); + + canvas.toBlob( blob => blob ? resolve( blob ) : reject() ); + } ); + + image.addEventListener( 'error', () => reject() ); + + image.src = imageSrc; + } ); +} diff --git a/packages/ckeditor5-image/tests/imageupload/imageuploadediting.js b/packages/ckeditor5-image/tests/imageupload/imageuploadediting.js index 18c18f8ee87..74da5ab5ba7 100644 --- a/packages/ckeditor5-image/tests/imageupload/imageuploadediting.js +++ b/packages/ckeditor5-image/tests/imageupload/imageuploadediting.js @@ -3,7 +3,7 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -/* globals window, setTimeout, atob, URL, Blob, console */ +/* globals document, window, setTimeout, atob, URL, Blob, HTMLCanvasElement, console */ import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; @@ -923,6 +923,134 @@ describe( 'ImageUploadEditing', () => { } ); } ); + describe( 'fallback image conversion on canvas', () => { + let metaElement; + let previousMetaContent; + + // Set strict Content Security Policy (CSP) rules before the first test in this block has been executed. + // The CSP rules cause that fetch() fails and it triggers the fallback procedure. + before( () => { + metaElement = document.querySelector( '[http-equiv=Content-Security-Policy]' ); + + if ( metaElement ) { + previousMetaContent = metaElement.getAttribute( 'content' ); + } else { + metaElement = document.createElement( 'meta' ); + metaElement.setAttribute( 'http-equiv', 'Content-Security-Policy' ); + + document.head.appendChild( metaElement ); + } + + metaElement.setAttribute( 'content', '' + + 'default-src \'none\'; ' + + 'connect-src \'self\'; ' + + 'script-src \'self\'; ' + + 'img-src * data: blob:;' + + 'style-src \'self\' \'unsafe-inline\'; ' + + 'frame-src *' + ); + } ); + + // Remove or restore the previous CSP rules after the last test in this block has been executed. + after( () => { + if ( previousMetaContent ) { + metaElement.setAttribute( 'content', previousMetaContent ); + } else { + document.head.removeChild( metaElement ); + } + } ); + + // See https://github.com/ckeditor/ckeditor5/issues/7957. + it( 'should upload image using canvas conversion', done => { + const spy = sinon.spy(); + const notification = editor.plugins.get( Notification ); + + notification.on( 'show:warning', evt => { + spy(); + evt.stop(); + }, { priority: 'high' } ); + + setModelData( model, '[]foo' ); + + const clipboardHtml = `

bar

`; + const dataTransfer = mockDataTransfer( clipboardHtml ); + + const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) ); + const targetViewRange = editor.editing.mapper.toViewRange( targetRange ); + + viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } ); + + adapterMocks[ 0 ].loader.file.then( () => { + setTimeout( () => { + sinon.assert.notCalled( spy ); + done(); + } ); + } ).catch( () => { + setTimeout( () => { + expect.fail( 'Promise should be resolved.' ); + } ); + } ); + } ); + + it( 'should not upload and remove image if canvas conversion failed', done => { + setModelData( model, '[]foo' ); + + const clipboardHtml = ``; + const dataTransfer = mockDataTransfer( clipboardHtml ); + + const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) ); + const targetViewRange = editor.editing.mapper.toViewRange( targetRange ); + + // Stub `HTMLCanvasElement#toBlob` to return invalid blob, so image conversion always fails. + sinon.stub( HTMLCanvasElement.prototype, 'toBlob' ).callsFake( fn => fn( null ) ); + + let content = null; + editor.plugins.get( 'Clipboard' ).on( 'inputTransformation', ( evt, data ) => { + content = data.content; + } ); + + viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } ); + + expectData( + '', + '[]foo', + '[]foo', + content, + done, + false + ); + } ); + + it( 'should not show notification when image could not be loaded', done => { + const spy = sinon.spy(); + const notification = editor.plugins.get( Notification ); + + notification.on( 'show:warning', evt => { + spy(); + evt.stop(); + }, { priority: 'high' } ); + + setModelData( model, '[]foo' ); + + const clipboardHtml = ''; + const dataTransfer = mockDataTransfer( clipboardHtml ); + + const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) ); + const targetViewRange = editor.editing.mapper.toViewRange( targetRange ); + + viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } ); + + adapterMocks[ 0 ].loader.file.then( () => { + expect.fail( 'Promise should be rejected.' ); + } ).catch( () => { + setTimeout( () => { + sinon.assert.notCalled( spy ); + done(); + } ); + } ); + } ); + } ); + // Helper for validating clipboard and model data as a result of a paste operation. This function checks both clipboard // data and model data synchronously (`expectedClipboardData`, `expectedModel`) and then the model data after `loader.file` // promise is resolved (so model state after successful/failed file fetch attempt). diff --git a/packages/ckeditor5-paste-from-office/tests/manual/tickets/7957/1.html b/packages/ckeditor5-paste-from-office/tests/manual/tickets/7957/1.html new file mode 100644 index 00000000000..f605f98b253 --- /dev/null +++ b/packages/ckeditor5-paste-from-office/tests/manual/tickets/7957/1.html @@ -0,0 +1,14 @@ + + + + +
+

Paste here:

+
diff --git a/packages/ckeditor5-paste-from-office/tests/manual/tickets/7957/1.js b/packages/ckeditor5-paste-from-office/tests/manual/tickets/7957/1.js new file mode 100644 index 00000000000..f74203075cc --- /dev/null +++ b/packages/ckeditor5-paste-from-office/tests/manual/tickets/7957/1.js @@ -0,0 +1,42 @@ +/** + * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals console, window, document */ + +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; +import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset'; + +import Strikethrough from '@ckeditor/ckeditor5-basic-styles/src/strikethrough'; +import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline'; +import Table from '@ckeditor/ckeditor5-table/src/table'; +import TableToolbar from '@ckeditor/ckeditor5-table/src/tabletoolbar'; +import EasyImage from '@ckeditor/ckeditor5-easy-image/src/easyimage'; +import FontColor from '@ckeditor/ckeditor5-font/src/fontcolor'; +import FontBackgroundColor from '@ckeditor/ckeditor5-font/src/fontbackgroundcolor'; +import PageBreak from '@ckeditor/ckeditor5-page-break/src/pagebreak'; +import TableProperties from '@ckeditor/ckeditor5-table/src/tableproperties'; +import TableCellProperties from '@ckeditor/ckeditor5-table/src/tablecellproperties'; + +import PasteFromOffice from '../../../../src/pastefromoffice'; + +import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config'; + +ClassicEditor + .create( document.querySelector( '#editor' ), { + plugins: [ ArticlePluginSet, Strikethrough, Underline, Table, TableToolbar, PageBreak, + TableProperties, TableCellProperties, EasyImage, PasteFromOffice, FontColor, FontBackgroundColor ], + toolbar: [ 'heading', '|', 'bold', 'italic', 'strikethrough', 'underline', 'link', + 'bulletedList', 'numberedList', 'blockQuote', 'insertTable', 'pageBreak', 'undo', 'redo' ], + table: { + contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells', 'tableProperties', 'tableCellProperties' ] + }, + cloudServices: CS_CONFIG + } ) + .then( editor => { + window.editor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-paste-from-office/tests/manual/tickets/7957/1.md b/packages/ckeditor5-paste-from-office/tests/manual/tickets/7957/1.md new file mode 100644 index 00000000000..18c5a1f3ec2 --- /dev/null +++ b/packages/ckeditor5-paste-from-office/tests/manual/tickets/7957/1.md @@ -0,0 +1,7 @@ +## Paste from Office + +Test for Paste from Word, when strict CSP rules are configured. + +Check: + +1. Copy & paste some content from Word including at least one image.