functions.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. <?php
  2. declare(strict_types=1);
  3. namespace Sabre\HTTP;
  4. /**
  5. * A collection of useful helpers for parsing or generating various HTTP
  6. * headers.
  7. *
  8. * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
  9. * @author Evert Pot (http://evertpot.com/)
  10. * @license http://sabre.io/license/ Modified BSD License
  11. */
  12. /**
  13. * Parses a HTTP date-string.
  14. *
  15. * This method returns false if the date is invalid.
  16. *
  17. * The following formats are supported:
  18. * Sun, 06 Nov 1994 08:49:37 GMT ; IMF-fixdate
  19. * Sunday, 06-Nov-94 08:49:37 GMT ; obsolete RFC 850 format
  20. * Sun Nov 6 08:49:37 1994 ; ANSI C's asctime() format
  21. *
  22. * See:
  23. * http://tools.ietf.org/html/rfc7231#section-7.1.1.1
  24. *
  25. * @return bool|\DateTime
  26. */
  27. function parseDate(string $dateString)
  28. {
  29. // Only the format is checked, valid ranges are checked by strtotime below
  30. $month = '(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)';
  31. $weekday = '(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)';
  32. $wkday = '(Mon|Tue|Wed|Thu|Fri|Sat|Sun)';
  33. $time = '([0-1]\d|2[0-3])(\:[0-5]\d){2}';
  34. $date3 = $month.' ([12]\d|3[01]| [1-9])';
  35. $date2 = '(0[1-9]|[12]\d|3[01])\-'.$month.'\-\d{2}';
  36. // 4-digit year cannot begin with 0 - unix timestamp begins in 1970
  37. $date1 = '(0[1-9]|[12]\d|3[01]) '.$month.' [1-9]\d{3}';
  38. // ANSI C's asctime() format
  39. // 4-digit year cannot begin with 0 - unix timestamp begins in 1970
  40. $asctime_date = $wkday.' '.$date3.' '.$time.' [1-9]\d{3}';
  41. // RFC 850, obsoleted by RFC 1036
  42. $rfc850_date = $weekday.', '.$date2.' '.$time.' GMT';
  43. // RFC 822, updated by RFC 1123
  44. $rfc1123_date = $wkday.', '.$date1.' '.$time.' GMT';
  45. // allowed date formats by RFC 2616
  46. $HTTP_date = "($rfc1123_date|$rfc850_date|$asctime_date)";
  47. // allow for space around the string and strip it
  48. $dateString = trim($dateString, ' ');
  49. if (!preg_match('/^'.$HTTP_date.'$/', $dateString)) {
  50. return false;
  51. }
  52. // append implicit GMT timezone to ANSI C time format
  53. if (false === strpos($dateString, ' GMT')) {
  54. $dateString .= ' GMT';
  55. }
  56. try {
  57. return new \DateTime($dateString, new \DateTimeZone('UTC'));
  58. } catch (\Exception $e) {
  59. return false;
  60. }
  61. }
  62. /**
  63. * Transforms a DateTime object to a valid HTTP/1.1 Date header value.
  64. */
  65. function toDate(\DateTime $dateTime): string
  66. {
  67. // We need to clone it, as we don't want to affect the existing
  68. // DateTime.
  69. $dateTime = clone $dateTime;
  70. $dateTime->setTimezone(new \DateTimeZone('GMT'));
  71. return $dateTime->format('D, d M Y H:i:s \G\M\T');
  72. }
  73. /**
  74. * This function can be used to aid with content negotiation.
  75. *
  76. * It takes 2 arguments, the $acceptHeaderValue, which usually comes from
  77. * an Accept header, and $availableOptions, which contains an array of
  78. * items that the server can support.
  79. *
  80. * The result of this function will be the 'best possible option'. If no
  81. * best possible option could be found, null is returned.
  82. *
  83. * When it's null you can according to the spec either return a default, or
  84. * you can choose to emit 406 Not Acceptable.
  85. *
  86. * The method also accepts sending 'null' for the $acceptHeaderValue,
  87. * implying that no accept header was sent.
  88. *
  89. * @param string|null $acceptHeaderValue
  90. *
  91. * @return string|null
  92. */
  93. function negotiateContentType($acceptHeaderValue, array $availableOptions)
  94. {
  95. if (!$acceptHeaderValue) {
  96. // Grabbing the first in the list.
  97. return reset($availableOptions);
  98. }
  99. $proposals = array_map(
  100. 'Sabre\HTTP\parseMimeType',
  101. explode(',', $acceptHeaderValue)
  102. );
  103. // Ensuring array keys are reset.
  104. $availableOptions = array_values($availableOptions);
  105. $options = array_map(
  106. 'Sabre\HTTP\parseMimeType',
  107. $availableOptions
  108. );
  109. $lastQuality = 0;
  110. $lastSpecificity = 0;
  111. $lastOptionIndex = 0;
  112. $lastChoice = null;
  113. foreach ($proposals as $proposal) {
  114. // Ignoring broken values.
  115. if (null === $proposal) {
  116. continue;
  117. }
  118. // If the quality is lower we don't have to bother comparing.
  119. if ($proposal['quality'] < $lastQuality) {
  120. continue;
  121. }
  122. foreach ($options as $optionIndex => $option) {
  123. if ('*' !== $proposal['type'] && $proposal['type'] !== $option['type']) {
  124. // no match on type.
  125. continue;
  126. }
  127. if ('*' !== $proposal['subType'] && $proposal['subType'] !== $option['subType']) {
  128. // no match on subtype.
  129. continue;
  130. }
  131. // Any parameters appearing on the options must appear on
  132. // proposals.
  133. foreach ($option['parameters'] as $paramName => $paramValue) {
  134. if (!array_key_exists($paramName, $proposal['parameters'])) {
  135. continue 2;
  136. }
  137. if ($paramValue !== $proposal['parameters'][$paramName]) {
  138. continue 2;
  139. }
  140. }
  141. // If we got here, we have a match on parameters, type and
  142. // subtype. We need to calculate a score for how specific the
  143. // match was.
  144. $specificity =
  145. ('*' !== $proposal['type'] ? 20 : 0) +
  146. ('*' !== $proposal['subType'] ? 10 : 0) +
  147. count($option['parameters']);
  148. // Does this entry win?
  149. if (
  150. ($proposal['quality'] > $lastQuality)
  151. || ($proposal['quality'] === $lastQuality && $specificity > $lastSpecificity)
  152. || ($proposal['quality'] === $lastQuality && $specificity === $lastSpecificity && $optionIndex < $lastOptionIndex)
  153. ) {
  154. $lastQuality = $proposal['quality'];
  155. $lastSpecificity = $specificity;
  156. $lastOptionIndex = $optionIndex;
  157. $lastChoice = $availableOptions[$optionIndex];
  158. }
  159. }
  160. }
  161. return $lastChoice;
  162. }
  163. /**
  164. * Parses the Prefer header, as defined in RFC7240.
  165. *
  166. * Input can be given as a single header value (string) or multiple headers
  167. * (array of string).
  168. *
  169. * This method will return a key->value array with the various Prefer
  170. * parameters.
  171. *
  172. * Prefer: return=minimal will result in:
  173. *
  174. * [ 'return' => 'minimal' ]
  175. *
  176. * Prefer: foo, wait=10 will result in:
  177. *
  178. * [ 'foo' => true, 'wait' => '10']
  179. *
  180. * This method also supports the formats from older drafts of RFC7240, and
  181. * it will automatically map them to the new values, as the older values
  182. * are still pretty common.
  183. *
  184. * Parameters are currently discarded. There's no known prefer value that
  185. * uses them.
  186. *
  187. * @param string|string[] $input
  188. */
  189. function parsePrefer($input): array
  190. {
  191. $token = '[!#$%&\'*+\-.^_`~A-Za-z0-9]+';
  192. // Work in progress
  193. $word = '(?: [a-zA-Z0-9]+ | "[a-zA-Z0-9]*" )';
  194. $regex = <<<REGEX
  195. /
  196. ^
  197. (?<name> $token) # Prefer property name
  198. \s* # Optional space
  199. (?: = \s* # Prefer property value
  200. (?<value> $word)
  201. )?
  202. (?: \s* ; (?: .*))? # Prefer parameters (ignored)
  203. $
  204. /x
  205. REGEX;
  206. $output = [];
  207. foreach (getHeaderValues($input) as $value) {
  208. if (!preg_match($regex, $value, $matches)) {
  209. // Ignore
  210. continue;
  211. }
  212. // Mapping old values to their new counterparts
  213. switch ($matches['name']) {
  214. case 'return-asynch':
  215. $output['respond-async'] = true;
  216. break;
  217. case 'return-representation':
  218. $output['return'] = 'representation';
  219. break;
  220. case 'return-minimal':
  221. $output['return'] = 'minimal';
  222. break;
  223. case 'strict':
  224. $output['handling'] = 'strict';
  225. break;
  226. case 'lenient':
  227. $output['handling'] = 'lenient';
  228. break;
  229. default:
  230. if (isset($matches['value'])) {
  231. $value = trim($matches['value'], '"');
  232. } else {
  233. $value = true;
  234. }
  235. $output[strtolower($matches['name'])] = empty($value) ? true : $value;
  236. break;
  237. }
  238. }
  239. return $output;
  240. }
  241. /**
  242. * This method splits up headers into all their individual values.
  243. *
  244. * A HTTP header may have more than one header, such as this:
  245. * Cache-Control: private, no-store
  246. *
  247. * Header values are always split with a comma.
  248. *
  249. * You can pass either a string, or an array. The resulting value is always
  250. * an array with each spliced value.
  251. *
  252. * If the second headers argument is set, this value will simply be merged
  253. * in. This makes it quicker to merge an old list of values with a new set.
  254. *
  255. * @param string|string[] $values
  256. * @param string|string[] $values2
  257. */
  258. function getHeaderValues($values, $values2 = null): array
  259. {
  260. $values = (array) $values;
  261. if ($values2) {
  262. $values = array_merge($values, (array) $values2);
  263. }
  264. $result = [];
  265. foreach ($values as $l1) {
  266. foreach (explode(',', $l1) as $l2) {
  267. $result[] = trim($l2);
  268. }
  269. }
  270. return $result;
  271. }
  272. /**
  273. * Parses a mime-type and splits it into:.
  274. *
  275. * 1. type
  276. * 2. subtype
  277. * 3. quality
  278. * 4. parameters
  279. */
  280. function parseMimeType(string $str): array
  281. {
  282. $parameters = [];
  283. // If no q= parameter appears, then quality = 1.
  284. $quality = 1;
  285. $parts = explode(';', $str);
  286. // The first part is the mime-type.
  287. $mimeType = trim(array_shift($parts));
  288. if ('*' === $mimeType) {
  289. $mimeType = '*/*';
  290. }
  291. $mimeType = explode('/', $mimeType);
  292. if (2 !== count($mimeType)) {
  293. // Illegal value
  294. var_dump($mimeType);
  295. exit;
  296. // throw new InvalidArgumentException('Not a valid mime-type: '.$str);
  297. }
  298. list($type, $subType) = $mimeType;
  299. foreach ($parts as $part) {
  300. $part = trim($part);
  301. if (strpos($part, '=')) {
  302. list($partName, $partValue) =
  303. explode('=', $part, 2);
  304. } else {
  305. $partName = $part;
  306. $partValue = null;
  307. }
  308. // The quality parameter, if it appears, also marks the end of
  309. // the parameter list. Anything after the q= counts as an
  310. // 'accept extension' and could introduce new semantics in
  311. // content-negotiation.
  312. if ('q' !== $partName) {
  313. $parameters[$partName] = $part;
  314. } else {
  315. $quality = (float) $partValue;
  316. break; // Stop parsing parts
  317. }
  318. }
  319. return [
  320. 'type' => $type,
  321. 'subType' => $subType,
  322. 'quality' => $quality,
  323. 'parameters' => $parameters,
  324. ];
  325. }
  326. /**
  327. * Encodes the path of a url.
  328. *
  329. * slashes (/) are treated as path-separators.
  330. */
  331. function encodePath(string $path): string
  332. {
  333. return preg_replace_callback('/([^A-Za-z0-9_\-\.~\(\)\/:@])/', function ($match) {
  334. return '%'.sprintf('%02x', ord($match[0]));
  335. }, $path);
  336. }
  337. /**
  338. * Encodes a 1 segment of a path.
  339. *
  340. * Slashes are considered part of the name, and are encoded as %2f
  341. */
  342. function encodePathSegment(string $pathSegment): string
  343. {
  344. return preg_replace_callback('/([^A-Za-z0-9_\-\.~\(\):@])/', function ($match) {
  345. return '%'.sprintf('%02x', ord($match[0]));
  346. }, $pathSegment);
  347. }
  348. /**
  349. * Decodes a url-encoded path.
  350. */
  351. function decodePath(string $path): string
  352. {
  353. return decodePathSegment($path);
  354. }
  355. /**
  356. * Decodes a url-encoded path segment.
  357. */
  358. function decodePathSegment(string $path): string
  359. {
  360. $path = rawurldecode($path);
  361. if (!mb_check_encoding($path, 'UTF-8') && mb_check_encoding($path, 'ISO-8859-1')) {
  362. $path = mb_convert_encoding($path, 'UTF-8', 'ISO-8859-1');
  363. }
  364. return $path;
  365. }