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