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:
What? Is it really possible to use CKEditor5's plugins in an Oracle APEX app?
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.
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.
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.