1<?php
2/**
3 * Zend Framework (http://framework.zend.com/)
4 *
5 * @link      http://github.com/zendframework/zf2 for the canonical source repository
6 * @copyright Copyright (c) 2005-2015 Zend Technologies USA Inc. (http://www.zend.com)
7 * @license   http://framework.zend.com/license/new-bsd New BSD License
8 */
9
10namespace Zend\Http\Header;
11
12use DateTime;
13use Zend\Uri\UriFactory;
14
15/**
16 * @throws Exception\InvalidArgumentException
17 * @see http://www.ietf.org/rfc/rfc2109.txt
18 * @see http://www.w3.org/Protocols/rfc2109/rfc2109
19 */
20class SetCookie implements MultipleHeaderInterface
21{
22    /**
23     * Cookie name
24     *
25     * @var string|null
26     */
27    protected $name = null;
28
29    /**
30     * Cookie value
31     *
32     * @var string|null
33     */
34    protected $value = null;
35
36    /**
37     * Version
38     *
39     * @var int|null
40     */
41    protected $version = null;
42
43    /**
44     * Max Age
45     *
46     * @var int|null
47     */
48    protected $maxAge = null;
49
50    /**
51     * Cookie expiry date
52     *
53     * @var int|null
54     */
55    protected $expires = null;
56
57    /**
58     * Cookie domain
59     *
60     * @var string|null
61     */
62    protected $domain = null;
63
64    /**
65     * Cookie path
66     *
67     * @var string|null
68     */
69    protected $path = null;
70
71    /**
72     * Whether the cookie is secure or not
73     *
74     * @var bool|null
75     */
76    protected $secure = null;
77
78    /**
79     * If the value need to be quoted or not
80     *
81     * @var bool
82     */
83    protected $quoteFieldValue = false;
84
85    /**
86     * @var bool|null
87     */
88    protected $httponly = null;
89
90    /**
91     * @static
92     * @throws Exception\InvalidArgumentException
93     * @param  $headerLine
94     * @param  bool $bypassHeaderFieldName
95     * @return array|SetCookie
96     */
97    public static function fromString($headerLine, $bypassHeaderFieldName = false)
98    {
99        static $setCookieProcessor = null;
100
101        if ($setCookieProcessor === null) {
102            $setCookieClass = get_called_class();
103            $setCookieProcessor = function ($headerLine) use ($setCookieClass) {
104                $header = new $setCookieClass;
105                $keyValuePairs = preg_split('#;\s*#', $headerLine);
106
107                foreach ($keyValuePairs as $keyValue) {
108                    if (preg_match('#^(?P<headerKey>[^=]+)=\s*("?)(?P<headerValue>[^"]*)\2#', $keyValue, $matches)) {
109                        $headerKey  = $matches['headerKey'];
110                        $headerValue= $matches['headerValue'];
111                    } else {
112                        $headerKey = $keyValue;
113                        $headerValue = null;
114                    }
115
116                    // First K=V pair is always the cookie name and value
117                    if ($header->getName() === null) {
118                        $header->setName($headerKey);
119                        $header->setValue(urldecode($headerValue));
120                        continue;
121                    }
122
123                    // Process the remaining elements
124                    switch (str_replace(array('-', '_'), '', strtolower($headerKey))) {
125                        case 'expires':
126                            $header->setExpires($headerValue);
127                            break;
128                        case 'domain':
129                            $header->setDomain($headerValue);
130                            break;
131                        case 'path':
132                            $header->setPath($headerValue);
133                            break;
134                        case 'secure':
135                            $header->setSecure(true);
136                            break;
137                        case 'httponly':
138                            $header->setHttponly(true);
139                            break;
140                        case 'version':
141                            $header->setVersion((int) $headerValue);
142                            break;
143                        case 'maxage':
144                            $header->setMaxAge((int) $headerValue);
145                            break;
146                        default:
147                            // Intentionally omitted
148                    }
149                }
150
151                return $header;
152            };
153        }
154
155        list($name, $value) = GenericHeader::splitHeaderLine($headerLine);
156        HeaderValue::assertValid($value);
157
158        // some sites return set-cookie::value, this is to get rid of the second :
159        $name = (strtolower($name) =='set-cookie:') ? 'set-cookie' : $name;
160
161        // check to ensure proper header type for this factory
162        if (strtolower($name) !== 'set-cookie') {
163            throw new Exception\InvalidArgumentException('Invalid header line for Set-Cookie string: "' . $name . '"');
164        }
165
166        $multipleHeaders = preg_split('#(?<!Sun|Mon|Tue|Wed|Thu|Fri|Sat),\s*#', $value);
167
168        if (count($multipleHeaders) <= 1) {
169            return $setCookieProcessor(array_pop($multipleHeaders));
170        } else {
171            $headers = array();
172            foreach ($multipleHeaders as $headerLine) {
173                $headers[] = $setCookieProcessor($headerLine);
174            }
175            return $headers;
176        }
177    }
178
179    /**
180     * Cookie object constructor
181     *
182     * @todo Add validation of each one of the parameters (legal domain, etc.)
183     *
184     * @param   string              $name
185     * @param   string              $value
186     * @param   int|string|DateTime $expires
187     * @param   string              $path
188     * @param   string              $domain
189     * @param   bool                $secure
190     * @param   bool                $httponly
191     * @param   string              $maxAge
192     * @param   int                 $version
193     */
194    public function __construct(
195        $name = null,
196        $value = null,
197        $expires = null,
198        $path = null,
199        $domain = null,
200        $secure = false,
201        $httponly = false,
202        $maxAge = null,
203        $version = null
204    ) {
205        $this->type = 'Cookie';
206
207        $this->setName($name)
208             ->setValue($value)
209             ->setVersion($version)
210             ->setMaxAge($maxAge)
211             ->setDomain($domain)
212             ->setExpires($expires)
213             ->setPath($path)
214             ->setSecure($secure)
215             ->setHttpOnly($httponly);
216    }
217
218    /**
219     * @return string 'Set-Cookie'
220     */
221    public function getFieldName()
222    {
223        return 'Set-Cookie';
224    }
225
226    /**
227     * @throws Exception\RuntimeException
228     * @return string
229     */
230    public function getFieldValue()
231    {
232        if ($this->getName() == '') {
233            return '';
234        }
235
236        $value = urlencode($this->getValue());
237        if ($this->hasQuoteFieldValue()) {
238            $value = '"'. $value . '"';
239        }
240
241        $fieldValue = $this->getName() . '=' . $value;
242
243        $version = $this->getVersion();
244        if ($version !== null) {
245            $fieldValue .= '; Version=' . $version;
246        }
247
248        $maxAge = $this->getMaxAge();
249        if ($maxAge!==null) {
250            $fieldValue .= '; Max-Age=' . $maxAge;
251        }
252
253        $expires = $this->getExpires();
254        if ($expires) {
255            $fieldValue .= '; Expires=' . $expires;
256        }
257
258        $domain = $this->getDomain();
259        if ($domain) {
260            $fieldValue .= '; Domain=' . $domain;
261        }
262
263        $path = $this->getPath();
264        if ($path) {
265            $fieldValue .= '; Path=' . $path;
266        }
267
268        if ($this->isSecure()) {
269            $fieldValue .= '; Secure';
270        }
271
272        if ($this->isHttponly()) {
273            $fieldValue .= '; HttpOnly';
274        }
275
276        return $fieldValue;
277    }
278
279    /**
280     * @param string $name
281     * @throws Exception\InvalidArgumentException
282     * @return SetCookie
283     */
284    public function setName($name)
285    {
286        HeaderValue::assertValid($name);
287        $this->name = $name;
288        return $this;
289    }
290
291    /**
292     * @return string
293     */
294    public function getName()
295    {
296        return $this->name;
297    }
298
299    /**
300     * @param string $value
301     * @return SetCookie
302     */
303    public function setValue($value)
304    {
305        $this->value = $value;
306        return $this;
307    }
308
309    /**
310     * @return string
311     */
312    public function getValue()
313    {
314        return $this->value;
315    }
316
317    /**
318     * Set version
319     *
320     * @param int $version
321     * @throws Exception\InvalidArgumentException
322     * @return SetCookie
323     */
324    public function setVersion($version)
325    {
326        if ($version !== null && !is_int($version)) {
327            throw new Exception\InvalidArgumentException('Invalid Version number specified');
328        }
329        $this->version = $version;
330        return $this;
331    }
332
333    /**
334     * Get version
335     *
336     * @return int
337     */
338    public function getVersion()
339    {
340        return $this->version;
341    }
342
343    /**
344     * Set Max-Age
345     *
346     * @param int $maxAge
347     * @throws Exception\InvalidArgumentException
348     * @return SetCookie
349     */
350    public function setMaxAge($maxAge)
351    {
352        if ($maxAge !== null && (!is_int($maxAge) || ($maxAge < 0))) {
353            throw new Exception\InvalidArgumentException('Invalid Max-Age number specified');
354        }
355        $this->maxAge = $maxAge;
356        return $this;
357    }
358
359    /**
360     * Get Max-Age
361     *
362     * @return int
363     */
364    public function getMaxAge()
365    {
366        return $this->maxAge;
367    }
368
369    /**
370     * Set Expires
371     *
372     * @param int|string|DateTime $expires
373     *
374     * @return self
375     *
376     * @throws Exception\InvalidArgumentException
377     */
378    public function setExpires($expires)
379    {
380        if ($expires === null) {
381            $this->expires = null;
382            return $this;
383        }
384
385        if ($expires instanceof DateTime) {
386            $expires = $expires->format(DateTime::COOKIE);
387        }
388
389        $tsExpires = $expires;
390
391        if (is_string($expires)) {
392            $tsExpires = strtotime($expires);
393
394            // if $tsExpires is invalid and PHP is compiled as 32bit. Check if it fail reason is the 2038 bug
395            if (!is_int($tsExpires) && PHP_INT_SIZE === 4) {
396                $dateTime = new DateTime($expires);
397                if ($dateTime->format('Y') > 2038) {
398                    $tsExpires = PHP_INT_MAX;
399                }
400            }
401        }
402
403        if (!is_int($tsExpires) || $tsExpires < 0) {
404            throw new Exception\InvalidArgumentException('Invalid expires time specified');
405        }
406
407        $this->expires = $tsExpires;
408
409        return $this;
410    }
411
412    /**
413     * @param bool $inSeconds
414     * @return int|string
415     */
416    public function getExpires($inSeconds = false)
417    {
418        if ($this->expires === null) {
419            return;
420        }
421        if ($inSeconds) {
422            return $this->expires;
423        }
424        return gmdate('D, d-M-Y H:i:s', $this->expires) . ' GMT';
425    }
426
427    /**
428     * @param string $domain
429     * @return SetCookie
430     */
431    public function setDomain($domain)
432    {
433        HeaderValue::assertValid($domain);
434        $this->domain = $domain;
435        return $this;
436    }
437
438    /**
439     * @return string
440     */
441    public function getDomain()
442    {
443        return $this->domain;
444    }
445
446    /**
447     * @param string $path
448     * @return SetCookie
449     */
450    public function setPath($path)
451    {
452        HeaderValue::assertValid($path);
453        $this->path = $path;
454        return $this;
455    }
456
457    /**
458     * @return string
459     */
460    public function getPath()
461    {
462        return $this->path;
463    }
464
465    /**
466     * @param  bool $secure
467     * @return SetCookie
468     */
469    public function setSecure($secure)
470    {
471        if (null !== $secure) {
472            $secure = (bool) $secure;
473        }
474        $this->secure = $secure;
475        return $this;
476    }
477
478    /**
479     * Set whether the value for this cookie should be quoted
480     *
481     * @param  bool $quotedValue
482     * @return SetCookie
483     */
484    public function setQuoteFieldValue($quotedValue)
485    {
486        $this->quoteFieldValue = (bool) $quotedValue;
487        return $this;
488    }
489
490    /**
491     * @return bool
492     */
493    public function isSecure()
494    {
495        return $this->secure;
496    }
497
498    /**
499     * @param  bool $httponly
500     * @return SetCookie
501     */
502    public function setHttponly($httponly)
503    {
504        if (null !== $httponly) {
505            $httponly = (bool) $httponly;
506        }
507        $this->httponly = $httponly;
508        return $this;
509    }
510
511    /**
512     * @return bool
513     */
514    public function isHttponly()
515    {
516        return $this->httponly;
517    }
518
519    /**
520     * Check whether the cookie has expired
521     *
522     * Always returns false if the cookie is a session cookie (has no expiry time)
523     *
524     * @param int $now Timestamp to consider as "now"
525     * @return bool
526     */
527    public function isExpired($now = null)
528    {
529        if ($now === null) {
530            $now = time();
531        }
532
533        if (is_int($this->expires) && $this->expires < $now) {
534            return true;
535        }
536
537        return false;
538    }
539
540    /**
541     * Check whether the cookie is a session cookie (has no expiry time set)
542     *
543     * @return bool
544     */
545    public function isSessionCookie()
546    {
547        return ($this->expires === null);
548    }
549
550    /**
551     * Check whether the value for this cookie should be quoted
552     *
553     * @return bool
554     */
555    public function hasQuoteFieldValue()
556    {
557        return $this->quoteFieldValue;
558    }
559
560    public function isValidForRequest($requestDomain, $path, $isSecure = false)
561    {
562        if ($this->getDomain() && (strrpos($requestDomain, $this->getDomain()) === false)) {
563            return false;
564        }
565
566        if ($this->getPath() && (strpos($path, $this->getPath()) !== 0)) {
567            return false;
568        }
569
570        if ($this->secure && $this->isSecure()!==$isSecure) {
571            return false;
572        }
573
574        return true;
575    }
576
577    /**
578     * Checks whether the cookie should be sent or not in a specific scenario
579     *
580     * @param string|\Zend\Uri\Uri $uri URI to check against (secure, domain, path)
581     * @param bool $matchSessionCookies Whether to send session cookies
582     * @param int $now Override the current time when checking for expiry time
583     * @return bool
584     * @throws Exception\InvalidArgumentException If URI does not have HTTP or HTTPS scheme.
585     */
586    public function match($uri, $matchSessionCookies = true, $now = null)
587    {
588        if (is_string($uri)) {
589            $uri = UriFactory::factory($uri);
590        }
591
592        // Make sure we have a valid Zend_Uri_Http object
593        if (! ($uri->isValid() && ($uri->getScheme() == 'http' || $uri->getScheme() =='https'))) {
594            throw new Exception\InvalidArgumentException('Passed URI is not a valid HTTP or HTTPS URI');
595        }
596
597        // Check that the cookie is secure (if required) and not expired
598        if ($this->secure && $uri->getScheme() != 'https') {
599            return false;
600        }
601        if ($this->isExpired($now)) {
602            return false;
603        }
604        if ($this->isSessionCookie() && ! $matchSessionCookies) {
605            return false;
606        }
607
608        // Check if the domain matches
609        if (! self::matchCookieDomain($this->getDomain(), $uri->getHost())) {
610            return false;
611        }
612
613        // Check that path matches using prefix match
614        if (! self::matchCookiePath($this->getPath(), $uri->getPath())) {
615            return false;
616        }
617
618        // If we didn't die until now, return true.
619        return true;
620    }
621
622    /**
623     * Check if a cookie's domain matches a host name.
624     *
625     * Used by Zend\Http\Cookies for cookie matching
626     *
627     * @param  string $cookieDomain
628     * @param  string $host
629     *
630     * @return bool
631     */
632    public static function matchCookieDomain($cookieDomain, $host)
633    {
634        $cookieDomain = strtolower($cookieDomain);
635        $host = strtolower($host);
636        // Check for either exact match or suffix match
637        return ($cookieDomain == $host ||
638                preg_match('/' . preg_quote($cookieDomain) . '$/', $host));
639    }
640
641    /**
642     * Check if a cookie's path matches a URL path
643     *
644     * Used by Zend\Http\Cookies for cookie matching
645     *
646     * @param  string $cookiePath
647     * @param  string $path
648     * @return bool
649     */
650    public static function matchCookiePath($cookiePath, $path)
651    {
652        return (strpos($path, $cookiePath) === 0);
653    }
654
655    public function toString()
656    {
657        return 'Set-Cookie: ' . $this->getFieldValue();
658    }
659
660    public function toStringMultipleHeaders(array $headers)
661    {
662        $headerLine = $this->toString();
663        /* @var $header SetCookie */
664        foreach ($headers as $header) {
665            if (!$header instanceof SetCookie) {
666                throw new Exception\RuntimeException(
667                    'The SetCookie multiple header implementation can only accept an array of SetCookie headers'
668                );
669            }
670            $headerLine .= "\n" . $header->toString();
671        }
672        return $headerLine;
673    }
674}
675