Move all files to 2017/

This commit is contained in:
Oliver Davies 2025-09-29 22:25:17 +01:00
parent ac7370f67f
commit 2875863330
15717 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,42 @@
# Schema for the configuration files of the Editor module.
editor.editor.*:
type: config_entity
label: 'Text editor'
mapping:
format:
type: string
label: 'Name'
editor:
type: string
label: 'Text editor'
settings:
type: editor.settings.[%parent.editor]
image_upload:
type: mapping
label: 'Image upload settings'
mapping:
status:
type: boolean
label: 'Status'
scheme:
type: string
label: 'File storage'
directory:
type: string
label: 'Upload directory'
max_size:
type: string
label: 'Maximum file size'
max_dimensions:
type: mapping
label: 'Maximum dimensions'
mapping:
width:
type: integer
nullable: true
label: 'Maximum width'
height:
type: integer
nullable: true
label: 'Maximum height'

View file

@ -0,0 +1,128 @@
<?php
/**
* @file
* Administration functions for editor.module.
*/
use Drupal\Core\StreamWrapper\StreamWrapperInterface;
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' => file_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',
],
];
$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 drupal.ckeditor.drupalimage.admin's summary rendering
// can use that.
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 = format_size(file_upload_max_size());
$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'),
'#field_prefix' => '<div class="container-inline clearfix">',
'#field_suffix' => '</div>',
'#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,
];
$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,
];
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'] = 'myeditoroverride';
}
/**
* 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,10 @@
name: 'Text Editor'
type: module
description: 'Provides a means to associate text formats with text editor libraries such as WYSIWYGs or toolbars.'
package: Core
version: VERSION
core: 8.x
dependencies:
- drupal:filter
- drupal:file
configure: filter.admin_overview

View file

@ -0,0 +1,22 @@
<?php
/**
* @file
* Install, update and uninstall functions for the Editor module.
*/
/**
* Synchronizes the editor status with the paired text format status.
*/
function editor_update_8001() {
$config_factory = \Drupal::configFactory();
// Iterate on all text formats config entities.
foreach ($config_factory->listAll('filter.format.') as $name) {
list(,, $id) = explode('.', $name, 3);
$status = $config_factory->get($name)->get('status');
$editor = $config_factory->getEditable("editor.editor.$id");
if (!$editor->isNew() && $editor->get('status') !== $status) {
$editor->set('status', $status)->save();
}
}
}

View file

@ -0,0 +1,39 @@
drupal.editor.admin:
version: VERSION
js:
js/editor.admin.js: {}
dependencies:
- core/jquery
- core/jquery.once
- core/drupal
drupal.editor:
version: VERSION
js:
js/editor.js: {}
dependencies:
- core/jquery
- core/drupal
- core/drupalSettings
- core/jquery.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
quickedit.inPlaceEditor.formattedText:
version: VERSION
js:
js/editor.formattedTextEditor.js: { attributes: { defer: true } }
dependencies:
- quickedit/quickedit
- editor/drupal.editor
- core/drupal.ajax
- core/drupalSettings

View file

@ -0,0 +1,638 @@
<?php
/**
* @file
* Adds bindings for client-side "text editors" to text formats.
*/
use Drupal\Component\Utility\Html;
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;
/**
* Implements hook_help().
*/
function editor_help($route_name, RouteMatchInterface $route_match) {
switch ($route_name) {
case 'help.page.editor':
$output = '';
$output .= '<h3>' . t('About') . '</h3>';
$output .= '<p>' . t('The Text Editor module provides a framework that other modules (such as <a href=":ckeditor">CKEditor 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', ':ckeditor' => (\Drupal::moduleHandler()->moduleExists('ckeditor')) ? \Drupal::url('help.page', ['name' => 'ckeditor']) : '#']) . '</p>';
$output .= '<h3>' . t('Uses') . '</h3>';
$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 enable a text editor. This can either be the core <a href=":ckeditor">CKEditor module</a>, which can be enabled 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 and install an external library as well as the Drupal module.', [':ckeditor' => (\Drupal::moduleHandler()->moduleExists('ckeditor')) ? \Drupal::url('help.page', ['name' => 'ckeditor']) : '#', ':extend' => \Drupal::url('system.modules_list')]) . '</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' => \Drupal::url('filter.admin_overview')]) . '</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() for \Drupal\filter\FilterFormatListBuilder.
*
* Implements hook_field_formatter_info_alter().
*
* @see quickedit_field_formatter_info_alter()
*/
function editor_field_formatter_info_alter(&$info) {
// Update \Drupal\text\Plugin\Field\FieldFormatter\TextDefaultFormatter's
// annotation to indicate that it supports the 'editor' in-place editor
// provided by this module.
$info['text_default']['quickedit'] = ['editor' => 'editor'];
}
/**
* 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',
'#attached' => [
'library' => [
'editor/drupal.editor.admin',
],
],
];
// Add editor-specific validation and submit handlers.
if ($editor) {
/** @var $plugin \Drupal\editor\Plugin\EditorPluginInterface */
$plugin = $manager->createInstance($editor->getEditor());
$settings_form = [];
$settings_form['#element_validate'][] = [$plugin, 'validateConfigurationForm'];
$form['editor']['settings']['subform'] = $plugin->buildConfigurationForm($settings_form, $form_state);
$form['editor']['settings']['subform']['#parents'] = ['editor', 'settings'];
$form['actions']['submit']['#submit'][] = [$plugin, 'submitConfigurationForm'];
}
$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);
}
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) {
// 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();
}
// 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);
}
$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 isset($editors[$format_id]) ? $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 $field => $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 $field => $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 = _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) {
$added_files = array_diff($uuids_by_field[$field], $original_uuids_by_field[$field]);
_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 $field => $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 $field => $uuids) {
_editor_delete_file_usage($uuids, $entity, 1);
}
}
/**
* Records file usage of files referenced by formatted text fields.
*
* Every referenced file that does not yet have the FILE_STATUS_PERMANENT state,
* will be given that state.
*
* @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::entityManager()->loadEntityByUuid('file', $uuid)) {
if ($file->status !== FILE_STATUS_PERMANENT) {
$file->status = FILE_STATUS_PERMANENT;
$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::entityManager()->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\FileInterface[] $files */
$files = \Drupal::entityTypeManager()
->getStorage('file')
->loadByProperties(['uri' => $uri]);
if (count($files)) {
foreach ($files as $item) {
// Since some database servers sometimes use a case-insensitive comparison
// by default, double check that the filename is an exact match.
if ($item->getFileUri() === $uri) {
$file = $item;
break;
}
}
}
if (!isset($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 = entity_load_multiple($entity_type, 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.
*
* @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.
return array_keys(array_filter($field_definitions, function (FieldDefinitionInterface $definition) {
return in_array($definition->getType(), ['text', 'text_long', 'text_with_summary'], TRUE);
}));
}
/**
* 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::entityManager()
->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();
}
}

View file

@ -0,0 +1,12 @@
<?php
/**
* @file
* Post update functions for Editor.
*/
/**
* Clear the render cache to fix file references added by Editor.
*/
function editor_post_update_clear_cache_for_file_reference_filter() {
}

View file

@ -0,0 +1,34 @@
editor.filter_xss:
path: '/editor/filter_xss/{filter_format}'
defaults:
_controller: '\Drupal\editor\EditorController::filterXss'
requirements:
_entity_access: 'filter_format.use'
editor.field_untransformed_text:
path: '/editor/{entity_type}/{entity}/{field_name}/{langcode}/{view_mode_id}'
defaults:
_controller: '\Drupal\editor\EditorController::getUntransformedText'
options:
parameters:
entity:
type: entity:{entity_type}
requirements:
_permission: 'access in-place editing'
_access_quickedit_entity_field: 'TRUE'
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,7 @@
services:
plugin.manager.editor:
class: Drupal\editor\Plugin\EditorManager
parent: default_plugin_manager
element.editor:
class: Drupal\editor\Element
arguments: ['@plugin.manager.editor']

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,361 @@
/**
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function ($, _, Drupal, document) {
Drupal.editorConfiguration = {
addedFeature: function addedFeature(feature) {
$(document).trigger('drupalEditorFeatureAdded', feature);
},
removedFeature: function removedFeature(feature) {
$(document).trigger('drupalEditorFeatureRemoved', feature);
},
modifiedFeature: function modifiedFeature(feature) {
$(document).trigger('drupalEditorFeatureModified', feature);
},
featureIsAllowedByFilters: function featureIsAllowedByFilters(feature) {
function emptyProperties(section) {
return section.attributes.length === 0 && section.classes.length === 0 && section.styles.length === 0;
}
function generateUniverseFromFeatureRequirements(feature) {
var properties = ['attributes', 'styles', 'classes'];
var universe = {};
for (var r = 0; r < feature.rules.length; r++) {
var featureRule = feature.rules[r];
var requiredTags = featureRule.required.tags;
for (var t = 0; t < requiredTags.length; t++) {
universe[requiredTags[t]] = {
tag: false,
touchedByAllowedPropertyRule: false,
touchedBytouchedByForbiddenPropertyRule: false
};
}
if (emptyProperties(featureRule.required)) {
continue;
}
for (var p = 0; p < properties.length; p++) {
var property = properties[p];
for (var pv = 0; pv < featureRule.required[property].length; pv++) {
var propertyValue = featureRule.required[property];
universe[requiredTags][property + ':' + propertyValue] = false;
}
}
}
return universe;
}
function findPropertyValueOnTag(universe, tag, property, propertyValue, allowing) {
if (!_.has(universe, tag)) {
return false;
}
var key = property + ':' + propertyValue;
if (allowing) {
universe[tag].touchedByAllowedPropertyRule = true;
}
if (_.indexOf(propertyValue, '*') === -1) {
if (_.has(universe, tag) && _.has(universe[tag], key)) {
if (allowing) {
universe[tag][key] = true;
}
return true;
}
return false;
}
var atLeastOneFound = false;
var regex = key.replace(/\*/g, '[^ ]*');
_.each(_.keys(universe[tag]), function (key) {
if (key.match(regex)) {
atLeastOneFound = true;
if (allowing) {
universe[tag][key] = true;
}
}
});
return atLeastOneFound;
}
function findPropertyValuesOnAllTags(universe, property, propertyValues, allowing) {
var atLeastOneFound = false;
_.each(_.keys(universe), function (tag) {
if (findPropertyValuesOnTag(universe, tag, property, propertyValues, allowing)) {
atLeastOneFound = true;
}
});
return atLeastOneFound;
}
function findPropertyValuesOnTag(universe, tag, property, propertyValues, allowing) {
if (tag === '*') {
return findPropertyValuesOnAllTags(universe, property, propertyValues, allowing);
}
var atLeastOneFound = false;
_.each(propertyValues, function (propertyValue) {
if (findPropertyValueOnTag(universe, tag, property, propertyValue, allowing)) {
atLeastOneFound = true;
}
});
return atLeastOneFound;
}
function deleteAllTagsFromUniverseIfAllowed(universe) {
var atLeastOneDeleted = false;
_.each(_.keys(universe), function (tag) {
if (deleteFromUniverseIfAllowed(universe, tag)) {
atLeastOneDeleted = true;
}
});
return atLeastOneDeleted;
}
function deleteFromUniverseIfAllowed(universe, tag) {
if (tag === '*') {
return deleteAllTagsFromUniverseIfAllowed(universe);
}
if (_.has(universe, tag) && _.every(_.omit(universe[tag], 'touchedByAllowedPropertyRule'))) {
delete universe[tag];
return true;
}
return false;
}
function anyForbiddenFilterRuleMatches(universe, filterStatus) {
var properties = ['attributes', 'styles', 'classes'];
var allRequiredTags = _.keys(universe);
var filterRule = void 0;
for (var i = 0; i < filterStatus.rules.length; i++) {
filterRule = filterStatus.rules[i];
if (filterRule.allow === false) {
if (_.intersection(allRequiredTags, filterRule.tags).length > 0) {
return true;
}
}
}
for (var n = 0; n < filterStatus.rules.length; n++) {
filterRule = filterStatus.rules[n];
if (filterRule.restrictedTags.tags.length && !emptyProperties(filterRule.restrictedTags.forbidden)) {
for (var j = 0; j < filterRule.restrictedTags.tags.length; j++) {
var tag = filterRule.restrictedTags.tags[j];
for (var k = 0; k < properties.length; k++) {
var property = properties[k];
if (findPropertyValuesOnTag(universe, tag, property, filterRule.restrictedTags.forbidden[property], false)) {
return true;
}
}
}
}
}
return false;
}
function markAllowedTagsAndPropertyValues(universe, filterStatus) {
var properties = ['attributes', 'styles', 'classes'];
var filterRule = void 0;
var tag = void 0;
for (var l = 0; !_.isEmpty(universe) && l < filterStatus.rules.length; l++) {
filterRule = filterStatus.rules[l];
if (filterRule.allow === true) {
for (var m = 0; !_.isEmpty(universe) && m < filterRule.tags.length; m++) {
tag = filterRule.tags[m];
if (_.has(universe, tag)) {
universe[tag].tag = true;
deleteFromUniverseIfAllowed(universe, tag);
}
}
}
}
for (var i = 0; !_.isEmpty(universe) && i < filterStatus.rules.length; i++) {
filterRule = filterStatus.rules[i];
if (filterRule.restrictedTags.tags.length && !emptyProperties(filterRule.restrictedTags.allowed)) {
for (var j = 0; !_.isEmpty(universe) && j < filterRule.restrictedTags.tags.length; j++) {
tag = filterRule.restrictedTags.tags[j];
for (var k = 0; k < properties.length; k++) {
var property = properties[k];
if (findPropertyValuesOnTag(universe, tag, property, filterRule.restrictedTags.allowed[property], true)) {
deleteFromUniverseIfAllowed(universe, tag);
}
}
}
}
}
}
function filterStatusAllowsFeature(filterStatus, feature) {
if (!filterStatus.active) {
return true;
}
if (feature.rules.length === 0) {
return true;
}
if (filterStatus.rules.length === 0) {
return true;
}
var universe = generateUniverseFromFeatureRequirements(feature);
if (anyForbiddenFilterRuleMatches(universe, filterStatus)) {
return false;
}
markAllowedTagsAndPropertyValues(universe, filterStatus);
if (_.some(_.pluck(filterStatus.rules, 'allow'))) {
if (_.isEmpty(universe)) {
return true;
}
if (!_.every(_.pluck(universe, 'tag'))) {
return false;
}
var tags = _.keys(universe);
for (var i = 0; i < tags.length; i++) {
var tag = tags[i];
if (_.has(universe, tag)) {
if (universe[tag].touchedByAllowedPropertyRule === false) {
delete universe[tag];
}
}
}
return _.isEmpty(universe);
}
return true;
}
Drupal.filterConfiguration.update();
return Object.keys(Drupal.filterConfiguration.statuses).every(function (filterID) {
return filterStatusAllowsFeature(Drupal.filterConfiguration.statuses[filterID], feature);
});
}
};
Drupal.EditorFeatureHTMLRule = function () {
this.required = {
tags: [],
attributes: [],
styles: [],
classes: []
};
this.allowed = {
tags: [],
attributes: [],
styles: [],
classes: []
};
this.raw = null;
};
Drupal.EditorFeature = function (name) {
this.name = name;
this.rules = [];
};
Drupal.EditorFeature.prototype.addHTMLRule = function (rule) {
this.rules.push(rule);
};
Drupal.FilterStatus = function (name) {
this.name = name;
this.active = false;
this.rules = [];
};
Drupal.FilterStatus.prototype.addHTMLRule = function (rule) {
this.rules.push(rule);
};
Drupal.FilterHTMLRule = function () {
this.tags = [];
this.allow = null;
this.restrictedTags = {
tags: [],
allowed: { attributes: [], styles: [], classes: [] },
forbidden: { attributes: [], styles: [], classes: [] }
};
return this;
};
Drupal.FilterHTMLRule.prototype.clone = function () {
var clone = new Drupal.FilterHTMLRule();
clone.tags = this.tags.slice(0);
clone.allow = this.allow;
clone.restrictedTags.tags = this.restrictedTags.tags.slice(0);
clone.restrictedTags.allowed.attributes = this.restrictedTags.allowed.attributes.slice(0);
clone.restrictedTags.allowed.styles = this.restrictedTags.allowed.styles.slice(0);
clone.restrictedTags.allowed.classes = this.restrictedTags.allowed.classes.slice(0);
clone.restrictedTags.forbidden.attributes = this.restrictedTags.forbidden.attributes.slice(0);
clone.restrictedTags.forbidden.styles = this.restrictedTags.forbidden.styles.slice(0);
clone.restrictedTags.forbidden.classes = this.restrictedTags.forbidden.classes.slice(0);
return clone;
};
Drupal.filterConfiguration = {
statuses: {},
liveSettingParsers: {},
update: function update() {
Object.keys(Drupal.filterConfiguration.statuses || {}).forEach(function (filterID) {
Drupal.filterConfiguration.statuses[filterID].active = $('[name="filters[' + filterID + '][status]"]').is(':checked');
if (Drupal.filterConfiguration.liveSettingParsers[filterID]) {
Drupal.filterConfiguration.statuses[filterID].rules = Drupal.filterConfiguration.liveSettingParsers[filterID].getRules();
}
});
}
};
Drupal.behaviors.initializeFilterConfiguration = {
attach: function attach(context, settings) {
var $context = $(context);
$context.find('#filters-status-wrapper input.form-checkbox').once('filter-editor-status').each(function () {
var $checkbox = $(this);
var nameAttribute = $checkbox.attr('name');
var filterID = nameAttribute.substring(8, nameAttribute.indexOf(']'));
Drupal.filterConfiguration.statuses[filterID] = new Drupal.FilterStatus(filterID);
});
}
};
})(jQuery, _, Drupal, document);

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);

View file

@ -0,0 +1,12 @@
/**
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function ($, Drupal) {
Drupal.AjaxCommands.prototype.editorDialogSave = function (ajax, response, status) {
$(window).trigger('editor:dialogsave', [response.values]);
};
})(jQuery, Drupal);

View file

@ -0,0 +1,345 @@
/**
* @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.val();
// 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').text(),
},
);
const confirmationDialog = Drupal.dialog(`<div>${message}</div>`, {
title: Drupal.t('Change text format?'),
dialogClass: '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.val(activeFormatID);
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;
}
$(context)
.find('[data-editor-for]')
.once('editor')
.each(function() {
const $this = $(this);
const field = findFieldForFormatSelector($this);
// Opt-out if no supported text area was found.
if (!field) {
return;
}
// Store the current active format.
const activeFormatID = $this.val();
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 ($this.is('select')) {
$this.on('change.editorAttach', { field }, onTextFormatChange);
}
// Detach any editor when the containing form is submitted.
$this.parents('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 = $(context)
.find('[data-editor-for]')
.findOnce('editor');
} else {
editors = $(context)
.find('[data-editor-for]')
.removeOnce('editor');
}
editors.each(function() {
const $this = $(this);
const activeFormatID = $this.val();
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,244 @@
/**
* @file
* Text editor-based in-place editor for formatted text content in Drupal.
*
* Depends on editor.module. Works with any (WYSIWYG) editor that implements the
* editor.js API, including the optional attachInlineEditor() and onChange()
* methods.
* For example, assuming that a hypothetical editor's name was "Magical Editor"
* and its editor.js API implementation lived at Drupal.editors.magical, this
* JavaScript would use:
* - Drupal.editors.magical.attachInlineEditor()
*/
(function($, Drupal, drupalSettings, _) {
Drupal.quickedit.editors.editor = Drupal.quickedit.EditorView.extend(
/** @lends Drupal.quickedit.editors.editor# */ {
/**
* The text format for this field.
*
* @type {string}
*/
textFormat: null,
/**
* Indicates whether this text format has transformations.
*
* @type {bool}
*/
textFormatHasTransformations: null,
/**
* Stores a reference to the text editor object for this field.
*
* @type {Drupal.quickedit.EditorModel}
*/
textEditor: null,
/**
* Stores the textual DOM element that is being in-place edited.
*
* @type {jQuery}
*/
$textElement: null,
/**
* @constructs
*
* @augments Drupal.quickedit.EditorView
*
* @param {object} options
* Options for the editor view.
*/
initialize(options) {
Drupal.quickedit.EditorView.prototype.initialize.call(this, options);
const metadata = Drupal.quickedit.metadata.get(
this.fieldModel.get('fieldID'),
'custom',
);
this.textFormat = drupalSettings.editor.formats[metadata.format];
this.textFormatHasTransformations = metadata.formatHasTransformations;
this.textEditor = Drupal.editors[this.textFormat.editor];
// Store the actual value of this field. We'll need this to restore the
// original value when the user discards his modifications.
const $fieldItems = this.$el.find('.quickedit-field');
if ($fieldItems.length) {
this.$textElement = $fieldItems.eq(0);
} else {
this.$textElement = this.$el;
}
this.model.set('originalValue', this.$textElement.html());
},
/**
* @inheritdoc
*
* @return {jQuery}
* The text element edited.
*/
getEditedElement() {
return this.$textElement;
},
/**
* @inheritdoc
*
* @param {object} fieldModel
* The field model.
* @param {string} state
* The current state.
*/
stateChange(fieldModel, state) {
const editorModel = this.model;
const from = fieldModel.previous('state');
const to = state;
switch (to) {
case 'inactive':
break;
case 'candidate':
// Detach the text editor when entering the 'candidate' state from one
// of the states where it could have been attached.
if (from !== 'inactive' && from !== 'highlighted') {
this.textEditor.detach(this.$textElement.get(0), this.textFormat);
}
// A field model's editor view revert() method is invoked when an
// 'active' field becomes a 'candidate' field. But, in the case of
// this in-place editor, the content will have been *replaced* if the
// text format has transformation filters. Therefore, if we stop
// in-place editing this entity, revert explicitly.
if (from === 'active' && this.textFormatHasTransformations) {
this.revert();
}
if (from === 'invalid') {
this.removeValidationErrors();
}
break;
case 'highlighted':
break;
case 'activating':
// When transformation filters have been applied to the formatted text
// of this field, then we'll need to load a re-formatted version of it
// without the transformation filters.
if (this.textFormatHasTransformations) {
const $textElement = this.$textElement;
this._getUntransformedText(untransformedText => {
$textElement.html(untransformedText);
fieldModel.set('state', 'active');
});
}
// When no transformation filters have been applied: start WYSIWYG
// editing immediately!
else {
// Defer updating the model until the current state change has
// propagated, to not trigger a nested state change event.
_.defer(() => {
fieldModel.set('state', 'active');
});
}
break;
case 'active': {
const textElement = this.$textElement.get(0);
const toolbarView = fieldModel.toolbarView;
this.textEditor.attachInlineEditor(
textElement,
this.textFormat,
toolbarView.getMainWysiwygToolgroupId(),
toolbarView.getFloatedWysiwygToolgroupId(),
);
// Set the state to 'changed' whenever the content has changed.
this.textEditor.onChange(textElement, htmlText => {
editorModel.set('currentValue', htmlText);
fieldModel.set('state', 'changed');
});
break;
}
case 'changed':
break;
case 'saving':
if (from === 'invalid') {
this.removeValidationErrors();
}
this.save();
break;
case 'saved':
break;
case 'invalid':
this.showValidationErrors();
break;
}
},
/**
* @inheritdoc
*
* @return {object}
* The settings for the quick edit UI.
*/
getQuickEditUISettings() {
return {
padding: true,
unifiedToolbar: true,
fullWidthToolbar: true,
popup: false,
};
},
/**
* @inheritdoc
*/
revert() {
this.$textElement.html(this.model.get('originalValue'));
},
/**
* Loads untransformed text for this field.
*
* More accurately: it re-filters formatted text to exclude transformation
* filters used by the text format.
*
* @param {function} callback
* A callback function that will receive the untransformed text.
*
* @see \Drupal\editor\Ajax\GetUntransformedTextCommand
*/
_getUntransformedText(callback) {
const fieldID = this.fieldModel.get('fieldID');
// Create a Drupal.ajax instance to load the form.
const textLoaderAjax = Drupal.ajax({
url: Drupal.quickedit.util.buildUrl(
fieldID,
Drupal.url(
'editor/!entity_type/!id/!field_name/!langcode/!view_mode',
),
),
submit: { nocssjs: true },
});
// Implement a scoped editorGetUntransformedText AJAX command: calls the
// callback.
textLoaderAjax.commands.editorGetUntransformedText = function(
ajax,
response,
status,
) {
callback(response.data);
};
// This will ensure our scoped editorGetUntransformedText AJAX command
// gets called.
textLoaderAjax.execute();
},
},
);
})(jQuery, Drupal, drupalSettings, _);

View file

@ -0,0 +1,132 @@
/**
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function ($, Drupal, drupalSettings, _) {
Drupal.quickedit.editors.editor = Drupal.quickedit.EditorView.extend({
textFormat: null,
textFormatHasTransformations: null,
textEditor: null,
$textElement: null,
initialize: function initialize(options) {
Drupal.quickedit.EditorView.prototype.initialize.call(this, options);
var metadata = Drupal.quickedit.metadata.get(this.fieldModel.get('fieldID'), 'custom');
this.textFormat = drupalSettings.editor.formats[metadata.format];
this.textFormatHasTransformations = metadata.formatHasTransformations;
this.textEditor = Drupal.editors[this.textFormat.editor];
var $fieldItems = this.$el.find('.quickedit-field');
if ($fieldItems.length) {
this.$textElement = $fieldItems.eq(0);
} else {
this.$textElement = this.$el;
}
this.model.set('originalValue', this.$textElement.html());
},
getEditedElement: function getEditedElement() {
return this.$textElement;
},
stateChange: function stateChange(fieldModel, state) {
var editorModel = this.model;
var from = fieldModel.previous('state');
var to = state;
switch (to) {
case 'inactive':
break;
case 'candidate':
if (from !== 'inactive' && from !== 'highlighted') {
this.textEditor.detach(this.$textElement.get(0), this.textFormat);
}
if (from === 'active' && this.textFormatHasTransformations) {
this.revert();
}
if (from === 'invalid') {
this.removeValidationErrors();
}
break;
case 'highlighted':
break;
case 'activating':
if (this.textFormatHasTransformations) {
var $textElement = this.$textElement;
this._getUntransformedText(function (untransformedText) {
$textElement.html(untransformedText);
fieldModel.set('state', 'active');
});
} else {
_.defer(function () {
fieldModel.set('state', 'active');
});
}
break;
case 'active':
{
var textElement = this.$textElement.get(0);
var toolbarView = fieldModel.toolbarView;
this.textEditor.attachInlineEditor(textElement, this.textFormat, toolbarView.getMainWysiwygToolgroupId(), toolbarView.getFloatedWysiwygToolgroupId());
this.textEditor.onChange(textElement, function (htmlText) {
editorModel.set('currentValue', htmlText);
fieldModel.set('state', 'changed');
});
break;
}
case 'changed':
break;
case 'saving':
if (from === 'invalid') {
this.removeValidationErrors();
}
this.save();
break;
case 'saved':
break;
case 'invalid':
this.showValidationErrors();
break;
}
},
getQuickEditUISettings: function getQuickEditUISettings() {
return {
padding: true,
unifiedToolbar: true,
fullWidthToolbar: true,
popup: false
};
},
revert: function revert() {
this.$textElement.html(this.model.get('originalValue'));
},
_getUntransformedText: function _getUntransformedText(callback) {
var fieldID = this.fieldModel.get('fieldID');
var textLoaderAjax = Drupal.ajax({
url: Drupal.quickedit.util.buildUrl(fieldID, Drupal.url('editor/!entity_type/!id/!field_name/!langcode/!view_mode')),
submit: { nocssjs: true }
});
textLoaderAjax.commands.editorGetUntransformedText = function (ajax, response, status) {
callback(response.data);
};
textLoaderAjax.execute();
}
});
})(jQuery, Drupal, drupalSettings, _);

View file

@ -0,0 +1,193 @@
/**
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function ($, Drupal, drupalSettings) {
function findFieldForFormatSelector($formatSelector) {
var fieldId = $formatSelector.attr('data-editor-for');
return $('#' + fieldId).get(0);
}
function filterXssWhenSwitching(field, format, originalFormatID, callback) {
if (format.editor.isXssSafe) {
callback(field, format);
} else {
$.ajax({
url: Drupal.url('editor/filter_xss/' + format.format),
type: 'POST',
data: {
value: field.value,
original_format_id: originalFormatID
},
dataType: 'json',
success: function success(xssFilteredValue) {
if (xssFilteredValue !== false) {
field.value = xssFilteredValue;
}
callback(field, format);
}
});
}
}
function changeTextEditor(field, newFormatID) {
var previousFormatID = field.getAttribute('data-editor-active-text-format');
if (drupalSettings.editor.formats[previousFormatID]) {
Drupal.editorDetach(field, drupalSettings.editor.formats[previousFormatID]);
} else {
$(field).off('.editor');
}
if (drupalSettings.editor.formats[newFormatID]) {
var format = drupalSettings.editor.formats[newFormatID];
filterXssWhenSwitching(field, format, previousFormatID, Drupal.editorAttach);
}
field.setAttribute('data-editor-active-text-format', newFormatID);
}
function onTextFormatChange(event) {
var $select = $(event.target);
var field = event.data.field;
var activeFormatID = field.getAttribute('data-editor-active-text-format');
var newFormatID = $select.val();
if (newFormatID === activeFormatID) {
return;
}
var supportContentFiltering = drupalSettings.editor.formats[newFormatID] && drupalSettings.editor.formats[newFormatID].editorSupportsContentFiltering;
var hasContent = field.value !== '';
if (hasContent && supportContentFiltering) {
var 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').text()
});
var confirmationDialog = Drupal.dialog('<div>' + message + '</div>', {
title: Drupal.t('Change text format?'),
dialogClass: 'editor-change-text-format-modal',
resizable: false,
buttons: [{
text: Drupal.t('Continue'),
class: 'button button--primary',
click: function click() {
changeTextEditor(field, newFormatID);
confirmationDialog.close();
}
}, {
text: Drupal.t('Cancel'),
class: 'button',
click: function click() {
$select.val(activeFormatID);
confirmationDialog.close();
}
}],
closeOnEscape: false,
create: function create() {
$(this).parent().find('.ui-dialog-titlebar-close').remove();
},
beforeClose: false,
close: function close(event) {
$(event.target).remove();
}
});
confirmationDialog.showModal();
} else {
changeTextEditor(field, newFormatID);
}
}
Drupal.editors = {};
Drupal.behaviors.editor = {
attach: function attach(context, settings) {
if (!settings.editor) {
return;
}
$(context).find('[data-editor-for]').once('editor').each(function () {
var $this = $(this);
var field = findFieldForFormatSelector($this);
if (!field) {
return;
}
var activeFormatID = $this.val();
field.setAttribute('data-editor-active-text-format', activeFormatID);
if (settings.editor.formats[activeFormatID]) {
Drupal.editorAttach(field, settings.editor.formats[activeFormatID]);
}
$(field).on('change.editor keypress.editor', function () {
field.setAttribute('data-editor-value-is-changed', 'true');
$(field).off('.editor');
});
if ($this.is('select')) {
$this.on('change.editorAttach', { field: field }, onTextFormatChange);
}
$this.parents('form').on('submit', function (event) {
if (event.isDefaultPrevented()) {
return;
}
if (settings.editor.formats[activeFormatID]) {
Drupal.editorDetach(field, settings.editor.formats[activeFormatID], 'serialize');
}
});
});
},
detach: function detach(context, settings, trigger) {
var editors = void 0;
if (trigger === 'serialize') {
editors = $(context).find('[data-editor-for]').findOnce('editor');
} else {
editors = $(context).find('[data-editor-for]').removeOnce('editor');
}
editors.each(function () {
var $this = $(this);
var activeFormatID = $this.val();
var field = findFieldForFormatSelector($this);
if (field && activeFormatID in settings.editor.formats) {
Drupal.editorDetach(field, settings.editor.formats[activeFormatID], trigger);
}
});
}
};
Drupal.editorAttach = function (field, format) {
if (format.editor) {
Drupal.editors[format.editor].attach(field, format);
Drupal.editors[format.editor].onChange(field, function () {
$(field).trigger('formUpdated');
field.setAttribute('data-editor-value-is-changed', 'true');
});
}
};
Drupal.editorDetach = function (field, format, trigger) {
if (format.editor) {
Drupal.editors[format.editor].detach(field, format, trigger);
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 string
*/
protected $values;
/**
* Constructs a EditorDialogSave object.
*
* @param string $values
* The values that should be passed to the form constructor in Drupal.
*/
public function __construct($values) {
$this->values = $values;
}
/**
* {@inheritdoc}
*/
public function render() {
return [
'command' => 'editorDialogSave',
'values' => $this->values,
];
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace Drupal\editor\Ajax;
use Drupal\Core\Ajax\BaseCommand;
/**
* AJAX command to rerender a formatted text field without any transformation
* filters.
*/
class GetUntransformedTextCommand extends BaseCommand {
/**
* Constructs a GetUntransformedTextCommand object.
*
* @param string $data
* The data to pass on to the client side.
*/
public function __construct($data) {
parent::__construct('editorGetUntransformedText', $data);
}
}

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 = "myeditor",
* 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\ckeditor\Plugin\Editor\CKEditor
*
* @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,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,81 @@
<?php
namespace Drupal\editor;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Entity\EntityInterface;
use Drupal\editor\Ajax\GetUntransformedTextCommand;
use Drupal\filter\Plugin\FilterInterface;
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 {
/**
* Returns an Ajax response to render a text field without transformation filters.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity of which a formatted text field is being rerendered.
* @param string $field_name
* The name of the (formatted text) field that is being rerendered
* @param string $langcode
* The name of the language for which the formatted text field is being
* rerendered.
* @param string $view_mode_id
* The view mode the formatted text field should be rerendered in.
*
* @return \Drupal\Core\Ajax\AjaxResponse
* The Ajax response.
*/
public function getUntransformedText(EntityInterface $entity, $field_name, $langcode, $view_mode_id) {
$response = new AjaxResponse();
// Direct text editing is only supported for single-valued fields.
$field = $entity->getTranslation($langcode)->$field_name;
$editable_text = check_markup($field->value, $field->format, $langcode, [FilterInterface::TYPE_TRANSFORM_REVERSIBLE, FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE]);
$response->addCommand(new GetUntransformedTextCommand($editable_text));
return $response;
}
/**
* 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->entityManager()
->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,172 @@
<?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 and forbidden 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 and forbidden 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 blacklisted by the text format must be
// appended to the list of default dangerous tags: if they're explicitly
// forbidden, then we must respect that configuration.
// When switching from another text format, we must use the union of
// forbidden tags: if either text format is more restrictive, then the
// safety expectations of *both* text formats apply.
$forbidden_tags = self::getForbiddenTags($format_restrictions);
if ($original_format !== NULL) {
$forbidden_tags = array_merge($forbidden_tags, self::getForbiddenTags($original_format_restrictions));
}
// 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);
// Also blacklist tags that are explicitly forbidden in either text format.
$blacklisted_tags = array_merge($blacklisted_tags, $forbidden_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;
}
/**
* Get all forbidden tags from a restrictions data structure.
*
* @param array|false $restrictions
* Restrictions as returned by FilterInterface::getHTMLRestrictions().
*
* @return array
* An array of forbidden HTML tags.
*
* @see \Drupal\filter\Plugin\Filter\FilterInterface::getHTMLRestrictions()
*/
protected static function getForbiddenTags($restrictions) {
if ($restrictions === FALSE || !isset($restrictions['forbidden_tags'])) {
return [];
}
else {
return $restrictions['forbidden_tags'];
}
}
/**
* {@inheritdoc}
*/
protected static function needsRemoval($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 XSSes anymore.
*/
public static function filterXss($html, FilterFormatInterface $format, FilterFormatInterface $original_format = NULL);
}

View file

@ -0,0 +1,118 @@
<?php
namespace Drupal\editor;
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 {
/**
* 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;
}
/**
* 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;
}
// filter_process_format() copies properties to the expanded 'value' 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,193 @@
<?php
namespace Drupal\editor\Entity;
use Drupal\Core\Config\Entity\ConfigEntityBase;
use Drupal\editor\EditorInterface;
/**
* Defines the configured text editor entity.
*
* @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",
* }
* )
*/
class Editor extends ConfigEntityBase implements EditorInterface {
/**
* The machine name of the text format with which this configured text editor
* is associated.
*
* @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);
$plugin = $this->editorPluginManager()->createInstance($this->editor);
$this->settings += $plugin->getDefaultSettings();
}
/**
* {@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::entityManager()->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;
}
}

View file

@ -0,0 +1,241 @@
<?php
namespace Drupal\editor\Form;
use Drupal\Component\Utility\Bytes;
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.
*
* @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) {
$this->fileStorage = $file_storage;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity.manager')->getStorage('file')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'editor_image_dialog';
}
/**
* {@inheritdoc}
*
* @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();
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::toInt($image_upload['max_size']), file_upload_max_size());
$existing_file = isset($image_element['data-entity-uuid']) ? \Drupal::entityManager()->loadEntityByUuid('file', $image_element['data-entity-uuid']) : NULL;
$fid = $existing_file ? $existing_file->id() : NULL;
$form['fid'] = [
'#title' => $this->t('Image'),
'#type' => 'managed_file',
'#upload_location' => $image_upload['scheme'] . '://' . $image_upload['directory'],
'#default_value' => $fid ? [$fid] : NULL,
'#upload_validators' => [
'file_validate_extensions' => ['gif png jpg jpeg'],
'file_validate_size' => [$max_filesize],
'file_validate_image_resolution' => [$max_dimensions],
],
'#required' => TRUE,
];
$form['attributes']['src'] = [
'#title' => $this->t('URL'),
'#type' => 'textfield',
'#default_value' => isset($image_element['src']) ? $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;
}
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 = isset($image_element['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)) {
$file = $this->fileStorage->load($fid);
$file_url = file_create_url($file->getFileUri());
// Transform absolute image URLs to relative image URLs: prevent problems
// on multisite set-ups and prevent mixed content errors.
$file_url = file_url_transform_relative($file_url);
$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,92 @@
<?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.
*
* @internal
*/
class EditorLinkDialog extends FormBase {
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'editor_link_dialog';
}
/**
* {@inheritdoc}
*
* @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) {
// The default values are set directly from \Drupal::request()->request,
// provided by the editor plugin opening the dialog.
$user_input = $form_state->getUserInput();
$input = isset($user_input['editor_object']) ? $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' => isset($input['href']) ? $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,81 @@
<?php
namespace Drupal\editor\Plugin;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\PluginBase;
use Drupal\editor\Entity\Editor;
/**
* 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}
*
* @todo Remove in Drupal 9.0.0.
*/
public function settingsForm(array $form, FormStateInterface $form_state, Editor $editor) {
@trigger_error('The ' . __METHOD__ . ' method is deprecated since version 8.3.x and will be removed in 9.0.0.', E_USER_DEPRECATED);
return $form;
}
/**
* {@inheritdoc}
*
* @todo Remove in Drupal 9.0.0.
*/
public function settingsFormValidate(array $form, FormStateInterface $form_state) {
@trigger_error('The ' . __METHOD__ . ' method is deprecated since version 8.3.x and will be removed in 9.0.0.', E_USER_DEPRECATED);
}
/**
* {@inheritdoc}
*
* @todo Remove in Drupal 9.0.0.
*/
public function settingsFormSubmit(array $form, FormStateInterface $form_state) {
@trigger_error('The ' . __METHOD__ . ' method is deprecated since version 8.3.x and will be removed in 9.0.0.', E_USER_DEPRECATED);
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
return $this->settingsForm($form, $form_state, $form_state->get('editor'));
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
return $this->settingsFormValidate($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
return $this->settingsFormSubmit($form, $form_state);
}
}

View file

@ -0,0 +1,99 @@
<?php
namespace Drupal\editor\Plugin;
use Drupal\Core\Plugin\DefaultPluginManager;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
/**
* 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', '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,100 @@
<?php
namespace Drupal\editor\Plugin\Filter;
use Drupal\Component\Utility\Html;
use Drupal\Core\Entity\EntityManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\filter\FilterProcessResult;
use Drupal\filter\Plugin\FilterBase;
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 = @Translation("Track images uploaded via a Text Editor"),
* description = @Translation("Ensures that the latest versions of images uploaded via a Text Editor are displayed."),
* type = Drupal\filter\Plugin\FilterInterface::TYPE_TRANSFORM_REVERSIBLE
* )
*/
class EditorFileReference extends FilterBase implements ContainerFactoryPluginInterface {
/**
* An entity manager object.
*
* @var \Drupal\Core\Entity\EntityManagerInterface
*/
protected $entityManager;
/**
* 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\EntityManagerInterface $entity_manager
* An entity manager object.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityManagerInterface $entity_manager) {
$this->entityManager = $entity_manager;
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.manager')
);
}
/**
* {@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->entityManager->loadEntityByUuid('file', $uuid);
if ($file) {
$node->setAttribute('src', file_url_transform_relative(file_create_url($file->getFileUri())));
}
}
// Only process the first occurrence of each file UUID.
if (!isset($processed_uuids[$uuid])) {
$processed_uuids[$uuid] = TRUE;
$file = $this->entityManager->loadEntityByUuid('file', $uuid);
if ($file) {
$result->addCacheTags($file->getCacheTags());
}
}
}
$result->setProcessedText(Html::serialize($dom));
}
return $result;
}
}

View file

@ -0,0 +1,96 @@
<?php
namespace Drupal\editor\Plugin\InPlaceEditor;
use Drupal\Component\Plugin\PluginBase;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\filter\Entity\FilterFormat;
use Drupal\quickedit\Plugin\InPlaceEditorInterface;
use Drupal\filter\Plugin\FilterInterface;
/**
* Defines the formatted text in-place editor.
*
* @InPlaceEditor(
* id = "editor"
* )
*/
class Editor extends PluginBase implements InPlaceEditorInterface {
/**
* {@inheritdoc}
*/
public function isCompatible(FieldItemListInterface $items) {
$field_definition = $items->getFieldDefinition();
// This editor is incompatible with multivalued fields.
if ($field_definition->getFieldStorageDefinition()->getCardinality() != 1) {
return FALSE;
}
// This editor is compatible with formatted ("rich") text fields; but only
// if there is a currently active text format, that text format has an
// associated editor and that editor supports inline editing.
elseif ($editor = editor_load($items[0]->format)) {
$definition = \Drupal::service('plugin.manager.editor')->getDefinition($editor->getEditor());
if ($definition['supports_inline_editing'] === TRUE) {
return TRUE;
}
}
return FALSE;
}
/**
* {@inheritdoc}
*/
public function getMetadata(FieldItemListInterface $items) {
$format_id = $items[0]->format;
$metadata['format'] = $format_id;
$metadata['formatHasTransformations'] = $this->textFormatHasTransformationFilters($format_id);
return $metadata;
}
/**
* Returns whether the text format has transformation filters.
*
* @param int $format_id
* A text format ID.
*
* @return bool
*/
protected function textFormatHasTransformationFilters($format_id) {
$format = FilterFormat::load($format_id);
return (bool) count(array_intersect([FilterInterface::TYPE_TRANSFORM_REVERSIBLE, FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE], $format->getFiltertypes()));
}
/**
* {@inheritdoc}
*/
public function getAttachments() {
$user = \Drupal::currentUser();
$user_format_ids = array_keys(filter_formats($user));
$manager = \Drupal::service('plugin.manager.editor');
$definitions = $manager->getDefinitions();
// Filter the current user's formats to those that support inline editing.
$formats = [];
foreach ($user_format_ids as $format_id) {
if ($editor = editor_load($format_id)) {
$editor_id = $editor->getEditor();
if (isset($definitions[$editor_id]['supports_inline_editing']) && $definitions[$editor_id]['supports_inline_editing'] === TRUE) {
$formats[] = $format_id;
}
}
}
// Get the attachments for all text editors that the user might use.
$attachments = $manager->getAttachments($formats);
// Also include editor.module's formatted text editor.
$attachments['library'][] = 'editor/quickedit.inPlaceEditor.formattedText';
return $attachments;
}
}

View file

@ -0,0 +1,34 @@
format: private_images
status: true
langcode: en
editor: ckeditor
settings:
toolbar:
rows:
-
-
name: Media
items:
- DrupalImage
-
name: Tools
items:
- Source
plugins:
language:
language_list: un
stylescombo:
styles: ''
image_upload:
status: true
scheme: private
directory: ''
max_size: ''
max_dimensions:
width: null
height: null
dependencies:
config:
- filter.format.private_images
module:
- ckeditor

View file

@ -0,0 +1,23 @@
format: private_images
name: 'Private images'
status: true
langcode: en
filters:
editor_file_reference:
id: editor_file_reference
provider: editor
status: true
weight: 0
settings: { }
filter_html:
id: filter_html
provider: filter
status: false
weight: -10
settings:
allowed_html: '<img src alt data-entity-type data-entity-uuid>'
filter_html_help: true
filter_html_nofollow: false
dependencies:
module:
- editor

View file

@ -0,0 +1,9 @@
name: 'Text Editor Private test'
type: module
description: 'Support module for the Text Editor Private module tests.'
core: 8.x
package: Testing
version: VERSION
dependencies:
- drupal:filter
- drupal:ckeditor

View file

@ -0,0 +1,17 @@
# Schema for the configuration files of the Editor test module.
editor.settings.unicorn:
type: mapping
label: 'Unicorn settings'
mapping:
ponies_too:
type: boolean
label: 'Ponies too'
editor.settings.trex:
type: mapping
label: 'T-Rex settings'
mapping:
stumpy_arms:
type: boolean
label: 'Stumpy arms'

View file

@ -0,0 +1,6 @@
name: 'Text Editor test'
type: module
description: 'Support module for the Text Editor module tests.'
core: 8.x
package: Testing
version: VERSION

View file

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

View file

@ -0,0 +1,46 @@
<?php
/**
* @file
* Helper module for the Text Editor tests.
*/
use Drupal\filter\FilterFormatInterface;
/**
* 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']);
}
}

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,65 @@
<?php
namespace Drupal\editor_test\Plugin\Editor;
use Drupal\Core\Form\FormStateInterface;
use Drupal\editor\Entity\Editor;
use Drupal\editor\Plugin\EditorBase;
/**
* Defines a Tyrannosaurus-Rex powered text editor for testing purposes.
*
* @Editor(
* id = "trex",
* label = @Translation("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 settingsForm(array $form, FormStateInterface $form_state, Editor $editor) {
$form['stumpy_arms'] = [
'#title' => t('Stumpy arms'),
'#type' => 'checkbox',
'#default_value' => TRUE,
];
return $form;
}
/**
* {@inheritdoc}
*/
public function getJSSettings(Editor $editor) {
$js_settings = [];
$settings = $editor->getSettings();
if ($settings['stumpy_arms']) {
$js_settings['doMyArmsLookStumpy'] = TRUE;
}
return $js_settings;
}
/**
* {@inheritdoc}
*/
public function getLibraries(Editor $editor) {
return [
'editor_test/trex',
];
}
}

View file

@ -0,0 +1,66 @@
<?php
namespace Drupal\editor_test\Plugin\Editor;
use Drupal\Core\Form\FormStateInterface;
use Drupal\editor\Entity\Editor;
use Drupal\editor\Plugin\EditorBase;
/**
* Defines a Unicorn-powered text editor for Drupal (for testing purposes).
*
* @Editor(
* id = "unicorn",
* label = @Translation("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 settingsForm(array $form, FormStateInterface $form_state, Editor $editor) {
$form['ponies_too'] = [
'#title' => t('Pony mode'),
'#type' => 'checkbox',
'#default_value' => TRUE,
];
return $form;
}
/**
* {@inheritdoc}
*/
public function getJSSettings(Editor $editor) {
$js_settings = [];
$settings = $editor->getSettings();
if ($settings['ponies_too']) {
$js_settings['ponyModeEnabled'] = TRUE;
}
return $js_settings;
}
/**
* {@inheritdoc}
*/
public function getLibraries(Editor $editor) {
return [
'editor_test/unicorn',
];
}
}

View file

@ -0,0 +1,237 @@
<?php
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
*/
public static $modules = ['filter', 'editor'];
/**
* A user with the 'administer filters' permission.
*
* @var \Drupal\user\UserInterface
*/
protected $adminUser;
protected function setUp() {
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() {
$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->assertTrue($roles_pos < $editor_pos && $editor_pos < $filters_pos, '"Text Editor" select appears in the correct location of the text format configuration UI.');
// Verify the <select>.
$select = $this->xpath('//select[@name="editor[editor]"]');
$select_is_disabled = $this->xpath('//select[@name="editor[editor]" and @disabled="disabled"]');
$options = $this->xpath('//select[@name="editor[editor]"]/option');
$this->assertTrue(count($select) === 1, 'The Text Editor select exists.');
$this->assertTrue(count($select_is_disabled) === 1, 'The Text Editor select is disabled.');
$this->assertTrue(count($options) === 1, 'The Text Editor select has only one option.');
$this->assertTrue(($options[0]->getText()) === 'None', 'Option 1 in the Text Editor select is "None".');
$this->assertRaw('This option is disabled because no modules that provide a text editor are currently enabled.', 'Description for select present that tells users to install a text editor module.');
}
/**
* Tests adding a text editor to an existing text format.
*/
public function testAddEditorToExistingFormat() {
$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->drupalPostForm(NULL, $edit, t('Save configuration'));
$this->verifyUnicornEditorConfiguration('filtered_html', FALSE);
// Switch back to 'None' and check the Unicorn Editor's settings are gone.
$edit = [
'editor[editor]' => '',
];
$this->drupalPostForm(NULL, $edit, 'Configure');
$unicorn_setting = $this->xpath('//input[@name="editor[settings][ponies_too]" and @type="checkbox" and @checked]');
$this->assertTrue(count($unicorn_setting) === 0, "Unicorn Editor's settings form is no longer present.");
}
/**
* Tests adding a text editor to a new text format.
*/
public function testAddEditorToNewFormat() {
$this->addEditorToNewFormat('monocerus', 'Monocerus');
$this->verifyUnicornEditorConfiguration('monocerus');
}
/**
* Tests format disabling.
*/
public function testDisableFormatWithEditor() {
$formats = ['monocerus' => 'Monocerus', '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' => mb_strtolower($this->randomMachineName())]);
$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 'moncerus'.
$node = Node::create([
'type' => $node_type->id(),
'title' => $this->randomString(),
]);
$node->body->value = $this->randomString(100);
$node->body->format = 'monocerus';
$node->save();
// Log in as an 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->assertRaw($text);
// Disable the format assigned to the 'body' field of the node.
FilterFormat::load('monocerus')->disable()->save();
// Edit again the node.
$this->drupalGet('node/' . $node->id() . '/edit');
$this->assertRaw($text);
}
/**
* 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->drupalPostForm(NULL, $edit, t('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->xpath('//select[@name="editor[editor]"]');
$select_is_disabled = $this->xpath('//select[@name="editor[editor]" and @disabled="disabled"]');
$options = $this->xpath('//select[@name="editor[editor]"]/option');
$this->assertTrue(count($select) === 1, 'The Text Editor select exists.');
$this->assertTrue(count($select_is_disabled) === 0, 'The Text Editor select is not disabled.');
$this->assertTrue(count($options) === 2, 'The Text Editor select has two options.');
$this->assertTrue(($options[0]->getText()) === 'None', 'Option 1 in the Text Editor select is "None".');
$this->assertTrue(($options[1]->getText()) === 'Unicorn Editor', '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->assertNoRaw('This option is disabled because no modules that provide a text editor are currently enabled.', 'Description for select absent that tells users to install a text editor module.');
// Select the "Unicorn Editor" editor and click the "Configure" button.
$edit = [
'editor[editor]' => 'unicorn',
];
$this->drupalPostForm(NULL, $edit, 'Configure');
$unicorn_setting = $this->xpath('//input[@name="editor[settings][ponies_too]" and @type="checkbox" and @checked]');
$this->assertTrue(count($unicorn_setting), "Unicorn Editor's settings form is present.");
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->assertIdentical($editor->getEditor(), 'unicorn', 'The text editor is configured correctly.');
$this->assertIdentical($settings['ponies_too'], $ponies_too, 'The text editor settings are stored correctly.');
$this->drupalGet('admin/config/content/formats/manage/' . $format_id);
$select = $this->xpath('//select[@name="editor[editor]"]');
$select_is_disabled = $this->xpath('//select[@name="editor[editor]" and @disabled="disabled"]');
$options = $this->xpath('//select[@name="editor[editor]"]/option');
$this->assertTrue(count($select) === 1, 'The Text Editor select exists.');
$this->assertTrue(count($select_is_disabled) === 0, 'The Text Editor select is not disabled.');
$this->assertTrue(count($options) === 2, 'The Text Editor select has two options.');
$this->assertTrue($options[1]->hasAttribute('selected'), 'Option 2 ("Unicorn Editor") is selected.');
}
}

View file

@ -0,0 +1,83 @@
<?php
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
*/
public static $modules = ['editor', 'filter', 'ckeditor'];
/**
* Test access to the editor image dialog.
*/
public function testEditorImageDialogAccess() {
$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' => 'ckeditor',
'format' => 'plain_text',
'settings' => [
'toolbar' => [
'rows' => [
[
[
'name' => 'Media',
'items' => [
'DrupalImage',
],
],
],
],
],
'plugins' => [],
],
'image_upload' => [
'status' => FALSE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => '',
'max_dimensions' => [
'width' => 0,
'height' => 0,
],
],
]);
$editor->save();
$this->resetAll();
$this->drupalGet($url);
$this->assertTrue($this->cssSelect('input[type=text][name="attributes[src]"]'), 'Image uploads disabled: input[type=text][name="attributes[src]"] is present.');
$this->assertFalse($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] + $editor->getImageUploadSettings())
->save();
$this->resetAll();
$this->drupalGet($url);
$this->assertFalse($this->cssSelect('input[type=text][name="attributes[src]"]'), 'Image uploads enabled: input[type=text][name="attributes[src]"] is absent.');
$this->assertTrue($this->cssSelect('input[type=file]'), 'Image uploads enabled: input[type=file] is present.');
$session->statusCodeEquals(200);
}
}

View file

@ -0,0 +1,293 @@
<?php
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
*/
public static $modules = ['filter', 'editor', 'editor_test', 'node'];
/**
* 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;
protected function setUp() {
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();
entity_get_form_display('node', 'page', 'default')
->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() {
// Only associate a text editor with the "Full HTML" text format.
$editor = Editor::create([
'format' => 'full_html',
'editor' => 'unicorn',
'image_upload' => [
'status' => FALSE,
'scheme' => file_default_scheme(),
'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');
list(, $editor_settings_present, $editor_js_present, $body, $format_selector) = $this->getThingsToCheck('body');
$this->assertFalse($editor_settings_present, 'No Text Editor module settings.');
$this->assertFalse($editor_js_present, 'No Text Editor JavaScript.');
$this->assertTrue(count($body) === 1, 'A body field exists.');
$this->assertTrue(count($format_selector) === 0, 'No text format selector exists on the page because the user only has access to a single format.');
$this->drupalLogout($this->normalUser);
// 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');
list($settings, $editor_settings_present, $editor_js_present, $body, $format_selector) = $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->assertIdentical($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->assertTrue(count($body) === 1, 'A body field exists.');
$this->assertTrue(count($format_selector) === 1, 'A single text format selector exists on the page.');
$specific_format_selector = $this->xpath('//select[contains(@class, "filter-list") and @data-editor-for="edit-body-0-value"]');
$this->assertTrue(count($specific_format_selector) === 1, 'A single text format selector exists on the page and has a "data-editor-for" attribute with the correct value.');
// Load the editor image dialog form and make sure it does not fatal.
$this->drupalGet('editor/dialog/image/full_html');
$this->assertResponse(200);
$this->drupalLogout($this->privilegedUser);
// 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');
list($settings, $editor_settings_present, $editor_js_present, $body, $format_selector) = $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->assertIdentical($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->assertTrue(count($body) === 1, 'A body field exists.');
$this->assertTrue(count($format_selector) === 0, 'No text format selector exists on the page.');
$hidden_input = $this->xpath('//input[@type="hidden" and @value="plain_text" and @data-editor-for="edit-body-0-value"]');
$this->assertTrue(count($hidden_input) === 1, 'A single text format hidden input exists on the page and has a "data-editor-for" attribute with the correct value.');
// 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 (s)he is not allowed to use. The editor is still loaded. CKEditor,
// for example, supports being loaded in a disabled state.
$this->drupalGet('node/1/edit');
list(, $editor_settings_present, $editor_js_present, $body, $format_selector) = $this->getThingsToCheck('body');
$this->assertTrue($editor_settings_present, 'Text Editor module settings.');
$this->assertTrue($editor_js_present, 'Text Editor JavaScript.');
$this->assertTrue(count($body) === 1, 'A body field exists.');
$this->assertFieldByXPath('//textarea[@id="edit-body-0-value" and @disabled="disabled"]', t('This field has been disabled because you do not have sufficient permissions to edit it.'), 'Text format access denied message found.');
$this->assertTrue(count($format_selector) === 0, 'No text format selector exists on the page.');
$hidden_input = $this->xpath('//input[@type="hidden" and contains(@class, "editor")]');
$this->assertTrue(count($hidden_input) === 0, 'A single text format hidden input does not exist on the page.');
}
/**
* Test supported element types.
*/
public function testSupportedElementTypes() {
// Associate the unicorn text editor with the "Full HTML" text format.
$editor = Editor::create([
'format' => 'full_html',
'editor' => 'unicorn',
'image_upload' => [
'status' => FALSE,
'scheme' => file_default_scheme(),
'directory' => 'inline-images',
'max_size' => '',
'max_dimensions' => ['width' => '', 'height' => ''],
],
]);
$editor->save();
// Create an "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');
list(, $editor_settings_present, $editor_js_present, $field, $format_selector) = $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->assertTrue(count($field) === 1, 'A text field exists.');
$this->assertTrue(count($format_selector) === 1, 'A single text format selector exists on the page.');
$specific_format_selector = $this->xpath('//select[contains(@class, "filter-list") and contains(@class, "editor") and @data-editor-for="edit-field-text-0-value"]');
$this->assertTrue(count($specific_format_selector) === 1, 'A single text format selector exists on the page and has the "editor" class and a "data-editor-for" attribute with the correct value.');
// 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');
list(, $editor_settings_present, $editor_js_present, $field, $format_selector) = $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->assertTrue(count($field) === 1, 'A text field exists.');
$this->assertTrue(count($format_selector) === 1, 'A single text format selector exists on the page.');
$specific_format_selector = $this->xpath('//select[contains(@class, "filter-list") and contains(@class, "editor") and @data-editor-for="edit-field-text-0-value"]');
$this->assertFalse(count($specific_format_selector) === 1, 'A single text format selector exists on the page and has the "editor" class and a "data-editor-for" attribute with the correct value.');
}
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.
strpos($this->getSession()->getPage()->getContent(), drupal_get_path('module', 'editor') . '/js/editor.js') !== FALSE,
// Body field.
$this->xpath('//' . $type . '[@id="edit-' . $field_name . '-0-value"]'),
// Format selector.
$this->xpath('//select[contains(@class, "filter-list")]'),
];
}
}

View file

@ -0,0 +1,134 @@
<?php
namespace Drupal\Tests\editor\Functional;
use Drupal\file\Entity\File;
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
*/
public static $modules = [
// Needed for the config: this is the only module in core that utilizes the
// functionality in editor.module to be tested, and depends on that.
'ckeditor',
// 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 CKEditor,
// - with drupalimage.image_upload.scheme set to 'private',
// - with drupalimage.image_upload.directory set to ''.
'editor_private_test',
];
/**
* Tests the editor file reference filter with private files.
*/
public function testEditorPrivateFileReferenceFilter() {
$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' => NODE_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,453 @@
<?php
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" />';
/**
* 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
*/
public 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 text format, dangerous tags allowed
* with text editor.
*
* @var \Drupal\user\UserInterface
*/
protected $trustedUser;
/**
* User with access to all text formats and text editors.
*
* @var \Drupal\user\UserInterface
*/
protected $privilegedUser;
protected function setUp() {
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() {
$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->pass(format_string('Scenario: sample %sample_id, %format.', [
'%sample_id' => $case['node_id'],
'%format' => $case['format'],
]));
$this->drupalLogin($account);
$this->drupalGet('node/' . $case['node_id'] . '/edit');
$dom_node = $this->xpath('//textarea[@id="edit-body-0-value"]');
$this->assertIdentical($case['value'], $dom_node[0]->getText(), 'The value was correctly filtered for XSS attack vectors.');
}
}
}
/**
* 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() {
$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.
$dom_node = $this->xpath('//textarea[@id="edit-body-0-value"]');
$this->assertIdentical(self::$sampleContent, $dom_node[0]->getAttribute('data-editor-value-original'), 'The data-editor-value-original attribute is correctly set.');
$this->assertIdentical('false', (string) $dom_node[0]->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) {
$this->pass(format_string('Scenario: sample %sample_id, switch from %original_format to %format.', [
'%sample_id' => $case['node_id'],
'%original_format' => $case['format'],
'%format' => $format,
]));
$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->assertIdentical($json, $expected_filtered_value, 'The value was correctly filtered for XSS attack vectors.');
}
}
}
/**
* Tests the standard text editor XSS filter being overridden.
*/
public function testEditorXssFilterOverride() {
// First: the Standard text editor XSS filter.
$this->drupalLogin($this->normalUser);
$this->drupalGet('node/2/edit');
$dom_node = $this->xpath('//textarea[@id="edit-body-0-value"]');
$this->assertIdentical(self::$sampleContentSecured, $dom_node[0]->getText(), 'The value was filtered by the Standard text editor XSS filter.');
// 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');
$dom_node = $this->xpath('//textarea[@id="edit-body-0-value"]');
$this->assertIdentical(self::$sampleContent, $dom_node[0]->getText(), 'The value was filtered by the Insecure text editor XSS filter.');
}
}

View file

@ -0,0 +1,227 @@
<?php
namespace Drupal\Tests\editor\Functional;
use Drupal\Component\Render\FormattableMarkup;
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
*/
public static $modules = ['editor', 'editor_test'];
/**
* A user with permission as administer for testing.
*
* @var \Drupal\user\Entity\User
*/
protected $adminUser;
/**
* {@inheritdoc}
*/
protected function setUp() {
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() {
// 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];
list($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);
list($uploaded_image_file_width, $uploaded_image_file_height) = $this->uploadImage($test_image->uri);
$this->assertEqual($uploaded_image_file_width, $image_file_width);
$this->assertEqual($uploaded_image_file_height, $image_file_height);
$this->assertNoRaw((string) new FormattableMarkup('The image was resized to fit within the maximum allowed dimensions of %dimensions pixels.', ['%dimensions' => $max_width . 'x' . $max_height]));
// Case 2: max width smaller than uploaded image: image scaled down.
$test_image = $testing_image_list[1];
list($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);
list($uploaded_image_file_width, $uploaded_image_file_height) = $this->uploadImage($test_image->uri);
$this->assertEqual($uploaded_image_file_width, $max_width);
$this->assertEqual($uploaded_image_file_height, $uploaded_image_file_height * ($uploaded_image_file_width / $max_width));
$this->assertRaw((string) new FormattableMarkup('The image was resized to fit within the maximum allowed dimensions of %dimensions pixels.', ['%dimensions' => $max_width . 'x' . $max_height]));
// Case 3: max height smaller than uploaded image: image scaled down.
$test_image = $testing_image_list[2];
list($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);
list($uploaded_image_file_width, $uploaded_image_file_height) = $this->uploadImage($test_image->uri);
$this->assertEqual($uploaded_image_file_width, $uploaded_image_file_width * ($uploaded_image_file_height / $max_height));
$this->assertEqual($uploaded_image_file_height, $max_height);
$this->assertRaw((string) new FormattableMarkup('The image was resized to fit within the maximum allowed dimensions of %dimensions pixels.', ['%dimensions' => $max_width . 'x' . $max_height]));
// Case 4: max dimensions greater than uploaded image: image not scaled.
$test_image = $testing_image_list[3];
list($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);
list($uploaded_image_file_width, $uploaded_image_file_height) = $this->uploadImage($test_image->uri);
$this->assertEqual($uploaded_image_file_width, $image_file_width);
$this->assertEqual($uploaded_image_file_height, $image_file_height);
$this->assertNoRaw((string) new FormattableMarkup('The image was resized to fit within the maximum allowed dimensions of %dimensions pixels.', ['%dimensions' => $max_width . 'x' . $max_height]));
// Case 5: only max width dimension was provided and it was smaller than
// uploaded image: image scaled down.
$test_image = $testing_image_list[4];
list($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);
list($uploaded_image_file_width, $uploaded_image_file_height) = $this->uploadImage($test_image->uri);
$this->assertEqual($uploaded_image_file_width, $max_width);
$this->assertEqual($uploaded_image_file_height, $uploaded_image_file_height * ($uploaded_image_file_width / $max_width));
$this->assertRaw((string) new FormattableMarkup('The image was resized to fit within the maximum allowed width of %width pixels.', ['%width' => $max_width]));
// Case 6: only max height dimension was provided and it was smaller than
// uploaded image: image scaled down.
$test_image = $testing_image_list[5];
list($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);
list($uploaded_image_file_width, $uploaded_image_file_height) = $this->uploadImage($test_image->uri);
$this->assertEqual($uploaded_image_file_width, $uploaded_image_file_width * ($uploaded_image_file_height / $max_height));
$this->assertEqual($uploaded_image_file_height, $max_height);
$this->assertRaw((string) new FormattableMarkup('The image was resized to fit within the maximum allowed height of %height pixels.', ['%height' => $max_height]));
}
/**
* 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->drupalPostForm('editor/dialog/image/basic_html', $edit, t('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 string $width
* The expected width of the uploaded image.
* @param string $height
* The expected height of the uploaded image.
*
* @return bool
*/
protected function assertSavedMaxDimensions($width, $height) {
$image_upload_settings = Editor::load('basic_html')->getImageUploadSettings();
$expected = [
'width' => $image_upload_settings['max_dimensions']['width'],
'height' => $image_upload_settings['max_dimensions']['height'],
];
$same_width = $this->assertEqual($width, $expected['width'], 'Actual width of "' . $width . '" equals the expected width of "' . $expected['width'] . '"');
$same_height = $this->assertEqual($height, $expected['height'], 'Actual height of "' . $height . '" equals the expected width of "' . $expected['height'] . '"');
return $same_width && $same_height;
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace Drupal\Tests\editor\Functional\Hal;
use Drupal\Tests\editor\Functional\Rest\EditorResourceTestBase;
use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
/**
* @group hal
*/
class EditorHalJsonAnonTest extends EditorResourceTestBase {
use AnonResourceTestTrait;
/**
* {@inheritdoc}
*/
public static $modules = ['hal'];
/**
* {@inheritdoc}
*/
protected static $format = 'hal_json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/hal+json';
}

View file

@ -0,0 +1,35 @@
<?php
namespace Drupal\Tests\editor\Functional\Hal;
use Drupal\Tests\editor\Functional\Rest\EditorResourceTestBase;
use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
/**
* @group hal
*/
class EditorHalJsonBasicAuthTest extends EditorResourceTestBase {
use BasicAuthResourceTestTrait;
/**
* {@inheritdoc}
*/
public static $modules = ['hal', 'basic_auth'];
/**
* {@inheritdoc}
*/
protected static $format = 'hal_json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/hal+json';
/**
* {@inheritdoc}
*/
protected static $auth = 'basic_auth';
}

View file

@ -0,0 +1,35 @@
<?php
namespace Drupal\Tests\editor\Functional\Hal;
use Drupal\Tests\editor\Functional\Rest\EditorResourceTestBase;
use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
/**
* @group hal
*/
class EditorHalJsonCookieTest extends EditorResourceTestBase {
use CookieResourceTestTrait;
/**
* {@inheritdoc}
*/
public static $modules = ['hal'];
/**
* {@inheritdoc}
*/
protected static $format = 'hal_json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/hal+json';
/**
* {@inheritdoc}
*/
protected static $auth = 'cookie';
}

View file

@ -0,0 +1,142 @@
<?php
namespace Drupal\Tests\editor\Functional;
use Drupal\Component\Serialization\Json;
use Drupal\Core\EventSubscriber\MainContentViewSubscriber;
use Drupal\filter\Entity\FilterFormat;
use Drupal\Tests\BrowserTestBase;
/**
* Tests Quick Edit module integration endpoints.
*
* @group editor
*/
class QuickEditIntegrationLoadingTest extends BrowserTestBase {
/**
* Modules to enable.
*
* @var array
*/
public static $modules = ['quickedit', 'filter', 'node', 'editor'];
/**
* The basic permissions necessary to view content and use in-place editing.
*
* @var array
*/
protected static $basicPermissions = ['access content', 'create article content', 'use text format filtered_html', 'access contextual links'];
protected function setUp() {
parent::setUp();
// Create a text format.
$filtered_html_format = FilterFormat::create([
'format' => 'filtered_html',
'name' => 'Filtered HTML',
'weight' => 0,
'filters' => [
'filter_caption' => [
'status' => 1,
],
],
]);
$filtered_html_format->save();
// Create a node type.
$this->drupalCreateContentType([
'type' => 'article',
'name' => 'Article',
]);
// Create one node of the above node type using the above text format.
$this->drupalCreateNode([
'type' => 'article',
'body' => [
0 => [
'value' => '<p>Do you also love Drupal?</p><img src="druplicon.png" data-caption="Druplicon" />',
'format' => 'filtered_html',
],
],
]);
}
/**
* Test loading of untransformed text when a user doesn't have access to it.
*/
public function testUsersWithoutPermission() {
// Create 3 users, each with insufficient permissions, i.e. without either
// or both of the following permissions:
// - the 'access in-place editing' permission
// - the 'edit any article content' permission (necessary to edit node 1)
$users = [
$this->drupalCreateUser(static::$basicPermissions),
$this->drupalCreateUser(array_merge(static::$basicPermissions, ['edit any article content'])),
$this->drupalCreateUser(array_merge(static::$basicPermissions, ['access in-place editing'])),
];
// Now test with each of the 3 users with insufficient permissions.
foreach ($users as $user) {
$this->drupalLogin($user);
$this->drupalGet('node/1');
// Ensure the text is transformed.
$this->assertRaw('<p>Do you also love Drupal?</p><figure role="group" class="caption caption-img"><img src="druplicon.png" /><figcaption>Druplicon</figcaption></figure>');
$client = $this->getHttpClient();
// Retrieving the untransformed text should result in an 403 response and
// return a different error message depending of the missing permission.
$response = $client->post($this->buildUrl('editor/node/1/body/en/full'), [
'query' => http_build_query([MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_ajax']),
'cookies' => $this->getSessionCookies(),
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/x-www-form-urlencoded',
],
'http_errors' => FALSE,
]);
$this->assertEquals(403, $response->getStatusCode());
if (!$user->hasPermission('access in-place editing')) {
$message = "The 'access in-place editing' permission is required.";
}
else {
$message = '';
}
$body = Json::decode($response->getBody());
$this->assertIdentical($message, $body['message']);
}
}
/**
* Test loading of untransformed text when a user does have access to it.
*/
public function testUserWithPermission() {
$user = $this->drupalCreateUser(array_merge(static::$basicPermissions, ['edit any article content', 'access in-place editing']));
$this->drupalLogin($user);
$this->drupalGet('node/1');
// Ensure the text is transformed.
$this->assertRaw('<p>Do you also love Drupal?</p><figure role="group" class="caption caption-img"><img src="druplicon.png" /><figcaption>Druplicon</figcaption></figure>');
$client = $this->getHttpClient();
$response = $client->post($this->buildUrl('editor/node/1/body/en/full'), [
'query' => http_build_query([MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_ajax']),
'cookies' => $this->getSessionCookies(),
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/x-www-form-urlencoded',
],
'http_errors' => FALSE,
]);
$this->assertEquals(200, $response->getStatusCode());
$ajax_commands = Json::decode($response->getBody());
$this->assertIdentical(1, count($ajax_commands), 'The untransformed text POST request results in one AJAX command.');
$this->assertIdentical('editorGetUntransformedText', $ajax_commands[0]['command'], 'The first AJAX command is an editorGetUntransformedText command.');
$this->assertIdentical('<p>Do you also love Drupal?</p><img src="druplicon.png" data-caption="Druplicon" />', $ajax_commands[0]['data'], 'The editorGetUntransformedText command contains the expected data.');
}
}

View file

@ -0,0 +1,24 @@
<?php
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';
}

View file

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

View file

@ -0,0 +1,29 @@
<?php
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';
}

View file

@ -0,0 +1,184 @@
<?php
namespace Drupal\Tests\editor\Functional\Rest;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase;
/**
* ResourceTestBase for Editor entity.
*/
abstract class EditorResourceTestBase extends EntityResourceTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['ckeditor', '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' => 'ckeditor',
]);
$camelids
->setImageUploadSettings([
'status' => FALSE,
'scheme' => file_default_scheme(),
'directory' => 'inline-images',
'max_size' => '',
'max_dimensions' => [
'width' => '',
'height' => '',
],
])
->save();
return $camelids;
}
/**
* {@inheritdoc}
*/
protected function getExpectedNormalizedEntity() {
return [
'dependencies' => [
'config' => [
'filter.format.llama',
],
'module' => [
'ckeditor',
],
],
'editor' => 'ckeditor',
'format' => 'llama',
'image_upload' => [
'status' => FALSE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => '',
'max_dimensions' => [
'width' => NULL,
'height' => NULL,
],
],
'langcode' => 'en',
'settings' => [
'toolbar' => [
'rows' => [
[
[
'name' => 'Formatting',
'items' => [
'Bold',
'Italic',
],
],
[
'name' => 'Links',
'items' => [
'DrupalLink',
'DrupalUnlink',
],
],
[
'name' => 'Lists',
'items' => [
'BulletedList',
'NumberedList',
],
],
[
'name' => 'Media',
'items' => [
'Blockquote',
'DrupalImage',
],
],
[
'name' => 'Tools',
'items' => [
'Source',
],
],
],
],
],
'plugins' => [
'language' => [
'language_list' => 'un',
],
],
],
'status' => TRUE,
'uuid' => $this->entity->uuid(),
];
}
/**
* {@inheritdoc}
*/
protected function getNormalizedPostEntity() {
// @todo Update in https://www.drupal.org/node/2300677.
}
/**
* {@inheritdoc}
*/
protected function getExpectedCacheContexts() {
// @see ::createEntity()
return ['user.permissions'];
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
if ($this->config('rest.settings')->get('bc_entity_resource_permissions')) {
return parent::getExpectedUnauthorizedAccessMessage($method);
}
return "The 'administer filters' permission is required.";
}
}

View file

@ -0,0 +1,26 @@
<?php
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';
}

View file

@ -0,0 +1,36 @@
<?php
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}
*/
public static $modules = ['basic_auth'];
/**
* {@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,31 @@
<?php
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';
}

View file

@ -0,0 +1,70 @@
<?php
namespace Drupal\Tests\editor\Functional\Update;
use Drupal\FunctionalTests\Update\UpdatePathTestBase;
/**
* Tests Editor module database updates.
*
* @group editor
* @group legacy
*/
class EditorUpdateTest extends UpdatePathTestBase {
/**
* {@inheritdoc}
*/
public function setDatabaseDumpFiles() {
$this->databaseDumpFiles = [
__DIR__ . '/../../../../../system/tests/fixtures/update/drupal-8.bare.standard.php.gz',
// Simulate an un-synchronized environment.
__DIR__ . '/../../../../../system/tests/fixtures/update/drupal-8.editor-editor_update_8001.php',
];
}
/**
* Tests editor_update_8001().
*
* @see editor_update_8001()
*/
public function testEditorUpdate8001() {
/** @var \Drupal\Core\Config\ConfigFactoryInterface $config_factory */
$config_factory = $this->container->get('config.factory');
$format_basic_html = $config_factory->get('filter.format.basic_html');
$editor_basic_html = $config_factory->get('editor.editor.basic_html');
$format_full_html = $config_factory->get('filter.format.full_html');
$editor_full_html = $config_factory->get('editor.editor.full_html');
// Checks if the 'basic_html' format and editor statuses differ.
$this->assertTrue($format_basic_html->get('status'));
$this->assertFalse($editor_basic_html->get('status'));
$this->assertNotIdentical($format_basic_html->get('status'), $editor_basic_html->get('status'));
// Checks if the 'full_html' format and editor statuses differ.
$this->assertFalse($format_full_html->get('status'));
$this->assertTrue($editor_full_html->get('status'));
$this->assertNotIdentical($format_full_html->get('status'), $editor_full_html->get('status'));
// Run updates.
$this->runUpdates();
// Reload text formats and editors.
$format_basic_html = $config_factory->get('filter.format.basic_html');
$editor_basic_html = $config_factory->get('editor.editor.basic_html');
$format_full_html = $config_factory->get('filter.format.full_html');
$editor_full_html = $config_factory->get('editor.editor.full_html');
// Checks if the 'basic_html' format and editor statuses are in sync.
$this->assertTrue($format_basic_html->get('status'));
$this->assertTrue($editor_basic_html->get('status'));
$this->assertIdentical($format_basic_html->get('status'), $editor_basic_html->get('status'));
// Checks if the 'full_html' format and editor statuses are in sync.
$this->assertFalse($format_full_html->get('status'));
$this->assertFalse($editor_full_html->get('status'));
$this->assertIdentical($format_full_html->get('status'), $editor_full_html->get('status'));
}
}

View file

@ -0,0 +1,123 @@
<?php
namespace Drupal\Tests\editor\Kernel;
use Drupal\Core\Cache\Cache;
use Drupal\file\Entity\File;
use Drupal\filter\FilterPluginCollection;
use Drupal\KernelTests\KernelTestBase;
/**
* Tests Editor module's file reference filter.
*
* @group editor
*/
class EditorFileReferenceFilterTest extends KernelTestBase {
/**
* Modules to enable.
*
* @var array
*/
public static $modules = ['system', 'filter', 'editor', 'field', 'file', 'user'];
/**
* @var \Drupal\filter\Plugin\FilterInterface[]
*/
protected $filters;
/**
* {@inheritdoc}
*/
protected function setUp() {
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() {
$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];
$this->pass('No data-entity-type and no data-entity-uuid attribute.');
$input = '<img src="llama.jpg" />';
$output = $test($input);
$this->assertIdentical($input, $output->getProcessedText());
$this->pass('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->assertIdentical($input, $output->getProcessedText());
$this->pass('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->assertIdentical($expected_output, $output->getProcessedText());
$this->assertEqual($cache_tag, $output->getCacheTags());
$this->pass('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->assertIdentical($expected_output, $output->getProcessedText());
$this->assertEqual($cache_tag, $output->getCacheTags());
$this->pass('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->assertIdentical($expected_output, $output->getProcessedText());
$this->assertEqual($cache_tag, $output->getCacheTags());
$this->pass('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->assertIdentical($input, $output->getProcessedText());
$this->assertEqual([], $output->getCacheTags());
$this->pass('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->assertIdentical($expected_output, $output->getProcessedText());
$this->assertEqual(Cache::mergeTags($cache_tag, $cache_tag_2), $output->getCacheTags());
$this->pass('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->assertIdentical($expected_output, $output->getProcessedText());
$this->assertEqual($cache_tag, $output->getCacheTags());
}
}

View file

@ -0,0 +1,209 @@
<?php
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\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
*/
public static $modules = ['editor', 'editor_test', 'node', 'file'];
protected function setUp() {
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);
}
/**
* Tests the configurable text editor manager.
*/
public function testEditorEntityHooks() {
$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_basename($image->getFileUri()));
$image->save();
$file_usage = $this->container->get('file.usage');
$this->assertIdentical([], $file_usage->listUsage($image), 'The image ' . $image_paths[$key] . ' has zero usages.');
$image_entities[] = $image;
}
$body = [];
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',
];
}
// Test editor_entity_insert(): increment.
$this->createUser();
$node = $node = Node::create([
'type' => 'page',
'title' => 'test',
'body' => $body,
'uid' => 1,
]);
$node->save();
foreach ($image_entities as $key => $image_entity) {
$this->assertIdentical(['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->assertIdentical(['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->assertIdentical(['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->assertIdentical(['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->assertIdentical(['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->assertIdentical(['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->assertIdentical(['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->assertIdentical(['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->assertIdentical(['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->assertIdentical([], $file_usage->listUsage($image_entity), 'The image ' . $image_paths[$key] . ' has zero usages again.');
}
}
}

View file

@ -0,0 +1,55 @@
<?php
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}
*/
public static $modules = ['filter', 'editor', 'editor_test'];
/**
* Tests text format removal or disabling.
*/
public function testTextFormatIntegration() {
// Create an arbitrary text format.
$format = FilterFormat::create([
'format' => mb_strtolower($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,122 @@
<?php
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
*/
class EditorImageDialogTest extends EntityKernelTestBase {
/**
* Text editor config entity for testing.
*
* @var \Drupal\editor\EditorInterface
*/
protected $editor;
/**
* Modules to enable.
*
* @var array
*/
public static $modules = ['node', 'file', 'editor', 'editor_test', 'user', 'system'];
/**
* Sets up the test.
*/
protected function setUp() {
parent::setUp();
$this->installEntitySchema('file');
$this->installSchema('system', ['key_value_expire']);
$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' => '',
'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');
\Drupal::service('router.builder')->rebuild();
}
/**
* Tests that editor image dialog works as expected.
*/
public function testEditorImageDialog() {
$input = [
'editor_object' => [
'src' => '/sites/default/files/inline-images/somefile.png',
'alt' => 'fda',
'width' => '',
'height' => '',
'data-entity-type' => 'file',
'data-entity-uuid' => 'some-uuid',
'data-align' => 'none',
'hasCaption' => 'false',
],
'dialogOptions' => [
'title' => 'Edit Image',
'dialogClass' => 'editor-image-dialog',
'autoResize' => 'true',
],
'_drupal_ajax' => '1',
'ajax_page_state' => [
'theme' => 'bartik',
'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::entityManager()->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->assertEqual(FALSE, $form_state->getValue(['attributes', 'hasCaption'], 'not-this'));
}
}

View file

@ -0,0 +1,111 @@
<?php
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
*/
public static $modules = ['system', 'user', 'filter', 'editor'];
/**
* The manager for text editor plugins.
*
* @var \Drupal\Component\Plugin\PluginManagerInterface
*/
protected $editorManager;
protected function setUp() {
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() {
$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->assertIdentical([], $this->editorManager->listOptions(), 'When no text editor is enabled, the manager works correctly.');
$this->assertIdentical([], $this->editorManager->getAttachments([]), 'No attachments when no text editor is enabled and retrieving attachments for zero text formats.');
$this->assertIdentical([], $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->assertIdentical('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->assertIdentical([], $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->assertIdentical($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->assertIdentical($expected, $this->editorManager->getAttachments(['filtered_html', 'full_html']), 'hook_editor_js_settings_alter() works correctly.');
}
}

View file

@ -0,0 +1,240 @@
<?php
namespace Drupal\Tests\editor\Kernel;
use Drupal\Component\Serialization\Json;
use Drupal\Core\EventSubscriber\AjaxResponseSubscriber;
use Drupal\Core\Language\LanguageInterface;
use Drupal\editor\Entity\Editor;
use Drupal\entity_test\Entity\EntityTest;
use Drupal\quickedit\MetadataGenerator;
use Drupal\Tests\quickedit\Kernel\QuickEditTestBase;
use Drupal\quickedit_test\MockQuickEditEntityFieldAccessCheck;
use Drupal\editor\EditorController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Drupal\filter\Entity\FilterFormat;
/**
* Tests Edit module integration (Editor module's inline editing support).
*
* @group editor
*/
class QuickEditIntegrationTest extends QuickEditTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['editor', 'editor_test'];
/**
* The manager for editor plug-ins.
*
* @var \Drupal\Component\Plugin\PluginManagerInterface
*/
protected $editorManager;
/**
* The metadata generator object to be tested.
*
* @var \Drupal\quickedit\MetadataGeneratorInterface
*/
protected $metadataGenerator;
/**
* The editor selector object to be used by the metadata generator object.
*
* @var \Drupal\quickedit\EditorSelectorInterface
*/
protected $editorSelector;
/**
* The access checker object to be used by the metadata generator object.
*
* @var \Drupal\quickedit\Access\QuickEditEntityFieldAccessCheckInterface
*/
protected $accessChecker;
/**
* The name of the field ued for tests.
*
* @var string
*/
protected $fieldName;
protected function setUp() {
parent::setUp();
// Install the Filter module.
// Create a field.
$this->fieldName = 'field_textarea';
$this->createFieldWithStorage(
$this->fieldName, 'text', 1, 'Long text field',
// Instance settings.
[],
// Widget type & settings.
'text_textarea',
['size' => 42],
// 'default' formatter type & settings.
'text_default',
[]
);
// Create text format.
$full_html_format = FilterFormat::create([
'format' => 'full_html',
'name' => 'Full HTML',
'weight' => 1,
'filters' => [],
]);
$full_html_format->save();
// Associate text editor with text format.
$editor = Editor::create([
'format' => $full_html_format->id(),
'editor' => 'unicorn',
]);
$editor->save();
// Also create a text format without an associated text editor.
FilterFormat::create([
'format' => 'no_editor',
'name' => 'No Text Editor',
'weight' => 2,
'filters' => [],
])->save();
}
/**
* Returns the in-place editor that quickedit selects.
*
* @param int $entity_id
* An entity ID.
* @param string $field_name
* A field name.
* @param string $view_mode
* A view mode.
*
* @return string
* Returns the selected in-place editor.
*/
protected function getSelectedEditor($entity_id, $field_name, $view_mode = 'default') {
$storage = $this->container->get('entity_type.manager')->getStorage('entity_test');
$storage->resetCache([$entity_id]);
$entity = $storage->load($entity_id);
$items = $entity->get($field_name);
$options = entity_get_display('entity_test', 'entity_test', $view_mode)->getComponent($field_name);
return $this->editorSelector->getEditor($options['type'], $items);
}
/**
* Tests editor selection when the Editor module is present.
*
* Tests a textual field, with text filtering, with cardinality 1 and >1,
* always with a ProcessedTextEditor plug-in present, but with varying text
* format compatibility.
*/
public function testEditorSelection() {
$this->editorManager = $this->container->get('plugin.manager.quickedit.editor');
$this->editorSelector = $this->container->get('quickedit.editor.selector');
// Create an entity with values for this text field.
$entity = EntityTest::create();
$entity->{$this->fieldName}->value = 'Hello, world!';
$entity->{$this->fieldName}->format = 'filtered_html';
$entity->save();
// Editor selection w/ cardinality 1, text format w/o associated text editor.
$this->assertEqual('form', $this->getSelectedEditor($entity->id(), $this->fieldName), "With cardinality 1, and the filtered_html text format, the 'form' editor is selected.");
// Editor selection w/ cardinality 1, text format w/ associated text editor.
$entity->{$this->fieldName}->format = 'full_html';
$entity->save();
$this->assertEqual('editor', $this->getSelectedEditor($entity->id(), $this->fieldName), "With cardinality 1, and the full_html text format, the 'editor' editor is selected.");
// Editor selection with text processing, cardinality >1
$this->fields->field_textarea_field_storage->setCardinality(2);
$this->fields->field_textarea_field_storage->save();
$this->assertEqual('form', $this->getSelectedEditor($entity->id(), $this->fieldName), "With cardinality >1, and both items using the full_html text format, the 'form' editor is selected.");
}
/**
* Tests (custom) metadata when the formatted text editor is used.
*/
public function testMetadata() {
$this->editorManager = $this->container->get('plugin.manager.quickedit.editor');
$this->accessChecker = new MockQuickEditEntityFieldAccessCheck();
$this->editorSelector = $this->container->get('quickedit.editor.selector');
$this->metadataGenerator = new MetadataGenerator($this->accessChecker, $this->editorSelector, $this->editorManager);
// Create an entity with values for the field.
$entity = EntityTest::create();
$entity->{$this->fieldName}->value = 'Test';
$entity->{$this->fieldName}->format = 'full_html';
$entity->save();
$entity = EntityTest::load($entity->id());
// Verify metadata.
$items = $entity->get($this->fieldName);
$metadata = $this->metadataGenerator->generateFieldMetadata($items, 'default');
$expected = [
'access' => TRUE,
'label' => 'Long text field',
'editor' => 'editor',
'custom' => [
'format' => 'full_html',
'formatHasTransformations' => FALSE,
],
];
$this->assertEqual($expected, $metadata, 'The correct metadata (including custom metadata) is generated.');
}
/**
* Tests in-place editor attachments when the Editor module is present.
*/
public function testAttachments() {
$this->editorSelector = $this->container->get('quickedit.editor.selector');
$editors = ['editor'];
$attachments = $this->editorSelector->getEditorAttachments($editors);
$this->assertIdentical($attachments, ['library' => ['editor/quickedit.inPlaceEditor.formattedText']], "Expected attachments for Editor module's in-place editor found.");
}
/**
* Tests GetUntransformedTextCommand AJAX command.
*/
public function testGetUntransformedTextCommand() {
// Create an entity with values for the field.
$entity = EntityTest::create();
$entity->{$this->fieldName}->value = 'Test';
$entity->{$this->fieldName}->format = 'full_html';
$entity->save();
$entity = EntityTest::load($entity->id());
// Verify AJAX response.
$controller = new EditorController();
$request = new Request();
$response = $controller->getUntransformedText($entity, $this->fieldName, LanguageInterface::LANGCODE_DEFAULT, 'default');
$expected = [
[
'command' => 'editorGetUntransformedText',
'data' => 'Test',
],
];
$ajax_response_attachments_processor = \Drupal::service('ajax_response.attachments_processor');
$subscriber = new AjaxResponseSubscriber($ajax_response_attachments_processor);
$event = new FilterResponseEvent(
\Drupal::service('http_kernel'),
$request,
HttpKernelInterface::MASTER_REQUEST,
$response
);
$subscriber->onResponse($event);
$this->assertEqual(Json::encode($expected), $response->getContent(), 'The GetUntransformedTextCommand AJAX command works correctly.');
}
}

View file

@ -0,0 +1,75 @@
<?php
namespace Drupal\Tests\editor\Unit;
use Drupal\Core\Form\FormState;
use Drupal\Core\Form\FormStateInterface;
use Drupal\editor\Entity\Editor;
use Drupal\editor\Plugin\EditorBase;
use Drupal\Tests\UnitTestCase;
/**
* @coversDefaultClass \Drupal\editor\Plugin\EditorBase
* @group editor
*/
class EditorBaseTest extends UnitTestCase {
/**
* @covers ::buildConfigurationForm
* @covers ::validateConfigurationForm
* @covers ::submitConfigurationForm
*/
public function testBc() {
$form_state = new FormState();
$form_state->set('editor', $this->prophesize(Editor::class)->reveal());
$editor_plugin = new BcEditor([], 'editor_plugin', []);
// settingsForm() is deprecated in favor of buildConfigurationForm().
$this->assertSame(
$editor_plugin->settingsForm([], clone $form_state, $this->prophesize(Editor::class)->reveal()),
$editor_plugin->buildConfigurationForm([], clone $form_state)
);
// settingsFormValidate() is deprecated in favor of
// validateConfigurationForm().
$form = [];
$form_state_a = clone $form_state;
$form_state_b = clone $form_state;
$editor_plugin->settingsFormValidate($form, $form_state_a, $this->prophesize(Editor::class)->reveal());
$editor_plugin->validateConfigurationForm($form, $form_state_b);
$this->assertEquals($form_state_a, $form_state_b);
// settingsFormSubmit() is deprecated in favor of submitConfigurationForm().
$form = [];
$form_state_a = clone $form_state;
$form_state_b = clone $form_state;
$editor_plugin->settingsFormSubmit($form, $form_state_a, $this->prophesize(Editor::class)->reveal());
$editor_plugin->submitConfigurationForm($form, $form_state_b);
$this->assertEquals($form_state_a, $form_state_b);
}
}
class BcEditor extends EditorBase {
public function settingsForm(array $form, FormStateInterface $form_state, Editor $editor) {
return ['foo' => 'bar'];
}
public function settingsFormValidate(array $form, FormStateInterface $form_state) {
$form_state->setValue('foo', 'bar');
}
public function settingsFormSubmit(array $form, FormStateInterface $form_state) {
$form_state->setValue('bar', 'baz');
}
public function getJSSettings(Editor $editor) {
return [];
}
public function getLibraries(Editor $editor) {
return [];
}
}

View file

@ -0,0 +1,141 @@
<?php
namespace Drupal\Tests\editor\Unit;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\Entity\EntityManager;
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 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() {
$this->editorId = $this->randomMachineName();
$this->entityTypeId = $this->randomMachineName();
$this->entityType = $this->getMock('\Drupal\Core\Entity\EntityTypeInterface');
$this->entityType->expects($this->any())
->method('getProvider')
->will($this->returnValue('editor'));
$this->entityTypeManager = $this->getMock(EntityTypeManagerInterface::class);
$this->entityTypeManager->expects($this->any())
->method('getDefinition')
->with($this->entityTypeId)
->will($this->returnValue($this->entityType));
$this->uuid = $this->getMock('\Drupal\Component\Uuid\UuidInterface');
$this->editorPluginManager = $this->getMockBuilder('Drupal\editor\Plugin\EditorManager')
->disableOriginalConstructor()
->getMock();
$entity_manager = new EntityManager();
$container = new ContainerBuilder();
$container->set('entity.manager', $entity_manager);
$container->set('entity_type.manager', $this->entityTypeManager);
$container->set('uuid', $this->uuid);
$container->set('plugin.manager.editor', $this->editorPluginManager);
// Inject the container into entity.manager so it can defer to
// entity_type.manager.
$entity_manager->setContainer($container);
\Drupal::setContainer($container);
}
/**
* @covers ::calculateDependencies
*/
public function testCalculateDependencies() {
$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')
->will($this->returnValue(['provider' => 'test_module']));
$plugin->expects($this->once())
->method('getDefaultSettings')
->will($this->returnValue([]));
$this->editorPluginManager->expects($this->any())
->method('createInstance')
->with($this->editorId)
->will($this->returnValue($plugin));
$entity = new Editor($values, $this->entityTypeId);
$filter_format = $this->getMock('Drupal\Core\Config\Entity\ConfigEntityInterface');
$filter_format->expects($this->once())
->method('getConfigDependencyName')
->will($this->returnValue('filter.format.test'));
$storage = $this->getMock('Drupal\Core\Entity\EntityStorageInterface');
$storage->expects($this->once())
->method('load')
->with($format_id)
->will($this->returnValue($filter_format));
$this->entityTypeManager->expects($this->once())
->method('getStorage')
->with('filter_format')
->will($this->returnValue($storage));
$dependencies = $entity->calculateDependencies()->getDependencies();
$this->assertContains('test_module', $dependencies['module']);
$this->assertContains('filter.format.test', $dependencies['config']);
}
}

View file

@ -0,0 +1,604 @@
<?php
namespace Drupal\Tests\editor\Unit\EditorXssFilter;
use Drupal\editor\EditorXssFilter\Standard;
use Drupal\Tests\UnitTestCase;
use Drupal\filter\Plugin\FilterInterface;
/**
* @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;
protected function setUp() {
// Mock text format configuration entity object.
$this->format = $this->getMockBuilder('\Drupal\filter\Entity\FilterFormat')
->disableOriginalConstructor()
->getMock();
$this->format->expects($this->any())
->method('getFilterTypes')
->will($this->returnValue([FilterInterface::TYPE_HTML_RESTRICTOR]));
$restrictions = [
'allowed' => [
'p' => TRUE,
'a' => TRUE,
'*' => [
'style' => FALSE,
'on*' => FALSE,
],
],
];
$this->format->expects($this->any())
->method('getHtmlRestrictions')
->will($this->returnValue($restrictions));
}
/**
* Provides test data for testFilterXss().
*
* @see \Drupal\Tests\editor\Unit\editor\EditorXssFilter\StandardTest::testFilterXss()
*/
public 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 nmouseover="alert(&#039;xxs&#039;)">'];
// 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>'];
// Spaces and meta chars before the JavaScript in images for XSS.
// @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Spaces_and_meta_chars_before_the_JavaScript_in_images_for_XSS
// @fixme This dataset currently fails under 5.4 because of
// https://www.drupal.org/node/1210798. Restore after it's fixed.
if (version_compare(PHP_VERSION, '5.4.0', '<')) {
$data[] = ['<IMG SRC=" &#14; javascript:alert(\'XSS\');">', '<IMG src="alert(&#039;XSS&#039;);">'];
}
// 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
$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) {
$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) {
$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 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',
[],
],
];
}
}