604ce01ac2f18028cbea4fcde8a735f58592179d
[yaffs-website] / web / core / modules / filter / src / Entity / FilterFormat.php
1 <?php
2
3 namespace Drupal\filter\Entity;
4
5 use Drupal\Component\Plugin\PluginInspectionInterface;
6 use Drupal\Core\Config\Entity\ConfigEntityBase;
7 use Drupal\Core\Entity\EntityWithPluginCollectionInterface;
8 use Drupal\Core\Entity\EntityStorageInterface;
9 use Drupal\filter\FilterFormatInterface;
10 use Drupal\filter\FilterPluginCollection;
11 use 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  */
56 class 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 (blacklisted) 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 (whitelisted) tags.
318           if (isset($restrictions['allowed'])) {
319             $intersection = $restrictions['allowed'];
320             foreach ($intersection as $tag => $attributes) {
321               // If the current tag is not whitelisted by the new filter, then
322               // it's 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 whitelisted 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 a (intersected) whitelist and a (unioned)
379       // blacklist, then remove any tags from the whitelist that also exist in the
380       // blacklist. Now the whitelist alone expresses all tag-level restrictions,
381       // and we can delete the blacklist.
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 (which
392       // contains attribute restrictions that apply to all tags), and only
393       // whitelisting filters were used, 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 }