src/AppBundle/Controller/EsiController.php line 69

Open in your IDE?
  1. <?php
  2. namespace AppBundle\Controller;
  3. use Psr\Log\LoggerInterface;
  4. use Symfony\Component\HttpFoundation\Response;
  5. use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
  6. /**
  7.  * Controller for page fragments rendered as Edge Side Includes (ESI)
  8.  *
  9.  * ESI would normally be rendered via `{{ render_esi(controller(...)) }}` function in twig, however that method
  10.  * generates a unique signature for each fragment, hence making URI un-guessable. Even though this makes sense from
  11.  * security viewpoint (in some scenarios), it would prevent us to selectively invalidate varnish cache via PURGE method.
  12.  *
  13.  * Registering this controller as a separate route and rendering ESI tags manually allows us to use ESI fragments at
  14.  * predictable URIs.
  15.  *
  16.  * Adding new ESI fragments for existing twig `render(controller(...))` calls:
  17.  *
  18.  * 1. register a route - see e.g. `esiModerators` in `routing.yml`
  19.  * 2. add method that wraps the original controller method (see e.g.
  20.  *    {@link EsiController::showModeratorsAction()})
  21.  * 3. verify that cache/vary headers added by the original controller method are in line with what you set in your new
  22.  *    EsiController method
  23.  * 4. replace original `render()` calls with `esi_wrapper('nameOfNewEsiRoute')` (see example below)
  24.  *
  25.  * **Note:**
  26.  * It is _immensely_ important to differentiate between ESI fragments that are user-agnostic (`esi/anonymous`) and those
  27.  * that are not. The former can call `::setResponseCacheHeaders($response, false, ...)` while the latter _must_ add
  28.  * `Vary: cookie` header by using `true` for the second argument! Varnish should then merge the `Vary` header internally
  29.  * and differentiate cache entries per-user (while still disregarding cookies for ESI fragments under `esi/anonymous`
  30.  * route).
  31.  *
  32.  * Also see ESI-specific comment in `routing.yml` regarding `anonymous/shared/user` link format for ESI fragments.
  33.  *
  34.  * Example - route without route params:
  35.  *
  36.  *     {{ esi_wrapper('esiModerators') }}
  37.  *
  38.  * Example - route with route params:
  39.  *
  40.  *     {{ esi_wrapper('esiImportedRss', {'context': 'homepage', 'rssSlug': 'fmg'}) }}
  41.  */
  42. class EsiController extends BaseController
  43. {
  44.     /**
  45.      * Render site header menu as ESI fragment
  46.      *
  47.      * @return Response
  48.      */
  49.     public function showHeaderMenuAction()
  50.     {
  51.         // render via original controller
  52.         $response $this->forward('AppBundle:Snippets:getHeaderMenu');
  53.         // ... but override cache headers
  54.         $isEsiAnonymous true;
  55.         $response $this->setResponseCacheHeadersFromConfig($response, !$isEsiAnonymous'cache_ttl_seconds_menu');
  56.         return $response;
  57.     }
  58.     /**
  59.      * Render site footer as ESI fragment
  60.      *
  61.      * @return Response
  62.      */
  63.     public function showFooterAction()
  64.     {
  65.         // render via original controller
  66.         $response $this->forward('AppBundle:Snippets:getFooter');
  67.         // ... but override cache headers
  68.         $isEsiAnonymous true;
  69.         $response $this->setResponseCacheHeadersFromConfig($response, !$isEsiAnonymous'cache_ttl_seconds_menu');
  70.         return $response;
  71.     }
  72.     /**
  73.      * Generic ESI renderer for any content loaded from locally cached JSON files
  74.      *
  75.      * Cache expiration is determined based on different between cron interval vs last modification time of JSON file.
  76.      *
  77.      * @param string $context Context where ESI fragment is displayed, i.e. `homepage`, (page) `section`or `sidebar`
  78.      * @param string $rssSlug Slug identifying which cached JSON file to use
  79.      *
  80.      * @return Response
  81.      */
  82.     public function showImportedRssAction($context$rssSlug)
  83.     {
  84.         // identify active configuration
  85.         list('forward' => $forward'cacheKey' => $cacheKey) = $this->getImportedRssConfiguration($context$rssSlug);
  86.         // render via original controller
  87.         $response $this->forward($forward);
  88.         // determine cache interval
  89.         $cronInterval $this->getOptionValue($cacheKey);
  90.         $cacheInterval $this->getCacheIntervalFromFileModificationTime("data/feeds/{$rssSlug}.json"$cronInterval);
  91.         // override cache headers
  92.         $isEsiAnonymous true;
  93.         $response $this->setResponseCacheHeaders($response, !$isEsiAnonymous$cacheInterval);
  94.         return $response;
  95.     }
  96.     /**
  97.      * Configuration helper for {@link EsiController::showImportedRssAction}
  98.      *
  99.      * @param string $context See {@link EsiController::showImportedRssAction}
  100.      * @param string $rssSlug See {@link EsiController::showImportedRssAction}
  101.      *
  102.      * @return array Returns array with configuration keys `forward` and `cacheKey`
  103.      */
  104.     private function getImportedRssConfiguration($context$rssSlug)
  105.     {
  106.         $defaults = ['cacheKey' => 'cron_interval_rss'];
  107.         $config = [
  108.             // 'context/rssSlug' => [ 'forward' => 'bundle:controller:method', 'cacheKey' => 'cron_interval_foobar' ]
  109.             'homepage/fmg' => ['forward' => 'AppBundle:Homepage:showFmgArticles'],
  110.             // examples:
  111.             // 'homepage/fmg'   => ['forward' => 'AppBundle:Homepage:showFmgArticles', 'cacheKey' => 'croninterval_rss_fmg'],
  112.             //  'section/hokej' => ['forward' => 'AppBundle:Page:showHokejArticles']
  113.             //    'aside/*'     => ['forward' => 'AppBundle:Snippets:showSidebarRssArticles']
  114.             //        '*/hokej' => ['forward' => 'AppBundle:Snippets:showHokejArticles']
  115.         ];
  116.         // check if configuration is available for requested route parameters
  117.         switch (true) {
  118.             // direct match
  119.             case array_key_exists("{$context}/{$rssSlug}"$config):
  120.                 $configKey "{$context}/{$rssSlug}";
  121.                 break;
  122.             // any context for given rssSlug
  123.             case array_key_exists("*/{$rssSlug}"$config):
  124.                 $configKey "*/{$rssSlug}";
  125.                 break;
  126.             // any rssSlug for given context
  127.             case array_key_exists("{$context}/*"$config):
  128.                 $configKey "{$context}/*";
  129.                 break;
  130.             // giving up...
  131.             default:
  132.                 throw new NotFoundHttpException();
  133.         }
  134.         // apply defaults to selected config
  135.         $activeConfig array_merge($defaults$config[$configKey]);
  136.         return $activeConfig;
  137.     }
  138.     /**
  139.      * Render moderators section as ESI fragment
  140.      *
  141.      * @return Response
  142.      */
  143.     public function showModeratorsAction()
  144.     {
  145.         // render via original controller
  146.         $response $this->forward('AppBundle:Snippets:getModerators');
  147.         // ... but override cache headers
  148.         $isEsiAnonymous true;
  149.         $response $this->setResponseCacheHeadersFromConfig($response, !$isEsiAnonymous'cache_ttl_seconds_other');
  150.         return $response;
  151.     }
  152.     /**
  153.      * Get cache interval based on file modification date
  154.      *
  155.      * Used as alternative to {@link BaseController::getCacheInterval} for ESI fragments that render content from JSON
  156.      * files that are managed by local or remote cron scripts. This allows to synchronize expiration of cached ESI
  157.      * fragments in varnish with cron execution intervals.
  158.      *
  159.      * @param string $filePath        Path to file relative to project root directory
  160.      * @param int    $cronInterval    How often (in seconds) is cron executed?
  161.      * @param int    $cronLag         How many seconds to lag behind file modification date, taking cron accuracy and
  162.      *                                processing time into account?
  163.      * @param int    $recheckInterval How often (in seconds) to re-check if file did not exist yet or was outdated?
  164.      *
  165.      * @return int
  166.      */
  167.     private function getCacheIntervalFromFileModificationTime($filePath$cronInterval$cronLag 15,
  168.                                                               $recheckInterval 15)
  169.     {
  170.         /** @var LoggerInterface $logger */
  171.         $logger $this->get('logger');
  172.         $cronInterval intval($cronInterval10);
  173.         if ($cronInterval <= 0) {
  174.             // cron interval not configured or invalid
  175.             $logger->warning('Given invalid cron interval while being asked to determine ESI cache headers for file'
  176.                              ' modification of data/' $filePath);
  177.             return $recheckInterval;
  178.         }
  179.         $jsonPath $this->getCachedJsonPath($filePath);
  180.         if (false === $jsonPath) {
  181.             $logger->warning('Failed to determine ESI cache headers from file modification date of data/' $filePath);
  182.             return $recheckInterval;
  183.         }
  184.         $jsonAge time() - filemtime($jsonPath);
  185.         $isOutdated = ($jsonAge >= $cronInterval $cronLag);
  186.         if ($isOutdated) {
  187.             return $recheckInterval;
  188.         }
  189.         return $cronInterval $cronLag $jsonAge;
  190.     }
  191. }