vendor/shopware/storefront/Framework/Cache/CacheResponseSubscriber.php line 183

  1. <?php declare(strict_types=1);
  2. namespace Shopware\Storefront\Framework\Cache;
  3. use Shopware\Core\Checkout\Cart\Cart;
  4. use Shopware\Core\Checkout\Cart\SalesChannel\CartService;
  5. use Shopware\Core\Framework\Adapter\Cache\CacheStateSubscriber;
  6. use Shopware\Core\Framework\Event\BeforeSendResponseEvent;
  7. use Shopware\Core\Framework\Log\Package;
  8. use Shopware\Core\PlatformRequest;
  9. use Shopware\Core\System\SalesChannel\Context\SalesChannelContextService;
  10. use Shopware\Core\System\SalesChannel\SalesChannelContext;
  11. use Shopware\Storefront\Framework\Routing\MaintenanceModeResolver;
  12. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  13. use Symfony\Component\HttpFoundation\Cookie;
  14. use Symfony\Component\HttpFoundation\Request;
  15. use Symfony\Component\HttpFoundation\Response;
  16. use Symfony\Component\HttpKernel\Event\RequestEvent;
  17. use Symfony\Component\HttpKernel\Event\ResponseEvent;
  18. use Symfony\Component\HttpKernel\EventListener\AbstractSessionListener;
  19. use Symfony\Component\HttpKernel\KernelEvents;
  20. /**
  21.  * @internal
  22.  */
  23. #[Package('storefront')]
  24. class CacheResponseSubscriber implements EventSubscriberInterface
  25. {
  26.     final public const STATE_LOGGED_IN CacheStateSubscriber::STATE_LOGGED_IN;
  27.     final public const STATE_CART_FILLED CacheStateSubscriber::STATE_CART_FILLED;
  28.     final public const CURRENCY_COOKIE 'sw-currency';
  29.     final public const CONTEXT_CACHE_COOKIE 'sw-cache-hash';
  30.     final public const SYSTEM_STATE_COOKIE 'sw-states';
  31.     final public const INVALIDATION_STATES_HEADER 'sw-invalidation-states';
  32.     private const CORE_HTTP_CACHED_ROUTES = [
  33.         'api.acl.privileges.get',
  34.     ];
  35.     /**
  36.      * @internal
  37.      */
  38.     public function __construct(
  39.         private readonly CartService $cartService,
  40.         private readonly int $defaultTtl,
  41.         private readonly bool $httpCacheEnabled,
  42.         private readonly MaintenanceModeResolver $maintenanceResolver,
  43.         private readonly bool $reverseProxyEnabled,
  44.         private readonly ?string $staleWhileRevalidate,
  45.         private readonly ?string $staleIfError
  46.     ) {
  47.     }
  48.     /**
  49.      * @return array<string, string|array{0: string, 1: int}|list<array{0: string, 1?: int}>>
  50.      */
  51.     public static function getSubscribedEvents(): array
  52.     {
  53.         return [
  54.             KernelEvents::REQUEST => 'addHttpCacheToCoreRoutes',
  55.             KernelEvents::RESPONSE => [
  56.                 ['setResponseCache', -1500],
  57.                 ['setResponseCacheHeader'1500],
  58.             ],
  59.             BeforeSendResponseEvent::class => 'updateCacheControlForBrowser',
  60.         ];
  61.     }
  62.     public function addHttpCacheToCoreRoutes(RequestEvent $event): void
  63.     {
  64.         $request $event->getRequest();
  65.         $route $request->attributes->get('_route');
  66.         if (\in_array($routeself::CORE_HTTP_CACHED_ROUTEStrue)) {
  67.             $request->attributes->set(PlatformRequest::ATTRIBUTE_HTTP_CACHEtrue);
  68.         }
  69.     }
  70.     public function setResponseCache(ResponseEvent $event): void
  71.     {
  72.         if (!$this->httpCacheEnabled) {
  73.             return;
  74.         }
  75.         $response $event->getResponse();
  76.         $request $event->getRequest();
  77.         $context $request->attributes->get(PlatformRequest::ATTRIBUTE_SALES_CHANNEL_CONTEXT_OBJECT);
  78.         if (!$context instanceof SalesChannelContext) {
  79.             return;
  80.         }
  81.         if (!$this->maintenanceResolver->shouldBeCached($request)) {
  82.             return;
  83.         }
  84.         $route $request->attributes->get('_route');
  85.         if ($route === 'frontend.checkout.configure') {
  86.             $this->setCurrencyCookie($request$response);
  87.         }
  88.         $cart $this->cartService->getCart($context->getToken(), $context);
  89.         $states $this->updateSystemState($cart$context$request$response);
  90.         // We need to allow it on login, otherwise the state is wrong
  91.         if (!($route === 'frontend.account.login' || $request->getMethod() === Request::METHOD_GET)) {
  92.             return;
  93.         }
  94.         if ($context->getCustomer() || $cart->getLineItems()->count() > 0) {
  95.             $newValue $this->buildCacheHash($context);
  96.             if ($request->cookies->get(self::CONTEXT_CACHE_COOKIE'') !== $newValue) {
  97.                 $cookie Cookie::create(self::CONTEXT_CACHE_COOKIE$newValue);
  98.                 $cookie->setSecureDefault($request->isSecure());
  99.                 $response->headers->setCookie($cookie);
  100.             }
  101.         } elseif ($request->cookies->has(self::CONTEXT_CACHE_COOKIE)) {
  102.             $response->headers->removeCookie(self::CONTEXT_CACHE_COOKIE);
  103.             $response->headers->clearCookie(self::CONTEXT_CACHE_COOKIE);
  104.         }
  105.         /** @var bool|array{maxAge?: int, states?: list<string>}|null $cache */
  106.         $cache $request->attributes->get(PlatformRequest::ATTRIBUTE_HTTP_CACHE);
  107.         if (!$cache) {
  108.             return;
  109.         }
  110.         if ($cache === true) {
  111.             $cache = [];
  112.         }
  113.         if ($this->hasInvalidationState($cache['states'] ?? [], $states)) {
  114.             return;
  115.         }
  116.         $maxAge $cache['maxAge'] ?? $this->defaultTtl;
  117.         $response->setSharedMaxAge($maxAge);
  118.         $response->headers->addCacheControlDirective('must-revalidate');
  119.         $response->headers->set(
  120.             self::INVALIDATION_STATES_HEADER,
  121.             implode(','$cache['states'] ?? [])
  122.         );
  123.         if ($this->staleIfError !== null) {
  124.             $response->headers->addCacheControlDirective('stale-if-error'$this->staleIfError);
  125.         }
  126.         if ($this->staleWhileRevalidate !== null) {
  127.             $response->headers->addCacheControlDirective('stale-while-revalidate'$this->staleWhileRevalidate);
  128.         }
  129.     }
  130.     public function setResponseCacheHeader(ResponseEvent $event): void
  131.     {
  132.         if (!$this->httpCacheEnabled) {
  133.             return;
  134.         }
  135.         $response $event->getResponse();
  136.         $request $event->getRequest();
  137.         /** @var bool|array{maxAge?: int, states?: list<string>}|null $cache */
  138.         $cache $request->attributes->get(PlatformRequest::ATTRIBUTE_HTTP_CACHE);
  139.         if (!$cache) {
  140.             return;
  141.         }
  142.         $response->headers->set(AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER'1');
  143.     }
  144.     /**
  145.      * In the default HttpCache implementation the reverse proxy cache is implemented too in PHP and triggered before the response is send to the client. We don't need to send the "real" cache-control headers to the end client (browser/cloudflare).
  146.      * If a external reverse proxy cache is used we still need to provide the actual cache-control, so the external system can cache the system correctly and set the cache-control again to
  147.      */
  148.     public function updateCacheControlForBrowser(BeforeSendResponseEvent $event): void
  149.     {
  150.         if ($this->reverseProxyEnabled) {
  151.             return;
  152.         }
  153.         $response $event->getResponse();
  154.         $noStore $response->headers->getCacheControlDirective('no-store');
  155.         // We don't want that the client will cache the website, if no reverse proxy is configured
  156.         $response->headers->remove('cache-control');
  157.         $response->setPrivate();
  158.         if ($noStore) {
  159.             $response->headers->addCacheControlDirective('no-store');
  160.         } else {
  161.             $response->headers->addCacheControlDirective('no-cache');
  162.         }
  163.     }
  164.     /**
  165.      * @param list<string> $cacheStates
  166.      * @param list<string> $states
  167.      */
  168.     private function hasInvalidationState(array $cacheStates, array $states): bool
  169.     {
  170.         foreach ($states as $state) {
  171.             if (\in_array($state$cacheStatestrue)) {
  172.                 return true;
  173.             }
  174.         }
  175.         return false;
  176.     }
  177.     private function buildCacheHash(SalesChannelContext $context): string
  178.     {
  179.         return md5(json_encode([
  180.             $context->getRuleIds(),
  181.             $context->getContext()->getVersionId(),
  182.             $context->getCurrency()->getId(),
  183.             $context->getCustomer() ? 'logged-in' 'not-logged-in',
  184.         ], \JSON_THROW_ON_ERROR));
  185.     }
  186.     /**
  187.      * System states can be used to stop caching routes at certain states. For example,
  188.      * the checkout routes are no longer cached if the customer has products in the cart or is logged in.
  189.      *
  190.      * @return list<string>
  191.      */
  192.     private function updateSystemState(Cart $cartSalesChannelContext $contextRequest $requestResponse $response): array
  193.     {
  194.         $states $this->getSystemStates($request$context$cart);
  195.         if (empty($states)) {
  196.             if ($request->cookies->has(self::SYSTEM_STATE_COOKIE)) {
  197.                 $response->headers->removeCookie(self::SYSTEM_STATE_COOKIE);
  198.                 $response->headers->clearCookie(self::SYSTEM_STATE_COOKIE);
  199.             }
  200.             return [];
  201.         }
  202.         $newStates implode(','$states);
  203.         if ($request->cookies->get(self::SYSTEM_STATE_COOKIE) !== $newStates) {
  204.             $cookie Cookie::create(self::SYSTEM_STATE_COOKIE$newStates);
  205.             $cookie->setSecureDefault($request->isSecure());
  206.             $response->headers->setCookie($cookie);
  207.         }
  208.         return $states;
  209.     }
  210.     /**
  211.      * @return list<string>
  212.      */
  213.     private function getSystemStates(Request $requestSalesChannelContext $contextCart $cart): array
  214.     {
  215.         $states = [];
  216.         $swStates = (string) $request->cookies->get(self::SYSTEM_STATE_COOKIE);
  217.         if ($swStates !== '') {
  218.             $states array_flip(explode(','$swStates));
  219.         }
  220.         $states $this->switchState($statesself::STATE_LOGGED_IN$context->getCustomer() !== null);
  221.         $states $this->switchState($statesself::STATE_CART_FILLED$cart->getLineItems()->count() > 0);
  222.         return array_keys($states);
  223.     }
  224.     /**
  225.      * @param array<string, int|bool> $states
  226.      *
  227.      * @return array<string, int|bool>
  228.      */
  229.     private function switchState(array $statesstring $keybool $match): array
  230.     {
  231.         if ($match) {
  232.             $states[$key] = true;
  233.             return $states;
  234.         }
  235.         unset($states[$key]);
  236.         return $states;
  237.     }
  238.     private function setCurrencyCookie(Request $requestResponse $response): void
  239.     {
  240.         $currencyId $request->get(SalesChannelContextService::CURRENCY_ID);
  241.         if (!$currencyId) {
  242.             return;
  243.         }
  244.         $cookie Cookie::create(self::CURRENCY_COOKIE$currencyId);
  245.         $cookie->setSecureDefault($request->isSecure());
  246.         $response->headers->setCookie($cookie);
  247.     }
  248. }