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