forked from snytkine/LampCMS
-
Notifications
You must be signed in to change notification settings - Fork 3
/
CacheHeaders.php
425 lines (381 loc) · 15 KB
/
CacheHeaders.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
<?php
/**
*
* License, TERMS and CONDITIONS
*
* This software is lisensed under the GNU LESSER GENERAL PUBLIC LICENSE (LGPL) version 3
* Please read the license here : http://www.gnu.org/licenses/lgpl-3.0.txt
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* 3. The name of the author may not be used to endorse or promote products
* derived from this software without specific prior written permission.
*
* ATTRIBUTION REQUIRED
* 4. All web pages generated by the use of this software, or at least
* the page that lists the recent questions (usually home page) must include
* a link to the http://www.lampcms.com and text of the link must indicate that
* the website's Questions/Answers functionality is powered by lampcms.com
* An example of acceptable link would be "Powered by <a href="http://www.lampcms.com">LampCMS</a>"
* The location of the link is not important, it can be in the footer of the page
* but it must not be hidden by style attibutes
*
* THIS SOFTWARE IS PROVIDED BY THE AUTHOR "AS IS" AND ANY EXPRESS OR IMPLIED
* WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
* IN NO EVENT SHALL THE FREEBSD PROJECT OR CONTRIBUTORS BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
* THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* This product includes GeoLite data created by MaxMind,
* available from http://www.maxmind.com/
*
*
* @author Dmitri Snytkine <cms@lampcms.com>
* @copyright 2005-2011 (or current year) ExamNotes.net inc.
* @license http://www.gnu.org/licenses/lgpl-3.0.txt GNU LESSER GENERAL PUBLIC LICENSE (LGPL) version 3
* @link http://www.lampcms.com Lampcms.com project
* @version Release: @package_version@
*
*
*/
namespace Lampcms;
/**
* This static class is responsible
* for responding
* to cache control request headers
* and/or to send out cache control
* request headers
*
* @author Dmitri Snytkine
*
*/
class CacheHeaders
{
/**
* Process the If-Modified-Since
* and If-None-Match headers
* This method is only called from classes
* in which it makes sense to make use of these headers
* If this method is called it will check
* if content has changes and if it has not,
* then it will send the 304 status header and exit
* This will save processing resources and of cause
* will save bandwidth since no body has to be sent
*
* If the If-Modified-Since nor If-None-Matched
* are used in the request, then this metod will
* send out the headers
* Last-Modified and/or Etag
* as passed to this method. This way a browser
* will use these values when making the next request
*
* http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.3.4
* Says that ideally the server should send both
* Last-Modified and Etag but it's not a requirement
*
* @param string $lastModified must be valid time string
* @param string $etag any unique string
* @param int $maxAge maximum time during which client considers the cache entry is
* not stale. This number must be in seconds. By default is 15 seconds, but can be
* set to 0 to indicate that cache entry should alwasy be revalidated with the server
*
* @param bool $bCheckForChange if set to false, then will only send out
* the Last-Modified and/or Etag headers but will not
* look at request headers at all.
* Sometimes we may want to only send out headers but
* don't return 304 code if script has not changed.
* This is not a good idea usually, so it's set to true by default
*
* @return mixed true on success or nothing at all
* because script will exit after sending the 304 header
*/
public static function processCacheHeaders($etag = null, $lastModified = null, $maxAge = 10, $bCheckForChange = true)
{
d('$etag '.$etag.' $lastModified: '.$lastModified);
if(empty($lastModified) && empty($etag)){
d('No headers values provided, exising');
return $this;
}
/**
* A small but important check to see
* if headers have already been sent, in which
* case we can't send any more headers or it will generate
* the 'Cannot modify header information'... error.
* In such case we log this as error so the admin
* may be notified
*
*/
if(\headers_sent($file, $line)){
e('LampcmsError Headers have already been sent in file '.$file. ' on line '.$line);
return true;
}
/**
* This tells browsers (at least this is what it's supposed to tell)
* is "it's OK to cache this page" but
* before serving the cached content alwasy check back
* with the server to see if content has changed.
*
* If we set the maxage to 600 (for example), then
* it would tell the server that if the content was downloaded
* less than 10 minues ago, then don't even attempt to contant
* the server, just serve the cached content.
*
* The private meas per-user cache. Basically this means
* that proxy server should not treat this cached entry as
* suitable for every user.
* Since one user may have a different language preference
* than the other, the same page can be served in English
* to one user and in Italian to another. So each client
* can still cache his own version of page, but it's
* not one fits all.
*
* More importantly the Header of page may include the 'welcome back'
* block which would include user's username like "Welcome back Sam"
* Surely this sort of page is indended only for Sam's browser cache
* and not for just any user, so proxies should not
* serve this copy to all users.
* However, the search bots may ignore the cache-control
* if its marked private since it does look to them
* to be user-specific and they don't like that.
* Search engines like to know that the page they see
* is the same page a user will see.
* Just to be on the safe side with them we will mark
* it as public and then take precautions that
* userID and language are part of Etag value.
* This way a logged in user will get different etag
* while Search bots will all get the same etag with
* the value of non-logged-in user.
*
* Important:
* Pragma is ignored when Cache-Control header is present!
* this is only for older http 1.0 browsers and only to
* override the php's default no-cache value of Pragma
*/
header("Pragma: public");
header("Cache-Control: public, maxage=$maxAge, must-revalidate");
/**
* header_remove is only available
* as of php 5.3
* The php by default (default in php.ini)
* will send Expires header with the date
* long in the past. This basically tells the
* browser not to use cached version of the site
* without checking with the site first.
* It does not mean no to cache, just not
* to use cached version without checking
* with the server. Some browsers may still interpret
* it as 'not to cache' since it does not make
* sense to cache page that is already expired.
* Basically it's better to unset this header, but
* not unsetting it should not hurt modern browsers.
*
* Also as of HTTP 1.1 the value in Cache-Control maxage
* always override the Last-Modified header, so as long
* as we send out Cache-Control maxage, we should not worry
* about this "Expires" header that php add without asking us
*
*/
if(function_exists('header_remove')){
header_remove("Expires");
} else {
header('Expires: ');
}
/**
* Now the logic part:
* First of all we must return the
* Etag and Last-Modified values
* in response headers regardless of
* the outcome of the 'nochange' check
* So we can just include these headers here now
*/
if(!empty($lastModified)){
header("Last-Modified: $lastModified");
}
if(!empty($etag)){
header("Etag: $etag");
}
/**
* If $bCheckForChange is false or null, then
* we not using the values of If-Modified-Since
* and If-None-Match to compare to our supplied values
* in which case no further action is going to be done here
*/
if(!$bCheckForChange){
return true;
}
/**
* As per http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.3.4
*
* An HTTP/1.1 origin server, upon receiving a conditional request
* that includes both a Last-Modified date
* (e.g., in an If-Modified-Since or If-Unmodified-Since header field)
* and one or more entity tags
* (e.g., in an If-Match, If-None-Match, or If-Range header field)
* as cache validators,
* MUST NOT return a response status of 304 (Not Modified)
* unless doing so is consistent with all of the conditional header fields
* in the request.
*
* This means that BOTH conditions should be checked
* and 304 returned only if BOTH conditions
* indicate 'NO Change', more specific
* both must "NOT indicate change"
* If either one condition indicates a definite 'change'
* then we must NOT return 304
*/
$noChangeByEtag = $noChangeByTimestamp = false;
/**
* If we can determing change/no change by timestamp then do it
* otherwise we skip this test
*/
if(!empty($_SERVER['HTTP_IF_MODIFIED_SINCE']) && (null !== $lastModified)){
/**
* If change is detected, then return right away,
* no need to run a possible second check (no need to compare etag)
*/
if(false === $noChangeByTimestamp = self::detectNoChangeByTimestamp($lastModified)){
return true;
}
}
/**
* If we can determine change/no change by etag then do it
* otherwise we skip this test
*/
if(!empty($_SERVER['HTTP_IF_NONE_MATCH']) && (null !== $etag)){
/**
* If change is detected (no match for etag)
* then return
*/
if(false === $noChangeByEtag = self::isEtagMatch($etag)){
return true;
}
}
/**
* Now if either one of the conditional checks return true,
* meaning that 'no change' has been detected
* we return 304 header but ONLY if request method is GET or HEAD,
* for all others return special code
*/
if($noChangeByEtag || $noChangeByTimestamp){
if($noChangeByEtag && ('GET' !== $_SERVER['REQUEST_METHOD'] && 'HEAD' !== $_SERVER['REQUEST_METHOD'])){
header("HTTP/1.1 412 (Precondition Failed)");
throw new \OutOfBoundsException;
}
header("HTTP/1.1 304 Not Modified");
throw new \OutOfBoundsException;
}
return true;
}
/**
* Etag parsing
* Not as simple as just comparing value!
* The If-None-Match may include multiple comma-separated etag values!
*
* From: http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26
* Instead, if the request method was GET or HEAD,
* the server SHOULD respond with a 304 (Not Modified) response,
* including the cache- related header fields (particularly ETag)
* of one of the entities that matched.
* For all other request methods,
* the server MUST respond with a status of 412 (Precondition Failed).
*
* From: http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26
* ... server MUST NOT perform the requested method,
* unless required to do so because the resource's modification date
* fails to match that supplied in an
* If-Modified-Since header field in the request.
*
* This means that if supplied etag matched our etag we still must
* check the If-Modified-Since header
*
* Note about weak validator:
* (Etag is a validator)
* From this url:
* http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.3.3
*
* The weak comparison function: in order to be considered equal,
* both validators MUST be identical in every way, but either or
* both of them MAY be tagged as "weak" without affecting the
* result.
*
* @param string $etag value of our ACTUAL etag for this page
* This value must be unique not only for this page but for
* the whole domain. This means that just having the timestamp of
* the message is not enough since another message may have the exact same timestamp
* To uniquely identify the page we should include messageID followed by a timestamp
* and may append any other string to help uniquly identify the page
*
* @return bool true if ANY of the etags (in case more than one is included)
* matches OUR etag (value of $etag)
* true means DEFINITE 'NO CHANGE' detected
*
* false means 'no match', meaning content changed or
* unable to determine
*/
protected static function isEtagMatch($etag = null)
{
/**
* Special case http1.1 allows for wildcard
* of etag and it matches any value
*/
if('*' === $_SERVER['HTTP_IF_NONE_MATCH']){
return true;
}
if(!strstr($etag, ', ')){
return ($etag === $_SERVER['HTTP_IF_NONE_MATCH']);
}
$aEtags = explode(',', $_SERVER['HTTP_IF_NONE_MATCH']);
foreach($aEtags as $tag){
if(trim($tag) === $etag){
return true;
}
}
return false;
}
/**
* MUST return true ONLY if
* we are certain that content has not changed
* This means that both If-Modified-Since header
* and $lastModified values are present
* and after examining them we determine that there
* is definetely no change.
*
* @param $lastModified
* @return bool true means definete 'no change', false
* means content has changed
*/
protected static function detectNoChangeByTimestamp($lastModified)
{
if($_SERVER['HTTP_IF_MODIFIED_SINCE'] === $lastModified){
/**
* A perfect match means no change!
*/
return true;
}
/**
* Handle the case where client composed an arbitrary value
* of If-Modified-Since
* This is not recommended, but we still must be able
* to handle this gacefully
* If value of If-Modified-Since greater than our Last-Modified
* that would mean that contant has indeed been modified
* For example, client asks for a content that has been modified
* after Dec 5 2009, but our content was last modified on Dec 4 2009
* As far as client is concerned, there has been no change
*/
if(strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) >= strtotime($lastModified) ){
return true;
}
return false;
}
}