Search code examples
amazon-s3ckfinderckeditor5

Set common key prefix for S3 bucket per CKFinder 3 instance


How can I make the CKFinder ASP.net S3 integration load content from a dynamic key prefix rather than just a root location?


I'm using CKEditor 5 and CKFinder 3 with the ASP.net Connector to allow image upload directly to an S3 bucket. The web application we are connecting this all to is not an ASP.net application.

Setting is up was simple enough by following the documentation.

However, our product is SaaS, so each time the CKFinder is launched, I need it to target a different key prefix in our bucket. Multiple websites run off the same app and each should be able to have their own gallery of images loaded via the CKFinder without being able to see the images belonging to other apps.


Our CKFinder Web.config:

<backend name="s3Bucket" adapter="s3">
   <option name="bucket" value="myBucket" />
   <option name="key" value="KEYHERE" />
   <option name="secret" value="SECRETHERE" />
   <option name="region" value="us-east-1" />
   <option name="root" value="images" />
 </backend>

This config gets content into the /images/ common key prefix "folder" just great, but for each app that uses the CKFinder, I want it to read from a different "root":

/images/app1Id/
/images/app2Id/
/images/app3Id/

Ideally, I want to set this when invoking the Editor/Finder instance; something like:

ClassicEditor.create( document.querySelector( '#textareaId' ), {
    ckfinder: {
        uploadUrl: '/ckfinder/connector?command=QuickUpload&type=Images&responseType=json',
        connectorRoot: '/images/app1Id/'
    },
    toolbar: [ 'heading', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList', 'blockQuote', 'ckfinder' ],
    heading: {
        options: [
            { model: 'paragraph', title: 'Paragraph', class: 'ck-heading_paragraph' },
            { model: 'heading1', view: 'h1', title: 'Heading 1', class: 'ck-heading_heading1' },
            { model: 'heading2', view: 'h2', title: 'Heading 2', class: 'ck-heading_heading2' }
        ]
    }
});

Here I added connectorRoot: '/images/app1Id/' as an example of what I would like to pass.

Is there some way to do something like this? I've read through the ASP.net Connector docs and see that you can build your own connector and use pass to send it data, but having to compile and maintain a custom connector does not sound very fun. The S3 connectivity here is so great and easy... if only it let me be a little more specific.


Solution

  • The solution we came to was to modify and customize the CKFinder ASP Connector. Big thanks to the CKSource team for helping us to get this running.


    ConnectorConfig.cs

    namespace CKSource.CKFinder.Connector.WebApp
    {
        using System.Configuration;
        using System.Linq;
    
        using CKSource.CKFinder.Connector.Config;
        using CKSource.CKFinder.Connector.Core.Acl;
        using CKSource.CKFinder.Connector.Core.Builders;
        using CKSource.CKFinder.Connector.Host.Owin;
        using CKSource.CKFinder.Connector.KeyValue.FileSystem;
        using CKSource.FileSystem.Amazon;
        //using CKSource.FileSystem.Azure;
        //using CKSource.FileSystem.Dropbox;
        //using CKSource.FileSystem.Ftp;
        using CKSource.FileSystem.Local;
    
        using Owin;
    
        public class ConnectorConfig
        {
            public static void RegisterFileSystems()
            {
                FileSystemFactory.RegisterFileSystem<LocalStorage>();
                //FileSystemFactory.RegisterFileSystem<DropboxStorage>();
                FileSystemFactory.RegisterFileSystem<AmazonStorage>();
                //FileSystemFactory.RegisterFileSystem<AzureStorage>();
                //FileSystemFactory.RegisterFileSystem<FtpStorage>();
            }
    
            public static void SetupConnector(IAppBuilder builder)
            {
                var allowedRoleMatcherTemplate = ConfigurationManager.AppSettings["ckfinderAllowedRole"];
                var authenticator = new RoleBasedAuthenticator(allowedRoleMatcherTemplate);
    
                var connectorFactory = new OwinConnectorFactory();
                var connectorBuilder = new ConnectorBuilder();
                var connector = connectorBuilder
                    .LoadConfig()
                    .SetAuthenticator(authenticator)
                    .SetRequestConfiguration(
                        (request, config) =>
                        {
    
                            config.LoadConfig();
    
                            var defaultBackend = config.GetBackend("default");
                            var keyValueStoreProvider = new FileSystemKeyValueStoreProvider(defaultBackend);
                            config.SetKeyValueStoreProvider(keyValueStoreProvider);
    
                            // Remove dummy resource type
                            config.RemoveResourceType("dummy");
    
                            var queryParameters = request.QueryParameters;
    
                            // This code lacks some input validation - make sure the user is allowed to access passed appId
                            string appId = queryParameters.ContainsKey("appId") ? Enumerable.FirstOrDefault(queryParameters["appId"]) : string.Empty;
    
                            // set up an array of StringMatchers for folder to hide!
                            StringMatcher[] hideFoldersMatcher = new StringMatcher[] { new StringMatcher(".*"), new StringMatcher("CVS"), new StringMatcher("thumbs"), new StringMatcher("__thumbs") };
    
                            // image type resource setup
                            var fileSystem_Images = new AmazonStorage(secret: "SECRET-HERE",
                                                                key: "KEY-HERE",
                                                                bucket: "BUCKET-HERE",
                                                                region: "us-east-1",
                                                                root: string.Format("images/{0}/userimages/", appId),
                                                                signatureVersion: "4");
    
                            string[] allowedExtentions_Images = new string[] {"gif","jpeg","jpg","png"};
    
                            config.AddBackend("s3Images", fileSystem_Images, string.Format("CDNURL-HERE/images/{0}/userimages/", appId), false);
    
                            config.AddResourceType("Images", resourceBuilder => {
                                resourceBuilder.SetBackend("s3Images", "/")
                                .SetAllowedExtensions(allowedExtentions_Images)
                                .SetHideFoldersMatchers(hideFoldersMatcher)
                                .SetMaxFileSize( 5242880 );
                            });
    
                             // file type resource setup
                            var fileSystem_Files = new AmazonStorage(secret: "SECRET-HERE",
                                                            key: "KEY-HERE",
                                                            bucket: "BUCKET-HERE",
                                                            region: "us-east-1",
                                                            root: string.Format("docs/{0}/userfiles/", appId),
                                                            signatureVersion: "4");
    
                            string[] allowedExtentions_Files = new string[] {"csv","doc","docx","gif","jpeg","jpg","ods","odt","pdf","png","ppt","pptx","rtf","txt","xls","xlsx"};
    
                            config.AddBackend("s3Files", fileSystem_Files, string.Format("CDNURL-HERE/docs/{0}/userfiles/", appId), false);
    
                            config.AddResourceType("Files", resourceBuilder => {
                                resourceBuilder.SetBackend("s3Files", "/")
                                .SetAllowedExtensions(allowedExtentions_Files)
                                .SetHideFoldersMatchers(hideFoldersMatcher)
                                .SetMaxFileSize( 10485760 );
                            });
    
                        })
                    .Build(connectorFactory);
    
                builder.UseConnector(connector);
            }
        }
    }
    

    Items of note:

    • Added using System.Linq; so that FirstOrDefault works when getting the appId
    • We removed some of the fileSystems (Azure,Dropbox,Ftp) because we do not use those in our integration
    • In the CKFinder web.config file, we create a 'dummy' resource type because the Finder requires at least one to be present, but we then remove it during connector config and replace it with our desired resource types <resourceTypes><resourceType name="dummy" backend="default"></resourceType>resourceTypes>
    • Please note and take care that you're placing some sensitive information in this file. Please consider how you version control this (or not) and you may want to take additional actions to make this more secure

    Initializing a CKEditor4/CKFinder3 instance

    <script src="/js/ckeditor/ckeditor.js"></script>
    <script src="/js/ckfinder3/ckfinder.js"></script>
    
    <script type="text/javascript">
    
        var myEditor = CKEDITOR.replace( 'bodyContent', {
    
            toolbar:                    'Default',
            width:                      '100%',
            startupMode:                'wysiwyg',
    
            filebrowserBrowseUrl:       '/js/ckfinder3/ckfinder.html?type=Files&appId=12345',
            filebrowserUploadUrl:       '/js/ckfinder3/connector?command=QuickUpload&type=Files&appId=12345',
    
            filebrowserImageBrowseUrl:  '/js/ckfinder3/ckfinder.html?type=Images&appId=12345',
            filebrowserImageUploadUrl:  '/js/ckfinder3/connector?command=QuickUpload&type=Images&appId=12345',
    
            uploadUrl:                  '/js/ckfinder3/connector?command=QuickUpload&type=Images&responseType=json&appId=12345'
    
        });
    </script>
    

    Items of note:

    • Due to other integration requirements, are using the Manual Integration method here, which requires us to manually define our filebrowserUrls
    • Currently, adding &pass=appId to your filebrowserUrls or adding config.pass = 'appId'; to your config.js file does not properly pass the desired value through to the editor
    • I believe this only fails when using the Manual Integration method (it should work correctly if you're using CKFinder.setupCKEditor())

    ckfinder.html

    <!DOCTYPE html>
    <!--
    Copyright (c) 2007-2019, CKSource - Frederico Knabben. All rights reserved.
    For licensing, see LICENSE.html or https://ckeditor.com/sales/license/ckfinder
    -->
    <html>
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
        <title>CKFinder 3 - File Browser</title>
    </head>
    <body>
    
    <script src="ckfinder.js"></script>
    <script>
    
        var urlParams = new URLSearchParams( window.location.search );
        var myAppId = ( urlParams.has( 'appId' ) ) ? urlParams.get( 'appId' ) : '';
    
        if ( myAppId !== '' ) {
            CKFinder.start( { pass: 'appId', appId: myAppId } );
        } else {
            document.write( 'Error loading configuration.' );
        }
    
    </script>
    
    </body>
    </html>
    

    Items of note:

    • This all seems to work much more smoothly when integrating into CKEditor5, but when integrating into CKEditor4, we experience a lot of issues getting the appId value to pass properly into the editor when utilizing the Manual Integration method for CKFinder
    • We modify the ckfinder.html file here to look for the desired url params and pass them into the CKFinder instance as it's started. This ensures they are passed through the entirety of the Finder instance
    • Check out this question for some great further details about this process as well as a more generic method of passing n params into your Finder instances: How do I pass custom values to CKFinder3 when instantiating a CKEditor4 instance?