No Description

Store.php 14KB

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