Passed
Push — master ( b45034...83eef8 )
by
unknown
03:50 queued 01:25
created

HttpCache::withWeakEtag()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

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