To participate you must create an account on apostrophenow.org. If you have already done so, click Login.

Ticket #494: aHtml.class.php

File aHtml.class.php, 21.6 KB (added by thingygeoff, 18 months ago)

Modified aHtml.class.php allowing for app.yml control of br tags

Line 
1<?php
2
3/**
4 * HTML related utilities. HTML markup to RSS markup conversion,
5 * simplification of HTML to a short list of legal tags and no
6 * dangerous attributes, mailto: obfuscation, word count limit
7 * that preserves valid HTML markup, and basic text-to-HTML
8 * conversion that preserves line breaks and creates links.
9 *
10 * doc-to-HTML conversion has been removed as it's out of scope for
11 * apostrophePlugin which should contain lightweight stuff only.
12 * We should consider putting that out as a separate plugin.
13 *
14 * @author Tom Boutell <tom@punkave.com>
15 */
16
17class aHtmlNotHtmlException extends Exception
18{
19 
20}
21
22class aHtml
23{
24  static private $badPunctuation = array('“', '”', '®', '‘', '’');
25  static private $badPunctuationReplacements = array('&lquot;', '&rquot;', '&reg;', '&lsquo;', '&rsquo;');
26
27  static private $rssEntityMap = 
28    array('&lquot;' => '\"',
29      '&rquot;' => '\"',
30      '&reg;' => '(Reg TM)', 
31      '&lsquo;' => '\'',
32      '&rsquo;' => '\'',
33      '&bull' => '*',
34      '&amp;' => '&amp;',
35      '&lt;' => '&lt;',
36      '&gt;' => '&gt;'
37    );
38
39  // Right now this just converts obscure HTML entities to
40  // simpler stuff that all feed readers will digest.
41  public static function htmlToRss($doc)
42  {
43    // Eval stuff like this is not the quickest. There
44    // must be a better way. We should be saving a
45    // pre-RSSified version of posts, for one thing.
46    return preg_replace(
47      '/(&\w+;)/e', 
48      "aHtml::entityToRss('$1')",
49      $doc);
50  }
51 
52  public static function entityToRss($entity)
53  {
54    if (isset(self::$rssEntityMap[$entity]))
55    {
56      return self::$rssEntityMap[$entity];
57    } 
58    else
59    {
60      return '';
61    }
62  }
63
64  // The default list of allowed tags for aHtml::simplify().
65  // These work well for user-generated content made with FCK.
66  // You can now alter this list by passing a similar list as the second
67  // argument to aHtml::simplify(). An array of tag names without braces is also allowed.
68 
69  // Reserving h1 and h2 for the site layout's use is generally a good idea
70 
71  static private $defaultAllowedTags =
72    '<h3><h4><h5><h6><blockquote><p><a><ul><ol><nl><li><b><i><strong><em><strike><code><hr><br><div><table><thead><caption><tbody><tr><th><td><pre>';
73
74  // The default list of allowed attributes for aHtml::simplify().
75  // You can now alter this list by passing a similar array as the fourth
76  // argument to aHtml::simplify().
77
78  static private $defaultAllowedAttributes = array(
79    "a" => array("href", "name", "target"),
80    "img" => array("src")
81  );
82 
83  // Subtle control of the style attribute is possible, but we don't allow
84  // any styles by default. See the allowedStyles argument to simplify()
85 
86  static private $defaultAllowedStyles = array();
87
88  // allowedTags can be an array of tag names, without < and > delimiters,
89  // or a continuous string of tag names bracketed by < and > (as strip_tags
90  // expects).
91 
92  // By default, if the 'a' tag is in allowedTags, then we allow the href attribute on
93  // that (but not JavaScript links). If the 'img' tag is in allowedTags,
94  // then we allow the src attribute on that (but no JavaScript there either).
95  // You can alter this by passing a different array of allowed attributes.
96
97  // If $complete is true, the returned string will be a complete
98  // HTML 4.x document with a doctype and html and body elements.
99  // otherwise, it will be a fragment without those things
100  // (which is what you almost certainly want).
101 
102  // If $allowedAttributes is not false, it should contain an array in which the
103  // keys are tag names and the values are arrays of attribute names to be permitted.
104  // Note that javascript: is forbidden at the start of any attribute, so attributes
105  // that act as URLs should be safe to permit (we now check for leading space and
106  // mixed case variations of javascript: as well).
107 
108  // If $allowedStyles is not false, it should contain an array in which the keys
109  // are tag names and the values are arrays of CSS style property names to be permitted.
110  // This is a much better idea than just allowing the style attribute, which is one
111  // of the best ways to kill the layout of an entire page.
112  //
113  // An example:
114  //
115  // array("table" => array("width", "height"),
116  //   "td" => array("width", "height"),
117  //   "th" => array("width", "height"))
118  //
119  // Note that rich text editors vary in how they handle table width and height;
120  // Safari sets the width and height attributes of the tags rather than going
121  // the CSS route. The simplest workaround is to allow that too.
122
123  static private $defaultHtmlStrictBr = false;
124
125  static public function simplify($value, $allowedTags = false, $complete = false, $allowedAttributes = false, $allowedStyles = false, $htmlStrictBr = false)
126  {
127    if ($allowedTags === false)
128    {
129      // Not using Symfony? Replace the entire sfConfig::get call with self::$defaultAllowedTags
130      $allowedTags = sfConfig::get('app_aToolkit_allowed_tags', self::$defaultAllowedTags);
131    }
132    if ($allowedAttributes === false)
133    {
134      // See above
135      $allowedAttributes = sfConfig::get('app_aToolkit_allowed_attributes', self::$defaultAllowedAttributes);
136    }
137    if ($allowedStyles === false)
138    {
139      // See above
140      $allowedStyles = sfConfig::get('app_aToolkit_allowed_styles', self::$defaultAllowedStyles);
141    }
142    if ($htmlStrictBr === false)
143    {
144      // See above
145      $htmlStrictBr = sfConfig::get('app_aToolkit_html_strict_br', self::$defaultHtmlStrictBr);
146    }
147    $value = trim($value);
148    if (!strlen($value))
149    {
150      // An empty string is NOT something to panic
151      // and generate warnings about
152      return '';
153    }
154    if (is_array($allowedTags))
155    {
156      $tags = "";
157      foreach ($allowedTags as $tag)
158      {
159        $tags .= "<$tag>";
160      }
161      $allowedTags = $tags;
162    }
163    $value = strip_tags($value, $allowedTags);
164
165    // Now we use DOMDocument to strip attributes. In principle of course
166    // we could do the whole job with DOMDocument. But in practice it is quite
167    // awkward to hoist subtags correctly when a parent tag is not on the
168    // allowed list with DOMDocument, and strip_tags takes care of that
169    // task just fine.
170
171    // At first I used matt@lvi.org's function from the strip_tags
172    // documentation wiki. Unfortunately preg_replace tends to return null
173    // on some of his regexps for nontrivial documents which is pretty
174    // disastrous. He seems to have some greedy regexps where he should
175    // have ungreedy regexps. Let's do it right rather than trying to
176    // make regular expressions do what they shouldn't.
177
178    // We also get rid of javascript: links here, a good idea from
179    // Matt's script.
180   
181    $oldHandler = set_error_handler("aHtml::warningsHandler", E_WARNING);
182   
183    // If we do not have a properly formed <html><head></head><body></body></html> document then
184    // UTF-8 encoded content will be trashed. This is important because we support fragments
185    // of HTML containing UTF-8 as part of a
186    if (!preg_match("/<head>/i", $value))
187    {
188      $value = '
189      <html>
190      <head>
191      <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
192      </head>
193      <body>
194      ' . $value . '
195      </body>
196      </html>
197      ';
198    }
199    try 
200    {
201      // Specify UTF-8 or UTF-8 encoded stuff passed in will turn into sushi.
202      $doc = new DOMDocument('1.0', 'UTF-8');
203      $doc->strictErrorChecking = true;
204      $doc->loadHTML($value);
205      self::stripAttributesNode($doc, $allowedAttributes, $allowedStyles);
206      // Per user contributed notes at
207      // http://us2.php.net/manual/en/domdocument.savehtml.php
208      // saveHTML forces a doctype and container tags on us; get
209      // rid of those as we only want a fragment here
210      $result = $doc->saveHTML();
211    } catch (aHtmlNotHtmlException $e)
212    {
213      // The user thought they were entering text and used & accordingly (as they so often do)
214      $result = htmlspecialchars($value);
215    }
216
217    if($htmlStrictBr)
218    {
219      $result = str_replace('<br>', '<br />', $result);
220    }
221
222    if ($oldHandler)
223    {
224      set_error_handler($oldHandler);
225    }
226     
227    if ($complete)
228    {
229      // Don't allow whitespace to balloon
230      return trim($result);
231    }
232
233    $result = self::documentToFragment($result);
234                return $result;
235  }
236
237  static public function documentToFragment($s)
238  {
239    // Added trim call because otherwise size begins to balloon indefinitely
240    return trim(preg_replace(array('/^<!DOCTYPE.+?>/', '/<head>.*?<\/head>/i'), '', 
241      str_replace( array('<html>', '</html>', '<body>', '</body>'), array('', '', '', ''), $s)));
242  }
243 
244  static public function warningsHandler($errno, $errstr, $errfile, $errline) 
245  {
246    // Most warnings should be ignored as DOMDocument cleans up the HTML in exactly
247    // the way we want. However "no name in entity" usually means the user thought they
248    // were entering plaintext, so we should throw an exception signaling that
249   
250    if (strstr("no name in Entity", $errstr))
251    {
252      throw new aHtmlNotHtmlException();
253    }
254    return;
255  }
256 
257  static private function stripAttributesNode($node, $allowedAttributes, $allowedStyles)
258  {
259    if ($node->hasChildNodes())
260    {
261      foreach ($node->childNodes as $child)
262      {
263        self::stripAttributesNode($child, $allowedAttributes, $allowedStyles);
264      }
265    }
266    if ($node->hasAttributes())
267    {
268      $removeList = array();
269      foreach ($node->attributes as $index => $attr)
270      {
271        $good = false;
272        if ($attr->name === 'style')
273        {
274          if (isset($allowedStyles[$node->nodeName]))
275          {
276            // There is no handy function in core PHP to parse CSS rules, so we'll do it ourselves
277           
278            // First chop it into raw tokens as follows: /* ... */, \', \", ;, :, ', " and anything else
279            $styles = array();
280            $rawTokens = preg_split('/(\/\*.*?\*\/|\\\'|\\\"|;|:|\'|")/', $attr->value, null, PREG_SPLIT_DELIM_CAPTURE);
281            // Now assemble quoted strings into single tokens, inclusive of escaped quotes, ;, :, etc. so that
282            // we don't get tripped up by them later
283            $realTokens = array();
284            $single = false;
285            $double = false;
286            $s = '';
287            foreach ($rawTokens as $rawToken)
288            {
289              if ($rawToken === "'")
290              {
291                if ($single)
292                {
293                  $single = false;
294                  $realTokens[] = "'" . $s . "'";
295                }
296                else
297                {
298                  $single = true;
299                  $s = '';
300                }
301              }
302              elseif ($rawToken === '"')
303              {
304                if ($double)
305                {
306                  $double = false;
307                  $realTokens[] = '"' . $s . '"';
308                }
309                else
310                {
311                  $double = true;
312                  $s = '';
313                }
314              }
315              else
316              {
317                if ($single || $double)
318                {
319                  $s .= $rawToken;
320                }
321                else
322                {
323                  $realTokens[] = $rawToken;
324                }
325              }
326            }
327            // Now we can just scan for semicolons and colons and make pretty rules
328            $styles = array();
329            $state = 'property';
330            $p = '';
331            $v = '';
332                                                if (end($realTokens) !== ';')
333                                                {
334                                                        $realTokens[] = ';';
335                                                }
336            foreach ($realTokens as $token)
337            {
338              if ($state === 'property')
339              {
340                if ($token === ':')
341                {
342                  $state = 'value';
343                }
344                else
345                {
346                  // We dump comments. Seems like a good idea in a tool used to clean up
347                  // rich text editor output. If we didn't do this, we'd need a way to
348                  // preserve them while still comparing names correctly
349                  if (substr($token, 0, 2) !== '/*')
350                  {
351                    $p .= $token;
352                  }
353                }
354              }
355              elseif ($state === 'value')
356              {
357                if ($token === ';')
358                {
359                  // TODO: unescape quotes and unicode escapes in property names so
360                  // we can compare them to the allowed properties, then reescape them
361                  // when assembling the final rules.
362                  //
363                  // Not that hard given the tokenizing we've already done,
364                  // but rich text editors don't generally introduce that nonsense
365                  // into style attributes
366                  $p = trim($p);
367                  $styles[$p] = $v;
368                  $p = '';
369                  $v = '';
370                  $state = 'property';
371                }
372                else
373                {
374                  // We dump comments. Seems like a good idea in a tool used to clean up
375                  // rich text editor output
376                  if (substr($token, 0, 2) !== '/*')
377                  {
378                    $v .= $token;
379                  }
380                }
381              }
382              else
383              {
384                throw new sfException('Unknown state in CSS parser in stripAttributesNode: ' . $state);
385              }
386            }
387            $allowed = array_flip($allowedStyles[$node->nodeName]);
388            $newStyles = array();
389            foreach ($styles as $p => $v)
390            {
391              if (isset($allowed[$p]))
392              {
393                $newStyles[$p] = $v;
394              }
395            }
396            $good = true;
397            $rules = array();
398            foreach ($newStyles as $p => $v)
399            {
400              $rules[] = "$p: $v;";
401            }
402            $attr->value = implode(' ', $rules);
403          }
404        }
405        if (!$good)
406        {
407          if (isset($allowedAttributes[$node->nodeName]))
408          {
409            foreach ($allowedAttributes[$node->nodeName] as $attrName)
410            {
411              // Be more careful about this: leading space is tolerated by the browser,
412              // so is mixed case in the protocol name (at least in Firefox and Safari,
413              // which is plenty bad enough)
414              if (($attr->name === $attrName) && (!preg_match('/^\s*javascript:/i', $attr->value)))
415              {
416                // We keep this one
417                $good = true;
418              }
419            }
420          }
421        }
422        if (!$good)
423        {
424          // Off with its head
425          $removeList[] = $attr->name; 
426        }
427      }
428      foreach ($removeList as $name)
429      {
430        $node->removeAttribute($name);
431      }
432    }
433  }
434
435  // TODO: limitWords currently might not do a great job on typical
436  // "gross" HTML without closing </p> tags and the like.
437
438  static private $nonContainerTags = array(
439    "br" => true,
440    "img" => true,
441    "input" => true
442  );
443
444        public static function limitWords($string, $word_limit, $options = array())
445        {
446          # TBB: tag-aware, doesn't split in the middle of tags
447          # (we will probably use fancier tags with attributes later,
448          # so this is important). Tags must be valid XHTML unless
449          # all allowed tags
450          $words = preg_split("/(\<.*?\>|\s+)/", $string, -1, 
451            PREG_SPLIT_DELIM_CAPTURE);
452          $wordCount = 0;
453          # Balance tags that need balancing. We don't have strict XHTML
454          # coming from OpenOffice (oh, if only) so we'll have to keep a
455          # list of the tags that are containers.
456          $open = array();
457          $result = "";
458          $count = 0;
459                $num_words = count($words);
460          foreach ($words as $word) {
461            if ($count >= $word_limit) {
462              break;
463            } elseif (preg_match("/\<.*?\/\>/", $word)) {
464              # XHTML non-container tag, we don't have to guess
465              $result .= $word;
466              continue;
467            } elseif (preg_match("/\<(\w+)/s", $word, $matches)) {
468              $tag = $matches[1];
469              $result .= $word;
470              if (isset(aHtml::$nonContainerTags[$tag])) {
471                continue;
472              }
473              $open[] = $tag;
474            } elseif (preg_match("/\<\/(\w+)/s", $word, $matches)) {
475              $tag = $matches[1];
476              if (!count($open)) {
477                # Groan, extra close tag, ignore
478                continue;
479              }
480              $last = array_pop($open);   
481              if ($last !== $tag) {
482                # They closed the wrong tag. Again, ignore for now, but
483                # we might want to work on a better solution
484                continue;
485              }
486              $result .= $word;
487            } elseif (preg_match("/^\s+$/s", $word)) {
488              $result .= $word;
489            } else {
490              if (strlen($word)) {
491                $count++;
492                $result .= $word;
493              }
494            }
495          }
496
497                $append_ellipsis = false;
498                if (isset($options['append_ellipsis']))
499                {
500                        $append_ellipsis = $options['append_ellipsis'];
501                }
502                if ($append_ellipsis == true && $num_words > $word_limit)
503                {
504                        $result .= '&hellip;';
505                }
506
507          for ($i = count($open) - 1; ($i >= 0); $i--) {
508            $result .= "</" . $open[$i] . ">";
509          }
510          return $result;
511        }
512
513  public static function toText($html)
514  {
515    # Nothing fancy, we use the text for indexing only anyway.
516    # It would be nice to do a prettier job here for future applications
517    # that need pretty plaintext representations. That would be useful
518    # as an alt-body in emails
519    $txt = strip_tags($html);
520    return $txt;
521  }
522
523  public static function obfuscateMailto($html)
524  {
525    # Obfuscates any mailto: links found in $html. Good if you already
526    # have nice HTML from FCK or what have you.
527   
528    # Note that this updated version is AJAX-friendly
529    # (it does not use document.write). Also, it preserves
530    # the innerHTML of the original link rather than forcing it
531    # to be the address found in the href.
532
533    # ACHTUNG: mailto links will become simply
534    # <a href="mailto:foo@bar.com">whatever-was-inside</a> (in the final
535    # presentation to the user, after obfuscation via javascript).
536    # If there are other attributes on the <a> tag they will get tossed out.
537    # This is usually not a problem for code that
538    # comes from FCK etc. If it is a problem for you, make
539    # this method smarter. Also consider just wrapping the link in
540    # a span or div, which will not lose its class, id, etc. TBB
541
542    return preg_replace_callback("/\<a[^\>]*?href=\"mailto\:(.*?)\@(.*?)\".*?\>(.*?)\<\/a\>/is", 
543      array('aHtml', 'obfuscateMailtoInstance'),
544      $html);
545  }
546 
547  public static function obfuscateMailtoInstance($args)
548  {
549    list($user, $domain, $label) = array_slice($args, 1);
550    // We get some weird escaping problems without the trims
551    $user = trim($user);
552    $domain = trim($domain);
553    $guid = aGuid::generate();
554    $href = self::jsEscape("mailto:$user@$domain");
555    $label = self::jsEscape(trim($label));
556    // ACHTUNG: this is carefully crafted to avoid introducing extra whitespace
557                // Note: $guid was returning IDs with leading numbers. This threw validation errors so I appended a 'g-' to the ID - JB 7.22.10
558    return "<a href='#' id='g-".$guid."'></a><script type='text/javascript' charset='utf-8'>
559          var e = document.getElementById('g-".$guid."');
560      e.setAttribute('href', '$href');
561      e.innerHTML = '$label';
562      </script>";
563  }
564
565  // This is intentionally obscure for use in mailto: obfuscators.
566  // For an efficient way to pass data to javascript, use json_encode
567  static public function jsEscape($str)
568  {
569
570    $new_str = '';
571
572    for($i = 0; ($i < strlen($str)); $i++) {
573      $new_str .= '\\x' . dechex(ord(substr($str, $i, 1)));
574    }
575
576    return $new_str;
577  }
578
579  /**
580   * Just the basics: escape entities, turn URLs into links, and turn newlines into line breaks.
581   * Also turn email addresses into links (we don't obfuscate them here as that makes them
582   * harder to manipulate some more, but check out aHtml::obfuscateMailto).
583   *
584   * This function is now a wrapper around TextHelper, except for the entity escape which is
585   * not included in simple_format_text for some reason
586   *
587   * @param string $text The text you want converted to basic HTML.
588   * @return string Text with br tags and anchor tags.
589   */
590  static public function textToHtml($text)
591  {
592    sfContext::getInstance()->getConfiguration()->loadHelpers(array('Tag', 'Text'));
593        return auto_link_text(simple_format_text(htmlentities($text, ENT_COMPAT, 'UTF-8')));
594  }
595
596  // For any given HTML, returns only the img tags. If
597  // format is set to array, the result is returned as an array
598  // in which each element is an associative array with, at a
599  // minimum, a src attribute and also width, height, alt and title
600  // attributes if they were present in the tag. If format
601  // is set to html, an array of the original <img> tags
602  // is returned without further processing.
603  static public function getImages($html, $format = 'array')
604  {
605    $allowed = array_flip(array("src", "width", "height", "title", "alt"));
606    if (!preg_match_all("/\<img\s.*?\/?\>/i", $html, $matches, PREG_PATTERN_ORDER))
607    {
608      return array();
609    }
610    $images = $matches[0];
611    if (empty($images))
612    {
613      return array();
614    }
615   
616    if ($format == 'array')
617    {
618      $images_info = array();
619      foreach ($images as $image)
620      {
621        // Use a backreference to make sure we match the same
622        // type of quote beginning and ending
623        preg_match_all('/(\w+)\s*=\s*(["\'])(.*?)\2/', 
624          $image, 
625          $matches, 
626          PREG_SET_ORDER);
627        $attributes = array();
628        foreach ($matches as $attributeRaw)
629        {
630          $name = strtolower($attributeRaw[1]);
631          $value = $attributeRaw[3];
632          if (!isset($allowed[$name]))
633          {
634            continue;
635          }
636          $attributes[$name] = $value;
637        }
638        if (!isset($attributes['src']))
639        {
640          continue;
641        }
642        $images_info[] = $attributes;
643      }
644     
645      return $images_info;
646    }
647
648    return $images;
649  }
650}