1<?php 2 3/* 4 * This file is part of the Symfony package. 5 * 6 * (c) Fabien Potencier <fabien@symfony.com> 7 * 8 * This code is partially based on the Rack-Cache library by Ryan Tomayko, 9 * which is released under the MIT license. 10 * (based on commit 02d2b48d75bcb63cf1c0c7149c077ad256542801) 11 * 12 * For the full copyright and license information, please view the LICENSE 13 * file that was distributed with this source code. 14 */ 15 16namespace Symfony\Component\HttpKernel\HttpCache; 17 18use Symfony\Component\HttpFoundation\Request; 19use Symfony\Component\HttpFoundation\Response; 20use Symfony\Component\HttpKernel\HttpKernelInterface; 21use Symfony\Component\HttpKernel\TerminableInterface; 22 23/** 24 * Cache provides HTTP caching. 25 * 26 * @author Fabien Potencier <fabien@symfony.com> 27 */ 28class HttpCache implements HttpKernelInterface, TerminableInterface 29{ 30 private $kernel; 31 private $store; 32 private $request; 33 private $surrogate; 34 private $surrogateCacheStrategy; 35 private $options = []; 36 private $traces = []; 37 38 /** 39 * Constructor. 40 * 41 * The available options are: 42 * 43 * * debug If true, exceptions are thrown when things go wrong. Otherwise, the cache 44 * will try to carry on and deliver a meaningful response. 45 * 46 * * trace_level May be one of 'none', 'short' and 'full'. For 'short', a concise trace of the 47 * master request will be added as an HTTP header. 'full' will add traces for all 48 * requests (including ESI subrequests). (default: 'full' if in debug; 'none' otherwise) 49 * 50 * * trace_header Header name to use for traces. (default: X-Symfony-Cache) 51 * 52 * * default_ttl The number of seconds that a cache entry should be considered 53 * fresh when no explicit freshness information is provided in 54 * a response. Explicit Cache-Control or Expires headers 55 * override this value. (default: 0) 56 * 57 * * private_headers Set of request headers that trigger "private" cache-control behavior 58 * on responses that don't explicitly state whether the response is 59 * public or private via a Cache-Control directive. (default: Authorization and Cookie) 60 * 61 * * allow_reload Specifies whether the client can force a cache reload by including a 62 * Cache-Control "no-cache" directive in the request. Set it to ``true`` 63 * for compliance with RFC 2616. (default: false) 64 * 65 * * allow_revalidate Specifies whether the client can force a cache revalidate by including 66 * a Cache-Control "max-age=0" directive in the request. Set it to ``true`` 67 * for compliance with RFC 2616. (default: false) 68 * 69 * * stale_while_revalidate Specifies the default number of seconds (the granularity is the second as the 70 * Response TTL precision is a second) during which the cache can immediately return 71 * a stale response while it revalidates it in the background (default: 2). 72 * This setting is overridden by the stale-while-revalidate HTTP Cache-Control 73 * extension (see RFC 5861). 74 * 75 * * stale_if_error Specifies the default number of seconds (the granularity is the second) during which 76 * the cache can serve a stale response when an error is encountered (default: 60). 77 * This setting is overridden by the stale-if-error HTTP Cache-Control extension 78 * (see RFC 5861). 79 */ 80 public function __construct(HttpKernelInterface $kernel, StoreInterface $store, SurrogateInterface $surrogate = null, array $options = []) 81 { 82 $this->store = $store; 83 $this->kernel = $kernel; 84 $this->surrogate = $surrogate; 85 86 // needed in case there is a fatal error because the backend is too slow to respond 87 register_shutdown_function([$this->store, 'cleanup']); 88 89 $this->options = array_merge([ 90 'debug' => false, 91 'default_ttl' => 0, 92 'private_headers' => ['Authorization', 'Cookie'], 93 'allow_reload' => false, 94 'allow_revalidate' => false, 95 'stale_while_revalidate' => 2, 96 'stale_if_error' => 60, 97 'trace_level' => 'none', 98 'trace_header' => 'X-Symfony-Cache', 99 ], $options); 100 101 if (!isset($options['trace_level'])) { 102 $this->options['trace_level'] = $this->options['debug'] ? 'full' : 'none'; 103 } 104 } 105 106 /** 107 * Gets the current store. 108 * 109 * @return StoreInterface A StoreInterface instance 110 */ 111 public function getStore() 112 { 113 return $this->store; 114 } 115 116 /** 117 * Returns an array of events that took place during processing of the last request. 118 * 119 * @return array An array of events 120 */ 121 public function getTraces() 122 { 123 return $this->traces; 124 } 125 126 private function addTraces(Response $response) 127 { 128 $traceString = null; 129 130 if ('full' === $this->options['trace_level']) { 131 $traceString = $this->getLog(); 132 } 133 134 if ('short' === $this->options['trace_level'] && $masterId = array_key_first($this->traces)) { 135 $traceString = implode('/', $this->traces[$masterId]); 136 } 137 138 if (null !== $traceString) { 139 $response->headers->add([$this->options['trace_header'] => $traceString]); 140 } 141 } 142 143 /** 144 * Returns a log message for the events of the last request processing. 145 * 146 * @return string A log message 147 */ 148 public function getLog() 149 { 150 $log = []; 151 foreach ($this->traces as $request => $traces) { 152 $log[] = sprintf('%s: %s', $request, implode(', ', $traces)); 153 } 154 155 return implode('; ', $log); 156 } 157 158 /** 159 * Gets the Request instance associated with the master request. 160 * 161 * @return Request A Request instance 162 */ 163 public function getRequest() 164 { 165 return $this->request; 166 } 167 168 /** 169 * Gets the Kernel instance. 170 * 171 * @return HttpKernelInterface An HttpKernelInterface instance 172 */ 173 public function getKernel() 174 { 175 return $this->kernel; 176 } 177 178 /** 179 * Gets the Surrogate instance. 180 * 181 * @return SurrogateInterface A Surrogate instance 182 * 183 * @throws \LogicException 184 */ 185 public function getSurrogate() 186 { 187 return $this->surrogate; 188 } 189 190 /** 191 * {@inheritdoc} 192 */ 193 public function handle(Request $request, $type = HttpKernelInterface::MASTER_REQUEST, $catch = true) 194 { 195 // FIXME: catch exceptions and implement a 500 error page here? -> in Varnish, there is a built-in error page mechanism 196 if (HttpKernelInterface::MASTER_REQUEST === $type) { 197 $this->traces = []; 198 // Keep a clone of the original request for surrogates so they can access it. 199 // We must clone here to get a separate instance because the application will modify the request during 200 // the application flow (we know it always does because we do ourselves by setting REMOTE_ADDR to 127.0.0.1 201 // and adding the X-Forwarded-For header, see HttpCache::forward()). 202 $this->request = clone $request; 203 if (null !== $this->surrogate) { 204 $this->surrogateCacheStrategy = $this->surrogate->createCacheStrategy(); 205 } 206 } 207 208 $this->traces[$this->getTraceKey($request)] = []; 209 210 if (!$request->isMethodSafe()) { 211 $response = $this->invalidate($request, $catch); 212 } elseif ($request->headers->has('expect') || !$request->isMethodCacheable()) { 213 $response = $this->pass($request, $catch); 214 } elseif ($this->options['allow_reload'] && $request->isNoCache()) { 215 /* 216 If allow_reload is configured and the client requests "Cache-Control: no-cache", 217 reload the cache by fetching a fresh response and caching it (if possible). 218 */ 219 $this->record($request, 'reload'); 220 $response = $this->fetch($request, $catch); 221 } else { 222 $response = $this->lookup($request, $catch); 223 } 224 225 $this->restoreResponseBody($request, $response); 226 227 if (HttpKernelInterface::MASTER_REQUEST === $type) { 228 $this->addTraces($response); 229 } 230 231 if (null !== $this->surrogate) { 232 if (HttpKernelInterface::MASTER_REQUEST === $type) { 233 $this->surrogateCacheStrategy->update($response); 234 } else { 235 $this->surrogateCacheStrategy->add($response); 236 } 237 } 238 239 $response->prepare($request); 240 241 $response->isNotModified($request); 242 243 return $response; 244 } 245 246 /** 247 * {@inheritdoc} 248 */ 249 public function terminate(Request $request, Response $response) 250 { 251 if ($this->getKernel() instanceof TerminableInterface) { 252 $this->getKernel()->terminate($request, $response); 253 } 254 } 255 256 /** 257 * Forwards the Request to the backend without storing the Response in the cache. 258 * 259 * @param bool $catch Whether to process exceptions 260 * 261 * @return Response A Response instance 262 */ 263 protected function pass(Request $request, $catch = false) 264 { 265 $this->record($request, 'pass'); 266 267 return $this->forward($request, $catch); 268 } 269 270 /** 271 * Invalidates non-safe methods (like POST, PUT, and DELETE). 272 * 273 * @param bool $catch Whether to process exceptions 274 * 275 * @return Response A Response instance 276 * 277 * @throws \Exception 278 * 279 * @see RFC2616 13.10 280 */ 281 protected function invalidate(Request $request, $catch = false) 282 { 283 $response = $this->pass($request, $catch); 284 285 // invalidate only when the response is successful 286 if ($response->isSuccessful() || $response->isRedirect()) { 287 try { 288 $this->store->invalidate($request); 289 290 // As per the RFC, invalidate Location and Content-Location URLs if present 291 foreach (['Location', 'Content-Location'] as $header) { 292 if ($uri = $response->headers->get($header)) { 293 $subRequest = Request::create($uri, 'get', [], [], [], $request->server->all()); 294 295 $this->store->invalidate($subRequest); 296 } 297 } 298 299 $this->record($request, 'invalidate'); 300 } catch (\Exception $e) { 301 $this->record($request, 'invalidate-failed'); 302 303 if ($this->options['debug']) { 304 throw $e; 305 } 306 } 307 } 308 309 return $response; 310 } 311 312 /** 313 * Lookups a Response from the cache for the given Request. 314 * 315 * When a matching cache entry is found and is fresh, it uses it as the 316 * response without forwarding any request to the backend. When a matching 317 * cache entry is found but is stale, it attempts to "validate" the entry with 318 * the backend using conditional GET. When no matching cache entry is found, 319 * it triggers "miss" processing. 320 * 321 * @param bool $catch Whether to process exceptions 322 * 323 * @return Response A Response instance 324 * 325 * @throws \Exception 326 */ 327 protected function lookup(Request $request, $catch = false) 328 { 329 try { 330 $entry = $this->store->lookup($request); 331 } catch (\Exception $e) { 332 $this->record($request, 'lookup-failed'); 333 334 if ($this->options['debug']) { 335 throw $e; 336 } 337 338 return $this->pass($request, $catch); 339 } 340 341 if (null === $entry) { 342 $this->record($request, 'miss'); 343 344 return $this->fetch($request, $catch); 345 } 346 347 if (!$this->isFreshEnough($request, $entry)) { 348 $this->record($request, 'stale'); 349 350 return $this->validate($request, $entry, $catch); 351 } 352 353 if ($entry->headers->hasCacheControlDirective('no-cache')) { 354 return $this->validate($request, $entry, $catch); 355 } 356 357 $this->record($request, 'fresh'); 358 359 $entry->headers->set('Age', $entry->getAge()); 360 361 return $entry; 362 } 363 364 /** 365 * Validates that a cache entry is fresh. 366 * 367 * The original request is used as a template for a conditional 368 * GET request with the backend. 369 * 370 * @param bool $catch Whether to process exceptions 371 * 372 * @return Response A Response instance 373 */ 374 protected function validate(Request $request, Response $entry, $catch = false) 375 { 376 $subRequest = clone $request; 377 378 // send no head requests because we want content 379 if ('HEAD' === $request->getMethod()) { 380 $subRequest->setMethod('GET'); 381 } 382 383 // add our cached last-modified validator 384 if ($entry->headers->has('Last-Modified')) { 385 $subRequest->headers->set('if_modified_since', $entry->headers->get('Last-Modified')); 386 } 387 388 // Add our cached etag validator to the environment. 389 // We keep the etags from the client to handle the case when the client 390 // has a different private valid entry which is not cached here. 391 $cachedEtags = $entry->getEtag() ? [$entry->getEtag()] : []; 392 $requestEtags = $request->getETags(); 393 if ($etags = array_unique(array_merge($cachedEtags, $requestEtags))) { 394 $subRequest->headers->set('if_none_match', implode(', ', $etags)); 395 } 396 397 $response = $this->forward($subRequest, $catch, $entry); 398 399 if (304 == $response->getStatusCode()) { 400 $this->record($request, 'valid'); 401 402 // return the response and not the cache entry if the response is valid but not cached 403 $etag = $response->getEtag(); 404 if ($etag && \in_array($etag, $requestEtags) && !\in_array($etag, $cachedEtags)) { 405 return $response; 406 } 407 408 $entry = clone $entry; 409 $entry->headers->remove('Date'); 410 411 foreach (['Date', 'Expires', 'Cache-Control', 'ETag', 'Last-Modified'] as $name) { 412 if ($response->headers->has($name)) { 413 $entry->headers->set($name, $response->headers->get($name)); 414 } 415 } 416 417 $response = $entry; 418 } else { 419 $this->record($request, 'invalid'); 420 } 421 422 if ($response->isCacheable()) { 423 $this->store($request, $response); 424 } 425 426 return $response; 427 } 428 429 /** 430 * Unconditionally fetches a fresh response from the backend and 431 * stores it in the cache if is cacheable. 432 * 433 * @param bool $catch Whether to process exceptions 434 * 435 * @return Response A Response instance 436 */ 437 protected function fetch(Request $request, $catch = false) 438 { 439 $subRequest = clone $request; 440 441 // send no head requests because we want content 442 if ('HEAD' === $request->getMethod()) { 443 $subRequest->setMethod('GET'); 444 } 445 446 // avoid that the backend sends no content 447 $subRequest->headers->remove('if_modified_since'); 448 $subRequest->headers->remove('if_none_match'); 449 450 $response = $this->forward($subRequest, $catch); 451 452 if ($response->isCacheable()) { 453 $this->store($request, $response); 454 } 455 456 return $response; 457 } 458 459 /** 460 * Forwards the Request to the backend and returns the Response. 461 * 462 * All backend requests (cache passes, fetches, cache validations) 463 * run through this method. 464 * 465 * @param bool $catch Whether to catch exceptions or not 466 * @param Response|null $entry A Response instance (the stale entry if present, null otherwise) 467 * 468 * @return Response A Response instance 469 */ 470 protected function forward(Request $request, $catch = false, Response $entry = null) 471 { 472 if ($this->surrogate) { 473 $this->surrogate->addSurrogateCapability($request); 474 } 475 476 // always a "master" request (as the real master request can be in cache) 477 $response = SubRequestHandler::handle($this->kernel, $request, HttpKernelInterface::MASTER_REQUEST, $catch); 478 479 /* 480 * Support stale-if-error given on Responses or as a config option. 481 * RFC 7234 summarizes in Section 4.2.4 (but also mentions with the individual 482 * Cache-Control directives) that 483 * 484 * A cache MUST NOT generate a stale response if it is prohibited by an 485 * explicit in-protocol directive (e.g., by a "no-store" or "no-cache" 486 * cache directive, a "must-revalidate" cache-response-directive, or an 487 * applicable "s-maxage" or "proxy-revalidate" cache-response-directive; 488 * see Section 5.2.2). 489 * 490 * https://tools.ietf.org/html/rfc7234#section-4.2.4 491 * 492 * We deviate from this in one detail, namely that we *do* serve entries in the 493 * stale-if-error case even if they have a `s-maxage` Cache-Control directive. 494 */ 495 if (null !== $entry 496 && \in_array($response->getStatusCode(), [500, 502, 503, 504]) 497 && !$entry->headers->hasCacheControlDirective('no-cache') 498 && !$entry->mustRevalidate() 499 ) { 500 if (null === $age = $entry->headers->getCacheControlDirective('stale-if-error')) { 501 $age = $this->options['stale_if_error']; 502 } 503 504 /* 505 * stale-if-error gives the (extra) time that the Response may be used *after* it has become stale. 506 * So we compare the time the $entry has been sitting in the cache already with the 507 * time it was fresh plus the allowed grace period. 508 */ 509 if ($entry->getAge() <= $entry->getMaxAge() + $age) { 510 $this->record($request, 'stale-if-error'); 511 512 return $entry; 513 } 514 } 515 516 /* 517 RFC 7231 Sect. 7.1.1.2 says that a server that does not have a reasonably accurate 518 clock MUST NOT send a "Date" header, although it MUST send one in most other cases 519 except for 1xx or 5xx responses where it MAY do so. 520 521 Anyway, a client that received a message without a "Date" header MUST add it. 522 */ 523 if (!$response->headers->has('Date')) { 524 $response->setDate(\DateTime::createFromFormat('U', time())); 525 } 526 527 $this->processResponseBody($request, $response); 528 529 if ($this->isPrivateRequest($request) && !$response->headers->hasCacheControlDirective('public')) { 530 $response->setPrivate(); 531 } elseif ($this->options['default_ttl'] > 0 && null === $response->getTtl() && !$response->headers->getCacheControlDirective('must-revalidate')) { 532 $response->setTtl($this->options['default_ttl']); 533 } 534 535 return $response; 536 } 537 538 /** 539 * Checks whether the cache entry is "fresh enough" to satisfy the Request. 540 * 541 * @return bool true if the cache entry if fresh enough, false otherwise 542 */ 543 protected function isFreshEnough(Request $request, Response $entry) 544 { 545 if (!$entry->isFresh()) { 546 return $this->lock($request, $entry); 547 } 548 549 if ($this->options['allow_revalidate'] && null !== $maxAge = $request->headers->getCacheControlDirective('max-age')) { 550 return $maxAge > 0 && $maxAge >= $entry->getAge(); 551 } 552 553 return true; 554 } 555 556 /** 557 * Locks a Request during the call to the backend. 558 * 559 * @return bool true if the cache entry can be returned even if it is staled, false otherwise 560 */ 561 protected function lock(Request $request, Response $entry) 562 { 563 // try to acquire a lock to call the backend 564 $lock = $this->store->lock($request); 565 566 if (true === $lock) { 567 // we have the lock, call the backend 568 return false; 569 } 570 571 // there is already another process calling the backend 572 573 // May we serve a stale response? 574 if ($this->mayServeStaleWhileRevalidate($entry)) { 575 $this->record($request, 'stale-while-revalidate'); 576 577 return true; 578 } 579 580 // wait for the lock to be released 581 if ($this->waitForLock($request)) { 582 // replace the current entry with the fresh one 583 $new = $this->lookup($request); 584 $entry->headers = $new->headers; 585 $entry->setContent($new->getContent()); 586 $entry->setStatusCode($new->getStatusCode()); 587 $entry->setProtocolVersion($new->getProtocolVersion()); 588 foreach ($new->headers->getCookies() as $cookie) { 589 $entry->headers->setCookie($cookie); 590 } 591 } else { 592 // backend is slow as hell, send a 503 response (to avoid the dog pile effect) 593 $entry->setStatusCode(503); 594 $entry->setContent('503 Service Unavailable'); 595 $entry->headers->set('Retry-After', 10); 596 } 597 598 return true; 599 } 600 601 /** 602 * Writes the Response to the cache. 603 * 604 * @throws \Exception 605 */ 606 protected function store(Request $request, Response $response) 607 { 608 try { 609 $this->store->write($request, $response); 610 611 $this->record($request, 'store'); 612 613 $response->headers->set('Age', $response->getAge()); 614 } catch (\Exception $e) { 615 $this->record($request, 'store-failed'); 616 617 if ($this->options['debug']) { 618 throw $e; 619 } 620 } 621 622 // now that the response is cached, release the lock 623 $this->store->unlock($request); 624 } 625 626 /** 627 * Restores the Response body. 628 */ 629 private function restoreResponseBody(Request $request, Response $response) 630 { 631 if ($response->headers->has('X-Body-Eval')) { 632 ob_start(); 633 634 if ($response->headers->has('X-Body-File')) { 635 include $response->headers->get('X-Body-File'); 636 } else { 637 eval('; ?>'.$response->getContent().'<?php ;'); 638 } 639 640 $response->setContent(ob_get_clean()); 641 $response->headers->remove('X-Body-Eval'); 642 if (!$response->headers->has('Transfer-Encoding')) { 643 $response->headers->set('Content-Length', \strlen($response->getContent())); 644 } 645 } elseif ($response->headers->has('X-Body-File')) { 646 // Response does not include possibly dynamic content (ESI, SSI), so we need 647 // not handle the content for HEAD requests 648 if (!$request->isMethod('HEAD')) { 649 $response->setContent(file_get_contents($response->headers->get('X-Body-File'))); 650 } 651 } else { 652 return; 653 } 654 655 $response->headers->remove('X-Body-File'); 656 } 657 658 protected function processResponseBody(Request $request, Response $response) 659 { 660 if (null !== $this->surrogate && $this->surrogate->needsParsing($response)) { 661 $this->surrogate->process($request, $response); 662 } 663 } 664 665 /** 666 * Checks if the Request includes authorization or other sensitive information 667 * that should cause the Response to be considered private by default. 668 */ 669 private function isPrivateRequest(Request $request): bool 670 { 671 foreach ($this->options['private_headers'] as $key) { 672 $key = strtolower(str_replace('HTTP_', '', $key)); 673 674 if ('cookie' === $key) { 675 if (\count($request->cookies->all())) { 676 return true; 677 } 678 } elseif ($request->headers->has($key)) { 679 return true; 680 } 681 } 682 683 return false; 684 } 685 686 /** 687 * Records that an event took place. 688 */ 689 private function record(Request $request, string $event) 690 { 691 $this->traces[$this->getTraceKey($request)][] = $event; 692 } 693 694 /** 695 * Calculates the key we use in the "trace" array for a given request. 696 */ 697 private function getTraceKey(Request $request): string 698 { 699 $path = $request->getPathInfo(); 700 if ($qs = $request->getQueryString()) { 701 $path .= '?'.$qs; 702 } 703 704 return $request->getMethod().' '.$path; 705 } 706 707 /** 708 * Checks whether the given (cached) response may be served as "stale" when a revalidation 709 * is currently in progress. 710 */ 711 private function mayServeStaleWhileRevalidate(Response $entry): bool 712 { 713 $timeout = $entry->headers->getCacheControlDirective('stale-while-revalidate'); 714 715 if (null === $timeout) { 716 $timeout = $this->options['stale_while_revalidate']; 717 } 718 719 return abs($entry->getTtl()) < $timeout; 720 } 721 722 /** 723 * Waits for the store to release a locked entry. 724 */ 725 private function waitForLock(Request $request): bool 726 { 727 $wait = 0; 728 while ($this->store->isLocked($request) && $wait < 100) { 729 usleep(50000); 730 ++$wait; 731 } 732 733 return $wait < 100; 734 } 735} 736