Store.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  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. * This method purges both the HTTP and the HTTPS version of the cache entry.
  272. *
  273. * @param string $url A URL
  274. *
  275. * @return bool true if the URL exists with either HTTP or HTTPS scheme and has been purged, false otherwise
  276. */
  277. public function purge($url)
  278. {
  279. $http = preg_replace('#^https:#', 'http:', $url);
  280. $https = preg_replace('#^http:#', 'https:', $url);
  281. $purgedHttp = $this->doPurge($http);
  282. $purgedHttps = $this->doPurge($https);
  283. return $purgedHttp || $purgedHttps;
  284. }
  285. /**
  286. * Purges data for the given URL.
  287. *
  288. * @param string $url A URL
  289. *
  290. * @return bool true if the URL exists and has been purged, false otherwise
  291. */
  292. private function doPurge($url)
  293. {
  294. $key = $this->getCacheKey(Request::create($url));
  295. if (isset($this->locks[$key])) {
  296. flock($this->locks[$key], LOCK_UN);
  297. fclose($this->locks[$key]);
  298. unset($this->locks[$key]);
  299. }
  300. if (file_exists($path = $this->getPath($key))) {
  301. unlink($path);
  302. return true;
  303. }
  304. return false;
  305. }
  306. /**
  307. * Loads data for the given key.
  308. *
  309. * @param string $key The store key
  310. *
  311. * @return string The data associated with the key
  312. */
  313. private function load($key)
  314. {
  315. $path = $this->getPath($key);
  316. return file_exists($path) ? file_get_contents($path) : false;
  317. }
  318. /**
  319. * Save data for the given key.
  320. *
  321. * @param string $key The store key
  322. * @param string $data The data to store
  323. *
  324. * @return bool
  325. */
  326. private function save($key, $data)
  327. {
  328. $path = $this->getPath($key);
  329. if (isset($this->locks[$key])) {
  330. $fp = $this->locks[$key];
  331. @ftruncate($fp, 0);
  332. @fseek($fp, 0);
  333. $len = @fwrite($fp, $data);
  334. if (strlen($data) !== $len) {
  335. @ftruncate($fp, 0);
  336. return false;
  337. }
  338. } else {
  339. if (!file_exists(dirname($path)) && false === @mkdir(dirname($path), 0777, true) && !is_dir(dirname($path))) {
  340. return false;
  341. }
  342. $tmpFile = tempnam(dirname($path), basename($path));
  343. if (false === $fp = @fopen($tmpFile, 'wb')) {
  344. return false;
  345. }
  346. @fwrite($fp, $data);
  347. @fclose($fp);
  348. if ($data != file_get_contents($tmpFile)) {
  349. return false;
  350. }
  351. if (false === @rename($tmpFile, $path)) {
  352. return false;
  353. }
  354. }
  355. @chmod($path, 0666 & ~umask());
  356. }
  357. public function getPath($key)
  358. {
  359. 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);
  360. }
  361. /**
  362. * Generates a cache key for the given Request.
  363. *
  364. * This method should return a key that must only depend on a
  365. * normalized version of the request URI.
  366. *
  367. * If the same URI can have more than one representation, based on some
  368. * headers, use a Vary header to indicate them, and each representation will
  369. * be stored independently under the same cache key.
  370. *
  371. * @param Request $request A Request instance
  372. *
  373. * @return string A key for the given Request
  374. */
  375. protected function generateCacheKey(Request $request)
  376. {
  377. return 'md'.hash('sha256', $request->getUri());
  378. }
  379. /**
  380. * Returns a cache key for the given Request.
  381. *
  382. * @param Request $request A Request instance
  383. *
  384. * @return string A key for the given Request
  385. */
  386. private function getCacheKey(Request $request)
  387. {
  388. if (isset($this->keyCache[$request])) {
  389. return $this->keyCache[$request];
  390. }
  391. return $this->keyCache[$request] = $this->generateCacheKey($request);
  392. }
  393. /**
  394. * Persists the Request HTTP headers.
  395. *
  396. * @param Request $request A Request instance
  397. *
  398. * @return array An array of HTTP headers
  399. */
  400. private function persistRequest(Request $request)
  401. {
  402. return $request->headers->all();
  403. }
  404. /**
  405. * Persists the Response HTTP headers.
  406. *
  407. * @param Response $response A Response instance
  408. *
  409. * @return array An array of HTTP headers
  410. */
  411. private function persistResponse(Response $response)
  412. {
  413. $headers = $response->headers->all();
  414. $headers['X-Status'] = array($response->getStatusCode());
  415. return $headers;
  416. }
  417. /**
  418. * Restores a Response from the HTTP headers and body.
  419. *
  420. * @param array $headers An array of HTTP headers for the Response
  421. * @param string $body The Response body
  422. *
  423. * @return Response
  424. */
  425. private function restoreResponse($headers, $body = null)
  426. {
  427. $status = $headers['X-Status'][0];
  428. unset($headers['X-Status']);
  429. if (null !== $body) {
  430. $headers['X-Body-File'] = array($body);
  431. }
  432. return new Response($body, $status, $headers);
  433. }
  434. }