The online editor engines: TinyMCE 3 and TinyMCE 8
Read this page if your editors use the online editor (ezoe) to edit XML text (ezxmltext) attributes, or if you
want to offer TinyMCE 8 or your own editor. Since Exponential 6.0.15 ezoe runs on an editor engine chosen from a
registry. Nothing changes for an existing installation: the default stays TinyMCE 3. One server-side check is new:
the embed dialog's upload refuses some file types (see "Upload check").
In short
| What changed | Two engines ship: TinyMCE 3.5.12 (default) and TinyMCE 8.9.2 (opt-in, GPL-2.0-or-later, license_key: 'gpl', no cloud call). ezoe/upload checks file types. |
| Who is affected | Sites that switch to TinyMCE 8; editors who upload unusual file types through the embed dialog. |
| How to check | grep -n "EditorEngine|EngineSwitch|UploadExtensionCheck" extension/ezoe/settings/ezoe.ini |
| How to fix | Add missing upload types to UploadFileExtensions[]. Switch engines with EditorEngine or per user. |
Both engines write the same XHTML that eZOEXMLInput and eZOEInputParser read, so the stored XML is the same
whichever engine an editor uses.
Try TinyMCE 8 in one minute
- As an editor, open
/user/preferences/set/ezoe_engine/tinymce8. - Edit an article. The editor is TinyMCE 8.
- To go back, open
/ezoe/engineand choose TinyMCE 3, or reset to the site default.
To make TinyMCE 8 the default for everyone without a preference, set EditorEngine=tinymce8 (see "Settings").
Settings
File extension/ezoe/settings/ezoe.ini, block [EditorSettings]. Every key can be overridden per siteaccess as for
any INI file.
| Key | Default | Meaning |
|---|---|---|
EditorEngine | tinymce3 | The engine of every user without a preference. |
EngineSwitch | disabled | enabled shows the engine switch below the editor: a group named "Editor" with every available engine, toggle buttons, the one in use pressed (aria-pressed="true"), each other one a submit button that switches to it. |
Engines[<id>] | tinymce3, tinymce8 | The registry: identifier = class implementing expOEEditorEngine. |
UploadFileExtensions[] | images, pdf, office, text, zip, audio, video | File types of the embed dialog's upload tab. |
UploadExtensionCheck | engine | Server-side check of those types: engine (only when the user's engine is not tinymce3), always, disabled. |
The block [Engine_tinymce8] holds ToolbarMap[<[EditorLayout] button>]=<toolbar item> and
ExternalPlugins[<name>]=<design path>. The engine class reads them; they are not hard coded in the template.
Table dialog
[Engine_tinymce8] TableDialog=classic|modern picks the dialog of "Insert table" and "Table properties":
| Value | Dialog |
|---|---|
classic (default) | The design of the TinyMCE 3 dialog of tag_table.tpl: window title "New <table> tag" / "Edit <table> tag", Properties tab, the clickable 6 x 5 size grid with Columns and Rows (new tables only), Width and Border with a px / % selector, Class, then the custom attributes of content.ini [table] (Summary (WAI), Caption), OK and Cancel. Texts and help titles are the translations of design/standard/ezoe, so German needs no new strings. The dialog is a TinyMCE 8 window with a small adapter in plugins/eztable/plugin.js (the TinyMCE 3 page itself depends on tinyMCEPopup and the TinyMCE 3 API and does not run in TinyMCE 8). |
modern | The TinyMCE 8 form dialog (Rows, Columns, Width, Border, Class and the custom attributes as plain fields). |
Both write the same markup (class, width, border, customattributes), so the stored ezxml is identical. Cell and row
dialogs are not affected by the setting. The value reaches the editor as the option ez_table_dialog.
Which engine an editor gets
expOEEditor::resolve() takes the first of: the user preference ezoe_engine, the siteaccess EditorEngine, the
global EditorEngine, then tinymce3. An unknown, broken or unavailable choice is skipped, never an error.
Under an enforced Content Security Policy
TinyMCE 3 cannot run where the siteaccess enforces its Content Security Policy without 'unsafe-inline' in
script-src (site.ini [ContentSecurityPolicy] Mode=enforce): its edit template starts it with an inline script,
and tiny_mce.js and its dialogs write onclick attributes and javascript: links themselves, which neither a nonce
nor a hash can allow. There the engine is blocked for that request:
- a preference,
EditorEngineorForceEnginenamingtinymce3is skipped; when the configured engine is blocked, editors get the first engine of the registry that runs (TinyMCE 8 as shipped), so the field never stays without an editor; - the engine switch and the page
/ezoe/engineleave it out of the choices (the page lists it with the status "Cannot be used" and the reason), and choosing it is refused; - under report-only (the shipped
Mode=auto) and with'unsafe-inline'inscript-srcnothing changes.
An engine says it needs inline scripts with the optional method needsInlineScripts() (expOETinyMCE3Engine: true;
an engine built on expOEEditorEngineBase reads [Engine_<id>] NeedsInlineScripts=true, false when not set).
expOEEditor::blockedByPolicy( $engine ) and expOEEditor::inlineScriptsAllowed() (which asks
expContentSecurityPolicy::allowsInlineScripts()) are the checks. A siteaccess that must edit with TinyMCE 3 sets,
for itself only:
# settings/siteaccess/<siteaccess>/site.ini.append.php
[ContentSecurityPolicy]
Directives[script-src]='self' 'unsafe-inline'
Directives[style-src]='self' 'unsafe-inline'The kernel then sends no nonce in that script-src (a nonce would switch 'unsafe-inline' off in the browser).
A user chooses with:
- the page
/ezoe/engine(a table of every registered engine with its version, description and status, the ones that cannot be used included, and a select over the available ones; needs the policyezoe/editor); - the engine switch below the editor (
EngineSwitch=enabled); - the URL
/user/preferences/set/ezoe_engine/tinymce8; - the remote service
expeditor::set(POSTengine; empty means the site default).
Each choice through the page, the button or the service writes the audit event content.ezoe.engine.change
(channel content; before and after engine).
Upload check
- The embed dialog uploads through
ezoe/uploadandeZContentUpload(class set byupload.ini; access by the policyezoe/relationsandcanEdit()). - Since 6.0.15 that view refuses a file whose type is not in
UploadFileExtensions[](seeUploadExtensionCheck), and any name that contains an executable extension (shell.php.jpg), because a request can skip the dialog. - If an editor's upload is refused, add the type to
UploadFileExtensions[]in an override.
Upload from a URL
The Upload tab also takes the address of a file ("From a URL"; ezoe/upload field uploadUrl, class
expOEUrlFetcher). Settings in ezoe.ini [EditorSettings]: UploadFromUrl=enabled|disabled,
UploadFromUrlMaxSize=145M (only this limit, PHP's upload_max_filesize does not apply), UploadFromUrlTimeout=300.
Security: http/https only, no credentials, every resolved address must be public and is the one connected to,
at most 3 redirects each re-checked, size enforced while streaming to a temporary file under var/tmp/ezoe_url/,
extension checked against UploadFileExtensions[] and content checked with finfo. eZContentUpload::handleLocalFile()
got an optional $publish argument (default true) so the view can set the attributes before publishing, like
handleUpload(). The kernel change needs exp:velocity deploy --kernel. Tests: tests/tests/extension/ezoe/expOEUrlFetcherTest.php.
Other server-side notes
- The TinyMCE 8 plugins call only existing endpoints (ezoe views, ezjscore functions of ezoe). They add no ezjscore function and no new unauthenticated endpoint.
- The Exp Debug bar shows and changes
EditorEngineandEngineSwitch(group Extensions). setup/radcounts everyEngines[]entry as an extension point ("Online editor engines") and shows an entry whose class is missing or is not anexpOEEditorEngineas broken.
Remote services (ezjscore/call/expeditor::<service>)
| Service | Access | Writes | Returns |
|---|---|---|---|
engines | ezoe/editor | no | registered engines, the current one, the default, problems |
get | ezoe/editor | no | preference, engine in use, default, switch enabled |
set | ezoe/editor | POST + token | chooses the engine of the current user |
uploadExtensions | ezoe/editor | no | allowed extensions, check mode, whether it is enforced |
config | ezoe/editor | no | engine, toolbar map, plugins, buttons of the layout, upload types |
Extension points
- An engine: one class and one INI line.
- Toolbar items and plugins of an engine:
[Engine_<id>]ToolbarMap[]andExternalPlugins[]. - Upload types:
UploadFileExtensions[]. - Audit: the branch
expOEAuditBranch(audit.ini.append.php).
Example: add a third engine
// extension/myeditor/classes/myeditorengine.php
class myEditorEngine extends expOEEditorEngineBase
{
public function identifier() { return 'myeditor'; }
public function label() { return 'My editor'; } // translated in design/standard/ezoe
public function isAvailable() { return is_file( __DIR__ . '/../design/standard/javascript/my.js' ); }
public function template() { return 'design:content/datatype/edit/ezxmltext_myeditor.tpl'; }
public function version() { return '1.0.0'; } // optional, shown on /ezoe/engine
public function description() { return 'My own editor.'; } // optional, translated in design/standard/ezoe
}# extension/myeditor/settings/ezoe.ini.append.php
[EditorSettings]
Engines[myeditor]=myEditorEngine
[Engine_myeditor]
ToolbarMap[bold]=boldThen:
php bin/php/ezpgenerateautoloads.php -e
php bin/php/ezcache.php --clear-tag=ini --allow-root-userThe template receives attribute, input_handler, attribute_base and editorRow; $input_handler.engine gives
its identifier, label, toolbar map, plugins and config. The engine now appears on /ezoe/engine, in the switch
buttons, in expeditor::engines and in setup/rad. An engine that returns '' from template() is rendered by the
built-in TinyMCE 3 markup of ezxmltext_ezoe.tpl.
version() and description() are optional (they are not part of expOEEditorEngine, so engines written before
them keep working): expOEEditor::details() asks for them when an engine has them, and expOEEditorEngineBase
answers both from [Engine_<id>] Version and Description. /ezoe/engine shows them with the status of each
engine: available, not available (isAvailable() is false: its files are missing) or why it cannot be used.
Browse tabs (embed and link dialogs)
The Browse tabs navigate the whole content tree like the TinyMCE 3 dialogs: a row of the top level roots the user may
read (content, media and users root, and the object being edited once it is published), a path that starts at "Top level
nodes" (node 1) with a link for every level, an "Up" link to the parent, paging inside large folders and selecting an
object from any level. The roots come from ez_settings.browse_roots, built in the template from content.ini
[NodeSettings] and the ezoe/browse policy.
Bookmarks tab (link dialog)
The "Insert/edit link" dialog has a Bookmarks tab like the embed dialog and the TinyMCE 3 link dialog. It lists the
bookmarks of the current user through ezoe::bookmarks (paged, with the same preview column as Search and Browse).
Choosing a row fills the link target with eznode://<node id> (ezobject://<object id> when "Link to the object
instead of the node" is checked), exactly like Search and Browse, so the stored ezxml is the same
<link node_id="..."> that TinyMCE 3 stores for a node link. Without bookmarks the tab says "You have no bookmarks"
and how to add one ("Add to bookmarks" on a content item in the administration interface). The Browse tab keeps its
full-tree navigation. The texts are in the plugin's German translation (ezlink/plugin.js); the server side
message reuses the existing design/standard/content/view translations.
Upstream code
The TinyMCE 8 distribution is vendored unmodified in extension/ezoe/design/standard/javascript/tinymce8/ (README,
license and notices included); only German is bundled. The browser tests are in extension/ezoe/tests/tinymce8/
(npm install && npm test).
Tests
php vendor/bin/phpunit tests/tests/extension/ezoe/The tests run against the live installation, with no test database. Every test puts the admin's engine preference and the INI values back.
| File | Covers |
|---|---|
expOEEditorRegistryTest | registry, resolution (preference, siteaccess, global), unknown / broken / unavailable engines, third engine, the details (version, description, status) of every entry, audit, setup/rad |
expOEContentSecurityPolicyEngineTest | an engine that needs inline scripts (TinyMCE 3) under an enforced policy: fallback of preference, configuration and ForceEngine, left out of the switch and the engine page, choosing it refused; unchanged when allowed |
expOEEngineConfigTest | each engine's template, toolbar map, plugins and config, shipped defaults, vendored TinyMCE 8 and its license, translations |
expOEUploadRulesTest | allowed and refused file names, the three check modes, the order of the check in the upload view |
expOEViewsJsonTest | every ezoe PHP file has no text before <?php or after ?>, the module definition prints nothing, ezoe/load answers JSON with nothing in front of it, browse and bookmarks |
expOEEngineViewTest | the page /ezoe/engine: the table of engines, select, saving, reset, unknown engine, policy |
expOEEditorServicesTest | the expeditor services, their guards and the catalogue |
expOERoundTripTest | the XHTML of both editors gives the same XML; XML shown to an editor and saved again keeps its content |
tests/tests/extension/ezoe/helpers/run_ezoe_view.php runs one view in a process of its own, because a view that ends
with cleanExit() cannot run inside PHPUnit.
A view or module definition with text outside the PHP tags breaks the editor's dialogs (JSON.parse: unexpected character on the final OK of the embed dialog). That is why the tests look at the raw output.