1<?php 2 3namespace Drupal\filter\Entity; 4 5use Drupal\Component\Plugin\PluginInspectionInterface; 6use Drupal\Core\Config\Entity\ConfigEntityBase; 7use Drupal\Core\Entity\EntityWithPluginCollectionInterface; 8use Drupal\Core\Entity\EntityStorageInterface; 9use Drupal\filter\FilterFormatInterface; 10use Drupal\filter\FilterPluginCollection; 11use Drupal\filter\Plugin\FilterInterface; 12 13/** 14 * Represents a text format. 15 * 16 * @ConfigEntityType( 17 * id = "filter_format", 18 * label = @Translation("Text format"), 19 * label_collection = @Translation("Text formats"), 20 * label_singular = @Translation("text format"), 21 * label_plural = @Translation("text formats"), 22 * label_count = @PluralTranslation( 23 * singular = "@count text format", 24 * plural = "@count text formats", 25 * ), 26 * handlers = { 27 * "form" = { 28 * "add" = "Drupal\filter\FilterFormatAddForm", 29 * "edit" = "Drupal\filter\FilterFormatEditForm", 30 * "disable" = "Drupal\filter\Form\FilterDisableForm" 31 * }, 32 * "list_builder" = "Drupal\filter\FilterFormatListBuilder", 33 * "access" = "Drupal\filter\FilterFormatAccessControlHandler", 34 * }, 35 * config_prefix = "format", 36 * admin_permission = "administer filters", 37 * entity_keys = { 38 * "id" = "format", 39 * "label" = "name", 40 * "weight" = "weight", 41 * "status" = "status" 42 * }, 43 * links = { 44 * "edit-form" = "/admin/config/content/formats/manage/{filter_format}", 45 * "disable" = "/admin/config/content/formats/manage/{filter_format}/disable" 46 * }, 47 * config_export = { 48 * "name", 49 * "format", 50 * "weight", 51 * "roles", 52 * "filters", 53 * } 54 * ) 55 */ 56class FilterFormat extends ConfigEntityBase implements FilterFormatInterface, EntityWithPluginCollectionInterface { 57 58 /** 59 * Unique machine name of the format. 60 * 61 * @todo Rename to $id. 62 * 63 * @var string 64 */ 65 protected $format; 66 67 /** 68 * Unique label of the text format. 69 * 70 * Since text formats impact a site's security, two formats with the same 71 * label but different filter configuration would impose a security risk. 72 * Therefore, each text format label must be unique. 73 * 74 * @todo Rename to $label. 75 * 76 * @var string 77 */ 78 protected $name; 79 80 /** 81 * Weight of this format in the text format selector. 82 * 83 * The first/lowest text format that is accessible for a user is used as 84 * default format. 85 * 86 * @var int 87 */ 88 protected $weight = 0; 89 90 /** 91 * List of user role IDs to grant access to use this format on initial creation. 92 * 93 * This property is always empty and unused for existing text formats. 94 * 95 * Default configuration objects of modules and installation profiles are 96 * allowed to specify a list of user role IDs to grant access to. 97 * 98 * This property only has an effect when a new text format is created and the 99 * list is not empty. By default, no user role is allowed to use a new format. 100 * 101 * @var array 102 */ 103 protected $roles; 104 105 /** 106 * Configured filters for this text format. 107 * 108 * An associative array of filters assigned to the text format, keyed by the 109 * instance ID of each filter and using the properties: 110 * - id: The plugin ID of the filter plugin instance. 111 * - provider: The name of the provider that owns the filter. 112 * - status: (optional) A Boolean indicating whether the filter is 113 * enabled in the text format. Defaults to FALSE. 114 * - weight: (optional) The weight of the filter in the text format. Defaults 115 * to 0. 116 * - settings: (optional) An array of configured settings for the filter. 117 * 118 * Use FilterFormat::filters() to access the actual filters. 119 * 120 * @var array 121 */ 122 protected $filters = []; 123 124 /** 125 * Holds the collection of filters that are attached to this format. 126 * 127 * @var \Drupal\filter\FilterPluginCollection 128 */ 129 protected $filterCollection; 130 131 /** 132 * {@inheritdoc} 133 */ 134 public function id() { 135 return $this->format; 136 } 137 138 /** 139 * {@inheritdoc} 140 */ 141 public function filters($instance_id = NULL) { 142 if (!isset($this->filterCollection)) { 143 $this->filterCollection = new FilterPluginCollection(\Drupal::service('plugin.manager.filter'), $this->filters); 144 $this->filterCollection->sort(); 145 } 146 if (isset($instance_id)) { 147 return $this->filterCollection->get($instance_id); 148 } 149 return $this->filterCollection; 150 } 151 152 /** 153 * {@inheritdoc} 154 */ 155 public function getPluginCollections() { 156 return ['filters' => $this->filters()]; 157 } 158 159 /** 160 * {@inheritdoc} 161 */ 162 public function setFilterConfig($instance_id, array $configuration) { 163 $this->filters[$instance_id] = $configuration; 164 if (isset($this->filterCollection)) { 165 $this->filterCollection->setInstanceConfiguration($instance_id, $configuration); 166 } 167 return $this; 168 } 169 170 /** 171 * {@inheritdoc} 172 */ 173 public function toArray() { 174 $properties = parent::toArray(); 175 // The 'roles' property is only used during install and should never 176 // actually be saved. 177 unset($properties['roles']); 178 return $properties; 179 } 180 181 /** 182 * {@inheritdoc} 183 */ 184 public function disable() { 185 if ($this->isFallbackFormat()) { 186 throw new \LogicException("The fallback text format '{$this->id()}' cannot be disabled."); 187 } 188 189 parent::disable(); 190 191 // Allow modules to react on text format deletion. 192 \Drupal::moduleHandler()->invokeAll('filter_format_disable', [$this]); 193 194 // Clear the filter cache whenever a text format is disabled. 195 filter_formats_reset(); 196 197 return $this; 198 } 199 200 /** 201 * {@inheritdoc} 202 */ 203 public function preSave(EntityStorageInterface $storage) { 204 // Ensure the filters have been sorted before saving. 205 $this->filters()->sort(); 206 207 parent::preSave($storage); 208 209 $this->name = trim($this->label()); 210 } 211 212 /** 213 * {@inheritdoc} 214 */ 215 public function postSave(EntityStorageInterface $storage, $update = TRUE) { 216 parent::postSave($storage, $update); 217 218 // Clear the static caches of filter_formats() and others. 219 filter_formats_reset(); 220 221 if (!$update && !$this->isSyncing()) { 222 // Default configuration of modules and installation profiles is allowed 223 // to specify a list of user roles to grant access to for the new format; 224 // apply the defined user role permissions when a new format is inserted 225 // and has a non-empty $roles property. 226 // Note: user_role_change_permissions() triggers a call chain back into 227 // \Drupal\filter\FilterPermissions::permissions() and lastly 228 // filter_formats(), so its cache must be reset upfront. 229 if (($roles = $this->get('roles')) && $permission = $this->getPermissionName()) { 230 foreach (user_roles() as $rid => $name) { 231 $enabled = in_array($rid, $roles, TRUE); 232 user_role_change_permissions($rid, [$permission => $enabled]); 233 } 234 } 235 } 236 } 237 238 /** 239 * Returns if this format is the fallback format. 240 * 241 * The fallback format can never be disabled. It must always be available. 242 * 243 * @return bool 244 * TRUE if this format is the fallback format, FALSE otherwise. 245 */ 246 public function isFallbackFormat() { 247 $fallback_format = \Drupal::config('filter.settings')->get('fallback_format'); 248 return $this->id() == $fallback_format; 249 } 250 251 /** 252 * {@inheritdoc} 253 */ 254 public function getPermissionName() { 255 return !$this->isFallbackFormat() ? 'use text format ' . $this->id() : FALSE; 256 } 257 258 /** 259 * {@inheritdoc} 260 */ 261 public function getFilterTypes() { 262 $filter_types = []; 263 264 $filters = $this->filters(); 265 foreach ($filters as $filter) { 266 if ($filter->status) { 267 $filter_types[] = $filter->getType(); 268 } 269 } 270 271 return array_unique($filter_types); 272 } 273 274 /** 275 * {@inheritdoc} 276 */ 277 public function getHtmlRestrictions() { 278 // Ignore filters that are disabled or don't have HTML restrictions. 279 $filters = array_filter($this->filters()->getAll(), function ($filter) { 280 if (!$filter->status) { 281 return FALSE; 282 } 283 if ($filter->getType() === FilterInterface::TYPE_HTML_RESTRICTOR && $filter->getHTMLRestrictions() !== FALSE) { 284 return TRUE; 285 } 286 return FALSE; 287 }); 288 289 if (empty($filters)) { 290 return FALSE; 291 } 292 else { 293 // From the set of remaining filters (they were filtered by array_filter() 294 // above), collect the list of tags and attributes that are allowed by all 295 // filters, i.e. the intersection of all allowed tags and attributes. 296 $restrictions = array_reduce($filters, function ($restrictions, $filter) { 297 $new_restrictions = $filter->getHTMLRestrictions(); 298 299 // The first filter with HTML restrictions provides the initial set. 300 if (!isset($restrictions)) { 301 return $new_restrictions; 302 } 303 // Subsequent filters with an "allowed html" setting must be intersected 304 // with the existing set, to ensure we only end up with the tags that are 305 // allowed by *all* filters with an "allowed html" setting. 306 else { 307 // Track the union of forbidden tags. 308 if (isset($new_restrictions['forbidden_tags'])) { 309 if (!isset($restrictions['forbidden_tags'])) { 310 $restrictions['forbidden_tags'] = $new_restrictions['forbidden_tags']; 311 } 312 else { 313 $restrictions['forbidden_tags'] = array_unique(array_merge($restrictions['forbidden_tags'], $new_restrictions['forbidden_tags'])); 314 } 315 } 316 317 // Track the intersection of allowed tags. 318 if (isset($restrictions['allowed'])) { 319 $intersection = $restrictions['allowed']; 320 foreach ($intersection as $tag => $attributes) { 321 // If the current tag is not allowed by the new filter, then it's 322 // outside of the intersection. 323 if (!array_key_exists($tag, $new_restrictions['allowed'])) { 324 // The exception is the asterisk (which applies to all tags): it 325 // does not need to be allowed by every filter in order to be 326 // used; not every filter needs attribute restrictions on all tags. 327 if ($tag === '*') { 328 continue; 329 } 330 unset($intersection[$tag]); 331 } 332 // The tag is in the intersection, but now we must calculate the 333 // intersection of the allowed attributes. 334 else { 335 $current_attributes = $intersection[$tag]; 336 $new_attributes = $new_restrictions['allowed'][$tag]; 337 // The current intersection does not allow any attributes, never 338 // allow. 339 if (!is_array($current_attributes) && $current_attributes == FALSE) { 340 continue; 341 } 342 // The new filter allows less attributes (all -> list or none). 343 elseif (!is_array($current_attributes) && $current_attributes == TRUE && ($new_attributes == FALSE || is_array($new_attributes))) { 344 $intersection[$tag] = $new_attributes; 345 } 346 // The new filter allows less attributes (list -> none). 347 elseif (is_array($current_attributes) && $new_attributes == FALSE) { 348 $intersection[$tag] = $new_attributes; 349 } 350 // The new filter allows more attributes; retain current. 351 elseif (is_array($current_attributes) && $new_attributes == TRUE) { 352 continue; 353 } 354 // The new filter allows the same attributes; retain current. 355 elseif ($current_attributes == $new_attributes) { 356 continue; 357 } 358 // Both list an array of attribute values; do an intersection, 359 // where we take into account that a value of: 360 // - TRUE means the attribute value is allowed; 361 // - FALSE means the attribute value is forbidden; 362 // hence we keep the ANDed result. 363 else { 364 $intersection[$tag] = array_intersect_key($intersection[$tag], $new_attributes); 365 foreach (array_keys($intersection[$tag]) as $attribute_value) { 366 $intersection[$tag][$attribute_value] = $intersection[$tag][$attribute_value] && $new_attributes[$attribute_value]; 367 } 368 } 369 } 370 } 371 $restrictions['allowed'] = $intersection; 372 } 373 374 return $restrictions; 375 } 376 }, NULL); 377 378 // Simplification: if we have both allowed (intersected) and forbidden 379 // (unioned) tags, then remove any allowed tags that are also forbidden. 380 // Once complete, the list of allowed tags expresses all tag-level 381 // restrictions, and the list of forbidden tags can be removed. 382 if (isset($restrictions['allowed']) && isset($restrictions['forbidden_tags'])) { 383 foreach ($restrictions['forbidden_tags'] as $tag) { 384 if (isset($restrictions['allowed'][$tag])) { 385 unset($restrictions['allowed'][$tag]); 386 } 387 } 388 unset($restrictions['forbidden_tags']); 389 } 390 391 // Simplification: if the only remaining allowed tag is the asterisk 392 // (which contains attribute restrictions that apply to all tags), and 393 // there are no forbidden tags, then effectively nothing is allowed. 394 if (isset($restrictions['allowed'])) { 395 if (count($restrictions['allowed']) === 1 && array_key_exists('*', $restrictions['allowed']) && !isset($restrictions['forbidden_tags'])) { 396 $restrictions['allowed'] = []; 397 } 398 } 399 400 return $restrictions; 401 } 402 } 403 404 /** 405 * {@inheritdoc} 406 */ 407 public function removeFilter($instance_id) { 408 unset($this->filters[$instance_id]); 409 $this->filterCollection->removeInstanceId($instance_id); 410 } 411 412 /** 413 * {@inheritdoc} 414 */ 415 public function onDependencyRemoval(array $dependencies) { 416 $changed = parent::onDependencyRemoval($dependencies); 417 $filters = $this->filters(); 418 foreach ($filters as $filter) { 419 // Remove disabled filters, so that this FilterFormat config entity can 420 // continue to exist. 421 if (!$filter->status && in_array($filter->provider, $dependencies['module'])) { 422 $this->removeFilter($filter->getPluginId()); 423 $changed = TRUE; 424 } 425 } 426 return $changed; 427 } 428 429 /** 430 * {@inheritdoc} 431 */ 432 protected function calculatePluginDependencies(PluginInspectionInterface $instance) { 433 // Only add dependencies for plugins that are actually configured. This is 434 // necessary because the filter plugin collection will return all available 435 // filter plugins. 436 // @see \Drupal\filter\FilterPluginCollection::getConfiguration() 437 if (isset($this->filters[$instance->getPluginId()])) { 438 parent::calculatePluginDependencies($instance); 439 } 440 } 441 442} 443