Completed
Push — composer-installed ( 5832b4 )
by Ilia
08:49
created

HTTP_ConditionalGet::__construct()   F

Complexity

Conditions 17
Paths 2560

Size

Total Lines 50

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 17
nc 2560
nop 1
dl 0
loc 50
rs 1.0499
c 0
b 0
f 0

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
/**
3
 * Class HTTP_ConditionalGet  
4
 * @package Minify
5
 * @subpackage HTTP
6
 */
7
8
/**
9
 * Implement conditional GET via a timestamp or hash of content
10
 *
11
 * E.g. Content from DB with update time:
12
 * <code>
13
 * list($updateTime, $content) = getDbUpdateAndContent();
14
 * $cg = new HTTP_ConditionalGet(array(
15
 *     'lastModifiedTime' => $updateTime
16
 *     ,'isPublic' => true
17
 * ));
18
 * $cg->sendHeaders();
19
 * if ($cg->cacheIsValid) {
20
 *     exit();
21
 * }
22
 * echo $content;
23
 * </code>
24
 * 
25
 * E.g. Shortcut for the above
26
 * <code>
27
 * HTTP_ConditionalGet::check($updateTime, true); // exits if client has cache
28
 * echo $content;
29
 * </code>
30
 *
31
 * E.g. Content from DB with no update time:
32
 * <code>
33
 * $content = getContentFromDB();
34
 * $cg = new HTTP_ConditionalGet(array(
35
 *     'contentHash' => md5($content)
36
 * ));
37
 * $cg->sendHeaders();
38
 * if ($cg->cacheIsValid) {
39
 *     exit();
40
 * }
41
 * echo $content;
42
 * </code>
43
 * 
44
 * E.g. Static content with some static includes:
45
 * <code>
46
 * // before content
47
 * $cg = new HTTP_ConditionalGet(array(
48
 *     'lastUpdateTime' => max(
49
 *         filemtime(__FILE__)
50
 *         ,filemtime('/path/to/header.inc')
51
 *         ,filemtime('/path/to/footer.inc')
52
 *     )
53
 * ));
54
 * $cg->sendHeaders();
55
 * if ($cg->cacheIsValid) {
56
 *     exit();
57
 * }
58
 * </code>
59
 * @package Minify
60
 * @subpackage HTTP
61
 * @author Stephen Clay <[email protected]>
62
 */
63
class HTTP_ConditionalGet {
64
65
    /**
66
     * Does the client have a valid copy of the requested resource?
67
     * 
68
     * You'll want to check this after instantiating the object. If true, do
69
     * not send content, just call sendHeaders() if you haven't already.
70
     *
71
     * @var bool
72
     */
73
    public $cacheIsValid = null;
74
75
    /**
76
     * @param array $spec options
77
     * 
78
     * 'isPublic': (bool) if false, the Cache-Control header will contain
79
     * "private", allowing only browser caching. (default false)
80
     * 
81
     * 'lastModifiedTime': (int) if given, both ETag AND Last-Modified headers
82
     * will be sent with content. This is recommended.
83
     *
84
     * 'encoding': (string) if set, the header "Vary: Accept-Encoding" will
85
     * always be sent and a truncated version of the encoding will be appended
86
     * to the ETag. E.g. "pub123456;gz". This will also trigger a more lenient 
87
     * checking of the client's If-None-Match header, as the encoding portion of
88
     * the ETag will be stripped before comparison.
89
     * 
90
     * 'contentHash': (string) if given, only the ETag header can be sent with
91
     * content (only HTTP1.1 clients can conditionally GET). The given string 
92
     * should be short with no quote characters and always change when the 
93
     * resource changes (recommend md5()). This is not needed/used if 
94
     * lastModifiedTime is given.
95
     * 
96
     * 'eTag': (string) if given, this will be used as the ETag header rather
97
     * than values based on lastModifiedTime or contentHash. Also the encoding
98
     * string will not be appended to the given value as described above.
99
     * 
100
     * 'invalidate': (bool) if true, the client cache will be considered invalid
101
     * without testing. Effectively this disables conditional GET. 
102
     * (default false)
103
     * 
104
     * 'maxAge': (int) if given, this will set the Cache-Control max-age in 
105
     * seconds, and also set the Expires header to the equivalent GMT date. 
106
     * After the max-age period has passed, the browser will again send a 
107
     * conditional GET to revalidate its cache.
108
     */
109
    public function __construct($spec)
110
    {
111
        $scope = (isset($spec['isPublic']) && $spec['isPublic'])
112
            ? 'public'
113
            : 'private';
114
        $maxAge = 0;
115
        // backwards compatibility (can be removed later)
116
        if (isset($spec['setExpires']) 
117
            && is_numeric($spec['setExpires'])
118
            && ! isset($spec['maxAge'])) {
119
            $spec['maxAge'] = $spec['setExpires'] - $_SERVER['REQUEST_TIME'];
120
        }
121
        if (isset($spec['maxAge'])) {
122
            $maxAge = $spec['maxAge'];
123
            $this->_headers['Expires'] = self::gmtDate(
124
                $_SERVER['REQUEST_TIME'] + $spec['maxAge'] 
125
            );
126
        }
127
        $etagAppend = '';
128
        if (isset($spec['encoding'])) {
129
            $this->_stripEtag = true;
130
            $this->_headers['Vary'] = 'Accept-Encoding';
131
            if ('' !== $spec['encoding']) {
132
                if (0 === strpos($spec['encoding'], 'x-')) {
133
                    $spec['encoding'] = substr($spec['encoding'], 2);
134
                }
135
                $etagAppend = ';' . substr($spec['encoding'], 0, 2);
136
            }
137
        }
138
        if (isset($spec['lastModifiedTime'])) {
139
            $this->_setLastModified($spec['lastModifiedTime']);
140
            if (isset($spec['eTag'])) { // Use it
141
                $this->_setEtag($spec['eTag'], $scope);
142
            } else { // base both headers on time
143
                $this->_setEtag($spec['lastModifiedTime'] . $etagAppend, $scope);
144
            }
145
        } elseif (isset($spec['eTag'])) { // Use it
146
            $this->_setEtag($spec['eTag'], $scope);
147
        } elseif (isset($spec['contentHash'])) { // Use the hash as the ETag
148
            $this->_setEtag($spec['contentHash'] . $etagAppend, $scope);
149
        }
150
        $privacy = ($scope === 'private')
151
            ? ', private'
152
            : '';
153
        $this->_headers['Cache-Control'] = "max-age={$maxAge}{$privacy}";
154
        // invalidate cache if disabled, otherwise check
155
        $this->cacheIsValid = (isset($spec['invalidate']) && $spec['invalidate'])
156
            ? false
157
            : $this->_isCacheValid();
158
    }
159
    
160
    /**
161
     * Get array of output headers to be sent
162
     * 
163
     * In the case of 304 responses, this array will only contain the response
164
     * code header: array('_responseCode' => 'HTTP/1.0 304 Not Modified')
165
     * 
166
     * Otherwise something like: 
167
     * <code>
168
     * array(
169
     *     'Cache-Control' => 'max-age=0, public'
170
     *     ,'ETag' => '"foobar"'
171
     * )
172
     * </code>
173
     *
174
     * @return array 
175
     */
176
    public function getHeaders()
177
    {
178
        return $this->_headers;
179
    }
180
181
    /**
182
     * Set the Content-Length header in bytes
183
     * 
184
     * With most PHP configs, as long as you don't flush() output, this method
185
     * is not needed and PHP will buffer all output and set Content-Length for 
186
     * you. Otherwise you'll want to call this to let the client know up front.
187
     * 
188
     * @param int $bytes
189
     * 
190
     * @return int copy of input $bytes
191
     */
192
    public function setContentLength($bytes)
193
    {
194
        return $this->_headers['Content-Length'] = $bytes;
195
    }
196
197
    /**
198
     * Send headers
199
     * 
200
     * @see getHeaders()
201
     * 
202
     * Note this doesn't "clear" the headers. Calling sendHeaders() will
203
     * call header() again (but probably have not effect) and getHeaders() will
204
     * still return the headers.
205
     *
206
     * @return null
207
     */
208
    public function sendHeaders()
209
    {
210
        $headers = $this->_headers;
211
        if (array_key_exists('_responseCode', $headers)) {
212
            // FastCGI environments require 3rd arg to header() to be set
213
            list(, $code) = explode(' ', $headers['_responseCode'], 3);
214
            header($headers['_responseCode'], true, $code);
215
            unset($headers['_responseCode']);
216
        }
217
        foreach ($headers as $name => $val) {
218
            header($name . ': ' . $val);
219
        }
220
    }
221
    
222
    /**
223
     * Exit if the client's cache is valid for this resource
224
     *
225
     * This is a convenience method for common use of the class
226
     *
227
     * @param int $lastModifiedTime if given, both ETag AND Last-Modified headers
228
     * will be sent with content. This is recommended.
229
     *
230
     * @param bool $isPublic (default false) if true, the Cache-Control header 
231
     * will contain "public", allowing proxies to cache the content. Otherwise 
232
     * "private" will be sent, allowing only browser caching.
233
     *
234
     * @param array $options (default empty) additional options for constructor
235
     */
236
    public static function check($lastModifiedTime = null, $isPublic = false, $options = array())
237
    {
238
        if (null !== $lastModifiedTime) {
239
            $options['lastModifiedTime'] = (int)$lastModifiedTime;
240
        }
241
        $options['isPublic'] = (bool)$isPublic;
242
        $cg = new HTTP_ConditionalGet($options);
243
        $cg->sendHeaders();
244
        if ($cg->cacheIsValid) {
245
            exit();
246
        }
247
    }
248
    
249
    
250
    /**
251
     * Get a GMT formatted date for use in HTTP headers
252
     * 
253
     * <code>
254
     * header('Expires: ' . HTTP_ConditionalGet::gmtdate($time));
255
     * </code>  
256
     *
257
     * @param int $time unix timestamp
258
     * 
259
     * @return string
260
     */
261
    public static function gmtDate($time)
262
    {
263
        return gmdate('D, d M Y H:i:s \G\M\T', $time);
264
    }
265
    
266
    protected $_headers = array();
267
    protected $_lmTime = null;
268
    protected $_etag = null;
269
    protected $_stripEtag = false;
270
271
    /**
272
     * @param string $hash
273
     *
274
     * @param string $scope
275
     */
276
    protected function _setEtag($hash, $scope)
277
    {
278
        $this->_etag = '"' . substr($scope, 0, 3) . $hash . '"';
279
        $this->_headers['ETag'] = $this->_etag;
280
    }
281
282
    /**
283
     * @param int $time
284
     */
285
    protected function _setLastModified($time)
286
    {
287
        $this->_lmTime = (int)$time;
288
        $this->_headers['Last-Modified'] = self::gmtDate($time);
289
    }
290
291
    /**
292
     * Determine validity of client cache and queue 304 header if valid
293
     *
294
     * @return bool
295
     */
296
    protected function _isCacheValid()
297
    {
298
        if (null === $this->_etag) {
299
            // lmTime is copied to ETag, so this condition implies that the
300
            // server sent neither ETag nor Last-Modified, so the client can't 
301
            // possibly has a valid cache.
302
            return false;
303
        }
304
        $isValid = ($this->resourceMatchedEtag() || $this->resourceNotModified());
305
        if ($isValid) {
306
            $this->_headers['_responseCode'] = 'HTTP/1.0 304 Not Modified';
307
        }
308
        return $isValid;
309
    }
310
311
    /**
312
     * @return bool
313
     */
314
    protected function resourceMatchedEtag()
315
    {
316
        if (!isset($_SERVER['HTTP_IF_NONE_MATCH'])) {
317
            return false;
318
        }
319
        $clientEtagList = get_magic_quotes_gpc()
320
            ? stripslashes($_SERVER['HTTP_IF_NONE_MATCH'])
321
            : $_SERVER['HTTP_IF_NONE_MATCH'];
322
        $clientEtags = explode(',', $clientEtagList);
323
        
324
        $compareTo = $this->normalizeEtag($this->_etag);
325
        foreach ($clientEtags as $clientEtag) {
326
            if ($this->normalizeEtag($clientEtag) === $compareTo) {
327
                // respond with the client's matched ETag, even if it's not what
328
                // we would've sent by default
329
                $this->_headers['ETag'] = trim($clientEtag);
330
                return true;
331
            }
332
        }
333
        return false;
334
    }
335
336
    /**
337
     * @param string $etag
338
     *
339
     * @return string
340
     */
341
    protected function normalizeEtag($etag) {
342
        $etag = trim($etag);
343
        return $this->_stripEtag
344
            ? preg_replace('/;\\w\\w"$/', '"', $etag)
345
            : $etag;
346
    }
347
348
    /**
349
     * @return bool
350
     */
351
    protected function resourceNotModified()
352
    {
353
        if (!isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
354
            return false;
355
        }
356
        // strip off IE's extra data (semicolon)
357
        list($ifModifiedSince) = explode(';', $_SERVER['HTTP_IF_MODIFIED_SINCE'], 2);
358
        if (strtotime($ifModifiedSince) >= $this->_lmTime) {
359
            // Apache 2.2's behavior. If there was no ETag match, send the 
360
            // non-encoded version of the ETag value.
361
            $this->_headers['ETag'] = $this->normalizeEtag($this->_etag);
362
            return true;
363
        }
364
        return false;
365
    }
366
}
367