Passed
Push — master ( 80aac7...ae3a29 )
by Alexander
08:15 queued 05:14
created

HttpCache::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 1
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Yii\Web\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
/**
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 5
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
72
    {
73
        if (
74 5
            ($this->lastModified === null && $this->etagSeed === null) ||
75 5
            !\in_array($request->getMethod(), [Method::GET, Method::HEAD], true)
76
        ) {
77 1
            return $handler->handle($request);
78
        }
79
80 4
        $lastModified = null;
81 4
        if ($this->lastModified !== null) {
82 2
            $lastModified = call_user_func($this->lastModified, $request, $this->params);
83
        }
84
85 4
        $etag = null;
86 4
        if ($this->etagSeed !== null) {
87 2
            $seed = call_user_func($this->etagSeed, $request, $this->params);
88 2
            if ($seed !== null) {
89 2
                $etag = $this->generateEtag($seed);
90
            }
91
        }
92
93 4
        $cacheIsValid = $this->validateCache($request, $lastModified, $etag);
94 4
        $response = $handler->handle($request);
95
96 4
        if ($cacheIsValid) {
97 2
            $response = $response->withStatus(Status::NOT_MODIFIED);
98
        }
99
100 4
        if ($this->cacheControlHeader !== null) {
101 4
            $response = $response->withHeader(Header::CACHE_CONTROL, $this->cacheControlHeader);
102
        }
103 4
        if ($etag !== null) {
104 2
            $response = $response->withHeader(Header::ETAG, $etag);
105
        }
106
107
        // https://tools.ietf.org/html/rfc7232#section-4.1
108 4
        if ($lastModified !== null && (!$cacheIsValid || $etag === null)) {
109 2
            $response = $response->withHeader(
110 2
                Header::LAST_MODIFIED,
111 2
                gmdate('D, d M Y H:i:s', $lastModified) . ' GMT'
112
            );
113
        }
114
115 4
        return $response;
116
    }
117
118
    /**
119
     * Validates if the HTTP cache contains valid content.
120
     * If both Last-Modified and ETag are null, returns false.
121
     * @param ServerRequestInterface $request
122
     * @param int|null $lastModified the calculated Last-Modified value in terms of a UNIX timestamp.
123
     * If null, the Last-Modified header will not be validated.
124
     * @param string|null $etag the calculated ETag value. If null, the ETag header will not be validated.
125
     * @return bool whether the HTTP cache is still valid.
126
     */
127 4
    private function validateCache(ServerRequestInterface $request, ?int $lastModified, ?string $etag): bool
128
    {
129 4
        if ($request->hasHeader(Header::IF_NONE_MATCH)) {
130
            // HTTP_IF_NONE_MATCH takes precedence over HTTP_IF_MODIFIED_SINCE
131
            // http://tools.ietf.org/html/rfc7232#section-3.3
132 2
            return $etag !== null && \in_array($etag, $this->getETags($request), true);
133
        }
134
135 2
        if ($request->hasHeader(Header::IF_MODIFIED_SINCE)) {
136 2
            $header = $request->getHeaderLine(Header::IF_MODIFIED_SINCE);
137 2
            return $lastModified !== null && @strtotime($header) >= $lastModified;
138
        }
139
140
        return false;
141
    }
142
143
    /**
144
     * Generates an ETag from the given seed string.
145
     * @param string $seed Seed for the ETag
146
     * @return string the generated ETag
147
     */
148 2
    private function generateEtag(string $seed): string
149
    {
150 2
        $etag = '"' . rtrim(base64_encode(sha1($seed, true)), '=') . '"';
151 2
        return $this->weakEtag ? 'W/' . $etag : $etag;
152
    }
153
154
    /**
155
     * Gets the Etags.
156
     *
157
     * @param ServerRequestInterface $request
158
     * @return array The entity tags
159
     */
160 2
    private function getETags(ServerRequestInterface $request): array
161
    {
162 2
        if ($request->hasHeader(Header::IF_NONE_MATCH)) {
163 2
            $header = $request->getHeaderLine(Header::IF_NONE_MATCH);
164 2
            $header = \str_replace('-gzip', '', $header);
165 2
            return \preg_split('/[\s,]+/', $header, -1, PREG_SPLIT_NO_EMPTY) ?: [];
166
        }
167
168
        return [];
169
    }
170
171 3
    public function setLastModified(callable $lastModified): void
172
    {
173 3
        $this->lastModified = $lastModified;
174 3
    }
175
176 2
    public function setEtagSeed(callable $etagSeed): void
177
    {
178 2
        $this->etagSeed = $etagSeed;
179 2
    }
180
181
    public function setWeakTag(bool $weakTag): void
182
    {
183
        $this->weakEtag = $weakTag;
184
    }
185
186
    public function setParams($params): void
187
    {
188
        $this->params = $params;
189
    }
190
191
    public function setCacheControlHeader(?string $header): void
192
    {
193
        $this->cacheControlHeader = $header;
194
    }
195
}
196