1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
/** |
4
|
|
|
* Class HTTPCacheControl |
5
|
|
|
* |
6
|
|
|
* @see https://docs.silverstripe.org/en/developer_guides/performance/http_cache_headers/ |
7
|
|
|
*/ |
8
|
|
|
class HTTPCacheControl extends SS_Object { |
9
|
|
|
|
10
|
|
|
/** |
11
|
|
|
* @var static |
12
|
|
|
*/ |
13
|
|
|
private static $inst; |
14
|
|
|
|
15
|
|
|
/** |
16
|
|
|
* Store for all the current directives and their values |
17
|
|
|
* Starts with an implicit config for disabled caching |
18
|
|
|
* |
19
|
|
|
* @var array |
20
|
|
|
*/ |
21
|
|
|
private $state = array(); |
22
|
|
|
|
23
|
|
|
/** |
24
|
|
|
* Forcing level of previous setting; higher number wins |
25
|
|
|
* Combination of consts belo |
26
|
|
|
*w |
27
|
|
|
* @var int |
28
|
|
|
*/ |
29
|
|
|
protected $forcingLevel = 0; |
30
|
|
|
|
31
|
|
|
/** |
32
|
|
|
* Forcing level forced, optionally combined with one of the below. |
33
|
|
|
*/ |
34
|
|
|
const LEVEL_FORCED = 10; |
35
|
|
|
|
36
|
|
|
/** |
37
|
|
|
* Forcing level caching disabled. Overrides public/private. |
38
|
|
|
*/ |
39
|
|
|
const LEVEL_DISABLED = 3; |
40
|
|
|
|
41
|
|
|
/** |
42
|
|
|
* Forcing level private-cached. Overrides public. |
43
|
|
|
*/ |
44
|
|
|
const LEVEL_PRIVATE = 2; |
45
|
|
|
|
46
|
|
|
/** |
47
|
|
|
* Forcing level public cached. Lowest priority. |
48
|
|
|
*/ |
49
|
|
|
const LEVEL_PUBLIC = 1; |
50
|
|
|
|
51
|
|
|
/** |
52
|
|
|
* Forcing level caching enabled. |
53
|
|
|
*/ |
54
|
|
|
const LEVEL_ENABLED = 0; |
55
|
|
|
|
56
|
|
|
|
57
|
|
|
/** |
58
|
|
|
* A list of allowed cache directives for HTTPResponses |
59
|
|
|
* |
60
|
|
|
* This doesn't include any experimental directives, |
61
|
|
|
* use the config system to add to these if you want to enable them |
62
|
|
|
* |
63
|
|
|
* @config |
64
|
|
|
* @var array |
65
|
|
|
*/ |
66
|
|
|
private static $allowed_directives = array( |
67
|
|
|
'public', |
68
|
|
|
'private', |
69
|
|
|
'no-cache', |
70
|
|
|
'max-age', |
71
|
|
|
's-maxage', |
72
|
|
|
'must-revalidate', |
73
|
|
|
'proxy-revalidate', |
74
|
|
|
'no-store', |
75
|
|
|
'no-transform', |
76
|
|
|
); |
77
|
|
|
|
78
|
|
|
public function __construct() |
79
|
|
|
{ |
80
|
|
|
parent::__construct(); |
81
|
|
|
|
82
|
|
|
// If we've not been provided an initial state, then grab HTTP.cache_contrpl from config |
83
|
|
|
if (!$this->state) { |
|
|
|
|
84
|
|
|
$this->setDirectivesFromArray(Config::inst()->get('HTTP', 'cache_control')); |
85
|
|
|
} |
86
|
|
|
} |
87
|
|
|
|
88
|
|
|
/** |
89
|
|
|
* Instruct the cache to apply a change with a given level, optionally |
90
|
|
|
* modifying it with a force flag to increase priority of this action. |
91
|
|
|
* |
92
|
|
|
* If the apply level was successful, the change is made and the internal level |
93
|
|
|
* threshold is incremented. |
94
|
|
|
* |
95
|
|
|
* @param int $level Priority of the given change |
96
|
|
|
* @param bool $force If usercode has requested this action is forced to a higher priority. |
97
|
|
|
* Note: Even if $force is set to true, other higher-priority forced changes can still |
98
|
|
|
* cause a change to be rejected if it is below the required threshold. |
99
|
|
|
* @return bool True if the given change is accepted, and that the internal |
100
|
|
|
* level threshold is updated (if necessary) to the new minimum level. |
101
|
|
|
*/ |
102
|
|
|
protected function applyChangeLevel($level, $force) |
103
|
|
|
{ |
104
|
|
|
$forcingLevel = $level + ($force ? self::LEVEL_FORCED : 0); |
105
|
|
|
if ($forcingLevel < $this->forcingLevel) { |
106
|
|
|
return false; |
107
|
|
|
} |
108
|
|
|
$this->forcingLevel = $forcingLevel; |
109
|
|
|
return true; |
110
|
|
|
} |
111
|
|
|
|
112
|
|
|
/** |
113
|
|
|
* Low level method for setting directives include any experimental or custom ones added via config |
114
|
|
|
* |
115
|
|
|
* @param string $directive |
116
|
|
|
* @param string|bool $value |
117
|
|
|
* |
118
|
|
|
* @return $this |
119
|
|
|
*/ |
120
|
|
|
public function setDirective($directive, $value = null) |
121
|
|
|
{ |
122
|
|
|
// make sure the directive is in the list of allowed directives |
123
|
|
|
$allowedDirectives = $this->config()->get('allowed_directives'); |
124
|
|
|
$directive = strtolower($directive); |
125
|
|
|
if (in_array($directive, $allowedDirectives)) { |
126
|
|
|
$this->state[$directive] = $value; |
127
|
|
|
} else { |
128
|
|
|
throw new InvalidArgumentException('Directive ' . $directive . ' is not allowed'); |
129
|
|
|
} |
130
|
|
|
return $this; |
131
|
|
|
} |
132
|
|
|
|
133
|
|
|
/** |
134
|
|
|
* Low level method to set directives from an associative array |
135
|
|
|
* |
136
|
|
|
* @param array $directives |
137
|
|
|
* |
138
|
|
|
* @return $this |
139
|
|
|
*/ |
140
|
|
|
public function setDirectivesFromArray($directives) |
141
|
|
|
{ |
142
|
|
|
foreach ($directives as $directive => $value) { |
143
|
|
|
// null values mean remove |
144
|
|
|
if (is_null($value)) { |
145
|
|
|
$this->removeDirective($directive); |
146
|
|
|
} else { |
147
|
|
|
// for legacy reasons we accept the string literal "true" as a bool |
148
|
|
|
// a bool value of true means there is no explicit value for the directive |
149
|
|
|
if ($value && (is_bool($value) || strtolower($value) === 'true')) { |
150
|
|
|
$value = null; |
151
|
|
|
} |
152
|
|
|
$this->setDirective($directive, $value); |
153
|
|
|
} |
154
|
|
|
} |
155
|
|
|
return $this; |
156
|
|
|
} |
157
|
|
|
|
158
|
|
|
/** |
159
|
|
|
* Low level method for removing directives |
160
|
|
|
* |
161
|
|
|
* @param string $directive |
162
|
|
|
* |
163
|
|
|
* @return $this |
164
|
|
|
*/ |
165
|
|
|
public function removeDirective($directive) |
166
|
|
|
{ |
167
|
|
|
unset($this->state[strtolower($directive)]); |
168
|
|
|
return $this; |
169
|
|
|
} |
170
|
|
|
|
171
|
|
|
/** |
172
|
|
|
* Low level method to check if a directive is currently set |
173
|
|
|
* |
174
|
|
|
* @param string $directive |
175
|
|
|
* |
176
|
|
|
* @return bool |
177
|
|
|
*/ |
178
|
|
|
public function hasDirective($directive) |
179
|
|
|
{ |
180
|
|
|
return array_key_exists(strtolower($directive), $this->state); |
181
|
|
|
} |
182
|
|
|
|
183
|
|
|
/** |
184
|
|
|
* Low level method to get the value of a directive |
185
|
|
|
* |
186
|
|
|
* Note that `null` value is acceptable for a directive |
187
|
|
|
* |
188
|
|
|
* @param string $directive |
189
|
|
|
* |
190
|
|
|
* @return string|false|null |
191
|
|
|
*/ |
192
|
|
|
public function getDirective($directive) |
193
|
|
|
{ |
194
|
|
|
if ($this->hasDirective($directive)) { |
195
|
|
|
return $this->state[strtolower($directive)]; |
196
|
|
|
} |
197
|
|
|
return false; |
198
|
|
|
} |
199
|
|
|
|
200
|
|
|
/** |
201
|
|
|
* The cache should not store anything about the client request or server response. |
202
|
|
|
* |
203
|
|
|
* Set the no-store directive (also removes max-age and s-maxage for consistency purposes) |
204
|
|
|
* |
205
|
|
|
* @param bool $noStore |
206
|
|
|
* |
207
|
|
|
* @return $this |
208
|
|
|
*/ |
209
|
|
|
public function setNoStore($noStore = true) |
210
|
|
|
{ |
211
|
|
|
if ($noStore) { |
212
|
|
|
$this->setDirective('no-store'); |
213
|
|
|
$this->removeDirective('max-age'); |
214
|
|
|
$this->removeDirective('s-maxage'); |
215
|
|
|
} else { |
216
|
|
|
$this->removeDirective('no-store'); |
217
|
|
|
} |
218
|
|
|
return $this; |
219
|
|
|
} |
220
|
|
|
|
221
|
|
|
/** |
222
|
|
|
* Forces caches to submit the request to the origin server for validation before releasing a cached copy. |
223
|
|
|
* |
224
|
|
|
* @param bool $noCache |
225
|
|
|
* |
226
|
|
|
* @return $this |
227
|
|
|
*/ |
228
|
|
|
public function setNoCache($noCache = true) |
229
|
|
|
{ |
230
|
|
|
if ($noCache) { |
231
|
|
|
$this->setDirective('no-cache'); |
232
|
|
|
} else { |
233
|
|
|
$this->removeDirective('no-cache'); |
234
|
|
|
} |
235
|
|
|
return $this; |
236
|
|
|
} |
237
|
|
|
|
238
|
|
|
/** |
239
|
|
|
* Specifies the maximum amount of time (seconds) a resource will be considered fresh. |
240
|
|
|
* This directive is relative to the time of the request. |
241
|
|
|
* |
242
|
|
|
* @param int $age |
243
|
|
|
* |
244
|
|
|
* @return $this |
245
|
|
|
*/ |
246
|
|
|
public function setMaxAge($age) |
247
|
|
|
{ |
248
|
|
|
$this->setDirective('max-age', $age); |
249
|
|
|
return $this; |
250
|
|
|
} |
251
|
|
|
|
252
|
|
|
/** |
253
|
|
|
* Overrides max-age or the Expires header, but it only applies to shared caches (e.g., proxies) |
254
|
|
|
* and is ignored by a private cache. |
255
|
|
|
* |
256
|
|
|
* @param int $age |
257
|
|
|
* |
258
|
|
|
* @return $this |
259
|
|
|
*/ |
260
|
|
|
public function setSharedMaxAge($age) |
261
|
|
|
{ |
262
|
|
|
$this->setDirective('s-maxage', $age); |
263
|
|
|
return $this; |
264
|
|
|
} |
265
|
|
|
|
266
|
|
|
/** |
267
|
|
|
* The cache must verify the status of the stale resources before using it and expired ones should not be used. |
268
|
|
|
* |
269
|
|
|
* @param bool $mustRevalidate |
270
|
|
|
* |
271
|
|
|
* @return $this |
272
|
|
|
*/ |
273
|
|
|
public function setMustRevalidate($mustRevalidate = true) |
274
|
|
|
{ |
275
|
|
|
if ($mustRevalidate) { |
276
|
|
|
$this->setDirective('must-revalidate'); |
277
|
|
|
} else { |
278
|
|
|
$this->removeDirective('must-revalidate'); |
279
|
|
|
} |
280
|
|
|
return $this; |
281
|
|
|
} |
282
|
|
|
|
283
|
|
|
/** |
284
|
|
|
* Simple way to set cache control header to a cacheable state. |
285
|
|
|
* Use this method over `publicCache()` if you are unsure about caching details. |
286
|
|
|
* |
287
|
|
|
* Removes `no-store` and `no-cache` directives; other directives will remain in place. |
288
|
|
|
* Use alongside `setMaxAge()` to indicate caching. |
289
|
|
|
* |
290
|
|
|
* Does not set `public` directive. Usually, `setMaxAge()` is sufficient. Use `publicCache()` if this is explicitly required. |
291
|
|
|
* See https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching#public_vs_private |
292
|
|
|
* |
293
|
|
|
* @see https://docs.silverstripe.org/en/developer_guides/performance/http_cache_headers/ |
294
|
|
|
* @param bool $force Force the cache to public even if its unforced private or public |
295
|
|
|
* @return $this |
296
|
|
|
*/ |
297
|
|
|
public function enableCache($force = false) |
298
|
|
|
{ |
299
|
|
|
// Only execute this if its forcing level is high enough |
300
|
|
|
if (!$this->applyChangeLevel(self::LEVEL_ENABLED, $force)) { |
301
|
|
|
SS_Log::log("Call to enableCache($force) didn't execute as it's lower priority than a previous call", SS_Log::DEBUG); |
302
|
|
|
return $this; |
303
|
|
|
} |
304
|
|
|
|
305
|
|
|
$this->removeDirective('no-store'); |
306
|
|
|
$this->removeDirective('no-cache'); |
307
|
|
|
return $this; |
308
|
|
|
} |
309
|
|
|
|
310
|
|
|
/** |
311
|
|
|
* Simple way to set cache control header to a non-cacheable state. |
312
|
|
|
* Use this method over `privateCache()` if you are unsure about caching details. |
313
|
|
|
* Takes precendence over unforced `enableCache()`, `privateCache()` or `publicCache()` calls. |
314
|
|
|
* |
315
|
|
|
* Removes all state and replaces it with `no-cache, no-store, must-revalidate`. Although `no-store` is sufficient |
316
|
|
|
* the others are added under recommendation from Mozilla (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#Examples) |
317
|
|
|
* |
318
|
|
|
* Does not set `private` directive, use `privateCache()` if this is explicitly required. |
319
|
|
|
* See https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching#public_vs_private |
320
|
|
|
* |
321
|
|
|
* @see https://docs.silverstripe.org/en/developer_guides/performance/http_cache_headers/ |
322
|
|
|
* @param bool $force Force the cache to diabled even if it's forced private or public |
323
|
|
|
* @return $this |
324
|
|
|
*/ |
325
|
|
|
public function disableCache($force = false) |
326
|
|
|
{ |
327
|
|
|
// Only execute this if its forcing level is high enough |
328
|
|
|
if (!$this->applyChangeLevel(self::LEVEL_DISABLED, $force )) { |
329
|
|
|
SS_Log::log("Call to disableCache($force) didn't execute as it's lower priority than a previous call", SS_Log::DEBUG); |
330
|
|
|
return $this; |
331
|
|
|
} |
332
|
|
|
|
333
|
|
|
$this->state = array( |
334
|
|
|
'no-cache' => null, |
335
|
|
|
'no-store' => null, |
336
|
|
|
'must-revalidate' => null, |
337
|
|
|
); |
338
|
|
|
return $this; |
339
|
|
|
} |
340
|
|
|
|
341
|
|
|
/** |
342
|
|
|
* Advanced way to set cache control header to a non-cacheable state. |
343
|
|
|
* Indicates that the response is intended for a single user and must not be stored by a shared cache. |
344
|
|
|
* A private cache (e.g. Web Browser) may store the response. Also removes `public` as this is a contradictory directive. |
345
|
|
|
* |
346
|
|
|
* @see https://docs.silverstripe.org/en/developer_guides/performance/http_cache_headers/ |
347
|
|
|
* @param bool $force Force the cache to private even if it's forced public |
348
|
|
|
* @return $this |
349
|
|
|
*/ |
350
|
|
|
public function privateCache($force = false) |
351
|
|
|
{ |
352
|
|
|
// Only execute this if its forcing level is high enough |
353
|
|
|
if (!$this->applyChangeLevel(self::LEVEL_PRIVATE, $force)) { |
354
|
|
|
SS_Log::log("Call to privateCache($force) didn't execute as it's lower priority than a previous call", SS_Log::DEBUG); |
355
|
|
|
return $this; |
356
|
|
|
} |
357
|
|
|
|
358
|
|
|
// Update the directives |
359
|
|
|
$this->setDirective('private'); |
360
|
|
|
$this->removeDirective('public'); |
361
|
|
|
$this->removeDirective('no-cache'); |
362
|
|
|
$this->removeDirective('no-store'); |
363
|
|
|
return $this; |
364
|
|
|
} |
365
|
|
|
|
366
|
|
|
/** |
367
|
|
|
* Advanced way to set cache control header to a cacheable state. |
368
|
|
|
* Indicates that the response may be cached by any cache. (eg: CDNs, Proxies, Web browsers) |
369
|
|
|
* Also removes `private` as this is a contradictory directive |
370
|
|
|
* |
371
|
|
|
* @see https://docs.silverstripe.org/en/developer_guides/performance/http_cache_headers/ |
372
|
|
|
* @param bool $force Force the cache to public even if it's private, unless it's been forced private |
373
|
|
|
* @return $this |
374
|
|
|
*/ |
375
|
|
|
public function publicCache($force = false) |
376
|
|
|
{ |
377
|
|
|
// Only execute this if its forcing level is high enough |
378
|
|
|
if (!$this->applyChangeLevel(self::LEVEL_PUBLIC, $force)) { |
379
|
|
|
SS_Log::log("Call to publicCache($force) didn't execute as it's lower priority than a previous call", SS_Log::DEBUG); |
380
|
|
|
return $this; |
381
|
|
|
} |
382
|
|
|
|
383
|
|
|
$this->setDirective('public'); |
384
|
|
|
$this->removeDirective('private'); |
385
|
|
|
$this->removeDirective('no-cache'); |
386
|
|
|
$this->removeDirective('no-store'); |
387
|
|
|
return $this; |
388
|
|
|
} |
389
|
|
|
|
390
|
|
|
/** |
391
|
|
|
* Generate and add the `Cache-Control` header to a response object |
392
|
|
|
* |
393
|
|
|
* @param SS_HTTPResponse $response |
394
|
|
|
* |
395
|
|
|
* @return $this |
396
|
|
|
*/ |
397
|
|
|
public function applyToResponse($response) |
398
|
|
|
{ |
399
|
|
|
$headers = $this->generateHeaders(); |
400
|
|
|
foreach ($headers as $name => $value) { |
401
|
|
|
$response->addHeader($name, $value); |
402
|
|
|
} |
403
|
|
|
return $this; |
404
|
|
|
} |
405
|
|
|
|
406
|
|
|
/** |
407
|
|
|
* Generate the cache header |
408
|
|
|
* |
409
|
|
|
* @return string |
410
|
|
|
*/ |
411
|
|
|
protected function generateCacheHeader() |
412
|
|
|
{ |
413
|
|
|
$cacheControl = array(); |
414
|
|
|
foreach ($this->state as $directive => $value) { |
415
|
|
|
if (is_null($value)) { |
416
|
|
|
$cacheControl[] = $directive; |
417
|
|
|
} else { |
418
|
|
|
$cacheControl[] = $directive . '=' . $value; |
419
|
|
|
} |
420
|
|
|
} |
421
|
|
|
return implode(', ', $cacheControl); |
422
|
|
|
} |
423
|
|
|
|
424
|
|
|
/** |
425
|
|
|
* Generate all headers to output |
426
|
|
|
* |
427
|
|
|
* @return array |
428
|
|
|
*/ |
429
|
|
|
public function generateHeaders() |
430
|
|
|
{ |
431
|
|
|
return array( |
432
|
|
|
'Cache-Control' => $this->generateCacheHeader(), |
433
|
|
|
); |
434
|
|
|
} |
435
|
|
|
|
436
|
|
|
/** |
437
|
|
|
* Reset registered http cache control and force a fresh instance to be built |
438
|
|
|
*/ |
439
|
|
|
public static function reset() { |
440
|
|
|
Injector::inst()->unregisterNamedObject(__CLASS__); |
441
|
|
|
} |
442
|
|
|
} |
443
|
|
|
|
This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.
Consider making the comparison explicit by using
empty(..)
or! empty(...)
instead.