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    /** @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}