Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.75% covered (warning)
88.75%
71 / 80
45.45% covered (danger)
45.45%
5 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
ConditionalHeaderUtil
88.75% covered (warning)
88.75%
71 / 80
45.45% covered (danger)
45.45%
5 / 11
59.31
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setValidators
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 setVarnishETagHack
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getETag
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getETagParts
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
4.13
 getLastModified
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 hasRepresentation
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
6.00
 checkPreconditions
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
22
 applyResponseHeaders
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
8
 weakCompare
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 strongCompare
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3namespace MediaWiki\Rest;
4
5use MediaWiki\Rest\HeaderParser\HttpDate;
6use MediaWiki\Rest\HeaderParser\IfNoneMatch;
7use RuntimeException;
8use Wikimedia\Timestamp\ConvertibleTimestamp;
9
10class 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}