Skip to content
Permalink
Browse files Browse the repository at this point in the history
NEXT-20309 - Fix cache control
  • Loading branch information
shyim committed Mar 2, 2022
1 parent cb53ad9 commit d518631
Show file tree
Hide file tree
Showing 5 changed files with 122 additions and 8 deletions.
10 changes: 10 additions & 0 deletions changelog/_unreleased/2022-03-01-fix-cache-control.md
@@ -0,0 +1,10 @@
---
title: Fix cache control
issue: NEXT-20309
author: Soner Sayakci
author_email: s.sayakci@shopware.com
---

# Storefront
* Added `\Shopware\Storefront\Framework\Cache\CacheResponseSubscriber` to ensure `cache-control: private` is send to clients when the default PHP reverse proxy is enabled

1 change: 1 addition & 0 deletions src/Storefront/DependencyInjection/services.xml
Expand Up @@ -481,6 +481,7 @@
<argument>%shopware.http.cache.default_ttl%</argument>
<argument>%shopware.http.cache.enabled%</argument>
<argument type="service" id="Shopware\Storefront\Framework\Routing\MaintenanceModeResolver"/>
<argument>%storefront.reverse_proxy.enabled%</argument>

<tag name="kernel.event_subscriber"/>
</service>
Expand Down
33 changes: 32 additions & 1 deletion src/Storefront/Framework/Cache/CacheResponseSubscriber.php
Expand Up @@ -5,6 +5,7 @@
use Shopware\Core\Checkout\Cart\Cart;
use Shopware\Core\Checkout\Cart\SalesChannel\CartService;
use Shopware\Core\Framework\Adapter\Cache\CacheStateSubscriber;
use Shopware\Core\Framework\Event\BeforeSendResponseEvent;
use Shopware\Core\PlatformRequest;
use Shopware\Core\System\SalesChannel\Context\SalesChannelContextService;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
Expand Down Expand Up @@ -32,6 +33,8 @@ class CacheResponseSubscriber implements EventSubscriberInterface
'api.acl.privileges.get',
];

private bool $reverseProxyEnabled;

private CartService $cartService;

private int $defaultTtl;
Expand All @@ -44,12 +47,14 @@ public function __construct(
CartService $cartService,
int $defaultTtl,
bool $httpCacheEnabled,
MaintenanceModeResolver $maintenanceModeResolver
MaintenanceModeResolver $maintenanceModeResolver,
bool $reverseProxyEnabled
) {
$this->cartService = $cartService;
$this->defaultTtl = $defaultTtl;
$this->httpCacheEnabled = $httpCacheEnabled;
$this->maintenanceResolver = $maintenanceModeResolver;
$this->reverseProxyEnabled = $reverseProxyEnabled;
}

/**
Expand All @@ -62,6 +67,7 @@ public static function getSubscribedEvents()
KernelEvents::RESPONSE => [
['setResponseCache', -1500],
],
BeforeSendResponseEvent::class => 'updateCacheControlForBrowser',
];
}

Expand Down Expand Up @@ -140,6 +146,31 @@ public function setResponseCache(ResponseEvent $event): void
);
}

/**
* 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).
* 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
*/
public function updateCacheControlForBrowser(BeforeSendResponseEvent $event): void
{
if ($this->reverseProxyEnabled) {
return;
}

$response = $event->getResponse();

$noStore = $response->headers->getCacheControlDirective('no-store');

// We don't want that the client will cache the website, if no reverse proxy is configured
$response->headers->remove('cache-control');
$response->setPrivate();

if ($noStore) {
$response->headers->addCacheControlDirective('no-store');
} else {
$response->headers->addCacheControlDirective('no-cache');
}
}

private function hasInvalidationState(HttpCache $cache, array $states): bool
{
foreach ($states as $state) {
Expand Down
Expand Up @@ -7,6 +7,7 @@
use Shopware\Core\Checkout\Cart\LineItem\LineItem;
use Shopware\Core\Checkout\Cart\SalesChannel\CartService;
use Shopware\Core\Checkout\Customer\CustomerEntity;
use Shopware\Core\Framework\Event\BeforeSendResponseEvent;
use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour;
use Shopware\Core\PlatformRequest;
use Shopware\Core\SalesChannelRequest;
Expand Down Expand Up @@ -34,7 +35,8 @@ public function testNoHeadersAreSetIfCacheIsDisabled(): void
$this->createMock(CartService::class),
100,
false,
$this->getContainer()->get(MaintenanceModeResolver::class)
$this->getContainer()->get(MaintenanceModeResolver::class),
false
);

$customer = $this->createMock(CustomerEntity::class);
Expand Down Expand Up @@ -71,7 +73,8 @@ public function testGenerateCashHashWithItemsInCart($customer, Cart $cart, bool
$service,
100,
true,
$this->getContainer()->get(MaintenanceModeResolver::class)
$this->getContainer()->get(MaintenanceModeResolver::class),
false
);

$salesChannelContext = $this->createMock(SalesChannelContext::class);
Expand Down Expand Up @@ -119,7 +122,8 @@ public function testMaintenanceRequest(bool $active, array $whitelist, bool $sho
$cartService,
100,
true,
$this->getContainer()->get(MaintenanceModeResolver::class)
$this->getContainer()->get(MaintenanceModeResolver::class),
false
);

$customer = $this->createMock(CustomerEntity::class);
Expand Down Expand Up @@ -182,4 +186,74 @@ public function maintenanceRequest()
yield 'Do not cache requests of whitelisted ip' => [true, [self::IP], false];
yield 'Cache requests if ip is not whitelisted' => [true, ['120.0.0.0'], true];
}

/**
* @dataProvider headerCases
*/
public function testResponseHeaders(bool $reverseProxyEnabled, ?string $beforeHeader, string $afterHeader): void
{
$response = new Response();

if ($beforeHeader) {
$response->headers->set('cache-control', $beforeHeader);
}

$subscriber = new CacheResponseSubscriber(
$this->createMock(CartService::class),
100,
true,
$this->createMock(MaintenanceModeResolver::class),
$reverseProxyEnabled
);

$subscriber->updateCacheControlForBrowser(new BeforeSendResponseEvent(new Request(), $response));

static::assertSame($afterHeader, $response->headers->get('cache-control'));
}

public function headerCases(): iterable
{
yield 'no cache proxy, default response' => [
false,
null,
'no-cache, private',
];

yield 'no cache proxy, default response with no-store (/account)' => [
false,
'no-store, private',
'no-store, private',
];

// @see: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#preventing_storing
yield 'no cache proxy, no-cache will be replaced with no-store' => [
false,
'no-store, no-cache, private',
'no-store, private',
];

yield 'no cache proxy, public content served as private for end client' => [
false,
'public, s-maxage=64000',
'no-cache, private',
];

yield 'cache proxy, cache-control is not touched' => [
true,
'public',
'public',
];

yield 'cache proxy, cache-control is not touched #2' => [
true,
'public, s-maxage=64000',
'public, s-maxage=64000',
];

yield 'cache proxy, cache-control is not touched #3' => [
true,
'private, no-store',
'no-store, private', // Symfony sorts the cache-control
];
}
}
Expand Up @@ -63,16 +63,14 @@ public function testStoreApiPresent(): void
/**
* @dataProvider dataProviderRevalidateRoutes
*/
public function testRevalidateHeaderPresent(string $route): void
public function testNoStoreHeaderPresent(string $route): void
{
$browser = KernelLifecycleManager::createBrowser(KernelLifecycleManager::getKernel(), false);
$browser->request('GET', $_SERVER['APP_URL'] . $route);
$response = $browser->getResponse();

static::assertTrue($response->headers->hasCacheControlDirective('must-revalidate'));
static::assertTrue($response->headers->hasCacheControlDirective('no-store'));
static::assertTrue($response->headers->hasCacheControlDirective('no-cache'));
static::assertSame(0, $response->getMaxAge());
static::assertLessThanOrEqual(0, $response->getMaxAge());
}

public function dataProviderRevalidateRoutes(): iterable
Expand Down

0 comments on commit d518631

Please sign in to comment.