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