5
5
*
6
6
* (c) Fabien Potencier <fabien@symfony.com>
7
7
*
8
- * This code is partially based on the Rack-Cache library by Ryan Tomayko,
9
- * which is released under the MIT license.
10
- * (based on commit 02d2b48d75bcb63cf1c0c7149c077ad256542801)
11
- *
12
8
* For the full copyright and license information, please view the LICENSE
13
9
* file that was distributed with this source code.
14
10
*/
28
24
*/
29
25
class ResponseCacheStrategy implements ResponseCacheStrategyInterface
30
26
{
31
- private $ cacheable = true ;
27
+ /**
28
+ * Cache-Control headers that are sent to the final response if they appear in ANY of the responses.
29
+ */
30
+ private static $ overrideDirectives = ['private ' , 'no-cache ' , 'no-store ' , 'no-transform ' , 'must-revalidate ' , 'proxy-revalidate ' ];
31
+
32
+ /**
33
+ * Cache-Control headers that are sent to the final response if they appear in ALL of the responses.
34
+ */
35
+ private static $ inheritDirectives = ['public ' , 'immutable ' ];
36
+
32
37
private $ embeddedResponses = 0 ;
33
- private $ ttls = [];
34
- private $ maxAges = [];
35
38
private $ isNotCacheableResponseEmbedded = false ;
39
+ private $ age = 0 ;
40
+ private $ flagDirectives = [
41
+ 'no-cache ' => null ,
42
+ 'no-store ' => null ,
43
+ 'no-transform ' => null ,
44
+ 'must-revalidate ' => null ,
45
+ 'proxy-revalidate ' => null ,
46
+ 'public ' => null ,
47
+ 'private ' => null ,
48
+ 'immutable ' => null ,
49
+ ];
50
+ private $ ageDirectives = [
51
+ 'max-age ' => null ,
52
+ 's-maxage ' => null ,
53
+ 'expires ' => null ,
54
+ ];
36
55
37
56
/**
38
57
* {@inheritdoc}
39
58
*/
40
59
public function add (Response $ response )
41
60
{
42
- if (!$ response ->isFresh () || !$ response ->isCacheable ()) {
43
- $ this ->cacheable = false ;
44
- } else {
45
- $ maxAge = $ response ->getMaxAge ();
46
- $ this ->ttls [] = $ response ->getTtl ();
47
- $ this ->maxAges [] = $ maxAge ;
48
-
49
- if (null === $ maxAge ) {
50
- $ this ->isNotCacheableResponseEmbedded = true ;
61
+ ++$ this ->embeddedResponses ;
62
+
63
+ foreach (self ::$ overrideDirectives as $ directive ) {
64
+ if ($ response ->headers ->hasCacheControlDirective ($ directive )) {
65
+ $ this ->flagDirectives [$ directive ] = true ;
51
66
}
52
67
}
53
68
54
- ++$ this ->embeddedResponses ;
69
+ foreach (self ::$ inheritDirectives as $ directive ) {
70
+ if (false !== $ this ->flagDirectives [$ directive ]) {
71
+ $ this ->flagDirectives [$ directive ] = $ response ->headers ->hasCacheControlDirective ($ directive );
72
+ }
73
+ }
74
+
75
+ $ age = $ response ->getAge ();
76
+ $ this ->age = max ($ this ->age , $ age );
77
+
78
+ if ($ this ->willMakeFinalResponseUncacheable ($ response )) {
79
+ $ this ->isNotCacheableResponseEmbedded = true ;
80
+
81
+ return ;
82
+ }
83
+
84
+ $ this ->storeRelativeAgeDirective ('max-age ' , $ response ->headers ->getCacheControlDirective ('max-age ' ), $ age );
85
+ $ this ->storeRelativeAgeDirective ('s-maxage ' , $ response ->headers ->getCacheControlDirective ('s-maxage ' ) ?: $ response ->headers ->getCacheControlDirective ('max-age ' ), $ age );
86
+
87
+ $ expires = $ response ->getExpires ();
88
+ $ expires = null !== $ expires ? $ expires ->format ('U ' ) - $ response ->getDate ()->format ('U ' ) : null ;
89
+ $ this ->storeRelativeAgeDirective ('expires ' , $ expires >= 0 ? $ expires : null , 0 );
55
90
}
56
91
57
92
/**
@@ -64,33 +99,124 @@ public function update(Response $response)
64
99
return ;
65
100
}
66
101
67
- // Remove validation related headers in order to avoid browsers using
68
- // their own cache, because some of the response content comes from
69
- // at least one embedded response (which likely has a different caching strategy).
70
- if ($ response ->isValidateable ()) {
71
- $ response ->setEtag (null );
72
- $ response ->setLastModified (null );
102
+ // Remove validation related headers of the master response,
103
+ // because some of the response content comes from at least
104
+ // one embedded response (which likely has a different caching strategy).
105
+ $ response ->setEtag (null );
106
+ $ response ->setLastModified (null );
107
+
108
+ $ this ->add ($ response );
109
+
110
+ $ response ->headers ->set ('Age ' , $ this ->age );
111
+
112
+ if ($ this ->isNotCacheableResponseEmbedded ) {
113
+ $ response ->setExpires ($ response ->getDate ());
114
+
115
+ if ($ this ->flagDirectives ['no-store ' ]) {
116
+ $ response ->headers ->set ('Cache-Control ' , 'no-cache, no-store, must-revalidate ' );
117
+ } else {
118
+ $ response ->headers ->set ('Cache-Control ' , 'no-cache, must-revalidate ' );
119
+ }
120
+
121
+ return ;
122
+ }
123
+
124
+ $ flags = array_filter ($ this ->flagDirectives );
125
+
126
+ if (isset ($ flags ['must-revalidate ' ])) {
127
+ $ flags ['no-cache ' ] = true ;
73
128
}
74
129
75
- if (!$ response ->isFresh () || !$ response ->isCacheable ()) {
76
- $ this ->cacheable = false ;
130
+ $ response ->headers ->set ('Cache-Control ' , implode (', ' , array_keys ($ flags )));
131
+
132
+ $ maxAge = null ;
133
+ $ sMaxage = null ;
134
+
135
+ if (\is_numeric ($ this ->ageDirectives ['max-age ' ])) {
136
+ $ maxAge = $ this ->ageDirectives ['max-age ' ] + $ this ->age ;
137
+ $ response ->headers ->addCacheControlDirective ('max-age ' , $ maxAge );
77
138
}
78
139
79
- if (! $ this ->cacheable ) {
80
- $ response -> headers -> set ( ' Cache-Control ' , ' no-cache, must-revalidate ' ) ;
140
+ if (\is_numeric ( $ this ->ageDirectives [ ' s-maxage ' ]) ) {
141
+ $ sMaxage = $ this -> ageDirectives [ ' s-maxage ' ] + $ this -> age ;
81
142
82
- return ;
143
+ if ($ maxAge !== $ sMaxage ) {
144
+ $ response ->headers ->addCacheControlDirective ('s-maxage ' , $ sMaxage );
145
+ }
146
+ }
147
+
148
+ if (\is_numeric ($ this ->ageDirectives ['expires ' ])) {
149
+ $ date = clone $ response ->getDate ();
150
+ $ date ->modify ('+ ' .($ this ->ageDirectives ['expires ' ] + $ this ->age ).' seconds ' );
151
+ $ response ->setExpires ($ date );
83
152
}
153
+ }
84
154
85
- $ this ->ttls [] = $ response ->getTtl ();
86
- $ this ->maxAges [] = $ response ->getMaxAge ();
155
+ /**
156
+ * RFC2616, Section 13.4.
157
+ *
158
+ * @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.4
159
+ *
160
+ * @return bool
161
+ */
162
+ private function willMakeFinalResponseUncacheable (Response $ response )
163
+ {
164
+ // RFC2616: A response received with a status code of 200, 203, 300, 301 or 410
165
+ // MAY be stored by a cache […] unless a cache-control directive prohibits caching.
166
+ if ($ response ->headers ->hasCacheControlDirective ('no-cache ' )
167
+ || $ response ->headers ->getCacheControlDirective ('no-store ' )
168
+ ) {
169
+ return true ;
170
+ }
87
171
88
- if ($ this ->isNotCacheableResponseEmbedded ) {
89
- $ response ->headers ->removeCacheControlDirective ('s-maxage ' );
90
- } elseif (null !== $ maxAge = min ($ this ->maxAges )) {
91
- $ response ->setSharedMaxAge ($ maxAge );
92
- $ response ->headers ->set ('Age ' , $ maxAge - min ($ this ->ttls ));
172
+ // Last-Modified and Etag headers cannot be merged, they render the response uncacheable
173
+ // by default (except if the response also has max-age etc.).
174
+ if (\in_array ($ response ->getStatusCode (), [200 , 203 , 300 , 301 , 410 ])
175
+ && null === $ response ->getLastModified ()
176
+ && null === $ response ->getEtag ()
177
+ ) {
178
+ return false ;
179
+ }
180
+
181
+ // RFC2616: A response received with any other status code (e.g. status codes 302 and 307)
182
+ // MUST NOT be returned in a reply to a subsequent request unless there are
183
+ // cache-control directives or another header(s) that explicitly allow it.
184
+ $ cacheControl = ['max-age ' , 's-maxage ' , 'must-revalidate ' , 'proxy-revalidate ' , 'public ' , 'private ' ];
185
+ foreach ($ cacheControl as $ key ) {
186
+ if ($ response ->headers ->hasCacheControlDirective ($ key )) {
187
+ return false ;
188
+ }
189
+ }
190
+
191
+ if ($ response ->headers ->has ('Expires ' )) {
192
+ return false ;
193
+ }
194
+
195
+ return true ;
196
+ }
197
+
198
+ /**
199
+ * Store lowest max-age/s-maxage/expires for the final response.
200
+ *
201
+ * The response might have been stored in cache a while ago. To keep things comparable,
202
+ * we have to subtract the age so that the value is normalized for an age of 0.
203
+ *
204
+ * If the value is lower than the currently stored value, we update the value, to keep a rolling
205
+ * minimal value of each instruction. If the value is NULL, the directive will not be set on the final response.
206
+ *
207
+ * @param string $directive
208
+ * @param int|null $value
209
+ * @param int $age
210
+ */
211
+ private function storeRelativeAgeDirective ($ directive , $ value , $ age )
212
+ {
213
+ if (null === $ value ) {
214
+ $ this ->ageDirectives [$ directive ] = false ;
215
+ }
216
+
217
+ if (false !== $ this ->ageDirectives [$ directive ]) {
218
+ $ value = $ value - $ age ;
219
+ $ this ->ageDirectives [$ directive ] = null !== $ this ->ageDirectives [$ directive ] ? min ($ this ->ageDirectives [$ directive ], $ value ) : $ value ;
93
220
}
94
- $ response ->setMaxAge (0 );
95
221
}
96
222
}
0 commit comments