Cache.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690
  1. <?php
  2. /**
  3. * @package Grav\Common
  4. *
  5. * @copyright Copyright (c) 2015 - 2023 Trilby Media, LLC. All rights reserved.
  6. * @license MIT License; see LICENSE file for details.
  7. */
  8. namespace Grav\Common;
  9. use DirectoryIterator;
  10. use \Doctrine\Common\Cache as DoctrineCache;
  11. use Exception;
  12. use Grav\Common\Config\Config;
  13. use Grav\Common\Filesystem\Folder;
  14. use Grav\Common\Scheduler\Scheduler;
  15. use LogicException;
  16. use Psr\SimpleCache\CacheInterface;
  17. use RocketTheme\Toolbox\Event\Event;
  18. use Symfony\Component\EventDispatcher\EventDispatcher;
  19. use function dirname;
  20. use function extension_loaded;
  21. use function function_exists;
  22. use function in_array;
  23. use function is_array;
  24. /**
  25. * The GravCache object is used throughout Grav to store and retrieve cached data.
  26. * It uses DoctrineCache library and supports a variety of caching mechanisms. Those include:
  27. *
  28. * APCu
  29. * RedisCache
  30. * MemCache
  31. * MemCacheD
  32. * FileSystem
  33. */
  34. class Cache extends Getters
  35. {
  36. /** @var string Cache key. */
  37. protected $key;
  38. /** @var int */
  39. protected $lifetime;
  40. /** @var int */
  41. protected $now;
  42. /** @var Config $config */
  43. protected $config;
  44. /** @var DoctrineCache\CacheProvider */
  45. protected $driver;
  46. /** @var CacheInterface */
  47. protected $simpleCache;
  48. /** @var string */
  49. protected $driver_name;
  50. /** @var string */
  51. protected $driver_setting;
  52. /** @var bool */
  53. protected $enabled;
  54. /** @var string */
  55. protected $cache_dir;
  56. protected static $standard_remove = [
  57. 'cache://twig/',
  58. 'cache://doctrine/',
  59. 'cache://compiled/',
  60. 'cache://clockwork/',
  61. 'cache://validated-',
  62. 'cache://images',
  63. 'asset://',
  64. ];
  65. protected static $standard_remove_no_images = [
  66. 'cache://twig/',
  67. 'cache://doctrine/',
  68. 'cache://compiled/',
  69. 'cache://clockwork/',
  70. 'cache://validated-',
  71. 'asset://',
  72. ];
  73. protected static $all_remove = [
  74. 'cache://',
  75. 'cache://images',
  76. 'asset://',
  77. 'tmp://'
  78. ];
  79. protected static $assets_remove = [
  80. 'asset://'
  81. ];
  82. protected static $images_remove = [
  83. 'cache://images'
  84. ];
  85. protected static $cache_remove = [
  86. 'cache://'
  87. ];
  88. protected static $tmp_remove = [
  89. 'tmp://'
  90. ];
  91. /**
  92. * Constructor
  93. *
  94. * @param Grav $grav
  95. */
  96. public function __construct(Grav $grav)
  97. {
  98. $this->init($grav);
  99. }
  100. /**
  101. * Initialization that sets a base key and the driver based on configuration settings
  102. *
  103. * @param Grav $grav
  104. * @return void
  105. */
  106. public function init(Grav $grav)
  107. {
  108. $this->config = $grav['config'];
  109. $this->now = time();
  110. if (null === $this->enabled) {
  111. $this->enabled = (bool)$this->config->get('system.cache.enabled');
  112. }
  113. /** @var Uri $uri */
  114. $uri = $grav['uri'];
  115. $prefix = $this->config->get('system.cache.prefix');
  116. $uniqueness = substr(md5($uri->rootUrl(true) . $this->config->key() . GRAV_VERSION), 2, 8);
  117. // Cache key allows us to invalidate all cache on configuration changes.
  118. $this->key = ($prefix ?: 'g') . '-' . $uniqueness;
  119. $this->cache_dir = $grav['locator']->findResource('cache://doctrine/' . $uniqueness, true, true);
  120. $this->driver_setting = $this->config->get('system.cache.driver');
  121. $this->driver = $this->getCacheDriver();
  122. $this->driver->setNamespace($this->key);
  123. /** @var EventDispatcher $dispatcher */
  124. $dispatcher = Grav::instance()['events'];
  125. $dispatcher->addListener('onSchedulerInitialized', [$this, 'onSchedulerInitialized']);
  126. }
  127. /**
  128. * @return CacheInterface
  129. */
  130. public function getSimpleCache()
  131. {
  132. if (null === $this->simpleCache) {
  133. $cache = new \Grav\Framework\Cache\Adapter\DoctrineCache($this->driver, '', $this->getLifetime());
  134. // Disable cache key validation.
  135. $cache->setValidation(false);
  136. $this->simpleCache = $cache;
  137. }
  138. return $this->simpleCache;
  139. }
  140. /**
  141. * Deletes the old out of date file-based caches
  142. *
  143. * @return int
  144. */
  145. public function purgeOldCache()
  146. {
  147. $cache_dir = dirname($this->cache_dir);
  148. $current = Utils::basename($this->cache_dir);
  149. $count = 0;
  150. foreach (new DirectoryIterator($cache_dir) as $file) {
  151. $dir = $file->getBasename();
  152. if ($dir === $current || $file->isDot() || $file->isFile()) {
  153. continue;
  154. }
  155. Folder::delete($file->getPathname());
  156. $count++;
  157. }
  158. return $count;
  159. }
  160. /**
  161. * Public accessor to set the enabled state of the cache
  162. *
  163. * @param bool|int $enabled
  164. * @return void
  165. */
  166. public function setEnabled($enabled)
  167. {
  168. $this->enabled = (bool)$enabled;
  169. }
  170. /**
  171. * Returns the current enabled state
  172. *
  173. * @return bool
  174. */
  175. public function getEnabled()
  176. {
  177. return $this->enabled;
  178. }
  179. /**
  180. * Get cache state
  181. *
  182. * @return string
  183. */
  184. public function getCacheStatus()
  185. {
  186. return 'Cache: [' . ($this->enabled ? 'true' : 'false') . '] Setting: [' . $this->driver_setting . '] Driver: [' . $this->driver_name . ']';
  187. }
  188. /**
  189. * Automatically picks the cache mechanism to use. If you pick one manually it will use that
  190. * If there is no config option for $driver in the config, or it's set to 'auto', it will
  191. * pick the best option based on which cache extensions are installed.
  192. *
  193. * @return DoctrineCache\CacheProvider The cache driver to use
  194. */
  195. public function getCacheDriver()
  196. {
  197. $setting = $this->driver_setting;
  198. $driver_name = 'file';
  199. // CLI compatibility requires a non-volatile cache driver
  200. if ($this->config->get('system.cache.cli_compatibility') && (
  201. $setting === 'auto' || $this->isVolatileDriver($setting))) {
  202. $setting = $driver_name;
  203. }
  204. if (!$setting || $setting === 'auto') {
  205. if (extension_loaded('apcu')) {
  206. $driver_name = 'apcu';
  207. } elseif (extension_loaded('wincache')) {
  208. $driver_name = 'wincache';
  209. }
  210. } else {
  211. $driver_name = $setting;
  212. }
  213. $this->driver_name = $driver_name;
  214. switch ($driver_name) {
  215. case 'apc':
  216. case 'apcu':
  217. $driver = new DoctrineCache\ApcuCache();
  218. break;
  219. case 'wincache':
  220. $driver = new DoctrineCache\WinCacheCache();
  221. break;
  222. case 'memcache':
  223. if (extension_loaded('memcache')) {
  224. $memcache = new \Memcache();
  225. $memcache->connect(
  226. $this->config->get('system.cache.memcache.server', 'localhost'),
  227. $this->config->get('system.cache.memcache.port', 11211)
  228. );
  229. $driver = new DoctrineCache\MemcacheCache();
  230. $driver->setMemcache($memcache);
  231. } else {
  232. throw new LogicException('Memcache PHP extension has not been installed');
  233. }
  234. break;
  235. case 'memcached':
  236. if (extension_loaded('memcached')) {
  237. $memcached = new \Memcached();
  238. $memcached->addServer(
  239. $this->config->get('system.cache.memcached.server', 'localhost'),
  240. $this->config->get('system.cache.memcached.port', 11211)
  241. );
  242. $driver = new DoctrineCache\MemcachedCache();
  243. $driver->setMemcached($memcached);
  244. } else {
  245. throw new LogicException('Memcached PHP extension has not been installed');
  246. }
  247. break;
  248. case 'redis':
  249. if (extension_loaded('redis')) {
  250. $redis = new \Redis();
  251. $socket = $this->config->get('system.cache.redis.socket', false);
  252. $password = $this->config->get('system.cache.redis.password', false);
  253. $databaseId = $this->config->get('system.cache.redis.database', 0);
  254. if ($socket) {
  255. $redis->connect($socket);
  256. } else {
  257. $redis->connect(
  258. $this->config->get('system.cache.redis.server', 'localhost'),
  259. $this->config->get('system.cache.redis.port', 6379)
  260. );
  261. }
  262. // Authenticate with password if set
  263. if ($password && !$redis->auth($password)) {
  264. throw new \RedisException('Redis authentication failed');
  265. }
  266. // Select alternate ( !=0 ) database ID if set
  267. if ($databaseId && !$redis->select($databaseId)) {
  268. throw new \RedisException('Could not select alternate Redis database ID');
  269. }
  270. $driver = new DoctrineCache\RedisCache();
  271. $driver->setRedis($redis);
  272. } else {
  273. throw new LogicException('Redis PHP extension has not been installed');
  274. }
  275. break;
  276. default:
  277. $driver = new DoctrineCache\FilesystemCache($this->cache_dir);
  278. break;
  279. }
  280. return $driver;
  281. }
  282. /**
  283. * Gets a cached entry if it exists based on an id. If it does not exist, it returns false
  284. *
  285. * @param string $id the id of the cached entry
  286. * @return mixed|bool returns the cached entry, can be any type, or false if doesn't exist
  287. */
  288. public function fetch($id)
  289. {
  290. if ($this->enabled) {
  291. return $this->driver->fetch($id);
  292. }
  293. return false;
  294. }
  295. /**
  296. * Stores a new cached entry.
  297. *
  298. * @param string $id the id of the cached entry
  299. * @param array|object|int $data the data for the cached entry to store
  300. * @param int|null $lifetime the lifetime to store the entry in seconds
  301. */
  302. public function save($id, $data, $lifetime = null)
  303. {
  304. if ($this->enabled) {
  305. if ($lifetime === null) {
  306. $lifetime = $this->getLifetime();
  307. }
  308. $this->driver->save($id, $data, $lifetime);
  309. }
  310. }
  311. /**
  312. * Deletes an item in the cache based on the id
  313. *
  314. * @param string $id the id of the cached data entry
  315. * @return bool true if the item was deleted successfully
  316. */
  317. public function delete($id)
  318. {
  319. if ($this->enabled) {
  320. return $this->driver->delete($id);
  321. }
  322. return false;
  323. }
  324. /**
  325. * Deletes all cache
  326. *
  327. * @return bool
  328. */
  329. public function deleteAll()
  330. {
  331. if ($this->enabled) {
  332. return $this->driver->deleteAll();
  333. }
  334. return false;
  335. }
  336. /**
  337. * Returns a boolean state of whether or not the item exists in the cache based on id key
  338. *
  339. * @param string $id the id of the cached data entry
  340. * @return bool true if the cached items exists
  341. */
  342. public function contains($id)
  343. {
  344. if ($this->enabled) {
  345. return $this->driver->contains(($id));
  346. }
  347. return false;
  348. }
  349. /**
  350. * Getter method to get the cache key
  351. *
  352. * @return string
  353. */
  354. public function getKey()
  355. {
  356. return $this->key;
  357. }
  358. /**
  359. * Setter method to set key (Advanced)
  360. *
  361. * @param string $key
  362. * @return void
  363. */
  364. public function setKey($key)
  365. {
  366. $this->key = $key;
  367. $this->driver->setNamespace($this->key);
  368. }
  369. /**
  370. * Helper method to clear all Grav caches
  371. *
  372. * @param string $remove standard|all|assets-only|images-only|cache-only
  373. * @return array
  374. */
  375. public static function clearCache($remove = 'standard')
  376. {
  377. $locator = Grav::instance()['locator'];
  378. $output = [];
  379. $user_config = USER_DIR . 'config/system.yaml';
  380. switch ($remove) {
  381. case 'all':
  382. $remove_paths = self::$all_remove;
  383. break;
  384. case 'assets-only':
  385. $remove_paths = self::$assets_remove;
  386. break;
  387. case 'images-only':
  388. $remove_paths = self::$images_remove;
  389. break;
  390. case 'cache-only':
  391. $remove_paths = self::$cache_remove;
  392. break;
  393. case 'tmp-only':
  394. $remove_paths = self::$tmp_remove;
  395. break;
  396. case 'invalidate':
  397. $remove_paths = [];
  398. break;
  399. default:
  400. if (Grav::instance()['config']->get('system.cache.clear_images_by_default')) {
  401. $remove_paths = self::$standard_remove;
  402. } else {
  403. $remove_paths = self::$standard_remove_no_images;
  404. }
  405. }
  406. // Delete entries in the doctrine cache if required
  407. if (in_array($remove, ['all', 'standard'])) {
  408. $cache = Grav::instance()['cache'];
  409. $cache->driver->deleteAll();
  410. }
  411. // Clearing cache event to add paths to clear
  412. Grav::instance()->fireEvent('onBeforeCacheClear', new Event(['remove' => $remove, 'paths' => &$remove_paths]));
  413. foreach ($remove_paths as $stream) {
  414. // Convert stream to a real path
  415. try {
  416. $path = $locator->findResource($stream, true, true);
  417. if ($path === false) {
  418. continue;
  419. }
  420. $anything = false;
  421. $files = glob($path . '/*');
  422. if (is_array($files)) {
  423. foreach ($files as $file) {
  424. if (is_link($file)) {
  425. $output[] = '<yellow>Skipping symlink: </yellow>' . $file;
  426. } elseif (is_file($file)) {
  427. if (@unlink($file)) {
  428. $anything = true;
  429. }
  430. } elseif (is_dir($file)) {
  431. if (Folder::delete($file, false)) {
  432. $anything = true;
  433. }
  434. }
  435. }
  436. }
  437. if ($anything) {
  438. $output[] = '<red>Cleared: </red>' . $path . '/*';
  439. }
  440. } catch (Exception $e) {
  441. // stream not found or another error while deleting files.
  442. $output[] = '<red>ERROR: </red>' . $e->getMessage();
  443. }
  444. }
  445. $output[] = '';
  446. if (($remove === 'all' || $remove === 'standard') && file_exists($user_config)) {
  447. touch($user_config);
  448. $output[] = '<red>Touched: </red>' . $user_config;
  449. $output[] = '';
  450. }
  451. // Clear stat cache
  452. @clearstatcache();
  453. // Clear opcache
  454. if (function_exists('opcache_reset')) {
  455. @opcache_reset();
  456. }
  457. Grav::instance()->fireEvent('onAfterCacheClear', new Event(['remove' => $remove, 'output' => &$output]));
  458. return $output;
  459. }
  460. /**
  461. * @return void
  462. */
  463. public static function invalidateCache()
  464. {
  465. $user_config = USER_DIR . 'config/system.yaml';
  466. if (file_exists($user_config)) {
  467. touch($user_config);
  468. }
  469. // Clear stat cache
  470. @clearstatcache();
  471. // Clear opcache
  472. if (function_exists('opcache_reset')) {
  473. @opcache_reset();
  474. }
  475. }
  476. /**
  477. * Set the cache lifetime programmatically
  478. *
  479. * @param int $future timestamp
  480. * @return void
  481. */
  482. public function setLifetime($future)
  483. {
  484. if (!$future) {
  485. return;
  486. }
  487. $interval = (int)($future - $this->now);
  488. if ($interval > 0 && $interval < $this->getLifetime()) {
  489. $this->lifetime = $interval;
  490. }
  491. }
  492. /**
  493. * Retrieve the cache lifetime (in seconds)
  494. *
  495. * @return int
  496. */
  497. public function getLifetime()
  498. {
  499. if ($this->lifetime === null) {
  500. $this->lifetime = (int)($this->config->get('system.cache.lifetime') ?: 604800); // 1 week default
  501. }
  502. return $this->lifetime;
  503. }
  504. /**
  505. * Returns the current driver name
  506. *
  507. * @return string
  508. */
  509. public function getDriverName()
  510. {
  511. return $this->driver_name;
  512. }
  513. /**
  514. * Returns the current driver setting
  515. *
  516. * @return string
  517. */
  518. public function getDriverSetting()
  519. {
  520. return $this->driver_setting;
  521. }
  522. /**
  523. * is this driver a volatile driver in that it resides in PHP process memory
  524. *
  525. * @param string $setting
  526. * @return bool
  527. */
  528. public function isVolatileDriver($setting)
  529. {
  530. return in_array($setting, ['apc', 'apcu', 'xcache', 'wincache'], true);
  531. }
  532. /**
  533. * Static function to call as a scheduled Job to purge old Doctrine files
  534. *
  535. * @param bool $echo
  536. *
  537. * @return string|void
  538. */
  539. public static function purgeJob($echo = false)
  540. {
  541. /** @var Cache $cache */
  542. $cache = Grav::instance()['cache'];
  543. $deleted_folders = $cache->purgeOldCache();
  544. $msg = 'Purged ' . $deleted_folders . ' old cache folders...';
  545. if ($echo) {
  546. echo $msg;
  547. } else {
  548. return $msg;
  549. }
  550. }
  551. /**
  552. * Static function to call as a scheduled Job to clear Grav cache
  553. *
  554. * @param string $type
  555. * @return void
  556. */
  557. public static function clearJob($type)
  558. {
  559. $result = static::clearCache($type);
  560. static::invalidateCache();
  561. echo strip_tags(implode("\n", $result));
  562. }
  563. /**
  564. * @param Event $event
  565. * @return void
  566. */
  567. public function onSchedulerInitialized(Event $event)
  568. {
  569. /** @var Scheduler $scheduler */
  570. $scheduler = $event['scheduler'];
  571. $config = Grav::instance()['config'];
  572. // File Cache Purge
  573. $at = $config->get('system.cache.purge_at');
  574. $name = 'cache-purge';
  575. $logs = 'logs/' . $name . '.out';
  576. $job = $scheduler->addFunction('Grav\Common\Cache::purgeJob', [true], $name);
  577. $job->at($at);
  578. $job->output($logs);
  579. $job->backlink('/config/system#caching');
  580. // Cache Clear
  581. $at = $config->get('system.cache.clear_at');
  582. $clear_type = $config->get('system.cache.clear_job_type');
  583. $name = 'cache-clear';
  584. $logs = 'logs/' . $name . '.out';
  585. $job = $scheduler->addFunction('Grav\Common\Cache::clearJob', [$clear_type], $name);
  586. $job->at($at);
  587. $job->output($logs);
  588. $job->backlink('/config/system#caching');
  589. }
  590. }