Passed
Push — master ( ddb17c...54000c )
by Alexander
03:42
created

HttpCache::validateCache()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 14
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 5.0729

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 6
c 1
b 0
f 0
nc 5
nop 3
dl 0
loc 14
ccs 6
cts 7
cp 0.8571
crap 5.0729
rs 9.6111
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Yii\Web\Middleware;
6
7
use Psr\Http\Message\ResponseFactoryInterface;
8
use Psr\Http\Message\ResponseInterface;
9
use Psr\Http\Message\ServerRequestInterface;
10
use Psr\Http\Server\MiddlewareInterface;
11
use Psr\Http\Server\RequestHandlerInterface;
12
use Yiisoft\Http\Method;
13
use Yiisoft\Http\Status;
14
15
/**
16
 * HttpCache implements client-side caching by utilizing the `Last-Modified` and `ETag` HTTP headers.
17
 */
18
final class HttpCache implements MiddlewareInterface
19
{
20
    private const DEFAULT_HEADER = 'public, max-age=3600';
21
22
    /**
23
     * @var callable a PHP callback that returns the UNIX timestamp of the last modification time.
24
     * The callback's signature should be:
25
     *
26
     * ```php
27
     * function (ServerRequestInterface $request, $params): int
28
     * ```
29
     *
30
     * where `$request` is the {@see ServerRequestInterface} object that this filter is currently handling;
31
     * `$params` takes the value of {@see params}. The callback should return a UNIX timestamp.
32
     *
33
     * @see http://tools.ietf.org/html/rfc7232#section-2.2
34
     */
35
    private $lastModified;
36
37
    /**
38
     * @var callable a PHP callback that generates the ETag seed string.
39
     * The callback's signature should be:
40
     *
41
     * ```php
42
     * function (ServerRequestInterface $request, $params): string
43
     * ```
44
     *
45
     * where `$request` is the {@see ServerRequestInterface} object that this middleware is currently handling;
46
     * `$params` takes the value of {@see $params}. The callback should return a string serving
47
     * as the seed for generating an ETag.
48
     */
49
    private $etagSeed;
50
51
    /**
52
     * @var bool whether to generate weak ETags.
53
     *
54
     * Weak ETags should be used if the content should be considered semantically equivalent, but not byte-equal.
55
     *
56
     * @see http://tools.ietf.org/html/rfc7232#section-2.3
57
     */
58
    private bool $weakEtag = false;
59
60
    /**
61
     * @var mixed additional parameters that should be passed to the {@see $lastModified} and {@see etagSeed} callbacks.
62
     */
63
    private $params;
64
65
    /**
66
     * @var string the value of the `Cache-Control` HTTP header. If null, the header will not be sent.
67
     * @see http://tools.ietf.org/html/rfc2616#section-14.9
68
     */
69
    private ?string $cacheControlHeader = self::DEFAULT_HEADER;
70
71
    private ResponseFactoryInterface $responseFactory;
72
73 5
    public function __construct(ResponseFactoryInterface $responseFactory)
74
    {
75 5
        $this->responseFactory = $responseFactory;
76 5
    }
77
78 5
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
79
    {
80
        if (
81 5
            ($this->lastModified === null && $this->etagSeed === null) ||
82 5
            !\in_array($request->getMethod(), [Method::GET, Method::HEAD], true)
83
        ) {
84 1
            return $handler->handle($request);
85
        }
86
87 4
        $lastModified = null;
88 4
        if ($this->lastModified !== null) {
89 2
            $lastModified = call_user_func($this->lastModified, $request, $this->params);
90
        }
91
92 4
        $etag = null;
93 4
        if ($this->etagSeed !== null) {
94 2
            $seed = call_user_func($this->etagSeed, $request, $this->params);
95 2
            if ($seed !== null) {
96 2
                $etag = $this->generateEtag($seed);
97
            }
98
        }
99
100 4
        $cacheIsValid = $this->validateCache($request, $lastModified, $etag);
101 4
        $response = $handler->handle($request);
102
103 4
        if ($cacheIsValid) {
104 2
            $response = $response->withStatus(Status::NOT_MODIFIED);
105
        }
106
107 4
        if ($this->cacheControlHeader !== null) {
108 4
            $response = $response->withHeader('Cache-Control', $this->cacheControlHeader);
109
        }
110 4
        if ($etag !== null) {
111 2
            $response = $response->withHeader('Etag', $etag);
112
        }
113
114
        // https://tools.ietf.org/html/rfc7232#section-4.1
115 4
        if ($lastModified !== null && (!$cacheIsValid || ($cacheIsValid && $etag === null))) {
116 2
            $response = $response->withHeader(
117 2
                'Last-Modified',
118 2
                gmdate('D, d M Y H:i:s', $lastModified) . ' GMT'
119
            );
120
        }
121
122 4
        return $response;
123
    }
124
125
    /**
126
     * Validates if the HTTP cache contains valid content.
127
     * If both Last-Modified and ETag are null, returns false.
128
     * @param ServerRequestInterface $request
129
     * @param int|null $lastModified the calculated Last-Modified value in terms of a UNIX timestamp.
130
     * If null, the Last-Modified header will not be validated.
131
     * @param string|null $etag the calculated ETag value. If null, the ETag header will not be validated.
132
     * @return bool whether the HTTP cache is still valid.
133
     */
134 4
    private function validateCache(ServerRequestInterface $request, ?int $lastModified, ?string $etag): bool
135
    {
136 4
        if ($request->hasHeader('If-None-Match')) {
137
            // HTTP_IF_NONE_MATCH takes precedence over HTTP_IF_MODIFIED_SINCE
138
            // http://tools.ietf.org/html/rfc7232#section-3.3
139 2
            return $etag !== null && \in_array($etag, $this->getETags($request), true);
140
        }
141
142 2
        if ($request->hasHeader('If-Modified-Since')) {
143 2
            $header = $request->getHeaderLine('If-Modified-Since');
144 2
            return $lastModified !== null && @strtotime($header) >= $lastModified;
145
        }
146
147
        return false;
148
    }
149
150
    /**
151
     * Generates an ETag from the given seed string.
152
     * @param string $seed Seed for the ETag
153
     * @return string the generated ETag
154
     */
155 2
    private function generateEtag(string $seed): string
156
    {
157 2
        $etag = '"' . rtrim(base64_encode(sha1($seed, true)), '=') . '"';
158 2
        return $this->weakEtag ? 'W/' . $etag : $etag;
159
    }
160
161
    /**
162
     * Gets the Etags.
163
     *
164
     * @param ServerRequestInterface $request
165
     * @return array The entity tags
166
     */
167 2
    private function getETags(ServerRequestInterface $request): array
168
    {
169 2
        if ($request->hasHeader('If-None-Match')) {
170 2
            $header = $request->getHeaderLine('If-None-Match');
171 2
            $header = \str_replace('-gzip', '', $header);
172 2
            return \preg_split('/[\s,]+/', $header, -1, PREG_SPLIT_NO_EMPTY) ?: [];
173
        }
174
175
        return [];
176
    }
177
178 3
    public function setLastModified(callable $lastModified): void
179
    {
180 3
        $this->lastModified = $lastModified;
181 3
    }
182
183 2
    public function setEtagSeed(callable $etagSeed): void
184
    {
185 2
        $this->etagSeed = $etagSeed;
186 2
    }
187
188
    public function setWeakTag(bool $weakTag): void
189
    {
190
        $this->weakEtag = $weakTag;
191
    }
192
193
    public function setParams($params): void
194
    {
195
        $this->params = $params;
196
    }
197
198
    public function setCacheControlHeader(?string $header): void
199
    {
200
        $this->cacheControlHeader = $header;
201
    }
202
}
203