Browse Source

Merge pull request #2 from ckeditor/t/1

Feature: CKFinder upload adapter initial implementation. Closes #1.
Piotr Jasiun 8 years ago
parent
commit
ef27c2403c

+ 3 - 0
packages/ckeditor5-adapter-ckfinder/lang/contexts.json

@@ -0,0 +1,3 @@
+{
+	"Cannot upload file:": "A generic error displayed on upload failure. File name is concatenated to this text."
+}

+ 16 - 2
packages/ckeditor5-adapter-ckfinder/package.json

@@ -3,9 +3,23 @@
   "version": "0.0.1",
   "description": "CKFinder adapter for CKEditor 5.",
   "keywords": [],
-  "dependencies": {},
+  "dependencies": {
+	  "@ckeditor/ckeditor5-core": "^0.8.0",
+	  "@ckeditor/ckeditor5-upload": "^0.0.1",
+	  "@ckeditor/ckeditor5-utils": "^0.9.0"
+  },
   "devDependencies": {
-  	"@ckeditor/ckeditor5-dev-lint": "^2.0.2",
+    "@ckeditor/ckeditor5-basic-styles": "^0.8.0",
+    "@ckeditor/ckeditor5-clipboard": "^0.5.0",
+    "@ckeditor/ckeditor5-dev-lint": "^2.0.2",
+    "@ckeditor/ckeditor5-editor-classic": "^0.7.2",
+    "@ckeditor/ckeditor5-enter": "^0.9.0",
+    "@ckeditor/ckeditor5-heading": "^0.9.0",
+    "@ckeditor/ckeditor5-image": "^0.5.0",
+    "@ckeditor/ckeditor5-list": "^0.6.0",
+    "@ckeditor/ckeditor5-paragraph": "^0.7.0",
+    "@ckeditor/ckeditor5-typing": "^0.9.0",
+    "@ckeditor/ckeditor5-undo": "^0.8.0",
   	"gulp": "^3.9.1",
   	"guppy-pre-commit": "^0.4.0"
   },

+ 186 - 0
packages/ckeditor5-adapter-ckfinder/src/uploadadapter.js

@@ -0,0 +1,186 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals XMLHttpRequest, FormData */
+
+/**
+ * @module adapter-ckfinder/uploadadapter
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository';
+import { getCsrfToken } from './utils';
+import log from '@ckeditor/ckeditor5-utils/src/log';
+
+/**
+ * Plugin that enables CKFinder uploads in CKEditor 5.
+ * Configure proper upload URL under `ckfinder.uploadUrl` key, for example:
+ *
+ *	Editor.create( editorElement, {
+ *		plugins: [ ... ],
+ *		ckfinder: {
+ *			uploadUrl: 'http://example.com/upload'
+ *		}
+ *	} );
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class CKFinderUploadAdapter extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ FileRepository ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const url = this.editor.config.get( 'ckfinder.uploadUrl' );
+
+		if ( !url ) {
+			/**
+			 * Please provide `ckfinder.uploadUrl` config option.
+			 *
+			 * @error ckfinder-upload-adapter-no-config
+			 */
+			log.warn( 'ckfinder-upload-adapter-no-config: Please provide "ckfinder.uploadUrl" config option.' );
+		}
+
+		// Register CKFinderAdapter
+		this.editor.plugins.get( FileRepository ).createAdapter = loader => new Adapter( loader, url, this.editor.t );
+	}
+}
+
+/**
+ * Upload adapter for CKFinder.
+ *
+ * @private
+ * @implements module:upload/filerepository~Adapter
+ */
+class Adapter {
+	/**
+	 * Creates new adapter instance.
+	 *
+	 * @param {module:upload/filerepository~FileLoader} loader
+	 * @param {String} url
+	 * @param {module:utils/locale~Locale#t} t
+	 */
+	constructor( loader, url, t ) {
+		/**
+		 * FileLoader instance to use during upload.
+		 *
+		 * @member {module:upload/filerepository~FileLoader} #loader
+		 */
+		this.loader = loader;
+
+		/**
+		 * Upload URL.
+		 *
+		 * @member {String} #url
+		 */
+		this.url = url;
+
+		/**
+		 * Locale translation method.
+		 *
+		 * @member {module:utils/locale~Locale#t} #t
+		 */
+		this.t = t;
+	}
+
+	/**
+	 * Starts upload process.
+	 *
+	 * @see module:upload/filerepository~Adapter#upload
+	 * @returns {Promise}
+	 */
+	upload() {
+		return new Promise( ( resolve, reject ) => {
+			this._initRequest();
+			this._initListeners( resolve, reject );
+			this._sendRequest();
+		} );
+	}
+
+	/**
+	 * Aborts upload process.
+	 *
+	 * @see module:upload/filerepository~Adapter#abort
+	 * @returns {Promise}
+	 */
+	abort() {
+		if ( this.xhr ) {
+			this.xhr.abort();
+		}
+	}
+
+	/**
+	 * Initializes XMLHttpRequest object.
+	 *
+	 * @private
+	 */
+	_initRequest() {
+		const xhr = this.xhr = new XMLHttpRequest();
+
+		xhr.open( 'POST', this.url, true );
+		xhr.responseType = 'json';
+	}
+
+	/**
+	 * Initializes XMLHttpRequest listeners.
+	 *
+	 * @private
+	 * @param {Function} resolve Callback function to be called when request is successful.
+	 * @param {Function} reject Callback function to be called when request cannot be completed.
+	 */
+	_initListeners( resolve, reject ) {
+		const xhr = this.xhr;
+		const loader = this.loader;
+		const t = this.t;
+		const genericError = t( 'Cannot upload file:' ) + ` ${ loader.file.name }.`;
+
+		xhr.addEventListener( 'error', () => reject( genericError ) );
+		xhr.addEventListener( 'abort', () => reject() );
+		xhr.addEventListener( 'load', () => {
+			const response = xhr.response;
+
+			if ( !response || !response.uploaded ) {
+				return reject( response && response.error && response.error.message ? response.error.message : genericError );
+			}
+
+			resolve( {
+				original: response.url
+			} );
+		} );
+
+		// Upload progress when it's supported.
+		/* istanbul ignore else */
+		if ( xhr.upload ) {
+			xhr.upload.addEventListener( 'progress', ( evt ) => {
+				if ( evt.lengthComputable ) {
+					loader.uploadTotal = evt.total;
+					loader.uploaded = evt.loaded;
+				}
+			} );
+		}
+	}
+
+	/**
+	 * Prepares data and sends the request.
+	 *
+	 * @private
+	 */
+	_sendRequest() {
+		// Prepare form data.
+		const data = new FormData();
+		data.append( 'upload', this.loader.file );
+		data.append( 'ckCsrfToken', getCsrfToken() );
+
+		// Send request.
+		this.xhr.send( data );
+	}
+}

+ 83 - 0
packages/ckeditor5-adapter-ckfinder/src/utils.js

@@ -0,0 +1,83 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals window, document */
+
+/**
+ * @module adapter-ckfinder/utils
+ */
+
+const TOKEN_COOKIE_NAME = 'ckCsrfToken';
+const TOKEN_LENGTH = 40;
+const tokenCharset = 'abcdefghijklmnopqrstuvwxyz0123456789';
+
+/**
+ * Returns the CSRF token value. The value is a hash stored in `document.cookie`
+ * under the `ckCsrfToken` key. The CSRF token can be used to secure the communication
+ * between the web browser and the CKFinder server.
+ *
+ * @returns {String}
+ */
+export function getCsrfToken() {
+	let token = getCookie( TOKEN_COOKIE_NAME );
+
+	if ( !token || token.length != TOKEN_LENGTH ) {
+		token = generateToken( TOKEN_LENGTH );
+		setCookie( TOKEN_COOKIE_NAME, token );
+	}
+
+	return token;
+}
+
+/**
+ * Returns the value of the cookie with a given name or `null` if the cookie is not found.
+ *
+ * @param {String} name
+ * @returns {String|null}
+ */
+export function getCookie( name ) {
+	name = name.toLowerCase();
+	const parts = document.cookie.split( ';' );
+
+	for ( let part of parts ) {
+		const pair = part.split( '=' );
+		const key = decodeURIComponent( pair[ 0 ].trim().toLowerCase() );
+
+		if ( key === name ) {
+			return decodeURIComponent( pair[ 1 ] );
+		}
+	}
+
+	return null;
+}
+
+/**
+ * Sets the value of the cookie with a given name.
+ *
+ * @param {String} name
+ * @param {String} value
+ */
+export function setCookie( name, value ) {
+	document.cookie = encodeURIComponent( name ) + '=' + encodeURIComponent( value ) + ';path=/';
+}
+
+// Generates CSRF token with given length.
+//
+// @private
+// @param {Number} length
+// @returns {string}
+function generateToken( length ) {
+	let result = '';
+	const randValues = new Uint8Array( length );
+
+	window.crypto.getRandomValues( randValues );
+
+	for ( let j = 0; j < randValues.length; j++ ) {
+		let character = tokenCharset.charAt( randValues[ j ] % tokenCharset.length );
+		result += Math.random() > 0.5 ? character.toUpperCase() : character;
+	}
+
+	return result;
+}

+ 4 - 0
packages/ckeditor5-adapter-ckfinder/tests/manual/uploadadapter.html

@@ -0,0 +1,4 @@
+<div id="editor">
+	<h2>Image upload</h2>
+	<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus consequat placerat. Vestibulum id tellus et mauris sagittis tincidunt quis id mauris. Curabitur consectetur lectus sit amet tellus mattis, non lobortis leo interdum.</p>
+</div>

+ 37 - 0
packages/ckeditor5-adapter-ckfinder/tests/manual/uploadadapter.js

@@ -0,0 +1,37 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals document, console */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classic';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import Typing from '@ckeditor/ckeditor5-typing/src/typing';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Heading from '@ckeditor/ckeditor5-heading/src/heading';
+import Image from '@ckeditor/ckeditor5-image/src/image';
+import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption';
+import Undo from '@ckeditor/ckeditor5-undo/src/undo';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
+import ImageToolbar from '@ckeditor/ckeditor5-image/src/imagetoolbar';
+import ImageStyle from '@ckeditor/ckeditor5-image/src/imagestyle';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
+import List from '@ckeditor/ckeditor5-list/src/list';
+import ImageUpload from '@ckeditor/ckeditor5-upload/src/imageupload';
+import CKFinderUploadAdapter from '../../src/uploadadapter';
+
+ClassicEditor.create( document.querySelector( '#editor' ), {
+	plugins: [
+		Enter, Typing, Paragraph, Heading, Undo, Bold, Italic, Heading, List, Image, ImageToolbar, Clipboard,
+		ImageCaption, ImageStyle, ImageUpload, CKFinderUploadAdapter
+	],
+	toolbar: [ 'headings', 'undo', 'redo', 'bold', 'italic', 'bulletedList', 'numberedList', 'insertImage' ],
+	ckfinder: {
+		uploadUrl: 'https://cksource.com/weuy2g4ryt278ywiue/core/connector/php/connector.php?command=QuickUpload&type=Files&responseType=json'
+	}
+} )
+.catch( err => {
+	console.error( err.stack );
+} );

+ 10 - 0
packages/ckeditor5-adapter-ckfinder/tests/manual/uploadadapter.md

@@ -0,0 +1,10 @@
+## CKFinder upload adapter
+
+1. Drop an image into editor.
+1. Image should be read and displayed.
+1. Upload progress bar should be visible above the image.
+1. After uploading is complete your image should be replaced with sample image from server (use dev tools to check image src attribute).
+
+Repeat all the steps with:
+* dropping multiple images,
+* using toolbar button to add one and multiple images.

+ 194 - 0
packages/ckeditor5-adapter-ckfinder/tests/uploadadapter.js

@@ -0,0 +1,194 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals document */
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import Image from '@ckeditor/ckeditor5-image/src/image';
+import ImageUpload from '@ckeditor/ckeditor5-upload/src/imageupload';
+import CKFinderUploadAdapter from '../src/uploadadapter';
+import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository';
+import { createNativeFileMock } from '@ckeditor/ckeditor5-upload/tests/_utils/mocks';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import log from '@ckeditor/ckeditor5-utils/src/log';
+
+describe( 'CKFinderUploadAdapter', () => {
+	let editor, sinonXHR;
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		const editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+
+		sinonXHR = testUtils.sinon.useFakeXMLHttpRequest();
+
+		return ClassicTestEditor.create( editorElement, {
+			plugins: [ Image, ImageUpload, CKFinderUploadAdapter ],
+			ckfinder: {
+				uploadUrl: 'http://example.com'
+			}
+		} )
+		.then( newEditor => {
+			editor = newEditor;
+		} );
+	} );
+
+	afterEach( () => {
+		sinonXHR.restore();
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( CKFinderUploadAdapter ) ).to.be.instanceOf( CKFinderUploadAdapter );
+	} );
+
+	describe( 'Adapter', () => {
+		let adapter, loaderMock;
+
+		beforeEach( () => {
+			const file = createNativeFileMock();
+			file.name = 'image.jpeg';
+			loaderMock = { file	};
+
+			adapter = editor.plugins.get( FileRepository ).createAdapter( loaderMock );
+		} );
+
+		it( 'crateAdapter method should be registered and have upload and abort methods', () => {
+			expect( adapter ).to.be.defined;
+			expect( adapter.upload ).to.be.function;
+			expect( adapter.abort ).to.be.function;
+		} );
+
+		it( 'should log warning when there is no configuration', () => {
+			const editorElement = document.createElement( 'div' );
+			document.body.appendChild( editorElement );
+			const warnSub = testUtils.sinon.stub( log, 'warn' );
+
+			return ClassicTestEditor.create( editorElement, {
+				plugins: [ Image, ImageUpload, CKFinderUploadAdapter ],
+			} )
+			.then( () => {
+				sinon.assert.calledOnce( warnSub );
+				sinon.assert.calledWithExactly(
+					warnSub,
+					'ckfinder-upload-adapter-no-config: Please provide "ckfinder.uploadUrl" config option.'
+				);
+			} );
+		} );
+
+		describe( 'upload', () => {
+			it( 'should return promise', () => {
+				expect( adapter.upload() ).to.be.instanceof( Promise );
+			} );
+
+			it( 'should call url from config', () => {
+				let request;
+				const validResponse = {
+					uploaded: 1
+				};
+
+				const promise = adapter.upload()
+					.then( () => {
+						expect( request.url ).to.equal( 'http://example.com' );
+					} );
+
+				request = sinonXHR.requests[ 0 ];
+				request.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( validResponse ) );
+
+				return promise;
+			} );
+
+			it( 'should throw an error on generic request error', () => {
+				let request;
+
+				const promise = adapter.upload()
+					.then( () => {
+						throw new Error( 'Promise should throw.' );
+					} )
+					.catch( ( msg ) => {
+						expect( msg ).to.equal( 'Cannot upload file: image.jpeg.' );
+					} );
+
+				request = sinonXHR.requests[ 0 ];
+				request.error();
+
+				return promise;
+			} );
+
+			it( 'should throw an error on error from server', () => {
+				let request;
+				const responseError = {
+					error: {
+						message: 'Foo bar baz.'
+					}
+				};
+
+				const promise = adapter.upload()
+					.then( () => {
+						throw new Error( 'Promise should throw.' );
+					} )
+					.catch( ( msg ) => {
+						expect( msg ).to.equal( 'Foo bar baz.' );
+					} );
+
+				request = sinonXHR.requests[ 0 ];
+				request.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( responseError ) );
+
+				return promise;
+			} );
+
+			it( 'should throw a generic error on error from server without message', () => {
+				let request;
+				const responseError = {
+					error: {}
+				};
+
+				const promise = adapter.upload()
+					.then( () => {
+						throw new Error( 'Promise should throw.' );
+					} )
+					.catch( ( msg ) => {
+						expect( msg ).to.equal( 'Cannot upload file: image.jpeg.' );
+					} );
+
+				request = sinonXHR.requests[ 0 ];
+				request.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( responseError ) );
+
+				return promise;
+			} );
+
+			it( 'should throw an error on abort', () => {
+				let request;
+
+				const promise = adapter.upload()
+					.then( () => {
+						throw new Error( 'Promise should throw.' );
+					} )
+					.catch( () => {
+						expect( request.aborted ).to.be.true;
+					} );
+
+				request = sinonXHR.requests[ 0 ];
+				adapter.abort();
+
+				return promise;
+			} );
+
+			it( 'abort should not throw before upload', () => {
+				expect( () => {
+					adapter.abort();
+				} ).to.not.throw();
+			} );
+
+			it( 'should update progress', () => {
+				adapter.upload();
+
+				const request = sinonXHR.requests[ 0 ];
+				request.uploadProgress( { loaded: 4, total: 10 } );
+
+				expect( loaderMock.uploadTotal ).to.equal( 10 );
+				expect( loaderMock.uploaded ).to.equal( 4 );
+			} );
+		} );
+	} );
+} );

+ 72 - 0
packages/ckeditor5-adapter-ckfinder/tests/utils.js

@@ -0,0 +1,72 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals document */
+
+import { getCsrfToken, getCookie, setCookie } from '../src/utils';
+
+describe( 'utils', () => {
+	beforeEach( () => {
+		clearCookie( 'ckCsrfToken' );
+	} );
+
+	describe( 'getCsrfToken', () => {
+		let token;
+
+		beforeEach( () => {
+			token = getCsrfToken();
+		} );
+
+		it( 'should be saved in cookie', () => {
+			expect( document.cookie.indexOf( `ckCsrfToken=${ token }` ) > -1 ).to.be.true;
+		} );
+
+		it( 'should have proper length', () => {
+			expect( token.length ).to.equal( 40 );
+		} );
+
+		it( 'should produce same token for all cals', () => {
+			expect( token ).to.equal( getCsrfToken() );
+		} );
+	} );
+
+	describe( 'get/set cookie', () => {
+		let cookieName, cookieValue;
+
+		beforeEach( () => {
+			cookieName = 'test-cookie-name';
+			cookieValue = 'test-value' + Math.random();
+
+			clearCookie( cookieName );
+		} );
+
+		describe( 'setCookie', () => {
+			it( 'should set cookie', () => {
+				setCookie( cookieName, cookieValue );
+				expect( document.cookie.indexOf( `${ cookieName }=${ cookieValue }` ) > -1 ).to.be.true;
+			} );
+		} );
+
+		describe( 'getCookie', () => {
+			it( 'should get cookie', () => {
+				document.cookie = encodeURIComponent( cookieName ) + '=' + encodeURIComponent( cookieValue ) + ';path=/';
+				expect( getCookie( cookieName ) ).to.equal( cookieValue );
+			} );
+
+			it( 'should return null if cookie is not present', () => {
+				expect( getCookie( cookieName ) ).to.be.null;
+			} );
+
+			it( 'should return empty cookie', () => {
+				document.cookie = encodeURIComponent( cookieName ) + '=;path=/';
+				expect( getCookie( cookieName ) ).to.equal( '' );
+			} );
+		} );
+	} );
+} );
+
+function clearCookie( name ) {
+	document.cookie = `${ name }=;expires=Thu, 01 Jan 1970 00:00:00 GMT`;
+}