filter.module 34.1 KB
Newer Older
1 2
<?php

3 4
/**
 * @file
5
 * Framework for handling the filtering of content.
6
 */
7

8
use Drupal\Component\Utility\Html;
9
use Drupal\Component\Utility\Unicode;
10
use Drupal\Component\Utility\Xss;
11
use Drupal\Core\Cache\Cache;
12
use Drupal\Core\Render\Element;
13
use Drupal\Core\Routing\RouteMatchInterface;
14
use Drupal\Core\Session\AccountInterface;
15
use Drupal\Core\Template\Attribute;
16
use Drupal\filter\Entity\FilterFormat;
17
use Drupal\filter\FilterFormatInterface;
18

19
/**
20
 * Implements hook_help().
21
 */
22
function filter_help($route_name, RouteMatchInterface $route_match) {
23 24
  switch ($route_name) {
    case 'help.page.filter':
25 26
      $output = '';
      $output .= '<h3>' . t('About') . '</h3>';
27
      $output .= '<p>' . t('The Filter module allows administrators to configure text formats. Text formats define the HTML tags, codes, and other input allowed in text entered in the site and they defend your web site against potentially damaging input from malicious users. A visual text editor can be associated with the text formats by using the <a href="!editor_help">Text Editor module</a>. For more information, see <a href="!filter_do">the online documentation for the Filter module</a>.', array('!filter_do' => 'https://www.drupal.org/documentation/modules/filter/','!editor_help' => (\Drupal::moduleHandler()->moduleExists('editor')) ? \Drupal::url('help.page', array('name' => 'editor')) : '#')) . '</p>';
28 29
      $output .= '<h3>' . t('Uses') . '</h3>';
      $output .= '<dl>';
30
      $output .= '<dt>' . t('Managing text formats') . '</dt>';
31 32 33 34
      $output .= '<dd>' . t('You can create and edit text formats on the <a href="!formats">Text formats page</a> (if the Text Editor module is enabled, this page is named Text formats and editors). One text format is included by default: Plain text (which removes all HTML tags). Additional text formats may be created during installation. You can create a text format by clicking "<a href="!add_format">Add text format</a>".', array('!formats' => \Drupal::url('filter.admin_overview'),'!add_format' => \Drupal::url('filter.format_add'))) . '</dd>';
      $output .= '<dt>' . t('Assigning roles to text formats') . '</dt>';
      $output .= '<dd>' . t('You can define which users will be able to use each text format by selecting roles. To ensure security, anonymous and untrusted users should only have access to text formats that restrict them to either plain text or a safe set of HTML tags. This is because HTML tags can allow embedding malicious links or scripts in text. More trusted registered users may be granted permission to use less restrictive text formats in order to create rich text. <strong>Improper text format configuration is a security risk</strong>.') . '</dd>';
      $output .= '<dt>' . t('Selecting filters') . '</dt>';
35 36 37 38
      $output .= '<dd>' . t('Each text format uses filters that add, remove, or transform elements within user-entered text. For example, one filter removes unapproved HTML tags, while another transforms URLs into clickable links. Filters are applied in a specific order and do not change the actual content, but instead, modify it temporarily before it is displayed.') . '</dd>';
      $output .= '<dd>' . t('Each filter can have additional configuration options. For example, for the "Limit allowed HTML tags" filter you need to define the list of HTML tags that the filter leaves in the text.') . '</dd>';
      $output .= '<dt>' . t('Using text fields with text formats') . '</dt>';
      $output .= '<dd>' . t('Text fields that allow text formats are those with "formatted" in the description. These are <em>Text (formatted, long, with summary)</em>, <em>Text (formatted)</em>, and <em>Text (formatted, long)</em>. You cannot change the type of field once a field has been created.') . '</dd>';
39
      $output .= '<dt>' . t('Choosing a text format') . '</dt>';
40
      $output .= '<dd>' . t('When creating or editing data in a field that has text formats enabled, users can select the format under the field from the Text format select list.') . '</dd>';
41
      $output .= '</dl>';
42
      return $output;
43

44
    case 'filter.admin_overview':
45
      $output = '<p>' . t('Text formats define the HTML tags, code, and other formatting that can be used when entering text. <strong>Improper text format configuration is a security risk</strong>. Learn more on the <a href="@filterhelp">Filter module help page</a>.', array('@filterhelp' => \Drupal::url('help.page', array('name' => 'filter')))) . '</p>';
46
      $output .= '<p>' . t('Text formats are presented on content editing pages in the order defined on this page. The first format available to a user will be selected by default.') . '</p>';
47
      return $output;
48

49
    case 'entity.filter_format.edit_form':
50
      $output = '<p>' . t('A text format contains filters that change the user input, for example stripping out malicious HTML or making URLs clickable. Filters are executed from top to bottom and the order is important, since one filter may prevent another filter from doing its job. For example, when URLs are converted into links before disallowed HTML tags are removed, all links may be removed. When this happens, the order of filters may need to be re-arranged.') . '</p>';
51
      return $output;
52 53 54
  }
}

55
/**
56
 * Implements hook_theme().
57 58 59 60
 */
function filter_theme() {
  return array(
    'filter_tips' => array(
61
      'variables' => array('tips' => NULL, 'long' => FALSE),
62
    ),
63
    'text_format_wrapper' => array(
64 65 66 67 68
      'variables' => array(
        'children' => NULL,
        'description' => NULL,
        'attributes' => array(),
      ),
69
    ),
70
    'filter_guidelines' => array(
71
      'variables' => array('format' => NULL),
72
    ),
73 74 75 76 77
    'filter_caption' => array(
      'variables' => array(
        'node' => NULL,
        'tag' => NULL,
        'caption' => NULL,
78
        'classes' => NULL,
79 80
      ),
    )
81 82 83
  );
}

84
/**
85
 * Retrieves a list of enabled text formats, ordered by weight.
86
 *
87
 * @param \Drupal\Core\Session\AccountInterface|null $account
88
 *   (optional) If provided, only those formats that are allowed for this user
89 90
 *   account will be returned. All enabled formats will be returned otherwise.
 *   Defaults to NULL.
91
 *
92
 * @return \Drupal\filter\FilterFormatInterface[]
93 94 95 96
 *   An array of text format objects, keyed by the format ID and ordered by
 *   weight.
 *
 * @see filter_formats_reset()
97
 */
98
function filter_formats(AccountInterface $account = NULL) {
99
  $formats = &drupal_static(__FUNCTION__, array());
100

101
  // All available formats are cached for performance.
102
  if (!isset($formats['all'])) {
103
    $language_interface = \Drupal::languageManager()->getCurrentLanguage();
104
    if ($cache = \Drupal::cache()->get("filter_formats:{$language_interface->getId()}")) {
105 106 107
      $formats['all'] = $cache->data;
    }
    else {
108
      $formats['all'] = \Drupal::entityManager()->getStorage('filter_format')->loadByProperties(array('status' => TRUE));
109
      uasort($formats['all'], 'Drupal\Core\Config\Entity\ConfigEntityBase::sort');
110
      \Drupal::cache()->set("filter_formats:{$language_interface->getId()}", $formats['all'], Cache::PERMANENT, \Drupal::entityManager()->getDefinition('filter_format')->getListCacheTags());
111
    }
112
  }
113

114 115 116 117 118
  // If no user was specified, return all formats.
  if (!isset($account)) {
    return $formats['all'];
  }

119
  // Build a list of user-specific formats.
120 121 122
  $account_id = $account->id();
  if (!isset($formats['user'][$account_id])) {
    $formats['user'][$account_id] = array();
123
    foreach ($formats['all'] as $format) {
124
      if ($format->access('use', $account)) {
125
        $formats['user'][$account_id][$format->id()] = $format;
126 127
      }
    }
128 129
  }

130
  return $formats['user'][$account_id];
131
}
132

133
/**
134
 * Resets the text format caches.
135 136 137 138 139 140 141 142 143 144
 *
 * @see filter_formats()
 */
function filter_formats_reset() {
  drupal_static_reset('filter_formats');
}

/**
 * Retrieves a list of roles that are allowed to use a given text format.
 *
145
 * @param \Drupal\filter\FilterFormatInterface $format
146
 *   An object representing the text format.
147
 *
148
 * @return array
149 150
 *   An array of role names, keyed by role ID.
 */
151
function filter_get_roles_by_format(FilterFormatInterface $format) {
152
  // Handle the fallback format upfront (all roles have access to this format).
153
  if ($format->isFallbackFormat()) {
154
    return user_role_names();
155
  }
156
  // Do not list any roles if the permission does not exist.
157
  $permission = $format->getPermissionName();
158
  return !empty($permission) ? user_role_names(FALSE, $permission) : array();
159 160 161 162 163 164 165
}

/**
 * Retrieves a list of text formats that are allowed for a given role.
 *
 * @param $rid
 *   The user role ID to retrieve text formats for.
166
 *
167
 * @return \Drupal\filter\FilterFormatInterface[]
168 169 170 171 172 173 174 175
 *   An array of text format objects that are allowed for the role, keyed by
 *   the text format ID and ordered by weight.
 */
function filter_get_formats_by_role($rid) {
  $formats = array();
  foreach (filter_formats() as $format) {
    $roles = filter_get_roles_by_format($format);
    if (isset($roles[$rid])) {
176
      $formats[$format->id()] = $format;
177
    }
178
  }
179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197
  return $formats;
}

/**
 * Returns the ID of the default text format for a particular user.
 *
 * The default text format is the first available format that the user is
 * allowed to access, when the formats are ordered by weight. It should
 * generally be used as a default choice when presenting the user with a list
 * of possible text formats (for example, in a node creation form).
 *
 * Conversely, when existing content that does not have an assigned text format
 * needs to be filtered for display, the default text format is the wrong
 * choice, because it is not guaranteed to be consistent from user to user, and
 * some trusted users may have an unsafe text format set by default, which
 * should not be used on text of unknown origin. Instead, the fallback format
 * returned by filter_fallback_format() should be used, since that is intended
 * to be a safe, consistent format that is always available to all users.
 *
198
 * @param \Drupal\Core\Session\AccountInterface|null $account
199
 *   (optional) The user account to check. Defaults to the currently logged-in
200
 *   user. Defaults to NULL.
201
 *
202
 * @return string
203 204 205 206
 *   The ID of the user's default text format.
 *
 * @see filter_fallback_format()
 */
207
function filter_default_format(AccountInterface $account = NULL) {
208
  if (!isset($account)) {
209
    $account = \Drupal::currentUser();
210 211 212
  }
  // Get a list of formats for this user, ordered by weight. The first one
  // available is the user's default format.
213 214
  $formats = filter_formats($account);
  $format = reset($formats);
215
  return $format->id();
216 217 218 219
}

/**
 * Returns the ID of the fallback text format that all users have access to.
220 221 222
 *
 * The fallback text format is a regular text format in every respect, except
 * it does not participate in the filter permission system and cannot be
223
 * disabled. It needs to exist because any user who has permission to create
224 225 226 227 228 229 230
 * formatted content must always have at least one text format they can use.
 *
 * Because the fallback format is available to all users, it should always be
 * configured securely. For example, when the Filter module is installed, this
 * format is initialized to output plain text. Installation profiles and site
 * administrators have the freedom to configure it further.
 *
231 232 233 234 235 236
 * Note that the fallback format is completely distinct from the default format,
 * which differs per user and is simply the first format which that user has
 * access to. The default and fallback formats are only guaranteed to be the
 * same for users who do not have access to any other format; otherwise, the
 * fallback format's weight determines its placement with respect to the user's
 * other formats.
237
 *
238 239
 * Any modules implementing a format deletion functionality must not delete this
 * format.
240
 *
241 242 243
 * @return
 *   The ID of the fallback text format.
 *
244
 * @see hook_filter_format_disable()
245
 * @see filter_default_format()
246 247 248
 */
function filter_fallback_format() {
  // This variable is automatically set in the database for all installations
249
  // of Drupal. In the event that it gets disabled or deleted somehow, there
250
  // is no safe default to return, since we do not want to risk making an
251 252 253
  // existing (and potentially unsafe) text format on the site automatically
  // available to all users. Returning NULL at least guarantees that this
  // cannot happen.
254
  return \Drupal::config('filter.settings')->get('fallback_format');
255 256
}

257
/**
258
 * Runs all the enabled filters on a piece of text.
259
 *
260
 * Note: Because filters can inject JavaScript or execute PHP code, security is
261
 * vital here. When a user supplies a text format, you should validate it using
262
 * $format->access() before accepting/using it. This is normally done in the
263 264
 * validation stage of the Form API. You should for example never make a
 * preview of content in a disallowed format.
265
 *
266 267 268
 * Note: this function should only be used when filtering text for use elsewhere
 * than on a rendered HTML page. If this is part of a HTML page, then a
 * renderable array with a #type 'processed_text' element should be used instead
269 270
 * of this, because that will allow cacheability metadata to be set and bubbled
 * up and attachments to be associated (assets, placeholders, etc.). In other
271 272 273 274
 * words: if you are presenting the filtered text in a HTML page, the only way
 * this will be presented correctly, is by using the 'processed_text' element.
 *
 * @param string $text
275
 *   The text to be filtered.
276
 * @param string|null $format_id
277 278
 *   (optional) The machine name of the filter format to be used to filter the
 *   text. Defaults to the fallback format. See filter_fallback_format().
279
 * @param string $langcode
280
 *   (optional) The language code of the text to be filtered, e.g. 'en' for
281
 *   English. This allows filters to be language-aware so language-specific
282
 *   text replacement can be implemented. Defaults to an empty string.
283 284 285 286
 * @param array $filter_types_to_skip
 *   (optional) An array of filter types to skip, or an empty array (default)
 *   to skip no filter types. All of the format's filters will be applied,
 *   except for filters of the types that are marked to be skipped.
287 288
 *   FilterInterface::TYPE_HTML_RESTRICTOR is the only type that cannot be
 *   skipped.
289
 *
290
 * @return \Drupal\Component\Utility\SafeStringInterface
291 292
 *   The filtered text.
 *
293 294
 * @see filter_process_text()
 *
295
 * @ingroup sanitization
296
 */
297 298 299 300 301 302 303 304
function check_markup($text, $format_id = NULL, $langcode = '', $filter_types_to_skip = array()) {
  $build = array(
    '#type' => 'processed_text',
    '#text' => $text,
    '#format' => $format_id,
    '#filter_types_to_skip' => $filter_types_to_skip,
    '#langcode' => $langcode,
  );
305
  return \Drupal::service('renderer')->renderPlain($build);
Dries's avatar
Dries committed
306 307
}

308
/**
309
 * Render API callback: Hides the field value of 'text_format' elements.
310
 *
311 312 313
 * To not break form processing and previews if a user does not have access to
 * a stored text format, the expanded form elements in filter_process_format()
 * are forced to take over the stored #default_values for 'value' and 'format'.
314 315 316 317 318 319 320 321 322 323
 * However, to prevent the unfiltered, original #value from being displayed to
 * the user, we replace it with a friendly notice here.
 *
 * @see filter_process_format()
 */
function filter_form_access_denied($element) {
  $element['#value'] = t('This field has been disabled because you do not have sufficient permissions to edit it.');
  return $element;
}

Dries's avatar
Dries committed
324
/**
325 326
 * Retrieves the filter tips.
 *
327
 * @param string $format_id
328 329
 *   The ID of the text format for which to retrieve tips, or -1 to return tips
 *   for all formats accessible to the current user.
330
 * @param bool $long
331 332 333
 *   (optional) Boolean indicating whether the long form of tips should be
 *   returned. Defaults to FALSE.
 *
334
 * @return array
335 336 337 338
 *   An associative array of filtering tips, keyed by filter name. Each
 *   filtering tip is an associative array with elements:
 *   - tip: Tip text.
 *   - id: Filter ID.
Dries's avatar
Dries committed
339
 */
340
function _filter_tips($format_id, $long = FALSE) {
341
  $formats = filter_formats(\Drupal::currentUser());
Dries's avatar
Dries committed
342 343 344

  $tips = array();

345
  // If only listing one format, extract it from the $formats array.
346 347
  if ($format_id != -1) {
    $formats = array($formats[$format_id]);
348 349
  }

Dries's avatar
Dries committed
350
  foreach ($formats as $format) {
351
    foreach ($format->filters() as $name => $filter) {
352 353
      if ($filter->status) {
        $tip = $filter->tips($long);
354
        if (isset($tip)) {
355 356 357 358
          $tips[$format->label()][$name] = array(
            'tip' => array('#markup' => $tip),
            'id' => $name,
          );
359
        }
Dries's avatar
Dries committed
360 361 362 363 364 365 366
      }
    }
  }

  return $tips;
}

367
/**
368 369 370
 * Prepares variables for text format guideline templates.
 *
 * Default template: filter-guidelines.html.twig.
371
 *
372
 * @param array $variables
373 374
 *   An associative array containing:
 *   - format: An object representing a text format.
375
 */
376
function template_preprocess_filter_guidelines(&$variables) {
377
  $format = $variables['format'];
378
  $variables['tips'] = array(
379
    '#theme' => 'filter_tips',
380
    '#tips' => _filter_tips($format->id(), FALSE),
381
  );
382 383
}

384 385 386 387 388 389 390 391 392 393
/**
 * Prepares variables for text format wrapper templates.
 *
 * Default template: text-format-wrapper.html.twig.
 *
 * @param array $variables
 *   An associative array containing:
 *   - attributes: An associative array containing properties of the element.
 */
function template_preprocess_text_format_wrapper(&$variables) {
394
  $variables['aria_description'] = FALSE;
395 396
  // Add element class and id for screen readers.
  if (isset($variables['attributes']['aria-describedby'])) {
397
    $variables['aria_description'] = TRUE;
398 399 400 401 402 403
    $variables['attributes']['id'] = $variables['attributes']['aria-describedby'];
    // Remove aria-describedby attribute as it shouldn't be visible here.
    unset($variables['attributes']['aria-describedby']);
  }
}

404 405 406 407 408 409 410 411 412 413 414 415 416 417
/**
 * Prepares variables for filter tips templates.
 *
 * Default template: filter-tips.html.twig.
 *
 * @param array $variables
 *   An associative array containing:
 *   - tips: An array containing descriptions and a CSS ID in the form of
 *     'module-name/filter-id' (only used when $long is TRUE) for each
 *     filter in one or more text formats. Example:
 *     @code
 *       array(
 *         'Full HTML' => array(
 *           0 => array(
418
 *             'tip' => 'Web page addresses and email addresses turn into links automatically.',
419 420 421 422 423 424 425 426 427 428 429 430 431 432 433
 *             'id' => 'filter/2',
 *           ),
 *         ),
 *       );
 *     @endcode
 *   - long: (optional) Whether the passed-in filter tips contain extended
 *     explanations, i.e. intended to be output on the path 'filter/tips'
 *     (TRUE), or are in a short format, i.e. suitable to be displayed below a
 *     form element. Defaults to FALSE.
 */
function template_preprocess_filter_tips(&$variables) {
  $tips = $variables['tips'];

  foreach ($variables['tips'] as $name => $tiplist) {
    foreach ($tiplist as $tip_key => $tip) {
434
      $tiplist[$tip_key]['attributes'] = new Attribute();
435 436 437
    }

    $variables['tips'][$name] = array(
438
      'attributes' => new Attribute(),
439
      'name' => $name,
440 441 442 443 444
      'list' => $tiplist,
    );
  }

  $variables['multiple'] = count($tips) > 1;
445 446
}

Dries's avatar
Dries committed
447
/**
448
 * @defgroup standard_filters Standard filters
Dries's avatar
Dries committed
449
 * @{
450
 * Filters implemented by the Filter module.
Dries's avatar
Dries committed
451 452 453
 */

/**
454
 * Provides filtering of input into accepted HTML.
Dries's avatar
Dries committed
455
 */
456 457
function _filter_html($text, $filter) {
  $allowed_tags = preg_split('/\s+|<|>/', $filter->settings['allowed_html'], -1, PREG_SPLIT_NO_EMPTY);
458
  $text = Xss::filter($text, $allowed_tags);
Dries's avatar
Dries committed
459

460
  if ($filter->settings['filter_html_nofollow']) {
461
    $html_dom = Html::load($text);
462
    $links = $html_dom->getElementsByTagName('a');
463
    foreach ($links as $link) {
464 465
      $link->setAttribute('rel', 'nofollow');
    }
466
    $text = Html::serialize($html_dom);
Dries's avatar
Dries committed
467 468 469 470 471
  }

  return trim($text);
}

472
/**
473
 * Converts text into hyperlinks automatically.
474 475 476
 *
 * This filter identifies and makes clickable three types of "links".
 * - URLs like http://example.com.
477
 * - Email addresses like name@example.com.
478 479
 * - Web addresses without the "http://" protocol defined, like
 *   www.example.com.
480 481
 * Each type must be processed separately, as there is no one regular
 * expression that could possibly match all of the cases in one pass.
482
 */
483
function _filter_url($text, $filter) {
484 485 486 487
  // Tags to skip and not recurse into.
  $ignore_tags = 'a|script|style|code|pre';

  // Pass length to regexp callback.
488
  _filter_url_trim(NULL, $filter->settings['filter_url_length']);
489

490 491 492 493 494 495 496 497
  // Create an array which contains the regexps for each type of link.
  // The key to the regexp is the name of a function that is used as
  // callback function to process matches of the regexp. The callback function
  // is to return the replacement for the match. The array is used and
  // matching/replacement done below inside some loops.
  $tasks = array();

  // Prepare protocols pattern for absolute URLs.
498 499 500 501 502 503
  // \Drupal\Component\Utility\UrlHelper::stripDangerousProtocols() will replace
  // any bad protocols with HTTP, so we need to support the identical list.
  // While '//' is technically optional for MAILTO only, we cannot cleanly
  // differ between protocols here without hard-coding MAILTO, so '//' is
  // optional for all protocols.
  // @see \Drupal\Component\Utility\UrlHelper::stripDangerousProtocols()
504
  $protocols = \Drupal::getContainer()->getParameter('filter_protocols');
505 506
  $protocols = implode(':(?://)?|', $protocols) . ':(?://)?';

507 508 509 510 511 512 513
  $valid_url_path_characters = "[\p{L}\p{M}\p{N}!\*\';:=\+,\.\$\/%#\[\]\-_~@&]";

  // Allow URL paths to contain balanced parens
  // 1. Used in Wikipedia URLs like /Primer_(film)
  // 2. Used in IIS sessions like /S(dfd346)/
  $valid_url_balanced_parens = '\('. $valid_url_path_characters . '+\)';

514
  // Valid end-of-path characters (so /foo. does not gobble the period).
515 516 517
  // 1. Allow =&# for empty URL parameters and other URL-join artifacts
  $valid_url_ending_characters = '[\p{L}\p{M}\p{N}:_+~#=/]|(?:' . $valid_url_balanced_parens . ')';

518 519
  $valid_url_query_chars = '[a-zA-Z0-9!?\*\'@\(\);:&=\+\$\/%#\[\]\-_\.,~|]';
  $valid_url_query_ending_chars = '[a-zA-Z0-9_&=#\/]';
520 521 522 523 524

  //full path
  //and allow @ in a url, but only in the middle. Catch things like http://example.com/@user/
  $valid_url_path = '(?:(?:'.$valid_url_path_characters . '*(?:'.$valid_url_balanced_parens .$valid_url_path_characters . '*)*'. $valid_url_ending_characters . ')|(?:@' . $valid_url_path_characters . '+\/))';

525 526 527 528
  // Prepare domain name pattern.
  // The ICANN seems to be on track towards accepting more diverse top level
  // domains, so this pattern has been "future-proofed" to allow for TLDs
  // of length 2-64.
529
  $domain = '(?:[\p{L}\p{M}\p{N}._+-]+\.)?[\p{L}\p{M}]{2,64}\b';
530
  $ip = '(?:[0-9]{1,3}\.){3}[0-9]{1,3}';
531 532
  $auth = '[\p{L}\p{M}\p{N}:%_+*~#?&=.,/;-]+@';
  $trail = '('.$valid_url_path.'*)?(\\?'.$valid_url_query_chars .'*'.$valid_url_query_ending_chars.')?';
533 534

  // Match absolute URLs.
535
  $url_pattern = "(?:$auth)?(?:$domain|$ip)/?(?:$trail)?";
536
  $pattern = "`((?:$protocols)(?:$url_pattern))`u";
537
  $tasks['_filter_url_parse_full_links'] = $pattern;
538

539
  // Match email addresses.
540
  $url_pattern = "[\p{L}\p{M}\p{N}._+-]{1,254}@(?:$domain)";
541
  $pattern = "`($url_pattern)`u";
542 543 544 545
  $tasks['_filter_url_parse_email_links'] = $pattern;

  // Match www domains.
  $url_pattern = "www\.(?:$domain)/?(?:$trail)?";
546
  $pattern = "`($url_pattern)`u";
547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598
  $tasks['_filter_url_parse_partial_links'] = $pattern;

  // Each type of URL needs to be processed separately. The text is joined and
  // re-split after each task, since all injected HTML tags must be correctly
  // protected before the next task.
  foreach ($tasks as $task => $pattern) {
    // HTML comments need to be handled separately, as they may contain HTML
    // markup, especially a '>'. Therefore, remove all comment contents and add
    // them back later.
    _filter_url_escape_comments('', TRUE);
    $text = preg_replace_callback('`<!--(.*?)-->`s', '_filter_url_escape_comments', $text);

    // Split at all tags; ensures that no tags or attributes are processed.
    $chunks = preg_split('/(<.+?>)/is', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
    // PHP ensures that the array consists of alternating delimiters and
    // literals, and begins and ends with a literal (inserting NULL as
    // required). Therefore, the first chunk is always text:
    $chunk_type = 'text';
    // If a tag of $ignore_tags is found, it is stored in $open_tag and only
    // removed when the closing tag is found. Until the closing tag is found,
    // no replacements are made.
    $open_tag = '';

    for ($i = 0; $i < count($chunks); $i++) {
      if ($chunk_type == 'text') {
        // Only process this text if there are no unclosed $ignore_tags.
        if ($open_tag == '') {
          // If there is a match, inject a link into this chunk via the callback
          // function contained in $task.
          $chunks[$i] = preg_replace_callback($pattern, $task, $chunks[$i]);
        }
        // Text chunk is done, so next chunk must be a tag.
        $chunk_type = 'tag';
      }
      else {
        // Only process this tag if there are no unclosed $ignore_tags.
        if ($open_tag == '') {
          // Check whether this tag is contained in $ignore_tags.
          if (preg_match("`<($ignore_tags)(?:\s|>)`i", $chunks[$i], $matches)) {
            $open_tag = $matches[1];
          }
        }
        // Otherwise, check whether this is the closing tag for $open_tag.
        else {
          if (preg_match("`<\/$open_tag>`i", $chunks[$i], $matches)) {
            $open_tag = '';
          }
        }
        // Tag chunk is done, so next chunk must be text.
        $chunk_type = 'text';
      }
    }
599

600
    $text = implode($chunks);
601
    // Revert to the original comment contents
602 603 604
    _filter_url_escape_comments('', FALSE);
    $text = preg_replace_callback('`<!--(.*?)-->`', '_filter_url_escape_comments', $text);
  }
605 606 607 608 609

  return $text;
}

/**
610 611 612
 * Makes links out of absolute URLs.
 *
 * Callback for preg_replace_callback() within _filter_url().
613 614
 */
function _filter_url_parse_full_links($match) {
615 616 617
  // The $i:th parenthesis in the regexp contains the URL.
  $i = 1;

618
  $match[$i] = Html::decodeEntities($match[$i]);
619 620
  $caption = Html::escape(_filter_url_trim($match[$i]));
  $match[$i] = Html::escape($match[$i]);
621
  return '<a href="' . $match[$i] . '">' . $caption . '</a>';
622 623 624
}

/**
625
 * Makes links out of email addresses.
626 627
 *
 * Callback for preg_replace_callback() within _filter_url().
628 629 630 631 632
 */
function _filter_url_parse_email_links($match) {
  // The $i:th parenthesis in the regexp contains the URL.
  $i = 0;

633
  $match[$i] = Html::decodeEntities($match[$i]);
634 635
  $caption = Html::escape(_filter_url_trim($match[$i]));
  $match[$i] = Html::escape($match[$i]);
636
  return '<a href="mailto:' . $match[$i] . '">' . $caption . '</a>';
637 638 639
}

/**
640 641 642
 * Makes links out of domain names starting with "www."
 *
 * Callback for preg_replace_callback() within _filter_url().
643 644
 */
function _filter_url_parse_partial_links($match) {
645 646 647
  // The $i:th parenthesis in the regexp contains the URL.
  $i = 1;

648
  $match[$i] = Html::decodeEntities($match[$i]);
649 650
  $caption = Html::escape(_filter_url_trim($match[$i]));
  $match[$i] = Html::escape($match[$i]);
651
  return '<a href="http://' . $match[$i] . '">' . $caption . '</a>';
652 653 654
}

/**
655 656 657
 * Escapes the contents of HTML comments.
 *
 * Callback for preg_replace_callback() within _filter_url().
658 659 660 661 662
 *
 * @param $match
 *   An array containing matches to replace from preg_replace_callback(),
 *   whereas $match[1] is expected to contain the content to be filtered.
 * @param $escape
663 664 665
 *   (optional) A Boolean indicating whether to escape (TRUE) or unescape
 *   comments (FALSE). Defaults to NULL, indicating neither. If TRUE, statically
 *   cached $comments are reset.
666 667 668 669 670 671 672 673 674 675 676 677
 */
function _filter_url_escape_comments($match, $escape = NULL) {
  static $mode, $comments = array();

  if (isset($escape)) {
    $mode = $escape;
    if ($escape){
      $comments = array();
    }
    return;
  }

678
  // Replace all HTML comments with a '<!-- [hash] -->' placeholder.
679 680
  if ($mode) {
    $content = $match[1];
681
    $hash = hash('sha256', $content);
682 683 684 685 686 687 688 689 690 691
    $comments[$hash] = $content;
    return "<!-- $hash -->";
  }
  // Or replace placeholders with actual comment contents.
  else {
    $hash = $match[1];
    $hash = trim($hash);
    $content = $comments[$hash];
    return "<!--$content-->";
  }
692 693 694
}

/**
695
 * Shortens long URLs to http://www.example.com/long/url…
696 697 698 699 700 701 702
 */
function _filter_url_trim($text, $length = NULL) {
  static $_length;
  if ($length !== NULL) {
    $_length = $length;
  }

703 704
  if (isset($_length)) {
    $text = Unicode::truncate($text, $_length, FALSE, TRUE);
705 706 707 708 709
  }

  return $text;
}

Dries's avatar
Dries committed
710
/**
711 712
 * Converts line breaks into <p> and <br> in an intelligent fashion.
 *
Dries's avatar
Dries committed
713 714 715
 * Based on: http://photomatt.net/scripts/autop
 */
function _filter_autop($text) {
716
  // All block level tags
717
  $block = '(?:table|thead|tfoot|caption|col|colgroup|tbody|tr|td|th|div|dl|dd|dt|ul|ol|li|pre|select|option|form|map|area|blockquote|address|math|input|p|h[1-6]|fieldset|legend|hr|article|aside|details|figcaption|figure|footer|header|hgroup|menu|nav|section|summary)';
Dries's avatar
Dries committed
718

719 720 721 722
  // Split at opening and closing PRE, SCRIPT, STYLE, OBJECT, IFRAME tags
  // and comments. We don't apply any processing to the contents of these tags
  // to avoid messing up code. We look for matched pairs and allow basic
  // nesting. For example:
Dries's avatar
Dries committed
723
  // "processed <pre> ignored <script> ignored </script> ignored </pre> processed"
724
  $chunks = preg_split('@(<!--.*?-->|</?(?:pre|script|style|object|iframe|!--)[^>]*>)@i', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
Dries's avatar
Dries committed
725 726
  // Note: PHP ensures the array consists of alternating delimiters and literals
  // and begins and ends with a literal (inserting NULL as required).
727
  $ignore = FALSE;
Dries's avatar
Dries committed
728 729 730 731
  $ignoretag = '';
  $output = '';
  foreach ($chunks as $i => $chunk) {
    if ($i % 2) {
732
      $comment = (substr($chunk, 0, 4) == '<!--');
733 734 735 736 737 738 739
      if ($comment) {
        // Nothing to do, this is a comment.
        $output .= $chunk;
        continue;
      }
      // Opening or closing tag?
      $open = ($chunk[1] != '/');
740
      list($tag) = preg_split('/[ >]/', substr($chunk, 2 - $open), 2);
Dries's avatar
Dries committed
741 742
      if (!$ignore) {
        if ($open) {
743
          $ignore = TRUE;
Dries's avatar
Dries committed
744 745 746 747
          $ignoretag = $tag;
        }
      }
      // Only allow a matching tag to close it.
748
      elseif (!$open && $ignoretag == $tag) {
749
        $ignore = FALSE;
Dries's avatar
Dries committed
750 751 752
        $ignoretag = '';
      }
    }
753
    elseif (!$ignore) {
754
      $chunk = preg_replace('|\n*$|', '', $chunk) . "\n\n"; // just to make things a little easier, pad the end
Dries's avatar
Dries committed
755
      $chunk = preg_replace('|<br />\s*<br />|', "\n\n", $chunk);
756 757
      $chunk = preg_replace('!(<' . $block . '[^>]*>)!', "\n$1", $chunk); // Space things out a little
      $chunk = preg_replace('!(</' . $block . '>)!', "$1\n\n", $chunk); // Space things out a little
Dries's avatar
Dries committed
758
      $chunk = preg_replace("/\n\n+/", "\n\n", $chunk); // take care of duplicates
759 760
      $chunk = preg_replace('/^\n|\n\s*\n$/', '', $chunk);
      $chunk = '<p>' . preg_replace('/\n\s*\n\n?(.)/', "</p>\n<p>$1", $chunk) . "</p>\n"; // make paragraphs, including one at the end
Dries's avatar
Dries committed
761 762 763
      $chunk = preg_replace("|<p>(<li.+?)</p>|", "$1", $chunk); // problem with nested lists
      $chunk = preg_replace('|<p><blockquote([^>]*)>|i', "<blockquote$1><p>", $chunk);
      $chunk = str_replace('</blockquote></p>', '</p></blockquote>', $chunk);
764
      $chunk = preg_replace('|<p>\s*</p>\n?|', '', $chunk); // under certain strange conditions it could create a P of entirely whitespace
765 766
      $chunk = preg_replace('!<p>\s*(</?' . $block . '[^>]*>)!', "$1", $chunk);
      $chunk = preg_replace('!(</?' . $block . '[^>]*>)\s*</p>!', "$1", $chunk);
Dries's avatar
Dries committed
767
      $chunk = preg_replace('|(?<!<br />)\s*\n|', "<br />\n", $chunk); // make line breaks
768
      $chunk = preg_replace('!(</?' . $block . '[^>]*>)\s*<br />!', "$1", $chunk);
769
      $chunk = preg_replace('!<br />(\s*</?(?:p|li|div|dl|dd|dt|th|pre|td|ul|ol)>)!', '$1', $chunk);
770
      $chunk = preg_replace('/&([^#])(?![A-Za-z0-9]{1,8};)/', '&amp;$1', $chunk);
Dries's avatar
Dries committed
771 772 773 774 775 776
    }
    $output .= $chunk;
  }
  return $output;
}

777 778 779 780
/**
 * Escapes all HTML tags, so they will be visible instead of being effective.
 */
function _filter_html_escape($text) {
781
  return trim(Html::escape($text));
782 783
}

784 785 786 787 788 789
/**
 * Process callback for local image filter.
 */
function _filter_html_image_secure_process($text) {
  // Find the path (e.g. '/') to Drupal root.
  $base_path = base_path();
790
  $base_path_length = Unicode::strlen($base_path);
791 792

  // Find the directory on the server where index.php resides.
793
  $local_dir = \Drupal::root() . '/';
794

795
  $html_dom = Html::load($text);
796 797 798
  $images = $html_dom->getElementsByTagName('img');
  foreach ($images as $image) {
    $src = $image->getAttribute('src');
799 800 801
    // Transform absolute image URLs to relative image URLs: prevent problems on
    // multisite set-ups and prevent mixed content errors.
    $image->setAttribute('src', file_url_transform_relative($src));
802 803 804 805

    // Verify that $src starts with $base_path.
    // This also ensures that external images cannot be referenced.
    $src = $image->getAttribute('src');
806
    if (Unicode::substr($src, 0, $base_path_length) === $base_path) {
807 808 809
      // Remove the $base_path to get the path relative to the Drupal root.
      // Ensure the path refers to an actual image by prefixing the image source
      // with the Drupal root and running getimagesize() on it.
810
      $local_image_path = $local_dir . Unicode::substr($src, $base_path_length);
811
      $local_image_path = rawurldecode($local_image_path);
812 813 814 815 816
      if (@getimagesize($local_image_path)) {
        // The image has the right path. Erroneous images are dealt with below.
        continue;
      }
    }
817 818 819
    // Allow modules and themes to replace an invalid image with an error
    // indicator. See filter_filter_secure_image_alter().
    \Drupal::moduleHandler()->alter('filter_secure_image', $image);
820
  }
821
  $text = Html::serialize($html_dom);
822 823 824 825
  return $text;
}

/**
826 827
 * Implements hook_filter_secure_image_alter().
 *
828 829 830 831 832 833 834
 * Formats an image DOM element that has an invalid source.
 *
 * @param DOMElement $image
 *   An IMG node to format, parsed from the filtered text.
 *
 * @see _filter_html_image_secure_process()
 */
835
function filter_filter_secure_image_alter(&$image) {
836
  // Turn an invalid image into an error indicator.
837
  $image->setAttribute('src', base_path() . 'core/misc/icons/e32700/error.svg');
838 839
  $image->setAttribute('alt', t('Image removed.'));
  $image->setAttribute('title', t('This image has been removed. For security reasons, only images from the local domain are allowed.'));
840 841
  $image->setAttribute('height', '16');
  $image->setAttribute('width',  '16');
842 843 844 845 846 847 848

  // Add a CSS class to aid in styling.
  $class = ($image->getAttribute('class') ? trim($image->getAttribute('class')) . ' ' : '');
  $class .= 'filter-image-invalid';
  $image->setAttribute('class', $class);
}

Dries's avatar
Dries committed
849
/**
850
 * @} End of "defgroup standard_filters".
Dries's avatar
Dries committed
851
 */