Skip to main content

Command Palette

Search for a command to run...

CKEditor5 Image Upload

Updated
7 min read
CKEditor5 Image Upload
L

Oracle APEX Developer (Insum) from Paris Contributor of Flows for APEX Passionate about APEX and web development.

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.

J

Hi, When sending an email with the editor content images are not included or if included many emails clients does not allow or accept them. Do you know if there is a image hosting service we can integrate to hold the images uploaded to the ckeditor? So when sending an email the images are visible to the recipient.

Thanks for any help on this! Javier

L

Hi Javier, This may be because the endpoints are protected. If you want the images to be visible in the body of the email, you probably need to unprotect the GET handler. I hope this helps. Louis

J

Hello Louis, I love your posts about the CKEditor 5 with Oracle Apex... I am currently working on a migration fix from CKEditor 4 to CKEditor 5 because APEX 22.2 completely disables the use of CKEditor 4, the problem is that in CKEditor 4 it was possible to use an attribute configObject.filebrowserImageBrowseUrl in which it was possible to add your own APEX page to search images in the database, however, CKEditor 5 does not have this implementation. My idea is to modify the behavior of the uploadImage button (toolbar) through the methods without modifying the Drop Image (pasting images from clipboard)... do you have any idea how to simulate the configObject.filebrowserImageBrowseUrl functionality or how to modify only the behavior of the imageUpload button from clipboard? Sorry if I ask you these questions, literally reading your posts I could understand much better the use of the ckeditor 5 in oracle apex, thank you very much.

1
L

Thank you for your comment, to be honest I haven't looked at that before so I have no ideas... But it seems to be an interesting topic. I might be interested in digging further

1
J

Thank you so much for replying! It's possible by killing the current behavior of a toolbar button and overriding it (using document selection) the action when clicking on it hahaha nice posts! I will keep reading them

N

Hi Louis, Thank you for this amazing blog. It is very helpful.

ORDS Security

Concerning the ORDS First Party Authentication (Apex-Session), do we need to setup something on ORDS for that to work ?

I've spent a lot of time trying to make it working but for some reason ORDS always return 401 Unauthorized error. (I can see in the POST request of my browser the http header "apex-session" with the correct value but ORDS reject the request ).

I've tried using a ORDS OAUTH2 Token and it works properly.

Image Store

Concerning the image storage, I'm not a big fan of storing blob inside the database.

And I also think that there is a better approach to send the images to the storage location without using ORDS and overloading the database with blob handling.

The idea is to send the image directly from the client browser to Google Cloud Storage (or another cloud storage).

GCS provide JSON API to "Initiate a resumable upload session" which return a secure upload URL. We only need to define a PL/SQL Function to initiate an upload session on GCS using OAUTH2 to return an "upload url" to the client. That way, the GCS oauth token is not visible by the client, which is something we want to avoid to secure the bucket content.

The challenge here is to be able to dynamically set the upload url of the "CKEditor Simple upload adaptor" by executing an AJAX Call to execute that PL/SQL function when the user pick an image... + Another challenge is the successful response that the "CKEditor Simple upload adapter" is waiting for : { "url":"....." } Maybe we would have to define a "Custom upload adapter"

Best Regards

  • Nicolas
1
L

Hi Nicolas, Thank you for your comment, it means a lot.

To protect ORDS endpoint, you have to define a privilege and protect your module with this privilege. Have you follow this step? OAuth2 is also a valid approach.

I am also not a big fan of blob storage but it's really easy to make it working quickly. I am working on another post with images stored in an OCI bucket. Stay tuned :-)

N

Louis Moreaux Thank you Louis, Yes, I've followed all the steps, and it is working fine for OAuth2, but for some reasons, the ORDS does not authorize my requests with the "Apex-Session" header. Maybe a cookie issue...

https://www.oracle.com/application-development/technologies/rest-data-services/listener-dev-guide.html

Good news for your next post. Would be nice if the images could be sent directly from the client to OCI bucket without going through the database... this would save CPU & Bandwidth...

Another challenge is to be able to automatically compress and resize the images on the client side before uploading to OCI... ;-)

S

Thanks Louis again for the blog post! Great job!

If I may add just 2 suggestions:

  • apex.jQuery("#pFlowId").val() can be written as apex.env.APP_ID and apex.jQuery("#pInstance").val() as apex.env.APP_SESSION
  • There is no need to pollute the "Execute when Page Loads" attribute. That bit can more elegantly be done directly in the item's JS Init Code:
    function(options){
      options.executeOnInitialization = editor => {
          editor.conversion.for...
      }
      return options;
    }
    

And a couple more things that you may still have to think about:

  • The content probably won't only be viewed from within the editor. Chances are you'll wish to just render the HTML in a static or dynamic content region somewhere. How would you go about appending that session token then?
  • You will most likely encounter the concept of "posts" as you won't only be editing the same content each time. In that case, how will you link a post to its images? That is, if a post is deleted, how will you also delete its images?
  • Similarly, say a user uploads an image, but never actually submits the page. What happens to these "unsaved" images?

I still don't have an elegant solution for these problems, but they're worth thinking about.

- Stefan

1
J
Jon Dixon3y ago

This is a great post, thank you!

One thing you try to improve security is to have the POST Handler in an ORDS module secured using OAuth2. Use apex_web_service.oauth_authenticate to get a token and then include the following in the CKEditor initialization to pass the bearer token:

      withCredentials: true,
      headers: {Authorization: 'Bearer &P10_OAUTH_TOKEN.'}

Having the POST handler secured by OAuth2 also allows other services to use it, not just APEX.

Keep the GET image handler in an unsecured ORDS Handler. Generate a random token when you add the image to the table. Append the token as a query string parameter to the URL returned in the POST response e.g.

{
    "url": "https://www.example.com/ords/get_ck_image/file/10?image_token=B0FALE1MW7ZFYFZGvGVvHfQIKAtMJF"
}

This way, images included in the HTML will show in a classic report, etc. <img src="https://www.example.com/ords/get_ck_image/file/13?file_token=BBF8RP5FCBU6EDYWtoUIIBJzZcotRK">

In the GET handler, include the token in the where clause:

select content_type, image
from ckeditor_images 
where id = :id
and image_token = :image_token

This isn't completely secure because someone could inspect the HTML and share the link. It does prevent people from trying to guess other image tokens though.

1
L

Thanks you Stefan Dobre for taking the time to read and comment on it! First of all, I just updated the post with your two suggestions, by the way thanks for the executeOnInitialization, I hadn't seen that attribute.

To display images outside of CKEditor, I would opt for a replace function that will add the session token (I added a classic report on the blog example).

To add an uploaded image to a "post", we can use the CKEditor API to be able to create an image element from a stored image (example added as well).

If a post is deleted or the page is not submitted, I would let the user decide if he wants to delete the file or not. An interesting approach could be to create an APEX automation to check if a file is used in a post or not and delete it (based on the upload date, for example after 30 days).

I will update the post with these ideas soon.

1
L

Hi Jon Dixon, Thanks for your comment, I also thought about using OAuth2 authentication and it is a valid approach but I wanted a really simple and APEX friendly solution. So I thought the session header was the right choice for this example. I think your comment is enough to document this second authentication mode.

1

More from this blog

Oracle APEX tips and tricks - Louis Moreaux

32 posts