Passed
Push — 6.4.9.0 ( 1aef59...e39b04 )
by Christian
12:13 queued 11s
created

CacheResponseSubscriber::switchState()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 11
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 5
nc 2
nop 3
dl 0
loc 11
rs 10
c 0
b 0
f 0
1
<?php declare(strict_types=1);
2
3
namespace Shopware\Storefront\Framework\Cache;
4
5
use Shopware\Core\Checkout\Cart\Cart;
6
use Shopware\Core\Checkout\Cart\SalesChannel\CartService;
7
use Shopware\Core\Framework\Adapter\Cache\CacheStateSubscriber;
8
use Shopware\Core\Framework\Event\BeforeSendResponseEvent;
9
use Shopware\Core\PlatformRequest;
10
use Shopware\Core\System\SalesChannel\Context\SalesChannelContextService;
11
use Shopware\Core\System\SalesChannel\SalesChannelContext;
12
use Shopware\Storefront\Framework\Cache\Annotation\HttpCache;
13
use Shopware\Storefront\Framework\Routing\MaintenanceModeResolver;
14
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
15
use Symfony\Component\HttpFoundation\Cookie;
16
use Symfony\Component\HttpFoundation\Request;
17
use Symfony\Component\HttpFoundation\Response;
18
use Symfony\Component\HttpKernel\Event\RequestEvent;
19
use Symfony\Component\HttpKernel\Event\ResponseEvent;
20
use Symfony\Component\HttpKernel\KernelEvents;
21
22
class CacheResponseSubscriber implements EventSubscriberInterface
23
{
24
    public const STATE_LOGGED_IN = CacheStateSubscriber::STATE_LOGGED_IN;
25
    public const STATE_CART_FILLED = CacheStateSubscriber::STATE_CART_FILLED;
26
27
    public const CURRENCY_COOKIE = 'sw-currency';
28
    public const CONTEXT_CACHE_COOKIE = 'sw-cache-hash';
29
    public const SYSTEM_STATE_COOKIE = 'sw-states';
30
    public const INVALIDATION_STATES_HEADER = 'sw-invalidation-states';
31
32
    private const CORE_HTTP_CACHED_ROUTES = [
33
        'api.acl.privileges.get',
34
    ];
35
36
    private bool $reverseProxyEnabled;
37
38
    private CartService $cartService;
39
40
    private int $defaultTtl;
41
42
    private bool $httpCacheEnabled;
43
44
    private MaintenanceModeResolver $maintenanceResolver;
45
46
    public function __construct(
47
        CartService $cartService,
48
        int $defaultTtl,
49
        bool $httpCacheEnabled,
50
        MaintenanceModeResolver $maintenanceModeResolver,
51
        bool $reverseProxyEnabled
52
    ) {
53
        $this->cartService = $cartService;
54
        $this->defaultTtl = $defaultTtl;
55
        $this->httpCacheEnabled = $httpCacheEnabled;
56
        $this->maintenanceResolver = $maintenanceModeResolver;
57
        $this->reverseProxyEnabled = $reverseProxyEnabled;
58
    }
59
60
    /**
61
     * @return array<string, string|array{0: string, 1: int}|list<array{0: string, 1?: int}>>
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<string, string|arr...y{0: string, 1?: int}>> at position 17 could not be parsed: Expected '>' at position 17, but found 'list'.
Loading history...
62
     */
63
    public static function getSubscribedEvents()
64
    {
65
        return [
66
            KernelEvents::REQUEST => 'addHttpCacheToCoreRoutes',
67
            KernelEvents::RESPONSE => [
68
                ['setResponseCache', -1500],
69
            ],
70
            BeforeSendResponseEvent::class => 'updateCacheControlForBrowser',
71
        ];
72
    }
73
74
    public function addHttpCacheToCoreRoutes(RequestEvent $event): void
75
    {
76
        $request = $event->getRequest();
77
        $route = $request->attributes->get('_route');
78
79
        if (\in_array($route, self::CORE_HTTP_CACHED_ROUTES, true)) {
80
            $request->attributes->set('_' . HttpCache::ALIAS, [new HttpCache([])]);
81
        }
82
    }
83
84
    public function setResponseCache(ResponseEvent $event): void
85
    {
86
        if (!$this->httpCacheEnabled) {
87
            return;
88
        }
89
90
        $response = $event->getResponse();
91
92
        $request = $event->getRequest();
93
94
        if ($this->maintenanceResolver->isMaintenanceRequest($request)) {
95
            return;
96
        }
97
98
        $context = $request->attributes->get(PlatformRequest::ATTRIBUTE_SALES_CHANNEL_CONTEXT_OBJECT);
99
100
        if (!$context instanceof SalesChannelContext) {
101
            return;
102
        }
103
104
        $route = $request->attributes->get('_route');
105
        if ($route === 'frontend.checkout.configure') {
106
            $this->setCurrencyCookie($request, $response);
107
        }
108
109
        $cart = $this->cartService->getCart($context->getToken(), $context);
110
111
        $states = $this->updateSystemState($cart, $context, $request, $response);
112
113
        if ($request->getMethod() !== Request::METHOD_GET) {
114
            return;
115
        }
116
117
        if ($context->getCustomer() || $cart->getLineItems()->count() > 0) {
118
            $cookie = Cookie::create(self::CONTEXT_CACHE_COOKIE, $this->buildCacheHash($context));
119
            $cookie->setSecureDefault($request->isSecure());
120
121
            $response->headers->setCookie($cookie);
122
        } else {
123
            $response->headers->removeCookie(self::CONTEXT_CACHE_COOKIE);
124
            $response->headers->clearCookie(self::CONTEXT_CACHE_COOKIE);
125
        }
126
127
        $config = $request->attributes->get('_' . HttpCache::ALIAS);
128
        if (empty($config)) {
129
            return;
130
        }
131
132
        /** @var HttpCache $cache */
133
        $cache = array_shift($config);
134
135
        if ($this->hasInvalidationState($cache, $states)) {
136
            return;
137
        }
138
139
        $maxAge = $cache->getMaxAge() ?? $this->defaultTtl;
140
141
        $response->setSharedMaxAge($maxAge);
142
        $response->headers->addCacheControlDirective('must-revalidate');
143
        $response->headers->set(
144
            self::INVALIDATION_STATES_HEADER,
145
            implode(',', $cache->getStates())
146
        );
147
    }
148
149
    /**
150
     * 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).
151
     * 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
152
     */
153
    public function updateCacheControlForBrowser(BeforeSendResponseEvent $event): void
154
    {
155
        if ($this->reverseProxyEnabled) {
156
            return;
157
        }
158
159
        $response = $event->getResponse();
160
161
        $noStore = $response->headers->getCacheControlDirective('no-store');
162
163
        // We don't want that the client will cache the website, if no reverse proxy is configured
164
        $response->headers->remove('cache-control');
165
        $response->setPrivate();
166
167
        if ($noStore) {
168
            $response->headers->addCacheControlDirective('no-store');
169
        } else {
170
            $response->headers->addCacheControlDirective('no-cache');
171
        }
172
    }
173
174
    private function hasInvalidationState(HttpCache $cache, array $states): bool
175
    {
176
        foreach ($states as $state) {
177
            if (\in_array($state, $cache->getStates(), true)) {
178
                return true;
179
            }
180
        }
181
182
        return false;
183
    }
184
185
    private function buildCacheHash(SalesChannelContext $context): string
186
    {
187
        return md5(json_encode([
188
            $context->getRuleIds(),
189
            $context->getContext()->getVersionId(),
190
            $context->getCurrency()->getId(),
191
        ]));
192
    }
193
194
    /**
195
     * System states can be used to stop caching routes at certain states. For example,
196
     * the checkout routes are no longer cached if the customer has products in the cart or is logged in.
197
     */
198
    private function updateSystemState(Cart $cart, SalesChannelContext $context, Request $request, Response $response): array
199
    {
200
        $states = $this->getSystemStates($request, $context, $cart);
201
202
        if (empty($states)) {
203
            $response->headers->removeCookie(self::SYSTEM_STATE_COOKIE);
204
            $response->headers->clearCookie(self::SYSTEM_STATE_COOKIE);
205
206
            return [];
207
        }
208
209
        $cookie = Cookie::create(self::SYSTEM_STATE_COOKIE, implode(',', $states));
210
        $cookie->setSecureDefault($request->isSecure());
211
212
        $response->headers->setCookie($cookie);
213
214
        return $states;
215
    }
216
217
    private function getSystemStates(Request $request, SalesChannelContext $context, Cart $cart): array
218
    {
219
        $states = [];
220
        $swStates = (string) $request->cookies->get(self::SYSTEM_STATE_COOKIE);
221
        if ($swStates !== null) {
0 ignored issues
show
introduced by
The condition $swStates !== null is always true.
Loading history...
222
            $states = explode(',', $swStates);
223
            $states = array_flip($states);
224
        }
225
226
        $states = $this->switchState($states, self::STATE_LOGGED_IN, $context->getCustomer() !== null);
227
228
        $states = $this->switchState($states, self::STATE_CART_FILLED, $cart->getLineItems()->count() > 0);
229
230
        return array_keys($states);
231
    }
232
233
    private function switchState(array $states, string $key, bool $match): array
234
    {
235
        if ($match) {
236
            $states[$key] = true;
237
238
            return $states;
239
        }
240
241
        unset($states[$key]);
242
243
        return $states;
244
    }
245
246
    private function setCurrencyCookie(Request $request, Response $response): void
247
    {
248
        $currencyId = $request->get(SalesChannelContextService::CURRENCY_ID);
249
250
        if (!$currencyId) {
251
            return;
252
        }
253
254
        $cookie = Cookie::create(self::CURRENCY_COOKIE, $currencyId);
255
        $cookie->setSecureDefault($request->isSecure());
256
257
        $response->headers->setCookie($cookie);
258
    }
259
}
260