EarlyRenderingControllerWrapperSubscriber.php 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. <?php
  2. namespace Drupal\Core\EventSubscriber;
  3. use Drupal\Core\Ajax\AjaxResponse;
  4. use Drupal\Core\Cache\CacheableDependencyInterface;
  5. use Drupal\Core\Cache\CacheableResponseInterface;
  6. use Drupal\Core\Render\AttachmentsInterface;
  7. use Drupal\Core\Render\BubbleableMetadata;
  8. use Drupal\Core\Render\RenderContext;
  9. use Drupal\Core\Render\RendererInterface;
  10. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  11. use Symfony\Component\HttpKernel\Controller\ArgumentResolverInterface;
  12. use Symfony\Component\HttpKernel\Event\FilterControllerEvent;
  13. use Symfony\Component\HttpKernel\KernelEvents;
  14. /**
  15. * Subscriber that wraps controllers, to handle early rendering.
  16. *
  17. * When controllers call drupal_render() (RendererInterface::render()) outside
  18. * of a render context, we call that "early rendering". Controllers should
  19. * return only render arrays, but we cannot prevent controllers from doing early
  20. * rendering. The problem with early rendering is that the bubbleable metadata
  21. * (cacheability & attachments) are lost.
  22. *
  23. * This can lead to broken pages (missing assets), stale pages (missing cache
  24. * tags causing a page not to be invalidated) or even security problems (missing
  25. * cache contexts causing a cached page not to be varied sufficiently).
  26. *
  27. * This event subscriber wraps all controller executions in a closure that sets
  28. * up a render context. Consequently, any early rendering will have their
  29. * bubbleable metadata (assets & cacheability) stored on that render context.
  30. *
  31. * If the render context is empty, then the controller either did not do any
  32. * rendering at all, or used the RendererInterface::renderRoot() or
  33. * ::renderPlain() methods. In that case, no bubbleable metadata is lost.
  34. *
  35. * If the render context is not empty, then the controller did use
  36. * drupal_render(), and bubbleable metadata was collected. This bubbleable
  37. * metadata is then merged onto the render array.
  38. *
  39. * In other words: this just exists to ease the transition to Drupal 8: it
  40. * allows controllers that return render arrays (the majority) and
  41. * \Drupal\Core\Ajax\AjaxResponse\AjaxResponse objects (a sizable minority that
  42. * often involve a fair amount of rendering) to still do early rendering. But
  43. * controllers that return any other kind of response are already expected to
  44. * do the right thing, so if early rendering is detected in such a case, an
  45. * exception is thrown.
  46. *
  47. * @see \Drupal\Core\Render\RendererInterface
  48. * @see \Drupal\Core\Render\Renderer
  49. *
  50. * @todo Remove in Drupal 9.0.0, by disallowing early rendering.
  51. */
  52. class EarlyRenderingControllerWrapperSubscriber implements EventSubscriberInterface {
  53. /**
  54. * The argument resolver.
  55. *
  56. * @var \Symfony\Component\HttpKernel\Controller\ArgumentResolverInterface
  57. */
  58. protected $argumentResolver;
  59. /**
  60. * The renderer.
  61. *
  62. * @var \Drupal\Core\Render\RendererInterface
  63. */
  64. protected $renderer;
  65. /**
  66. * Constructs a new EarlyRenderingControllerWrapperSubscriber instance.
  67. *
  68. * @param \Symfony\Component\HttpKernel\Controller\ArgumentResolverInterface $argument_resolver
  69. * The argument resolver.
  70. * @param \Drupal\Core\Render\RendererInterface $renderer
  71. * The renderer.
  72. */
  73. public function __construct(ArgumentResolverInterface $argument_resolver, RendererInterface $renderer) {
  74. $this->argumentResolver = $argument_resolver;
  75. $this->renderer = $renderer;
  76. }
  77. /**
  78. * Ensures bubbleable metadata from early rendering is not lost.
  79. *
  80. * @param \Symfony\Component\HttpKernel\Event\FilterControllerEvent $event
  81. * The controller event.
  82. */
  83. public function onController(FilterControllerEvent $event) {
  84. $controller = $event->getController();
  85. // See \Symfony\Component\HttpKernel\HttpKernel::handleRaw().
  86. $arguments = $this->argumentResolver->getArguments($event->getRequest(), $controller);
  87. $event->setController(function () use ($controller, $arguments) {
  88. return $this->wrapControllerExecutionInRenderContext($controller, $arguments);
  89. });
  90. }
  91. /**
  92. * Wraps a controller execution in a render context.
  93. *
  94. * @param callable $controller
  95. * The controller to execute.
  96. * @param array $arguments
  97. * The arguments to pass to the controller.
  98. *
  99. * @return mixed
  100. * The return value of the controller.
  101. *
  102. * @throws \LogicException
  103. * When early rendering has occurred in a controller that returned a
  104. * Response or domain object that cares about attachments or cacheability.
  105. *
  106. * @see \Symfony\Component\HttpKernel\HttpKernel::handleRaw()
  107. */
  108. protected function wrapControllerExecutionInRenderContext($controller, array $arguments) {
  109. $context = new RenderContext();
  110. $response = $this->renderer->executeInRenderContext($context, function () use ($controller, $arguments) {
  111. // Now call the actual controller, just like HttpKernel does.
  112. return call_user_func_array($controller, $arguments);
  113. });
  114. // If early rendering happened, i.e. if code in the controller called
  115. // drupal_render() outside of a render context, then the bubbleable metadata
  116. // for that is stored in the current render context.
  117. if (!$context->isEmpty()) {
  118. /** @var \Drupal\Core\Render\BubbleableMetadata $early_rendering_bubbleable_metadata */
  119. $early_rendering_bubbleable_metadata = $context->pop();
  120. // If a render array or AjaxResponse is returned by the controller, merge
  121. // the "lost" bubbleable metadata.
  122. if (is_array($response)) {
  123. BubbleableMetadata::createFromRenderArray($response)
  124. ->merge($early_rendering_bubbleable_metadata)
  125. ->applyTo($response);
  126. }
  127. elseif ($response instanceof AjaxResponse) {
  128. $response->addAttachments($early_rendering_bubbleable_metadata->getAttachments());
  129. // @todo Make AjaxResponse cacheable in
  130. // https://www.drupal.org/node/956186. Meanwhile, allow contrib
  131. // subclasses to be.
  132. if ($response instanceof CacheableResponseInterface) {
  133. $response->addCacheableDependency($early_rendering_bubbleable_metadata);
  134. }
  135. }
  136. // If a non-Ajax Response or domain object is returned and it cares about
  137. // attachments or cacheability, then throw an exception: early rendering
  138. // is not permitted in that case. It is the developer's responsibility
  139. // to not use early rendering.
  140. elseif ($response instanceof AttachmentsInterface || $response instanceof CacheableResponseInterface || $response instanceof CacheableDependencyInterface) {
  141. 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)));
  142. }
  143. else {
  144. // A Response or domain object is returned that does not care about
  145. // attachments nor cacheability; for instance, a RedirectResponse. It is
  146. // safe to discard any early rendering metadata.
  147. }
  148. }
  149. return $response;
  150. }
  151. /**
  152. * {@inheritdoc}
  153. */
  154. public static function getSubscribedEvents() {
  155. $events[KernelEvents::CONTROLLER][] = ['onController'];
  156. return $events;
  157. }
  158. }