Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
88.75% |
71 / 80 |
|
45.45% |
5 / 11 |
CRAP | |
0.00% |
0 / 1 |
ConditionalHeaderUtil | |
88.75% |
71 / 80 |
|
45.45% |
5 / 11 |
59.31 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setValidators | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
setVarnishETagHack | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getETag | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
getETagParts | |
80.00% |
8 / 10 |
|
0.00% |
0 / 1 |
4.13 | |||
getLastModified | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
3.02 | |||
hasRepresentation | |
50.00% |
3 / 6 |
|
0.00% |
0 / 1 |
6.00 | |||
checkPreconditions | |
100.00% |
30 / 30 |
|
100.00% |
1 / 1 |
22 | |||
applyResponseHeaders | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
8 | |||
weakCompare | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
3.33 | |||
strongCompare | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
6 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Rest; |
4 | |
5 | use MediaWiki\Rest\HeaderParser\HttpDate; |
6 | use MediaWiki\Rest\HeaderParser\IfNoneMatch; |
7 | use RuntimeException; |
8 | use Wikimedia\Timestamp\ConvertibleTimestamp; |
9 | |
10 | class ConditionalHeaderUtil { |
11 | /** @var bool */ |
12 | private $varnishETagHack = true; |
13 | /** @var callback|string|null */ |
14 | private $eTag; |
15 | /** @var callback|string|int|null */ |
16 | private $lastModified; |
17 | /** @var callback|bool */ |
18 | private $hasRepresentation; |
19 | |
20 | private IfNoneMatch $eTagParser; |
21 | |
22 | private ?array $eTagParts = null; |
23 | |
24 | public function __construct() { |
25 | $this->eTagParser = new IfNoneMatch; |
26 | } |
27 | |
28 | /** |
29 | * Initialize the object with information about the requested resource. |
30 | * |
31 | * @param callable|string|null $eTag The entity-tag (including quotes), or null if |
32 | * it is unknown. Can also be provided as a callback for later evaluation. |
33 | * @param callable|string|int|null $lastModified The Last-Modified date in a format |
34 | * accepted by ConvertibleTimestamp, or null if it is unknown. |
35 | * Can also be provided as a callback for later evaluation. |
36 | * @param callable|bool|null $hasRepresentation Whether the server can serve a |
37 | * representation of the target resource. This should be true if the |
38 | * resource exists, and false if it does not exist. It is used for |
39 | * wildcard validators -- the intended use case is to abort a PUT if the |
40 | * resource does (or does not) exist. If null is passed, we assume that |
41 | * the resource exists if an ETag or last-modified data was specified for it. |
42 | * Can also be provided as a callback for later evaluation. |
43 | */ |
44 | public function setValidators( |
45 | $eTag, |
46 | $lastModified, |
47 | $hasRepresentation = null |
48 | ) { |
49 | $this->eTag = $eTag; |
50 | $this->lastModified = $lastModified; |
51 | $this->hasRepresentation = $hasRepresentation; |
52 | } |
53 | |
54 | /** |
55 | * If the Varnish ETag hack is disabled by calling this method, |
56 | * strong ETag comparison will follow RFC 7232, rejecting all weak |
57 | * ETags for If-Match comparison. |
58 | * |
59 | * @param bool $hack |
60 | */ |
61 | public function setVarnishETagHack( $hack ) { |
62 | $this->varnishETagHack = $hack; |
63 | } |
64 | |
65 | private function getETag(): ?string { |
66 | if ( is_callable( $this->eTag ) ) { |
67 | // resolve callback |
68 | $this->eTag = ( $this->eTag )(); |
69 | } |
70 | |
71 | return $this->eTag; |
72 | } |
73 | |
74 | private function getETagParts(): ?array { |
75 | if ( $this->eTagParts !== null ) { |
76 | return $this->eTagParts; |
77 | } |
78 | |
79 | $eTag = $this->getETag(); |
80 | |
81 | if ( $eTag === null ) { |
82 | return null; |
83 | } |
84 | |
85 | $this->eTagParts = $this->eTagParser->parseETag( $eTag ); |
86 | if ( !$this->eTagParts ) { |
87 | throw new RuntimeException( 'Invalid ETag returned by handler: `' . |
88 | $this->eTagParser->getLastError() . '`' ); |
89 | } |
90 | |
91 | return $this->eTagParts; |
92 | } |
93 | |
94 | private function getLastModified(): ?int { |
95 | if ( is_callable( $this->lastModified ) ) { |
96 | // resolve callback |
97 | $this->lastModified = ( $this->lastModified )(); |
98 | } |
99 | |
100 | if ( is_string( $this->lastModified ) ) { |
101 | // normalize to int |
102 | $this->lastModified = (int)ConvertibleTimestamp::convert( |
103 | TS_UNIX, |
104 | $this->lastModified |
105 | ); |
106 | } |
107 | |
108 | // should be int or null now. |
109 | return $this->lastModified; |
110 | } |
111 | |
112 | private function hasRepresentation(): bool { |
113 | if ( is_callable( $this->hasRepresentation ) ) { |
114 | // resolve callback |
115 | $this->hasRepresentation = ( $this->hasRepresentation )(); |
116 | } |
117 | |
118 | if ( $this->hasRepresentation === null ) { |
119 | // apply fallback |
120 | $this->hasRepresentation = $this->getETag() !== null |
121 | || $this->getLastModified() !== null; |
122 | } |
123 | |
124 | return $this->hasRepresentation; |
125 | } |
126 | |
127 | /** |
128 | * Check conditional request headers in the order required by RFC 7232 section 6. |
129 | * |
130 | * @param RequestInterface $request |
131 | * @return int|null The status code to immediately return, or null to |
132 | * continue processing the request. |
133 | */ |
134 | public function checkPreconditions( RequestInterface $request ) { |
135 | $getOrHead = in_array( $request->getMethod(), [ 'GET', 'HEAD' ] ); |
136 | if ( $request->hasHeader( 'If-Match' ) ) { |
137 | $im = $request->getHeader( 'If-Match' ); |
138 | $match = false; |
139 | foreach ( $this->eTagParser->parseHeaderList( $im ) as $tag ) { |
140 | if ( ( $tag['whole'] === '*' && $this->hasRepresentation() ) || |
141 | $this->strongCompare( $this->getETagParts(), $tag ) |
142 | ) { |
143 | $match = true; |
144 | break; |
145 | } |
146 | } |
147 | if ( !$match ) { |
148 | return 412; |
149 | } |
150 | } elseif ( $request->hasHeader( 'If-Unmodified-Since' ) ) { |
151 | $requestDate = HttpDate::parse( $request->getHeader( 'If-Unmodified-Since' )[0] ); |
152 | $lastModified = $this->getLastModified(); |
153 | if ( $requestDate !== null |
154 | && ( $lastModified === null || $lastModified > $requestDate ) |
155 | ) { |
156 | return 412; |
157 | } |
158 | } |
159 | if ( $request->hasHeader( 'If-None-Match' ) ) { |
160 | $inm = $request->getHeader( 'If-None-Match' ); |
161 | foreach ( $this->eTagParser->parseHeaderList( $inm ) as $tag ) { |
162 | if ( ( $tag['whole'] === '*' && $this->hasRepresentation() ) || |
163 | $this->weakCompare( $this->getETagParts(), $tag ) |
164 | ) { |
165 | return $getOrHead ? 304 : 412; |
166 | } |
167 | } |
168 | } elseif ( $getOrHead && $request->hasHeader( 'If-Modified-Since' ) ) { |
169 | $requestDate = HttpDate::parse( $request->getHeader( 'If-Modified-Since' )[0] ); |
170 | $lastModified = $this->getLastModified(); |
171 | if ( $requestDate !== null && $lastModified !== null |
172 | && $lastModified <= $requestDate |
173 | ) { |
174 | return 304; |
175 | } |
176 | } |
177 | // RFC 7232 states that If-Range should be evaluated here. However, the |
178 | // purpose of If-Range is to cause the Range request header to be |
179 | // conditionally ignored, not to immediately send a response, so it |
180 | // doesn't fit here. RFC 7232 only requires that If-Range be checked |
181 | // after the other conditional header fields, a requirement that is |
182 | // satisfied if it is processed in Handler::execute(). |
183 | return null; |
184 | } |
185 | |
186 | /** |
187 | * Set Last-Modified and ETag headers in the response according to the cached |
188 | * values set by setValidators(), which are also used for precondition checks. |
189 | * |
190 | * If the headers are already present in the response, the existing headers |
191 | * take precedence. |
192 | */ |
193 | public function applyResponseHeaders( ResponseInterface $response ) { |
194 | if ( $response->getStatusCode() >= 400 |
195 | || $response->getStatusCode() === 301 |
196 | || $response->getStatusCode() === 307 |
197 | ) { |
198 | // Don't add Last-Modified and ETag for errors, including 412. |
199 | // Note that 304 responses are required to have these headers set. |
200 | // See IETF RFC 7232 section 4. |
201 | return; |
202 | } |
203 | |
204 | $lastModified = $this->getLastModified(); |
205 | if ( $lastModified !== null && !$response->hasHeader( 'Last-Modified' ) ) { |
206 | $response->setHeader( 'Last-Modified', HttpDate::format( $lastModified ) ); |
207 | } |
208 | |
209 | $eTag = $this->getETag(); |
210 | if ( $eTag !== null && !$response->hasHeader( 'ETag' ) ) { |
211 | $response->setHeader( 'ETag', $eTag ); |
212 | } |
213 | } |
214 | |
215 | /** |
216 | * The weak comparison function, per RFC 7232, section 2.3.2. |
217 | * |
218 | * @param array|null $resourceETag ETag generated by the handler, parsed tag info array |
219 | * @param array|null $headerETag ETag supplied by the client, parsed tag info array |
220 | * @return bool |
221 | */ |
222 | private function weakCompare( $resourceETag, $headerETag ) { |
223 | if ( $resourceETag === null || $headerETag === null ) { |
224 | return false; |
225 | } |
226 | return $resourceETag['contents'] === $headerETag['contents']; |
227 | } |
228 | |
229 | /** |
230 | * The strong comparison function |
231 | * |
232 | * A strong ETag returned by the server may have been "weakened" by Varnish when applying |
233 | * compression. So optionally ignore the weakness of the header. |
234 | * {@link https://varnish-cache.org/docs/6.0/users-guide/compression.html}. |
235 | * @see T238849 and T310710 |
236 | * |
237 | * @param array|null $resourceETag ETag generated by the handler, parsed tag info array |
238 | * @param array|null $headerETag ETag supplied by the client, parsed tag info array |
239 | * |
240 | * @return bool |
241 | */ |
242 | private function strongCompare( $resourceETag, $headerETag ) { |
243 | if ( $resourceETag === null || $headerETag === null ) { |
244 | return false; |
245 | } |
246 | |
247 | return !$resourceETag['weak'] |
248 | && ( $this->varnishETagHack || !$headerETag['weak'] ) |
249 | && $resourceETag['contents'] === $headerETag['contents']; |
250 | } |
251 | |
252 | } |