friendship ended with social-app. php is my new best friend
1<?php 2 3/* 4 * This file is part of the Symfony package. 5 * 6 * (c) Fabien Potencier <fabien@symfony.com> 7 * 8 * For the full copyright and license information, please view the LICENSE 9 * file that was distributed with this source code. 10 */ 11 12namespace Symfony\Component\DomCrawler; 13 14use Symfony\Component\DomCrawler\Field\ChoiceFormField; 15use Symfony\Component\DomCrawler\Field\FormField; 16 17/** 18 * Form represents an HTML form. 19 * 20 * @author Fabien Potencier <fabien@symfony.com> 21 */ 22class Form extends Link implements \ArrayAccess 23{ 24 private \DOMElement $button; 25 private FormFieldRegistry $fields; 26 27 /** 28 * @param \DOMElement $node A \DOMElement instance 29 * @param string|null $currentUri The URI of the page where the form is embedded 30 * @param string|null $method The method to use for the link (if null, it defaults to the method defined by the form) 31 * @param string|null $baseHref The URI of the <base> used for relative links, but not for empty action 32 * 33 * @throws \LogicException if the node is not a button inside a form tag 34 */ 35 public function __construct( 36 \DOMElement $node, 37 ?string $currentUri = null, 38 ?string $method = null, 39 private ?string $baseHref = null, 40 ) { 41 parent::__construct($node, $currentUri, $method); 42 43 $this->initialize(); 44 } 45 46 /** 47 * Gets the form node associated with this form. 48 */ 49 public function getFormNode(): \DOMElement 50 { 51 return $this->node; 52 } 53 54 /** 55 * Sets the value of the fields. 56 * 57 * @param array $values An array of field values 58 * 59 * @return $this 60 */ 61 public function setValues(array $values): static 62 { 63 foreach ($values as $name => $value) { 64 $this->fields->set($name, $value); 65 } 66 67 return $this; 68 } 69 70 /** 71 * Gets the field values. 72 * 73 * The returned array does not include file fields (@see getFiles). 74 */ 75 public function getValues(): array 76 { 77 $values = []; 78 foreach ($this->fields->all() as $name => $field) { 79 if ($field->isDisabled()) { 80 continue; 81 } 82 83 if (!$field instanceof Field\FileFormField && $field->hasValue()) { 84 $values[$name] = $field->getValue(); 85 } 86 } 87 88 return $values; 89 } 90 91 /** 92 * Gets the file field values. 93 */ 94 public function getFiles(): array 95 { 96 if (!\in_array($this->getMethod(), ['POST', 'PUT', 'DELETE', 'PATCH'])) { 97 return []; 98 } 99 100 $files = []; 101 102 foreach ($this->fields->all() as $name => $field) { 103 if ($field->isDisabled()) { 104 continue; 105 } 106 107 if ($field instanceof Field\FileFormField) { 108 $files[$name] = $field->getValue(); 109 } 110 } 111 112 return $files; 113 } 114 115 /** 116 * Gets the field values as PHP. 117 * 118 * This method converts fields with the array notation 119 * (like foo[bar] to arrays) like PHP does. 120 */ 121 public function getPhpValues(): array 122 { 123 $values = []; 124 foreach ($this->getValues() as $name => $value) { 125 $qs = http_build_query([$name => $value], '', '&'); 126 if ($qs) { 127 parse_str($qs, $expandedValue); 128 $varName = substr($name, 0, \strlen(key($expandedValue))); 129 $values[] = [$varName => current($expandedValue)]; 130 } 131 } 132 133 return array_replace_recursive([], ...$values); 134 } 135 136 /** 137 * Gets the file field values as PHP. 138 * 139 * This method converts fields with the array notation 140 * (like foo[bar] to arrays) like PHP does. 141 * The returned array is consistent with the array for field values 142 * (@see getPhpValues), rather than uploaded files found in $_FILES. 143 * For a compound file field foo[bar] it will create foo[bar][name], 144 * instead of foo[name][bar] which would be found in $_FILES. 145 */ 146 public function getPhpFiles(): array 147 { 148 $values = []; 149 foreach ($this->getFiles() as $name => $value) { 150 $qs = http_build_query([$name => $value], '', '&'); 151 if ($qs) { 152 parse_str($qs, $expandedValue); 153 $varName = substr($name, 0, \strlen(key($expandedValue))); 154 155 array_walk_recursive( 156 $expandedValue, 157 function (&$value, $key) { 158 if (ctype_digit($value) && ('size' === $key || 'error' === $key)) { 159 $value = (int) $value; 160 } 161 } 162 ); 163 164 reset($expandedValue); 165 166 $values[] = [$varName => current($expandedValue)]; 167 } 168 } 169 170 return array_replace_recursive([], ...$values); 171 } 172 173 /** 174 * Gets the URI of the form. 175 * 176 * The returned URI is not the same as the form "action" attribute. 177 * This method merges the value if the method is GET to mimics 178 * browser behavior. 179 */ 180 public function getUri(): string 181 { 182 $uri = parent::getUri(); 183 184 if (!\in_array($this->getMethod(), ['POST', 'PUT', 'DELETE', 'PATCH'])) { 185 $currentParameters = []; 186 if ($query = parse_url($uri, \PHP_URL_QUERY)) { 187 parse_str($query, $currentParameters); 188 } 189 190 $queryString = http_build_query(array_merge($currentParameters, $this->getValues()), '', '&'); 191 192 $pos = strpos($uri, '?'); 193 $base = false === $pos ? $uri : substr($uri, 0, $pos); 194 $uri = rtrim($base.'?'.$queryString, '?'); 195 } 196 197 return $uri; 198 } 199 200 protected function getRawUri(): string 201 { 202 // If the form was created from a button rather than the form node, check for HTML5 action overrides 203 if ($this->button !== $this->node && $this->button->getAttribute('formaction')) { 204 return $this->button->getAttribute('formaction'); 205 } 206 207 return $this->node->getAttribute('action'); 208 } 209 210 /** 211 * Gets the form method. 212 * 213 * If no method is defined in the form, GET is returned. 214 */ 215 public function getMethod(): string 216 { 217 if (null !== $this->method) { 218 return $this->method; 219 } 220 221 // If the form was created from a button rather than the form node, check for HTML5 method override 222 if ($this->button !== $this->node && $this->button->getAttribute('formmethod')) { 223 return strtoupper($this->button->getAttribute('formmethod')); 224 } 225 226 return $this->node->getAttribute('method') ? strtoupper($this->node->getAttribute('method')) : 'GET'; 227 } 228 229 /** 230 * Gets the form name. 231 * 232 * If no name is defined on the form, an empty string is returned. 233 */ 234 public function getName(): string 235 { 236 return $this->node->getAttribute('name'); 237 } 238 239 /** 240 * Returns true if the named field exists. 241 */ 242 public function has(string $name): bool 243 { 244 return $this->fields->has($name); 245 } 246 247 /** 248 * Removes a field from the form. 249 */ 250 public function remove(string $name): void 251 { 252 $this->fields->remove($name); 253 } 254 255 /** 256 * Gets a named field. 257 * 258 * @return FormField|FormField[]|FormField[][] 259 * 260 * @throws \InvalidArgumentException When field is not present in this form 261 */ 262 public function get(string $name): FormField|array 263 { 264 return $this->fields->get($name); 265 } 266 267 /** 268 * Sets a named field. 269 */ 270 public function set(FormField $field): void 271 { 272 $this->fields->add($field); 273 } 274 275 /** 276 * Gets all fields. 277 * 278 * @return FormField[] 279 */ 280 public function all(): array 281 { 282 return $this->fields->all(); 283 } 284 285 /** 286 * Returns true if the named field exists. 287 * 288 * @param string $name The field name 289 */ 290 public function offsetExists(mixed $name): bool 291 { 292 return $this->has($name); 293 } 294 295 /** 296 * Gets the value of a field. 297 * 298 * @param string $name The field name 299 * 300 * @return FormField|FormField[]|FormField[][] 301 * 302 * @throws \InvalidArgumentException if the field does not exist 303 */ 304 public function offsetGet(mixed $name): FormField|array 305 { 306 return $this->fields->get($name); 307 } 308 309 /** 310 * Sets the value of a field. 311 * 312 * @param string $name The field name 313 * @param string|array $value The value of the field 314 * 315 * @throws \InvalidArgumentException if the field does not exist 316 */ 317 public function offsetSet(mixed $name, mixed $value): void 318 { 319 $this->fields->set($name, $value); 320 } 321 322 /** 323 * Removes a field from the form. 324 * 325 * @param string $name The field name 326 */ 327 public function offsetUnset(mixed $name): void 328 { 329 $this->fields->remove($name); 330 } 331 332 /** 333 * Disables validation. 334 * 335 * @return $this 336 */ 337 public function disableValidation(): static 338 { 339 foreach ($this->fields->all() as $field) { 340 if ($field instanceof ChoiceFormField) { 341 $field->disableValidation(); 342 } 343 } 344 345 return $this; 346 } 347 348 /** 349 * Sets the node for the form. 350 * 351 * Expects a 'submit' button \DOMElement and finds the corresponding form element, or the form element itself. 352 * 353 * @throws \LogicException If given node is not a button or input or does not have a form ancestor 354 */ 355 protected function setNode(\DOMElement $node): void 356 { 357 $this->button = $node; 358 if ('button' === $node->nodeName || ('input' === $node->nodeName && \in_array(strtolower($node->getAttribute('type')), ['submit', 'button', 'image']))) { 359 if ($node->hasAttribute('form')) { 360 // if the node has the HTML5-compliant 'form' attribute, use it 361 $formId = $node->getAttribute('form'); 362 $form = $node->ownerDocument->getElementById($formId); 363 if (null === $form) { 364 throw new \LogicException(\sprintf('The selected node has an invalid form attribute (%s).', $formId)); 365 } 366 $this->node = $form; 367 368 return; 369 } 370 // we loop until we find a form ancestor 371 do { 372 if (null === $node = $node->parentNode) { 373 throw new \LogicException('The selected node does not have a form ancestor.'); 374 } 375 } while ('form' !== $node->nodeName); 376 } elseif ('form' !== $node->nodeName) { 377 throw new \LogicException(\sprintf('Unable to submit on a "%s" tag.', $node->nodeName)); 378 } 379 380 $this->node = $node; 381 } 382 383 /** 384 * Adds form elements related to this form. 385 * 386 * Creates an internal copy of the submitted 'button' element and 387 * the form node or the entire document depending on whether we need 388 * to find non-descendant elements through HTML5 'form' attribute. 389 */ 390 private function initialize(): void 391 { 392 $this->fields = new FormFieldRegistry(); 393 394 $xpath = new \DOMXPath($this->node->ownerDocument); 395 396 // add submitted button if it has a valid name 397 if ('form' !== $this->button->nodeName && $this->button->hasAttribute('name') && $this->button->getAttribute('name')) { 398 if ('input' == $this->button->nodeName && 'image' == strtolower($this->button->getAttribute('type'))) { 399 $name = $this->button->getAttribute('name'); 400 $this->button->setAttribute('value', '0'); 401 402 // temporarily change the name of the input node for the x coordinate 403 $this->button->setAttribute('name', $name.'.x'); 404 $this->set(new Field\InputFormField($this->button)); 405 406 // temporarily change the name of the input node for the y coordinate 407 $this->button->setAttribute('name', $name.'.y'); 408 $this->set(new Field\InputFormField($this->button)); 409 410 // restore the original name of the input node 411 $this->button->setAttribute('name', $name); 412 } else { 413 $this->set(new Field\InputFormField($this->button)); 414 } 415 } 416 417 // find form elements corresponding to the current form 418 if ($this->node->hasAttribute('id')) { 419 // corresponding elements are either descendants or have a matching HTML5 form attribute 420 $formId = Crawler::xpathLiteral($this->node->getAttribute('id')); 421 422 $fieldNodes = $xpath->query(\sprintf('( descendant::input[@form=%s] | descendant::button[@form=%1$s] | descendant::textarea[@form=%1$s] | descendant::select[@form=%1$s] | //form[@id=%1$s]//input[not(@form)] | //form[@id=%1$s]//button[not(@form)] | //form[@id=%1$s]//textarea[not(@form)] | //form[@id=%1$s]//select[not(@form)] )[( not(ancestor::template) or ancestor::turbo-stream )]', $formId)); 423 } else { 424 // do the xpath query with $this->node as the context node, to only find descendant elements 425 // however, descendant elements with form attribute are not part of this form 426 $fieldNodes = $xpath->query('( descendant::input[not(@form)] | descendant::button[not(@form)] | descendant::textarea[not(@form)] | descendant::select[not(@form)] )[( not(ancestor::template) or ancestor::turbo-stream )]', $this->node); 427 } 428 429 foreach ($fieldNodes as $node) { 430 $this->addField($node); 431 } 432 433 if ($this->baseHref && '' !== $this->node->getAttribute('action')) { 434 $this->currentUri = $this->baseHref; 435 } 436 } 437 438 private function addField(\DOMElement $node): void 439 { 440 if (!$node->hasAttribute('name') || !$node->getAttribute('name')) { 441 return; 442 } 443 444 $nodeName = $node->nodeName; 445 if ('select' == $nodeName || 'input' == $nodeName && 'checkbox' == strtolower($node->getAttribute('type'))) { 446 $this->set(new ChoiceFormField($node)); 447 } elseif ('input' == $nodeName && 'radio' == strtolower($node->getAttribute('type'))) { 448 // there may be other fields with the same name that are no choice 449 // fields already registered (see https://github.com/symfony/symfony/issues/11689) 450 if ($this->has($node->getAttribute('name')) && $this->get($node->getAttribute('name')) instanceof ChoiceFormField) { 451 $this->get($node->getAttribute('name'))->addChoice($node); 452 } else { 453 $this->set(new ChoiceFormField($node)); 454 } 455 } elseif ('input' == $nodeName && 'file' == strtolower($node->getAttribute('type'))) { 456 $this->set(new Field\FileFormField($node)); 457 } elseif ('input' == $nodeName && !\in_array(strtolower($node->getAttribute('type')), ['submit', 'button', 'image'])) { 458 $this->set(new Field\InputFormField($node)); 459 } elseif ('textarea' == $nodeName) { 460 $this->set(new Field\TextareaFormField($node)); 461 } 462 } 463}