1<?php
2
3/*
4 * This file is part of the Symfony package.
5 *
6 * (c) Fabien Potencier <fabien@symfony.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace Symfony\Component\HttpFoundation;
13
14// Help opcache.preload discover always-needed symbols
15class_exists(ResponseHeaderBag::class);
16
17/**
18 * Response represents an HTTP response.
19 *
20 * @author Fabien Potencier <fabien@symfony.com>
21 */
22class Response
23{
24    const HTTP_CONTINUE = 100;
25    const HTTP_SWITCHING_PROTOCOLS = 101;
26    const HTTP_PROCESSING = 102;            // RFC2518
27    const HTTP_EARLY_HINTS = 103;           // RFC8297
28    const HTTP_OK = 200;
29    const HTTP_CREATED = 201;
30    const HTTP_ACCEPTED = 202;
31    const HTTP_NON_AUTHORITATIVE_INFORMATION = 203;
32    const HTTP_NO_CONTENT = 204;
33    const HTTP_RESET_CONTENT = 205;
34    const HTTP_PARTIAL_CONTENT = 206;
35    const HTTP_MULTI_STATUS = 207;          // RFC4918
36    const HTTP_ALREADY_REPORTED = 208;      // RFC5842
37    const HTTP_IM_USED = 226;               // RFC3229
38    const HTTP_MULTIPLE_CHOICES = 300;
39    const HTTP_MOVED_PERMANENTLY = 301;
40    const HTTP_FOUND = 302;
41    const HTTP_SEE_OTHER = 303;
42    const HTTP_NOT_MODIFIED = 304;
43    const HTTP_USE_PROXY = 305;
44    const HTTP_RESERVED = 306;
45    const HTTP_TEMPORARY_REDIRECT = 307;
46    const HTTP_PERMANENTLY_REDIRECT = 308;  // RFC7238
47    const HTTP_BAD_REQUEST = 400;
48    const HTTP_UNAUTHORIZED = 401;
49    const HTTP_PAYMENT_REQUIRED = 402;
50    const HTTP_FORBIDDEN = 403;
51    const HTTP_NOT_FOUND = 404;
52    const HTTP_METHOD_NOT_ALLOWED = 405;
53    const HTTP_NOT_ACCEPTABLE = 406;
54    const HTTP_PROXY_AUTHENTICATION_REQUIRED = 407;
55    const HTTP_REQUEST_TIMEOUT = 408;
56    const HTTP_CONFLICT = 409;
57    const HTTP_GONE = 410;
58    const HTTP_LENGTH_REQUIRED = 411;
59    const HTTP_PRECONDITION_FAILED = 412;
60    const HTTP_REQUEST_ENTITY_TOO_LARGE = 413;
61    const HTTP_REQUEST_URI_TOO_LONG = 414;
62    const HTTP_UNSUPPORTED_MEDIA_TYPE = 415;
63    const HTTP_REQUESTED_RANGE_NOT_SATISFIABLE = 416;
64    const HTTP_EXPECTATION_FAILED = 417;
65    const HTTP_I_AM_A_TEAPOT = 418;                                               // RFC2324
66    const HTTP_MISDIRECTED_REQUEST = 421;                                         // RFC7540
67    const HTTP_UNPROCESSABLE_ENTITY = 422;                                        // RFC4918
68    const HTTP_LOCKED = 423;                                                      // RFC4918
69    const HTTP_FAILED_DEPENDENCY = 424;                                           // RFC4918
70    const HTTP_TOO_EARLY = 425;                                                   // RFC-ietf-httpbis-replay-04
71    const HTTP_UPGRADE_REQUIRED = 426;                                            // RFC2817
72    const HTTP_PRECONDITION_REQUIRED = 428;                                       // RFC6585
73    const HTTP_TOO_MANY_REQUESTS = 429;                                           // RFC6585
74    const HTTP_REQUEST_HEADER_FIELDS_TOO_LARGE = 431;                             // RFC6585
75    const HTTP_UNAVAILABLE_FOR_LEGAL_REASONS = 451;
76    const HTTP_INTERNAL_SERVER_ERROR = 500;
77    const HTTP_NOT_IMPLEMENTED = 501;
78    const HTTP_BAD_GATEWAY = 502;
79    const HTTP_SERVICE_UNAVAILABLE = 503;
80    const HTTP_GATEWAY_TIMEOUT = 504;
81    const HTTP_VERSION_NOT_SUPPORTED = 505;
82    const HTTP_VARIANT_ALSO_NEGOTIATES_EXPERIMENTAL = 506;                        // RFC2295
83    const HTTP_INSUFFICIENT_STORAGE = 507;                                        // RFC4918
84    const HTTP_LOOP_DETECTED = 508;                                               // RFC5842
85    const HTTP_NOT_EXTENDED = 510;                                                // RFC2774
86    const HTTP_NETWORK_AUTHENTICATION_REQUIRED = 511;                             // RFC6585
87
88    /**
89     * @var ResponseHeaderBag
90     */
91    public $headers;
92
93    /**
94     * @var string
95     */
96    protected $content;
97
98    /**
99     * @var string
100     */
101    protected $version;
102
103    /**
104     * @var int
105     */
106    protected $statusCode;
107
108    /**
109     * @var string
110     */
111    protected $statusText;
112
113    /**
114     * @var string
115     */
116    protected $charset;
117
118    /**
119     * Status codes translation table.
120     *
121     * The list of codes is complete according to the
122     * {@link https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml Hypertext Transfer Protocol (HTTP) Status Code Registry}
123     * (last updated 2016-03-01).
124     *
125     * Unless otherwise noted, the status code is defined in RFC2616.
126     *
127     * @var array
128     */
129    public static $statusTexts = [
130        100 => 'Continue',
131        101 => 'Switching Protocols',
132        102 => 'Processing',            // RFC2518
133        103 => 'Early Hints',
134        200 => 'OK',
135        201 => 'Created',
136        202 => 'Accepted',
137        203 => 'Non-Authoritative Information',
138        204 => 'No Content',
139        205 => 'Reset Content',
140        206 => 'Partial Content',
141        207 => 'Multi-Status',          // RFC4918
142        208 => 'Already Reported',      // RFC5842
143        226 => 'IM Used',               // RFC3229
144        300 => 'Multiple Choices',
145        301 => 'Moved Permanently',
146        302 => 'Found',
147        303 => 'See Other',
148        304 => 'Not Modified',
149        305 => 'Use Proxy',
150        307 => 'Temporary Redirect',
151        308 => 'Permanent Redirect',    // RFC7238
152        400 => 'Bad Request',
153        401 => 'Unauthorized',
154        402 => 'Payment Required',
155        403 => 'Forbidden',
156        404 => 'Not Found',
157        405 => 'Method Not Allowed',
158        406 => 'Not Acceptable',
159        407 => 'Proxy Authentication Required',
160        408 => 'Request Timeout',
161        409 => 'Conflict',
162        410 => 'Gone',
163        411 => 'Length Required',
164        412 => 'Precondition Failed',
165        413 => 'Payload Too Large',
166        414 => 'URI Too Long',
167        415 => 'Unsupported Media Type',
168        416 => 'Range Not Satisfiable',
169        417 => 'Expectation Failed',
170        418 => 'I\'m a teapot',                                               // RFC2324
171        421 => 'Misdirected Request',                                         // RFC7540
172        422 => 'Unprocessable Entity',                                        // RFC4918
173        423 => 'Locked',                                                      // RFC4918
174        424 => 'Failed Dependency',                                           // RFC4918
175        425 => 'Too Early',                                                   // RFC-ietf-httpbis-replay-04
176        426 => 'Upgrade Required',                                            // RFC2817
177        428 => 'Precondition Required',                                       // RFC6585
178        429 => 'Too Many Requests',                                           // RFC6585
179        431 => 'Request Header Fields Too Large',                             // RFC6585
180        451 => 'Unavailable For Legal Reasons',                               // RFC7725
181        500 => 'Internal Server Error',
182        501 => 'Not Implemented',
183        502 => 'Bad Gateway',
184        503 => 'Service Unavailable',
185        504 => 'Gateway Timeout',
186        505 => 'HTTP Version Not Supported',
187        506 => 'Variant Also Negotiates',                                     // RFC2295
188        507 => 'Insufficient Storage',                                        // RFC4918
189        508 => 'Loop Detected',                                               // RFC5842
190        510 => 'Not Extended',                                                // RFC2774
191        511 => 'Network Authentication Required',                             // RFC6585
192    ];
193
194    /**
195     * @throws \InvalidArgumentException When the HTTP status code is not valid
196     */
197    public function __construct(?string $content = '', int $status = 200, array $headers = [])
198    {
199        $this->headers = new ResponseHeaderBag($headers);
200        $this->setContent($content);
201        $this->setStatusCode($status);
202        $this->setProtocolVersion('1.0');
203    }
204
205    /**
206     * Factory method for chainability.
207     *
208     * Example:
209     *
210     *     return Response::create($body, 200)
211     *         ->setSharedMaxAge(300);
212     *
213     * @return static
214     */
215    public static function create(?string $content = '', int $status = 200, array $headers = [])
216    {
217        return new static($content, $status, $headers);
218    }
219
220    /**
221     * Returns the Response as an HTTP string.
222     *
223     * The string representation of the Response is the same as the
224     * one that will be sent to the client only if the prepare() method
225     * has been called before.
226     *
227     * @return string The Response as an HTTP string
228     *
229     * @see prepare()
230     */
231    public function __toString()
232    {
233        return
234            sprintf('HTTP/%s %s %s', $this->version, $this->statusCode, $this->statusText)."\r\n".
235            $this->headers."\r\n".
236            $this->getContent();
237    }
238
239    /**
240     * Clones the current Response instance.
241     */
242    public function __clone()
243    {
244        $this->headers = clone $this->headers;
245    }
246
247    /**
248     * Prepares the Response before it is sent to the client.
249     *
250     * This method tweaks the Response to ensure that it is
251     * compliant with RFC 2616. Most of the changes are based on
252     * the Request that is "associated" with this Response.
253     *
254     * @return $this
255     */
256    public function prepare(Request $request)
257    {
258        $headers = $this->headers;
259
260        if ($this->isInformational() || $this->isEmpty()) {
261            $this->setContent(null);
262            $headers->remove('Content-Type');
263            $headers->remove('Content-Length');
264            // prevent PHP from sending the Content-Type header based on default_mimetype
265            ini_set('default_mimetype', '');
266        } else {
267            // Content-type based on the Request
268            if (!$headers->has('Content-Type')) {
269                $format = $request->getRequestFormat(null);
270                if (null !== $format && $mimeType = $request->getMimeType($format)) {
271                    $headers->set('Content-Type', $mimeType);
272                }
273            }
274
275            // Fix Content-Type
276            $charset = $this->charset ?: 'UTF-8';
277            if (!$headers->has('Content-Type')) {
278                $headers->set('Content-Type', 'text/html; charset='.$charset);
279            } elseif (0 === stripos($headers->get('Content-Type'), 'text/') && false === stripos($headers->get('Content-Type'), 'charset')) {
280                // add the charset
281                $headers->set('Content-Type', $headers->get('Content-Type').'; charset='.$charset);
282            }
283
284            // Fix Content-Length
285            if ($headers->has('Transfer-Encoding')) {
286                $headers->remove('Content-Length');
287            }
288
289            if ($request->isMethod('HEAD')) {
290                // cf. RFC2616 14.13
291                $length = $headers->get('Content-Length');
292                $this->setContent(null);
293                if ($length) {
294                    $headers->set('Content-Length', $length);
295                }
296            }
297        }
298
299        // Fix protocol
300        if ('HTTP/1.0' != $request->server->get('SERVER_PROTOCOL')) {
301            $this->setProtocolVersion('1.1');
302        }
303
304        // Check if we need to send extra expire info headers
305        if ('1.0' == $this->getProtocolVersion() && false !== strpos($headers->get('Cache-Control'), 'no-cache')) {
306            $headers->set('pragma', 'no-cache');
307            $headers->set('expires', -1);
308        }
309
310        $this->ensureIEOverSSLCompatibility($request);
311
312        if ($request->isSecure()) {
313            foreach ($headers->getCookies() as $cookie) {
314                $cookie->setSecureDefault(true);
315            }
316        }
317
318        return $this;
319    }
320
321    /**
322     * Sends HTTP headers.
323     *
324     * @return $this
325     */
326    public function sendHeaders()
327    {
328        // headers have already been sent by the developer
329        if (headers_sent()) {
330            return $this;
331        }
332
333        // headers
334        foreach ($this->headers->allPreserveCaseWithoutCookies() as $name => $values) {
335            $replace = 0 === strcasecmp($name, 'Content-Type');
336            foreach ($values as $value) {
337                header($name.': '.$value, $replace, $this->statusCode);
338            }
339        }
340
341        // cookies
342        foreach ($this->headers->getCookies() as $cookie) {
343            header('Set-Cookie: '.$cookie, false, $this->statusCode);
344        }
345
346        // status
347        header(sprintf('HTTP/%s %s %s', $this->version, $this->statusCode, $this->statusText), true, $this->statusCode);
348
349        return $this;
350    }
351
352    /**
353     * Sends content for the current web response.
354     *
355     * @return $this
356     */
357    public function sendContent()
358    {
359        echo $this->content;
360
361        return $this;
362    }
363
364    /**
365     * Sends HTTP headers and content.
366     *
367     * @return $this
368     */
369    public function send()
370    {
371        $this->sendHeaders();
372        $this->sendContent();
373
374        if (\function_exists('fastcgi_finish_request')) {
375            fastcgi_finish_request();
376        } elseif (!\in_array(\PHP_SAPI, ['cli', 'phpdbg'], true)) {
377            static::closeOutputBuffers(0, true);
378        }
379
380        return $this;
381    }
382
383    /**
384     * Sets the response content.
385     *
386     * @return $this
387     *
388     * @throws \UnexpectedValueException
389     */
390    public function setContent(?string $content)
391    {
392        $this->content = $content ?? '';
393
394        return $this;
395    }
396
397    /**
398     * Gets the current response content.
399     *
400     * @return string|false
401     */
402    public function getContent()
403    {
404        return $this->content;
405    }
406
407    /**
408     * Sets the HTTP protocol version (1.0 or 1.1).
409     *
410     * @return $this
411     *
412     * @final
413     */
414    public function setProtocolVersion(string $version): object
415    {
416        $this->version = $version;
417
418        return $this;
419    }
420
421    /**
422     * Gets the HTTP protocol version.
423     *
424     * @final
425     */
426    public function getProtocolVersion(): string
427    {
428        return $this->version;
429    }
430
431    /**
432     * Sets the response status code.
433     *
434     * If the status text is null it will be automatically populated for the known
435     * status codes and left empty otherwise.
436     *
437     * @return $this
438     *
439     * @throws \InvalidArgumentException When the HTTP status code is not valid
440     *
441     * @final
442     */
443    public function setStatusCode(int $code, $text = null): object
444    {
445        $this->statusCode = $code;
446        if ($this->isInvalid()) {
447            throw new \InvalidArgumentException(sprintf('The HTTP status code "%s" is not valid.', $code));
448        }
449
450        if (null === $text) {
451            $this->statusText = isset(self::$statusTexts[$code]) ? self::$statusTexts[$code] : 'unknown status';
452
453            return $this;
454        }
455
456        if (false === $text) {
457            $this->statusText = '';
458
459            return $this;
460        }
461
462        $this->statusText = $text;
463
464        return $this;
465    }
466
467    /**
468     * Retrieves the status code for the current web response.
469     *
470     * @final
471     */
472    public function getStatusCode(): int
473    {
474        return $this->statusCode;
475    }
476
477    /**
478     * Sets the response charset.
479     *
480     * @return $this
481     *
482     * @final
483     */
484    public function setCharset(string $charset): object
485    {
486        $this->charset = $charset;
487
488        return $this;
489    }
490
491    /**
492     * Retrieves the response charset.
493     *
494     * @final
495     */
496    public function getCharset(): ?string
497    {
498        return $this->charset;
499    }
500
501    /**
502     * Returns true if the response may safely be kept in a shared (surrogate) cache.
503     *
504     * Responses marked "private" with an explicit Cache-Control directive are
505     * considered uncacheable.
506     *
507     * Responses with neither a freshness lifetime (Expires, max-age) nor cache
508     * validator (Last-Modified, ETag) are considered uncacheable because there is
509     * no way to tell when or how to remove them from the cache.
510     *
511     * Note that RFC 7231 and RFC 7234 possibly allow for a more permissive implementation,
512     * for example "status codes that are defined as cacheable by default [...]
513     * can be reused by a cache with heuristic expiration unless otherwise indicated"
514     * (https://tools.ietf.org/html/rfc7231#section-6.1)
515     *
516     * @final
517     */
518    public function isCacheable(): bool
519    {
520        if (!\in_array($this->statusCode, [200, 203, 300, 301, 302, 404, 410])) {
521            return false;
522        }
523
524        if ($this->headers->hasCacheControlDirective('no-store') || $this->headers->getCacheControlDirective('private')) {
525            return false;
526        }
527
528        return $this->isValidateable() || $this->isFresh();
529    }
530
531    /**
532     * Returns true if the response is "fresh".
533     *
534     * Fresh responses may be served from cache without any interaction with the
535     * origin. A response is considered fresh when it includes a Cache-Control/max-age
536     * indicator or Expires header and the calculated age is less than the freshness lifetime.
537     *
538     * @final
539     */
540    public function isFresh(): bool
541    {
542        return $this->getTtl() > 0;
543    }
544
545    /**
546     * Returns true if the response includes headers that can be used to validate
547     * the response with the origin server using a conditional GET request.
548     *
549     * @final
550     */
551    public function isValidateable(): bool
552    {
553        return $this->headers->has('Last-Modified') || $this->headers->has('ETag');
554    }
555
556    /**
557     * Marks the response as "private".
558     *
559     * It makes the response ineligible for serving other clients.
560     *
561     * @return $this
562     *
563     * @final
564     */
565    public function setPrivate(): object
566    {
567        $this->headers->removeCacheControlDirective('public');
568        $this->headers->addCacheControlDirective('private');
569
570        return $this;
571    }
572
573    /**
574     * Marks the response as "public".
575     *
576     * It makes the response eligible for serving other clients.
577     *
578     * @return $this
579     *
580     * @final
581     */
582    public function setPublic(): object
583    {
584        $this->headers->addCacheControlDirective('public');
585        $this->headers->removeCacheControlDirective('private');
586
587        return $this;
588    }
589
590    /**
591     * Marks the response as "immutable".
592     *
593     * @return $this
594     *
595     * @final
596     */
597    public function setImmutable(bool $immutable = true): object
598    {
599        if ($immutable) {
600            $this->headers->addCacheControlDirective('immutable');
601        } else {
602            $this->headers->removeCacheControlDirective('immutable');
603        }
604
605        return $this;
606    }
607
608    /**
609     * Returns true if the response is marked as "immutable".
610     *
611     * @final
612     */
613    public function isImmutable(): bool
614    {
615        return $this->headers->hasCacheControlDirective('immutable');
616    }
617
618    /**
619     * Returns true if the response must be revalidated by shared caches once it has become stale.
620     *
621     * This method indicates that the response must not be served stale by a
622     * cache in any circumstance without first revalidating with the origin.
623     * When present, the TTL of the response should not be overridden to be
624     * greater than the value provided by the origin.
625     *
626     * @final
627     */
628    public function mustRevalidate(): bool
629    {
630        return $this->headers->hasCacheControlDirective('must-revalidate') || $this->headers->hasCacheControlDirective('proxy-revalidate');
631    }
632
633    /**
634     * Returns the Date header as a DateTime instance.
635     *
636     * @throws \RuntimeException When the header is not parseable
637     *
638     * @final
639     */
640    public function getDate(): ?\DateTimeInterface
641    {
642        return $this->headers->getDate('Date');
643    }
644
645    /**
646     * Sets the Date header.
647     *
648     * @return $this
649     *
650     * @final
651     */
652    public function setDate(\DateTimeInterface $date): object
653    {
654        if ($date instanceof \DateTime) {
655            $date = \DateTimeImmutable::createFromMutable($date);
656        }
657
658        $date = $date->setTimezone(new \DateTimeZone('UTC'));
659        $this->headers->set('Date', $date->format('D, d M Y H:i:s').' GMT');
660
661        return $this;
662    }
663
664    /**
665     * Returns the age of the response in seconds.
666     *
667     * @final
668     */
669    public function getAge(): int
670    {
671        if (null !== $age = $this->headers->get('Age')) {
672            return (int) $age;
673        }
674
675        return max(time() - (int) $this->getDate()->format('U'), 0);
676    }
677
678    /**
679     * Marks the response stale by setting the Age header to be equal to the maximum age of the response.
680     *
681     * @return $this
682     */
683    public function expire()
684    {
685        if ($this->isFresh()) {
686            $this->headers->set('Age', $this->getMaxAge());
687            $this->headers->remove('Expires');
688        }
689
690        return $this;
691    }
692
693    /**
694     * Returns the value of the Expires header as a DateTime instance.
695     *
696     * @final
697     */
698    public function getExpires(): ?\DateTimeInterface
699    {
700        try {
701            return $this->headers->getDate('Expires');
702        } catch (\RuntimeException $e) {
703            // according to RFC 2616 invalid date formats (e.g. "0" and "-1") must be treated as in the past
704            return \DateTime::createFromFormat('U', time() - 172800);
705        }
706    }
707
708    /**
709     * Sets the Expires HTTP header with a DateTime instance.
710     *
711     * Passing null as value will remove the header.
712     *
713     * @return $this
714     *
715     * @final
716     */
717    public function setExpires(\DateTimeInterface $date = null): object
718    {
719        if (null === $date) {
720            $this->headers->remove('Expires');
721
722            return $this;
723        }
724
725        if ($date instanceof \DateTime) {
726            $date = \DateTimeImmutable::createFromMutable($date);
727        }
728
729        $date = $date->setTimezone(new \DateTimeZone('UTC'));
730        $this->headers->set('Expires', $date->format('D, d M Y H:i:s').' GMT');
731
732        return $this;
733    }
734
735    /**
736     * Returns the number of seconds after the time specified in the response's Date
737     * header when the response should no longer be considered fresh.
738     *
739     * First, it checks for a s-maxage directive, then a max-age directive, and then it falls
740     * back on an expires header. It returns null when no maximum age can be established.
741     *
742     * @final
743     */
744    public function getMaxAge(): ?int
745    {
746        if ($this->headers->hasCacheControlDirective('s-maxage')) {
747            return (int) $this->headers->getCacheControlDirective('s-maxage');
748        }
749
750        if ($this->headers->hasCacheControlDirective('max-age')) {
751            return (int) $this->headers->getCacheControlDirective('max-age');
752        }
753
754        if (null !== $this->getExpires()) {
755            return (int) $this->getExpires()->format('U') - (int) $this->getDate()->format('U');
756        }
757
758        return null;
759    }
760
761    /**
762     * Sets the number of seconds after which the response should no longer be considered fresh.
763     *
764     * This methods sets the Cache-Control max-age directive.
765     *
766     * @return $this
767     *
768     * @final
769     */
770    public function setMaxAge(int $value): object
771    {
772        $this->headers->addCacheControlDirective('max-age', $value);
773
774        return $this;
775    }
776
777    /**
778     * Sets the number of seconds after which the response should no longer be considered fresh by shared caches.
779     *
780     * This methods sets the Cache-Control s-maxage directive.
781     *
782     * @return $this
783     *
784     * @final
785     */
786    public function setSharedMaxAge(int $value): object
787    {
788        $this->setPublic();
789        $this->headers->addCacheControlDirective('s-maxage', $value);
790
791        return $this;
792    }
793
794    /**
795     * Returns the response's time-to-live in seconds.
796     *
797     * It returns null when no freshness information is present in the response.
798     *
799     * When the responses TTL is <= 0, the response may not be served from cache without first
800     * revalidating with the origin.
801     *
802     * @final
803     */
804    public function getTtl(): ?int
805    {
806        $maxAge = $this->getMaxAge();
807
808        return null !== $maxAge ? $maxAge - $this->getAge() : null;
809    }
810
811    /**
812     * Sets the response's time-to-live for shared caches in seconds.
813     *
814     * This method adjusts the Cache-Control/s-maxage directive.
815     *
816     * @return $this
817     *
818     * @final
819     */
820    public function setTtl(int $seconds): object
821    {
822        $this->setSharedMaxAge($this->getAge() + $seconds);
823
824        return $this;
825    }
826
827    /**
828     * Sets the response's time-to-live for private/client caches in seconds.
829     *
830     * This method adjusts the Cache-Control/max-age directive.
831     *
832     * @return $this
833     *
834     * @final
835     */
836    public function setClientTtl(int $seconds): object
837    {
838        $this->setMaxAge($this->getAge() + $seconds);
839
840        return $this;
841    }
842
843    /**
844     * Returns the Last-Modified HTTP header as a DateTime instance.
845     *
846     * @throws \RuntimeException When the HTTP header is not parseable
847     *
848     * @final
849     */
850    public function getLastModified(): ?\DateTimeInterface
851    {
852        return $this->headers->getDate('Last-Modified');
853    }
854
855    /**
856     * Sets the Last-Modified HTTP header with a DateTime instance.
857     *
858     * Passing null as value will remove the header.
859     *
860     * @return $this
861     *
862     * @final
863     */
864    public function setLastModified(\DateTimeInterface $date = null): object
865    {
866        if (null === $date) {
867            $this->headers->remove('Last-Modified');
868
869            return $this;
870        }
871
872        if ($date instanceof \DateTime) {
873            $date = \DateTimeImmutable::createFromMutable($date);
874        }
875
876        $date = $date->setTimezone(new \DateTimeZone('UTC'));
877        $this->headers->set('Last-Modified', $date->format('D, d M Y H:i:s').' GMT');
878
879        return $this;
880    }
881
882    /**
883     * Returns the literal value of the ETag HTTP header.
884     *
885     * @final
886     */
887    public function getEtag(): ?string
888    {
889        return $this->headers->get('ETag');
890    }
891
892    /**
893     * Sets the ETag value.
894     *
895     * @param string|null $etag The ETag unique identifier or null to remove the header
896     * @param bool        $weak Whether you want a weak ETag or not
897     *
898     * @return $this
899     *
900     * @final
901     */
902    public function setEtag(string $etag = null, bool $weak = false): object
903    {
904        if (null === $etag) {
905            $this->headers->remove('Etag');
906        } else {
907            if (0 !== strpos($etag, '"')) {
908                $etag = '"'.$etag.'"';
909            }
910
911            $this->headers->set('ETag', (true === $weak ? 'W/' : '').$etag);
912        }
913
914        return $this;
915    }
916
917    /**
918     * Sets the response's cache headers (validation and/or expiration).
919     *
920     * Available options are: etag, last_modified, max_age, s_maxage, private, public and immutable.
921     *
922     * @return $this
923     *
924     * @throws \InvalidArgumentException
925     *
926     * @final
927     */
928    public function setCache(array $options): object
929    {
930        if ($diff = array_diff(array_keys($options), ['etag', 'last_modified', 'max_age', 's_maxage', 'private', 'public', 'immutable'])) {
931            throw new \InvalidArgumentException(sprintf('Response does not support the following options: "%s".', implode('", "', $diff)));
932        }
933
934        if (isset($options['etag'])) {
935            $this->setEtag($options['etag']);
936        }
937
938        if (isset($options['last_modified'])) {
939            $this->setLastModified($options['last_modified']);
940        }
941
942        if (isset($options['max_age'])) {
943            $this->setMaxAge($options['max_age']);
944        }
945
946        if (isset($options['s_maxage'])) {
947            $this->setSharedMaxAge($options['s_maxage']);
948        }
949
950        if (isset($options['public'])) {
951            if ($options['public']) {
952                $this->setPublic();
953            } else {
954                $this->setPrivate();
955            }
956        }
957
958        if (isset($options['private'])) {
959            if ($options['private']) {
960                $this->setPrivate();
961            } else {
962                $this->setPublic();
963            }
964        }
965
966        if (isset($options['immutable'])) {
967            $this->setImmutable((bool) $options['immutable']);
968        }
969
970        return $this;
971    }
972
973    /**
974     * Modifies the response so that it conforms to the rules defined for a 304 status code.
975     *
976     * This sets the status, removes the body, and discards any headers
977     * that MUST NOT be included in 304 responses.
978     *
979     * @return $this
980     *
981     * @see https://tools.ietf.org/html/rfc2616#section-10.3.5
982     *
983     * @final
984     */
985    public function setNotModified(): object
986    {
987        $this->setStatusCode(304);
988        $this->setContent(null);
989
990        // remove headers that MUST NOT be included with 304 Not Modified responses
991        foreach (['Allow', 'Content-Encoding', 'Content-Language', 'Content-Length', 'Content-MD5', 'Content-Type', 'Last-Modified'] as $header) {
992            $this->headers->remove($header);
993        }
994
995        return $this;
996    }
997
998    /**
999     * Returns true if the response includes a Vary header.
1000     *
1001     * @final
1002     */
1003    public function hasVary(): bool
1004    {
1005        return null !== $this->headers->get('Vary');
1006    }
1007
1008    /**
1009     * Returns an array of header names given in the Vary header.
1010     *
1011     * @final
1012     */
1013    public function getVary(): array
1014    {
1015        if (!$vary = $this->headers->all('Vary')) {
1016            return [];
1017        }
1018
1019        $ret = [];
1020        foreach ($vary as $item) {
1021            $ret = array_merge($ret, preg_split('/[\s,]+/', $item));
1022        }
1023
1024        return $ret;
1025    }
1026
1027    /**
1028     * Sets the Vary header.
1029     *
1030     * @param string|array $headers
1031     * @param bool         $replace Whether to replace the actual value or not (true by default)
1032     *
1033     * @return $this
1034     *
1035     * @final
1036     */
1037    public function setVary($headers, bool $replace = true): object
1038    {
1039        $this->headers->set('Vary', $headers, $replace);
1040
1041        return $this;
1042    }
1043
1044    /**
1045     * Determines if the Response validators (ETag, Last-Modified) match
1046     * a conditional value specified in the Request.
1047     *
1048     * If the Response is not modified, it sets the status code to 304 and
1049     * removes the actual content by calling the setNotModified() method.
1050     *
1051     * @return bool true if the Response validators match the Request, false otherwise
1052     *
1053     * @final
1054     */
1055    public function isNotModified(Request $request): bool
1056    {
1057        if (!$request->isMethodCacheable()) {
1058            return false;
1059        }
1060
1061        $notModified = false;
1062        $lastModified = $this->headers->get('Last-Modified');
1063        $modifiedSince = $request->headers->get('If-Modified-Since');
1064
1065        if ($etags = $request->getETags()) {
1066            $notModified = \in_array($this->getEtag(), $etags) || \in_array('*', $etags);
1067        }
1068
1069        if ($modifiedSince && $lastModified) {
1070            $notModified = strtotime($modifiedSince) >= strtotime($lastModified) && (!$etags || $notModified);
1071        }
1072
1073        if ($notModified) {
1074            $this->setNotModified();
1075        }
1076
1077        return $notModified;
1078    }
1079
1080    /**
1081     * Is response invalid?
1082     *
1083     * @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
1084     *
1085     * @final
1086     */
1087    public function isInvalid(): bool
1088    {
1089        return $this->statusCode < 100 || $this->statusCode >= 600;
1090    }
1091
1092    /**
1093     * Is response informative?
1094     *
1095     * @final
1096     */
1097    public function isInformational(): bool
1098    {
1099        return $this->statusCode >= 100 && $this->statusCode < 200;
1100    }
1101
1102    /**
1103     * Is response successful?
1104     *
1105     * @final
1106     */
1107    public function isSuccessful(): bool
1108    {
1109        return $this->statusCode >= 200 && $this->statusCode < 300;
1110    }
1111
1112    /**
1113     * Is the response a redirect?
1114     *
1115     * @final
1116     */
1117    public function isRedirection(): bool
1118    {
1119        return $this->statusCode >= 300 && $this->statusCode < 400;
1120    }
1121
1122    /**
1123     * Is there a client error?
1124     *
1125     * @final
1126     */
1127    public function isClientError(): bool
1128    {
1129        return $this->statusCode >= 400 && $this->statusCode < 500;
1130    }
1131
1132    /**
1133     * Was there a server side error?
1134     *
1135     * @final
1136     */
1137    public function isServerError(): bool
1138    {
1139        return $this->statusCode >= 500 && $this->statusCode < 600;
1140    }
1141
1142    /**
1143     * Is the response OK?
1144     *
1145     * @final
1146     */
1147    public function isOk(): bool
1148    {
1149        return 200 === $this->statusCode;
1150    }
1151
1152    /**
1153     * Is the response forbidden?
1154     *
1155     * @final
1156     */
1157    public function isForbidden(): bool
1158    {
1159        return 403 === $this->statusCode;
1160    }
1161
1162    /**
1163     * Is the response a not found error?
1164     *
1165     * @final
1166     */
1167    public function isNotFound(): bool
1168    {
1169        return 404 === $this->statusCode;
1170    }
1171
1172    /**
1173     * Is the response a redirect of some form?
1174     *
1175     * @final
1176     */
1177    public function isRedirect(string $location = null): bool
1178    {
1179        return \in_array($this->statusCode, [201, 301, 302, 303, 307, 308]) && (null === $location ?: $location == $this->headers->get('Location'));
1180    }
1181
1182    /**
1183     * Is the response empty?
1184     *
1185     * @final
1186     */
1187    public function isEmpty(): bool
1188    {
1189        return \in_array($this->statusCode, [204, 304]);
1190    }
1191
1192    /**
1193     * Cleans or flushes output buffers up to target level.
1194     *
1195     * Resulting level can be greater than target level if a non-removable buffer has been encountered.
1196     *
1197     * @final
1198     */
1199    public static function closeOutputBuffers(int $targetLevel, bool $flush): void
1200    {
1201        $status = ob_get_status(true);
1202        $level = \count($status);
1203        $flags = PHP_OUTPUT_HANDLER_REMOVABLE | ($flush ? PHP_OUTPUT_HANDLER_FLUSHABLE : PHP_OUTPUT_HANDLER_CLEANABLE);
1204
1205        while ($level-- > $targetLevel && ($s = $status[$level]) && (!isset($s['del']) ? !isset($s['flags']) || ($s['flags'] & $flags) === $flags : $s['del'])) {
1206            if ($flush) {
1207                ob_end_flush();
1208            } else {
1209                ob_end_clean();
1210            }
1211        }
1212    }
1213
1214    /**
1215     * Checks if we need to remove Cache-Control for SSL encrypted downloads when using IE < 9.
1216     *
1217     * @see http://support.microsoft.com/kb/323308
1218     *
1219     * @final
1220     */
1221    protected function ensureIEOverSSLCompatibility(Request $request): void
1222    {
1223        if (false !== stripos($this->headers->get('Content-Disposition'), 'attachment') && 1 == preg_match('/MSIE (.*?);/i', $request->server->get('HTTP_USER_AGENT'), $match) && true === $request->isSecure()) {
1224            if ((int) preg_replace('/(MSIE )(.*?);/', '$2', $match[0]) < 9) {
1225                $this->headers->remove('Cache-Control');
1226            }
1227        }
1228    }
1229}
1230