|
1 <?php |
|
2 |
|
3 /* |
|
4 * This file is part of the Symfony package. |
|
5 * |
|
6 * (c) Fabien Potencier <fabien@symfony.com> |
|
7 * |
|
8 * This code is partially based on the Rack-Cache library by Ryan Tomayko, |
|
9 * which is released under the MIT license. |
|
10 * |
|
11 * For the full copyright and license information, please view the LICENSE |
|
12 * file that was distributed with this source code. |
|
13 */ |
|
14 |
|
15 namespace Symfony\Component\HttpKernel\HttpCache; |
|
16 |
|
17 use Symfony\Component\HttpFoundation\Request; |
|
18 use Symfony\Component\HttpFoundation\Response; |
|
19 use Symfony\Component\HttpFoundation\HeaderBag; |
|
20 |
|
21 /** |
|
22 * Store implements all the logic for storing cache metadata (Request and Response headers). |
|
23 * |
|
24 * @author Fabien Potencier <fabien@symfony.com> |
|
25 */ |
|
26 class Store implements StoreInterface |
|
27 { |
|
28 private $root; |
|
29 private $keyCache; |
|
30 private $locks; |
|
31 |
|
32 /** |
|
33 * Constructor. |
|
34 * |
|
35 * @param string $root The path to the cache directory |
|
36 */ |
|
37 public function __construct($root) |
|
38 { |
|
39 $this->root = $root; |
|
40 if (!is_dir($this->root)) { |
|
41 mkdir($this->root, 0777, true); |
|
42 } |
|
43 $this->keyCache = new \SplObjectStorage(); |
|
44 $this->locks = array(); |
|
45 } |
|
46 |
|
47 /** |
|
48 * Cleanups storage. |
|
49 */ |
|
50 public function cleanup() |
|
51 { |
|
52 // unlock everything |
|
53 foreach ($this->locks as $lock) { |
|
54 @unlink($lock); |
|
55 } |
|
56 |
|
57 $error = error_get_last(); |
|
58 if (1 === $error['type'] && false === headers_sent()) { |
|
59 // send a 503 |
|
60 header('HTTP/1.0 503 Service Unavailable'); |
|
61 header('Retry-After: 10'); |
|
62 echo '503 Service Unavailable'; |
|
63 } |
|
64 } |
|
65 |
|
66 /** |
|
67 * Locks the cache for a given Request. |
|
68 * |
|
69 * @param Request $request A Request instance |
|
70 * |
|
71 * @return Boolean|string true if the lock is acquired, the path to the current lock otherwise |
|
72 */ |
|
73 public function lock(Request $request) |
|
74 { |
|
75 if (false !== $lock = @fopen($path = $this->getPath($this->getCacheKey($request).'.lck'), 'x')) { |
|
76 fclose($lock); |
|
77 |
|
78 $this->locks[] = $path; |
|
79 |
|
80 return true; |
|
81 } |
|
82 |
|
83 return $path; |
|
84 } |
|
85 |
|
86 /** |
|
87 * Releases the lock for the given Request. |
|
88 * |
|
89 * @param Request $request A Request instance |
|
90 */ |
|
91 public function unlock(Request $request) |
|
92 { |
|
93 return @unlink($this->getPath($this->getCacheKey($request).'.lck')); |
|
94 } |
|
95 |
|
96 /** |
|
97 * Locates a cached Response for the Request provided. |
|
98 * |
|
99 * @param Request $request A Request instance |
|
100 * |
|
101 * @return Response|null A Response instance, or null if no cache entry was found |
|
102 */ |
|
103 public function lookup(Request $request) |
|
104 { |
|
105 $key = $this->getCacheKey($request); |
|
106 |
|
107 if (!$entries = $this->getMetadata($key)) { |
|
108 return null; |
|
109 } |
|
110 |
|
111 // find a cached entry that matches the request. |
|
112 $match = null; |
|
113 foreach ($entries as $entry) { |
|
114 if ($this->requestsMatch(isset($entry[1]['vary']) ? $entry[1]['vary'][0] : '', $request->headers->all(), $entry[0])) { |
|
115 $match = $entry; |
|
116 |
|
117 break; |
|
118 } |
|
119 } |
|
120 |
|
121 if (null === $match) { |
|
122 return null; |
|
123 } |
|
124 |
|
125 list($req, $headers) = $match; |
|
126 if (file_exists($body = $this->getPath($headers['x-content-digest'][0]))) { |
|
127 return $this->restoreResponse($headers, $body); |
|
128 } |
|
129 |
|
130 // TODO the metaStore referenced an entity that doesn't exist in |
|
131 // the entityStore. We definitely want to return nil but we should |
|
132 // also purge the entry from the meta-store when this is detected. |
|
133 return null; |
|
134 } |
|
135 |
|
136 /** |
|
137 * Writes a cache entry to the store for the given Request and Response. |
|
138 * |
|
139 * Existing entries are read and any that match the response are removed. This |
|
140 * method calls write with the new list of cache entries. |
|
141 * |
|
142 * @param Request $request A Request instance |
|
143 * @param Response $response A Response instance |
|
144 * |
|
145 * @return string The key under which the response is stored |
|
146 */ |
|
147 public function write(Request $request, Response $response) |
|
148 { |
|
149 $key = $this->getCacheKey($request); |
|
150 $storedEnv = $this->persistRequest($request); |
|
151 |
|
152 // write the response body to the entity store if this is the original response |
|
153 if (!$response->headers->has('X-Content-Digest')) { |
|
154 $digest = 'en'.sha1($response->getContent()); |
|
155 |
|
156 if (false === $this->save($digest, $response->getContent())) { |
|
157 throw new \RuntimeException('Unable to store the entity.'); |
|
158 } |
|
159 |
|
160 $response->headers->set('X-Content-Digest', $digest); |
|
161 |
|
162 if (!$response->headers->has('Transfer-Encoding')) { |
|
163 $response->headers->set('Content-Length', strlen($response->getContent())); |
|
164 } |
|
165 } |
|
166 |
|
167 // read existing cache entries, remove non-varying, and add this one to the list |
|
168 $entries = array(); |
|
169 $vary = $response->headers->get('vary'); |
|
170 foreach ($this->getMetadata($key) as $entry) { |
|
171 if (!isset($entry[1]['vary'])) { |
|
172 $entry[1]['vary'] = array(''); |
|
173 } |
|
174 |
|
175 if ($vary != $entry[1]['vary'][0] || !$this->requestsMatch($vary, $entry[0], $storedEnv)) { |
|
176 $entries[] = $entry; |
|
177 } |
|
178 } |
|
179 |
|
180 $headers = $this->persistResponse($response); |
|
181 unset($headers['age']); |
|
182 |
|
183 array_unshift($entries, array($storedEnv, $headers)); |
|
184 |
|
185 if (false === $this->save($key, serialize($entries))) { |
|
186 throw new \RuntimeException('Unable to store the metadata.'); |
|
187 } |
|
188 |
|
189 return $key; |
|
190 } |
|
191 |
|
192 /** |
|
193 * Invalidates all cache entries that match the request. |
|
194 * |
|
195 * @param Request $request A Request instance |
|
196 */ |
|
197 public function invalidate(Request $request) |
|
198 { |
|
199 $modified = false; |
|
200 $key = $this->getCacheKey($request); |
|
201 |
|
202 $entries = array(); |
|
203 foreach ($this->getMetadata($key) as $entry) { |
|
204 $response = $this->restoreResponse($entry[1]); |
|
205 if ($response->isFresh()) { |
|
206 $response->expire(); |
|
207 $modified = true; |
|
208 $entries[] = array($entry[0], $this->persistResponse($response)); |
|
209 } else { |
|
210 $entries[] = $entry; |
|
211 } |
|
212 } |
|
213 |
|
214 if ($modified) { |
|
215 if (false === $this->save($key, serialize($entries))) { |
|
216 throw new \RuntimeException('Unable to store the metadata.'); |
|
217 } |
|
218 } |
|
219 |
|
220 // As per the RFC, invalidate Location and Content-Location URLs if present |
|
221 foreach (array('Location', 'Content-Location') as $header) { |
|
222 if ($uri = $request->headers->get($header)) { |
|
223 $subRequest = Request::create($uri, 'get', array(), array(), array(), $request->server->all()); |
|
224 |
|
225 $this->invalidate($subRequest); |
|
226 } |
|
227 } |
|
228 } |
|
229 |
|
230 /** |
|
231 * Determines whether two Request HTTP header sets are non-varying based on |
|
232 * the vary response header value provided. |
|
233 * |
|
234 * @param string $vary A Response vary header |
|
235 * @param array $env1 A Request HTTP header array |
|
236 * @param array $env2 A Request HTTP header array |
|
237 * |
|
238 * @return Boolean true if the the two environments match, false otherwise |
|
239 */ |
|
240 private function requestsMatch($vary, $env1, $env2) |
|
241 { |
|
242 if (empty($vary)) { |
|
243 return true; |
|
244 } |
|
245 |
|
246 foreach (preg_split('/[\s,]+/', $vary) as $header) { |
|
247 $key = strtr(strtolower($header), '_', '-'); |
|
248 $v1 = isset($env1[$key]) ? $env1[$key] : null; |
|
249 $v2 = isset($env2[$key]) ? $env2[$key] : null; |
|
250 if ($v1 !== $v2) { |
|
251 return false; |
|
252 } |
|
253 } |
|
254 |
|
255 return true; |
|
256 } |
|
257 |
|
258 /** |
|
259 * Gets all data associated with the given key. |
|
260 * |
|
261 * Use this method only if you know what you are doing. |
|
262 * |
|
263 * @param string $key The store key |
|
264 * |
|
265 * @return array An array of data associated with the key |
|
266 */ |
|
267 private function getMetadata($key) |
|
268 { |
|
269 if (false === $entries = $this->load($key)) { |
|
270 return array(); |
|
271 } |
|
272 |
|
273 return unserialize($entries); |
|
274 } |
|
275 |
|
276 /** |
|
277 * Purges data for the given URL. |
|
278 * |
|
279 * @param string $url A URL |
|
280 * |
|
281 * @return Boolean true if the URL exists and has been purged, false otherwise |
|
282 */ |
|
283 public function purge($url) |
|
284 { |
|
285 if (file_exists($path = $this->getPath($this->getCacheKey(Request::create($url))))) { |
|
286 unlink($path); |
|
287 |
|
288 return true; |
|
289 } |
|
290 |
|
291 return false; |
|
292 } |
|
293 |
|
294 /** |
|
295 * Loads data for the given key. |
|
296 * |
|
297 * @param string $key The store key |
|
298 * |
|
299 * @return string The data associated with the key |
|
300 */ |
|
301 private function load($key) |
|
302 { |
|
303 $path = $this->getPath($key); |
|
304 |
|
305 return file_exists($path) ? file_get_contents($path) : false; |
|
306 } |
|
307 |
|
308 /** |
|
309 * Save data for the given key. |
|
310 * |
|
311 * @param string $key The store key |
|
312 * @param string $data The data to store |
|
313 */ |
|
314 private function save($key, $data) |
|
315 { |
|
316 $path = $this->getPath($key); |
|
317 if (!is_dir(dirname($path)) && false === @mkdir(dirname($path), 0777, true)) { |
|
318 return false; |
|
319 } |
|
320 |
|
321 $tmpFile = tempnam(dirname($path), basename($path)); |
|
322 if (false === $fp = @fopen($tmpFile, 'wb')) { |
|
323 return false; |
|
324 } |
|
325 @fwrite($fp, $data); |
|
326 @fclose($fp); |
|
327 |
|
328 if ($data != file_get_contents($tmpFile)) { |
|
329 return false; |
|
330 } |
|
331 |
|
332 if (false === @rename($tmpFile, $path)) { |
|
333 return false; |
|
334 } |
|
335 |
|
336 chmod($path, 0644); |
|
337 } |
|
338 |
|
339 public function getPath($key) |
|
340 { |
|
341 return $this->root.DIRECTORY_SEPARATOR.substr($key, 0, 2).DIRECTORY_SEPARATOR.substr($key, 2, 2).DIRECTORY_SEPARATOR.substr($key, 4, 2).DIRECTORY_SEPARATOR.substr($key, 6); |
|
342 } |
|
343 |
|
344 /** |
|
345 * Returns a cache key for the given Request. |
|
346 * |
|
347 * @param Request $request A Request instance |
|
348 * |
|
349 * @return string A key for the given Request |
|
350 */ |
|
351 private function getCacheKey(Request $request) |
|
352 { |
|
353 if (isset($this->keyCache[$request])) { |
|
354 return $this->keyCache[$request]; |
|
355 } |
|
356 |
|
357 return $this->keyCache[$request] = 'md'.sha1($request->getUri()); |
|
358 } |
|
359 |
|
360 /** |
|
361 * Persists the Request HTTP headers. |
|
362 * |
|
363 * @param Request $request A Request instance |
|
364 * |
|
365 * @return array An array of HTTP headers |
|
366 */ |
|
367 private function persistRequest(Request $request) |
|
368 { |
|
369 return $request->headers->all(); |
|
370 } |
|
371 |
|
372 /** |
|
373 * Persists the Response HTTP headers. |
|
374 * |
|
375 * @param Response $response A Response instance |
|
376 * |
|
377 * @return array An array of HTTP headers |
|
378 */ |
|
379 private function persistResponse(Response $response) |
|
380 { |
|
381 $headers = $response->headers->all(); |
|
382 $headers['X-Status'] = array($response->getStatusCode()); |
|
383 |
|
384 return $headers; |
|
385 } |
|
386 |
|
387 /** |
|
388 * Restores a Response from the HTTP headers and body. |
|
389 * |
|
390 * @param array $headers An array of HTTP headers for the Response |
|
391 * @param string $body The Response body |
|
392 */ |
|
393 private function restoreResponse($headers, $body = null) |
|
394 { |
|
395 $status = $headers['X-Status'][0]; |
|
396 unset($headers['X-Status']); |
|
397 |
|
398 if (null !== $body) { |
|
399 $headers['X-Body-File'] = array($body); |
|
400 } |
|
401 |
|
402 return new Response($body, $status, $headers); |
|
403 } |
|
404 } |