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