root/trunk/src/filter/AgaviFormPopulationFilter.class.php

Revision 2550, 34.6 KB (checked in by david, 5 months ago)

merge [2519:2549/branches/0.11]

  • 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($container->getRequestMethod(), $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      throw new AgaviParseException($emsg);
195    }
196
197    libxml_clear_errors();
198    libxml_use_internal_errors($luie);
199
200    $properXhtml = false;
201    foreach($this->xpath->query(sprintf('//%1$shead/%1$smeta', $this->xmlnsPrefix)) as $meta) {
202      if(strtolower($meta->getAttribute('http-equiv')) == 'content-type') {
203        if($this->doc->encoding === null) {
204          // media-type = type "/" subtype *( ";" parameter ), says http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7
205          if(preg_match('/;\s*charset=(")?(?P<charset>.+?(?(-2)(?=(?<!\\\\)")|(?=[;\s])))(?(-2)")/i', $meta->getAttribute('content'), $matches)) {
206            $this->doc->encoding = $matches['charset'];
207          } else {
208            $this->doc->encoding = self::ENCODING_UTF_8;
209          }
210        }
211        if(strpos($meta->getAttribute('content'), 'application/xhtml+xml') !== false) {
212          $properXhtml = true;
213        }
214        break;
215      }
216    }
217
218    if(($encoding = $cfg['force_encoding']) === false) {
219      if($this->doc->actualEncoding) {
220        $encoding = $this->doc->actualEncoding;
221      } elseif($this->doc->encoding) {
222        $encoding = $this->doc->encoding;
223      } else {
224        $encoding = $this->doc->encoding = self::ENCODING_UTF_8;
225      }
226    } else {
227      $this->doc->encoding = $encoding;
228    }
229    $encoding = strtolower($encoding);
230    $utf8 = $encoding == self::ENCODING_UTF_8;
231    if(!$utf8 && $encoding != self::ENCODING_ISO_8859_1 && !function_exists('iconv')) {
232      throw new AgaviException('No iconv module available, input encoding "' . $encoding . '" cannot be handled.');
233    }
234
235    $base = $this->xpath->query(sprintf('/%1$shtml/%1$shead/%1$sbase[@href]', $this->xmlnsPrefix));
236    if($base->length) {
237      $baseHref = $base->item(0)->getAttribute('href');
238    } else {
239      $baseHref = $rq->getUrl();
240    }
241    $baseHref = substr($baseHref, 0, strrpos($baseHref, '/') + 1);
242
243    $forms = array();
244    if(is_array($populate)) {
245      $query = array();
246      foreach(array_keys($populate) as $id) {
247        if(is_string($id)) {
248          $query[] = sprintf('@id="%s"', $id);
249        }
250      }
251      if($query) {
252        $forms = $this->xpath->query(sprintf('//%1$sform[%2$s]', $this->xmlnsPrefix, implode(' or ', $query)));
253      }
254    } else {
255      $forms = $this->xpath->query(sprintf('//%1$sform[@action]', $this->xmlnsPrefix));
256    }
257
258    // an array of all validation incidents; errors inserted for fields or multiple fields will be removed in here
259    $allIncidents = $vm->getIncidents();
260
261    foreach($forms as $form) {
262      if($populate instanceof AgaviParameterHolder) {
263        $action = preg_replace('/#.*$/', '', trim($form->getAttribute('action')));
264        if(!(
265          $action == $rurl ||
266          (strpos($action, '/') === 0 && preg_replace(array('#/\./#', '#/\.$#', '#[^\./]+/\.\.(/|\z)#', '#/{2,}#'), array('/', '/', '', '/'), $action) == $ruri) ||
267          $baseHref . preg_replace(array('#/\./#', '#/\.$#', '#[^\./]+/\.\.(/|\z)#', '#/{2,}#'), array('/', '/', '', '/'), $action) == $rurl
268        )) {
269          continue;
270        }
271        $p = $populate;
272      } else {
273        if(isset($populate[$form->getAttribute('id')])) {
274          if($populate[$form->getAttribute('id')] instanceof AgaviParameterHolder) {
275            $p = $populate[$form->getAttribute('id')];
276          } elseif($populate[$form->getAttribute('id')] === true) {
277            $p = $rq->getRequestData();
278          } else {
279            continue;
280          }
281        } else {
282          continue;
283        }
284      }
285
286      // our array for remembering foo[] field's indices
287      $remember = array();
288
289      // build the XPath query
290      $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);
291      if($cfg['include_hidden_inputs']) {
292        $query .= ' or @type="hidden"';
293      }
294      $query .= ')]';
295      foreach($this->xpath->query($query, $form) as $element) {
296
297        $pname = $name = $element->getAttribute('name');
298
299        $multiple = $element->nodeName == 'select' && $element->hasAttribute('multiple');
300
301        $checkValue = false;
302        if($element->getAttribute('type') == 'checkbox' || $element->getAttribute('type') == 'radio') {
303          if(($pos = strpos($pname, '[]')) && ($pos + 2 != strlen($pname))) {
304            // foo[][3] checkboxes etc not possible, [] must occur only once and at the end
305            continue;
306          } elseif($pos !== false) {
307            $checkValue = true;
308            $pname = substr($pname, 0, $pos);
309          }
310        }
311        if(preg_match_all('/([^\[]+)?(?:\[([^\]]*)\])/', $pname, $matches)) {
312          $pname = $matches[1][0];
313
314          if($multiple) {
315            $count = count($matches[2]) - 1;
316          } else {
317            $count = count($matches[2]);
318          }
319          for($i = 0; $i < $count; $i++) {
320            $val = $matches[2][$i];
321            if((string)$matches[2][$i] === (string)(int)$matches[2][$i]) {
322              $val = (int)$val;
323            }
324            if(!isset($remember[$pname])) {
325              $add = ($val !== "" ? $val : 0);
326              if(is_int($add)) {
327                $remember[$pname] = $add;
328              }
329            } else {
330              if($val !== "") {
331                $add = $val;
332                if(is_int($val) && $add > $remember[$pname]) {
333                  $remember[$pname] = $add;
334                }
335              } else {
336                $add = ++$remember[$pname];
337              }
338            }
339            $pname .= '[' . $add . ']';
340          }
341        }
342
343        if(!$utf8) {
344          $pname = $this->fromUtf8($pname, $encoding);
345        }
346
347        if($skip !== null && preg_match($skip, $pname . ($checkValue ? '[]' : ''))) {
348          // skip field
349          continue;
350        }
351
352        // there's an error with the element's name in the request? good. let's give the baby a class!
353        if($vm->isFieldFailed($pname)) {
354          // a collection of all elements that need an error class
355          $errorClassElements = array();
356          // the element itself of course
357          $errorClassElements[] = $element;
358          // all implicit labels
359          foreach($this->xpath->query(sprintf('ancestor::%1$slabel[not(@for)]', $this->xmlnsPrefix), $element) as $label) {
360            $errorClassElements[] = $label;
361          }
362          // and all explicit labels
363          if(($id = $element->getAttribute('id')) != '') {
364            foreach($this->xpath->query(sprintf('descendant::%1$slabel[@for="%2$s"]', $this->xmlnsPrefix, $id), $form) as $label) {
365              $errorClassElements[] = $label;
366            }
367          }
368
369          // now loop over all those elements and assign the class
370          foreach($errorClassElements as $errorClassElement) {
371            // go over all the elements in the error class map
372            foreach($cfg['error_class_map'] as $xpathExpression => $errorClassName) {
373              // evaluate each xpath expression
374              $errorClassResults = $this->xpath->query(AgaviToolkit::expandVariables($xpathExpression, array('htmlnsPrefix' => $this->xmlnsPrefix)), $errorClassElement);
375              if($errorClassResults && $errorClassResults->length) {
376                // 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!
377                foreach($errorClassResults as $errorClassDestinationElement) {
378                  $errorClassDestinationElement->setAttribute('class', preg_replace('/\s*$/', ' ' . $errorClassName, $errorClassDestinationElement->getAttribute('class')));
379                }
380               
381                // and break the foreach, our expression matched after all - no need to look further
382                break;
383              }
384            }
385          }
386
387          // up next: the error messages
388          $fieldIncidents = array();
389          $multiFieldIncidents = array();
390          foreach($vm->getFieldIncidents($pname) as $incident) {
391            if(($incidentKey = array_search($incident, $allIncidents, true)) !== false) {
392              if(count($incident->getFields()) > 1) {
393                $multiFieldIncidents[] = $incident;
394              } else {
395                $fieldIncidents[] = $incident;
396              }
397              // remove it from the list of all incidents
398              unset($allIncidents[$incidentKey]);
399            }
400          }
401          // 1) insert error messages that are specific to this field
402          if(!$this->insertErrorMessages($element, $fieldErrorMessageRules, $fieldIncidents)) {
403            $allIncidents = array_merge($allIncidents, $fieldIncidents);
404          }
405          // 2) insert error messages that belong to multiple fields (including this one), if that message was not inserted before
406          if(!$this->insertErrorMessages($element, $multiFieldErrorMessageRules, $multiFieldIncidents)) {
407            $allIncidents = array_merge($allIncidents, $multiFieldIncidents);
408          }
409        }
410
411        $value = $p->getParameter($pname);
412
413        if(is_array($value) && !($element->nodeName == 'select' || $checkValue)) {
414          // name didn't match exactly. skip.
415          continue;
416        }
417
418        if(is_bool($value)) {
419          $value = (string)(int)$value;
420        } elseif(!$utf8) {
421          $value = $this->toUtf8($value, $encoding);
422        } else {
423          if(is_array($value)) {
424            $value = array_map('strval', $value);
425          } else {
426            $value = (string) $value;
427          }
428        }
429
430        if($element->nodeName == 'input') {
431
432          if(!$element->hasAttribute('type') || $element->getAttribute('type') == 'text' || $element->getAttribute('type') == 'hidden') {
433
434            // text inputs
435            $element->removeAttribute('value');
436            if($p->hasParameter($pname)) {
437              $element->setAttribute('value', $value);
438            }
439
440          } elseif($element->getAttribute('type') == 'checkbox' || $element->getAttribute('type') == 'radio') {
441
442            // checkboxes and radios
443            $element->removeAttribute('checked');
444
445            if($checkValue && is_array($value)) {
446              $eValue = $element->getAttribute('value');
447              if(!$utf8) {
448                $eValue = $this->fromUtf8($eValue, $encoding);
449              }
450              if(!in_array($eValue, $value)) {
451                continue;
452              } else {
453                $element->setAttribute('checked', 'checked');
454              }
455            } elseif($p->hasParameter($pname) && (($element->hasAttribute('value') && $element->getAttribute('value') == $value) || (!$element->hasAttribute('value') && $p->getParameter($pname)))) {
456              $element->setAttribute('checked', 'checked');
457            }
458
459          } elseif($element->getAttribute('type') == 'password') {
460
461            // passwords
462            $element->removeAttribute('value');
463            if($cfg['include_password_inputs'] && $p->hasParameter($pname)) {
464              $element->setAttribute('value', $value);
465            }
466          }
467
468        } elseif($element->nodeName == 'select') {
469          // select elements
470          // yes, we still use XPath because there could be OPTGROUPs
471          foreach($this->xpath->query(sprintf('descendant::%1$soption', $this->xmlnsPrefix), $element) as $option) {
472            $option->removeAttribute('selected');
473            if($p->hasParameter($pname) && ($option->getAttribute('value') === $value || ($multiple && is_array($value) && in_array($option->getAttribute('value'), $value)))) {
474              $option->setAttribute('selected', 'selected');
475            }
476          }
477
478        } elseif($element->nodeName == 'textarea') {
479
480          // textareas
481          foreach($element->childNodes as $cn) {
482            // remove all child nodes (= text nodes)
483            $element->removeChild($cn);
484          }
485          // append a new text node
486          if($xhtml && $properXhtml) {
487            $element->appendChild($this->doc->createCDATASection($value));
488          } else {
489            $element->appendChild($this->doc->createTextNode($value));
490          }
491        }
492
493      }
494
495      // now output the remaining incidents
496      if($this->insertErrorMessages($form, $errorMessageRules, $allIncidents)) {
497        $allIncidents = array();
498      }
499    }
500
501    $rq->setAttribute('orphaned_errors', $allIncidents, 'org.agavi.filter.FormPopulationFilter');
502
503    if($xhtml) {
504      $firstError = null;
505
506      if(!$cfg['parse_xhtml_as_xml']) {
507        // workaround for a bug in dom or something that results in two xmlns attributes being generated for the <html> element
508        // attributes must be removed and created again
509        // and don't change the DOMNodeList in the foreach!
510        $remove = array();
511        $reset = array();
512        foreach($this->doc->documentElement->attributes as $attribute) {
513          // remember to remove the node
514          $remove[] = $attribute;
515          // not for the xmlns at