root/branches/0.11/src/filter/AgaviFormPopulationFilter.class.php

Revision 2685, 35.4 KB (checked in by david, 3 months ago)

Allow ignoring of (X)HTML parse errors in FPF (through "ignore_parse_errors" parameter), closes #613

  • Property svn:keywords set to Id
Line 
1<?php
2
3// +---------------------------------------------------------------------------+
4// | This file is part of the Agavi package.                                   |
5// | Copyright (c) 2005-2008 the Agavi Project.                                |
6// |                                                                           |
7// | For the full copyright and license information, please view the LICENSE   |
8// | file that was distributed with this source code. You can also view the    |
9// | LICENSE file online at http://www.agavi.org/LICENSE.txt                   |
10// |   vi: set noexpandtab:                                                    |
11// |   Local Variables:                                                        |
12// |   indent-tabs-mode: t                                                     |
13// |   End:                                                                    |
14// +---------------------------------------------------------------------------+
15
16/**
17 * AgaviFormPopulationFilter automatically populates a form that is re-posted,
18 * which usually happens when a View::INPUT is returned again after a POST
19 * request because an error occured during validation.
20 * That means that developers don't have to fill in request parameters into
21 * form elements in their templates anymore. Text inputs, selects, radios, they
22 * all get set to the value the user selected before submitting the form.
23 * If you would like to set default values, you still have to do that in your
24 * template. The filter will recognize this situation and automatically remove
25 * the default value you assigned after receiving a POST request.
26 * This filter only works with POST requests, and compares the form's URL and
27 * the requested URL to decide if it's appropriate to fill in a specific form
28 * it encounters while processing the output document sent back to the browser.
29 * Since this form is executed very late in the process, it works independently
30 * of any template language.
31 *
32 * @package    agavi
33 * @subpackage filter
34 *
35 * @author     David Zülke <dz@bitxtender.com>
36 * @copyright  Authors
37 * @copyright  The Agavi Project
38 *
39 * @since      0.11.0
40 *
41 * @version    $Id$
42 */
43class AgaviFormPopulationFilter extends AgaviFilter implements AgaviIGlobalFilter, AgaviIActionFilter
44{
45  const ENCODING_UTF_8 = 'utf-8';
46
47  const ENCODING_ISO_8859_1 = 'iso-8859-1';
48
49  /**
50   * @var        DOMDocument Our (X)HTML document.
51   */
52  protected $doc;
53
54  /**
55   * @var        DOMXPath Our XPath instance for the document.
56   */
57  protected $xpath;
58
59  /**
60   * @var        string The XML NS prefix we're working on with XPath, including
61   *                    a colon (or empty string if document has no NS).
62   */
63  protected $xmlnsPrefix = '';
64
65  /**
66   * Execute this filter.
67   *
68   * @param      AgaviFilterChain        The filter chain.
69   * @param      AgaviExecutionContainer The current execution container.
70   *
71   * @throws     <b>AgaviFilterException</b> If an error occurs during execution.
72   *
73   * @author     David Zülke <dz@bitxtender.com>
74   * @since      0.11.0
75   */
76  public function executeOnce(AgaviFilterChain $filterChain, AgaviExecutionContainer $container)
77  {
78    $filterChain->execute($container);
79    $response = $container->getResponse();
80
81    if(!$response->isContentMutable() || !($output = $response->getContent())) {
82      return;
83    }
84
85    $rq = $this->getContext()->getRequest();
86
87    $vm = $container->getValidationManager();
88
89    $cfg = $rq->getAttributes('org.agavi.filter.FormPopulationFilter');
90
91    $ot = $response->getOutputType();
92
93    if(is_array($cfg['output_types']) && !in_array($ot->getName(), $cfg['output_types'])) {
94      return;
95    }
96
97    if(is_array($cfg['populate']) || $cfg['populate'] instanceof AgaviParameterHolder) {
98      $populate = $cfg['populate'];
99    } elseif($cfg['populate'] === true || (in_array($rq->getMethod(), $cfg['methods']) && $cfg['populate'] !== false)) {
100      $populate = $rq->getRequestData();
101    } else {
102      return;
103    }
104
105    $skip = null;
106    if($cfg['skip'] instanceof AgaviParameterHolder) {
107      $cfg['skip'] = $cfg['skip']->getParameters();
108    } elseif($cfg['skip'] !== null && !is_array($cfg['skip'])) {
109      $cfg['skip'] = null;
110    }
111    if($cfg['skip'] !== null && count($cfg['skip'])) {
112      $skip = '/(\A' . str_replace('\[\]', '\[[^\]]*\]', implode('|\A', array_map('preg_quote', $cfg['skip']))) . ')/';
113    }
114
115    if($cfg['force_request_uri'] !== false) {
116      $ruri = $cfg['force_request_uri'];
117    } else {
118      $ruri = $rq->getRequestUri();
119    }
120    if($cfg['force_request_url'] !== false) {
121      $rurl = $cfg['force_request_url'];
122    } else {
123      $rurl = $rq->getUrl();
124    }
125
126    $errorMessageRules = array();
127    if(isset($cfg['error_messages']) && is_array($cfg['error_messages'])) {
128      $errorMessageRules = $cfg['error_messages'];
129    }
130    $fieldErrorMessageRules = $errorMessageRules;
131    if(isset($cfg['field_error_messages']) && is_array($cfg['field_error_messages']) && count($cfg['field_error_messages'])) {
132      $fieldErrorMessageRules = $cfg['field_error_messages'];
133    }
134    $multiFieldErrorMessageRules = $fieldErrorMessageRules;
135    if(isset($cfg['multi_field_error_messages']) && is_array($cfg['multi_field_error_messages']) && count($cfg['multi_field_error_messages'])) {
136      $multiFieldErrorMessageRules = $cfg['multi_field_error_messages'];
137    }
138
139    $luie = libxml_use_internal_errors(true);
140    libxml_clear_errors();
141
142    $this->doc = new DOMDocument();
143
144    $this->doc->substituteEntities = $cfg['dom_substitute_entities'];
145    $this->doc->resolveExternals   = $cfg['dom_resolve_externals'];
146    $this->doc->validateOnParse    = $cfg['dom_validate_on_parse'];
147    $this->doc->preserveWhiteSpace = $cfg['dom_preserve_white_space'];
148    $this->doc->formatOutput       = $cfg['dom_format_output'];
149
150    $xhtml = (preg_match('/<!DOCTYPE[^>]+XHTML[^>]+/', $output) > 0 && strtolower($cfg['force_output_mode']) != 'html') || strtolower($cfg['force_output_mode']) == 'xhtml';
151
152    $hasXmlProlog = false;
153    if($xhtml && preg_match('/^<\?xml[^\?]*\?>/', $output)) {
154      $hasXmlProlog = true;
155    } elseif($xhtml && preg_match('/charset=(.+)\s*$/i', $ot->getParameter('http_headers[Content-Type]'), $matches)) {
156      // add an XML prolog with the char encoding, works around issues with ISO-8859-1 etc
157      $output = "<?xml version='1.0' encoding='" . $matches[1] . "' ?>\n" . $output;
158    }
159
160    if($xhtml && $cfg['parse_xhtml_as_xml']) {
161      $this->doc->loadXML($output);
162      $this->xpath = new DomXPath($this->doc);
163      if($this->doc->documentElement && $this->doc->documentElement->namespaceURI) {
164        $this->xpath->registerNamespace('html', $this->doc->documentElement->namespaceURI);
165        $this->xmlnsPrefix = 'html:';
166      } else {
167        $this->xmlnsPrefix = '';
168      }
169    } else {
170      $this->doc->loadHTML($output);
171      $this->xpath = new DomXPath($this->doc);
172      $this->xmlnsPrefix = '';
173    }
174
175    if(libxml_get_last_error() !== false) {
176      $errors = array();
177      foreach(libxml_get_errors() as $error) {
178        $errors[] = sprintf("Line %d: %s", $error->line, $error->message);
179      }
180      libxml_clear_errors();
181      libxml_use_internal_errors($luie);
182      $emsg = sprintf(
183        'Form Population Filter could not parse the document due to the following error%s: ' . "\n\n%s",
184        count($errors) > 1 ? 's' : '',
185        implode("\n", $errors)
186      );
187      if(AgaviConfig::get('core.use_logging') && $cfg['log_parse_errors']) {
188        $lmsg = $emsg . "\n\nResponse content:\n\n" . $response->getContent();
189        $lm = $this->context->getLoggerManager();
190        $mc = $lm->getDefaultMessageClass();
191        $m = new $mc($lmsg, $cfg['logging_severity']);
192        $lm->log($m, $cfg['logging_logger']);
193      }
194     
195      // all in all, that didn't go so well. let's see if we should just silently abort instead of throwin an exception
196      if($cfg['ignore_parse_errors']) {
197        return;
198      }
199     
200      throw new AgaviParseException($emsg);
201    }
202
203    libxml_clear_errors();
204    libxml_use_internal_errors($luie);
205
206    $properXhtml = false;
207    foreach($this->xpath->query(sprintf('//%1$shead/%1$smeta', $this->xmlnsPrefix)) as $meta) {
208      if(strtolower($meta->getAttribute('http-equiv')) == 'content-type') {
209        if($this->doc->encoding === null) {
210          // media-type = type "/" subtype *( ";" parameter ), says http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7
211          if(preg_match('/;\s*charset=(")?(?P<charset>.+?(?(-2)(?=(?<!\\\\)")|(?=[;\s])))(?(-2)")/i', $meta->getAttribute('content'), $matches)) {
212            $this->doc->encoding = $matches['charset'];
213          } else {
214            $this->doc->encoding = self::ENCODING_UTF_8;
215          }
216        }
217        if(strpos($meta->getAttribute('content'), 'application/xhtml+xml') !== false) {
218          $properXhtml = true;
219        }
220        break;
221      }
222    }
223
224    if(($encoding = $cfg['force_encoding']) === false) {
225      if($this->doc->actualEncoding) {
226        $encoding = $this->doc->actualEncoding;
227      } elseif($this->doc->encoding) {
228        $encoding = $this->doc->encoding;
229      } else {
230        $encoding = $this->doc->encoding = self::ENCODING_UTF_8;
231      }
232    } else {
233      $this->doc->encoding = $encoding;
234    }
235    $encoding = strtolower($encoding);
236    $utf8 = $encoding == self::ENCODING_UTF_8;
237    if(!$utf8 && $encoding != self::ENCODING_ISO_8859_1 && !function_exists('iconv')) {
238      throw new AgaviException('No iconv module available, input encoding "' . $encoding . '" cannot be handled.');
239    }
240
241    $base = $this->xpath->query(sprintf('/%1$shtml/%1$shead/%1$sbase[@href]', $this->xmlnsPrefix));
242    if($base->length) {
243      $baseHref = $base->item(0)->getAttribute('href');
244    } else {
245      $baseHref = $rq->getUrl();
246    }
247    $baseHref = substr($baseHref, 0, strrpos($baseHref, '/') + 1);
248
249    $forms = array();
250    if(is_array($populate)) {
251      $query = array();
252      foreach(array_keys($populate) as $id) {
253        if(is_string($id)) {
254          $query[] = sprintf('@id="%s"', $id);
255        }
256      }
257      if($query) {
258        $forms = $this->xpath->query(sprintf('//%1$sform[%2$s]', $this->xmlnsPrefix, implode(' or ', $query)));
259      }
260    } else {
261      $forms = $this->xpath->query(sprintf('//%1$sform[@action]', $this->xmlnsPrefix));
262    }
263
264    // an array of all validation incidents; errors inserted for fields or multiple fields will be removed in here
265    $allIncidents = $vm->getIncidents();
266
267    foreach($forms as $form) {
268      if($populate instanceof AgaviParameterHolder) {
269        $action = preg_replace('/#.*$/', '', trim($form->getAttribute('action')));
270        if(!(
271          $action == $rurl ||
272          (strpos($action, '/') === 0 && preg_replace(array('#/\./#', '#/\.$#', '#[^\./]+/\.\.(/|\z)#', '#/{2,}#'), array('/', '/', '', '/'), $action) == $ruri) ||
273          $baseHref . preg_replace(array('#/\./#', '#/\.$#', '#[^\./]+/\.\.(/|\z)#', '#/{2,}#'), array('/', '/', '', '/'), $action) == $rurl
274        )) {
275          continue;
276        }
277        $p = $populate;
278      } else {
279        if(isset($populate[$form->getAttribute('id')])) {
280          if($populate[$form->getAttribute('id')] instanceof AgaviParameterHolder) {
281            $p = $populate[$form->getAttribute('id')];
282          } elseif($populate[$form->getAttribute('id')] === true) {
283            $p = $rq->getRequestData();
284          } else {
285            continue;
286          }
287        } else {
288          continue;
289        }
290      }
291
292      // our array for remembering foo[] field's indices
293      $remember = array();
294
295      // build the XPath query
296      $query = sprintf('descendant::%1$stextarea[@name] | descendant::%1$sselect[@name] | descendant::%1$sinput[@name and (not(@type) or @type="text" or (@type="checkbox" and not(contains(@name, "[]"))) or (@type="checkbox" and contains(@name, "[]") and @value) or @type="radio" or @type="password" or @type="file"', $this->xmlnsPrefix);
297      if($cfg['include_hidden_inputs']) {
298        $query .= ' or @type="hidden"';
299      }
300      $query .= ')]';
301      foreach($this->xpath->query($query, $form) as $element) {
302
303        $pname = $name = $element->getAttribute('name');
304
305        $multiple = $element->nodeName == 'select' && $element->hasAttribute('multiple');
306
307        $checkValue = false;
308        if($element->getAttribute('type') == 'checkbox' || $element->getAttribute('type') == 'radio') {
309          if(($pos = strpos($pname, '[]')) && ($pos + 2 != strlen($pname))) {
310            // foo[][3] checkboxes etc not possible, [] must occur only once and at the end
311            continue;
312          } elseif($pos !== false) {
313            $checkValue = true;
314            $pname = substr($pname, 0, $pos);
315          }
316        }
317        if(preg_match_all('/([^\[]+)?(?:\[([^\]]*)\])/', $pname, $matches)) {
318          $pname = $matches[1][0];
319
320          if($multiple) {
321            $count = count($matches[2]) - 1;
322          } else {
323            $count = count($matches[2]);
324          }
325          for($i = 0; $i < $count; $i++) {
326            $val = $matches[2][$i];
327            if((string)$matches[2][$i] === (string)(int)$matches[2][$i]) {
328              $val = (int)$val;
329            }
330            if(!isset($remember[$pname])) {
331              $add = ($val !== "" ? $val : 0);
332              if(is_int($add)) {
333                $remember[$pname] = $add;
334              }
335            } else {
336              if($val !== "") {
337                $add = $val;
338                if(is_int($val) && $add > $remember[$pname]) {
339                  $remember[$pname] = $add;
340                }
341              } else {
342                $add = ++$remember[$pname];
343              }
344            }
345            $pname .= '[' . $add . ']';
346          }
347        }
348
349        if(!$utf8) {
350          $pname = $this->fromUtf8($pname, $encoding);
351        }
352
353        if($skip !== null && preg_match($skip, $pname . ($checkValue ? '[]' : ''))) {
354          // skip field
355          continue;
356        }
357
358        // there's an error with the element's name in the request? good. let's give the baby a class!
359        if($vm->isFieldFailed($pname)) {
360          // a collection of all elements that need an error class
361          $errorClassElements = array();
362          // the element itself of course
363          $errorClassElements[] = $element;
364          // all implicit labels
365          foreach($this->xpath->query(sprintf('ancestor::%1$slabel[not(@for)]', $this->xmlnsPrefix), $element) as $label) {
366            $errorClassElements[] = $label;
367          }
368          // and all explicit labels
369          if(($id = $element->getAttribute('id')) != '') {
370            foreach($this->xpath->query(sprintf('descendant::%1$slabel[@for="%2$s"]', $this->xmlnsPrefix, $id), $form) as $label) {
371              $errorClassElements[] = $label;
372            }
373          }
374
375          // now loop over all those elements and assign the class
376          foreach($errorClassElements as $errorClassElement) {
377            // go over all the elements in the error class map
378            foreach($cfg['error_class_map'] as $xpathExpression => $errorClassName) {
379              // evaluate each xpath expression
380              $errorClassResults = $this->xpath->query(AgaviToolkit::expandVariables($xpathExpression, array('htmlnsPrefix' => $this->xmlnsPrefix)), $errorClassElement);
381              if($errorClassResults && $errorClassResults->length) {
382                // we have results. the xpath expressions are used to locale the actual elements we set the error class on - doesn't necessarily have to be the erroneous element or the label!
383                foreach($errorClassResults as $errorClassDestinationElement) {
384                  $errorClassDestinationElement->setAttribute('class', preg_replace('/\s*$/', ' ' . $errorClassName, $errorClassDestinationElement->getAttribute('class')));
385                }
386               
387                // and break the foreach, our expression matched after all - no need to look further
388                break;
389              }
390            }
391          }
392
393          // up next: the error messages
394          $fieldIncidents = array();
395          $multiFieldIncidents = array();
396          foreach($vm->getFieldIncidents($pname) as $incident) {
397            if(($incidentKey = array_search($incident, $allIncidents, true)) !== false) {
398              if(count($incident->getFields()) > 1) {
399                $multiFieldIncidents[] = $incident;
400              } else {
401                $fieldIncidents[] = $incident;
402              }
403              // remove it from the list of all incidents
404              unset($allIncidents[$incidentKey]);
405            }
406          }
407          // 1) insert error messages that are specific to this field
408          if(!$this->insertErrorMessages($element, $fieldErrorMessageRules, $fieldIncidents)) {
409            $allIncidents = array_merge($allIncidents, $fieldIncidents);
410          }
411          // 2) insert error messages that belong to multiple fields (including this one), if that message was not inserted before
412          if(!$this->insertErrorMessages($element, $multiFieldErrorMessageRules, $multiFieldIncidents)) {
413            $allIncidents = array_merge($allIncidents, $multiFieldIncidents);
414          }
415        }
416
417        $value = $p->getParameter($pname);
418
419        if(is_array($value) && !($element->nodeName == 'select' || $checkValue)) {
420          // name didn't match exactly. skip.
421          continue;
422        }
423
424        if(is_bool($value)) {
425          $value = (string)(int)$value;
426        } elseif(!$utf8) {
427          $value = $this->toUtf8($value, $encoding);
428        } else {
429          if(is_array($value)) {
430            $value = array_map('strval', $value);
431          } else {
432            $value = (string) $value;
433          }
434        }
435
436        if($element->nodeName == 'input') {
437
438          if(!$element->hasAttribute('type') || $element->getAttribute('type') == 'text' || $element->getAttribute('type') == 'hidden') {
439
440            // text inputs
441            $element->removeAttribute('value');
442            if($p->hasParameter($pname)) {
443              $element->setAttribute('value', $value);
444            }
445
446          } elseif($element->getAttribute('type') == 'checkbox' || $element->getAttribute('type') == 'radio') {
447
448            // checkboxes and radios
449            $element->removeAttribute('checked');
450
451            if($checkValue && is_array($value)) {
452              $eValue = $element->getAttribute('value');
453              if(!$utf8) {
454                $eValue = $this->fromUtf8($eValue, $encoding);
455              }
456              if(!in_array($eValue, $value)) {
457                continue;
458              } else {
459                $element->setAttribute('checked', 'checked');
460              }
461            } elseif($p->hasParameter($pname) && (($element->hasAttribute('value') && $element->getAttribute('value') == $value) || (!$element->hasAttribute('value') && $p->getParameter($pname)))) {
462              $element->setAttribute('checked', 'checked');
463            }
464
465          } elseif($element->getAttribute('type') == 'password') {
466
467            // passwords
468            $element->removeAttribute('value');
469            if($cfg['include_password_inputs'] && $p->hasParameter($pname)) {
470              $element->setAttribute('value', $value);
471            }
472          }
473
474        } elseif($element->nodeName == 'select') {
475          // select elements
476          // yes, we still use XPath because there could be OPTGROUPs
477          foreach($this->xpath->query(sprintf('descendant::%1$soption', $this->xmlnsPrefix), $element) as $option) {
478            $option->removeAttribute('selected');
479            if($p->hasParameter($pname) && ($option->getAttribute('value') === $value || ($multiple && is_array($value) && in_array($option->getAttribute('value'), $value)))) {
480              $option->setAttribute('selected', 'selected');
481            }
482          }
483
484        } elseif($element->nodeName == 'textarea') {
485
486          // textareas
487          foreach($element->childNodes as $cn) {
488            // remove all child nodes (= text nodes)
489            $element->removeChild($cn);
490          }
491          // append a new text node
492          if($xhtml && $properXhtml) {
493            $element->appendChild($this->doc->createCDATASection($value));
494          } else {
495            $element->appendChild($this->doc->createTextNode($value));
496          }
497        }
498
499      }
500
501      // now output the remaining incidents
502      if($this->insertErrorMessages($form, $errorMessageRules, $allIncidents)) {
503        $allIncidents = array();
504      }
505    }
506
507    $rq->setAttribute('orphaned_errors', $allIncidents, 'org.agavi.filter.FormPopulationFilter');
508
509    if($xhtml) {
510      $fiveTwo = version_compare(PHP_VERSION, '5.2', 'ge');
511      $firstError = null;
512
513      if(!$cfg['parse_xhtml_as_xml']) {
514        // workaround for a bug in dom or something that results in two xmlns attributes being generated for the <html> element
515        // attributes