Main Image
Bild
Cookie
July 15, 2022

Control Drupal's page cache with cookies

by Jürgen Haas

A LakeDrops client runs a campaign where a single block containing a view should only be visible to invited, still anonymous users. This block is embedded in a landing page which remains accessible to all visitors. Due to high traffic, turning off 's page cache isn't an option. So, how could that be done? The problem to solve: once the landing page gets visited the first time, that content will be cached and delivered as is to all subsequent anonymous visitors.

To solve this problem, we've chosen a 3-step-approach:

  1. set a cookie for invited users
  2. control access to the view by that cookie
  3. turn off page cache on the landing page only for those visitors with the cookie being present

This is not difficult at all, the most enduring challenge is behind us in finding out how this could be done. Let's go through the necessary steps.

Set a cookie for invited users

Let's say the landing page is available from /our-products, and we create another path for the campaign as /you-are-invited-for-special-offerings?token=SOMEHASH which will be sent out to all invited customers or prospects. Once they visit that URL, a cookie will be set and the visitor being redirected to the product list automatically.

To do this, we have to define a controller:

# File: mymodule.routing.yml
mymodule.campaign:
  path: '/you-are-invited-for-special-offerings'
  defaults:
    _title: 'Campaign Name'
    _controller: '\Drupal\mymodule\Controller\Campaign::build'
  requirements:
    _custom_access: '\Drupal\mymodule\Controller\Campaign::checkAccess'

The controller class then has to implement the access control and the build function:

public function checkAccess(): AccessResultInterface {
  if (($query = $this->request->getCurrentRequest()->query) && $query->get('token', FALSE)) {
    return AccessResult::allowed();
  }
  return AccessResult::forbidden();
}

public function build() {
  \Drupal::service('page_cache_kill_switch')->trigger();
  $token = $this->request->getCurrentRequest()->query->get('token');
  $cookie = new Cookie('mymodule_campaign_token', $token, '+24 hours');
  $response = new RedirectResponse(Url::fromUserInput('/our-products')->toString());
  $response->headers->setCookie($cookie);
  return $response;
}

The access control only allows access, if the request contains a query argument token, all others will receive an "access denied" response to that route.

The build method first kills the page cache for this route, as we need to process each request individually. We then create a cookie which contains the given token value and is valid for 24 hours only. The redirect gets the visitor to the product list and the cookie will be associated with that response. Step 1 of 3 completed.

Views access plugin for cookies

The view with the privileged content already exists, so we only need to control access to that view with the cookie. This can be done with a plugin:

<?php

namespace Drupal\mymodule\Plugin\views\access;

use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\views\Plugin\views\access\AccessPluginBase;
use Symfony\Component\Routing\Route;

/**
 * Access plugin that provides access depending on cookies.
 *
 * @ingroup views_access_plugins
 *
 * @ViewsAccess(
 *   id = "mymodule_cookie",
 *   title = @Translation("Cookie")
 * )
 */
class Cookie extends AccessPluginBase implements CacheableDependencyInterface {

  /**
   * {@inheritdoc}
   */
  protected $usesOptions = TRUE;

  /**
   * {@inheritdoc}
   */
  public function summaryTitle(): string {
    return $this->t('Cookie');
  }

  /**
   * {@inheritdoc}
   */
  public function access(AccountInterface $account): bool {
    $cookieName = $this->options['cookie_name'];
    $cookieValue = $this->options['cookie_value'];
    $request = \Drupal::service('request_stack');
    if (($cookies = $request->getCurrentRequest()->cookies) && $value = $cookies->get($cookieName, FALSE)) {
      if (empty($cookieValue) || $cookieValue === $value) {
        return TRUE;
      }
    }
    return FALSE;
  }

  /**
   * {@inheritdoc}
   */
  public function alterRouteDefinition(Route $route): void {
    $route->setRequirement('_custom_access', '\Drupal\mymodule\Plugin\views\access\Cookie::access');
  }

  /**
   * {@inheritdoc}
   */
  protected function defineOptions(): array {
    $options = parent::defineOptions();
    $options['cookie_name'] = ['default' => ''];
    $options['cookie_value'] = ['default' => ''];

    return $options;
  }

  /**
   * {@inheritdoc}
   */
  public function buildOptionsForm(&$form, FormStateInterface $form_state): void {
    parent::buildOptionsForm($form, $form_state);
    $form['cookie_name'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Name'),
      '#default_value' => $this->options['cookie_name'],
    ];
    $form['cookie_value'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Value'),
      '#default_value' => $this->options['cookie_value'],
      '#description' => $this->t('Leave empty if you only want to check the existance of the cookie.'),
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function getCacheMaxAge(): int {
    return 0;
  }

  /**
   * {@inheritdoc}
   */
  public function getCacheContexts(): array {
    return [];
  }

  /**
   * {@inheritdoc}
   */
  public function getCacheTags(): array {
    return [];
  }

}

This code might be self-explaining, so we call step 2 of 3 completed as well.

Disable page-cache only for requests containing the cookie

This one almost left us behind without any hair left, as we were pulling it out while trying a number of different attempts: pre-render hooks didn't work, nor did any event subscriber. They all get skipped for cached pages and are therefore useless in this context. Next stop, we've tried the implementation of our own StackMiddleware which can be triggered before the page cache middleware by Symfony. This approach then led us to the final approach which tailors the behaviour of the page cache: a page cache request policy.

Once we found out about such policies, the implementation turned out to be more than simple. First, let's define the policy service in our custom :

# File: mymodule.services.yml
services:
  mymodule.page_cache_request_policy.disallow_campaign_requests:
    class: Drupal\mymodule\PageCache\DisallowCampaignRequests
    public: false
    tags:
      - { name: page_cache_request_policy }

The service class then actually feels minimalistic:

<?php

namespace Drupal\mymodule\PageCache;

use Drupal\Core\PageCache\RequestPolicyInterface;
use Symfony\Component\HttpFoundation\Request;

/**
 * Cache policy for /our-products when the campaign cookie is being set.
 */
class DisallowCampaignRequests implements RequestPolicyInterface {

  /**
   * {@inheritdoc}
   */
  public function check(Request $request) {
    if ($request->cookies->get('mymodule_campaign_token', FALSE) && $request->getPathInfo() === '/our-products') {
      return static::DENY;
    }
  }

}

Yes, that's all, final step completed as well.

Conclusion

This has not been the first time that Drupal's page cache impressed us to such an extent. It hooks into the http request so early, that almost everything Drupal provides is just being ignored completely. And therefore the response is so fast. Imagine, the cache content is stored in RAM, e.g. with Redis, the behaviour of such a Drupal site is close to the performance of a static website, isn't it?

Our biggest problem to provide the functionality for this customer requirement hasn't been technical, it's been lack of knowledge about the options available. Having solved it after a couple of hours leaves us with a notion of excitement - and that's one reason why we love Drupal that much!

Tags

Tools

Add new comment