5dcfb0e235253f4e17f6f78422a24ae4bf77618b
[yaffs-website] / src / Uri.php
1 <?php
2 /**
3  * @see       https://github.com/zendframework/zend-diactoros for the canonical source repository
4  * @copyright Copyright (c) 2015-2017 Zend Technologies USA Inc. (http://www.zend.com)
5  * @license   https://github.com/zendframework/zend-diactoros/blob/master/LICENSE.md New BSD License
6  */
7
8 namespace Zend\Diactoros;
9
10 use InvalidArgumentException;
11 use Psr\Http\Message\UriInterface;
12
13 use function array_key_exists;
14 use function array_keys;
15 use function count;
16 use function explode;
17 use function get_class;
18 use function gettype;
19 use function implode;
20 use function is_numeric;
21 use function is_object;
22 use function is_string;
23 use function ltrim;
24 use function parse_url;
25 use function preg_replace;
26 use function preg_replace_callback;
27 use function rawurlencode;
28 use function sprintf;
29 use function strpos;
30 use function strtolower;
31 use function substr;
32
33 /**
34  * Implementation of Psr\Http\UriInterface.
35  *
36  * Provides a value object representing a URI for HTTP requests.
37  *
38  * Instances of this class  are considered immutable; all methods that
39  * might change state are implemented such that they retain the internal
40  * state of the current instance and return a new instance that contains the
41  * changed state.
42  */
43 class Uri implements UriInterface
44 {
45     /**
46      * Sub-delimiters used in user info, query strings and fragments.
47      *
48      * @const string
49      */
50     const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;=';
51
52     /**
53      * Unreserved characters used in user info, paths, query strings, and fragments.
54      *
55      * @const string
56      */
57     const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~\pL';
58
59     /**
60      * @var int[] Array indexed by valid scheme names to their corresponding ports.
61      */
62     protected $allowedSchemes = [
63         'http'  => 80,
64         'https' => 443,
65     ];
66
67     /**
68      * @var string
69      */
70     private $scheme = '';
71
72     /**
73      * @var string
74      */
75     private $userInfo = '';
76
77     /**
78      * @var string
79      */
80     private $host = '';
81
82     /**
83      * @var int
84      */
85     private $port;
86
87     /**
88      * @var string
89      */
90     private $path = '';
91
92     /**
93      * @var string
94      */
95     private $query = '';
96
97     /**
98      * @var string
99      */
100     private $fragment = '';
101
102     /**
103      * generated uri string cache
104      * @var string|null
105      */
106     private $uriString;
107
108     /**
109      * @param string $uri
110      * @throws InvalidArgumentException on non-string $uri argument
111      */
112     public function __construct($uri = '')
113     {
114         if ('' === $uri) {
115             return;
116         }
117
118         if (! is_string($uri)) {
119             throw new InvalidArgumentException(sprintf(
120                 'URI passed to constructor must be a string; received "%s"',
121                 is_object($uri) ? get_class($uri) : gettype($uri)
122             ));
123         }
124
125         $this->parseUri($uri);
126     }
127
128     /**
129      * Operations to perform on clone.
130      *
131      * Since cloning usually is for purposes of mutation, we reset the
132      * $uriString property so it will be re-calculated.
133      */
134     public function __clone()
135     {
136         $this->uriString = null;
137     }
138
139     /**
140      * {@inheritdoc}
141      */
142     public function __toString()
143     {
144         if (null !== $this->uriString) {
145             return $this->uriString;
146         }
147
148         $this->uriString = static::createUriString(
149             $this->scheme,
150             $this->getAuthority(),
151             $this->getPath(), // Absolute URIs should use a "/" for an empty path
152             $this->query,
153             $this->fragment
154         );
155
156         return $this->uriString;
157     }
158
159     /**
160      * {@inheritdoc}
161      */
162     public function getScheme()
163     {
164         return $this->scheme;
165     }
166
167     /**
168      * {@inheritdoc}
169      */
170     public function getAuthority()
171     {
172         if ('' === $this->host) {
173             return '';
174         }
175
176         $authority = $this->host;
177         if ('' !== $this->userInfo) {
178             $authority = $this->userInfo . '@' . $authority;
179         }
180
181         if ($this->isNonStandardPort($this->scheme, $this->host, $this->port)) {
182             $authority .= ':' . $this->port;
183         }
184
185         return $authority;
186     }
187
188     /**
189      * Retrieve the user-info part of the URI.
190      *
191      * This value is percent-encoded, per RFC 3986 Section 3.2.1.
192      *
193      * {@inheritdoc}
194      */
195     public function getUserInfo()
196     {
197         return $this->userInfo;
198     }
199
200     /**
201      * {@inheritdoc}
202      */
203     public function getHost()
204     {
205         return $this->host;
206     }
207
208     /**
209      * {@inheritdoc}
210      */
211     public function getPort()
212     {
213         return $this->isNonStandardPort($this->scheme, $this->host, $this->port)
214             ? $this->port
215             : null;
216     }
217
218     /**
219      * {@inheritdoc}
220      */
221     public function getPath()
222     {
223         return $this->path;
224     }
225
226     /**
227      * {@inheritdoc}
228      */
229     public function getQuery()
230     {
231         return $this->query;
232     }
233
234     /**
235      * {@inheritdoc}
236      */
237     public function getFragment()
238     {
239         return $this->fragment;
240     }
241
242     /**
243      * {@inheritdoc}
244      */
245     public function withScheme($scheme)
246     {
247         if (! is_string($scheme)) {
248             throw new InvalidArgumentException(sprintf(
249                 '%s expects a string argument; received %s',
250                 __METHOD__,
251                 is_object($scheme) ? get_class($scheme) : gettype($scheme)
252             ));
253         }
254
255         $scheme = $this->filterScheme($scheme);
256
257         if ($scheme === $this->scheme) {
258             // Do nothing if no change was made.
259             return $this;
260         }
261
262         $new = clone $this;
263         $new->scheme = $scheme;
264
265         return $new;
266     }
267
268     /**
269      * Create and return a new instance containing the provided user credentials.
270      *
271      * The value will be percent-encoded in the new instance, but with measures
272      * taken to prevent double-encoding.
273      *
274      * {@inheritdoc}
275      */
276     public function withUserInfo($user, $password = null)
277     {
278         if (! is_string($user)) {
279             throw new InvalidArgumentException(sprintf(
280                 '%s expects a string user argument; received %s',
281                 __METHOD__,
282                 is_object($user) ? get_class($user) : gettype($user)
283             ));
284         }
285         if (null !== $password && ! is_string($password)) {
286             throw new InvalidArgumentException(sprintf(
287                 '%s expects a string or null password argument; received %s',
288                 __METHOD__,
289                 is_object($password) ? get_class($password) : gettype($password)
290             ));
291         }
292
293         $info = $this->filterUserInfoPart($user);
294         if (null !== $password) {
295             $info .= ':' . $this->filterUserInfoPart($password);
296         }
297
298         if ($info === $this->userInfo) {
299             // Do nothing if no change was made.
300             return $this;
301         }
302
303         $new = clone $this;
304         $new->userInfo = $info;
305
306         return $new;
307     }
308
309     /**
310      * {@inheritdoc}
311      */
312     public function withHost($host)
313     {
314         if (! is_string($host)) {
315             throw new InvalidArgumentException(sprintf(
316                 '%s expects a string argument; received %s',
317                 __METHOD__,
318                 is_object($host) ? get_class($host) : gettype($host)
319             ));
320         }
321
322         if ($host === $this->host) {
323             // Do nothing if no change was made.
324             return $this;
325         }
326
327         $new = clone $this;
328         $new->host = strtolower($host);
329
330         return $new;
331     }
332
333     /**
334      * {@inheritdoc}
335      */
336     public function withPort($port)
337     {
338         if ($port !== null) {
339             if (! is_numeric($port) || is_float($port)) {
340                 throw new InvalidArgumentException(sprintf(
341                     'Invalid port "%s" specified; must be an integer, an integer string, or null',
342                     is_object($port) ? get_class($port) : gettype($port)
343                 ));
344             }
345
346             $port = (int) $port;
347         }
348
349         if ($port === $this->port) {
350             // Do nothing if no change was made.
351             return $this;
352         }
353
354         if ($port !== null && ($port < 1 || $port > 65535)) {
355             throw new InvalidArgumentException(sprintf(
356                 'Invalid port "%d" specified; must be a valid TCP/UDP port',
357                 $port
358             ));
359         }
360
361         $new = clone $this;
362         $new->port = $port;
363
364         return $new;
365     }
366
367     /**
368      * {@inheritdoc}
369      */
370     public function withPath($path)
371     {
372         if (! is_string($path)) {
373             throw new InvalidArgumentException(
374                 'Invalid path provided; must be a string'
375             );
376         }
377
378         if (strpos($path, '?') !== false) {
379             throw new InvalidArgumentException(
380                 'Invalid path provided; must not contain a query string'
381             );
382         }
383
384         if (strpos($path, '#') !== false) {
385             throw new InvalidArgumentException(
386                 'Invalid path provided; must not contain a URI fragment'
387             );
388         }
389
390         $path = $this->filterPath($path);
391
392         if ($path === $this->path) {
393             // Do nothing if no change was made.
394             return $this;
395         }
396
397         $new = clone $this;
398         $new->path = $path;
399
400         return $new;
401     }
402
403     /**
404      * {@inheritdoc}
405      */
406     public function withQuery($query)
407     {
408         if (! is_string($query)) {
409             throw new InvalidArgumentException(
410                 'Query string must be a string'
411             );
412         }
413
414         if (strpos($query, '#') !== false) {
415             throw new InvalidArgumentException(
416                 'Query string must not include a URI fragment'
417             );
418         }
419
420         $query = $this->filterQuery($query);
421
422         if ($query === $this->query) {
423             // Do nothing if no change was made.
424             return $this;
425         }
426
427         $new = clone $this;
428         $new->query = $query;
429
430         return $new;
431     }
432
433     /**
434      * {@inheritdoc}
435      */
436     public function withFragment($fragment)
437     {
438         if (! is_string($fragment)) {
439             throw new InvalidArgumentException(sprintf(
440                 '%s expects a string argument; received %s',
441                 __METHOD__,
442                 is_object($fragment) ? get_class($fragment) : gettype($fragment)
443             ));
444         }
445
446         $fragment = $this->filterFragment($fragment);
447
448         if ($fragment === $this->fragment) {
449             // Do nothing if no change was made.
450             return $this;
451         }
452
453         $new = clone $this;
454         $new->fragment = $fragment;
455
456         return $new;
457     }
458
459     /**
460      * Parse a URI into its parts, and set the properties
461      *
462      * @param string $uri
463      */
464     private function parseUri($uri)
465     {
466         $parts = parse_url($uri);
467
468         if (false === $parts) {
469             throw new \InvalidArgumentException(
470                 'The source URI string appears to be malformed'
471             );
472         }
473
474         $this->scheme    = isset($parts['scheme']) ? $this->filterScheme($parts['scheme']) : '';
475         $this->userInfo  = isset($parts['user']) ? $this->filterUserInfoPart($parts['user']) : '';
476         $this->host      = isset($parts['host']) ? strtolower($parts['host']) : '';
477         $this->port      = isset($parts['port']) ? $parts['port'] : null;
478         $this->path      = isset($parts['path']) ? $this->filterPath($parts['path']) : '';
479         $this->query     = isset($parts['query']) ? $this->filterQuery($parts['query']) : '';
480         $this->fragment  = isset($parts['fragment']) ? $this->filterFragment($parts['fragment']) : '';
481
482         if (isset($parts['pass'])) {
483             $this->userInfo .= ':' . $parts['pass'];
484         }
485     }
486
487     /**
488      * Create a URI string from its various parts
489      *
490      * @param string $scheme
491      * @param string $authority
492      * @param string $path
493      * @param string $query
494      * @param string $fragment
495      * @return string
496      */
497     private static function createUriString($scheme, $authority, $path, $query, $fragment)
498     {
499         $uri = '';
500
501         if ('' !== $scheme) {
502             $uri .= sprintf('%s:', $scheme);
503         }
504
505         if ('' !== $authority) {
506             $uri .= '//' . $authority;
507         }
508
509         if ('' !== $path && '/' !== substr($path, 0, 1)) {
510             $path = '/' . $path;
511         }
512
513         $uri .= $path;
514
515
516         if ('' !== $query) {
517             $uri .= sprintf('?%s', $query);
518         }
519
520         if ('' !== $fragment) {
521             $uri .= sprintf('#%s', $fragment);
522         }
523
524         return $uri;
525     }
526
527     /**
528      * Is a given port non-standard for the current scheme?
529      *
530      * @param string $scheme
531      * @param string $host
532      * @param int $port
533      * @return bool
534      */
535     private function isNonStandardPort($scheme, $host, $port)
536     {
537         if ('' === $scheme) {
538             return '' === $host || null !== $port;
539         }
540
541         if ('' === $host || null === $port) {
542             return false;
543         }
544
545         return ! isset($this->allowedSchemes[$scheme]) || $port !== $this->allowedSchemes[$scheme];
546     }
547
548     /**
549      * Filters the scheme to ensure it is a valid scheme.
550      *
551      * @param string $scheme Scheme name.
552      *
553      * @return string Filtered scheme.
554      */
555     private function filterScheme($scheme)
556     {
557         $scheme = strtolower($scheme);
558         $scheme = preg_replace('#:(//)?$#', '', $scheme);
559
560         if ('' === $scheme) {
561             return '';
562         }
563
564         if (! isset($this->allowedSchemes[$scheme])) {
565             throw new InvalidArgumentException(sprintf(
566                 'Unsupported scheme "%s"; must be any empty string or in the set (%s)',
567                 $scheme,
568                 implode(', ', array_keys($this->allowedSchemes))
569             ));
570         }
571
572         return $scheme;
573     }
574
575     /**
576      * Filters a part of user info in a URI to ensure it is properly encoded.
577      *
578      * @param string $part
579      * @return string
580      */
581     private function filterUserInfoPart($part)
582     {
583         // Note the addition of `%` to initial charset; this allows `|` portion
584         // to match and thus prevent double-encoding.
585         return preg_replace_callback(
586             '/(?:[^%' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . ']+|%(?![A-Fa-f0-9]{2}))/u',
587             [$this, 'urlEncodeChar'],
588             $part
589         );
590     }
591
592     /**
593      * Filters the path of a URI to ensure it is properly encoded.
594      *
595      * @param string $path
596      * @return string
597      */
598     private function filterPath($path)
599     {
600         $path = preg_replace_callback(
601             '/(?:[^' . self::CHAR_UNRESERVED . ')(:@&=\+\$,\/;%]+|%(?![A-Fa-f0-9]{2}))/u',
602             [$this, 'urlEncodeChar'],
603             $path
604         );
605
606         if ('' === $path) {
607             // No path
608             return $path;
609         }
610
611         if ($path[0] !== '/') {
612             // Relative path
613             return $path;
614         }
615
616         // Ensure only one leading slash, to prevent XSS attempts.
617         return '/' . ltrim($path, '/');
618     }
619
620     /**
621      * Filter a query string to ensure it is propertly encoded.
622      *
623      * Ensures that the values in the query string are properly urlencoded.
624      *
625      * @param string $query
626      * @return string
627      */
628     private function filterQuery($query)
629     {
630         if ('' !== $query && strpos($query, '?') === 0) {
631             $query = substr($query, 1);
632         }
633
634         $parts = explode('&', $query);
635         foreach ($parts as $index => $part) {
636             list($key, $value) = $this->splitQueryValue($part);
637             if ($value === null) {
638                 $parts[$index] = $this->filterQueryOrFragment($key);
639                 continue;
640             }
641             $parts[$index] = sprintf(
642                 '%s=%s',
643                 $this->filterQueryOrFragment($key),
644                 $this->filterQueryOrFragment($value)
645             );
646         }
647
648         return implode('&', $parts);
649     }
650
651     /**
652      * Split a query value into a key/value tuple.
653      *
654      * @param string $value
655      * @return array A value with exactly two elements, key and value
656      */
657     private function splitQueryValue($value)
658     {
659         $data = explode('=', $value, 2);
660         if (! isset($data[1])) {
661             $data[] = null;
662         }
663         return $data;
664     }
665
666     /**
667      * Filter a fragment value to ensure it is properly encoded.
668      *
669      * @param string $fragment
670      * @return string
671      */
672     private function filterFragment($fragment)
673     {
674         if ('' !== $fragment && strpos($fragment, '#') === 0) {
675             $fragment = '%23' . substr($fragment, 1);
676         }
677
678         return $this->filterQueryOrFragment($fragment);
679     }
680
681     /**
682      * Filter a query string key or value, or a fragment.
683      *
684      * @param string $value
685      * @return string
686      */
687     private function filterQueryOrFragment($value)
688     {
689         return preg_replace_callback(
690             '/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/\?]+|%(?![A-Fa-f0-9]{2}))/u',
691             [$this, 'urlEncodeChar'],
692             $value
693         );
694     }
695
696     /**
697      * URL encode a character returned by a regex.
698      *
699      * @param array $matches
700      * @return string
701      */
702     private function urlEncodeChar(array $matches)
703     {
704         return rawurlencode($matches[0]);
705     }
706 }