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