Skip to main content
Version: 4.0 preview

Rich-text editor image upload

Version
4.0 preview
Updated
View markdown

Scope: what happens when an operator drops, pastes or picks an image in a ConfigBox rich-text field — the endpoint, the downscale-and-WebP pipeline, where the files land and how they are named, what is refused and why, and the two shapes the HTML filter rules out · Last reviewed: 2026-08-07

Every HTML field in ConfigBox is a TinyMCE 6 editor (see Property typestranslatable with the USE_HTMLEDITOR option tag). Until now those editors could only link an image that was already somewhere on the web. They can now take one from the operator's machine: drag and drop, paste from the clipboard, and the Upload tab of the Image dialog all work, and all three arrive at the same endpoint, because that is TinyMCE's own design — each produces a blob and hands it to images_upload_handler.

What comes back is a downscaled WebP in the customization assets directory, and the URL to it.


1. The parts

Clientassets/javascript/htmleditor.js (AMD module configbox/htmleditor) — the TinyMCE configuration and the upload handler
Endpointcontrollers/admineditorimages.php — task upload
Pipelinehelpers/editorimage.phpConfigboxEditorImageHelper
Storagedata/customization/assets/uploads/editor/<YYYY>/<MM>/
Testtests/specs/backend/editor-image-upload.spec.ts in the host project

configbox/properties/translatable no longer configures the editor itself; it hands the view to configbox/htmleditor and keeps the language switcher. The editor's configuration and its upload pipeline live in one module rather than inside the property that happens to use it.


2. What happens to the file

  1. Validated. Size, then format three ways — see §5.
  2. Oriented from its EXIF data. Phone photos are stored rotated with a "turn me" flag; a browser honours that flag but every re-encode drops it, so the rotation is baked in here or the image ends up sideways.
  3. Normalised to RGB with any colour profile removed — the same step KenedoPropertyImage takes before its mutations. A CMYK JPEG otherwise re-encodes with visibly shifted colours.
  4. Scaled down to fit MAX_EDGE (1600 px) on both axes. Never up: an image already inside the box keeps its own size.
  5. Encoded to WebP at quality 82. This is the file the editor links.
  6. Written again in its original format next to the WebP, same basename — see §4.

Decoding and encoding go through Intervention Image 3, with Imagick when the server has it and GD otherwise — chosen the same way KenedoPropertyImage::makeMutation chooses.

The numbers

They are class constants on ConfigboxEditorImageHelper, not settings:

ConstantValue
MAX_EDGE1600Longest edge stored. Covers a full-width image on a 2x display in the widest content column ConfigBox templates use.
QUALITY_WEBP82The usual sweet spot — visually indistinguishable, roughly a third of JPEG.
QUALITY_SOURCE90Higher on purpose: that copy exists to be re-encoded from.
MAX_UPLOAD_BYTES20 MBPHP's upload_max_filesize / post_max_size may be lower, in which case they win and the refusal says so.

3. Where the files go, and what they are called

data/customization/assets/uploads/editor/2026/08/grune-kuche-ansicht-8e0c41d9.webp
grune-kuche-ansicht-8e0c41d9.jpg

The customization assets directory is the one place in the data tree that is both writable and served: data/.htaccess denies execution of anything PHP-shaped across the whole tree, data/customization/.htaccess denies serving, and data/customization/assets/.htaccess grants it back for everything else (all three written by ConfigboxUpdateHelper::ensureDataAccessRules). It also survives an extension update, which anything under the component's own assets/ does not.

Month folders keep any one directory small enough to open over FTP after a few years of use, and make "everything we uploaded last August" a directory rather than a query. The date is the server's, not the store's configured timezone — this is a filing decision about a file on a disk, not something a customer sees.

The name is a slug of what the operator uploaded plus a content hash. The slug keeps the file recognisable — which is not cosmetic, because HTMLPurifier fills a missing alt attribute with the file name, so it can end up being what a screen reader announces. Non-ASCII is transliterated (Grüne Küchegrune-kuche) rather than stripped to hyphens.

The hash is of the output, not the upload

It is the first 8 hex digits of the sha1 of the finished WebP bytes. Two consequences, both wanted:

  • Uploading the same picture twice yields one file, whatever it was called either time. The second upload writes nothing and reports reused: true. Pasting from the clipboard re-encodes in the browser, so the same screenshot pasted twice is not the same bytes on the way in — hashing the output catches those, hashing the upload would not.
  • The URL changes whenever the pixels change, so the files can be cached hard and forever.

Dedupe is scoped to the month folder the image would be written to. Finding the same image filed under an older month would mean scanning the tree on every upload, which costs more than the copy saves.


4. The second file

The file written in the original format is not a fallback — nothing will ever request it. It is the re-encode source: the scaled image kept in a format any tool opens, so quality or dimensions can be revisited later without asking the operator to find the original again, and so an operator who wants the picture back gets something a photo editor will open.

It is skipped when the upload was already WebP, because then it would be a byte-for-byte second copy.

Why WebP is the one that gets linked

The obvious alternative — a <picture> with a WebP <source> and a legacy <img> fallback — cannot survive this product. Editor content is filtered through HTMLPurifier on save (KRequest::getHtml), and its XHTML 1.0 Transitional definition has no picture element, so a <picture> is unwrapped to its inner <img> the moment the record is saved. Verified against the running filter, not assumed.

That leaves one URL per image, and WebP is the right one to spend it on: every browser that can run TinyMCE 6 reads it.

The same check is why image captions are off (image_caption: false). TinyMCE's caption wraps the image in <figure><figcaption>, and the filter has neither element — the caption is dropped and the image unwrapped. Offering a feature the save destroys is worse than not offering it.


5. What is refused, and why

Every refusal is a 422 carrying the stable code EDITOR_IMAGE_REFUSED and a translated detail that says what to do differently. A breakage that is ours — an unwritable directory, an encoder failure — is a 500 with EDITOR_IMAGE_FAILED and a log entry.

RefusedWhy
Anything but jpg / jpeg / png / webpSee GIF below.
Bytes that disagree with the extensionChecked with getimagesize(). A PNG called .jpg would be filed under the wrong extension; a PHP script called .jpg is the case that matters, and only the byte check catches it.
A file that will not decodegetimagesize() is more permissive than Imagick; a file the encoder cannot read is refused with a message telling the operator to re-save it.
Over MAX_UPLOAD_BYTESAnd separately, PHP's own limits, which get their own message naming the two ini settings.

GIF is refused deliberately. Its one capability the other formats lack is animation, and neither image driver carries an animation through a WebP re-encode — accepting it would silently return a still frame of what the operator uploaded. Refusing it says so instead.

The endpoint is gated

admineditorimages starts with admin, and KenedoController::isAuthorized() puts every such controller behind the host's core.manage permission — the same gate as the admin screens the editor lives on. Without that prefix this would be an open write-to-disk endpoint. Uploads land in a tree that denies PHP execution regardless of what a file is called, so the format checks are the first lock rather than the only one.

The endpoint URL is resolved by KenedoPlatform::getEndpointUrl() through the host router, which is application-relative — from an admin page it resolves under /administrator/, which is also where the session holding core.manage lives.


6. Saving while an upload is still running

Images upload in the background (automatic_uploads), and until one finishes the editor content holds a blob: URL that means nothing outside the page. Saving then would store an image that is gone on reload — and silently, because the HTML filter drops a blob: src on the way in.

So kenedo.js's form-save path waits for pending uploads before triggerSave():

await Promise.all(tinyMCE.get().map(function(editor) { return editor.uploadImages(); }));

Editors with no pending image resolve immediately, which is nearly always the case.

Only initialized editors are asked. tinyMCE.get() lists editors from the moment they are registered, which is well before they are usable — one still booting has no uploader at all, and asking it for its uploads throws. An editor nobody can type in yet cannot be holding a pending image either, so skipping it costs nothing.

A failed upload is not waited for again and does not block the save. Be clear about what that means: the content still holds that image's blob: URL, the filter drops it on save, and the image is gone after saving — not merely un-uploaded. That is deliberate, and it is what the operator was already told when the upload failed in front of them; it is also how a broken external image URL behaves. Re-drop the image and save again.


7. What this is not

  • Not a media browser. There is no way to pick a previously uploaded image — an operator who wants the same picture on a second page uploads it again. That is deliberate for now, and cheap: the content hash means the second upload writes nothing and returns the first file's URL.
  • Not responsive images. One URL, one width. srcset needs several files and a shape the HTML filter would have to be taught, and neither is free.
  • Not garbage-collected. Deleting an image from a description leaves the file on disk, the same way every other CMS behaves. Files are small and content-addressed; a sweep would have to read every translatable string in the database to know what is unreferenced.