1<?php
2
3namespace GuzzleHttp\Cookie;
4
5/**
6 * Set-Cookie object
7 */
8class SetCookie
9{
10    /**
11     * @var array
12     */
13    private static $defaults = [
14        'Name' => null,
15        'Value' => null,
16        'Domain' => null,
17        'Path' => '/',
18        'Max-Age' => null,
19        'Expires' => null,
20        'Secure' => false,
21        'Discard' => false,
22        'HttpOnly' => false,
23    ];
24
25    /**
26     * @var array Cookie data
27     */
28    private $data;
29
30    /**
31     * Create a new SetCookie object from a string.
32     *
33     * @param string $cookie Set-Cookie header string
34     */
35    public static function fromString(string $cookie): self
36    {
37        // Create the default return array
38        $data = self::$defaults;
39        // Explode the cookie string using a series of semicolons
40        $pieces = \array_filter(\array_map('trim', \explode(';', $cookie)));
41        // The name of the cookie (first kvp) must exist and include an equal sign.
42        if (!isset($pieces[0]) || \strpos($pieces[0], '=') === false) {
43            return new self($data);
44        }
45
46        // Add the cookie pieces into the parsed data array
47        foreach ($pieces as $part) {
48            $cookieParts = \explode('=', $part, 2);
49            $key = \trim($cookieParts[0]);
50            $value = isset($cookieParts[1])
51                ? \trim($cookieParts[1], " \n\r\t\0\x0B")
52                : true;
53
54            // Only check for non-cookies when cookies have been found
55            if (!isset($data['Name'])) {
56                $data['Name'] = $key;
57                $data['Value'] = $value;
58            } else {
59                foreach (\array_keys(self::$defaults) as $search) {
60                    if (!\strcasecmp($search, $key)) {
61                        if ($search === 'Max-Age') {
62                            if (is_numeric($value)) {
63                                $data[$search] = (int) $value;
64                            }
65                        } else {
66                            $data[$search] = $value;
67                        }
68                        continue 2;
69                    }
70                }
71                $data[$key] = $value;
72            }
73        }
74
75        return new self($data);
76    }
77
78    /**
79     * @param array $data Array of cookie data provided by a Cookie parser
80     */
81    public function __construct(array $data = [])
82    {
83        $this->data = self::$defaults;
84
85        if (isset($data['Name'])) {
86            $this->setName($data['Name']);
87        }
88
89        if (isset($data['Value'])) {
90            $this->setValue($data['Value']);
91        }
92
93        if (isset($data['Domain'])) {
94            $this->setDomain($data['Domain']);
95        }
96
97        if (isset($data['Path'])) {
98            $this->setPath($data['Path']);
99        }
100
101        if (isset($data['Max-Age'])) {
102            $this->setMaxAge($data['Max-Age']);
103        }
104
105        if (isset($data['Expires'])) {
106            $this->setExpires($data['Expires']);
107        }
108
109        if (isset($data['Secure'])) {
110            $this->setSecure($data['Secure']);
111        }
112
113        if (isset($data['Discard'])) {
114            $this->setDiscard($data['Discard']);
115        }
116
117        if (isset($data['HttpOnly'])) {
118            $this->setHttpOnly($data['HttpOnly']);
119        }
120
121        // Set the remaining values that don't have extra validation logic
122        foreach (array_diff(array_keys($data), array_keys(self::$defaults)) as $key) {
123            $this->data[$key] = $data[$key];
124        }
125
126        // Extract the Expires value and turn it into a UNIX timestamp if needed
127        if (!$this->getExpires() && $this->getMaxAge()) {
128            // Calculate the Expires date
129            $this->setExpires(\time() + $this->getMaxAge());
130        } elseif (null !== ($expires = $this->getExpires()) && !\is_numeric($expires)) {
131            $this->setExpires($expires);
132        }
133    }
134
135    public function __toString()
136    {
137        $str = $this->data['Name'].'='.($this->data['Value'] ?? '').'; ';
138        foreach ($this->data as $k => $v) {
139            if ($k !== 'Name' && $k !== 'Value' && $v !== null && $v !== false) {
140                if ($k === 'Expires') {
141                    $str .= 'Expires='.\gmdate('D, d M Y H:i:s \G\M\T', $v).'; ';
142                } else {
143                    $str .= ($v === true ? $k : "{$k}={$v}").'; ';
144                }
145            }
146        }
147
148        return \rtrim($str, '; ');
149    }
150
151    public function toArray(): array
152    {
153        return $this->data;
154    }
155
156    /**
157     * Get the cookie name.
158     *
159     * @return string
160     */
161    public function getName()
162    {
163        return $this->data['Name'];
164    }
165
166    /**
167     * Set the cookie name.
168     *
169     * @param string $name Cookie name
170     */
171    public function setName($name): void
172    {
173        if (!is_string($name)) {
174            trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a string to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
175        }
176
177        $this->data['Name'] = (string) $name;
178    }
179
180    /**
181     * Get the cookie value.
182     *
183     * @return string|null
184     */
185    public function getValue()
186    {
187        return $this->data['Value'];
188    }
189
190    /**
191     * Set the cookie value.
192     *
193     * @param string $value Cookie value
194     */
195    public function setValue($value): void
196    {
197        if (!is_string($value)) {
198            trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a string to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
199        }
200
201        $this->data['Value'] = (string) $value;
202    }
203
204    /**
205     * Get the domain.
206     *
207     * @return string|null
208     */
209    public function getDomain()
210    {
211        return $this->data['Domain'];
212    }
213
214    /**
215     * Set the domain of the cookie.
216     *
217     * @param string|null $domain
218     */
219    public function setDomain($domain): void
220    {
221        if (!is_string($domain) && null !== $domain) {
222            trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a string or null to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
223        }
224
225        $this->data['Domain'] = null === $domain ? null : (string) $domain;
226    }
227
228    /**
229     * Get the path.
230     *
231     * @return string
232     */
233    public function getPath()
234    {
235        return $this->data['Path'];
236    }
237
238    /**
239     * Set the path of the cookie.
240     *
241     * @param string $path Path of the cookie
242     */
243    public function setPath($path): void
244    {
245        if (!is_string($path)) {
246            trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a string to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
247        }
248
249        $this->data['Path'] = (string) $path;
250    }
251
252    /**
253     * Maximum lifetime of the cookie in seconds.
254     *
255     * @return int|null
256     */
257    public function getMaxAge()
258    {
259        return null === $this->data['Max-Age'] ? null : (int) $this->data['Max-Age'];
260    }
261
262    /**
263     * Set the max-age of the cookie.
264     *
265     * @param int|null $maxAge Max age of the cookie in seconds
266     */
267    public function setMaxAge($maxAge): void
268    {
269        if (!is_int($maxAge) && null !== $maxAge) {
270            trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing an int or null to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
271        }
272
273        $this->data['Max-Age'] = $maxAge === null ? null : (int) $maxAge;
274    }
275
276    /**
277     * The UNIX timestamp when the cookie Expires.
278     *
279     * @return string|int|null
280     */
281    public function getExpires()
282    {
283        return $this->data['Expires'];
284    }
285
286    /**
287     * Set the unix timestamp for which the cookie will expire.
288     *
289     * @param int|string|null $timestamp Unix timestamp or any English textual datetime description.
290     */
291    public function setExpires($timestamp): void
292    {
293        if (!is_int($timestamp) && !is_string($timestamp) && null !== $timestamp) {
294            trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing an int, string or null to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
295        }
296
297        $this->data['Expires'] = null === $timestamp ? null : (\is_numeric($timestamp) ? (int) $timestamp : \strtotime((string) $timestamp));
298    }
299
300    /**
301     * Get whether or not this is a secure cookie.
302     *
303     * @return bool
304     */
305    public function getSecure()
306    {
307        return $this->data['Secure'];
308    }
309
310    /**
311     * Set whether or not the cookie is secure.
312     *
313     * @param bool $secure Set to true or false if secure
314     */
315    public function setSecure($secure): void
316    {
317        if (!is_bool($secure)) {
318            trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a bool to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
319        }
320
321        $this->data['Secure'] = (bool) $secure;
322    }
323
324    /**
325     * Get whether or not this is a session cookie.
326     *
327     * @return bool|null
328     */
329    public function getDiscard()
330    {
331        return $this->data['Discard'];
332    }
333
334    /**
335     * Set whether or not this is a session cookie.
336     *
337     * @param bool $discard Set to true or false if this is a session cookie
338     */
339    public function setDiscard($discard): void
340    {
341        if (!is_bool($discard)) {
342            trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a bool to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
343        }
344
345        $this->data['Discard'] = (bool) $discard;
346    }
347
348    /**
349     * Get whether or not this is an HTTP only cookie.
350     *
351     * @return bool
352     */
353    public function getHttpOnly()
354    {
355        return $this->data['HttpOnly'];
356    }
357
358    /**
359     * Set whether or not this is an HTTP only cookie.
360     *
361     * @param bool $httpOnly Set to true or false if this is HTTP only
362     */
363    public function setHttpOnly($httpOnly): void
364    {
365        if (!is_bool($httpOnly)) {
366            trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a bool to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
367        }
368
369        $this->data['HttpOnly'] = (bool) $httpOnly;
370    }
371
372    /**
373     * Check if the cookie matches a path value.
374     *
375     * A request-path path-matches a given cookie-path if at least one of
376     * the following conditions holds:
377     *
378     * - The cookie-path and the request-path are identical.
379     * - The cookie-path is a prefix of the request-path, and the last
380     *   character of the cookie-path is %x2F ("/").
381     * - The cookie-path is a prefix of the request-path, and the first
382     *   character of the request-path that is not included in the cookie-
383     *   path is a %x2F ("/") character.
384     *
385     * @param string $requestPath Path to check against
386     */
387    public function matchesPath(string $requestPath): bool
388    {
389        $cookiePath = $this->getPath();
390
391        // Match on exact matches or when path is the default empty "/"
392        if ($cookiePath === '/' || $cookiePath == $requestPath) {
393            return true;
394        }
395
396        // Ensure that the cookie-path is a prefix of the request path.
397        if (0 !== \strpos($requestPath, $cookiePath)) {
398            return false;
399        }
400
401        // Match if the last character of the cookie-path is "/"
402        if (\substr($cookiePath, -1, 1) === '/') {
403            return true;
404        }
405
406        // Match if the first character not included in cookie path is "/"
407        return \substr($requestPath, \strlen($cookiePath), 1) === '/';
408    }
409
410    /**
411     * Check if the cookie matches a domain value.
412     *
413     * @param string $domain Domain to check against
414     */
415    public function matchesDomain(string $domain): bool
416    {
417        $cookieDomain = $this->getDomain();
418        if (null === $cookieDomain) {
419            return true;
420        }
421
422        // Remove the leading '.' as per spec in RFC 6265.
423        // https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3
424        $cookieDomain = \ltrim(\strtolower($cookieDomain), '.');
425
426        $domain = \strtolower($domain);
427
428        // Domain not set or exact match.
429        if ('' === $cookieDomain || $domain === $cookieDomain) {
430            return true;
431        }
432
433        // Matching the subdomain according to RFC 6265.
434        // https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3
435        if (\filter_var($domain, \FILTER_VALIDATE_IP)) {
436            return false;
437        }
438
439        return (bool) \preg_match('/\.'.\preg_quote($cookieDomain, '/').'$/', $domain);
440    }
441
442    /**
443     * Check if the cookie is expired.
444     */
445    public function isExpired(): bool
446    {
447        return $this->getExpires() !== null && \time() > $this->getExpires();
448    }
449
450    /**
451     * Check if the cookie is valid according to RFC 6265.
452     *
453     * @return bool|string Returns true if valid or an error message if invalid
454     */
455    public function validate()
456    {
457        $name = $this->getName();
458        if ($name === '') {
459            return 'The cookie name must not be empty';
460        }
461
462        // Check if any of the invalid characters are present in the cookie name
463        if (\preg_match(
464            '/[\x00-\x20\x22\x28-\x29\x2c\x2f\x3a-\x40\x5c\x7b\x7d\x7f]/',
465            $name
466        )) {
467            return 'Cookie name must not contain invalid characters: ASCII '
468                .'Control characters (0-31;127), space, tab and the '
469                .'following characters: ()<>@,;:\"/?={}';
470        }
471
472        // Value must not be null. 0 and empty string are valid. Empty strings
473        // are technically against RFC 6265, but known to happen in the wild.
474        $value = $this->getValue();
475        if ($value === null) {
476            return 'The cookie value must not be empty';
477        }
478
479        // Domains must not be empty, but can be 0. "0" is not a valid internet
480        // domain, but may be used as server name in a private network.
481        $domain = $this->getDomain();
482        if ($domain === null || $domain === '') {
483            return 'The cookie domain must not be empty';
484        }
485
486        return true;
487    }
488}
489