EarlyRenderingControllerWrapperSubscriber.php 6.39 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166
<?php

/**
 * @file
 * Contains \Drupal\Core\EventSubscriber\EarlyRenderingControllerWrapperSubscriber.
 */

namespace Drupal\Core\EventSubscriber;

use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableResponseInterface;
use Drupal\Core\Controller\ControllerResolverInterface;
use Drupal\Core\Render\AttachmentsInterface;
use Drupal\Core\Render\BubbleableMetadata;
use Drupal\Core\Render\RenderContext;
use Drupal\Core\Render\RendererInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\FilterControllerEvent;
use Symfony\Component\HttpKernel\KernelEvents;

/**
 * Subscriber that wraps controllers, to handle early rendering.
 *
 * When controllers call drupal_render() (RendererInterface::render()) outside
 * of a render context, we call that "early rendering". Controllers should
 * return only render arrays, but we cannot prevent controllers from doing early
 * rendering. The problem with early rendering is that the bubbleable metadata
 * (cacheability & attachments) are lost.
 *
 * This can lead to broken pages (missing assets), stale pages (missing cache
 * tags causing a page not to be invalidated) or even security problems (missing
 * cache contexts causing a cached page not to be varied sufficiently).
 *
 * This event subscriber wraps all controller executions in a closure that sets
 * up a render context. Consequently, any early rendering will have their
 * bubbleable metadata (assets & cacheability) stored on that render context.
 *
 * If the render context is empty, then the controller either did not do any
 * rendering at all, or used the RendererInterface::renderRoot() or
 * ::renderPlain() methods. In that case, no bubbleable metadata is lost.
 *
 * If the render context is not empty, then the controller did use
 * drupal_render(), and bubbleable metadata was collected. This bubbleable
 * metadata is then merged onto the render array.
 *
 * In other words: this just exists to ease the transition to Drupal 8: it
 * allows controllers that return render arrays (the majority) to still do early
 * rendering. But controllers that return responses are already expected to do
 * the right thing: if early rendering is detected in such a case, an exception
 * is thrown.
 *
 * @see \Drupal\Core\Render\RendererInterface
 * @see \Drupal\Core\Render\Renderer
 *
 * @todo Remove in Drupal 9.0.0, by disallowing early rendering.
 */
class EarlyRenderingControllerWrapperSubscriber implements EventSubscriberInterface {

  /**
   * The controller resolver.
   *
   * @var \Drupal\Core\Controller\ControllerResolverInterface
   */
  protected $controllerResolver;

  /**
   * The renderer.
   *
   * @var \Drupal\Core\Render\RendererInterface
   */
  protected $renderer;

  /**
   * Constructs a new EarlyRenderingControllerWrapperSubscriber instance.
   *
   * @param \Drupal\Core\Controller\ControllerResolverInterface $controller_resolver
   *   The controller resolver.
   * @param \Drupal\Core\Render\RendererInterface $renderer
   *   The renderer.
   */
  public function __construct(ControllerResolverInterface $controller_resolver, RendererInterface $renderer) {
    $this->controllerResolver = $controller_resolver;
    $this->renderer = $renderer;
  }

  /**
   * Ensures bubbleable metadata from early rendering is not lost.
   *
   * @param \Symfony\Component\HttpKernel\Event\FilterControllerEvent $event
   *   The controller event.
   */
  public function onController(FilterControllerEvent $event) {
    $controller = $event->getController();

    // See \Symfony\Component\HttpKernel\HttpKernel::handleRaw().
    $arguments = $this->controllerResolver->getArguments($event->getRequest(), $controller);

    $event->setController(function() use ($controller, $arguments) {
      return $this->wrapControllerExecutionInRenderContext($controller, $arguments);
    });
  }

  /**
   * Wraps a controller execution in a render context.
   *
   * @param callable $controller
   *   The controller to execute.
   * @param array $arguments
   *   The arguments to pass to the controller.
   *
   * @return mixed
   *   The return value of the controller.
   *
   * @throws \LogicException
   *   When early rendering has occurred in a controller that returned a
   *   Response or domain object that cares about attachments or cacheability.
   *
   * @see \Symfony\Component\HttpKernel\HttpKernel::handleRaw()
   */
  protected function wrapControllerExecutionInRenderContext($controller, array $arguments) {
    $context = new RenderContext();

    $response = $this->renderer->executeInRenderContext($context, function() use ($controller, $arguments) {
      // Now call the actual controller, just like HttpKernel does.
      return call_user_func_array($controller, $arguments);
    });

    // If early rendering happened, i.e. if code in the controller called
    // drupal_render() outside of a render context, then the bubbleable metadata
    // for that is stored in the current render context.
    if (!$context->isEmpty()) {
      // If a render array is returned by the controller, merge the "lost"
      // bubbleable metadata.
      if (is_array($response)) {
        $early_rendering_bubbleable_metadata = $context->pop();
        BubbleableMetadata::createFromRenderArray($response)
          ->merge($early_rendering_bubbleable_metadata)
          ->applyTo($response);
      }
      // If a Response or domain object is returned, and it cares about
      // attachments or cacheability, then throw an exception: early rendering
      // is not permitted in that case. It is the developer's responsibility
      // to not use early rendering.
      elseif ($response instanceof AttachmentsInterface || $response instanceof CacheableResponseInterface || $response instanceof CacheableDependencyInterface) {
        throw new \LogicException(sprintf('The controller result claims to be providing relevant cache metadata, but leaked metadata was detected. Please ensure you are not rendering content too early. Returned object class: %s.', get_class($response)));
      }
      else {
        // A Response or domain object is returned that does not care about
        // attachments nor cacheability. E.g. a RedirectResponse. It is safe to
        // discard any early rendering metadata.
      }
    }

    return $response;
  }

  /**
   * {@inheritdoc}
   */
  public static function getSubscribedEvents() {
    $events[KernelEvents::CONTROLLER][] = ['onController'];

    return $events;
  }

}