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