Rich-text editor image upload
- Version
- 3.x
- Updated
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 types — translatable 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
| Client | assets/javascript/htmleditor.js (AMD module configbox/htmleditor) — the TinyMCE configuration and the upload handler |
| Endpoint | controllers/admineditorimages.php — task upload |
| Pipeline | helpers/editorimage.php — ConfigboxEditorImageHelper |
| Storage | data/customization/assets/uploads/editor/<YYYY>/<MM>/ |
| Test | tests/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
- Validated. Size, then format three ways — see §5.
- 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.
- Normalised to RGB with any colour profile removed — the same step
KenedoPropertyImagetakes before its mutations. A CMYK JPEG otherwise re-encodes with visibly shifted colours. - Scaled down to fit
MAX_EDGE(1600 px) on both axes. Never up: an image already inside the box keeps its own size. - Encoded to WebP at quality 82. This is the file the editor links.
- 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:
| Constant | Value | |
|---|---|---|
MAX_EDGE | 1600 | Longest edge stored. Covers a full-width image on a 2x display in the widest content column ConfigBox templates use. |
QUALITY_WEBP | 82 | The usual sweet spot — visually indistinguishable, roughly a third of JPEG. |
QUALITY_SOURCE | 90 | Higher on purpose: that copy exists to be re-encoded from. |
MAX_UPLOAD_BYTES | 20 MB | PHP'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üche → grune-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.
| Refused | Why |
|---|---|
| Anything but jpg / jpeg / png / webp | See GIF below. |
| Bytes that disagree with the extension | Checked 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 decode | getimagesize() 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_BYTES | And 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.
srcsetneeds 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.
Related
- Property types — the
translatableproperty andUSE_HTMLEDITOR - Property type: image — the other image pipeline, per-record images with named mutations
- Kenedo controller — task dispatch and the
adminauthorization gate - API contract — the response envelope and the RFC 9457 problem shape