first commit

This commit is contained in:
2024-07-15 12:33:27 +02:00
commit ce50ae282b
22084 changed files with 2623791 additions and 0 deletions

View File

@@ -0,0 +1,130 @@
<?php
/**
* @file
* Administration functions for editor.module.
*/
use Drupal\Component\Utility\Environment;
use Drupal\Core\StreamWrapper\StreamWrapperInterface;
use Drupal\Core\StringTranslation\ByteSizeMarkup;
use Drupal\editor\Entity\Editor;
/**
* Subform constructor to configure the text editor's image upload settings.
*
* Each text editor plugin that is configured to offer the ability to insert
* images and uses EditorImageDialog for that, should use this form to update
* the text editor's configuration so that EditorImageDialog knows whether it
* should allow the user to upload images.
*
* @param \Drupal\editor\Entity\Editor $editor
* The text editor entity that is being edited.
*
* @return array
* The image upload settings form.
*
* @see \Drupal\editor\Form\EditorImageDialog
*/
function editor_image_upload_settings_form(Editor $editor) {
// Defaults.
$image_upload = $editor->getImageUploadSettings();
$image_upload += [
'status' => FALSE,
'scheme' => \Drupal::config('system.file')->get('default_scheme'),
'directory' => 'inline-images',
'max_size' => '',
'max_dimensions' => ['width' => '', 'height' => ''],
];
$form['status'] = [
'#type' => 'checkbox',
'#title' => t('Enable image uploads'),
'#default_value' => $image_upload['status'],
'#attributes' => [
'data-editor-image-upload' => 'status',
],
'#description' => t('When enabled, images can only be uploaded. When disabled, images can only be added by URL.'),
];
$show_if_image_uploads_enabled = [
'visible' => [
':input[data-editor-image-upload="status"]' => ['checked' => TRUE],
],
];
// Any visible, writable wrapper can potentially be used for uploads,
// including a remote file system that integrates with a CDN.
$options = \Drupal::service('stream_wrapper_manager')->getDescriptions(StreamWrapperInterface::WRITE_VISIBLE);
if (!empty($options)) {
$form['scheme'] = [
'#type' => 'radios',
'#title' => t('File storage'),
'#default_value' => $image_upload['scheme'],
'#options' => $options,
'#states' => $show_if_image_uploads_enabled,
'#access' => count($options) > 1,
];
}
// Set data- attributes with human-readable names for all possible stream
// wrappers, so that it can be used by the summary rendering of other code.
foreach (\Drupal::service('stream_wrapper_manager')->getNames(StreamWrapperInterface::WRITE_VISIBLE) as $scheme => $name) {
$form['scheme'][$scheme]['#attributes']['data-label'] = t('Storage: @name', ['@name' => $name]);
}
$form['directory'] = [
'#type' => 'textfield',
'#default_value' => $image_upload['directory'],
'#title' => t('Upload directory'),
'#description' => t("A directory relative to Drupal's files directory where uploaded images will be stored."),
'#states' => $show_if_image_uploads_enabled,
];
$default_max_size = ByteSizeMarkup::create(Environment::getUploadMaxSize());
$form['max_size'] = [
'#type' => 'textfield',
'#default_value' => $image_upload['max_size'],
'#title' => t('Maximum file size'),
'#description' => t('If this is left empty, then the file size will be limited by the PHP maximum upload size of @size.', ['@size' => $default_max_size]),
'#maxlength' => 20,
'#size' => 10,
'#placeholder' => $default_max_size,
'#states' => $show_if_image_uploads_enabled,
];
$form['max_dimensions'] = [
'#type' => 'item',
'#title' => t('Maximum dimensions'),
'#description' => t('Images larger than these dimensions will be scaled down.'),
'#states' => $show_if_image_uploads_enabled,
];
$form['max_dimensions']['width'] = [
'#title' => t('Width'),
'#title_display' => 'invisible',
'#type' => 'number',
'#default_value' => (empty($image_upload['max_dimensions']['width'])) ? '' : $image_upload['max_dimensions']['width'],
'#size' => 8,
'#maxlength' => 8,
'#min' => 1,
'#max' => 99999,
'#placeholder' => t('width'),
'#field_suffix' => ' x ',
'#states' => $show_if_image_uploads_enabled,
'#prefix' => '<div class="form--inline clearfix">',
];
$form['max_dimensions']['height'] = [
'#title' => t('Height'),
'#title_display' => 'invisible',
'#type' => 'number',
'#default_value' => (empty($image_upload['max_dimensions']['height'])) ? '' : $image_upload['max_dimensions']['height'],
'#size' => 8,
'#maxlength' => 8,
'#min' => 1,
'#max' => 99999,
'#placeholder' => t('height'),
'#field_suffix' => t('pixels'),
'#states' => $show_if_image_uploads_enabled,
'#suffix' => '</div>',
];
return $form;
}

View File

@@ -0,0 +1,70 @@
<?php
/**
* @file
* Documentation for Text Editor API.
*/
use Drupal\filter\FilterFormatInterface;
/**
* @addtogroup hooks
* @{
*/
/**
* Performs alterations on text editor definitions.
*
* @param array $editors
* An array of metadata of text editors, as collected by the plugin annotation
* discovery mechanism.
*
* @see \Drupal\editor\Plugin\EditorBase
*/
function hook_editor_info_alter(array &$editors) {
$editors['some_other_editor']['label'] = t('A different name');
$editors['some_other_editor']['library']['module'] = 'my_editor_override';
}
/**
* Modifies JavaScript settings that are added for text editors.
*
* @param array $settings
* All the settings that will be added to the page for the text formats to
* which a user has access.
*/
function hook_editor_js_settings_alter(array &$settings) {
if (isset($settings['editor']['formats']['basic_html'])) {
$settings['editor']['formats']['basic_html']['editor'] = 'MyDifferentEditor';
$settings['editor']['formats']['basic_html']['editorSettings']['buttons'] = ['strong', 'italic', 'underline'];
}
}
/**
* Modifies the text editor XSS filter that will used for the given text format.
*
* Is only called when an EditorXssFilter will effectively be used; this hook
* does not allow one to alter that decision.
*
* @param string &$editor_xss_filter_class
* The text editor XSS filter class that will be used.
* @param \Drupal\filter\FilterFormatInterface $format
* The text format configuration entity. Provides context based upon which
* one may want to adjust the filtering.
* @param \Drupal\filter\FilterFormatInterface|null $original_format
* (optional) The original text format configuration entity (when switching
* text formats/editors). Also provides context based upon which one may want
* to adjust the filtering.
*
* @see \Drupal\editor\EditorXssFilterInterface
*/
function hook_editor_xss_filter_alter(&$editor_xss_filter_class, FilterFormatInterface $format, ?FilterFormatInterface $original_format = NULL) {
$filters = $format->filters()->getAll();
if (isset($filters['filter_wysiwyg']) && $filters['filter_wysiwyg']->status) {
$editor_xss_filter_class = '\Drupal\filter_wysiwyg\EditorXssFilter\WysiwygFilter';
}
}
/**
* @} End of "addtogroup hooks".
*/

View File

@@ -0,0 +1,14 @@
name: 'Text Editor'
type: module
description: 'Provides a framework to associate text editors (like WYSIWYGs) and toolbars with text formats.'
package: Core
# version: VERSION
dependencies:
- drupal:filter
- drupal:file
configure: filter.admin_overview
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,13 @@
<?php
/**
* @file
* Install, update and uninstall functions for the Editor module.
*/
/**
* Implements hook_update_last_removed().
*/
function editor_update_last_removed() {
return 8001;
}

View File

@@ -0,0 +1,30 @@
drupal.editor.admin:
version: VERSION
js:
js/editor.admin.js: {}
dependencies:
- core/jquery
- core/once
- core/drupal
deprecated: The %library_id% asset library is deprecated in Drupal 10.3.0 and will be removed in Drupal 11.0.0. See https://www.drupal.org/node/3422372
drupal.editor:
version: VERSION
js:
js/editor.js: {}
dependencies:
- core/jquery
- core/drupal
- core/drupalSettings
- core/once
- core/drupal.dialog
drupal.editor.dialog:
version: VERSION
js:
js/editor.dialog.js: {}
dependencies:
- core/jquery
- core/drupal.dialog
- core/drupal.ajax
- core/drupalSettings

686
core/modules/editor/editor.module Executable file
View File

@@ -0,0 +1,686 @@
<?php
/**
* @file
* Adds bindings for client-side "text editors" to text formats.
*/
use Drupal\Core\Url;
use Drupal\Component\Utility\Html;
use Drupal\Core\Form\SubformState;
use Drupal\editor\EditorInterface;
use Drupal\editor\Entity\Editor;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Element;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Entity\EntityInterface;
use Drupal\filter\FilterFormatInterface;
use Drupal\filter\Plugin\FilterInterface;
use Drupal\text\Plugin\Field\FieldType\TextItemBase;
/**
* Implements hook_help().
*/
function editor_help($route_name, RouteMatchInterface $route_match) {
switch ($route_name) {
case 'help.page.editor':
$output = '';
$output .= '<h2>' . t('About') . '</h2>';
$output .= '<p>' . t('The Text Editor module provides a framework that other modules (such as <a href=":ckeditor5">CKEditor5 module</a>) can use to provide toolbars and other functionality that allow users to format text more easily than typing HTML tags directly. For more information, see the <a href=":documentation">online documentation for the Text Editor module</a>.', [':documentation' => 'https://www.drupal.org/documentation/modules/editor', ':ckeditor5' => (\Drupal::moduleHandler()->moduleExists('ckeditor5')) ? Url::fromRoute('help.page', ['name' => 'ckeditor5'])->toString() : '#']) . '</p>';
$output .= '<h2>' . t('Uses') . '</h2>';
$output .= '<dl>';
$output .= '<dt>' . t('Installing text editors') . '</dt>';
$output .= '<dd>' . t('The Text Editor module provides a framework for managing editors. To use it, you also need to install a text editor. This can either be the core <a href=":ckeditor5">CKEditor5 module</a>, which can be installed on the <a href=":extend">Extend page</a>, or a contributed module for any other text editor. When installing a contributed text editor module, be sure to check the installation instructions, because you will most likely need to download an external library as well as the Drupal module.', [':ckeditor5' => (\Drupal::moduleHandler()->moduleExists('ckeditor5')) ? Url::fromRoute('help.page', ['name' => 'ckeditor5'])->toString() : '#', ':extend' => Url::fromRoute('system.modules_list')->toString()]) . '</dd>';
$output .= '<dt>' . t('Enabling a text editor for a text format') . '</dt>';
$output .= '<dd>' . t('On the <a href=":formats">Text formats and editors page</a> you can see which text editor is associated with each text format. You can change this by clicking on the <em>Configure</em> link, and then choosing a text editor or <em>none</em> from the <em>Text editor</em> drop-down list. The text editor will then be displayed with any text field for which this text format is chosen.', [':formats' => Url::fromRoute('filter.admin_overview')->toString()]) . '</dd>';
$output .= '<dt>' . t('Configuring a text editor') . '</dt>';
$output .= '<dd>' . t('Once a text editor is associated with a text format, you can configure it by clicking on the <em>Configure</em> link for this format. Depending on the specific text editor, you can configure it for example by adding buttons to its toolbar. Typically these buttons provide formatting or editing tools, and they often insert HTML tags into the field source. For details, see the help page of the specific text editor.') . '</dd>';
$output .= '<dt>' . t('Using different text editors and formats') . '</dt>';
$output .= '<dd>' . t('If you change the text format on a text field, the text editor will change as well because the text editor configuration is associated with the individual text format. This allows the use of the same text editor with different options for different text formats. It also allows users to choose between text formats with different text editors if they are installed.') . '</dd>';
$output .= '</dl>';
return $output;
}
}
/**
* Implements hook_menu_links_discovered_alter().
*
* Rewrites the menu entries for filter module that relate to the configuration
* of text editors.
*/
function editor_menu_links_discovered_alter(array &$links) {
$links['filter.admin_overview']['title'] = new TranslatableMarkup('Text formats and editors');
$links['filter.admin_overview']['description'] = new TranslatableMarkup('Select and configure text editors, and how content is filtered when displayed.');
}
/**
* Implements hook_element_info_alter().
*
* Extends the functionality of text_format elements (provided by Filter
* module), so that selecting a text format notifies a client-side text editor
* when it should be enabled or disabled.
*
* @see \Drupal\filter\Element\TextFormat
*/
function editor_element_info_alter(&$types) {
$types['text_format']['#pre_render'][] = 'element.editor:preRenderTextFormat';
}
/**
* Implements hook_form_FORM_ID_alter().
*/
function editor_form_filter_admin_overview_alter(&$form, FormStateInterface $form_state) {
// @todo Cleanup column injection: https://www.drupal.org/node/1876718.
// Splice in the column for "Text editor" into the header.
$position = array_search('name', $form['formats']['#header']) + 1;
$start = array_splice($form['formats']['#header'], 0, $position, ['editor' => t('Text editor')]);
$form['formats']['#header'] = array_merge($start, $form['formats']['#header']);
// Then splice in the name of each text editor for each text format.
$editors = \Drupal::service('plugin.manager.editor')->getDefinitions();
foreach (Element::children($form['formats']) as $format_id) {
$editor = editor_load($format_id);
$editor_name = ($editor && isset($editors[$editor->getEditor()])) ? $editors[$editor->getEditor()]['label'] : '—';
$editor_column['editor'] = ['#markup' => $editor_name];
$position = array_search('name', array_keys($form['formats'][$format_id])) + 1;
$start = array_splice($form['formats'][$format_id], 0, $position, $editor_column);
$form['formats'][$format_id] = array_merge($start, $form['formats'][$format_id]);
}
}
/**
* Implements hook_form_BASE_FORM_ID_alter() for \Drupal\filter\FilterFormatEditForm.
*/
function editor_form_filter_format_form_alter(&$form, FormStateInterface $form_state) {
$editor = $form_state->get('editor');
if ($editor === NULL) {
$format = $form_state->getFormObject()->getEntity();
$format_id = $format->isNew() ? NULL : $format->id();
$editor = editor_load($format_id);
$form_state->set('editor', $editor);
}
// Associate a text editor with this text format.
$manager = \Drupal::service('plugin.manager.editor');
$editor_options = $manager->listOptions();
$form['editor'] = [
// Position the editor selection before the filter settings (weight of 0),
// but after the filter label and name (weight of -20).
'#weight' => -9,
];
$form['editor']['editor'] = [
'#type' => 'select',
'#title' => t('Text editor'),
'#options' => $editor_options,
'#empty_option' => t('None'),
'#default_value' => $editor ? $editor->getEditor() : '',
'#ajax' => [
'trigger_as' => ['name' => 'editor_configure'],
'callback' => 'editor_form_filter_admin_form_ajax',
'wrapper' => 'editor-settings-wrapper',
],
'#weight' => -10,
];
$form['editor']['configure'] = [
'#type' => 'submit',
'#name' => 'editor_configure',
'#value' => t('Configure'),
'#limit_validation_errors' => [['editor']],
'#submit' => ['editor_form_filter_admin_format_editor_configure'],
'#ajax' => [
'callback' => 'editor_form_filter_admin_form_ajax',
'wrapper' => 'editor-settings-wrapper',
],
'#weight' => -10,
'#attributes' => ['class' => ['js-hide']],
];
// If there aren't any options (other than "None"), disable the select list.
if (empty($editor_options)) {
$form['editor']['editor']['#disabled'] = TRUE;
$form['editor']['editor']['#description'] = t('This option is disabled because no modules that provide a text editor are currently enabled.');
}
$form['editor']['settings'] = [
'#tree' => TRUE,
'#weight' => -8,
'#type' => 'container',
'#id' => 'editor-settings-wrapper',
];
// Add editor-specific validation and submit handlers.
if ($editor) {
/** @var \Drupal\editor\Plugin\EditorPluginInterface $plugin */
$plugin = $manager->createInstance($editor->getEditor());
$form_state->set('editor_plugin', $plugin);
$form['editor']['settings']['subform'] = [];
$subform_state = SubformState::createForSubform($form['editor']['settings']['subform'], $form, $form_state);
$form['editor']['settings']['subform'] = $plugin->buildConfigurationForm($form['editor']['settings']['subform'], $subform_state);
$form['editor']['settings']['subform']['#parents'] = ['editor', 'settings'];
}
$form['#validate'][] = 'editor_form_filter_admin_format_validate';
$form['actions']['submit']['#submit'][] = 'editor_form_filter_admin_format_submit';
}
/**
* Button submit handler for filter_format_form()'s 'editor_configure' button.
*/
function editor_form_filter_admin_format_editor_configure($form, FormStateInterface $form_state) {
$editor = $form_state->get('editor');
$editor_value = $form_state->getValue(['editor', 'editor']);
if ($editor_value !== NULL) {
if ($editor_value === '') {
$form_state->set('editor', FALSE);
$form_state->set('editor_plugin', NULL);
}
elseif (empty($editor) || $editor_value !== $editor->getEditor()) {
$format = $form_state->getFormObject()->getEntity();
$editor = Editor::create([
'format' => $format->isNew() ? NULL : $format->id(),
'editor' => $editor_value,
]);
$form_state->set('editor', $editor);
}
}
$form_state->setRebuild();
}
/**
* AJAX callback handler for filter_format_form().
*/
function editor_form_filter_admin_form_ajax($form, FormStateInterface $form_state) {
return $form['editor']['settings'];
}
/**
* Additional validate handler for filter_format_form().
*/
function editor_form_filter_admin_format_validate($form, FormStateInterface $form_state) {
$editor_set = $form_state->getValue(['editor', 'editor']) !== "";
$subform_array_exists = (!empty($form['editor']['settings']['subform']) && is_array($form['editor']['settings']['subform']));
if ($editor_set && $subform_array_exists && $editor_plugin = $form_state->get('editor_plugin')) {
$subform_state = SubformState::createForSubform($form['editor']['settings']['subform'], $form, $form_state);
$editor_plugin->validateConfigurationForm($form['editor']['settings']['subform'], $subform_state);
}
// This validate handler is not applicable when using the 'Configure' button.
if ($form_state->getTriggeringElement()['#name'] === 'editor_configure') {
return;
}
// When using this form with JavaScript disabled in the browser, the
// 'Configure' button won't be clicked automatically. So, when the user has
// selected a text editor and has then clicked 'Save configuration', we should
// point out that the user must still configure the text editor.
if ($form_state->getValue(['editor', 'editor']) !== '' && !$form_state->get('editor')) {
$form_state->setErrorByName('editor][editor', t('You must configure the selected text editor.'));
}
}
/**
* Additional submit handler for filter_format_form().
*/
function editor_form_filter_admin_format_submit($form, FormStateInterface $form_state) {
// Delete the existing editor if disabling or switching between editors.
$format = $form_state->getFormObject()->getEntity();
$format_id = $format->isNew() ? NULL : $format->id();
$original_editor = editor_load($format_id);
if ($original_editor && $original_editor->getEditor() != $form_state->getValue(['editor', 'editor'])) {
$original_editor->delete();
}
$editor_set = $form_state->getValue(['editor', 'editor']) !== "";
$subform_array_exists = (!empty($form['editor']['settings']['subform']) && is_array($form['editor']['settings']['subform']));
if (($editor_plugin = $form_state->get('editor_plugin')) && $editor_set && $subform_array_exists) {
$subform_state = SubformState::createForSubform($form['editor']['settings']['subform'], $form, $form_state);
$editor_plugin->submitConfigurationForm($form['editor']['settings']['subform'], $subform_state);
}
// Create a new editor or update the existing editor.
if ($editor = $form_state->get('editor')) {
// Ensure the text format is set: when creating a new text format, this
// would equal the empty string.
$editor->set('format', $format_id);
if ($settings = $form_state->getValue(['editor', 'settings'])) {
$editor->setSettings($settings);
}
// When image uploads are disabled (status = FALSE), the schema for image
// upload settings does not allow other keys to be present.
// @see editor.image_upload_settings.*
// @see editor.image_upload_settings.1
// @see editor.schema.yml
$image_upload_settings = $editor->getImageUploadSettings();
if (!$image_upload_settings['status']) {
$editor->setImageUploadSettings(['status' => FALSE]);
}
$editor->save();
}
}
/**
* Loads an individual configured text editor based on text format ID.
*
* @param int $format_id
* A text format ID.
*
* @return \Drupal\editor\Entity\Editor|null
* A text editor object, or NULL.
*/
function editor_load($format_id) {
// Load all the editors at once here, assuming that either no editors or more
// than one editor will be needed on a page (such as having multiple text
// formats for administrators). Loading a small number of editors all at once
// is more efficient than loading multiple editors individually.
$editors = Editor::loadMultiple();
return $editors[$format_id] ?? NULL;
}
/**
* Applies text editor XSS filtering.
*
* @param string $html
* The HTML string that will be passed to the text editor.
* @param \Drupal\filter\FilterFormatInterface|null $format
* The text format whose text editor will be used or NULL if the previously
* defined text format is now disabled.
* @param \Drupal\filter\FilterFormatInterface|null $original_format
* (optional) The original text format (i.e. when switching text formats,
* $format is the text format that is going to be used, $original_format is
* the one that was being used initially, the one that is stored in the
* database when editing).
*
* @return string|false
* The XSS filtered string or FALSE when no XSS filtering needs to be applied,
* because one of the next conditions might occur:
* - No text editor is associated with the text format,
* - The previously defined text format is now disabled,
* - The text editor is safe from XSS,
* - The text format does not use any XSS protection filters.
*
* @see https://www.drupal.org/node/2099741
*/
function editor_filter_xss($html, ?FilterFormatInterface $format = NULL, ?FilterFormatInterface $original_format = NULL) {
$editor = $format ? editor_load($format->id()) : NULL;
// If no text editor is associated with this text format or the previously
// defined text format is now disabled, then we don't need text editor XSS
// filtering either.
if (!isset($editor)) {
return FALSE;
}
// If the text editor associated with this text format guarantees security,
// then we also don't need text editor XSS filtering.
$definition = \Drupal::service('plugin.manager.editor')->getDefinition($editor->getEditor());
if ($definition['is_xss_safe'] === TRUE) {
return FALSE;
}
// If there is no filter preventing XSS attacks in the text format being used,
// then no text editor XSS filtering is needed either. (Because then the
// editing user can already be attacked by merely viewing the content.)
// e.g.: an admin user creates content in Full HTML and then edits it, no text
// format switching happens; in this case, no text editor XSS filtering is
// desirable, because it would strip style attributes, amongst others.
$current_filter_types = $format->getFilterTypes();
if (!in_array(FilterInterface::TYPE_HTML_RESTRICTOR, $current_filter_types, TRUE)) {
if ($original_format === NULL) {
return FALSE;
}
// Unless we are switching from another text format, in which case we must
// first check whether a filter preventing XSS attacks is used in that text
// format, and if so, we must still apply XSS filtering.
// e.g.: an anonymous user creates content in Restricted HTML, an admin user
// edits it (then no XSS filtering is applied because no text editor is
// used), and switches to Full HTML (for which a text editor is used). Then
// we must apply XSS filtering to protect the admin user.
else {
$original_filter_types = $original_format->getFilterTypes();
if (!in_array(FilterInterface::TYPE_HTML_RESTRICTOR, $original_filter_types, TRUE)) {
return FALSE;
}
}
}
// Otherwise, apply the text editor XSS filter. We use the default one unless
// a module tells us to use a different one.
$editor_xss_filter_class = '\Drupal\editor\EditorXssFilter\Standard';
\Drupal::moduleHandler()->alter('editor_xss_filter', $editor_xss_filter_class, $format, $original_format);
return call_user_func($editor_xss_filter_class . '::filterXss', $html, $format, $original_format);
}
/**
* Implements hook_entity_insert().
*/
function editor_entity_insert(EntityInterface $entity) {
// Only act on content entities.
if (!($entity instanceof FieldableEntityInterface)) {
return;
}
$referenced_files_by_field = _editor_get_file_uuids_by_field($entity);
foreach ($referenced_files_by_field as $uuids) {
_editor_record_file_usage($uuids, $entity);
}
}
/**
* Implements hook_entity_update().
*/
function editor_entity_update(EntityInterface $entity) {
// Only act on content entities.
if (!($entity instanceof FieldableEntityInterface)) {
return;
}
// On new revisions, all files are considered to be a new usage and no
// deletion of previous file usages are necessary.
if (!empty($entity->original) && $entity->getRevisionId() != $entity->original->getRevisionId()) {
$referenced_files_by_field = _editor_get_file_uuids_by_field($entity);
foreach ($referenced_files_by_field as $uuids) {
_editor_record_file_usage($uuids, $entity);
}
}
// On modified revisions, detect which file references have been added (and
// record their usage) and which ones have been removed (delete their usage).
// File references that existed both in the previous version of the revision
// and in the new one don't need their usage to be updated.
else {
$original_uuids_by_field = empty($entity->original) ? [] :
_editor_get_file_uuids_by_field($entity->original);
$uuids_by_field = _editor_get_file_uuids_by_field($entity);
// Detect file usages that should be incremented.
foreach ($uuids_by_field as $field => $uuids) {
$original_uuids = $original_uuids_by_field[$field] ?? [];
if ($added_files = array_diff($uuids_by_field[$field], $original_uuids)) {
_editor_record_file_usage($added_files, $entity);
}
}
// Detect file usages that should be decremented.
foreach ($original_uuids_by_field as $field => $uuids) {
$removed_files = array_diff($original_uuids_by_field[$field], $uuids_by_field[$field]);
_editor_delete_file_usage($removed_files, $entity, 1);
}
}
}
/**
* Implements hook_entity_delete().
*/
function editor_entity_delete(EntityInterface $entity) {
// Only act on content entities.
if (!($entity instanceof FieldableEntityInterface)) {
return;
}
$referenced_files_by_field = _editor_get_file_uuids_by_field($entity);
foreach ($referenced_files_by_field as $uuids) {
_editor_delete_file_usage($uuids, $entity, 0);
}
}
/**
* Implements hook_entity_revision_delete().
*/
function editor_entity_revision_delete(EntityInterface $entity) {
// Only act on content entities.
if (!($entity instanceof FieldableEntityInterface)) {
return;
}
$referenced_files_by_field = _editor_get_file_uuids_by_field($entity);
foreach ($referenced_files_by_field as $uuids) {
_editor_delete_file_usage($uuids, $entity, 1);
}
}
/**
* Records file usage of files referenced by formatted text fields.
*
* Every referenced file that is temporally saved will be resaved as permanent.
*
* @param array $uuids
* An array of file entity UUIDs.
* @param \Drupal\Core\Entity\EntityInterface $entity
* An entity whose fields to inspect for file references.
*/
function _editor_record_file_usage(array $uuids, EntityInterface $entity) {
foreach ($uuids as $uuid) {
if ($file = \Drupal::service('entity.repository')->loadEntityByUuid('file', $uuid)) {
/** @var \Drupal\file\FileInterface $file */
if ($file->isTemporary()) {
$file->setPermanent();
$file->save();
}
\Drupal::service('file.usage')->add($file, 'editor', $entity->getEntityTypeId(), $entity->id());
}
}
}
/**
* Deletes file usage of files referenced by formatted text fields.
*
* @param array $uuids
* An array of file entity UUIDs.
* @param \Drupal\Core\Entity\EntityInterface $entity
* An entity whose fields to inspect for file references.
* @param $count
* The number of references to delete. Should be 1 when deleting a single
* revision and 0 when deleting an entity entirely.
*
* @see \Drupal\file\FileUsage\FileUsageInterface::delete()
*/
function _editor_delete_file_usage(array $uuids, EntityInterface $entity, $count) {
foreach ($uuids as $uuid) {
if ($file = \Drupal::service('entity.repository')->loadEntityByUuid('file', $uuid)) {
\Drupal::service('file.usage')->delete($file, 'editor', $entity->getEntityTypeId(), $entity->id(), $count);
}
}
}
/**
* Implements hook_file_download().
*
* @see file_file_download()
* @see file_get_file_references()
*/
function editor_file_download($uri) {
// Get the file record based on the URI. If not in the database just return.
/** @var \Drupal\file\FileRepositoryInterface $file_repository */
$file_repository = \Drupal::service('file.repository');
$file = $file_repository->loadByUri($uri);
if (!$file) {
return;
}
// Temporary files are handled by file_file_download(), so nothing to do here
// about them.
// @see file_file_download()
// Find out if any editor-backed field contains the file.
$usage_list = \Drupal::service('file.usage')->listUsage($file);
// Stop processing if there are no references in order to avoid returning
// headers for files controlled by other modules. Make an exception for
// temporary files where the host entity has not yet been saved (for example,
// an image preview on a node creation form) in which case, allow download by
// the file's owner.
if (empty($usage_list['editor']) && ($file->isPermanent() || $file->getOwnerId() != \Drupal::currentUser()->id())) {
return;
}
// Editor.module MUST NOT call $file->access() here (like file_file_download()
// does) as checking the 'download' access to a file entity would end up in
// FileAccessControlHandler->checkAccess() and ->getFileReferences(), which
// calls file_get_file_references(). This latter one would allow downloading
// files only handled by the file.module, which is exactly not the case right
// here. So instead we must check if the current user is allowed to view any
// of the entities that reference the image using the 'editor' module.
if ($file->isPermanent()) {
$referencing_entity_is_accessible = FALSE;
$references = empty($usage_list['editor']) ? [] : $usage_list['editor'];
foreach ($references as $entity_type => $entity_ids_usage_count) {
$referencing_entities = \Drupal::entityTypeManager()->getStorage($entity_type)->loadMultiple(array_keys($entity_ids_usage_count));
/** @var \Drupal\Core\Entity\EntityInterface $referencing_entity */
foreach ($referencing_entities as $referencing_entity) {
if ($referencing_entity->access('view', NULL, TRUE)->isAllowed()) {
$referencing_entity_is_accessible = TRUE;
break 2;
}
}
}
if (!$referencing_entity_is_accessible) {
return -1;
}
}
// Access is granted.
$headers = file_get_content_headers($file);
return $headers;
}
/**
* Finds all files referenced (data-entity-uuid) by formatted text fields.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* An entity whose fields to analyze.
*
* @return array
* An array of file entity UUIDs.
*/
function _editor_get_file_uuids_by_field(EntityInterface $entity) {
$uuids = [];
$formatted_text_fields = _editor_get_formatted_text_fields($entity);
foreach ($formatted_text_fields as $formatted_text_field) {
$text = '';
$field_items = $entity->get($formatted_text_field);
foreach ($field_items as $field_item) {
$text .= $field_item->value;
if ($field_item->getFieldDefinition()->getType() == 'text_with_summary') {
$text .= $field_item->summary;
}
}
$uuids[$formatted_text_field] = _editor_parse_file_uuids($text);
}
return $uuids;
}
/**
* Determines the formatted text fields on an entity.
*
* A field type is considered to provide formatted text if its class is a
* subclass of Drupal\text\Plugin\Field\FieldType\TextItemBase.
*
* @param \Drupal\Core\Entity\FieldableEntityInterface $entity
* An entity whose fields to analyze.
*
* @return array
* The names of the fields on this entity that support formatted text.
*/
function _editor_get_formatted_text_fields(FieldableEntityInterface $entity) {
$field_definitions = $entity->getFieldDefinitions();
if (empty($field_definitions)) {
return [];
}
// Only return formatted text fields.
// @todo improve as part of https://www.drupal.org/node/2732429
$field_type_manager = \Drupal::service('plugin.manager.field.field_type');
return array_keys(array_filter($field_definitions, function (FieldDefinitionInterface $definition) use ($field_type_manager) {
$type = $definition->getType();
$plugin_class = $field_type_manager->getPluginClass($type);
return is_subclass_of($plugin_class, TextItemBase::class);
}));
}
/**
* Parse an HTML snippet for any linked file with data-entity-uuid attributes.
*
* @param string $text
* The partial (X)HTML snippet to load. Invalid markup will be corrected on
* import.
*
* @return array
* An array of all found UUIDs.
*/
function _editor_parse_file_uuids($text) {
$dom = Html::load($text);
$xpath = new \DOMXPath($dom);
$uuids = [];
foreach ($xpath->query('//*[@data-entity-type="file" and @data-entity-uuid]') as $node) {
$uuids[] = $node->getAttribute('data-entity-uuid');
}
return $uuids;
}
/**
* Implements hook_ENTITY_TYPE_presave().
*
* Synchronizes the editor status to its paired text format status.
*/
function editor_filter_format_presave(FilterFormatInterface $format) {
// The text format being created cannot have a text editor yet.
if ($format->isNew()) {
return;
}
/** @var \Drupal\filter\FilterFormatInterface $original */
$original = \Drupal::entityTypeManager()
->getStorage('filter_format')
->loadUnchanged($format->getOriginalId());
// If the text format status is the same, return early.
if (($status = $format->status()) === $original->status()) {
return;
}
/** @var \Drupal\editor\EditorInterface $editor */
if ($editor = Editor::load($format->id())) {
$editor->setStatus($status)->save();
}
}
/**
* Implements hook_ENTITY_TYPE_presave().
*/
function editor_editor_presave(EditorInterface $editor) {
// @see editor_post_update_sanitize_image_upload_settings()
$image_upload_settings = $editor->getImageUploadSettings();
// When image uploads are disabled, then none of the other key-value pairs
// make sense.
// TRICKY: the configuration system has historically stored `type: boolean`
// not as `true` and `false`, but as `1` and `0`, so use `==`, not `===`.
// @see editor_post_update_sanitize_image_upload_settings()
if (!array_key_exists('status', $image_upload_settings) || $image_upload_settings['status'] == FALSE) {
$editor->setImageUploadSettings(['status' => FALSE]);
}
else {
// When image uploads are enabled, then some of the key-value pairs need
// some conversions to comply with the config schema. Note that all these
// keys SHOULD exist, but because validation has historically been absent,
// err on the side of caution.
// @see editor_post_update_sanitize_image_upload_settings()
if (array_key_exists('directory', $image_upload_settings) && $image_upload_settings['directory'] === '') {
$image_upload_settings['directory'] = NULL;
}
if (array_key_exists('max_size', $image_upload_settings) && $image_upload_settings['max_size'] === '') {
$image_upload_settings['max_size'] = NULL;
}
if (array_key_exists('max_dimensions', $image_upload_settings)) {
if (!array_key_exists('width', $image_upload_settings['max_dimensions']) || $image_upload_settings['max_dimensions']['width'] === 0) {
$image_upload_settings['max_dimensions']['width'] = NULL;
}
if (!array_key_exists('height', $image_upload_settings['max_dimensions']) || $image_upload_settings['max_dimensions']['height'] === 0) {
$image_upload_settings['max_dimensions']['height'] = NULL;
}
}
$editor->setImageUploadSettings($image_upload_settings);
}
}

View File

@@ -0,0 +1,69 @@
<?php
/**
* @file
* Post update functions for Editor.
*/
use Drupal\Core\Config\Entity\ConfigEntityUpdater;
use Drupal\editor\EditorInterface;
use Drupal\filter\Entity\FilterFormat;
use Drupal\filter\FilterFormatInterface;
use Drupal\filter\FilterPluginCollection;
/**
* Implements hook_removed_post_updates().
*/
function editor_removed_post_updates() {
return [
'editor_post_update_clear_cache_for_file_reference_filter' => '9.0.0',
];
}
/**
* Enable filter_image_lazy_load if editor_file_reference is enabled.
*/
function editor_post_update_image_lazy_load(): void {
if (\Drupal::service('plugin.manager.filter')->hasDefinition('editor_file_reference')) {
foreach (FilterFormat::loadMultiple() as $format) {
assert($format instanceof FilterFormatInterface);
$collection = $format->filters();
$configuration = $collection->getConfiguration();
assert($collection instanceof FilterPluginCollection);
if (array_key_exists('editor_file_reference', $configuration)) {
$collection->addInstanceId('filter_image_lazy_load');
$configuration['filter_image_lazy_load'] = [
'id' => 'filter_image_lazy_load',
'provider' => 'editor',
'status' => TRUE,
// Place lazy loading after editor file reference.
'weight' => $configuration['editor_file_reference']['weight'] + 1,
'settings' => [],
];
$collection->setConfiguration($configuration);
$format->save();
}
}
}
}
/**
* Clean up image upload settings.
*/
function editor_post_update_sanitize_image_upload_settings(&$sandbox = []) {
$config_entity_updater = \Drupal::classResolver(ConfigEntityUpdater::class);
$callback = function (EditorInterface $editor) {
$image_upload_settings = $editor->getImageUploadSettings();
// Only update if the editor has image uploads:
// - empty image upload settings
// - disabled and >=1 other keys in its image upload settings
// - enabled (to tighten the key-value pairs in its settings).
// @see editor_editor_presave()
return !array_key_exists('status', $image_upload_settings)
|| ($image_upload_settings['status'] == FALSE && count($image_upload_settings) >= 2)
|| $image_upload_settings['status'] == TRUE;
};
$config_entity_updater->update($sandbox, 'editor', $callback);
}

View File

@@ -0,0 +1,22 @@
editor.filter_xss:
path: '/editor/filter_xss/{filter_format}'
defaults:
_controller: '\Drupal\editor\EditorController::filterXss'
requirements:
_entity_access: 'filter_format.use'
editor.image_dialog:
path: '/editor/dialog/image/{editor}'
defaults:
_form: '\Drupal\editor\Form\EditorImageDialog'
_title: 'Upload image'
requirements:
_entity_access: 'editor.use'
editor.link_dialog:
path: '/editor/dialog/link/{editor}'
defaults:
_form: '\Drupal\editor\Form\EditorLinkDialog'
_title: 'Add link'
requirements:
_entity_access: 'editor.use'

View File

@@ -0,0 +1,13 @@
services:
_defaults:
autoconfigure: true
plugin.manager.editor:
class: Drupal\editor\Plugin\EditorManager
parent: default_plugin_manager
element.editor:
class: Drupal\editor\Element
arguments: ['@plugin.manager.editor']
Drupal\editor\Element: '@element.editor'
editor.config_translation_mapper_subscriber:
class: Drupal\editor\EventSubscriber\EditorConfigTranslationSubscriber
arguments: ['@config.factory']

View File

@@ -0,0 +1,24 @@
---
label: 'Connecting text editors to text formats'
related:
- core.content_structure
- field_ui.manage_form
- filter.overview
---
{% set filter_overview_topic = render_var(help_topic_link('filter.overview')) %}
{% set overview_link_text %}{% trans %}Text formats and editors{% endtrans %}{% endset %}
{% set overview_link = render_var(help_route_link(overview_link_text, 'filter.admin_overview')) %}
<h2>{% trans %}Goal{% endtrans %}</h2>
<p>{% trans %}Configure a text format so that when a user is editing text and selects this text format, a text editor installed on your site is shown. Configure the text editor, such as choosing which buttons and functions are available. See {{ filter_overview_topic }} for more about text formats.{% endtrans %}</p>
<h2>{% trans %}What is a text editor?{% endtrans %}</h2>
<p>{% trans %}A text editor is software (typically, a JavaScript library) that provides buttons and other command mechanisms to make editing HTML text easier. Some editors are called <em>visual</em> or <em>WYSIWYG (What You See Is What You Get)</em> editors; these editors hide the details of HTML from the user, and instead show formatted output on the screen. The core Text Editor module provides a framework for deploying text editors on your site. The core CKEditor 5 module provides CKEditor 5, which is a widely-used JavaScript text editor that creates clean and valid HTML; the module also enforces the HTML tag restrictions in the associated text format. Various contributed modules provide other editors; to install a new editor, besides installing the module, you may need to download the editor library from a third-party site.{% endtrans %}</p>
<h2>{% trans %}Steps{% endtrans %}</h2>
<ol>
<li>{% trans %}In the <em>Manage</em> administrative menu, navigate to <em>Configuration</em> &gt; <em>Content Authoring</em> &gt; <em>{{ overview_link }}</em>. The <em>Text editor</em> column in the table shows the text editor that is currently connected to each text format, if any.{% endtrans %}</li>
<li>{% trans %}Follow the steps on {{ filter_overview_topic }} to add a new text format or configure an existing text format; when you reach the step about text editors, return to this topic.{% endtrans %}</li>
<li>{% trans %}Select <em>CKEditor 5</em> as the <em>Text editor</em>, or another text editor that you have installed. The rest of these steps assume you selected <em>CKEditor 5</em>.{% endtrans %}</li>
<li>{% trans %}Under <em>Toolbar configuration</em>, drag buttons between <em>Available buttons</em> and <em>Active toolbar</em>; only buttons in <em>Active toolbar</em> will be shown to the user. Focusing or hovering over a button will display a tooltip explaining what the button does.{% endtrans %}</li>
<li>{% trans %}Drag buttons within <em>Active toolbar</em> to the desired order, and group buttons by dragging them between group identifiers; drag <em>a new group identifier</em> to the toolbar to add additional groups.{% endtrans %}</li>
<li>{% trans %}If you add buttons that require configuration, the <em>CKEditor 5 plugin settings</em> section will be visible, and provide their respective configuration forms. {% endtrans %}</li>
<li>{% trans %}Return to {{ filter_overview_topic }} to complete the text format configuration, and be sure to save the text format.{% endtrans %}</li>
</ol>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
/**
* @file
* AJAX commands used by Editor module.
*/
(function ($, Drupal) {
/**
* Command to save the contents of an editor-provided modal.
*
* This command does not close the open modal. It should be followed by a
* call to `Drupal.AjaxCommands.prototype.closeDialog`. Editors that are
* integrated with dialogs must independently listen for an
* `editor:dialogsave` event to save the changes into the contents of their
* interface.
*
* @param {Drupal.Ajax} [ajax]
* The Drupal.Ajax object.
* @param {object} response
* The server response from the ajax request.
* @param {Array} response.values
* The values that were saved.
* @param {number} [status]
* The status code from the ajax request.
*
* @fires event:editor:dialogsave
*/
Drupal.AjaxCommands.prototype.editorDialogSave = function (
ajax,
response,
status,
) {
$(window).trigger('editor:dialogsave', [response.values]);
};
})(jQuery, Drupal);

339
core/modules/editor/js/editor.js Executable file
View File

@@ -0,0 +1,339 @@
/**
* @file
* Attaches behavior for the Editor module.
*/
(function ($, Drupal, drupalSettings) {
/**
* Finds the text area field associated with the given text format selector.
*
* @param {jQuery} $formatSelector
* A text format selector DOM element.
*
* @return {HTMLElement}
* The text area DOM element, if it was found.
*/
function findFieldForFormatSelector($formatSelector) {
const fieldId = $formatSelector.attr('data-editor-for');
// This selector will only find text areas in the top-level document. We do
// not support attaching editors on text areas within iframes.
return $(`#${fieldId}`).get(0);
}
/**
* Filter away XSS attack vectors when switching text formats.
*
* @param {HTMLElement} field
* The textarea DOM element.
* @param {object} format
* The text format that's being activated, from
* drupalSettings.editor.formats.
* @param {string} originalFormatID
* The text format ID of the original text format.
* @param {function} callback
* A callback to be called (with no parameters) after the field's value has
* been XSS filtered.
*/
function filterXssWhenSwitching(field, format, originalFormatID, callback) {
// A text editor that already is XSS-safe needs no additional measures.
if (format.editor.isXssSafe) {
callback(field, format);
}
// Otherwise, ensure XSS safety: let the server XSS filter this value.
else {
$.ajax({
url: Drupal.url(`editor/filter_xss/${format.format}`),
type: 'POST',
data: {
value: field.value,
original_format_id: originalFormatID,
},
dataType: 'json',
success(xssFilteredValue) {
// If the server returns false, then no XSS filtering is needed.
if (xssFilteredValue !== false) {
field.value = xssFilteredValue;
}
callback(field, format);
},
});
}
}
/**
* Changes the text editor on a text area.
*
* @param {HTMLElement} field
* The text area DOM element.
* @param {string} newFormatID
* The text format we're changing to; the text editor for the currently
* active text format will be detached, and the text editor for the new text
* format will be attached.
*/
function changeTextEditor(field, newFormatID) {
const previousFormatID = field.getAttribute(
'data-editor-active-text-format',
);
// Detach the current editor (if any) and attach a new editor.
if (drupalSettings.editor.formats[previousFormatID]) {
Drupal.editorDetach(
field,
drupalSettings.editor.formats[previousFormatID],
);
}
// When no text editor is currently active, stop tracking changes.
else {
$(field).off('.editor');
}
// Attach the new text editor (if any).
if (drupalSettings.editor.formats[newFormatID]) {
const format = drupalSettings.editor.formats[newFormatID];
filterXssWhenSwitching(
field,
format,
previousFormatID,
Drupal.editorAttach,
);
}
// Store the new active format.
field.setAttribute('data-editor-active-text-format', newFormatID);
}
/**
* Handles changes in text format.
*
* @param {jQuery.Event} event
* The text format change event.
*/
function onTextFormatChange(event) {
const select = event.target;
const field = event.data.field;
const activeFormatID = field.getAttribute('data-editor-active-text-format');
const newFormatID = select.value;
// Prevent double-attaching if the change event is triggered manually.
if (newFormatID === activeFormatID) {
return;
}
// When changing to a text format that has a text editor associated
// with it that supports content filtering, then first ask for
// confirmation, because switching text formats might cause certain
// markup to be stripped away.
const supportContentFiltering =
drupalSettings.editor.formats[newFormatID] &&
drupalSettings.editor.formats[newFormatID].editorSupportsContentFiltering;
// If there is no content yet, it's always safe to change the text format.
const hasContent = field.value !== '';
if (hasContent && supportContentFiltering) {
const message = Drupal.t(
'Changing the text format to %text_format will permanently remove content that is not allowed in that text format.<br><br>Save your changes before switching the text format to avoid losing data.',
{
'%text_format': $(select).find('option:selected')[0].textContent,
},
);
const confirmationDialog = Drupal.dialog(`<div>${message}</div>`, {
title: Drupal.t('Change text format?'),
classes: {
'ui-dialog': 'editor-change-text-format-modal',
},
resizable: false,
buttons: [
{
text: Drupal.t('Continue'),
class: 'button button--primary',
click() {
changeTextEditor(field, newFormatID);
confirmationDialog.close();
},
},
{
text: Drupal.t('Cancel'),
class: 'button',
click() {
// Restore the active format ID: cancel changing text format. We
// cannot simply call event.preventDefault() because jQuery's
// change event is only triggered after the change has already
// been accepted.
select.value = activeFormatID;
const eventChange = new Event('change');
select.dispatchEvent(eventChange);
confirmationDialog.close();
},
},
],
// Prevent this modal from being closed without the user making a choice
// as per http://stackoverflow.com/a/5438771.
closeOnEscape: false,
create() {
$(this).parent().find('.ui-dialog-titlebar-close').remove();
},
beforeClose: false,
close(event) {
// Automatically destroy the DOM element that was used for the dialog.
$(event.target).remove();
},
});
confirmationDialog.showModal();
} else {
changeTextEditor(field, newFormatID);
}
}
/**
* Initialize an empty object for editors to place their attachment code.
*
* @namespace
*/
Drupal.editors = {};
/**
* Enables editors on text_format elements.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches an editor to an input element.
* @prop {Drupal~behaviorDetach} detach
* Detaches an editor from an input element.
*/
Drupal.behaviors.editor = {
attach(context, settings) {
// If there are no editor settings, there are no editors to enable.
if (!settings.editor) {
return;
}
once('editor', '[data-editor-for]', context).forEach((editor) => {
const $this = $(editor);
const field = findFieldForFormatSelector($this);
// Opt-out if no supported text area was found.
if (!field) {
return;
}
// Store the current active format.
const activeFormatID = editor.value;
field.setAttribute('data-editor-active-text-format', activeFormatID);
// Directly attach this text editor, if the text format is enabled.
if (settings.editor.formats[activeFormatID]) {
// XSS protection for the current text format/editor is performed on
// the server side, so we don't need to do anything special here.
Drupal.editorAttach(field, settings.editor.formats[activeFormatID]);
}
// When there is no text editor for this text format, still track
// changes, because the user has the ability to switch to some text
// editor, otherwise this code would not be executed.
$(field).on('change.editor keypress.editor', () => {
field.setAttribute('data-editor-value-is-changed', 'true');
// Just knowing that the value was changed is enough, stop tracking.
$(field).off('.editor');
});
// Attach onChange handler to text format selector element.
if (editor.tagName === 'SELECT') {
$this.on('change.editorAttach', { field }, onTextFormatChange);
}
// Detach any editor when the containing form is submitted.
$(field.form).on('submit', (event) => {
// Do not detach if the event was canceled.
if (event.isDefaultPrevented()) {
return;
}
// Detach the current editor (if any).
if (settings.editor.formats[activeFormatID]) {
Drupal.editorDetach(
field,
settings.editor.formats[activeFormatID],
'serialize',
);
}
});
});
},
detach(context, settings, trigger) {
let editors;
// The 'serialize' trigger indicates that we should simply update the
// underlying element with the new text, without destroying the editor.
if (trigger === 'serialize') {
// Removing the editor-processed class guarantees that the editor will
// be reattached. Only do this if we're planning to destroy the editor.
editors = once.filter('editor', '[data-editor-for]', context);
} else {
editors = once.remove('editor', '[data-editor-for]', context);
}
editors.forEach((editor) => {
const $this = $(editor);
const activeFormatID = editor.value;
const field = findFieldForFormatSelector($this);
if (field && activeFormatID in settings.editor.formats) {
Drupal.editorDetach(
field,
settings.editor.formats[activeFormatID],
trigger,
);
}
});
},
};
/**
* Attaches editor behaviors to the field.
*
* @param {HTMLElement} field
* The textarea DOM element.
* @param {object} format
* The text format that's being activated, from
* drupalSettings.editor.formats.
*
* @listens event:change
*
* @fires event:formUpdated
*/
Drupal.editorAttach = function (field, format) {
if (format.editor) {
// Attach the text editor.
Drupal.editors[format.editor].attach(field, format);
// Ensures form.js' 'formUpdated' event is triggered even for changes that
// happen within the text editor.
Drupal.editors[format.editor].onChange(field, () => {
$(field).trigger('formUpdated');
// Keep track of changes, so we know what to do when switching text
// formats and guaranteeing XSS protection.
field.setAttribute('data-editor-value-is-changed', 'true');
});
}
};
/**
* Detaches editor behaviors from the field.
*
* @param {HTMLElement} field
* The textarea DOM element.
* @param {object} format
* The text format that's being activated, from
* drupalSettings.editor.formats.
* @param {string} trigger
* Trigger value from the detach behavior.
*/
Drupal.editorDetach = function (field, format, trigger) {
if (format.editor) {
Drupal.editors[format.editor].detach(field, format, trigger);
// Restore the original value if the user didn't make any changes yet.
if (field.getAttribute('data-editor-value-is-changed') === 'false') {
field.value = field.getAttribute('data-editor-value-original');
}
}
};
})(jQuery, Drupal, drupalSettings);

View File

@@ -0,0 +1,42 @@
<?php
namespace Drupal\editor\Ajax;
use Drupal\Core\Ajax\CommandInterface;
/**
* Provides an AJAX command for saving the contents of an editor dialog.
*
* This command is implemented in editor.dialog.js in
* Drupal.AjaxCommands.prototype.editorDialogSave.
*/
class EditorDialogSave implements CommandInterface {
/**
* An array of values that will be passed back to the editor by the dialog.
*
* @var array
*/
protected array $values;
/**
* Constructs an EditorDialogSave object.
*
* @param array $values
* The values that should be passed to the form constructor in Drupal.
*/
public function __construct(array $values) {
$this->values = $values;
}
/**
* {@inheritdoc}
*/
public function render() {
return [
'command' => 'editorDialogSave',
'values' => $this->values,
];
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace Drupal\editor\Annotation;
use Drupal\Component\Annotation\Plugin;
/**
* Defines an Editor annotation object.
*
* Plugin Namespace: Plugin\Editor
*
* Text editor plugin implementations need to define a plugin definition array
* through annotation. These definition arrays may be altered through
* hook_editor_info_alter(). The definition includes the following keys:
*
* - id: The unique, system-wide identifier of the text editor. Typically named
* the same as the editor library.
* - label: The human-readable name of the text editor, translated.
* - supports_content_filtering: Whether the editor supports "allowed content
* only" filtering.
* - supports_inline_editing: Whether the editor supports the inline editing
* provided by the Edit module.
* - is_xss_safe: Whether this text editor is not vulnerable to XSS attacks.
* - supported_element_types: On which form element #types this text editor is
* capable of working.
*
* A complete sample plugin definition should be defined as in this example:
*
* @code
* @Editor(
* id = "my_editor",
* label = @Translation("My Editor"),
* supports_content_filtering = FALSE,
* supports_inline_editing = FALSE,
* is_xss_safe = FALSE,
* supported_element_types = {
* "textarea",
* "textfield",
* }
* )
* @endcode
*
* For a working example, see \Drupal\ckeditor5\Plugin\Editor\CKEditor5
*
* @see \Drupal\editor\Plugin\EditorPluginInterface
* @see \Drupal\editor\Plugin\EditorBase
* @see \Drupal\editor\Plugin\EditorManager
* @see hook_editor_info_alter()
* @see plugin_api
*
* @Annotation
*/
class Editor extends Plugin {
/**
* The plugin ID.
*
* @var string
*/
public $id;
/**
* The human-readable name of the editor plugin.
*
* @ingroup plugin_translatable
*
* @var \Drupal\Core\Annotation\Translation
*/
public $label;
/**
* Whether the editor supports "allowed content only" filtering.
*
* @var bool
*/
public $supports_content_filtering;
/**
* Whether the editor supports the inline editing provided by the Edit module.
*
* @var bool
*/
public $supports_inline_editing;
/**
* Whether this text editor is not vulnerable to XSS attacks.
*
* @var bool
*/
public $is_xss_safe;
/**
* A list of element types this text editor supports.
*
* @var string[]
*/
public $supported_element_types;
}

View File

@@ -0,0 +1,53 @@
<?php
namespace Drupal\editor\Attribute;
use Drupal\Component\Plugin\Attribute\Plugin;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Defines an Editor attribute object.
*
* Plugin Namespace: Plugin\Editor
*
* For a working example, see \Drupal\ckeditor5\Plugin\Editor\CKEditor5
*
* @see \Drupal\editor\Plugin\EditorPluginInterface
* @see \Drupal\editor\Plugin\EditorBase
* @see \Drupal\editor\Plugin\EditorManager
* @see hook_editor_info_alter()
* @see plugin_api
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
class Editor extends Plugin {
/**
* Constructs an Editor object.
*
* @param string $id
* The plugin ID.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup $label
* The human-readable name of the text editor, translated
* @param bool $supports_content_filtering
* Whether the editor supports "allowed content only" filtering.
* @param bool $supports_inline_editing
* Whether the editor supports the inline editing provided by the Edit
* module.
* @param bool $is_xss_safe
* Whether this text editor is not vulnerable to XSS attacks.
* @param string[] $supported_element_types
* On which form element #types this text editor is capable of working.
* @param class-string|null $deriver
* (optional) The deriver class.
*/
public function __construct(
public readonly string $id,
public readonly TranslatableMarkup $label,
public readonly bool $supports_content_filtering,
public readonly bool $supports_inline_editing,
public readonly bool $is_xss_safe,
public readonly array $supported_element_types,
public readonly ?string $deriver = NULL,
) {}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Drupal\editor;
use Drupal\Core\Entity\EntityAccessControlHandler;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;
/**
* Defines the access control handler for the text editor entity type.
*
* @see \Drupal\editor\Entity\Editor
*/
class EditorAccessControlHandler extends EntityAccessControlHandler {
/**
* {@inheritdoc}
*/
protected function checkAccess(EntityInterface $editor, $operation, AccountInterface $account) {
/** @var \Drupal\editor\EditorInterface $editor */
return $editor->getFilterFormat()->access($operation, $account, TRUE);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Drupal\editor;
use Drupal\Core\Controller\ControllerBase;
use Drupal\filter\FilterFormatInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Returns responses for Editor module routes.
*/
class EditorController extends ControllerBase {
/**
* Apply the necessary XSS filtering for using a certain text format's editor.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The current request object.
* @param \Drupal\filter\FilterFormatInterface $filter_format
* The text format whose text editor (if any) will be used.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* A JSON response containing the XSS-filtered value.
*
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
* Thrown if no value to filter is specified.
*
* @see editor_filter_xss()
*/
public function filterXss(Request $request, FilterFormatInterface $filter_format) {
$value = $request->request->get('value');
if (!isset($value)) {
throw new NotFoundHttpException();
}
// The original_format parameter will only exist when switching text format.
$original_format_id = $request->request->get('original_format_id');
$original_format = NULL;
if (isset($original_format_id)) {
$original_format = $this->entityTypeManager()
->getStorage('filter_format')
->load($original_format_id);
}
return new JsonResponse(editor_filter_xss($value, $filter_format, $original_format));
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace Drupal\editor;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
/**
* Provides an interface defining a text editor entity.
*/
interface EditorInterface extends ConfigEntityInterface {
/**
* Returns whether this text editor has an associated filter format.
*
* A text editor may be created at the same time as the filter format it's
* going to be associated with; in that case, no filter format object is
* available yet.
*
* @return bool
*/
public function hasAssociatedFilterFormat();
/**
* Returns the filter format this text editor is associated with.
*
* This could be NULL if the associated filter format is still being created.
* @see hasAssociatedFilterFormat()
*
* @return \Drupal\filter\FilterFormatInterface|null
*/
public function getFilterFormat();
/**
* Returns the associated text editor plugin ID.
*
* @return string
* The text editor plugin ID.
*/
public function getEditor();
/**
* Set the text editor plugin ID.
*
* @param string $editor
* The text editor plugin ID to set.
*/
public function setEditor($editor);
/**
* Returns the text editor plugin-specific settings.
*
* @return array
* A structured array containing all text editor settings.
*/
public function getSettings();
/**
* Sets the text editor plugin-specific settings.
*
* @param array $settings
* The structured array containing all text editor settings.
*
* @return $this
*/
public function setSettings(array $settings);
/**
* Returns the image upload settings.
*
* @return array
* A structured array containing image upload settings.
*/
public function getImageUploadSettings();
/**
* Sets the image upload settings.
*
* @param array $image_upload
* The structured array containing image upload settings.
*
* @return $this
*/
public function setImageUploadSettings(array $image_upload);
}

View File

@@ -0,0 +1,138 @@
<?php
namespace Drupal\editor\EditorXssFilter;
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\Xss;
use Drupal\filter\FilterFormatInterface;
use Drupal\editor\EditorXssFilterInterface;
/**
* Defines the standard text editor XSS filter.
*/
class Standard extends Xss implements EditorXssFilterInterface {
/**
* {@inheritdoc}
*/
public static function filterXss($html, FilterFormatInterface $format, ?FilterFormatInterface $original_format = NULL) {
// Apply XSS filtering, but blacklist the <script>, <style>, <link>, <embed>
// and <object> tags.
// The <script> and <style> tags are blacklisted because their contents
// can be malicious (and therefore they are inherently unsafe), whereas for
// all other tags, only their attributes can make them malicious. Since
// \Drupal\Component\Utility\Xss::filter() protects against malicious
// attributes, we take no blacklisting action.
// The exceptions to the above rule are <link>, <embed> and <object>:
// - <link> because the href attribute allows the attacker to import CSS
// using the HTTP(S) protocols which Xss::filter() considers safe by
// default. The imported remote CSS is applied to the main document, thus
// allowing for the same XSS attacks as a regular <style> tag.
// - <embed> and <object> because these tags allow non-HTML applications or
// content to be embedded using the src or data attributes, respectively.
// This is safe in the case of HTML documents, but not in the case of
// Flash objects for example, that may access/modify the main document
// directly.
// <iframe> is considered safe because it only allows HTML content to be
// embedded, hence ensuring the same origin policy always applies.
$dangerous_tags = ['script', 'style', 'link', 'embed', 'object'];
// Simply blacklisting these five dangerous tags would bring safety, but
// also user frustration: what if a text format is configured to allow
// <embed>, for example? Then we would strip that tag, even though it is
// allowed, thereby causing data loss!
// Therefore, we want to be smarter still. We want to take into account
// which HTML tags are allowed by the text format we're filtering for, and
// if we're switching from another text format, we want to take that
// format's allowed tags into account as well.
// In other words: we only expect markup allowed in both the original and
// the new format to continue to exist.
$format_restrictions = $format->getHtmlRestrictions();
if ($original_format !== NULL) {
$original_format_restrictions = $original_format->getHtmlRestrictions();
}
// Any tags that are explicitly whitelisted by the text format must be
// removed from the list of default dangerous tags: if they're explicitly
// allowed, then we must respect that configuration.
// When switching from another format, we must use the intersection of
// allowed tags: if either format is more restrictive, then the safety
// expectations of *both* formats apply.
$allowed_tags = self::getAllowedTags($format_restrictions);
if ($original_format !== NULL) {
$allowed_tags = array_intersect($allowed_tags, self::getAllowedTags($original_format_restrictions));
}
// Don't blacklist dangerous tags that are explicitly allowed in both text
// formats.
$blacklisted_tags = array_diff($dangerous_tags, $allowed_tags);
$output = static::filter($html, $blacklisted_tags);
// Since data-attributes can contain encoded HTML markup that could be
// decoded and interpreted by editors, we need to apply XSS filtering to
// their contents.
return static::filterXssDataAttributes($output);
}
/**
* Applies a very permissive XSS/HTML filter to data-attributes.
*
* @param string $html
* The string to apply the data-attributes filtering to.
*
* @return string
* The filtered string.
*/
protected static function filterXssDataAttributes($html) {
if (stristr($html, 'data-') !== FALSE) {
$dom = Html::load($html);
$xpath = new \DOMXPath($dom);
foreach ($xpath->query('//@*[starts-with(name(.), "data-")]') as $node) {
// The data-attributes contain an HTML-encoded value, so we need to
// decode the value, apply XSS filtering and then re-save as encoded
// value. There is no need to explicitly decode $node->value, since the
// DOMAttr::value getter returns the decoded value.
$value = Xss::filterAdmin($node->value);
$node->value = Html::escape($value);
}
$html = Html::serialize($dom);
}
return $html;
}
/**
* Get all allowed tags from a restrictions data structure.
*
* @param array|false $restrictions
* Restrictions as returned by FilterInterface::getHTMLRestrictions().
*
* @return array
* An array of allowed HTML tags.
*
* @see \Drupal\filter\Plugin\Filter\FilterInterface::getHTMLRestrictions()
*/
protected static function getAllowedTags($restrictions) {
if ($restrictions === FALSE || !isset($restrictions['allowed'])) {
return [];
}
$allowed_tags = array_keys($restrictions['allowed']);
// Exclude the wildcard tag, which is used to set attribute restrictions on
// all tags simultaneously.
$allowed_tags = array_diff($allowed_tags, ['*']);
return $allowed_tags;
}
/**
* {@inheritdoc}
*/
protected static function needsRemoval(array $html_tags, $elem) {
// See static::filterXss() about how this class uses blacklisting instead
// of the normal whitelisting.
return !parent::needsRemoval($html_tags, $elem);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Drupal\editor;
use Drupal\filter\FilterFormatInterface;
/**
* Defines an interface for text editor XSS (Cross-site scripting) filters.
*/
interface EditorXssFilterInterface {
/**
* Filters HTML to prevent XSS attacks when a user edits it in a text editor.
*
* Should filter as minimally as possible, only to remove XSS attack vectors.
*
* Is only called when:
* - loading a non-XSS-safe text editor for a $format that contains a filter
* preventing XSS attacks (a FilterInterface::TYPE_HTML_RESTRICTOR filter):
* if the output is safe, it should also be safe to edit.
* - loading a non-XSS-safe text editor for a $format that doesn't contain a
* filter preventing XSS attacks, but we're switching from a previous text
* format ($original_format is not NULL) that did prevent XSS attacks: if
* the output was previously safe, it should be safe to switch to another
* text format and edit.
*
* @param string $html
* The HTML to be filtered.
* @param \Drupal\filter\FilterFormatInterface $format
* The text format configuration entity. Provides context based upon which
* one may want to adjust the filtering.
* @param \Drupal\filter\FilterFormatInterface|null $original_format
* (optional) The original text format configuration entity (when switching
* text formats/editors). Also provides context based upon which one may
* want to adjust the filtering.
*
* @return string
* The filtered HTML that cannot cause any XSS anymore.
*/
public static function filterXss($html, FilterFormatInterface $format, ?FilterFormatInterface $original_format = NULL);
}

View File

@@ -0,0 +1,126 @@
<?php
namespace Drupal\editor;
use Drupal\Core\Security\TrustedCallbackInterface;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\Component\Plugin\PluginManagerInterface;
use Drupal\Core\Render\BubbleableMetadata;
/**
* Defines a service for Text Editor's render elements.
*/
class Element implements TrustedCallbackInterface {
/**
* The Text Editor plugin manager service.
*
* @var \Drupal\Component\Plugin\PluginManagerInterface
*/
protected $pluginManager;
/**
* Constructs a new Element object.
*
* @param \Drupal\Component\Plugin\PluginManagerInterface $plugin_manager
* The Text Editor plugin manager service.
*/
public function __construct(PluginManagerInterface $plugin_manager) {
$this->pluginManager = $plugin_manager;
}
/**
* {@inheritdoc}
*/
public static function trustedCallbacks() {
return ['preRenderTextFormat'];
}
/**
* Additional #pre_render callback for 'text_format' elements.
*/
public function preRenderTextFormat(array $element) {
// Allow modules to programmatically enforce no client-side editor by
// setting the #editor property to FALSE.
if (isset($element['#editor']) && !$element['#editor']) {
return $element;
}
// \Drupal\filter\Element\TextFormat::processFormat() copies properties to
// the expanded 'value' to the child element, including the #pre_render
// property. Skip this text format widget, if it contains no 'format'.
if (!isset($element['format'])) {
return $element;
}
$format_ids = array_keys($element['format']['format']['#options']);
// Early-return if no text editor is associated with any of the text formats.
$editors = Editor::loadMultiple($format_ids);
foreach ($editors as $key => $editor) {
$definition = $this->pluginManager->getDefinition($editor->getEditor());
if (!in_array($element['#base_type'], $definition['supported_element_types'])) {
unset($editors[$key]);
}
}
if (count($editors) === 0) {
return $element;
}
// Use a hidden element for a single text format.
$field_id = $element['value']['#id'];
if (!$element['format']['format']['#access']) {
// Use the first (and only) available text format.
$format_id = $format_ids[0];
$element['format']['editor'] = [
'#type' => 'hidden',
'#name' => $element['format']['format']['#name'],
'#value' => $format_id,
'#attributes' => [
'data-editor-for' => $field_id,
],
];
}
// Otherwise, attach to text format selector.
else {
$element['format']['format']['#attributes']['class'][] = 'editor';
$element['format']['format']['#attributes']['data-editor-for'] = $field_id;
}
// Hide the text format's filters' guidelines of those text formats that have
// a text editor associated: they're rather useless when using a text editor.
foreach ($editors as $format_id => $editor) {
$element['format']['guidelines'][$format_id]['#access'] = FALSE;
}
// Attach Text Editor module's (this module) library.
$element['#attached']['library'][] = 'editor/drupal.editor';
// Attach attachments for all available editors.
$element['#attached'] = BubbleableMetadata::mergeAttachments($element['#attached'], $this->pluginManager->getAttachments($format_ids));
// Apply XSS filters when editing content if necessary. Some types of text
// editors cannot guarantee that the end user won't become a victim of XSS.
if (!empty($element['value']['#value'])) {
$original = $element['value']['#value'];
$format = FilterFormat::load($element['format']['format']['#value']);
// Ensure XSS-safety for the current text format/editor.
$filtered = editor_filter_xss($original, $format);
if ($filtered !== FALSE) {
$element['value']['#value'] = $filtered;
}
// Only when the user has access to multiple text formats, we must add data-
// attributes for the original value and change tracking, because they are
// only necessary when the end user can switch between text formats/editors.
if ($element['format']['format']['#access']) {
$element['value']['#attributes']['data-editor-value-is-changed'] = 'false';
$element['value']['#attributes']['data-editor-value-original'] = $original;
}
}
return $element;
}
}

View File

@@ -0,0 +1,225 @@
<?php
namespace Drupal\editor\Entity;
use Drupal\Component\Plugin\Exception\PluginNotFoundException;
use Drupal\Core\Config\Entity\ConfigEntityBase;
use Drupal\Core\StreamWrapper\StreamWrapperInterface;
use Drupal\editor\EditorInterface;
/**
* Defines the configured text editor entity.
*
* An Editor entity is created when a filter format entity (Text format) is
* saved after selecting an editor plugin (eg: CKEditor). The ID of the
* Editor entity will be same as the ID of the filter format entity in which
* the editor plugin was selected.
*
* @ConfigEntityType(
* id = "editor",
* label = @Translation("Text editor"),
* label_collection = @Translation("Text editors"),
* label_singular = @Translation("text editor"),
* label_plural = @Translation("text editors"),
* label_count = @PluralTranslation(
* singular = "@count text editor",
* plural = "@count text editors",
* ),
* handlers = {
* "access" = "Drupal\editor\EditorAccessControlHandler",
* },
* entity_keys = {
* "id" = "format"
* },
* config_export = {
* "format",
* "editor",
* "settings",
* "image_upload",
* },
* constraints = {
* "RequiredConfigDependencies" = {
* "filter_format"
* }
* }
* )
*/
class Editor extends ConfigEntityBase implements EditorInterface {
/**
* Machine name of the text format for this configured text editor.
*
* @var string
*
* @see getFilterFormat()
*/
protected $format;
/**
* The name (plugin ID) of the text editor.
*
* @var string
*/
protected $editor;
/**
* The structured array of text editor plugin-specific settings.
*
* @var array
*/
protected $settings = [];
/**
* The structured array of image upload settings.
*
* @var array
*/
protected $image_upload = [];
/**
* The filter format this text editor is associated with.
*
* @var \Drupal\filter\FilterFormatInterface
*/
protected $filterFormat;
/**
* @var \Drupal\Component\Plugin\PluginManagerInterface
*/
protected $editorPluginManager;
/**
* {@inheritdoc}
*/
public function id() {
return $this->format;
}
/**
* {@inheritdoc}
*/
public function __construct(array $values, $entity_type) {
parent::__construct($values, $entity_type);
try {
$plugin = $this->editorPluginManager()->createInstance($this->editor);
$this->settings += $plugin->getDefaultSettings();
}
catch (PluginNotFoundException $e) {
// When a Text Editor plugin has gone missing, still allow the Editor
// config entity to be constructed. The only difference is that default
// settings are not added.
}
}
/**
* {@inheritdoc}
*/
public function label() {
return $this->getFilterFormat()->label();
}
/**
* {@inheritdoc}
*/
public function calculateDependencies() {
parent::calculateDependencies();
// Create a dependency on the associated FilterFormat.
$this->addDependency('config', $this->getFilterFormat()->getConfigDependencyName());
// @todo use EntityWithPluginCollectionInterface so configuration between
// config entity and dependency on provider is managed automatically.
$definition = $this->editorPluginManager()->createInstance($this->editor)->getPluginDefinition();
$this->addDependency('module', $definition['provider']);
return $this;
}
/**
* {@inheritdoc}
*/
public function hasAssociatedFilterFormat() {
return $this->format !== NULL;
}
/**
* {@inheritdoc}
*/
public function getFilterFormat() {
if (!$this->filterFormat) {
$this->filterFormat = \Drupal::entityTypeManager()->getStorage('filter_format')->load($this->format);
}
return $this->filterFormat;
}
/**
* Returns the editor plugin manager.
*
* @return \Drupal\Component\Plugin\PluginManagerInterface
*/
protected function editorPluginManager() {
if (!$this->editorPluginManager) {
$this->editorPluginManager = \Drupal::service('plugin.manager.editor');
}
return $this->editorPluginManager;
}
/**
* {@inheritdoc}
*/
public function getEditor() {
return $this->editor;
}
/**
* {@inheritdoc}
*/
public function setEditor($editor) {
$this->editor = $editor;
return $this;
}
/**
* {@inheritdoc}
*/
public function getSettings() {
return $this->settings;
}
/**
* {@inheritdoc}
*/
public function setSettings(array $settings) {
$this->settings = $settings;
return $this;
}
/**
* {@inheritdoc}
*/
public function getImageUploadSettings() {
return $this->image_upload;
}
/**
* {@inheritdoc}
*/
public function setImageUploadSettings(array $image_upload_settings) {
$this->image_upload = $image_upload_settings;
return $this;
}
/**
* Computes all valid choices for the "image_upload.scheme" setting.
*
* @see editor.schema.yml
*
* @return string[]
* All valid choices.
*
* @internal
*/
public static function getValidStreamWrappers(): array {
return array_keys(\Drupal::service('stream_wrapper_manager')->getNames(StreamWrapperInterface::WRITE_VISIBLE));
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace Drupal\editor\EventSubscriber;
use Drupal\config_translation\ConfigEntityMapperInterface;
use Drupal\config_translation\Event\ConfigMapperPopulateEvent;
use Drupal\config_translation\Event\ConfigTranslationEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
/**
* Adds configuration names to configuration mapper on POPULATE_MAPPER event.
*/
class EditorConfigTranslationSubscriber implements EventSubscriberInterface {
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* EditorConfigTranslationSubscriber constructor.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The factory for configuration objects.
*/
public function __construct(ConfigFactoryInterface $config_factory) {
$this->configFactory = $config_factory;
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
$events = [];
if (class_exists('Drupal\config_translation\Event\ConfigTranslationEvents')) {
$events[ConfigTranslationEvents::POPULATE_MAPPER][] = ['addConfigNames'];
}
return $events;
}
/**
* Reacts to the populating of a configuration mapper.
*
* @param \Drupal\config_translation\Event\ConfigMapperPopulateEvent $event
* The configuration mapper event.
*/
public function addConfigNames(ConfigMapperPopulateEvent $event) {
$mapper = $event->getMapper();
if ($mapper instanceof ConfigEntityMapperInterface && $mapper->getType() == 'filter_format') {
$editor_config_name = 'editor.editor.' . $mapper->getEntity()->id();
// Only add the text editor config if it exists, otherwise we assume no
// editor has been set for this text format.
if (!$this->configFactory->get($editor_config_name)->isNew()) {
$mapper->addConfigName($editor_config_name);
}
}
}
}

View File

@@ -0,0 +1,250 @@
<?php
namespace Drupal\editor\Form;
use Drupal\Component\Utility\Bytes;
use Drupal\Component\Utility\Environment;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\editor\Entity\Editor;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\HtmlCommand;
use Drupal\editor\Ajax\EditorDialogSave;
use Drupal\Core\Ajax\CloseModalDialogCommand;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Entity\EntityStorageInterface;
/**
* Provides an image dialog for text editors.
*
* @deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. There is no
* replacement.
*
* @see https://www.drupal.org/node/3291493
*
* @internal
*/
class EditorImageDialog extends FormBase {
/**
* The file storage service.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $fileStorage;
/**
* Constructs a form object for image dialog.
*
* @param \Drupal\Core\Entity\EntityStorageInterface $file_storage
* The file storage service.
*/
public function __construct(EntityStorageInterface $file_storage) {
@trigger_error(__NAMESPACE__ . '\EditorImageDialog is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. There is no replacement. See https://www.drupal.org/node/3291493', E_USER_DEPRECATED);
$this->fileStorage = $file_storage;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager')->getStorage('file')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'editor_image_dialog';
}
/**
* {@inheritdoc}
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param \Drupal\editor\Entity\Editor $editor
* The text editor to which this dialog corresponds.
*/
public function buildForm(array $form, FormStateInterface $form_state, ?Editor $editor = NULL) {
// This form is special, in that the default values do not come from the
// server side, but from the client side, from a text editor. We must cache
// this data in form state, because when the form is rebuilt, we will be
// receiving values from the form, instead of the values from the text
// editor. If we don't cache it, this data will be lost.
if (isset($form_state->getUserInput()['editor_object'])) {
// By convention, the data that the text editor sends to any dialog is in
// the 'editor_object' key. And the image dialog for text editors expects
// that data to be the attributes for an <img> element.
$image_element = $form_state->getUserInput()['editor_object'];
$form_state->set('image_element', $image_element);
$form_state->setCached(TRUE);
}
else {
// Retrieve the image element's attributes from form state.
$image_element = $form_state->get('image_element') ?: [];
}
$form['#tree'] = TRUE;
$form['#attached']['library'][] = 'editor/drupal.editor.dialog';
$form['#prefix'] = '<div id="editor-image-dialog-form">';
$form['#suffix'] = '</div>';
// Construct strings to use in the upload validators.
$image_upload = $editor->getImageUploadSettings();
$existing_file = isset($image_element['data-entity-uuid']) ? \Drupal::service('entity.repository')->loadEntityByUuid('file', $image_element['data-entity-uuid']) : NULL;
$fid = $existing_file ? $existing_file->id() : NULL;
$form['fid'] = [
'#title' => $this->t('Image'),
'#type' => 'managed_file',
'#default_value' => $fid ? [$fid] : NULL,
'#upload_validators' => [
'FileExtension' => ['extensions' => 'gif png jpg jpeg'],
],
'#required' => TRUE,
];
$form['attributes']['src'] = [
'#title' => $this->t('URL'),
'#type' => 'textfield',
'#default_value' => $image_element['src'] ?? '',
'#maxlength' => 2048,
'#required' => TRUE,
];
// If the editor has image uploads enabled, show a managed_file form item,
// otherwise show a (file URL) text form item.
if ($image_upload['status']) {
$form['attributes']['src']['#access'] = FALSE;
$form['attributes']['src']['#required'] = FALSE;
if (!empty($image_upload['max_dimensions']['width']) || !empty($image_upload['max_dimensions']['height'])) {
$max_dimensions = $image_upload['max_dimensions']['width'] . 'x' . $image_upload['max_dimensions']['height'];
}
else {
$max_dimensions = 0;
}
$max_filesize = min(Bytes::toNumber($image_upload['max_size'] ?? 0), Environment::getUploadMaxSize());
$form['fid']['#upload_location'] = $image_upload['scheme'] . '://' . ($image_upload['directory'] ?? '');
$form['fid']['#upload_validators']['FileSizeLimit'] = ['fileLimit' => $max_filesize];
$form['fid']['#upload_validators']['FileImageDimensions'] = ['maxDimensions' => $max_dimensions];
}
else {
$form['fid']['#access'] = FALSE;
$form['fid']['#required'] = FALSE;
}
// The alt attribute is *required*, but we allow users to opt-in to empty
// alt attributes for the very rare edge cases where that is valid by
// specifying two double quotes as the alternative text in the dialog.
// However, that *is* stored as an empty alt attribute, so if we're editing
// an existing image (which means the src attribute is set) and its alt
// attribute is empty, then we show that as two double quotes in the dialog.
// @see https://www.drupal.org/node/2307647
$alt = $image_element['alt'] ?? '';
if ($alt === '' && !empty($image_element['src'])) {
$alt = '""';
}
$form['attributes']['alt'] = [
'#title' => $this->t('Alternative text'),
'#description' => $this->t('Short description of the image used by screen readers and displayed when the image is not loaded. This is important for accessibility.'),
'#type' => 'textfield',
'#required' => TRUE,
'#required_error' => $this->t('Alternative text is required.<br />(Only in rare cases should this be left empty. To create empty alternative text, enter <code>""</code> — two double quotes without any content).'),
'#default_value' => $alt,
'#maxlength' => 2048,
];
// When Drupal core's filter_align is being used, the text editor may
// offer the ability to change the alignment.
if (isset($image_element['data-align']) && $editor->getFilterFormat()->filters('filter_align')->status) {
$form['align'] = [
'#title' => $this->t('Align'),
'#type' => 'radios',
'#options' => [
'none' => $this->t('None'),
'left' => $this->t('Left'),
'center' => $this->t('Center'),
'right' => $this->t('Right'),
],
'#default_value' => $image_element['data-align'] === '' ? 'none' : $image_element['data-align'],
'#wrapper_attributes' => ['class' => ['container-inline']],
'#attributes' => ['class' => ['container-inline']],
'#parents' => ['attributes', 'data-align'],
];
}
// When Drupal core's filter_caption is being used, the text editor may
// offer the ability to in-place edit the image's caption: show a toggle.
if (isset($image_element['hasCaption']) && $editor->getFilterFormat()->filters('filter_caption')->status) {
$form['caption'] = [
'#title' => $this->t('Caption'),
'#type' => 'checkbox',
'#default_value' => $image_element['hasCaption'] === 'true',
'#parents' => ['attributes', 'hasCaption'],
];
}
$form['actions'] = [
'#type' => 'actions',
];
$form['actions']['save_modal'] = [
'#type' => 'submit',
'#value' => $this->t('Save'),
// No regular submit-handler. This form only works via JavaScript.
'#submit' => [],
'#ajax' => [
'callback' => '::submitForm',
'event' => 'click',
],
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$response = new AjaxResponse();
// Convert any uploaded files from the FID values to data-entity-uuid
// attributes and set data-entity-type to 'file'.
$fid = $form_state->getValue(['fid', 0]);
if (!empty($fid)) {
/** @var \Drupal\file\FileInterface $file */
$file = $this->fileStorage->load($fid);
$file_url = $file->createFileUrl();
$form_state->setValue(['attributes', 'src'], $file_url);
$form_state->setValue(['attributes', 'data-entity-uuid'], $file->uuid());
$form_state->setValue(['attributes', 'data-entity-type'], 'file');
}
// When the alt attribute is set to two double quotes, transform it to the
// empty string: two double quotes signify "empty alt attribute". See above.
if (trim($form_state->getValue(['attributes', 'alt'])) === '""') {
$form_state->setValue(['attributes', 'alt'], '');
}
if ($form_state->getErrors()) {
unset($form['#prefix'], $form['#suffix']);
$form['status_messages'] = [
'#type' => 'status_messages',
'#weight' => -10,
];
$response->addCommand(new HtmlCommand('#editor-image-dialog-form', $form));
}
else {
$response->addCommand(new EditorDialogSave($form_state->getValues()));
$response->addCommand(new CloseModalDialogCommand());
}
return $response;
}
}

View File

@@ -0,0 +1,110 @@
<?php
namespace Drupal\editor\Form;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\editor\Entity\Editor;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\HtmlCommand;
use Drupal\editor\Ajax\EditorDialogSave;
use Drupal\Core\Ajax\CloseModalDialogCommand;
/**
* Provides a link dialog for text editors.
*
* @deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. There is no
* replacement.
*
* @see https://www.drupal.org/node/3291493
*
* @internal
*/
class EditorLinkDialog extends FormBase {
/**
* Constructs a form object for link dialog.
*/
public function __construct() {
@trigger_error(__NAMESPACE__ . '\EditorLinkDialog is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. There is no replacement. See https://www.drupal.org/node/3291493', E_USER_DEPRECATED);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'editor_link_dialog';
}
/**
* {@inheritdoc}
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param \Drupal\editor\Entity\Editor $editor
* The text editor to which this dialog corresponds.
*
* @return array
*/
public function buildForm(array $form, FormStateInterface $form_state, ?Editor $editor = NULL) {
// The default values are set directly from \Drupal::request()->request,
// provided by the editor plugin opening the dialog.
$user_input = $form_state->getUserInput();
$input = $user_input['editor_object'] ?? [];
$form['#tree'] = TRUE;
$form['#attached']['library'][] = 'editor/drupal.editor.dialog';
$form['#prefix'] = '<div id="editor-link-dialog-form">';
$form['#suffix'] = '</div>';
// Everything under the "attributes" key is merged directly into the
// generated link tag's attributes.
$form['attributes']['href'] = [
'#title' => $this->t('URL'),
'#type' => 'textfield',
'#default_value' => $input['href'] ?? '',
'#maxlength' => 2048,
];
$form['actions'] = [
'#type' => 'actions',
];
$form['actions']['save_modal'] = [
'#type' => 'submit',
'#value' => $this->t('Save'),
// No regular submit-handler. This form only works via JavaScript.
'#submit' => [],
'#ajax' => [
'callback' => '::submitForm',
'event' => 'click',
],
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$response = new AjaxResponse();
if ($form_state->getErrors()) {
unset($form['#prefix'], $form['#suffix']);
$form['status_messages'] = [
'#type' => 'status_messages',
'#weight' => -10,
];
$response->addCommand(new HtmlCommand('#editor-link-dialog-form', $form));
}
else {
$response->addCommand(new EditorDialogSave($form_state->getValues()));
$response->addCommand(new CloseModalDialogCommand());
}
return $response;
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Drupal\editor\Plugin;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\PluginBase;
/**
* Defines a base class from which other modules providing editors may extend.
*
* This class provides default implementations of the EditorPluginInterface so
* that classes extending this one do not need to implement every method.
*
* Plugins extending this class need to specify an annotation containing the
* plugin definition so the plugin can be discovered.
*
* @see \Drupal\editor\Annotation\Editor
* @see \Drupal\editor\Plugin\EditorPluginInterface
* @see \Drupal\editor\Plugin\EditorManager
* @see plugin_api
*/
abstract class EditorBase extends PluginBase implements EditorPluginInterface {
/**
* {@inheritdoc}
*/
public function getDefaultSettings() {
return [];
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
return $form;
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace Drupal\editor\Plugin;
use Drupal\Core\Plugin\DefaultPluginManager;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\editor\Attribute\Editor;
/**
* Configurable text editor manager.
*
* @see \Drupal\editor\Annotation\Editor
* @see \Drupal\editor\Plugin\EditorPluginInterface
* @see \Drupal\editor\Plugin\EditorBase
* @see plugin_api
*/
class EditorManager extends DefaultPluginManager {
/**
* Constructs an EditorManager object.
*
* @param \Traversable $namespaces
* An object that implements \Traversable which contains the root paths
* keyed by the corresponding namespace to look for plugin implementations.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
* Cache backend instance to use.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler to invoke the alter hook with.
*/
public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
parent::__construct('Plugin/Editor', $namespaces, $module_handler, 'Drupal\editor\Plugin\EditorPluginInterface', Editor::class, 'Drupal\editor\Annotation\Editor');
$this->alterInfo('editor_info');
$this->setCacheBackend($cache_backend, 'editor_plugins');
}
/**
* Populates a key-value pair of available text editors.
*
* @return array
* An array of translated text editor labels, keyed by ID.
*/
public function listOptions() {
$options = [];
foreach ($this->getDefinitions() as $key => $definition) {
$options[$key] = $definition['label'];
}
return $options;
}
/**
* Retrieves text editor libraries and JavaScript settings.
*
* @param array $format_ids
* An array of format IDs as returned by array_keys(filter_formats()).
*
* @return array
* An array of attachments, for use with #attached.
*
* @see \Drupal\Core\Render\AttachmentsResponseProcessorInterface::processAttachments()
*/
public function getAttachments(array $format_ids) {
$attachments = ['library' => []];
$settings = [];
foreach ($format_ids as $format_id) {
$editor = editor_load($format_id);
if (!$editor) {
continue;
}
$plugin = $this->createInstance($editor->getEditor());
$plugin_definition = $plugin->getPluginDefinition();
// Libraries.
$attachments['library'] = array_merge($attachments['library'], $plugin->getLibraries($editor));
// Format-specific JavaScript settings.
$settings['editor']['formats'][$format_id] = [
'format' => $format_id,
'editor' => $editor->getEditor(),
'editorSettings' => $plugin->getJSSettings($editor),
'editorSupportsContentFiltering' => $plugin_definition['supports_content_filtering'],
'isXssSafe' => $plugin_definition['is_xss_safe'],
];
}
// Allow other modules to alter all JavaScript settings.
$this->moduleHandler->alter('editor_js_settings', $settings);
if (empty($attachments['library']) && empty($settings)) {
return [];
}
$attachments['drupalSettings'] = $settings;
return $attachments;
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace Drupal\editor\Plugin;
use Drupal\Component\Plugin\PluginInspectionInterface;
use Drupal\Core\Plugin\PluginFormInterface;
use Drupal\editor\Entity\Editor;
/**
* Defines an interface for configurable text editors.
*
* Modules implementing this interface may want to extend the EditorBase class,
* which provides default implementations of each method where appropriate.
*
* If the editor's behavior depends on extensive options and/or external data,
* then the implementing module can choose to provide a separate, global
* configuration page rather than per-text-format settings. In that case, this
* form should provide a link to the separate settings page.
*
* @see \Drupal\editor\Annotation\Editor
* @see \Drupal\editor\Plugin\EditorBase
* @see \Drupal\editor\Plugin\EditorManager
* @see plugin_api
*/
interface EditorPluginInterface extends PluginInspectionInterface, PluginFormInterface {
/**
* Returns the default settings for this configurable text editor.
*
* @return array
* An array of settings as they would be stored by a configured text editor
* entity (\Drupal\editor\Entity\Editor).
*/
public function getDefaultSettings();
/**
* Returns JavaScript settings to be attached.
*
* Most text editors use JavaScript to provide a WYSIWYG or toolbar on the
* client-side interface. This method can be used to convert internal settings
* of the text editor into JavaScript variables that will be accessible when
* the text editor is loaded.
*
* @param \Drupal\editor\Entity\Editor $editor
* A configured text editor object.
*
* @return array
* An array of settings that will be added to the page for use by this text
* editor's JavaScript integration.
*
* @see \Drupal\Core\Render\AttachmentsResponseProcessorInterface::processAttachments()
* @see EditorManager::getAttachments()
*/
public function getJSSettings(Editor $editor);
/**
* Returns libraries to be attached.
*
* Because this is a method, plugins can dynamically choose to attach a
* different library for different configurations, instead of being forced to
* always use the same method.
*
* @param \Drupal\editor\Entity\Editor $editor
* A configured text editor object.
*
* @return array
* An array of libraries that will be added to the page for use by this text
* editor.
*
* @see \Drupal\Core\Render\AttachmentsResponseProcessorInterface::processAttachments()
* @see EditorManager::getAttachments()
*/
public function getLibraries(Editor $editor);
}

View File

@@ -0,0 +1,128 @@
<?php
namespace Drupal\editor\Plugin\Filter;
use Drupal\Component\Utility\Html;
use Drupal\Core\Entity\EntityRepositoryInterface;
use Drupal\Core\Image\ImageFactory;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\file\FileInterface;
use Drupal\filter\Attribute\Filter;
use Drupal\filter\FilterProcessResult;
use Drupal\filter\Plugin\FilterBase;
use Drupal\filter\Plugin\FilterInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a filter to track images uploaded via a Text Editor.
*
* Generates file URLs and associates the cache tags of referenced files.
*/
#[Filter(
id: "editor_file_reference",
title: new TranslatableMarkup("Track images uploaded via a Text Editor"),
description: new TranslatableMarkup("Ensures that the latest versions of images uploaded via a Text Editor are displayed, along with their dimensions."),
type: FilterInterface::TYPE_TRANSFORM_REVERSIBLE
)]
class EditorFileReference extends FilterBase implements ContainerFactoryPluginInterface {
/**
* The entity repository.
*
* @var \Drupal\Core\Entity\EntityRepositoryInterface
*/
protected $entityRepository;
/**
* The image factory.
*
* @var \Drupal\Core\Image\ImageFactory
*/
protected $imageFactory;
/**
* Constructs a \Drupal\editor\Plugin\Filter\EditorFileReference object.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository
* The entity repository.
* @param \Drupal\Core\Image\ImageFactory $image_factory
* The image factory.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityRepositoryInterface $entity_repository, ImageFactory $image_factory) {
$this->entityRepository = $entity_repository;
$this->imageFactory = $image_factory;
parent::__construct($configuration, $plugin_id, $plugin_definition);
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('entity.repository'),
$container->get('image.factory')
);
}
/**
* {@inheritdoc}
*/
public function process($text, $langcode) {
$result = new FilterProcessResult($text);
if (stristr($text, 'data-entity-type="file"') !== FALSE) {
$dom = Html::load($text);
$xpath = new \DOMXPath($dom);
$processed_uuids = [];
foreach ($xpath->query('//*[@data-entity-type="file" and @data-entity-uuid]') as $node) {
$uuid = $node->getAttribute('data-entity-uuid');
// If there is a 'src' attribute, set it to the file entity's current
// URL. This ensures the URL works even after the file location changes.
if ($node->hasAttribute('src')) {
$file = $this->entityRepository->loadEntityByUuid('file', $uuid);
if ($file instanceof FileInterface) {
$node->setAttribute('src', $file->createFileUrl());
if ($node->nodeName == 'img') {
$image = $this->imageFactory->get($file->getFileUri());
$width = $image->getWidth();
$height = $image->getHeight();
// Set dimensions to avoid content layout shift (CLS).
// @see https://web.dev/cls/
if ($width !== NULL && !$node->hasAttribute('width')) {
$node->setAttribute('width', (string) $width);
}
if ($height !== NULL && !$node->hasAttribute('height')) {
$node->setAttribute('height', (string) $height);
}
}
}
}
// Only process the first occurrence of each file UUID.
if (!isset($processed_uuids[$uuid])) {
$processed_uuids[$uuid] = TRUE;
$file = $this->entityRepository->loadEntityByUuid('file', $uuid);
if ($file instanceof FileInterface) {
$result->addCacheTags($file->getCacheTags());
}
}
}
$result->setProcessedText(Html::serialize($dom));
}
return $result;
}
}

View File

@@ -0,0 +1,41 @@
<?php
/**
* @file
* Test fixture.
*/
use Drupal\Core\Database\Database;
use Drupal\Core\Serialization\Yaml;
$connection = Database::getConnection();
$umami_basic_html_format = Yaml::decode(file_get_contents(__DIR__ . '/filter.format.umami_basic_html.yml'));
$umami_basic_html_format['format'] = 'umami_basic_html';
$connection->insert('config')
->fields([
'collection',
'name',
'data',
])
->values([
'collection' => '',
'name' => 'filter.format.umami_basic_html',
'data' => serialize($umami_basic_html_format),
])
->execute();
$umami_basic_html_editor = Yaml::decode(file_get_contents(__DIR__ . '/editor.editor.umami_basic_html.yml'));
$umami_basic_html_editor['format'] = 'umami_basic_html';
$connection->insert('config')
->fields([
'collection',
'name',
'data',
])
->values([
'collection' => '',
'name' => 'editor.editor.umami_basic_html',
'data' => serialize($umami_basic_html_editor),
])
->execute();

View File

@@ -0,0 +1,64 @@
uuid: c82794ef-c451-49c6-be67-39e2b0649a47
langcode: en
status: true
dependencies:
config:
- filter.format.basic_html
module:
- ckeditor5
format: basic_html
editor: ckeditor5
settings:
toolbar:
items:
- bold
- italic
- '|'
- link
- '|'
- bulletedList
- numberedList
- '|'
- blockQuote
- '|'
- heading
- '|'
- sourceEditing
- '|'
plugins:
ckeditor5_heading:
enabled_headings:
- heading2
- heading3
- heading4
- heading5
- heading6
ckeditor5_list:
properties:
reversed: false
startIndex: true
multiBlock: false
ckeditor5_sourceEditing:
allowed_tags:
- '<cite>'
- '<dl>'
- '<dt>'
- '<dd>'
- '<a hreflang>'
- '<blockquote cite>'
- '<ul type>'
- '<ol type>'
- '<h2 id>'
- '<h3 id>'
- '<h4 id>'
- '<h5 id>'
- '<h6 id>'
- '<img src alt data-entity-type data-entity-uuid data-align data-caption width height loading>'
image_upload:
status: false
scheme: public
directory: inline-images
max_size: ''
max_dimensions:
width: null
height: null

View File

@@ -0,0 +1,55 @@
uuid: 32fd1f3a-8ea1-44be-851f-64659c260bea
langcode: en
status: true
dependencies:
module:
- editor
name: 'Basic HTML'
format: basic_html
weight: 0
filters:
editor_file_reference:
id: editor_file_reference
provider: editor
status: true
weight: 11
settings: { }
filter_align:
id: filter_align
provider: filter
status: true
weight: 7
settings: { }
filter_autop:
id: filter_autop
provider: filter
status: true
weight: 0
settings: { }
filter_caption:
id: filter_caption
provider: filter
status: true
weight: 8
settings: { }
filter_html:
id: filter_html
provider: filter
status: true
weight: -10
settings:
allowed_html: '<a href hreflang> <em> <strong> <cite> <blockquote cite> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id> <p> <br> <img src alt loading height width data-entity-type data-entity-uuid data-align data-caption> <drupal-media data-entity-type data-entity-uuid data-view-mode data-align data-caption alt title>'
filter_html_help: false
filter_html_nofollow: false
filter_html_image_secure:
id: filter_html_image_secure
provider: filter
status: true
weight: 9
settings: { }
filter_image_lazy_load:
id: filter_image_lazy_load
provider: filter
status: true
weight: 15
settings: { }

View File

@@ -0,0 +1,13 @@
name: 'Text Editor Private test'
type: module
description: 'Support module for the Text Editor Private module tests.'
package: Testing
# version: VERSION
dependencies:
- drupal:filter
- drupal:editor_test
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,12 @@
name: 'Text Editor test'
type: module
description: 'Support module for the Text Editor module tests.'
package: Testing
# version: VERSION
dependencies:
- drupal:editor
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,8 @@
unicorn:
version: VERSION
js:
unicorn.js: {}
trex:
version: VERSION
js:
trex.js: {}

View File

@@ -0,0 +1,93 @@
<?php
/**
* @file
* Helper module for the Text Editor tests.
*/
use Drupal\Core\Entity\EntityInterface;
use Drupal\node\NodeInterface;
use Drupal\filter\FilterFormatInterface;
use Drupal\file\FileInterface;
/**
* Implements hook_entity_update().
*
* @see \Drupal\Tests\editor\Kernel\EntityUpdateTest
*/
function editor_test_entity_update(EntityInterface $entity) {
// Only act on nodes.
if (!$entity instanceof NodeInterface) {
return;
}
// Avoid infinite loop by only going through our post save logic once.
if (!empty($entity->editor_test_updating)) {
return;
}
// Set flag for whether or not the entity needs to be resaved.
$needs_update = FALSE;
// Perform our post save logic.
if ($entity->title->value == 'test updated') {
// Change the node title.
$entity->title->value = 'test updated 2';
$needs_update = TRUE;
}
if ($needs_update) {
// Set flag on entity that our logic was already executed.
$entity->editor_test_updating = TRUE;
// And resave entity.
$entity->save();
}
}
/**
* Implements hook_editor_js_settings_alter().
*/
function editor_test_editor_js_settings_alter(&$settings) {
// Allow tests to enable or disable this alter hook.
if (!\Drupal::state()->get('editor_test_js_settings_alter_enabled', FALSE)) {
return;
}
if (isset($settings['editor']['formats']['full_html'])) {
$settings['editor']['formats']['full_html']['editorSettings']['ponyModeEnabled'] = FALSE;
}
}
/**
* Implements hook_editor_xss_filter_alter().
*/
function editor_test_editor_xss_filter_alter(&$editor_xss_filter_class, FilterFormatInterface $format, ?FilterFormatInterface $original_format = NULL) {
// Allow tests to enable or disable this alter hook.
if (!\Drupal::state()->get('editor_test_editor_xss_filter_alter_enabled', FALSE)) {
return;
}
$filters = $format->filters()->getAll();
if (isset($filters['filter_html']) && $filters['filter_html']->status) {
$editor_xss_filter_class = '\Drupal\editor_test\EditorXssFilter\Insecure';
}
}
/**
* Implements hook_editor_info_alter().
*/
function editor_test_editor_info_alter(&$items) {
if (!\Drupal::state()->get('editor_test_give_me_a_trex_thanks', FALSE)) {
unset($items['trex']);
}
}
/**
* Implements hook_ENTITY_TYPE_presave() for file entities.
*/
function editor_test_file_presave(FileInterface $file) {
// Use state to keep track of how many times a file is saved.
$file_save_count = \Drupal::state()->get('editor_test.file_save_count', []);
$file_save_count[$file->getFilename()] = isset($file_save_count[$file->getFilename()]) ? $file_save_count[$file->getFilename()] + 1 : 1;
\Drupal::state()->set('editor_test.file_save_count', $file_save_count);
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Drupal\editor_test\EditorXssFilter;
use Drupal\filter\FilterFormatInterface;
use Drupal\editor\EditorXssFilterInterface;
/**
* Defines an insecure text editor XSS filter (for testing purposes).
*/
class Insecure implements EditorXssFilterInterface {
/**
* {@inheritdoc}
*/
public static function filterXss($html, FilterFormatInterface $format, ?FilterFormatInterface $original_format = NULL) {
// Don't apply any XSS filtering, just return the string we received.
return $html;
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace Drupal\editor_test\Plugin\Editor;
use Drupal\Core\Form\FormStateInterface;
use Drupal\editor\Attribute\Editor;
use Drupal\editor\Entity\Editor as EditorEntity;
use Drupal\editor\Plugin\EditorBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Defines a Tyrannosaurus-Rex powered text editor for testing purposes.
*/
#[Editor(
id: 'trex',
label: new TranslatableMarkup('TRex Editor'),
supports_content_filtering: TRUE,
supports_inline_editing: TRUE,
is_xss_safe: FALSE,
supported_element_types: [
'textarea',
]
)]
class TRexEditor extends EditorBase {
/**
* {@inheritdoc}
*/
public function getDefaultSettings() {
return ['stumpy_arms' => TRUE];
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form['stumpy_arms'] = [
'#title' => $this->t('Stumpy arms'),
'#type' => 'checkbox',
'#default_value' => TRUE,
];
return $form;
}
/**
* {@inheritdoc}
*/
public function getJSSettings(EditorEntity $editor) {
$js_settings = [];
$settings = $editor->getSettings();
if ($settings['stumpy_arms']) {
$js_settings['doMyArmsLookStumpy'] = TRUE;
}
return $js_settings;
}
/**
* {@inheritdoc}
*/
public function getLibraries(EditorEntity $editor) {
return [
'editor_test/trex',
];
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace Drupal\editor_test\Plugin\Editor;
use Drupal\Core\Form\FormStateInterface;
use Drupal\editor\Attribute\Editor;
use Drupal\editor\Entity\Editor as EditorEntity;
use Drupal\editor\Plugin\EditorBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Defines a Unicorn-powered text editor for Drupal (for testing purposes).
*/
#[Editor(
id: 'unicorn',
label: new TranslatableMarkup('Unicorn Editor'),
supports_content_filtering: TRUE,
supports_inline_editing: TRUE,
is_xss_safe: FALSE,
supported_element_types: [
'textarea',
'textfield',
]
)]
class UnicornEditor extends EditorBase {
/**
* {@inheritdoc}
*/
public function getDefaultSettings() {
return ['ponies_too' => TRUE];
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form['ponies_too'] = [
'#title' => $this->t('Pony mode'),
'#type' => 'checkbox',
'#default_value' => TRUE,
];
$form_state->loadInclude('editor', 'admin.inc');
$form['image_upload'] = editor_image_upload_settings_form($form_state->get('editor'));
$form['image_upload']['#element_validate'][] = [$this, 'validateImageUploadSettings'];
return $form;
}
/**
* #element_validate handler for "image_upload" in buildConfigurationForm().
*
* Moves the text editor's image upload settings into $editor->image_upload.
*
* @see editor_image_upload_settings_form()
*/
public function validateImageUploadSettings(array $element, FormStateInterface $form_state) {
$settings = &$form_state->getValue(['editor', 'settings', 'image_upload']);
$form_state->get('editor')->setImageUploadSettings($settings);
$form_state->unsetValue(['editor', 'settings', 'image_upload']);
}
/**
* {@inheritdoc}
*/
public function getJSSettings(EditorEntity $editor) {
$js_settings = [];
$settings = $editor->getSettings();
if ($settings['ponies_too']) {
$js_settings['ponyModeEnabled'] = TRUE;
}
return $js_settings;
}
/**
* {@inheritdoc}
*/
public function getLibraries(EditorEntity $editor) {
return [
'editor_test/unicorn',
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Drupal\editor_test\Plugin\Field\FieldType;
use Drupal\Core\Field\Attribute\FieldType;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\text\Plugin\Field\FieldType\TextLongItem;
/**
* Plugin implementation of the 'editor_test_text_long' field type.
*/
#[FieldType(
id: "editor_test_text_long",
label: new TranslatableMarkup("Filter test text (formatted, long)"),
description: new TranslatableMarkup("This field stores a long text with a text format."),
default_widget: "text_textarea",
default_formatter: "text_default"
)]
class EditorTestTextLongItem extends TextLongItem {
}

View File

@@ -0,0 +1,257 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\Functional;
use Drupal\Component\Render\FormattableMarkup;
use Drupal\filter\Entity\FilterFormat;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\BrowserTestBase;
/**
* Tests administration of text editors.
*
* @group editor
*/
class EditorAdminTest extends BrowserTestBase {
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['filter', 'editor'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* A user with the 'administer filters' permission.
*
* @var \Drupal\user\UserInterface
*/
protected $adminUser;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Add text format.
$filtered_html_format = FilterFormat::create([
'format' => 'filtered_html',
'name' => 'Filtered HTML',
'weight' => 0,
'filters' => [],
]);
$filtered_html_format->save();
// Create admin user.
$this->adminUser = $this->drupalCreateUser(['administer filters']);
}
/**
* Tests an existing format without any editors available.
*/
public function testNoEditorAvailable(): void {
$this->drupalLogin($this->adminUser);
$this->drupalGet('admin/config/content/formats/manage/filtered_html');
// Ensure the form field order is correct.
$raw_content = $this->getSession()->getPage()->getContent();
$roles_pos = strpos($raw_content, 'Roles');
$editor_pos = strpos($raw_content, 'Text editor');
$filters_pos = strpos($raw_content, 'Enabled filters');
$this->assertGreaterThan($roles_pos, $editor_pos);
$this->assertLessThan($filters_pos, $editor_pos);
// Verify the <select>.
$select = $this->assertSession()->selectExists('editor[editor]');
$this->assertSame('disabled', $select->getAttribute('disabled'));
$options = $select->findAll('css', 'option');
$this->assertCount(1, $options);
$this->assertSame('None', $options[0]->getText(), 'Option 1 in the Text Editor select is "None".');
$this->assertSession()->pageTextContains('This option is disabled because no modules that provide a text editor are currently enabled.');
}
/**
* Tests adding a text editor to an existing text format.
*/
public function testAddEditorToExistingFormat(): void {
$this->enableUnicornEditor();
$this->drupalLogin($this->adminUser);
$this->drupalGet('admin/config/content/formats/manage/filtered_html');
$edit = $this->selectUnicornEditor();
// Configure Unicorn Editor's setting to another value.
$edit['editor[settings][ponies_too]'] = FALSE;
$this->submitForm($edit, 'Save configuration');
$this->verifyUnicornEditorConfiguration('filtered_html', FALSE);
// Switch back to 'None' and check the Unicorn Editor's settings are gone.
$edit = [
'editor[editor]' => '',
];
$this->submitForm($edit, 'Configure');
$this->assertSession()->fieldNotExists('editor[settings][ponies_too]');
}
/**
* Tests adding a text editor to a new text format.
*/
public function testAddEditorToNewFormat(): void {
$this->addEditorToNewFormat('monoceros', 'Monoceros');
$this->verifyUnicornEditorConfiguration('monoceros');
}
/**
* Tests format disabling.
*/
public function testDisableFormatWithEditor(): void {
$formats = ['monoceros' => 'Monoceros', 'tattoo' => 'Tattoo'];
// Install the node module.
$this->container->get('module_installer')->install(['node']);
$this->resetAll();
// Create a new node type and attach the 'body' field to it.
$node_type = NodeType::create(['type' => $this->randomMachineName(), 'name' => $this->randomString()]);
$node_type->save();
node_add_body_field($node_type, $this->randomString());
$permissions = ['administer filters', "edit any {$node_type->id()} content"];
foreach ($formats as $format => $name) {
// Create a format and add an editor to this format.
$this->addEditorToNewFormat($format, $name);
// Add permission for this format.
$permissions[] = "use text format $format";
}
// Create a node having the body format value 'monoceros'.
$node = Node::create([
'type' => $node_type->id(),
'title' => $this->randomString(),
]);
$node->body->value = $this->randomString(100);
$node->body->format = 'monoceros';
$node->save();
// Log in as a user able to use both formats and edit nodes of created type.
$account = $this->drupalCreateUser($permissions);
$this->drupalLogin($account);
// The node edit page header.
$text = (string) new FormattableMarkup('<em>Edit @type</em> @title', ['@type' => $node_type->label(), '@title' => $node->label()]);
// Go to node edit form.
$this->drupalGet('node/' . $node->id() . '/edit');
$this->assertSession()->responseContains($text);
// Disable the format assigned to the 'body' field of the node.
FilterFormat::load('monoceros')->disable()->save();
// Edit again the node.
$this->drupalGet('node/' . $node->id() . '/edit');
$this->assertSession()->responseContains($text);
}
/**
* Tests switching text editor to none does not throw a TypeError.
*/
public function testSwitchEditorToNone(): void {
$this->enableUnicornEditor();
$this->drupalLogin($this->adminUser);
$this->drupalGet('admin/config/content/formats/manage/filtered_html');
$edit = $this->selectUnicornEditor();
// Switch editor to 'None'.
$edit = [
'editor[editor]' => '',
];
$this->submitForm($edit, 'Configure');
$this->submitForm($edit, 'Save configuration');
}
/**
* Adds an editor to a new format using the UI.
*
* @param string $format_id
* The format id.
* @param string $format_name
* The format name.
*/
protected function addEditorToNewFormat($format_id, $format_name) {
$this->enableUnicornEditor();
$this->drupalLogin($this->adminUser);
$this->drupalGet('admin/config/content/formats/add');
// Configure the text format name.
$edit = [
'name' => $format_name,
'format' => $format_id,
];
$edit += $this->selectUnicornEditor();
$this->submitForm($edit, 'Save configuration');
}
/**
* Enables the unicorn editor.
*/
protected function enableUnicornEditor() {
if (!$this->container->get('module_handler')->moduleExists('editor_test')) {
$this->container->get('module_installer')->install(['editor_test']);
}
}
/**
* Tests and selects the unicorn editor.
*
* @return array
* Returns an edit array containing the values to be posted.
*/
protected function selectUnicornEditor() {
// Verify the <select> when a text editor is available.
$select = $this->assertSession()->selectExists('editor[editor]');
$this->assertFalse($select->hasAttribute('disabled'));
$options = $select->findAll('css', 'option');
$this->assertCount(2, $options);
$this->assertSame('None', $options[0]->getText(), 'Option 1 in the Text Editor select is "None".');
$this->assertSame('Unicorn Editor', $options[1]->getText(), 'Option 2 in the Text Editor select is "Unicorn Editor".');
$this->assertTrue($options[0]->hasAttribute('selected'), 'Option 1 ("None") is selected.');
// Ensure the none option is selected.
$this->assertSession()->pageTextNotContains('This option is disabled because no modules that provide a text editor are currently enabled.');
// Select the "Unicorn Editor" editor and click the "Configure" button.
$edit = [
'editor[editor]' => 'unicorn',
];
$this->submitForm($edit, 'Configure');
$this->assertSession()->checkboxChecked('editor[settings][ponies_too]');
return $edit;
}
/**
* Verifies unicorn editor configuration.
*
* @param string $format_id
* The format machine name.
* @param bool $ponies_too
* The expected value of the ponies_too setting.
*/
protected function verifyUnicornEditorConfiguration($format_id, $ponies_too = TRUE) {
$editor = editor_load($format_id);
$settings = $editor->getSettings();
$this->assertSame('unicorn', $editor->getEditor(), 'The text editor is configured correctly.');
$this->assertSame($ponies_too, $settings['ponies_too'], 'The text editor settings are stored correctly.');
$this->drupalGet('admin/config/content/formats/manage/' . $format_id);
$select = $this->assertSession()->selectExists('editor[editor]');
$this->assertFalse($select->hasAttribute('disabled'));
$options = $select->findAll('css', 'option');
$this->assertCount(2, $options);
$this->assertTrue($options[1]->isSelected(), 'Option 2 ("Unicorn Editor") is selected.');
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\Functional;
use Drupal\Core\Url;
use Drupal\editor\Entity\Editor;
use Drupal\Tests\BrowserTestBase;
/**
* Test access to the editor dialog forms.
*
* @group editor
*/
class EditorDialogAccessTest extends BrowserTestBase {
/**
* Modules to install.
*
* @var array
*/
protected static $modules = ['editor', 'filter', 'text', 'editor_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests access to the editor image dialog.
*/
public function testEditorImageDialogAccess(): void {
$url = Url::fromRoute('editor.image_dialog', ['editor' => 'plain_text']);
$session = $this->assertSession();
// With no text editor, expect a 404.
$this->drupalGet($url);
$session->statusCodeEquals(404);
// With a text editor but without image upload settings, expect a 200, but
// there should not be an input[type=file].
$editor = Editor::create([
'editor' => 'unicorn',
'format' => 'plain_text',
'image_upload' => [
'status' => FALSE,
],
]);
$editor->save();
$this->resetAll();
$this->drupalGet($url);
$this->assertNotEmpty($this->cssSelect('input[type=text][name="attributes[src]"]'), 'Image uploads disabled: input[type=text][name="attributes[src]"] is present.');
$this->assertEmpty($this->cssSelect('input[type=file]'), 'Image uploads disabled: input[type=file] is absent.');
$session->statusCodeEquals(200);
// With image upload settings, expect a 200, and now there should be an
// input[type=file].
$editor->setImageUploadSettings([
'status' => TRUE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => NULL,
'max_dimensions' => [
'width' => NULL,
'height' => NULL,
],
])->save();
$this->resetAll();
$this->drupalGet($url);
$this->assertEmpty($this->cssSelect('input[type=text][name="attributes[src]"]'), 'Image uploads enabled: input[type=text][name="attributes[src]"] is absent.');
$this->assertNotEmpty($this->cssSelect('input[type=file]'), 'Image uploads enabled: input[type=file] is present.');
$session->statusCodeEquals(200);
}
}

View File

@@ -0,0 +1,327 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\Functional;
use Drupal\editor\Entity\Editor;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\filter\Entity\FilterFormat;
use Drupal\Tests\BrowserTestBase;
/**
* Tests loading of text editors.
*
* @group editor
*/
class EditorLoadingTest extends BrowserTestBase {
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['filter', 'editor', 'editor_test', 'node'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* An untrusted user, with access to the 'plain_text' format.
*
* @var \Drupal\user\UserInterface
*/
protected $untrustedUser;
/**
* A normal user with additional access to the 'filtered_html' format.
*
* @var \Drupal\user\UserInterface
*/
protected $normalUser;
/**
* A privileged user with additional access to the 'full_html' format.
*
* @var \Drupal\user\UserInterface
*/
protected $privilegedUser;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Let there be T-rex.
\Drupal::state()->set('editor_test_give_me_a_trex_thanks', TRUE);
\Drupal::service('plugin.manager.editor')->clearCachedDefinitions();
// Add text formats.
$filtered_html_format = FilterFormat::create([
'format' => 'filtered_html',
'name' => 'Filtered HTML',
'weight' => 0,
'filters' => [],
]);
$filtered_html_format->save();
$full_html_format = FilterFormat::create([
'format' => 'full_html',
'name' => 'Full HTML',
'weight' => 1,
'filters' => [],
]);
$full_html_format->save();
// Create article node type.
$this->drupalCreateContentType([
'type' => 'article',
'name' => 'Article',
]);
// Create page node type, but remove the body.
$this->drupalCreateContentType([
'type' => 'page',
'name' => 'Page',
]);
$body = FieldConfig::loadByName('node', 'page', 'body');
$body->delete();
// Create a formatted text field, which uses an <input type="text">.
FieldStorageConfig::create([
'field_name' => 'field_text',
'entity_type' => 'node',
'type' => 'text',
])->save();
FieldConfig::create([
'field_name' => 'field_text',
'entity_type' => 'node',
'label' => 'Textfield',
'bundle' => 'page',
])->save();
\Drupal::service('entity_display.repository')
->getFormDisplay('node', 'page')
->setComponent('field_text')
->save();
// Create 3 users, each with access to different text formats.
$this->untrustedUser = $this->drupalCreateUser([
'create article content',
'edit any article content',
]);
$this->normalUser = $this->drupalCreateUser([
'create article content',
'edit any article content',
'use text format filtered_html',
]);
$this->privilegedUser = $this->drupalCreateUser([
'create article content',
'edit any article content',
'create page content',
'edit any page content',
'use text format filtered_html',
'use text format full_html',
]);
}
/**
* Tests loading of text editors.
*/
public function testLoading(): void {
// Only associate a text editor with the "Full HTML" text format.
$editor = Editor::create([
'format' => 'full_html',
'editor' => 'unicorn',
'image_upload' => [
'status' => FALSE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => '',
'max_dimensions' => ['width' => '', 'height' => ''],
],
]);
$editor->save();
// The normal user:
// - has access to 2 text formats;
// - doesn't have access to the full_html text format, so: no text editor.
$this->drupalLogin($this->normalUser);
$this->drupalGet('node/add/article');
[, $editor_settings_present, $editor_js_present, $body] = $this->getThingsToCheck('body');
$this->assertFalse($editor_settings_present, 'No Text Editor module settings.');
$this->assertFalse($editor_js_present, 'No Text Editor JavaScript.');
$this->assertSession()->elementsCount('xpath', $body, 1);
$this->assertSession()->elementNotExists('css', 'select.js-filter-list');
$this->drupalLogout();
// The privileged user:
// - has access to 2 text formats (and the fallback format);
// - does have access to the full_html text format, so: Unicorn text editor.
$this->drupalLogin($this->privilegedUser);
$this->drupalGet('node/add/article');
[$settings, $editor_settings_present, $editor_js_present, $body] = $this->getThingsToCheck('body');
$expected = [
'formats' => [
'full_html' => [
'format' => 'full_html',
'editor' => 'unicorn',
'editorSettings' => ['ponyModeEnabled' => TRUE],
'editorSupportsContentFiltering' => TRUE,
'isXssSafe' => FALSE,
],
],
];
$this->assertTrue($editor_settings_present, "Text Editor module's JavaScript settings are on the page.");
$this->assertSame($expected, $settings['editor'], "Text Editor module's JavaScript settings on the page are correct.");
$this->assertTrue($editor_js_present, 'Text Editor JavaScript is present.');
$this->assertSession()->elementsCount('xpath', $body, 1);
$this->assertSession()->elementsCount('css', 'select.js-filter-list', 1);
$select = $this->assertSession()->elementExists('css', 'select.js-filter-list');
$this->assertSame('edit-body-0-value', $select->getAttribute('data-editor-for'));
// Load the editor image dialog form and make sure it does not fatal.
$this->drupalGet('editor/dialog/image/full_html');
$this->assertSession()->statusCodeEquals(200);
$this->drupalLogout();
// Also associate a text editor with the "Plain Text" text format.
$editor = Editor::create([
'format' => 'plain_text',
'editor' => 'unicorn',
]);
$editor->save();
// The untrusted user:
// - has access to 1 text format (plain_text);
// - has access to the plain_text text format, so: Unicorn text editor.
$this->drupalLogin($this->untrustedUser);
$this->drupalGet('node/add/article');
[$settings, $editor_settings_present, $editor_js_present, $body] = $this->getThingsToCheck('body');
$expected = [
'formats' => [
'plain_text' => [
'format' => 'plain_text',
'editor' => 'unicorn',
'editorSettings' => ['ponyModeEnabled' => TRUE],
'editorSupportsContentFiltering' => TRUE,
'isXssSafe' => FALSE,
],
],
];
$this->assertTrue($editor_settings_present, "Text Editor module's JavaScript settings are on the page.");
$this->assertSame($expected, $settings['editor'], "Text Editor module's JavaScript settings on the page are correct.");
$this->assertTrue($editor_js_present, 'Text Editor JavaScript is present.');
$this->assertSession()->elementsCount('xpath', $body, 1);
$this->assertSession()->elementNotExists('css', 'select.js-filter-list');
// Verify that a single text format hidden input exists on the page and has
// a "data-editor-for" attribute with the correct value.
$hidden_input = $this->assertSession()->hiddenFieldExists('body[0][format]');
$this->assertSame('plain_text', $hidden_input->getValue());
$this->assertSame('edit-body-0-value', $hidden_input->getAttribute('data-editor-for'));
// Create an "article" node that uses the full_html text format, then try
// to let the untrusted user edit it.
$this->drupalCreateNode([
'type' => 'article',
'body' => [
['value' => $this->randomMachineName(32), 'format' => 'full_html'],
],
]);
// The untrusted user tries to edit content that is written in a text format
// that they are not allowed to use. The editor is still loaded. CKEditor,
// for example, supports being loaded in a disabled state.
$this->drupalGet('node/1/edit');
[, $editor_settings_present, $editor_js_present, $body] = $this->getThingsToCheck('body');
$this->assertTrue($editor_settings_present, 'Text Editor module settings.');
$this->assertTrue($editor_js_present, 'Text Editor JavaScript.');
$this->assertSession()->elementsCount('xpath', $body, 1);
$this->assertSession()->fieldDisabled("edit-body-0-value");
$this->assertSession()->fieldValueEquals("edit-body-0-value", 'This field has been disabled because you do not have sufficient permissions to edit it.');
$this->assertSession()->elementNotExists('css', 'select.js-filter-list');
// Verify that no single text format hidden input exists on the page.
$this->assertSession()->elementNotExists('xpath', '//input[@type="hidden" and contains(@class, "editor")]');
}
/**
* Tests supported element types.
*/
public function testSupportedElementTypes(): void {
// Associate the unicorn text editor with the "Full HTML" text format.
$editor = Editor::create([
'format' => 'full_html',
'editor' => 'unicorn',
'image_upload' => [
'status' => FALSE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => '',
'max_dimensions' => ['width' => '', 'height' => ''],
],
]);
$editor->save();
// Create a "page" node that uses the full_html text format.
$this->drupalCreateNode([
'type' => 'page',
'field_text' => [
['value' => $this->randomMachineName(32), 'format' => 'full_html'],
],
]);
// Assert the unicorn editor works with textfields.
$this->drupalLogin($this->privilegedUser);
$this->drupalGet('node/1/edit');
[, $editor_settings_present, $editor_js_present, $field] = $this->getThingsToCheck('field-text', 'input');
$this->assertTrue($editor_settings_present, "Text Editor module's JavaScript settings are on the page.");
$this->assertTrue($editor_js_present, 'Text Editor JavaScript is present.');
$this->assertSession()->elementsCount('xpath', $field, 1);
// Verify that a single text format selector exists on the page and has the
// "editor" class and a "data-editor-for" attribute with the correct value.
$this->assertSession()->elementsCount('css', 'select.js-filter-list', 1);
$select = $this->assertSession()->elementExists('css', 'select.js-filter-list');
$this->assertStringContainsString('editor', $select->getAttribute('class'));
$this->assertSame('edit-field-text-0-value', $select->getAttribute('data-editor-for'));
// Associate the trex text editor with the "Full HTML" text format.
$editor->delete();
Editor::create([
'format' => 'full_html',
'editor' => 'trex',
])->save();
$this->drupalGet('node/1/edit');
[, $editor_settings_present, $editor_js_present, $field] = $this->getThingsToCheck('field-text', 'input');
$this->assertFalse($editor_settings_present, "Text Editor module's JavaScript settings are not on the page.");
$this->assertFalse($editor_js_present, 'Text Editor JavaScript is not present.');
$this->assertSession()->elementsCount('xpath', $field, 1);
// Verify that a single text format selector exists on the page but without
// the "editor" class or a "data-editor-for" attribute with the expected
// value.
$this->assertSession()->elementsCount('css', 'select.js-filter-list', 1);
$select = $this->assertSession()->elementExists('css', 'select.js-filter-list');
$this->assertStringNotContainsString('editor', $select->getAttribute('class'));
$this->assertNotSame('edit-field-text-0-value', $select->getAttribute('data-editor-for'));
}
protected function getThingsToCheck($field_name, $type = 'textarea') {
$settings = $this->getDrupalSettings();
return [
// JavaScript settings.
$settings,
// Editor.module's JS settings present.
isset($settings['editor']),
// Editor.module's JS present.
str_contains($this->getSession()->getPage()->getContent(), $this->getModulePath('editor') . '/js/editor.js'),
// Body field.
'//' . $type . '[@id="edit-' . $field_name . '-0-value"]',
];
}
}

View File

@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\Functional;
use Drupal\file\Entity\File;
use Drupal\node\NodeInterface;
use Drupal\Tests\BrowserTestBase;
use Drupal\user\Entity\Role;
use Drupal\user\RoleInterface;
/**
* Tests Editor module's file reference filter with private files.
*
* @group editor
*/
class EditorPrivateFileReferenceFilterTest extends BrowserTestBase {
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = [
'editor_test',
// Depends on filter.module (indirectly).
'node',
// Pulls in the config we're using during testing which create a text format
// - with the filter_html_image_secure filter DISABLED,
// - with the editor set to Unicorn editor,
// - with drupalimage.image_upload.scheme set to 'private',
// - with drupalimage.image_upload.directory set to ''.
'editor_private_test',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests the editor file reference filter with private files.
*/
public function testEditorPrivateFileReferenceFilter(): void {
$author = $this->drupalCreateUser();
$this->drupalLogin($author);
// Create a content type with a body field.
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']);
// Create a file in the 'private:// ' stream.
$filename = 'test.png';
$src = '/system/files/' . $filename;
/** @var \Drupal\file\FileInterface $file */
$file = File::create([
'uri' => 'private://' . $filename,
]);
$file->setTemporary();
$file->setOwner($author);
// Create the file itself.
file_put_contents($file->getFileUri(), $this->randomString());
$file->save();
// The image should be visible for its author.
$this->drupalGet($src);
$this->assertSession()->statusCodeEquals(200);
// The not-yet-permanent image should NOT be visible for anonymous.
$this->drupalLogout();
$this->drupalGet($src);
$this->assertSession()->statusCodeEquals(403);
// Resave the file to be permanent.
$file->setPermanent();
$file->save();
// Create some nodes to ensure file usage count does not match the ID's
// of the nodes we are going to check.
for ($i = 0; $i < 5; $i++) {
$this->drupalCreateNode([
'type' => 'page',
'uid' => $author->id(),
]);
}
// Create a node with its body field properly pointing to the just-created
// file.
$published_node = $this->drupalCreateNode([
'type' => 'page',
'body' => [
'value' => '<img alt="alt" data-entity-type="file" data-entity-uuid="' . $file->uuid() . '" src="' . $src . '" />',
'format' => 'private_images',
],
'uid' => $author->id(),
]);
// Create an unpublished node with its body field properly pointing to the
// just-created file.
$unpublished_node = $this->drupalCreateNode([
'type' => 'page',
'status' => NodeInterface::NOT_PUBLISHED,
'body' => [
'value' => '<img alt="alt" data-entity-type="file" data-entity-uuid="' . $file->uuid() . '" src="' . $src . '" />',
'format' => 'private_images',
],
'uid' => $author->id(),
]);
// Do the actual test. The image should be visible for anonymous users,
// because they can view the published node. Even though they can't view
// the unpublished node.
$this->drupalGet($published_node->toUrl());
$this->assertSession()->statusCodeEquals(200);
$this->drupalGet($unpublished_node->toUrl());
$this->assertSession()->statusCodeEquals(403);
$this->drupalGet($src);
$this->assertSession()->statusCodeEquals(200);
// When the published node is also unpublished, the image should also
// become inaccessible to anonymous users.
$published_node->setUnpublished()->save();
$this->drupalGet($published_node->toUrl());
$this->assertSession()->statusCodeEquals(403);
$this->drupalGet($src);
$this->assertSession()->statusCodeEquals(403);
// Disallow anonymous users to view the entity, which then should also
// disallow them to view the image.
$published_node->setPublished()->save();
Role::load(RoleInterface::ANONYMOUS_ID)
->revokePermission('access content')
->save();
$this->drupalGet($published_node->toUrl());
$this->assertSession()->statusCodeEquals(403);
$this->drupalGet($src);
$this->assertSession()->statusCodeEquals(403);
}
}

View File

@@ -0,0 +1,450 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\Functional;
use Drupal\Component\Serialization\Json;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\Tests\BrowserTestBase;
/**
* Tests XSS protection for content creators when using text editors.
*
* @group editor
*/
class EditorSecurityTest extends BrowserTestBase {
/**
* The sample content to use in all tests.
*
* @var string
*/
protected static $sampleContent = '<p style="color: red">Hello, Dumbo Octopus!</p><script>alert(0)</script><embed type="image/svg+xml" src="image.svg" />';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* The secured sample content to use in most tests.
*
* @var string
*/
protected static $sampleContentSecured = '<p>Hello, Dumbo Octopus!</p>alert(0)';
/**
* The secured sample content to use in tests when the <embed> tag is allowed.
*
* @var string
*/
protected static $sampleContentSecuredEmbedAllowed = '<p>Hello, Dumbo Octopus!</p>alert(0)<embed type="image/svg+xml" src="image.svg" />';
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['filter', 'editor', 'editor_test', 'node'];
/**
* User with access to Restricted HTML text format without text editor.
*
* @var \Drupal\user\UserInterface
*/
protected $untrustedUser;
/**
* User with access to Restricted HTML text format with text editor.
*
* @var \Drupal\user\UserInterface
*/
protected $normalUser;
/**
* User with access to Restricted HTML and tags considered dangerous.
*
* @var \Drupal\user\UserInterface
*/
protected $trustedUser;
/**
* User with access to all text formats and text editors.
*
* @var \Drupal\user\UserInterface
*/
protected $privilegedUser;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Create 5 text formats, to cover all potential use cases:
// 1. restricted_without_editor (untrusted: anonymous)
// 2. restricted_with_editor (normal: authenticated)
// 3. restricted_plus_dangerous_tag_with_editor (privileged: trusted)
// 4. unrestricted_without_editor (privileged: admin)
// 5. unrestricted_with_editor (privileged: admin)
// With text formats 2, 3 and 5, we also associate a text editor that does
// not guarantee XSS safety. "restricted" means the text format has XSS
// filters on output, "unrestricted" means the opposite.
$format = FilterFormat::create([
'format' => 'restricted_without_editor',
'name' => 'Restricted HTML, without text editor',
'weight' => 0,
'filters' => [
// A filter of the FilterInterface::TYPE_HTML_RESTRICTOR type.
'filter_html' => [
'status' => 1,
'settings' => [
'allowed_html' => '<h2> <h3> <h4> <h5> <h6> <p> <br> <strong> <a>',
],
],
],
]);
$format->save();
$format = FilterFormat::create([
'format' => 'restricted_with_editor',
'name' => 'Restricted HTML, with text editor',
'weight' => 1,
'filters' => [
// A filter of the FilterInterface::TYPE_HTML_RESTRICTOR type.
'filter_html' => [
'status' => 1,
'settings' => [
'allowed_html' => '<h2> <h3> <h4> <h5> <h6> <p> <br> <strong> <a>',
],
],
],
]);
$format->save();
$editor = Editor::create([
'format' => 'restricted_with_editor',
'editor' => 'unicorn',
]);
$editor->save();
$format = FilterFormat::create([
'format' => 'restricted_plus_dangerous_tag_with_editor',
'name' => 'Restricted HTML, dangerous tag allowed, with text editor',
'weight' => 1,
'filters' => [
// A filter of the FilterInterface::TYPE_HTML_RESTRICTOR type.
'filter_html' => [
'status' => 1,
'settings' => [
'allowed_html' => '<h2> <h3> <h4> <h5> <h6> <p> <br> <strong> <a> <embed>',
],
],
],
]);
$format->save();
$editor = Editor::create([
'format' => 'restricted_plus_dangerous_tag_with_editor',
'editor' => 'unicorn',
]);
$editor->save();
$format = FilterFormat::create([
'format' => 'unrestricted_without_editor',
'name' => 'Unrestricted HTML, without text editor',
'weight' => 0,
'filters' => [],
]);
$format->save();
$format = FilterFormat::create([
'format' => 'unrestricted_with_editor',
'name' => 'Unrestricted HTML, with text editor',
'weight' => 1,
'filters' => [],
]);
$format->save();
$editor = Editor::create([
'format' => 'unrestricted_with_editor',
'editor' => 'unicorn',
]);
$editor->save();
// Create node type.
$this->drupalCreateContentType([
'type' => 'article',
'name' => 'Article',
]);
// Create 4 users, each with access to different text formats/editors:
// - "untrusted": restricted_without_editor
// - "normal": restricted_with_editor,
// - "trusted": restricted_plus_dangerous_tag_with_editor
// - "privileged": restricted_without_editor, restricted_with_editor,
// restricted_plus_dangerous_tag_with_editor,
// unrestricted_without_editor and unrestricted_with_editor
$this->untrustedUser = $this->drupalCreateUser([
'create article content',
'edit any article content',
'use text format restricted_without_editor',
]);
$this->normalUser = $this->drupalCreateUser([
'create article content',
'edit any article content',
'use text format restricted_with_editor',
]);
$this->trustedUser = $this->drupalCreateUser([
'create article content',
'edit any article content',
'use text format restricted_plus_dangerous_tag_with_editor',
]);
$this->privilegedUser = $this->drupalCreateUser([
'create article content',
'edit any article content',
'use text format restricted_without_editor',
'use text format restricted_with_editor',
'use text format restricted_plus_dangerous_tag_with_editor',
'use text format unrestricted_without_editor',
'use text format unrestricted_with_editor',
]);
// Create an "article" node for each possible text format, with the same
// sample content, to do our tests on.
$samples = [
['author' => $this->untrustedUser->id(), 'format' => 'restricted_without_editor'],
['author' => $this->normalUser->id(), 'format' => 'restricted_with_editor'],
['author' => $this->trustedUser->id(), 'format' => 'restricted_plus_dangerous_tag_with_editor'],
['author' => $this->privilegedUser->id(), 'format' => 'unrestricted_without_editor'],
['author' => $this->privilegedUser->id(), 'format' => 'unrestricted_with_editor'],
];
foreach ($samples as $sample) {
$this->drupalCreateNode([
'type' => 'article',
'body' => [
['value' => self::$sampleContent, 'format' => $sample['format']],
],
'uid' => $sample['author'],
]);
}
}
/**
* Tests initial security: is the user safe without switching text formats?
*
* Tests 8 scenarios. Tests only with a text editor that is not XSS-safe.
*/
public function testInitialSecurity(): void {
$expected = [
[
'node_id' => 1,
'format' => 'restricted_without_editor',
// No text editor => no XSS filtering.
'value' => self::$sampleContent,
'users' => [
$this->untrustedUser,
$this->privilegedUser,
],
],
[
'node_id' => 2,
'format' => 'restricted_with_editor',
// Text editor => XSS filtering.
'value' => self::$sampleContentSecured,
'users' => [
$this->normalUser,
$this->privilegedUser,
],
],
[
'node_id' => 3,
'format' => 'restricted_plus_dangerous_tag_with_editor',
// Text editor => XSS filtering.
'value' => self::$sampleContentSecuredEmbedAllowed,
'users' => [
$this->trustedUser,
$this->privilegedUser,
],
],
[
'node_id' => 4,
'format' => 'unrestricted_without_editor',
// No text editor => no XSS filtering.
'value' => self::$sampleContent,
'users' => [
$this->privilegedUser,
],
],
[
'node_id' => 5,
'format' => 'unrestricted_with_editor',
// Text editor, no security filter => no XSS filtering.
'value' => self::$sampleContent,
'users' => [
$this->privilegedUser,
],
],
];
// Log in as each user that may edit the content, and assert the value.
foreach ($expected as $case) {
foreach ($case['users'] as $account) {
$this->drupalLogin($account);
$this->drupalGet('node/' . $case['node_id'] . '/edit');
// Verify that the value is correctly filtered for XSS attack vectors.
$this->assertSession()->fieldValueEquals('edit-body-0-value', $case['value']);
}
}
}
/**
* Tests administrator security: is the user safe when switching text formats?
*
* Tests 24 scenarios. Tests only with a text editor that is not XSS-safe.
*
* When changing from a more restrictive text format with a text editor (or a
* text format without a text editor) to a less restrictive text format, it is
* possible that a malicious user could trigger an XSS.
*
* E.g. when switching a piece of text that uses the Restricted HTML text
* format and contains a <script> tag to the Full HTML text format, the
* <script> tag would be executed. Unless we apply appropriate filtering.
*/
public function testSwitchingSecurity(): void {
$expected = [
[
'node_id' => 1,
// No text editor => no XSS filtering.
'value' => self::$sampleContent,
'format' => 'restricted_without_editor',
'switch_to' => [
'restricted_with_editor' => self::$sampleContentSecured,
// Intersection of restrictions => most strict XSS filtering.
'restricted_plus_dangerous_tag_with_editor' => self::$sampleContentSecured,
// No text editor => no XSS filtering.
'unrestricted_without_editor' => FALSE,
'unrestricted_with_editor' => self::$sampleContentSecured,
],
],
[
'node_id' => 2,
// Text editor => XSS filtering.
'value' => self::$sampleContentSecured,
'format' => 'restricted_with_editor',
'switch_to' => [
// No text editor => no XSS filtering.
'restricted_without_editor' => FALSE,
// Intersection of restrictions => most strict XSS filtering.
'restricted_plus_dangerous_tag_with_editor' => self::$sampleContentSecured,
// No text editor => no XSS filtering.
'unrestricted_without_editor' => FALSE,
'unrestricted_with_editor' => self::$sampleContentSecured,
],
],
[
'node_id' => 3,
// Text editor => XSS filtering.
'value' => self::$sampleContentSecuredEmbedAllowed,
'format' => 'restricted_plus_dangerous_tag_with_editor',
'switch_to' => [
// No text editor => no XSS filtering.
'restricted_without_editor' => FALSE,
// Intersection of restrictions => most strict XSS filtering.
'restricted_with_editor' => self::$sampleContentSecured,
// No text editor => no XSS filtering.
'unrestricted_without_editor' => FALSE,
// Intersection of restrictions => most strict XSS filtering.
'unrestricted_with_editor' => self::$sampleContentSecured,
],
],
[
'node_id' => 4,
// No text editor => no XSS filtering.
'value' => self::$sampleContent,
'format' => 'unrestricted_without_editor',
'switch_to' => [
// No text editor => no XSS filtering.
'restricted_without_editor' => FALSE,
'restricted_with_editor' => self::$sampleContentSecured,
// Intersection of restrictions => most strict XSS filtering.
'restricted_plus_dangerous_tag_with_editor' => self::$sampleContentSecured,
// From no editor, no security filters, to editor, still no security
// filters: resulting content when viewed was already vulnerable, so
// it must be intentional.
'unrestricted_with_editor' => FALSE,
],
],
[
'node_id' => 5,
// Text editor => XSS filtering.
'value' => self::$sampleContentSecured,
'format' => 'unrestricted_with_editor',
'switch_to' => [
// From editor, no security filters to security filters, no editor: no
// risk.
'restricted_without_editor' => FALSE,
'restricted_with_editor' => self::$sampleContentSecured,
// Intersection of restrictions => most strict XSS filtering.
'restricted_plus_dangerous_tag_with_editor' => self::$sampleContentSecured,
// From no editor, no security filters, to editor, still no security
// filters: resulting content when viewed was already vulnerable, so
// it must be intentional.
'unrestricted_without_editor' => FALSE,
],
],
];
// Log in as the privileged user, and for every sample, do the following:
// - switch to every other text format/editor
// - assert the XSS-filtered values that we get from the server
$this->drupalLogin($this->privilegedUser);
$cookies = $this->getSessionCookies();
foreach ($expected as $case) {
$this->drupalGet('node/' . $case['node_id'] . '/edit');
// Verify data- attributes.
$body = $this->assertSession()->fieldExists('edit-body-0-value');
$this->assertSame(self::$sampleContent, $body->getAttribute('data-editor-value-original'), 'The data-editor-value-original attribute is correctly set.');
$this->assertSame('false', (string) $body->getAttribute('data-editor-value-is-changed'), 'The data-editor-value-is-changed attribute is correctly set.');
// Switch to every other text format/editor and verify the results.
foreach ($case['switch_to'] as $format => $expected_filtered_value) {
$post = [
'value' => self::$sampleContent,
'original_format_id' => $case['format'],
];
$client = $this->getHttpClient();
$response = $client->post($this->buildUrl('/editor/filter_xss/' . $format), [
'body' => http_build_query($post),
'cookies' => $cookies,
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/x-www-form-urlencoded',
],
'http_errors' => FALSE,
]);
$this->assertEquals(200, $response->getStatusCode());
$json = Json::decode($response->getBody());
$this->assertSame($expected_filtered_value, $json, 'The value was correctly filtered for XSS attack vectors.');
}
}
}
/**
* Tests the standard text editor XSS filter being overridden.
*/
public function testEditorXssFilterOverride(): void {
// First: the Standard text editor XSS filter.
$this->drupalLogin($this->normalUser);
$this->drupalGet('node/2/edit');
$this->assertSession()->fieldValueEquals('edit-body-0-value', self::$sampleContentSecured);
// Enable editor_test.module's hook_editor_xss_filter_alter() implementation
// to alter the text editor XSS filter class being used.
\Drupal::state()->set('editor_test_editor_xss_filter_alter_enabled', TRUE);
// First: the Insecure text editor XSS filter.
$this->drupalGet('node/2/edit');
$this->assertSession()->fieldValueEquals('edit-body-0-value', self::$sampleContent);
}
}

View File

@@ -0,0 +1,236 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\Functional;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\TestFileCreationTrait;
/**
* Tests scaling of inline images.
*
* @group editor
*/
class EditorUploadImageScaleTest extends BrowserTestBase {
use TestFileCreationTrait;
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['editor', 'editor_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* A user with permission as administer for testing.
*
* @var \Drupal\user\Entity\User
*/
protected $adminUser;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Add text format.
FilterFormat::create([
'format' => 'basic_html',
'name' => 'Basic HTML',
'weight' => 0,
])->save();
// Set up text editor.
Editor::create([
'format' => 'basic_html',
'editor' => 'unicorn',
'image_upload' => [
'status' => TRUE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => '',
'max_dimensions' => [
'width' => NULL,
'height' => NULL,
],
],
])->save();
// Create admin user.
$this->adminUser = $this->drupalCreateUser([
'administer filters',
'use text format basic_html',
]);
$this->drupalLogin($this->adminUser);
}
/**
* Tests scaling of inline images.
*/
public function testEditorUploadImageScale(): void {
// Generate testing images.
$testing_image_list = $this->getTestFiles('image');
// Case 1: no max dimensions set: uploaded image not scaled.
$test_image = $testing_image_list[0];
[$image_file_width, $image_file_height] = $this->getTestImageInfo($test_image->uri);
$max_width = NULL;
$max_height = NULL;
$this->setMaxDimensions($max_width, $max_height);
$this->assertSavedMaxDimensions($max_width, $max_height);
[$uploaded_image_file_width, $uploaded_image_file_height] = $this->uploadImage($test_image->uri);
$this->assertEquals($image_file_width, $uploaded_image_file_width);
$this->assertEquals($image_file_height, $uploaded_image_file_height);
$this->assertSession()->pageTextNotContains("The image was resized to fit within the maximum allowed dimensions of {$max_width}x{$max_height} pixels.");
// Case 2: max width smaller than uploaded image: image scaled down.
$test_image = $testing_image_list[1];
[$image_file_width, $image_file_height] = $this->getTestImageInfo($test_image->uri);
$max_width = $image_file_width - 5;
$max_height = $image_file_height;
$this->setMaxDimensions($max_width, $max_height);
$this->assertSavedMaxDimensions($max_width, $max_height);
[$uploaded_image_file_width, $uploaded_image_file_height] = $this->uploadImage($test_image->uri);
$this->assertEquals($max_width, $uploaded_image_file_width);
$this->assertEquals($uploaded_image_file_height * ($uploaded_image_file_width / $max_width), $uploaded_image_file_height);
$this->assertSession()->pageTextContains("The image was resized to fit within the maximum allowed dimensions of {$max_width}x{$max_height} pixels.");
// Case 3: max height smaller than uploaded image: image scaled down.
$test_image = $testing_image_list[2];
[$image_file_width, $image_file_height] = $this->getTestImageInfo($test_image->uri);
$max_width = $image_file_width;
$max_height = $image_file_height - 5;
$this->setMaxDimensions($max_width, $max_height);
$this->assertSavedMaxDimensions($max_width, $max_height);
[$uploaded_image_file_width, $uploaded_image_file_height] = $this->uploadImage($test_image->uri);
$this->assertEquals($uploaded_image_file_width * ($uploaded_image_file_height / $max_height), $uploaded_image_file_width);
$this->assertEquals($max_height, $uploaded_image_file_height);
$this->assertSession()->pageTextContains("The image was resized to fit within the maximum allowed dimensions of {$max_width}x{$max_height} pixels.");
// Case 4: max dimensions greater than uploaded image: image not scaled.
$test_image = $testing_image_list[3];
[$image_file_width, $image_file_height] = $this->getTestImageInfo($test_image->uri);
$max_width = $image_file_width + 5;
$max_height = $image_file_height + 5;
$this->setMaxDimensions($max_width, $max_height);
$this->assertSavedMaxDimensions($max_width, $max_height);
[$uploaded_image_file_width, $uploaded_image_file_height] = $this->uploadImage($test_image->uri);
$this->assertEquals($image_file_width, $uploaded_image_file_width);
$this->assertEquals($image_file_height, $uploaded_image_file_height);
$this->assertSession()->pageTextNotContains("The image was resized to fit within the maximum allowed dimensions of {$max_width}x{$max_height} pixels.");
// Case 5: only max width dimension was provided and it was smaller than
// uploaded image: image scaled down.
$test_image = $testing_image_list[4];
[$image_file_width, $image_file_height] = $this->getTestImageInfo($test_image->uri);
$max_width = $image_file_width - 5;
$max_height = NULL;
$this->setMaxDimensions($max_width, $max_height);
$this->assertSavedMaxDimensions($max_width, $max_height);
[$uploaded_image_file_width, $uploaded_image_file_height] = $this->uploadImage($test_image->uri);
$this->assertEquals($max_width, $uploaded_image_file_width);
$this->assertEquals($uploaded_image_file_height * ($uploaded_image_file_width / $max_width), $uploaded_image_file_height);
$this->assertSession()->pageTextContains("The image was resized to fit within the maximum allowed width of {$max_width} pixels.");
// Case 6: only max height dimension was provided and it was smaller than
// uploaded image: image scaled down.
$test_image = $testing_image_list[5];
[$image_file_width, $image_file_height] = $this->getTestImageInfo($test_image->uri);
$max_width = NULL;
$max_height = $image_file_height - 5;
$this->setMaxDimensions($max_width, $max_height);
$this->assertSavedMaxDimensions($max_width, $max_height);
[$uploaded_image_file_width, $uploaded_image_file_height] = $this->uploadImage($test_image->uri);
$this->assertEquals($uploaded_image_file_width * ($uploaded_image_file_height / $max_height), $uploaded_image_file_width);
$this->assertEquals($max_height, $uploaded_image_file_height);
$this->assertSession()->pageTextContains("The image was resized to fit within the maximum allowed height of {$max_height} pixels.");
}
/**
* Gets the dimensions of an uploaded image.
*
* @param string $uri
* The URI of the image.
*
* @return array
* An array containing the uploaded image's width and height.
*/
protected function getTestImageInfo($uri) {
$image_file = $this->container->get('image.factory')->get($uri);
return [
(int) $image_file->getWidth(),
(int) $image_file->getHeight(),
];
}
/**
* Sets the maximum dimensions and saves the configuration.
*
* @param string|int $width
* The width of the image.
* @param string|int $height
* The height of the image.
*/
protected function setMaxDimensions($width, $height) {
$editor = Editor::load('basic_html');
$image_upload_settings = $editor->getImageUploadSettings();
$image_upload_settings['max_dimensions']['width'] = $width;
$image_upload_settings['max_dimensions']['height'] = $height;
$editor->setImageUploadSettings($image_upload_settings);
$editor->save();
}
/**
* Uploads an image via the editor dialog.
*
* @param string $uri
* The URI of the image.
*
* @return array
* An array containing the uploaded image's width and height.
*/
protected function uploadImage($uri) {
$edit = [
'files[fid]' => \Drupal::service('file_system')->realpath($uri),
];
$this->drupalGet('editor/dialog/image/basic_html');
$this->drupalGet('editor/dialog/image/basic_html');
$this->submitForm($edit, 'Upload');
$uploaded_image_file = $this->container->get('image.factory')->get('public://inline-images/' . basename($uri));
return [
(int) $uploaded_image_file->getWidth(),
(int) $uploaded_image_file->getHeight(),
];
}
/**
* Asserts whether the saved maximum dimensions equal the ones provided.
*
* @param int|null $width
* The expected width of the uploaded image.
* @param int|null $height
* The expected height of the uploaded image.
*
* @internal
*/
protected function assertSavedMaxDimensions(?int $width, ?int $height): void {
$image_upload_settings = Editor::load('basic_html')->getImageUploadSettings();
$expected = [
'width' => $image_upload_settings['max_dimensions']['width'],
'height' => $image_upload_settings['max_dimensions']['height'],
];
$this->assertEquals($expected['width'], $width, 'Actual width of "' . $width . '" equals the expected width of "' . $expected['width'] . '"');
$this->assertEquals($expected['height'], $height, 'Actual height of "' . $height . '" equals the expected width of "' . $expected['height'] . '"');
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\Functional;
use Drupal\Tests\system\Functional\Module\GenericModuleTestBase;
/**
* Generic module test for editor.
*
* @group editor
*/
class GenericTest extends GenericModuleTestBase {}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\Functional\Rest;
use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
/**
* @group rest
*/
class EditorJsonAnonTest extends EditorResourceTestBase {
use AnonResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\Functional\Rest;
use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
/**
* @group rest
*/
class EditorJsonBasicAuthTest extends EditorResourceTestBase {
use BasicAuthResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['basic_auth'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected static $auth = 'basic_auth';
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\Functional\Rest;
use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
/**
* @group rest
*/
class EditorJsonCookieTest extends EditorResourceTestBase {
use CookieResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected static $auth = 'cookie';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\Functional\Rest;
use Drupal\ckeditor5\Plugin\CKEditor5Plugin\Heading;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\Tests\rest\Functional\EntityResource\ConfigEntityResourceTestBase;
/**
* ResourceTestBase for Editor entity.
*/
abstract class EditorResourceTestBase extends ConfigEntityResourceTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['ckeditor5', 'editor'];
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'editor';
/**
* The Editor entity.
*
* @var \Drupal\editor\EditorInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer filters']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
// Create a "Llama" filter format.
$llama_format = FilterFormat::create([
'name' => 'Llama',
'format' => 'llama',
'langcode' => 'es',
'filters' => [
'filter_html' => [
'status' => TRUE,
'settings' => [
'allowed_html' => '<p> <a> <b> <lo>',
],
],
],
]);
$llama_format->save();
// Create a "Camelids" editor.
$camelids = Editor::create([
'format' => 'llama',
'editor' => 'ckeditor5',
]);
$camelids
->setImageUploadSettings([
'status' => TRUE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => '',
'max_dimensions' => [
'width' => '',
'height' => '',
],
])
->save();
return $camelids;
}
/**
* {@inheritdoc}
*/
protected function getExpectedNormalizedEntity() {
return [
'dependencies' => [
'config' => [
'filter.format.llama',
],
'module' => [
'ckeditor5',
],
],
'editor' => 'ckeditor5',
'format' => 'llama',
'image_upload' => [
'status' => TRUE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => NULL,
'max_dimensions' => [
'width' => NULL,
'height' => NULL,
],
],
'langcode' => 'en',
'settings' => [
'toolbar' => [
'items' => ['heading', 'bold', 'italic'],
],
'plugins' => [
'ckeditor5_heading' => Heading::DEFAULT_CONFIGURATION,
],
],
'status' => TRUE,
'uuid' => $this->entity->uuid(),
];
}
/**
* {@inheritdoc}
*/
protected function getNormalizedPostEntity() {
// @todo Update in https://www.drupal.org/node/2300677.
return [];
}
/**
* {@inheritdoc}
*/
protected function getExpectedCacheContexts() {
// @see ::createEntity()
return ['user.permissions'];
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
return "The 'administer filters' permission is required.";
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\Functional\Rest;
use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* @group rest
*/
class EditorXmlAnonTest extends EditorResourceTestBase {
use AnonResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\Functional\Rest;
use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* @group rest
*/
class EditorXmlBasicAuthTest extends EditorResourceTestBase {
use BasicAuthResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['basic_auth'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected static $auth = 'basic_auth';
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\Functional\Rest;
use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* @group rest
*/
class EditorXmlCookieTest extends EditorResourceTestBase {
use CookieResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected static $auth = 'cookie';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\Functional\Update;
use Drupal\FunctionalTests\Update\UpdatePathTestBase;
/**
* Tests adding editor_image_lazy_load filter to editor_file_reference.
*
* @group Update
* @group #slow
*/
class EditorAddLazyLoadImageFilterUpdateTest extends UpdatePathTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setDatabaseDumpFiles(): void {
$this->databaseDumpFiles = [
__DIR__ . '/../../../../../system/tests/fixtures/update/drupal-9.4.0.bare.standard.php.gz',
];
}
/**
* Tests upgrading filter settings.
*
* @see editor_post_update_image_lazy_load()
*/
public function testUpdateLazyImageLoad(): void {
$config = $this->config('filter.format.full_html');
$this->assertArrayNotHasKey('filter_image_lazy_load', $config->get('filters'));
$this->runUpdates();
$config = $this->config('filter.format.full_html');
$filters = $config->get('filters');
$this->assertArrayHasKey('filter_image_lazy_load', $filters);
$this->assertEquals($filters['editor_file_reference']['weight'] + 1, $filters['filter_image_lazy_load']['weight']);
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\Functional\Update;
use Drupal\FunctionalTests\Update\UpdatePathTestBase;
/**
* @group Update
* @group editor
* @see editor_post_update_sanitize_image_upload_settings()
*/
class EditorSanitizeImageUploadSettingsUpdateTest extends UpdatePathTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setDatabaseDumpFiles(): void {
$this->databaseDumpFiles = [
__DIR__ . '/../../../../../system/tests/fixtures/update/drupal-9.4.0.bare.standard.php.gz',
__DIR__ . '/../../../fixtures/update/editor-3412361.php',
];
}
/**
* Ensure image upload settings for Text Editor config entities are corrected.
*
* @see editor_post_update_sanitize_image_upload_settings()
*/
public function testUpdateRemoveMeaninglessImageUploadSettings(): void {
$basic_html_before = $this->config('editor.editor.basic_html');
$this->assertSame([
'status' => TRUE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => '',
'max_dimensions' => [
'width' => 0,
'height' => 0,
],
], $basic_html_before->get('image_upload'));
$full_html_before = $this->config('editor.editor.full_html');
$this->assertSame([
'status' => TRUE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => '',
'max_dimensions' => [
'width' => 0,
'height' => 0,
],
], $full_html_before->get('image_upload'));
$umami_basic_html_before = $this->config('editor.editor.umami_basic_html');
$this->assertSame([
'status' => FALSE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => '',
'max_dimensions' => [
'width' => NULL,
'height' => NULL,
],
], $umami_basic_html_before->get('image_upload'));
$this->runUpdates();
$basic_html_after = $this->config('editor.editor.basic_html');
$this->assertNotSame($basic_html_before->get('image_upload'), $basic_html_after->get('image_upload'));
$this->assertSame([
'status' => TRUE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => NULL,
'max_dimensions' => [
'width' => NULL,
'height' => NULL,
],
], $basic_html_after->get('image_upload'));
$full_html_after = $this->config('editor.editor.full_html');
$this->assertNotSame($full_html_before->get('image_upload'), $full_html_after->get('image_upload'));
$this->assertSame([
'status' => TRUE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => NULL,
'max_dimensions' => [
'width' => NULL,
'height' => NULL,
],
], $full_html_after->get('image_upload'));
$umami_basic_html_after = $this->config('editor.editor.umami_basic_html');
$this->assertNotSame($umami_basic_html_before->get('image_upload'), $umami_basic_html_after->get('image_upload'));
$this->assertSame([
'status' => FALSE,
], $umami_basic_html_after->get('image_upload'));
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\FunctionalJavascript;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* @group editor
*/
class EditorAdminTest extends WebDriverTestBase {
/**
* The user to use during testing.
*
* @var \Drupal\user\UserInterface
*/
protected $user;
/**
* {@inheritdoc}
*/
protected static $modules = [
'editor_test',
'ckeditor5',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->user = $this->drupalCreateUser([
'access administration pages',
'administer site configuration',
'administer filters',
]);
$this->drupalLogin($this->user);
}
/**
* Tests that editor selection can be toggled without breaking ajax.
*/
public function testEditorSelection(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->drupalGet('/admin/config/content/formats/add');
$page->fillField('name', 'Sulaco');
// Wait for machine name to be filled in.
$this->assertNotEmpty($assert_session->waitForText('sulaco'));
$page->selectFieldOption('editor[editor]', 'unicorn');
$this->assertNotEmpty($this->assertSession()->waitForField('editor[settings][ponies_too]'));
$page->pressButton('Save configuration');
// Test that toggling the editor selection off and back on works.
$this->drupalGet('/admin/config/content/formats/manage/sulaco');
// Deselect and reselect an editor.
$page->selectFieldOption('editor[editor]', '');
$this->assertNotEmpty($this->assertSession()->waitForElementRemoved('named', ['field', 'editor[settings][ponies_too]']));
$page->selectFieldOption('editor[editor]', 'unicorn');
$this->assertNotEmpty($this->assertSession()->waitForField('editor[settings][ponies_too]'));
}
/**
* Tests that editor creation works fine while switching text editor field.
*
* The order in which the different editors are selected is significant,
* because the form state must change accordingly.
* @see https://www.drupal.org/project/drupal/issues/3230829
*/
public function testEditorCreation(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->drupalGet('/admin/config/content/formats/add');
$page->fillField('name', $this->randomString());
$page->selectFieldOption('editor[editor]', 'ckeditor5');
$this->assertNotEmpty($this->assertSession()->waitForElementVisible('css', 'ul.ckeditor5-toolbar-available__buttons'));
$page->selectFieldOption('editor[editor]', '');
$this->assertNotEmpty($this->assertSession()->waitForElementRemoved('css', 'ul.ckeditor5-toolbar-available__buttons'));
$this->assertEmpty($this->assertSession()->waitForField('editor[settings][ponies_too]'));
$page->selectFieldOption('editor[editor]', 'unicorn');
$this->assertNotEmpty($this->assertSession()->waitForField('editor[settings][ponies_too]'));
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\Kernel;
use Drupal\editor\Form\EditorImageDialog;
use Drupal\editor\Form\EditorLinkDialog;
use Drupal\KernelTests\KernelTestBase;
/**
* Tests the deprecations in Drupal\editor.
*
* @group editor
* @group legacy
*/
class EditorDeprecationTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['editor'];
/**
* Tests the deprecation of the Drupal\editor\Form\EditorLinkDialog class.
*
* @see EditorLinkDialog
*/
public function testEditorLinkDialog(): void {
$this->expectDeprecation('Drupal\editor\Form\EditorLinkDialog is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. There is no replacement. See https://www.drupal.org/node/3291493');
new EditorLinkDialog();
}
/**
* Tests the deprecation of the Drupal\editor\Form\EditorImageDialog class.
*
* @see EditorImageDialog
*/
public function testEditorImageDialog(): void {
$this->expectDeprecation('Drupal\editor\Form\EditorImageDialog is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. There is no replacement. See https://www.drupal.org/node/3291493');
new EditorImageDialog($this->createMock('\Drupal\file\FileStorage'));
}
}

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\Kernel;
use Drupal\Core\Cache\Cache;
use Drupal\Core\File\FileExists;
use Drupal\file\Entity\File;
use Drupal\filter\FilterPluginCollection;
use Drupal\KernelTests\KernelTestBase;
use Drupal\Tests\TestFileCreationTrait;
/**
* Tests Editor module's file reference filter.
*
* @group editor
*/
class EditorFileReferenceFilterTest extends KernelTestBase {
use TestFileCreationTrait;
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = [
'system',
'filter',
'editor',
'field',
'file',
'user',
];
/**
* @var \Drupal\filter\Plugin\FilterInterface[]
*/
protected $filters;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->installConfig(['system']);
$this->installEntitySchema('file');
$this->installSchema('file', ['file_usage']);
$manager = $this->container->get('plugin.manager.filter');
$bag = new FilterPluginCollection($manager, []);
$this->filters = $bag->getAll();
}
/**
* Tests the editor file reference filter.
*/
public function testEditorFileReferenceFilter(): void {
$filter = $this->filters['editor_file_reference'];
$test = function ($input) use ($filter) {
return $filter->process($input, 'und');
};
file_put_contents('public://llama.jpg', $this->randomMachineName());
$image = File::create(['uri' => 'public://llama.jpg']);
$image->save();
$id = $image->id();
$uuid = $image->uuid();
$cache_tag = ['file:' . $id];
file_put_contents('public://alpaca.jpg', $this->randomMachineName());
$image_2 = File::create(['uri' => 'public://alpaca.jpg']);
$image_2->save();
$id_2 = $image_2->id();
$uuid_2 = $image_2->uuid();
$cache_tag_2 = ['file:' . $id_2];
// No data-entity-type and no data-entity-uuid attribute.
$input = '<img src="llama.jpg" />';
$output = $test($input);
$this->assertSame($input, $output->getProcessedText());
// A non-file data-entity-type attribute value.
$input = '<img src="llama.jpg" data-entity-type="invalid-entity-type-value" data-entity-uuid="' . $uuid . '" />';
$output = $test($input);
$this->assertSame($input, $output->getProcessedText());
// One data-entity-uuid attribute.
$input = '<img src="llama.jpg" data-entity-type="file" data-entity-uuid="' . $uuid . '">';
$expected_output = '<img src="/' . $this->siteDirectory . '/files/llama.jpg" data-entity-type="file" data-entity-uuid="' . $uuid . '">';
$output = $test($input);
$this->assertSame($expected_output, $output->getProcessedText());
$this->assertEquals($cache_tag, $output->getCacheTags());
// One data-entity-uuid attribute with odd capitalization.
$input = '<img src="llama.jpg" data-entity-type="file" DATA-entity-UUID = "' . $uuid . '" />';
$expected_output = '<img src="/' . $this->siteDirectory . '/files/llama.jpg" data-entity-type="file" data-entity-uuid="' . $uuid . '">';
$output = $test($input);
$this->assertSame($expected_output, $output->getProcessedText());
$this->assertEquals($cache_tag, $output->getCacheTags());
// One data-entity-uuid attribute on a non-image tag.
$input = '<video src="llama.jpg" data-entity-type="file" data-entity-uuid="' . $uuid . '" />';
$expected_output = '<video src="/' . $this->siteDirectory . '/files/llama.jpg" data-entity-type="file" data-entity-uuid="' . $uuid . '"></video>';
$output = $test($input);
$this->assertSame($expected_output, $output->getProcessedText());
$this->assertEquals($cache_tag, $output->getCacheTags());
// One data-entity-uuid attribute with an invalid value.
$input = '<img src="llama.jpg" data-entity-type="file" data-entity-uuid="invalid-' . $uuid . '">';
$output = $test($input);
$this->assertSame($input, $output->getProcessedText());
$this->assertEquals([], $output->getCacheTags());
// Two different data-entity-uuid attributes.
$input = '<img src="llama.jpg" data-entity-type="file" data-entity-uuid="' . $uuid . '" />';
$input .= '<img src="alpaca.jpg" data-entity-type="file" data-entity-uuid="' . $uuid_2 . '" />';
$expected_output = '<img src="/' . $this->siteDirectory . '/files/llama.jpg" data-entity-type="file" data-entity-uuid="' . $uuid . '">';
$expected_output .= '<img src="/' . $this->siteDirectory . '/files/alpaca.jpg" data-entity-type="file" data-entity-uuid="' . $uuid_2 . '">';
$output = $test($input);
$this->assertSame($expected_output, $output->getProcessedText());
$this->assertEquals(Cache::mergeTags($cache_tag, $cache_tag_2), $output->getCacheTags());
// Two identical data-entity-uuid attributes.
$input = '<img src="llama.jpg" data-entity-type="file" data-entity-uuid="' . $uuid . '" />';
$input .= '<img src="llama.jpg" data-entity-type="file" data-entity-uuid="' . $uuid . '" />';
$expected_output = '<img src="/' . $this->siteDirectory . '/files/llama.jpg" data-entity-type="file" data-entity-uuid="' . $uuid . '">';
$expected_output .= '<img src="/' . $this->siteDirectory . '/files/llama.jpg" data-entity-type="file" data-entity-uuid="' . $uuid . '">';
$output = $test($input);
$this->assertSame($expected_output, $output->getProcessedText());
$this->assertEquals($cache_tag, $output->getCacheTags());
// Add a valid image for image dimension testing.
/** @var array stdClass */
$files = $this->getTestFiles('image');
$image = reset($files);
\Drupal::service('file_system')->copy($image->uri, 'public://llama.jpg', FileExists::Replace);
[$width, $height] = getimagesize('public://llama.jpg');
$dimensions = 'width="' . $width . '" height="' . $height . '"';
// Image dimensions are present.
$input = '<img src="llama.jpg" data-entity-type="file" data-entity-uuid="' . $uuid . '" />';
$expected_output = '<img src="/' . $this->siteDirectory . '/files/llama.jpg" data-entity-type="file" data-entity-uuid="' . $uuid . '" ' . $dimensions . '>';
$output = $test($input);
$this->assertSame($expected_output, $output->getProcessedText());
$this->assertEquals($cache_tag, $output->getCacheTags());
// Image dimensions are set manually.
$input = '<img src="llama.jpg" data-entity-type="file" data-entity-uuid="' . $uuid . '"width="41" height="21" />';
$expected_output = '<img src="/' . $this->siteDirectory . '/files/llama.jpg" data-entity-type="file" data-entity-uuid="' . $uuid . '" width="41" height="21">';
$output = $test($input);
$this->assertSame($expected_output, $output->getProcessedText());
$this->assertEquals($cache_tag, $output->getCacheTags());
}
}

View File

@@ -0,0 +1,294 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\Kernel;
use Drupal\editor\Entity\Editor;
use Drupal\KernelTests\Core\Entity\EntityKernelTestBase;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\file\Entity\File;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\filter\Entity\FilterFormat;
/**
* Tests tracking of file usage by the Text Editor module.
*
* @group editor
*/
class EditorFileUsageTest extends EntityKernelTestBase {
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['editor', 'editor_test', 'node', 'file'];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->installEntitySchema('file');
$this->installSchema('node', ['node_access']);
$this->installSchema('file', ['file_usage']);
$this->installConfig(['node']);
// Add text formats.
$filtered_html_format = FilterFormat::create([
'format' => 'filtered_html',
'name' => 'Filtered HTML',
'weight' => 0,
'filters' => [],
]);
$filtered_html_format->save();
// Set cardinality for body field.
FieldStorageConfig::loadByName('node', 'body')
->setCardinality(FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED)
->save();
// Set up text editor.
$editor = Editor::create([
'format' => 'filtered_html',
'editor' => 'unicorn',
]);
$editor->save();
// Create a node type for testing.
$type = NodeType::create(['type' => 'page', 'name' => 'page']);
$type->save();
node_add_body_field($type);
FieldStorageConfig::create([
'field_name' => 'description',
'entity_type' => 'node',
'type' => 'editor_test_text_long',
'cardinality' => FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED,
])->save();
FieldConfig::create([
'field_name' => 'description',
'entity_type' => 'node',
'bundle' => 'page',
'label' => 'Description',
])->save();
}
/**
* Tests file save operations when node with referenced files is saved.
*/
public function testFileSaveOperations(): void {
$permanent_image = File::create([
'uri' => 'core/misc/druplicon.png',
'status' => 1,
]);
$permanent_image->save();
$temporary_image = File::create([
'uri' => 'core/misc/tree.png',
'status' => 0,
]);
$temporary_image->save();
$body_value = '<img data-entity-type="file" data-entity-uuid="' . $permanent_image->uuid() . '" />';
$body_value .= '<img data-entity-type="file" data-entity-uuid="' . $temporary_image->uuid() . '" />';
$body[] = [
'value' => $body_value,
'format' => 'filtered_html',
];
$node = Node::create([
'type' => 'page',
'title' => 'test',
'body' => $body,
'uid' => 1,
]);
$node->save();
$file_save_count = \Drupal::state()->get('editor_test.file_save_count', []);
$this->assertEquals(1, $file_save_count[$permanent_image->getFilename()]);
$this->assertEquals(2, $file_save_count[$temporary_image->getFilename()]);
// Assert both images are now permanent.
$permanent_image = File::load($permanent_image->id());
$temporary_image = File::load($temporary_image->id());
$this->assertTrue($permanent_image->isPermanent(), 'Permanent image was saved as permanent.');
$this->assertTrue($temporary_image->isPermanent(), 'Temporary image was saved as permanent.');
}
/**
* Tests the configurable text editor manager.
*/
public function testEditorEntityHooks(): void {
$image_paths = [
0 => 'core/misc/druplicon.png',
1 => 'core/misc/tree.png',
2 => 'core/misc/help.png',
];
$image_entities = [];
foreach ($image_paths as $key => $image_path) {
$image = File::create();
$image->setFileUri($image_path);
$image->setFilename(\Drupal::service('file_system')->basename($image->getFileUri()));
$image->save();
$file_usage = $this->container->get('file.usage');
$this->assertSame([], $file_usage->listUsage($image), 'The image ' . $image_paths[$key] . ' has zero usages.');
$image_entities[] = $image;
}
$body = [];
$description = [];
foreach ($image_entities as $key => $image_entity) {
// Don't be rude, say hello.
$body_value = '<p>Hello, world!</p>';
// Test handling of a valid image entry.
$body_value .= '<img src="awesome-llama-' . $key . '.jpg" data-entity-type="file" data-entity-uuid="' . $image_entity->uuid() . '" />';
// Test handling of an invalid data-entity-uuid attribute.
$body_value .= '<img src="awesome-llama-' . $key . '.jpg" data-entity-type="file" data-entity-uuid="invalid-entity-uuid-value" />';
// Test handling of an invalid data-entity-type attribute.
$body_value .= '<img src="awesome-llama-' . $key . '.jpg" data-entity-type="invalid-entity-type-value" data-entity-uuid="' . $image_entity->uuid() . '" />';
// Test handling of a non-existing UUID.
$body_value .= '<img src="awesome-llama-' . $key . '.jpg" data-entity-type="file" data-entity-uuid="30aac704-ba2c-40fc-b609-9ed121aa90f4" />';
$body[] = [
'value' => $body_value,
'format' => 'filtered_html',
];
$description[] = [
'value' => 'something',
'format' => 'filtered_html',
];
}
// Test editor_entity_insert(): increment.
$this->createUser();
$node = $node = Node::create([
'type' => 'page',
'title' => 'test',
'body' => $body,
'description' => $description,
'uid' => 1,
]);
$node->save();
foreach ($image_entities as $key => $image_entity) {
$this->assertSame(['editor' => ['node' => [1 => '1']]], $file_usage->listUsage($image_entity), 'The image ' . $image_paths[$key] . ' has 1 usage.');
}
// Test editor_entity_update(): increment, twice, by creating new revisions.
$node->setNewRevision(TRUE);
$node->save();
$second_revision_id = $node->getRevisionId();
$node->setNewRevision(TRUE);
$node->save();
foreach ($image_entities as $key => $image_entity) {
$this->assertSame(['editor' => ['node' => [1 => '3']]], $file_usage->listUsage($image_entity), 'The image ' . $image_paths[$key] . ' has 3 usages.');
}
// Test hook_entity_update(): decrement, by modifying the last revision:
// remove the data-entity-type attribute from the body field.
$original_values = [];
for ($i = 0; $i < count($image_entities); $i++) {
$original_value = $node->body[$i]->value;
$new_value = str_replace('data-entity-type', 'data-entity-type-modified', $original_value);
$node->body[$i]->value = $new_value;
$original_values[$i] = $original_value;
}
$node->save();
foreach ($image_entities as $key => $image_entity) {
$this->assertSame(['editor' => ['node' => [1 => '2']]], $file_usage->listUsage($image_entity), 'The image ' . $image_paths[$key] . ' has 2 usages.');
}
// Test editor_entity_update(): increment again by creating a new revision:
// read the data- attributes to the body field.
$node->setNewRevision(TRUE);
foreach ($original_values as $key => $original_value) {
$node->body[$key]->value = $original_value;
}
$node->save();
foreach ($image_entities as $key => $image_entity) {
$this->assertSame(['editor' => ['node' => [1 => '3']]], $file_usage->listUsage($image_entity), 'The image ' . $image_paths[$key] . ' has 3 usages.');
}
// Test hook_entity_update(): decrement, by modifying the last revision:
// remove the data-entity-uuid attribute from the body field.
foreach ($original_values as $key => $original_value) {
$original_value = $node->body[$key]->value;
$new_value = str_replace('data-entity-type', 'data-entity-type-modified', $original_value);
$node->body[$key]->value = $new_value;
}
$node->save();
foreach ($image_entities as $key => $image_entity) {
$this->assertSame(['editor' => ['node' => [1 => '2']]], $file_usage->listUsage($image_entity), 'The image ' . $image_paths[$key] . ' has 2 usages.');
}
// Test hook_entity_update(): increment, by modifying the last revision:
// read the data- attributes to the body field.
foreach ($original_values as $key => $original_value) {
$node->body[$key]->value = $original_value;
}
$node->save();
foreach ($image_entities as $key => $image_entity) {
$this->assertSame(['editor' => ['node' => [1 => '3']]], $file_usage->listUsage($image_entity), 'The image ' . $image_paths[$key] . ' has 3 usages.');
}
// Test editor_entity_revision_delete(): decrement, by deleting a revision.
$this->container->get('entity_type.manager')->getStorage('node')->deleteRevision($second_revision_id);
foreach ($image_entities as $key => $image_entity) {
$this->assertSame(['editor' => ['node' => [1 => '2']]], $file_usage->listUsage($image_entity), 'The image ' . $image_paths[$key] . ' has 2 usages.');
}
// Populate both the body and summary. Because this will be the same
// revision of the same node, it will record only one usage.
foreach ($original_values as $key => $original_value) {
$node->body[$key]->value = $original_value;
$node->body[$key]->summary = $original_value;
}
$node->save();
foreach ($image_entities as $key => $image_entity) {
$this->assertSame(['editor' => ['node' => [1 => '2']]], $file_usage->listUsage($image_entity), 'The image ' . $image_paths[$key] . ' has 2 usages.');
}
// Empty out the body value, but keep the summary. The number of usages
// should not change.
foreach ($original_values as $key => $original_value) {
$node->body[$key]->value = '';
$node->body[$key]->summary = $original_value;
}
$node->save();
foreach ($image_entities as $key => $image_entity) {
$this->assertSame(['editor' => ['node' => [1 => '2']]], $file_usage->listUsage($image_entity), 'The image ' . $image_paths[$key] . ' has 2 usages.');
}
// Empty out the body and summary. The number of usages should decrease by
// one.
foreach ($original_values as $key => $original_value) {
$node->body[$key]->value = '';
$node->body[$key]->summary = '';
}
$node->save();
foreach ($image_entities as $key => $image_entity) {
$this->assertSame(['editor' => ['node' => [1 => '1']]], $file_usage->listUsage($image_entity), 'The image ' . $image_paths[$key] . ' has 1 usage.');
}
// Set the field of a custom field type that is a subclass of
// Drupal\text\Plugin\Field\FieldType\TextItemBase. The number of usages
// should increase by one.
foreach ($original_values as $key => $original_value) {
$node->description[$key]->value = $original_value;
}
$node->save();
foreach ($image_entities as $key => $image_entity) {
$this->assertSame(['editor' => ['node' => [1 => '2']]], $file_usage->listUsage($image_entity), 'The image ' . $image_paths[$key] . ' has 2 usages.');
}
// Test editor_entity_delete().
$node->delete();
foreach ($image_entities as $key => $image_entity) {
$this->assertSame([], $file_usage->listUsage($image_entity), 'The image ' . $image_paths[$key] . ' has zero usages again.');
}
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\Kernel;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\KernelTests\KernelTestBase;
/**
* Tests integration with filter module.
*
* @group editor
*/
class EditorFilterIntegrationTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['filter', 'editor', 'editor_test'];
/**
* Tests text format removal or disabling.
*/
public function testTextFormatIntegration(): void {
// Create an arbitrary text format.
$format = FilterFormat::create([
'format' => $this->randomMachineName(),
'name' => $this->randomString(),
]);
$format->save();
// Create a paired editor.
Editor::create(['format' => $format->id(), 'editor' => 'unicorn'])->save();
// Disable the text format.
$format->disable()->save();
// The paired editor should be disabled too.
$this->assertFalse(Editor::load($format->id())->status());
// Re-enable the text format.
$format->enable()->save();
// The paired editor should be enabled too.
$this->assertTrue(Editor::load($format->id())->status());
// Completely remove the text format. Usually this cannot occur via UI, but
// can be triggered from API.
$format->delete();
// The paired editor should be removed.
$this->assertNull(Editor::load($format->id()));
}
}

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\Kernel;
use Drupal\Core\Form\FormState;
use Drupal\editor\Entity\Editor;
use Drupal\editor\Form\EditorImageDialog;
use Drupal\filter\Entity\FilterFormat;
use Drupal\KernelTests\Core\Entity\EntityKernelTestBase;
use Drupal\node\Entity\NodeType;
/**
* Tests EditorImageDialog validation and conversion functionality.
*
* @group editor
* @group legacy
*/
class EditorImageDialogTest extends EntityKernelTestBase {
/**
* Text editor config entity for testing.
*
* @var \Drupal\editor\EditorInterface
*/
protected $editor;
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = [
'node',
'file',
'editor',
'editor_test',
'user',
'system',
];
/**
* Sets up the test.
*/
protected function setUp(): void {
parent::setUp();
$this->installEntitySchema('file');
$this->installSchema('node', ['node_access']);
$this->installSchema('file', ['file_usage']);
$this->installConfig(['node']);
// Add text formats.
$format = FilterFormat::create([
'format' => 'filtered_html',
'name' => 'Filtered HTML',
'weight' => 0,
'filters' => [
'filter_align' => ['status' => TRUE],
'filter_caption' => ['status' => TRUE],
],
]);
$format->save();
// Set up text editor.
$editor = Editor::create([
'format' => 'filtered_html',
'editor' => 'unicorn',
'image_upload' => [
'max_size' => 100,
'scheme' => 'public',
'directory' => '',
'max_dimensions' => [
'width' => NULL,
'height' => NULL,
],
'status' => TRUE,
],
]);
$editor->save();
$this->editor = $editor;
// Create a node type for testing.
$type = NodeType::create(['type' => 'page', 'name' => 'page']);
$type->save();
node_add_body_field($type);
$this->installEntitySchema('user');
}
/**
* Tests that editor image dialog works as expected.
*/
public function testEditorImageDialog(): void {
$input = [
'editor_object' => [
'src' => '/sites/default/files/inline-images/some-file.png',
'alt' => 'fda',
'width' => '',
'height' => '',
'data-entity-type' => 'file',
'data-entity-uuid' => 'some-uuid',
'data-align' => 'none',
'hasCaption' => 'false',
],
'dialogOptions' => [
'title' => 'Edit Image',
'classes' => [
'ui-dialog' => 'editor-image-dialog',
],
'autoResize' => 'true',
],
'_drupal_ajax' => '1',
'ajax_page_state' => [
'theme' => 'olivero',
'theme_token' => 'some-token',
'libraries' => '',
],
];
$form_state = (new FormState())
->setRequestMethod('POST')
->setUserInput($input)
->addBuildInfo('args', [$this->editor]);
$form_builder = $this->container->get('form_builder');
$form_object = new EditorImageDialog(\Drupal::entityTypeManager()->getStorage('file'));
$form_id = $form_builder->getFormId($form_object, $form_state);
$form = $form_builder->retrieveForm($form_id, $form_state);
$form_builder->prepareForm($form_id, $form, $form_state);
$form_builder->processForm($form_id, $form, $form_state);
// Assert these two values are present and we don't get the 'not-this'
// default back.
$this->assertFalse($form_state->getValue(['attributes', 'hasCaption'], 'not-this'));
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\Kernel;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\KernelTests\KernelTestBase;
/**
* Tests detection of text editors and correct generation of attachments.
*
* @group editor
*/
class EditorManagerTest extends KernelTestBase {
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['system', 'user', 'filter', 'editor'];
/**
* The manager for text editor plugins.
*
* @var \Drupal\Component\Plugin\PluginManagerInterface
*/
protected $editorManager;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Install the Filter module.
// Add text formats.
$filtered_html_format = FilterFormat::create([
'format' => 'filtered_html',
'name' => 'Filtered HTML',
'weight' => 0,
'filters' => [],
]);
$filtered_html_format->save();
$full_html_format = FilterFormat::create([
'format' => 'full_html',
'name' => 'Full HTML',
'weight' => 1,
'filters' => [],
]);
$full_html_format->save();
}
/**
* Tests the configurable text editor manager.
*/
public function testManager(): void {
$this->editorManager = $this->container->get('plugin.manager.editor');
// Case 1: no text editor available:
// - listOptions() should return an empty list of options
// - getAttachments() should return an empty #attachments array (and not
// a JS settings structure that is empty)
$this->assertSame([], $this->editorManager->listOptions(), 'When no text editor is enabled, the manager works correctly.');
$this->assertSame([], $this->editorManager->getAttachments([]), 'No attachments when no text editor is enabled and retrieving attachments for zero text formats.');
$this->assertSame([], $this->editorManager->getAttachments(['filtered_html', 'full_html']), 'No attachments when no text editor is enabled and retrieving attachments for multiple text formats.');
// Enable the Text Editor Test module, which has the Unicorn Editor and
// clear the editor manager's cache so it is picked up.
$this->enableModules(['editor_test']);
$this->editorManager = $this->container->get('plugin.manager.editor');
$this->editorManager->clearCachedDefinitions();
// Case 2: a text editor available.
$this->assertSame('Unicorn Editor', (string) $this->editorManager->listOptions()['unicorn'], 'When some text editor is enabled, the manager works correctly.');
// Case 3: a text editor available & associated (but associated only with
// the 'Full HTML' text format).
$unicorn_plugin = $this->editorManager->createInstance('unicorn');
$editor = Editor::create([
'format' => 'full_html',
'editor' => 'unicorn',
]);
$editor->save();
$this->assertSame([], $this->editorManager->getAttachments([]), 'No attachments when one text editor is enabled and retrieving attachments for zero text formats.');
$expected = [
'library' => [
0 => 'editor_test/unicorn',
],
'drupalSettings' => [
'editor' => [
'formats' => [
'full_html' => [
'format' => 'full_html',
'editor' => 'unicorn',
'editorSettings' => $unicorn_plugin->getJSSettings($editor),
'editorSupportsContentFiltering' => TRUE,
'isXssSafe' => FALSE,
],
],
],
],
];
$this->assertSame($expected, $this->editorManager->getAttachments(['filtered_html', 'full_html']), 'Correct attachments when one text editor is enabled and retrieving attachments for multiple text formats.');
// Case 4: a text editor available associated, but now with its JS settings
// being altered via hook_editor_js_settings_alter().
\Drupal::state()->set('editor_test_js_settings_alter_enabled', TRUE);
$expected['drupalSettings']['editor']['formats']['full_html']['editorSettings']['ponyModeEnabled'] = FALSE;
$this->assertSame($expected, $this->editorManager->getAttachments(['filtered_html', 'full_html']), 'hook_editor_js_settings_alter() works correctly.');
}
}

View File

@@ -0,0 +1,263 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\Kernel;
use Drupal\ckeditor5\Plugin\CKEditor5Plugin\Heading;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\KernelTests\Core\Config\ConfigEntityValidationTestBase;
/**
* Tests validation of editor entities.
*
* @group editor
*/
class EditorValidationTest extends ConfigEntityValidationTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['ckeditor5', 'editor', 'filter'];
/**
* {@inheritdoc}
*/
protected static array $propertiesWithRequiredKeys = [
'settings' => [
"'toolbar' is a required key because editor is ckeditor5 (see config schema type editor.settings.ckeditor5).",
"'plugins' is a required key because editor is ckeditor5 (see config schema type editor.settings.ckeditor5).",
],
'image_upload' => "'status' is a required key.",
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$format = FilterFormat::create([
'format' => 'test',
'name' => 'Test',
]);
$format->save();
$this->entity = Editor::create([
'format' => $format->id(),
'editor' => 'ckeditor5',
'settings' => [
// @see \Drupal\ckeditor5\Plugin\Editor\CKEditor5::getDefaultSettings()
'toolbar' => [
'items' => ['heading', 'bold', 'italic'],
],
'plugins' => [
'ckeditor5_heading' => Heading::DEFAULT_CONFIGURATION,
],
],
]);
$this->entity->save();
}
/**
* {@inheritdoc}
*/
public function testImmutableProperties(array $valid_values = [], ?array $additional_expected_validation_errors_when_missing = NULL): void {
// TRICKY: Every Text Editor is associated with a Text Format. It must exist
// to avoid triggering a validation error.
// @see \Drupal\editor\EditorInterface::hasAssociatedFilterFormat
FilterFormat::create([
'format' => 'another',
'name' => 'Another',
])->save();
parent::testImmutableProperties(['format' => 'another']);
}
/**
* Tests that validation fails if config dependencies are invalid.
*/
public function testInvalidDependencies(): void {
// Remove the config dependencies from the editor entity.
$dependencies = $this->entity->getDependencies();
$dependencies['config'] = [];
$this->entity->set('dependencies', $dependencies);
$this->assertValidationErrors(['' => 'This text editor requires a text format.']);
// Things look sort-of like `filter.format.*` should fail validation
// because they don't exist.
$dependencies['config'] = [
'filter.format',
'filter.format.',
];
$this->entity->set('dependencies', $dependencies);
$this->assertValidationErrors([
'' => 'This text editor requires a text format.',
'dependencies.config.0' => "The 'filter.format' config does not exist.",
'dependencies.config.1' => "The 'filter.format.' config does not exist.",
]);
}
/**
* Tests validating an editor with an unknown plugin ID.
*/
public function testInvalidPluginId(): void {
$this->entity->setEditor('non_existent');
$this->assertValidationErrors(['editor' => "The 'non_existent' plugin does not exist."]);
}
/**
* Tests validating an editor with a non-existent `format`.
*/
public function testInvalidFormat(): void {
$this->entity->set('format', 'non_existent');
$this->assertValidationErrors([
'' => "The 'format' property cannot be changed.",
'format' => "The 'filter.format.non_existent' config does not exist.",
]);
}
/**
* {@inheritdoc}
*/
public function testLabelValidation(): void {
// @todo Remove this override in https://www.drupal.org/i/3231354. The label of Editor entities is dynamically computed: it's retrieved from the associated FilterFormat entity. That issue will change this.
// @see \Drupal\editor\Entity\Editor::label()
$this->markTestSkipped();
}
/**
* `image_upload.status = TRUE` must cause additional keys to be required.
*/
public function testImageUploadSettingsAreDynamicallyRequired(): void {
// When image uploads are disabled, no other key-value pairs are needed.
$this->entity->setImageUploadSettings(['status' => FALSE]);
$this->assertValidationErrors([]);
// But when they are enabled, many others are needed.
$this->entity->setImageUploadSettings(['status' => TRUE]);
$this->assertValidationErrors([
'image_upload' => [
"'scheme' is a required key because image_upload.status is 1 (see config schema type editor.image_upload_settings.1).",
"'directory' is a required key because image_upload.status is 1 (see config schema type editor.image_upload_settings.1).",
"'max_size' is a required key because image_upload.status is 1 (see config schema type editor.image_upload_settings.1).",
"'max_dimensions' is a required key because image_upload.status is 1 (see config schema type editor.image_upload_settings.1).",
],
]);
// Specify all required keys, but forget one.
$this->entity->setImageUploadSettings([
'status' => TRUE,
'scheme' => 'public',
'directory' => 'uploaded-images',
'max_size' => '5 MB',
]);
$this->assertValidationErrors(['image_upload' => "'max_dimensions' is a required key because image_upload.status is 1 (see config schema type editor.image_upload_settings.1)."]);
// Specify all required keys.
$this->entity->setImageUploadSettings([
'status' => TRUE,
'scheme' => 'public',
'directory' => 'uploaded-images',
'max_size' => '5 MB',
'max_dimensions' => [
'width' => 10000,
'height' => 10000,
],
]);
$this->assertValidationErrors([]);
// Specify all required keys … but now disable image uploads again. This
// should trigger a validation error from the ValidKeys constraint.
$this->entity->setImageUploadSettings([
'status' => FALSE,
'scheme' => 'public',
'directory' => 'uploaded-images',
'max_size' => '5 MB',
'max_dimensions' => [
'width' => 10000,
'height' => 10000,
],
]);
$this->assertValidationErrors([
'image_upload' => [
"'scheme' is an unknown key because image_upload.status is 0 (see config schema type editor.image_upload_settings.*).",
"'directory' is an unknown key because image_upload.status is 0 (see config schema type editor.image_upload_settings.*).",
"'max_size' is an unknown key because image_upload.status is 0 (see config schema type editor.image_upload_settings.*).",
"'max_dimensions' is an unknown key because image_upload.status is 0 (see config schema type editor.image_upload_settings.*).",
],
]);
// Remove the values that the messages said are unknown.
$this->entity->setImageUploadSettings(['status' => FALSE]);
$this->assertValidationErrors([]);
// Note how this is the same as the initial value. This proves that `status`
// being FALSE prevents any meaningless key-value pairs to be present, and
// `status` being TRUE requires those then meaningful pairs to be present.
}
/**
* @testWith [{"scheme": "public"}, {}]
* [{"scheme": "private"}, {"image_upload.scheme": "The file storage you selected is not a visible, readable and writable stream wrapper. Possible choices: <em class=\"placeholder\">&quot;public&quot;</em>."}]
* [{"directory": null}, {}]
* [{"directory": ""}, {"image_upload.directory": "This value should not be blank."}]
* [{"directory": "inline\nimages"}, {"image_upload.directory": "The image upload directory is not allowed to span multiple lines or contain control characters."}]
* [{"directory": "foo\b\b\binline-images"}, {"image_upload.directory": "The image upload directory is not allowed to span multiple lines or contain control characters."}]
* [{"max_size": null}, {}]
* [{"max_size": "foo"}, {"image_upload.max_size": "This value must be a number of bytes, optionally with a unit such as \"MB\" or \"megabytes\". <em class=\"placeholder\">foo</em> does not represent a number of bytes."}]
* [{"max_size": ""}, {"image_upload.max_size": "This value must be a number of bytes, optionally with a unit such as \"MB\" or \"megabytes\". <em class=\"placeholder\"></em> does not represent a number of bytes."}]
* [{"max_size": "7 exabytes"}, {}]
* [{"max_dimensions": {"width": null, "height": 15}}, {}]
* [{"max_dimensions": {"width": null, "height": null}}, {}]
* [{"max_dimensions": {"width": null, "height": 0}}, {"image_upload.max_dimensions.height": "This value should be between <em class=\"placeholder\">1</em> and <em class=\"placeholder\">99999</em>."}]
* [{"max_dimensions": {"width": 100000, "height": 1}}, {"image_upload.max_dimensions.width": "This value should be between <em class=\"placeholder\">1</em> and <em class=\"placeholder\">99999</em>."}]
*/
public function testImageUploadSettingsValidation(array $invalid_setting, array $expected_message): void {
$this->entity->setImageUploadSettings($invalid_setting + [
'status' => TRUE,
'scheme' => 'public',
'directory' => 'uploaded-images',
'max_size' => '5 MB',
'max_dimensions' => [
'width' => 10000,
'height' => 10000,
],
]);
$this->assertValidationErrors($expected_message);
}
/**
* {@inheritdoc}
*/
public function testRequiredPropertyValuesMissing(?array $additional_expected_validation_errors_when_missing = NULL): void {
parent::testRequiredPropertyValuesMissing([
'dependencies' => [
// @see ::testInvalidDependencies()
// @see \Drupal\Core\Config\Plugin\Validation\Constraint\RequiredConfigDependenciesConstraintValidator
'' => 'This text editor requires a text format.',
],
'settings' => [
'settings.plugins.ckeditor5_heading' => 'Configuration for the enabled plugin "<em class="placeholder">Headings</em>" (<em class="placeholder">ckeditor5_heading</em>) is missing.',
],
]);
}
/**
* {@inheritdoc}
*/
public function testRequiredPropertyKeysMissing(?array $additional_expected_validation_errors_when_missing = NULL): void {
parent::testRequiredPropertyKeysMissing([
'dependencies' => [
// @see ::testInvalidDependencies()
// @see \Drupal\Core\Config\Plugin\Validation\Constraint\RequiredConfigDependenciesConstraintValidator
'' => 'This text editor requires a text format.',
],
'settings' => [
'settings.plugins.ckeditor5_heading' => 'Configuration for the enabled plugin "<em class="placeholder">Headings</em>" (<em class="placeholder">ckeditor5_heading</em>) is missing.',
],
]);
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\Kernel;
use Drupal\KernelTests\Core\Entity\EntityKernelTestBase;
use Drupal\node\Entity\NodeType;
use Drupal\node\Entity\Node;
/**
* Tests updating an entity.
*
* @group editor
*/
class EntityUpdateTest extends EntityKernelTestBase {
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['editor', 'editor_test', 'node'];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->installSchema('node', ['node_access']);
$this->installConfig(['node']);
// Create a node type for testing.
$type = NodeType::create(['type' => 'page', 'name' => 'page']);
$type->save();
// Set editor_test module weight to be lower than editor module's weight so
// that editor_test_entity_update() is called before editor_entity_update().
$extension_config = \Drupal::configFactory()->get('core.extension');
$editor_module_weight = $extension_config->get('module.editor');
module_set_weight('editor_test', $editor_module_weight - 1);
}
/**
* Tests updating an existing entity.
*
* @see editor_test_entity_update()
*/
public function testEntityUpdate(): void {
// Create a node.
$node = Node::create([
'type' => 'page',
'title' => 'test',
]);
$node->save();
// Update the node.
// What happens is the following:
// 1. \Drupal\Core\Entity\EntityStorageBase::doPostSave() gets called.
// 2. editor_test_entity_update() gets called.
// 3. A resave of the updated entity gets triggered (second save call).
// 4. \Drupal\Core\Entity\EntityStorageBase::doPostSave() gets called.
// 5. editor_test_entity_update() gets called.
// 6. editor_entity_update() gets called (caused by the second save call).
// 7. editor_entity_update() gets called (caused by the first save call).
$node->title->value = 'test updated';
$node->save();
}
}

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\Unit;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\editor\Entity\Editor;
use Drupal\Tests\UnitTestCase;
/**
* @coversDefaultClass \Drupal\editor\Entity\Editor
* @group editor
*/
class EditorConfigEntityUnitTest extends UnitTestCase {
/**
* The entity type used for testing.
*
* @var \Drupal\Core\Entity\EntityTypeInterface|\PHPUnit\Framework\MockObject\MockObject
*/
protected $entityType;
/**
* The entity type manager used for testing.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface|\PHPUnit\Framework\MockObject\MockObject
*/
protected $entityTypeManager;
/**
* The ID of the type of the entity under test.
*
* @var string
*/
protected $entityTypeId;
/**
* The UUID generator used for testing.
*
* @var \Drupal\Component\Uuid\UuidInterface|\PHPUnit\Framework\MockObject\MockObject
*/
protected $uuid;
/**
* The editor plugin manager used for testing.
*
* @var \Drupal\editor\Plugin\EditorManager|\PHPUnit\Framework\MockObject\MockObject
*/
protected $editorPluginManager;
/**
* Editor plugin ID.
*
* @var string
*/
protected $editorId;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->editorId = $this->randomMachineName();
$this->entityTypeId = $this->randomMachineName();
$this->entityType = $this->createMock('\Drupal\Core\Entity\EntityTypeInterface');
$this->entityType->expects($this->any())
->method('getProvider')
->willReturn('editor');
$this->entityTypeManager = $this->createMock(EntityTypeManagerInterface::class);
$this->entityTypeManager->expects($this->any())
->method('getDefinition')
->with($this->entityTypeId)
->willReturn($this->entityType);
$this->uuid = $this->createMock('\Drupal\Component\Uuid\UuidInterface');
$this->editorPluginManager = $this->getMockBuilder('Drupal\editor\Plugin\EditorManager')
->disableOriginalConstructor()
->getMock();
$container = new ContainerBuilder();
$container->set('entity_type.manager', $this->entityTypeManager);
$container->set('uuid', $this->uuid);
$container->set('plugin.manager.editor', $this->editorPluginManager);
\Drupal::setContainer($container);
}
/**
* @covers ::calculateDependencies
*/
public function testCalculateDependencies(): void {
$format_id = 'filter.format.test';
$values = ['editor' => $this->editorId, 'format' => $format_id];
$plugin = $this->getMockBuilder('Drupal\editor\Plugin\EditorPluginInterface')
->disableOriginalConstructor()
->getMock();
$plugin->expects($this->once())
->method('getPluginDefinition')
->willReturn(['provider' => 'test_module']);
$plugin->expects($this->once())
->method('getDefaultSettings')
->willReturn([]);
$this->editorPluginManager->expects($this->any())
->method('createInstance')
->with($this->editorId)
->willReturn($plugin);
$entity = new Editor($values, $this->entityTypeId);
$filter_format = $this->createMock('Drupal\Core\Config\Entity\ConfigEntityInterface');
$filter_format->expects($this->once())
->method('getConfigDependencyName')
->willReturn('filter.format.test');
$storage = $this->createMock('Drupal\Core\Entity\EntityStorageInterface');
$storage->expects($this->once())
->method('load')
->with($format_id)
->willReturn($filter_format);
$this->entityTypeManager->expects($this->once())
->method('getStorage')
->with('filter_format')
->willReturn($storage);
$dependencies = $entity->calculateDependencies()->getDependencies();
$this->assertContains('test_module', $dependencies['module']);
$this->assertContains('filter.format.test', $dependencies['config']);
}
}

View File

@@ -0,0 +1,607 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\editor\Unit\EditorXssFilter;
use Drupal\editor\EditorXssFilter\Standard;
use Drupal\Tests\UnitTestCase;
use Drupal\filter\Plugin\FilterInterface;
// cspell:ignore ascript attributename bgsound bscript ckers cript datafld
// cspell:ignore dataformatas datasrc dynsrc ession livescript msgbox nmouseover
// cspell:ignore noxss pression ript scri scriptlet unicoded vbscript
/**
* @coversDefaultClass \Drupal\editor\EditorXssFilter\Standard
* @group editor
*/
class StandardTest extends UnitTestCase {
/**
* The mocked text format configuration entity.
*
* @var \Drupal\filter\Entity\FilterFormat|\PHPUnit\Framework\MockObject\MockObject
*/
protected $format;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Mock text format configuration entity object.
$this->format = $this->getMockBuilder('\Drupal\filter\Entity\FilterFormat')
->disableOriginalConstructor()
->getMock();
$this->format->expects($this->any())
->method('getFilterTypes')
->willReturn([FilterInterface::TYPE_HTML_RESTRICTOR]);
$restrictions = [
'allowed' => [
'p' => TRUE,
'a' => TRUE,
'*' => [
'style' => FALSE,
'on*' => FALSE,
],
],
];
$this->format->expects($this->any())
->method('getHtmlRestrictions')
->willReturn($restrictions);
}
/**
* Provides test data for testFilterXss().
*
* @see \Drupal\Tests\editor\Unit\editor\EditorXssFilter\StandardTest::testFilterXss()
*/
public static function providerTestFilterXss() {
$data = [];
$data[] = ['<p>Hello, world!</p><unknown>Pink Fairy Armadillo</unknown>', '<p>Hello, world!</p><unknown>Pink Fairy Armadillo</unknown>'];
$data[] = ['<p style="color:red">Hello, world!</p><unknown>Pink Fairy Armadillo</unknown>', '<p>Hello, world!</p><unknown>Pink Fairy Armadillo</unknown>'];
$data[] = ['<p>Hello, world!</p><unknown>Pink Fairy Armadillo</unknown><script>alert("evil");</script>', '<p>Hello, world!</p><unknown>Pink Fairy Armadillo</unknown>alert("evil");'];
$data[] = ['<p>Hello, world!</p><unknown>Pink Fairy Armadillo</unknown><a href="javascript:alert(1)">test</a>', '<p>Hello, world!</p><unknown>Pink Fairy Armadillo</unknown><a href="alert(1)">test</a>'];
// All cases listed on https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet
// No Filter Evasion.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#No_Filter_Evasion
$data[] = ['<SCRIPT SRC=http://ha.ckers.org/xss.js></SCRIPT>', ''];
// Image XSS using the JavaScript directive.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Image_XSS_using_the_JavaScript_directive
$data[] = ['<IMG SRC="javascript:alert(\'XSS\');">', '<IMG src="alert(&#039;XSS&#039;);">'];
// No quotes and no semicolon.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#No_quotes_and_no_semicolon
$data[] = ['<IMG SRC=javascript:alert(\'XSS\')>', '<IMG>'];
// Case insensitive XSS attack vector.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Case_insensitive_XSS_attack_vector
$data[] = ['<IMG SRC=JaVaScRiPt:alert(\'XSS\')>', '<IMG>'];
// HTML entities.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#HTML_entities
$data[] = ['<IMG SRC=javascript:alert("XSS")>', '<IMG>'];
// Grave accent obfuscation.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Grave_accent_obfuscation
$data[] = ['<IMG SRC=`javascript:alert("RSnake says, \'XSS\'")`>', '<IMG>'];
// Malformed A tags.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Malformed_A_tags
$data[] = ['<a onmouseover="alert(document.cookie)">xxs link</a>', '<a>xxs link</a>'];
$data[] = ['<a onmouseover=alert(document.cookie)>xxs link</a>', '<a>xxs link</a>'];
// Malformed IMG tags.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Malformed_IMG_tags
$data[] = ['<IMG """><SCRIPT>alert("XSS")</SCRIPT>">', '<IMG>alert("XSS")"&gt;'];
// fromCharCode.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#fromCharCode
$data[] = ['<IMG SRC=javascript:alert(String.fromCharCode(88,83,83))>', '<IMG src="alert(String.fromCharCode(88,83,83))">'];
// Default SRC tag to get past filters that check SRC domain.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Default_SRC_tag_to_get_past_filters_that_check_SRC_domain
$data[] = ['<IMG SRC=# onmouseover="alert(\'xxs\')">', '<IMG src="#">'];
// Default SRC tag by leaving it empty.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Default_SRC_tag_by_leaving_it_empty
$data[] = ['<IMG SRC= onmouseover="alert(\'xxs\')">', '<IMG>'];
// Default SRC tag by leaving it out entirely.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Default_SRC_tag_by_leaving_it_out_entirely
$data[] = ['<IMG onmouseover="alert(\'xxs\')">', '<IMG>'];
// Decimal HTML character references.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Decimal_HTML_character_references
$data[] = ['<IMG SRC=&#106;&#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;&#58;&#97;&#108;&#101;&#114;&#116;&#40;&#39;&#88;&#83;&#83;&#39;&#41;>', '<IMG src="alert(&#039;XSS&#039;)">'];
// Decimal HTML character references without trailing semicolons.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Decimal_HTML_character_references_without_trailing_semicolons
$data[] = ['<IMG SRC=&#0000106&#0000097&#0000118&#0000097&#0000115&#0000099&#0000114&#0000105&#0000112&#0000116&#0000058&#0000097&#0000108&#0000101&#0000114&#0000116&#0000040&#0000039&#0000088&#0000083&#0000083&#0000039&#0000041>', '<IMG src="&amp;#0000106&amp;#0000097&amp;#0000118&amp;#0000097&amp;#0000115&amp;#0000099&amp;#0000114&amp;#0000105&amp;#0000112&amp;#0000116&amp;#0000058&amp;#0000097&amp;#0000108&amp;#0000101&amp;#0000114&amp;#0000116&amp;#0000040&amp;#0000039&amp;#0000088&amp;#0000083&amp;#0000083&amp;#0000039&amp;#0000041">'];
// Hexadecimal HTML character references without trailing semicolons.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Hexadecimal_HTML_character_references_without_trailing_semicolons
$data[] = ['<IMG SRC=&#x6A&#x61&#x76&#x61&#x73&#x63&#x72&#x69&#x70&#x74&#x3A&#x61&#x6C&#x65&#x72&#x74&#x28&#x27&#x58&#x53&#x53&#x27&#x29>', '<IMG src="&amp;#x6A&amp;#x61&amp;#x76&amp;#x61&amp;#x73&amp;#x63&amp;#x72&amp;#x69&amp;#x70&amp;#x74&amp;#x3A&amp;#x61&amp;#x6C&amp;#x65&amp;#x72&amp;#x74&amp;#x28&amp;#x27&amp;#x58&amp;#x53&amp;#x53&amp;#x27&amp;#x29">'];
// Embedded tab.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Embedded_tab
$data[] = ['<IMG SRC="jav ascript:alert(\'XSS\');">', '<IMG src="alert(&#039;XSS&#039;);">'];
// Embedded Encoded tab.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Embedded_Encoded_tab
$data[] = ['<IMG SRC="jav&#x09;ascript:alert(\'XSS\');">', '<IMG src="alert(&#039;XSS&#039;);">'];
// Embedded newline to break up XSS.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Embedded_newline_to_break_up_XSS
$data[] = ['<IMG SRC="jav&#x0A;ascript:alert(\'XSS\');">', '<IMG src="alert(&#039;XSS&#039;);">'];
// Embedded carriage return to break up XSS.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Embedded_carriage_return_to_break_up_XSS
$data[] = ['<IMG SRC="jav&#x0D;ascript:alert(\'XSS\');">', '<IMG src="alert(&#039;XSS&#039;);">'];
// Null breaks up JavaScript directive.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Null_breaks_up_JavaScript_directive
$data[] = ["<IMG SRC=java\0script:alert(\"XSS\")>", '<IMG>'];
// Non-alpha-non-digit XSS.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Non-alpha-non-digit_XSS
$data[] = ['<SCRIPT/XSS SRC="http://ha.ckers.org/xss.js"></SCRIPT>', ''];
$data[] = ['<BODY onload!#$%&()*~+-_.,:;?@[/|\]^`=alert("XSS")>', '<BODY>'];
$data[] = ['<SCRIPT/SRC="http://ha.ckers.org/xss.js"></SCRIPT>', ''];
// Extraneous open brackets.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Extraneous_open_brackets
$data[] = ['<<SCRIPT>alert("XSS");//<</SCRIPT>', '&lt;alert("XSS");//&lt;'];
// No closing script tags.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#No_closing_script_tags
$data[] = ['<SCRIPT SRC=http://ha.ckers.org/xss.js?< B >', ''];
// Protocol resolution in script tags.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Protocol_resolution_in_script_tags
$data[] = ['<SCRIPT SRC=//ha.ckers.org/.j>', ''];
// Half open HTML/JavaScript XSS vector.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Half_open_HTML.2FJavaScript_XSS_vector
$data[] = ['<IMG SRC="javascript:alert(\'XSS\')"', '<IMG src="alert(&#039;XSS&#039;)">'];
// Double open angle brackets.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Double_open_angle_brackets
// @see http://ha.ckers.org/blog/20060611/hotbot-xss-vulnerability/ to
// understand why this is a vulnerability.
$data[] = ['<iframe src=http://ha.ckers.org/scriptlet.html <', '<iframe src="http://ha.ckers.org/scriptlet.html">'];
// Escaping JavaScript escapes.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Escaping_JavaScript_escapes
// This one is irrelevant for Drupal; we *never* output any JavaScript code
// that depends on the URL's query string.
// End title tag.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#End_title_tag
$data[] = ['</TITLE><SCRIPT>alert("XSS");</SCRIPT>', '</TITLE>alert("XSS");'];
// INPUT image.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#INPUT_image
$data[] = ['<INPUT TYPE="IMAGE" SRC="javascript:alert(\'XSS\');">', '<INPUT type="IMAGE" src="alert(&#039;XSS&#039;);">'];
// BODY image.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#BODY_image
$data[] = ['<BODY BACKGROUND="javascript:alert(\'XSS\')">', '<BODY background="alert(&#039;XSS&#039;)">'];
// IMG Dynsrc.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#IMG_Dynsrc
$data[] = ['<IMG DYNSRC="javascript:alert(\'XSS\')">', '<IMG dynsrc="alert(&#039;XSS&#039;)">'];
// IMG lowsrc.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#IMG_lowsrc
$data[] = ['<IMG LOWSRC="javascript:alert(\'XSS\')">', '<IMG lowsrc="alert(&#039;XSS&#039;)">'];
// List-style-image.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#List-style-image
$data[] = ['<STYLE>li {list-style-image: url("javascript:alert(\'XSS\')");}</STYLE><UL><LI>XSS</br>', 'li {list-style-image: url("javascript:alert(\'XSS\')");}<UL><LI>XSS</br>'];
// VBscript in an image.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#VBscript_in_an_image
$data[] = ['<IMG SRC=\'vbscript:msgbox("XSS")\'>', '<IMG src=\'msgbox(&quot;XSS&quot;)\'>'];
// Livescript (older versions of Netscape only).
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Livescript_.28older_versions_of_Netscape_only.29
$data[] = ['<IMG SRC="livescript:[code]">', '<IMG src="[code]">'];
// BODY tag.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#BODY_tag
$data[] = ['<BODY ONLOAD=alert(\'XSS\')>', '<BODY>'];
// Event handlers.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Event_Handlers
$events = [
'onAbort',
'onActivate',
'onAfterPrint',
'onAfterUpdate',
'onBeforeActivate',
'onBeforeCopy',
'onBeforeCut',
'onBeforeDeactivate',
'onBeforeEditFocus',
'onBeforePaste',
'onBeforePrint',
'onBeforeUnload',
'onBeforeUpdate',
'onBegin',
'onBlur',
'onBounce',
'onCellChange',
'onChange',
'onClick',
'onContextMenu',
'onControlSelect',
'onCopy',
'onCut',
'onDataAvailable',
'onDataSetChanged',
'onDataSetComplete',
'onDblClick',
'onDeactivate',
'onDrag',
'onDragEnd',
'onDragLeave',
'onDragEnter',
'onDragOver',
'onDragDrop',
'onDragStart',
'onDrop',
'onEnd',
'onError',
'onErrorUpdate',
'onFilterChange',
'onFinish',
'onFocus',
'onFocusIn',
'onFocusOut',
'onHashChange',
'onHelp',
'onInput',
'onKeyDown',
'onKeyPress',
'onKeyUp',
'onLayoutComplete',
'onLoad',
'onLoseCapture',
'onMediaComplete',
'onMediaError',
'onMessage',
'onMousedown',
'onMouseEnter',
'onMouseLeave',
'onMouseMove',
'onMouseOut',
'onMouseOver',
'onMouseUp',
'onMouseWheel',
'onMove',
'onMoveEnd',
'onMoveStart',
'onOffline',
'onOnline',
'onOutOfSync',
'onPaste',
'onPause',
'onPopState',
'onProgress',
'onPropertyChange',
'onReadyStateChange',
'onRedo',
'onRepeat',
'onReset',
'onResize',
'onResizeEnd',
'onResizeStart',
'onResume',
'onReverse',
'onRowsEnter',
'onRowExit',
'onRowDelete',
'onRowInserted',
'onScroll',
'onSeek',
'onSelect',
'onSelectionChange',
'onSelectStart',
'onStart',
'onStop',
'onStorage',
'onSyncRestored',
'onSubmit',
'onTimeError',
'onTrackChange',
'onUndo',
'onUnload',
'onURLFlip',
];
foreach ($events as $event) {
$data[] = ['<p ' . $event . '="javascript:alert(\'XSS\');">Dangerous llama!</p>', '<p>Dangerous llama!</p>'];
}
// BGSOUND.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#BGSOUND
$data[] = ['<BGSOUND SRC="javascript:alert(\'XSS\');">', '<BGSOUND src="alert(&#039;XSS&#039;);">'];
// & JavaScript includes.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#.26_JavaScript_includes
$data[] = ['<BR SIZE="&{alert(\'XSS\')}">', '<BR size="">'];
// STYLE sheet.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#STYLE_sheet
$data[] = ['<LINK REL="stylesheet" HREF="javascript:alert(\'XSS\');">', ''];
// Remote style sheet.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Remote_style_sheet
$data[] = ['<LINK REL="stylesheet" HREF="http://ha.ckers.org/xss.css">', ''];
// Remote style sheet part 2.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Remote_style_sheet_part_2
$data[] = ['<STYLE>@import\'http://ha.ckers.org/xss.css\';</STYLE>', '@import\'http://ha.ckers.org/xss.css\';'];
// Remote style sheet part 3.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Remote_style_sheet_part_3
$data[] = ['<META HTTP-EQUIV="Link" Content="<http://ha.ckers.org/xss.css>; REL=stylesheet">', '<META http-equiv="Link">; REL=stylesheet"&gt;'];
// Remote style sheet part 4.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Remote_style_sheet_part_4
$data[] = ['<STYLE>BODY{-moz-binding:url("http://ha.ckers.org/xssmoz.xml#xss")}</STYLE>', 'BODY{-moz-binding:url("http://ha.ckers.org/xssmoz.xml#xss")}'];
// STYLE tags with broken up JavaScript for XSS.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#STYLE_tags_with_broken_up_JavaScript_for_XSS
$data[] = ['<STYLE>@im\port\'\ja\vasc\ript:alert("XSS")\';</STYLE>', '@im\port\'\ja\vasc\ript:alert("XSS")\';'];
// STYLE attribute using a comment to break up expression.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#STYLE_attribute_using_a_comment_to_break_up_expression
$data[] = ['<IMG STYLE="xss:expr/*XSS*/ession(alert(\'XSS\'))">', '<IMG>'];
// IMG STYLE with expression.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#IMG_STYLE_with_expression
$data[] = [
'exp/*<A STYLE=\'no\xss:noxss("*//*");
xss:ex/*XSS*//*/*/pression(alert("XSS"))\'>',
'exp/*<A>',
];
// STYLE tag (Older versions of Netscape only).
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#STYLE_tag_.28Older_versions_of_Netscape_only.29
$data[] = ['<STYLE TYPE="text/javascript">alert(\'XSS\');</STYLE>', 'alert(\'XSS\');'];
// STYLE tag using background-image.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#STYLE_tag_using_background-image
$data[] = ['<STYLE>.XSS{background-image:url("javascript:alert(\'XSS\')");}</STYLE><A CLASS=XSS></A>', '.XSS{background-image:url("javascript:alert(\'XSS\')");}<A class="XSS"></A>'];
// STYLE tag using background.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#STYLE_tag_using_background
$data[] = ['<STYLE type="text/css">BODY{background:url("javascript:alert(\'XSS\')")}</STYLE>', 'BODY{background:url("javascript:alert(\'XSS\')")}'];
// Anonymous HTML with STYLE attribute.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Anonymous_HTML_with_STYLE_attribute
$data[] = ['<XSS STYLE="xss:expression(alert(\'XSS\'))">', '<XSS>'];
// Local htc file.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Local_htc_file
$data[] = ['<XSS STYLE="behavior: url(xss.htc);">', '<XSS>'];
// US-ASCII encoding.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#US-ASCII_encoding
// This one is irrelevant for Drupal; Drupal *always* outputs UTF-8.
// META.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#META
$data[] = ['<META HTTP-EQUIV="refresh" CONTENT="0;url=javascript:alert(\'XSS\');">', '<META http-equiv="refresh" content="alert(&#039;XSS&#039;);">'];
// META using data.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#META_using_data
$data[] = ['<META HTTP-EQUIV="refresh" CONTENT="0;url=data:text/html base64,PHNjcmlwdD5hbGVydCgnWFNTJyk8L3NjcmlwdD4K">', '<META http-equiv="refresh" content="text/html base64,PHNjcmlwdD5hbGVydCgnWFNTJyk8L3NjcmlwdD4K">'];
// META with additional URL parameter
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#META
$data[] = ['<META HTTP-EQUIV="refresh" CONTENT="0; URL=http://;URL=javascript:alert(\'XSS\');">', '<META http-equiv="refresh" content="//;URL=javascript:alert(&#039;XSS&#039;);">'];
// IFRAME.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#IFRAME
$data[] = ['<IFRAME SRC="javascript:alert(\'XSS\');"></IFRAME>', '<IFRAME src="alert(&#039;XSS&#039;);"></IFRAME>'];
// IFRAME Event based.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#IFRAME_Event_based
$data[] = ['<IFRAME SRC=# onmouseover="alert(document.cookie)"></IFRAME>', '<IFRAME src="#"></IFRAME>'];
// FRAME.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#FRAME
$data[] = ['<FRAMESET><FRAME SRC="javascript:alert(\'XSS\');"></FRAMESET>', '<FRAMESET><FRAME src="alert(&#039;XSS&#039;);"></FRAMESET>'];
// TABLE.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#TABLE
$data[] = ['<TABLE BACKGROUND="javascript:alert(\'XSS\')">', '<TABLE background="alert(&#039;XSS&#039;)">'];
// TD.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#TD
$data[] = ['<TABLE><TD BACKGROUND="javascript:alert(\'XSS\')">', '<TABLE><TD background="alert(&#039;XSS&#039;)">'];
// DIV background-image.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#DIV_background-image
$data[] = ['<DIV STYLE="background-image: url(javascript:alert(\'XSS\'))">', '<DIV>'];
// DIV background-image with unicoded XSS exploit.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#DIV_background-image_with_unicoded_XSS_exploit
$data[] = ['<DIV STYLE="background-image:\0075\0072\006C\0028\'\006a\0061\0076\0061\0073\0063\0072\0069\0070\0074\003a\0061\006c\0065\0072\0074\0028.1027\0058.1053\0053\0027\0029\'\0029">', '<DIV>'];
// DIV background-image plus extra characters.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#DIV_background-image_plus_extra_characters
$data[] = ['<DIV STYLE="background-image: url(&#1;javascript:alert(\'XSS\'))">', '<DIV>'];
// DIV expression.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#DIV_expression
$data[] = ['<DIV STYLE="width: expression(alert(\'XSS\'));">', '<DIV>'];
// Downlevel-Hidden block.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Downlevel-Hidden_block
$data[] = ['<!--[if gte IE 4]>
<SCRIPT>alert(\'XSS\');</SCRIPT>
<![endif]-->',
"\n alert('XSS');\n ",
];
// BASE tag.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#BASE_tag
$data[] = ['<BASE HREF="javascript:alert(\'XSS\');//">', '<BASE href="alert(&#039;XSS&#039;);//">'];
// OBJECT tag.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#OBJECT_tag
$data[] = ['<OBJECT TYPE="text/x-scriptlet" DATA="http://ha.ckers.org/scriptlet.html"></OBJECT>', ''];
// Using an EMBED tag you can embed a Flash movie that contains XSS.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Using_an_EMBED_tag_you_can_embed_a_Flash_movie_that_contains_XSS
$data[] = ['<EMBED SRC="http://ha.ckers.org/xss.swf" AllowScriptAccess="always"></EMBED>', ''];
// You can EMBED SVG which can contain your XSS vector.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#You_can_EMBED_SVG_which_can_contain_your_XSS_vector
// cspell:disable-next-line
$data[] = ['<EMBED SRC="data:image/svg+xml;base64,PHN2ZyB4bWxuczpzdmc9Imh0dH A6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcv MjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hs aW5rIiB2ZXJzaW9uPSIxLjAiIHg9IjAiIHk9IjAiIHdpZHRoPSIxOTQiIGhlaWdodD0iMjAw IiBpZD0ieHNzIj48c2NyaXB0IHR5cGU9InRleHQvZWNtYXNjcmlwdCI+YWxlcnQoIlh TUyIpOzwvc2NyaXB0Pjwvc3ZnPg==" type="image/svg+xml" AllowScriptAccess="always"></EMBED>', ''];
// XML data island with CDATA obfuscation.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#XML_data_island_with_CDATA_obfuscation
$data[] = ['<XML ID="xss"><I><B><IMG SRC="javas<!-- -->cript:alert(\'XSS\')"></B></I></XML><SPAN DATASRC="#xss" DATAFLD="B" DATAFORMATAS="HTML"></SPAN>', '<XML id="xss"><I><B><IMG>cript:alert(\'XSS\')"&gt;</B></I></XML><SPAN datasrc="#xss" datafld="B" dataformatas="HTML"></SPAN>'];
// Locally hosted XML with embedded JavaScript that is generated using an XML data island.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Locally_hosted_XML_with_embedded_JavaScript_that_is_generated_using_an_XML_data_island
// This one is irrelevant for Drupal; Drupal disallows XML uploads by
// default.
// HTML+TIME in XML.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#HTML.2BTIME_in_XML
$data[] = ['<?xml:namespace prefix="t" ns="urn:schemas-microsoft-com:time"><?import namespace="t" implementation="#default#time2"><t:set attributeName="innerHTML" to="XSS<SCRIPT DEFER>alert("XSS")</SCRIPT>">', '&lt;?xml:namespace prefix="t" ns="urn:schemas-microsoft-com:time"&gt;&lt;?import namespace="t" implementation="#default#time2"&gt;<t set attributename="innerHTML">alert("XSS")"&gt;'];
// Assuming you can only fit in a few characters and it filters against ".js".
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Assuming_you_can_only_fit_in_a_few_characters_and_it_filters_against_.22.js.22
$data[] = ['<SCRIPT SRC="http://ha.ckers.org/xss.jpg"></SCRIPT>', ''];
// IMG Embedded commands.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#IMG_Embedded_commands
// This one is irrelevant for Drupal; this is actually a CSRF, for which
// Drupal has CSRF protection. See https://www.drupal.org/node/178896.
// Cookie manipulation.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Cookie_manipulation
$data[] = ['<META HTTP-EQUIV="Set-Cookie" Content="UserID=<SCRIPT>alert(\'XSS\')</SCRIPT>">', '<META http-equiv="Set-Cookie">alert(\'XSS\')"&gt;'];
// UTF-7 encoding.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#UTF-7_encoding
// This one is irrelevant for Drupal; Drupal *always* outputs UTF-8.
// XSS using HTML quote encapsulation.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#XSS_using_HTML_quote_encapsulation
$data[] = ['<SCRIPT a=">" SRC="http://ha.ckers.org/xss.js"></SCRIPT>', '" SRC="http://ha.ckers.org/xss.js"&gt;'];
$data[] = ['<SCRIPT =">" SRC="http://ha.ckers.org/xss.js"></SCRIPT>', '" SRC="http://ha.ckers.org/xss.js"&gt;'];
$data[] = ['<SCRIPT a=">" \'\' SRC="http://ha.ckers.org/xss.js"></SCRIPT>', '" \'\' SRC="http://ha.ckers.org/xss.js"&gt;'];
$data[] = ['<SCRIPT "a=\'>\'" SRC="http://ha.ckers.org/xss.js"></SCRIPT>', '\'" SRC="http://ha.ckers.org/xss.js"&gt;'];
$data[] = ['<SCRIPT a=`>` SRC="http://ha.ckers.org/xss.js"></SCRIPT>', '` SRC="http://ha.ckers.org/xss.js"&gt;'];
$data[] = ['<SCRIPT a=">\'>" SRC="http://ha.ckers.org/xss.js"></SCRIPT>', '\'&gt;" SRC="http://ha.ckers.org/xss.js"&gt;'];
$data[] = ['<SCRIPT>document.write("<SCRI");</SCRIPT>PT SRC="http://ha.ckers.org/xss.js"></SCRIPT>', 'document.write("<SCRI>PT SRC="http://ha.ckers.org/xss.js"&gt;'];
// URL string evasion.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#URL_string_evasion
// This one is irrelevant for Drupal; Drupal doesn't forbid linking to some
// sites, it only forbids linking to any protocols other than those that are
// whitelisted.
// Test XSS filtering on data-attributes.
// @see \Drupal\editor\EditorXssFilter::filterXssDataAttributes()
// The following two test cases verify that XSS attack vectors are filtered.
$data[] = ['<img src="butterfly.jpg" data-caption="&lt;script&gt;alert();&lt;/script&gt;" />', '<img src="butterfly.jpg" data-caption="alert();">'];
$data[] = ['<img src="butterfly.jpg" data-caption="&lt;EMBED SRC=&quot;http://ha.ckers.org/xss.swf&quot; AllowScriptAccess=&quot;always&quot;&gt;&lt;/EMBED&gt;" />', '<img src="butterfly.jpg" data-caption>'];
// When including HTML-tags as visible content, they are double-escaped.
// This test case ensures that we leave that content unchanged.
$data[] = ['<img src="butterfly.jpg" data-caption="&amp;lt;script&amp;gt;alert();&amp;lt;/script&amp;gt;" />', '<img src="butterfly.jpg" data-caption="&amp;lt;script&amp;gt;alert();&amp;lt;/script&amp;gt;">'];
return $data;
}
/**
* Tests the method for filtering XSS.
*
* @param string $input
* The input.
* @param string $expected_output
* The expected output.
*
* @dataProvider providerTestFilterXss
*/
public function testFilterXss($input, $expected_output): void {
$output = Standard::filterXss($input, $this->format);
$this->assertSame($expected_output, $output);
}
/**
* Tests removing disallowed tags and XSS prevention.
*
* \Drupal\Component\Utility\Xss::filter() has the ability to run in blacklist
* mode, in which it still applies the exact same filtering, with one
* exception: it no longer works with a list of allowed tags, but with a list
* of disallowed tags.
*
* @param string $value
* The value to filter.
* @param string $expected
* The string that is expected to be missing.
* @param string $message
* The assertion message to display upon failure.
* @param array $disallowed_tags
* (optional) The disallowed HTML tags to be passed to \Drupal\Component\Utility\Xss::filter().
*
* @dataProvider providerTestBlackListMode
*/
public function testBlacklistMode($value, $expected, $message, array $disallowed_tags): void {
$value = Standard::filter($value, $disallowed_tags);
$this->assertSame($expected, $value, $message);
}
/**
* Data provider for testBlacklistMode().
*
* @see testBlacklistMode()
*
* @return array
* An array of arrays containing the following elements:
* - The value to filter.
* - The value to expect after filtering.
* - The assertion message.
* - (optional) The disallowed HTML tags to be passed to \Drupal\Component\Utility\Xss::filter().
*/
public static function providerTestBlackListMode() {
return [
[
'<unknown style="visibility:hidden">Pink Fairy Armadillo</unknown><video src="gerenuk.mp4"><script>alert(0)</script>',
'<unknown>Pink Fairy Armadillo</unknown><video src="gerenuk.mp4">alert(0)',
'Disallow only the script tag',
['script'],
],
[
'<unknown style="visibility:hidden">Pink Fairy Armadillo</unknown><video src="gerenuk.mp4"><script>alert(0)</script>',
'<unknown>Pink Fairy Armadillo</unknown>alert(0)',
'Disallow both the script and video tags',
['script', 'video'],
],
// No real use case for this, but it is an edge case we must ensure works.
[
'<unknown style="visibility:hidden">Pink Fairy Armadillo</unknown><video src="gerenuk.mp4"><script>alert(0)</script>',
'<unknown>Pink Fairy Armadillo</unknown><video src="gerenuk.mp4"><script>alert(0)</script>',
'Disallow no tags',
[],
],
];
}
}