AcceptHeader.php 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. <?php
  2. /*
  3. * This file is part of the Symfony package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Component\HttpFoundation;
  11. // Help opcache.preload discover always-needed symbols
  12. class_exists(AcceptHeaderItem::class);
  13. /**
  14. * Represents an Accept-* header.
  15. *
  16. * An accept header is compound with a list of items,
  17. * sorted by descending quality.
  18. *
  19. * @author Jean-François Simon <contact@jfsimon.fr>
  20. */
  21. class AcceptHeader
  22. {
  23. /**
  24. * @var array<string, AcceptHeaderItem>
  25. */
  26. private array $items = [];
  27. private bool $sorted = true;
  28. /**
  29. * @param AcceptHeaderItem[] $items
  30. */
  31. public function __construct(array $items)
  32. {
  33. foreach ($items as $item) {
  34. $this->add($item);
  35. }
  36. }
  37. /**
  38. * Builds an AcceptHeader instance from a string.
  39. */
  40. public static function fromString(?string $headerValue): self
  41. {
  42. $items = [];
  43. foreach (HeaderUtils::split($headerValue ?? '', ',;=') as $i => $parts) {
  44. $part = array_shift($parts);
  45. $item = new AcceptHeaderItem($part[0], HeaderUtils::combine($parts));
  46. $items[] = $item->setIndex($i);
  47. }
  48. return new self($items);
  49. }
  50. /**
  51. * Returns header value's string representation.
  52. */
  53. public function __toString(): string
  54. {
  55. return implode(',', $this->items);
  56. }
  57. /**
  58. * Tests if header has given value.
  59. */
  60. public function has(string $value): bool
  61. {
  62. $canonicalKey = $this->getCanonicalKey(AcceptHeaderItem::fromString($value));
  63. return isset($this->items[$canonicalKey]);
  64. }
  65. /**
  66. * Returns given value's item, if exists.
  67. */
  68. public function get(string $value): ?AcceptHeaderItem
  69. {
  70. $queryItem = AcceptHeaderItem::fromString($value.';q=1');
  71. $canonicalKey = $this->getCanonicalKey($queryItem);
  72. if (isset($this->items[$canonicalKey])) {
  73. return $this->items[$canonicalKey];
  74. }
  75. // Collect and filter matching candidates
  76. if (!$candidates = array_filter($this->items, fn (AcceptHeaderItem $item) => $this->matches($item, $queryItem))) {
  77. return null;
  78. }
  79. usort(
  80. $candidates,
  81. fn ($a, $b) => $this->getSpecificity($b, $queryItem) <=> $this->getSpecificity($a, $queryItem) // Descending specificity
  82. ?: $b->getQuality() <=> $a->getQuality() // Descending quality
  83. ?: $a->getIndex() <=> $b->getIndex() // Ascending index (stability)
  84. );
  85. return reset($candidates);
  86. }
  87. /**
  88. * Adds an item.
  89. *
  90. * @return $this
  91. */
  92. public function add(AcceptHeaderItem $item): static
  93. {
  94. $this->items[$this->getCanonicalKey($item)] = $item;
  95. $this->sorted = false;
  96. return $this;
  97. }
  98. /**
  99. * Returns all items.
  100. *
  101. * @return AcceptHeaderItem[]
  102. */
  103. public function all(): array
  104. {
  105. $this->sort();
  106. return $this->items;
  107. }
  108. /**
  109. * Filters items on their value using given regex.
  110. */
  111. public function filter(string $pattern): self
  112. {
  113. return new self(array_filter($this->items, static fn ($item) => preg_match($pattern, $item->getValue())));
  114. }
  115. /**
  116. * Returns first item.
  117. */
  118. public function first(): ?AcceptHeaderItem
  119. {
  120. $this->sort();
  121. return $this->items ? reset($this->items) : null;
  122. }
  123. /**
  124. * Sorts items by descending quality.
  125. */
  126. private function sort(): void
  127. {
  128. if (!$this->sorted) {
  129. uasort($this->items, static fn ($a, $b) => $b->getQuality() <=> $a->getQuality() ?: $a->getIndex() <=> $b->getIndex());
  130. $this->sorted = true;
  131. }
  132. }
  133. /**
  134. * Generates the canonical key for storing/retrieving an item.
  135. */
  136. private function getCanonicalKey(AcceptHeaderItem $item): string
  137. {
  138. $parts = [];
  139. // Normalize and sort attributes for consistent key generation
  140. $attributes = $this->getMediaParams($item);
  141. ksort($attributes);
  142. foreach ($attributes as $name => $value) {
  143. if (null === $value) {
  144. $parts[] = $name; // Flag parameter (e.g., "flowed")
  145. continue;
  146. }
  147. // Quote values containing spaces, commas, semicolons, or equals per RFC 9110
  148. // This handles cases like 'format="value with space"' or similar.
  149. $quotedValue = \is_string($value) && preg_match('/[\s;,=]/', $value) ? '"'.addcslashes($value, '"\\').'"' : $value;
  150. $parts[] = $name.'='.$quotedValue;
  151. }
  152. return $item->getValue().($parts ? ';'.implode(';', $parts) : '');
  153. }
  154. /**
  155. * Checks if a given header item (range) matches a queried item (value).
  156. *
  157. * @param AcceptHeaderItem $rangeItem The item from the Accept header (e.g., text/*;format=flowed)
  158. * @param AcceptHeaderItem $queryItem The item being queried (e.g., text/plain;format=flowed;charset=utf-8)
  159. */
  160. private function matches(AcceptHeaderItem $rangeItem, AcceptHeaderItem $queryItem): bool
  161. {
  162. $rangeValue = strtolower($rangeItem->getValue());
  163. $queryValue = strtolower($queryItem->getValue());
  164. // Handle universal wildcard ranges
  165. if ('*' === $rangeValue || '*/*' === $rangeValue) {
  166. return $this->rangeParametersMatch($rangeItem, $queryItem);
  167. }
  168. // Queries for '*' only match wildcard ranges (handled above)
  169. if ('*' === $queryValue) {
  170. return false;
  171. }
  172. // Ensure media vs. non-media consistency
  173. $isQueryMedia = str_contains($queryValue, '/');
  174. $isRangeMedia = str_contains($rangeValue, '/');
  175. if ($isQueryMedia !== $isRangeMedia) {
  176. return false;
  177. }
  178. // Non-media: exact match only (wildcards handled above)
  179. if (!$isQueryMedia) {
  180. return $rangeValue === $queryValue && $this->rangeParametersMatch($rangeItem, $queryItem);
  181. }
  182. // Media type: type/subtype with wildcards
  183. [$queryType, $querySubtype] = explode('/', $queryValue, 2);
  184. [$rangeType, $rangeSubtype] = explode('/', $rangeValue, 2) + [1 => '*'];
  185. if ('*' !== $rangeType && $rangeType !== $queryType) {
  186. return false;
  187. }
  188. if ('*' !== $rangeSubtype && $rangeSubtype !== $querySubtype) {
  189. return false;
  190. }
  191. // Parameters must match
  192. return $this->rangeParametersMatch($rangeItem, $queryItem);
  193. }
  194. /**
  195. * Checks if the parameters of a range item are satisfied by the query item.
  196. *
  197. * Parameters are case-insensitive; range params must be a subset of query params.
  198. */
  199. private function rangeParametersMatch(AcceptHeaderItem $rangeItem, AcceptHeaderItem $queryItem): bool
  200. {
  201. $queryAttributes = $this->getMediaParams($queryItem);
  202. $rangeAttributes = $this->getMediaParams($rangeItem);
  203. foreach ($rangeAttributes as $name => $rangeValue) {
  204. if (!\array_key_exists($name, $queryAttributes)) {
  205. return false; // Missing required param
  206. }
  207. $queryValue = $queryAttributes[$name];
  208. if (null === $rangeValue) {
  209. return null === $queryValue; // Both flags or neither
  210. }
  211. if (null === $queryValue || strtolower($queryValue) !== strtolower($rangeValue)) {
  212. return false;
  213. }
  214. }
  215. return true;
  216. }
  217. /**
  218. * Calculates a specificity score for sorting: media precision + param count.
  219. */
  220. private function getSpecificity(AcceptHeaderItem $item, AcceptHeaderItem $queryItem): int
  221. {
  222. $rangeValue = strtolower($item->getValue());
  223. $queryValue = strtolower($queryItem->getValue());
  224. $paramCount = \count($this->getMediaParams($item));
  225. $isQueryMedia = str_contains($queryValue, '/');
  226. $isRangeMedia = str_contains($rangeValue, '/');
  227. if (!$isQueryMedia && !$isRangeMedia) {
  228. return ('*' !== $rangeValue ? 2000 : 1000) + $paramCount;
  229. }
  230. [$rangeType, $rangeSubtype] = explode('/', $rangeValue, 2) + [1 => '*'];
  231. $specificity = match (true) {
  232. '*' !== $rangeSubtype => 3000, // Exact subtype (text/plain)
  233. '*' !== $rangeType => 2000, // Type wildcard (text/*)
  234. default => 1000, // Full wildcard (*/* or *)
  235. };
  236. return $specificity + $paramCount;
  237. }
  238. /**
  239. * Returns normalized attributes: keys lowercased, excluding 'q'.
  240. */
  241. private function getMediaParams(AcceptHeaderItem $item): array
  242. {
  243. $attributes = array_change_key_case($item->getAttributes(), \CASE_LOWER);
  244. unset($attributes['q']);
  245. return $attributes;
  246. }
  247. }