CKEditor5 Image Upload

Photo by Jakob Owens on Unsplash

CKEditor5 Image Upload

While watching the Oracle APEX Office Hours episode New in APEX 22.1 (Part 3): Ed. Improvements, REST Enabled SQL Queries, and easy App Gallery Installs, I saw something I had missed in the release notes:

image.png

What? Is it really possible to use CKEditor5's plugins in an Oracle APEX app?

Hugh Grant dancing

So let's see what we can achieve with that.

How to enable a plugin

To be able to use plugins we have to add code under the JavaScript Initialization Code attribute of one Rich Text Editor page item:

function(options){
    // enable the Media embed feature
    options.editorOptions.extraPlugins.push(CKEditor5.mediaEmbed.MediaEmbed);

    return options;
}

Now try to past a YouTube links inside the text editor. Pretty cool isn't it?

Copy and paste images

One of the best features we can now offer our users in a snap is the ability to copy and paste images from their computer.

The easy way

The Base64 upload adapter allows you to upload images from your file system by copy and paste or selection via your file manager. Remember that the image is converted in base 64 so it is very inefficient as mentioned in the documentation. Anyway, we can implement it by adding this initialization code:

function(options){
    // enable the Base64 upload feature
    options.editorOptions.extraPlugins.push(CKEditor5.upload.Base64UploadAdapter);

    // Add a button to upload an image in the toolbar
    let toolbar = options.editorOptions.toolbar;
    toolbar.push("uploadImage");
    options.editorOptions.toolbar = toolbar;

    return options;
}

Now we can copy and paste an image or click on the image upload button and choose a file from our file system. Pretty cool even if it's a base 64 upload isn't it?

But wait, after some tests it seems impossible to render images from the a database clob. Actually it works for files less than 3 or 4 ko but for bigger files then it displays the base 64 string.

The less easy way

The other option we have is to use the Simple upload adapter. In this scenario, the files are send to the server using an XMLHttpRequest, then the server should return the url to get the image back.

Data model

First we have to create a simple table to store our images as we have to handle the storage:

-- create tables
create table ckeditor_images (
    id                             number generated by default on null as identity 
                                   constraint ckeditor_images_id_pk primary key,
    image                          blob,
    content_type                   varchar2(100 char),
    created                        date not null,
    created_by                     varchar2(255 char) not null,
    updated                        date not null,
    updated_by                     varchar2(255 char) not null
)
;

-- triggers
create or replace trigger ckeditor_images_biu
    before insert or update 
    on ckeditor_images
    for each row
begin
    if inserting then
        :new.created := sysdate;
        :new.created_by := coalesce(sys_context('APEX$SESSION','APP_USER'),user);
    end if;
    :new.updated := sysdate;
    :new.updated_by := coalesce(sys_context('APEX$SESSION','APP_USER'),user);
end ckeditor_images_biu;
/

REST services

Then we have to create two RESTful Services, the first one will manage the file's upload and the second one the download.


-- Generated by ORDS REST Data Services 21.2.4.r2431032
-- Schema: L  Date: Fri Jul 22 12:18:33 2022 
--

DECLARE
 l_roles     OWA.VC_ARR;
 l_modules   OWA.VC_ARR;
 l_patterns  OWA.VC_ARR;

BEGIN
 ORDS.ENABLE_SCHEMA(
     p_enabled             => TRUE,
     p_schema              => 'L',
     p_url_mapping_type    => 'BASE_PATH',
     p_url_mapping_pattern => 'louis',
     p_auto_rest_auth      => TRUE);

 ORDS.DEFINE_MODULE(
     p_module_name    => 'CKEditor',
     p_base_path      => '/ckeditor/',
     p_items_per_page => 25,
     p_status         => 'PUBLISHED',
     p_comments       => NULL);

 ORDS.DEFINE_TEMPLATE(
     p_module_name    => 'CKEditor',
     p_pattern        => 'file',
     p_priority       => 0,
     p_etag_type      => 'HASH',
     p_etag_query     => NULL,
     p_comments       => NULL);

 ORDS.DEFINE_HANDLER(
     p_module_name    => 'CKEditor',
     p_pattern        => 'file',
     p_method         => 'POST',
     p_source_type    => 'plsql/block',
     p_mimes_allowed  => '',
     p_comments       => NULL,
     p_source         => 
'declare
   l_id number;
   l_blob blob;
   e_file_too_big exception;
begin 
   dbms_lob.createtemporary(l_blob, false);
   l_blob := :body;

   if (sys.dbms_lob.getlength(l_blob) > 100000) then
       raise e_file_too_big;
   end if;

   insert into ckeditor_images (image, content_type) values (l_blob, :content_type)
   returning id into l_id;
   commit;

   dbms_lob.freetemporary(l_blob);

   :status := 201;

   apex_json.open_object;
   apex_json.write(
       p_name => ''url'', 
       p_value => owa_util.get_cgi_env(''REQUEST_PROTOCOL'') ||
                  ''://'' ||
                  owa_util.get_cgi_env(''SERVER_NAME'') ||
                  owa_util.get_cgi_env(''SCRIPT_NAME'')||
                  owa_util.get_cgi_env(''PATH_INFO'') || 
                  ''/'' || l_id /*||
                  ''?_apex_session='' || :apex_session*/
   );
   apex_json.close_object;
exception
when e_file_too_big then 
   apex_json.open_object;
   apex_json.open_object(''error'');
   apex_json.write(''message'', ''File too big (max 100kb).'');
   apex_json.close_object;
   apex_json.close_object;
when others then
   apex_json.open_object;
   apex_json.open_object(''error'');
   apex_json.write(''message'', ''Something wrong!'');
   apex_json.close_object;
   apex_json.close_object;
end;');

 ORDS.DEFINE_PARAMETER(
     p_module_name        => 'CKEditor',
     p_pattern            => 'file',
     p_method             => 'POST',
     p_name               => 'X-ORDS-STATUS-CODE',
     p_bind_variable_name => 'status',
     p_source_type        => 'HEADER',
     p_param_type         => 'INT',
     p_access_method      => 'OUT',
     p_comments           => NULL);

 ORDS.DEFINE_PARAMETER(
     p_module_name        => 'CKEditor',
     p_pattern            => 'file',
     p_method             => 'POST',
     p_name               => 'apex-session',
     p_bind_variable_name => 'apex_session',
     p_source_type        => 'HEADER',
     p_param_type         => 'STRING',
     p_access_method      => 'IN',
     p_comments           => NULL);

 ORDS.DEFINE_TEMPLATE(
     p_module_name    => 'CKEditor',
     p_pattern        => 'file/:id',
     p_priority       => 0,
     p_etag_type      => 'HASH',
     p_etag_query     => NULL,
     p_comments       => NULL);

 ORDS.DEFINE_HANDLER(
     p_module_name    => 'CKEditor',
     p_pattern        => 'file/:id',
     p_method         => 'GET',
     p_source_type    => 'resource/lob',
     p_mimes_allowed  => '',
     p_comments       => NULL,
     p_source         => 
'select content_type, image
from ckeditor_images 
where id = :id');

 ORDS.DEFINE_PARAMETER(
     p_module_name        => 'CKEditor',
     p_pattern            => 'file/:id',
     p_method             => 'GET',
     p_name               => 'id',
     p_bind_variable_name => 'id',
     p_source_type        => 'URI',
     p_param_type         => 'INT',
     p_access_method      => 'IN',
     p_comments           => NULL);



 l_roles(1) := 'RESTful Services';
 l_modules(1) := 'CKEditor';

 ORDS.DEFINE_PRIVILEGE(
     p_privilege_name => 'Privilege_CKEditor',
     p_roles          => l_roles,
     p_patterns       => l_patterns,
     p_modules        => l_modules,
     p_label          => 'CKEditor Privilege',
     p_description    => '',
     p_comments       => NULL);  

 l_roles.DELETE;
 l_modules.DELETE;
 l_patterns.DELETE;                                    

COMMIT;

END;

Configure the editor

Finally we have to enable it using the JavaScript Initialization Code:

function(options){
    // enable the Simple upload adapter
    options.editorOptions.extraPlugins.push(CKEditor5.upload.SimpleUploadAdapter);

    // configure the uploader
    options.editorOptions.simpleUpload = {
            uploadUrl: "Your POST endpoint URL"
    };

    // Add a button to upload an image in the toolbar
    let toolbar = options.editorOptions.toolbar;
    toolbar.push("uploadImage");
    options.editorOptions.toolbar = toolbar;

    return options;
}

Now, when you will upload, it will be stored in the ckeditor_images table and downloaded by CKEditor via your RESTful service for display.

f

Cherry on the cake

We can now add images to the editor but we can do something more, let's add the image toolbar. Go back to the initialization code and replace it by this one:

function(options){
   // Enable image features (resizing, insert from link, add link to an image, styling)
   options.editorOptions.extraPlugins.push(
       CKEditor5.image.Image,
       CKEditor5.image.ImageResize, 
       CKEditor5.image.AutoImage,
       CKEditor5.link.LinkImage,
       CKEditor5.upload.SimpleUploadAdapter
   );

   // configure the uploader
   options.editorOptions.simpleUpload = {
           uploadUrl: "Your POST endpoint URL"
   };

   // Add a button to upload an image in the toolbar
   let toolbar = options.editorOptions.toolbar;
   toolbar.push("uploadImage");
   options.editorOptions.toolbar = toolbar;

   // Configure the image toolbar
   options.editorOptions.image.toolbar = [
        'imageStyle:inline', 
       'imageStyle:wrapText', 
       'imageStyle:breakText', 
       '|', 
       'imageResize', 
       '|', 
       'toggleImageCaption',
       'imageTextAlternative',
       '|',
       'LinkImage'
   ];

   return options;
}

Securing the endpoints

At the moment, the ORDS endpoints are not protected and we don't want to allow everyone to call them as it would be possible to load unauthorized data or even worse to leak data.

The documentation explains that we can use some headers to secure the POST calls. I was looking for an example on how I can simply secure it when I found this post by Vincent Morneau. Following a tweet from Kris Rice, he explains that you can use a specific header to protect your endpoints with your APEX session.

Protect your endpoints

You just need to add a new privilege according to the screenshot below. After doing this, uploading an image should no longer work.

image.png

Enable credentials in the editor

Add this code to the Function and Global Variable Declaration attribute of the page to retrieve the APEX application ID and the current session.

let appId = apex.env.APP_ID;
let sessionId = apex.env.APP_SESSION;
let apexSession = appId + ',' + sessionId;

Then update the initialization code of the editor to add the credentials

   // configure the uploader
   options.editorOptions.simpleUpload = {
           uploadUrl: "Your POST endpoint URL",
           withCredentials: true,
           headers: {
              'Apex-Session': apexSession
           }
   }

Finally test it! At this step, the upload should work but the images was not correctly displayed because the credentials are not automatically added to the url and we don't want to store them.

Downcast to the rescue

We have to add a downcast converter that will add the APEX session to the url. You can read more about the conversion here.

Add this function to the editor initialization code

// add the downcast converter
    options.executeOnInitialization = editor =>  {
        editor.conversion.for( 'editingDowncast' ).add( dispatcher => {
            dispatcher.on(
                'attribute:src',
                ( evt, data, conversionApi ) => {
                    if ( !conversionApi.consumable.test( data.item, 'attribute:src' ) ) {
                        return;
                    }
                    if ( data.attributeNewValue ) {
                        data.attributeNewValue += '?_apex_session=' + apexSession;
                    }
                },
                { priority: 'high' }
            );
        } );
    }

This will append the credentials to the url on page load as well as while uploading images.

It is much better now right? You can find a working example here.

I hope you enjoyed this one, in the next parts we will explore other features. Stay tuned.