diff --git a/plugins/system/cache/cache.php b/plugins/system/cache/cache.php deleted file mode 100644 index 79b74556bc2b7..0000000000000 --- a/plugins/system/cache/cache.php +++ /dev/null @@ -1,280 +0,0 @@ - - * @license GNU General Public License version 2 or later; see LICENSE.txt - */ - -defined('_JEXEC') or die; - -use Joomla\CMS\Cache\Cache; -use Joomla\CMS\Factory; -use Joomla\CMS\Plugin\CMSPlugin; -use Joomla\CMS\Plugin\PluginHelper; -use Joomla\CMS\Profiler\Profiler; -use Joomla\CMS\Uri\Uri; - -/** - * Joomla! Page Cache Plugin. - * - * @since 1.5 - */ -class PlgSystemCache extends CMSPlugin -{ - /** - * Cache instance. - * - * @var \Joomla\CMS\Cache\CacheController - * @since 1.5 - */ - public $_cache; - - /** - * Cache key - * - * @var string - * @since 3.0 - */ - public $_cache_key; - - /** - * Application object. - * - * @var \Joomla\CMS\Application\CMSApplication - * @since 3.8.0 - */ - protected $app; - - /** - * Constructor. - * - * @param object &$subject The object to observe. - * @param array $config An optional associative array of configuration settings. - * - * @since 1.5 - */ - public function __construct(&$subject, $config) - { - parent::__construct($subject, $config); - - // Run only when we're on Site Application side - if (!$this->app->isClient('site')) - { - return; - } - - // Set the cache options. - $options = array( - 'defaultgroup' => 'page', - 'browsercache' => $this->params->get('browsercache', 0), - 'caching' => false, - ); - - // Instantiate cache with previous options and create the cache key identifier. - $this->_cache = Cache::getInstance('page', $options); - $this->_cache_key = Uri::getInstance()->toString(); - } - - /** - * Get a cache key for the current page based on the url and possible other factors. - * - * @return string - * - * @since 3.7 - */ - protected function getCacheKey() - { - static $key; - - // Run only when we're on Site Application side - if (!$this->app->isClient('site')) - { - return ''; - } - - if (!$key) - { - PluginHelper::importPlugin('pagecache'); - - $parts = $this->app->triggerEvent('onPageCacheGetKey'); - $parts[] = Uri::getInstance()->toString(); - - $key = md5(serialize($parts)); - } - - return $key; - } - - /** - * Checks if URL exists in cache, if so dumps it directly and closes. - * - * @return void - * - * @since 4.0.0 - */ - public function onAfterRoute() - { - if (!$this->app->isClient('site') || $this->app->get('offline', '0') || $this->app->getMessageQueue()) - { - return; - } - - // If any pagecache plugins return false for onPageCacheSetCaching, do not use the cache. - PluginHelper::importPlugin('pagecache'); - - $results = $this->app->triggerEvent('onPageCacheSetCaching'); - $caching = !in_array(false, $results, true); - - if ($caching && $this->app->getIdentity()->guest && $this->app->input->getMethod() === 'GET') - { - $this->_cache->setCaching(true); - } - - $data = $this->_cache->get($this->getCacheKey()); - - // If page exist in cache, show cached page. - if ($data !== false) - { - // Set HTML page from cache. - $this->app->setBody($data); - - // Dumps HTML page. - echo $this->app->toString((bool) $this->app->get('gzip')); - - // Mark afterCache in debug and run debug onAfterRespond events, e.g. show Joomla Debug Console if debug is active. - if (JDEBUG) - { - // Create a document instance and load it into the application. - $document = Factory::getContainer()->get('document.factory')->createDocument($this->app->input->get('format', 'html')); - $this->app->loadDocument($document); - - Profiler::getInstance('Application')->mark('afterCache'); - $this->app->triggerEvent('onAfterRespond'); - } - - // Closes the application. - $this->app->close(); - } - } - - /** - * After Render Event. - * Verify if current page is not excluded from cache. - * - * @return void - * - * @since 3.9.12 - */ - public function onAfterRender() - { - // Run only when we're on Site Application side - if (!$this->app->isClient('site')) - { - return; - } - - if ($this->_cache->getCaching() === false) - { - return; - } - - // We need to check if user is guest again here, because auto-login plugins have not been fired before the first aid check. - // Page is excluded if excluded in plugin settings. - if (!$this->app->getIdentity()->guest || $this->app->getMessageQueue() || $this->isExcluded() === true) - { - $this->_cache->setCaching(false); - - return; - } - - // Disable compression before caching the page. - $this->app->set('gzip', false); - } - - /** - * After Respond Event. - * Stores page in cache. - * - * @return void - * - * @since 1.5 - */ - public function onAfterRespond() - { - // Run only when we're on Site Application side - if (!$this->app->isClient('site')) - { - return; - } - - if ($this->_cache->getCaching() === false) - { - return; - } - - // Saves current page in cache. - $this->_cache->store($this->app->getBody(), $this->getCacheKey()); - } - - /** - * Check if the page is excluded from the cache or not. - * - * @return boolean True if the page is excluded else false - * - * @since 3.5 - */ - protected function isExcluded() - { - // Check if menu items have been excluded. - if ($exclusions = $this->params->get('exclude_menu_items', array())) - { - // Get the current menu item. - $active = $this->app->getMenu()->getActive(); - - if ($active && $active->id && in_array((int) $active->id, (array) $exclusions)) - { - return true; - } - } - - // Check if regular expressions are being used. - if ($exclusions = $this->params->get('exclude', '')) - { - // Normalize line endings. - $exclusions = str_replace(array("\r\n", "\r"), "\n", $exclusions); - - // Split them. - $exclusions = explode("\n", $exclusions); - - // Gets internal URI. - // Router can be injected when turned into a DI built plugin - $internal_uri = '/index.php?' . Uri::getInstance()->buildQuery(Factory::getContainer()->get(SiteRouter::class)->getVars()); - - // Loop through each pattern. - if ($exclusions) - { - foreach ($exclusions as $exclusion) - { - // Make sure the exclusion has some content - if ($exclusion !== '') - { - // Test both external and internal URI - if (preg_match('#' . $exclusion . '#i', $this->_cache_key . ' ' . $internal_uri)) - { - return true; - } - } - } - } - } - - // If any pagecache plugins return true for onPageCacheIsExcluded, exclude. - PluginHelper::importPlugin('pagecache'); - - $results = $this->app->triggerEvent('onPageCacheIsExcluded'); - - return in_array(true, $results, true); - } -} diff --git a/plugins/system/cache/cache.xml b/plugins/system/cache/cache.xml index 35e2d96375345..11d8eaf24dff5 100644 --- a/plugins/system/cache/cache.xml +++ b/plugins/system/cache/cache.xml @@ -9,8 +9,10 @@ www.joomla.org 3.0.0 PLG_CACHE_XML_DESCRIPTION + Joomla\Plugin\System\Cache - cache.php + services + src language/en-GB/plg_system_cache.ini diff --git a/plugins/system/cache/services/provider.php b/plugins/system/cache/services/provider.php new file mode 100644 index 0000000000000..6cf09d9211bb5 --- /dev/null +++ b/plugins/system/cache/services/provider.php @@ -0,0 +1,48 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Cache\CacheControllerFactoryInterface; +use Joomla\CMS\Extension\PluginInterface; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\CMS\Profiler\Profiler; +use Joomla\CMS\Router\SiteRouter; +use Joomla\DI\Container; +use Joomla\DI\ServiceProviderInterface; +use Joomla\Event\DispatcherInterface; +use Joomla\Plugin\System\Cache\Extension\Cache; + +return new class implements ServiceProviderInterface { + /** + * Registers the service provider with a DI container. + * + * @param Container $container The DI container. + * + * @return void + * @since __DEPLOY_VERSION__ + */ + public function register(Container $container) + { + $container->set( + PluginInterface::class, + function (Container $container) + { + $plugin = PluginHelper::getPlugin('system', 'cache'); + $dispatcher = $container->get(DispatcherInterface::class); + $documentFactory = $container->get('document.factory'); + $cacheControllerFactory = $container->get(CacheControllerFactoryInterface::class); + $profiler = (defined('JDEBUG') && JDEBUG) ? Profiler::getInstance('Application') : null; + $router = $container->has(SiteRouter::class) ? $container->get(SiteRouter::class) : null; + + return new Cache($dispatcher, (array) $plugin, $documentFactory, $cacheControllerFactory, $profiler, $router); + } + ); + } +}; diff --git a/plugins/system/cache/src/Extension/Cache.php b/plugins/system/cache/src/Extension/Cache.php new file mode 100644 index 0000000000000..705c445af7c4f --- /dev/null +++ b/plugins/system/cache/src/Extension/Cache.php @@ -0,0 +1,401 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Plugin\System\Cache\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Application\CMSApplication; +use Joomla\CMS\Application\CMSApplicationInterface; +use Joomla\CMS\Cache\CacheController; +use Joomla\CMS\Cache\CacheControllerFactoryInterface; +use Joomla\CMS\Document\FactoryInterface as DocumentFactoryInterface; +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\CMS\Profiler\Profiler; +use Joomla\CMS\Router\SiteRouter; +use Joomla\CMS\Uri\Uri; +use Joomla\Event\DispatcherInterface; +use Joomla\Event\Event; +use Joomla\Event\Priority; +use Joomla\Event\SubscriberInterface; + +/** + * Joomla! Page Cache Plugin. + * + * @since 1.5 + */ +final class Cache extends CMSPlugin implements SubscriberInterface +{ + /** + * Application object. + * + * @var CMSApplication + * @since 3.8.0 + */ + protected $app; + + /** + * Cache instance. + * + * @var CacheController + * @since 1.5 + */ + private $cache; + + /** + * The application's document factory interface + * + * @var DocumentFactoryInterface + * @since __DEPLOY_VERSION__ + */ + private $documentFactory; + + /** + * Cache controller factory interface + * + * @var CacheControllerFactoryInterface + * @since __DEPLOY_VERSION__ + */ + private $cacheControllerFactory; + + /** + * The application profiler, used when Debug Site is set to Yes in Global Configuration. + * + * @var Profiler|null + * @since __DEPLOY_VERSION__ + */ + private $profiler; + + /** + * The frontend router, injected by the service provider. + * + * @var SiteRouter|null + * @since __DEPLOY_VERSION__ + */ + private $router; + + /** + * Constructor + * + * @param DispatcherInterface $subject The object to observe + * @param array $config An optional associative + * array of configuration + * settings. Recognized key + * values include 'name', + * 'group', 'params', + * 'language' + * (this list is not meant + * to be comprehensive). + * @param DocumentFactoryInterface $documentFactory The application's + * document factory + * @param CacheControllerFactoryInterface $cacheControllerFactory Cache controller factory + * @param Profiler|null $profiler The application profiler + * @param SiteRouter|null $router The frontend router + * + * @since __DEPLOY_VERSION__ + */ + public function __construct( + &$subject, + $config, + DocumentFactoryInterface $documentFactory, + CacheControllerFactoryInterface $cacheControllerFactory, + ?Profiler $profiler, + ?SiteRouter $router + ) + { + parent::__construct($subject, $config); + + $this->documentFactory = $documentFactory; + $this->cacheControllerFactory = $cacheControllerFactory; + $this->profiler = $profiler; + $this->router = $router; + } + + /** + * Returns an array of CMS events this plugin will listen to and the respective handlers. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + public static function getSubscribedEvents(): array + { + /** + * Note that onAfterRender and onAfterRespond must be the last handlers to run for this + * plugin to operate as expected. These handlers put pages into cache. We must make sure + * that a. the page SHOULD be cached and b. we are caching the complete page, as it's + * output to the browser. + */ + return [ + 'onAfterRoute' => 'onAfterRoute', + 'onAfterRender' => ['onAfterRender', Priority::LOW], + 'onAfterRespond' => ['onAfterRespond', Priority::LOW], + ]; + } + + /** + * Returns a cached page if the current URL exists in the cache. + * + * @param Event $event The Joomla event being handled + * + * @return void + * + * @since 4.0.0 + */ + public function onAfterRoute(Event $event) + { + if (!$this->appStateSupportsCaching()) + { + return; + } + + // If any `pagecache` plugins return false for onPageCacheSetCaching, do not use the cache. + PluginHelper::importPlugin('pagecache'); + + $results = $this->app->triggerEvent('onPageCacheSetCaching'); + + $this->getCacheController()->setCaching(!in_array(false, $results, true)); + + $data = $this->getCacheController()->get($this->getCacheKey()); + + if ($data === false) + { + // No cached data. + return; + } + + // Set the page content from the cache and output it to the browser. + $this->app->setBody($data); + + echo $this->app->toString((bool) $this->app->get('gzip')); + + // Mark afterCache in debug and run debug onAfterRespond events, e.g. show Joomla Debug Console if debug is active. + if (JDEBUG) + { + // Create a document instance and load it into the application. + $document = $this->documentFactory + ->createDocument($this->app->input->get('format', 'html')); + $this->app->loadDocument($document); + + if ($this->profiler) + { + $this->profiler->mark('afterCache'); + } + + $this->app->triggerEvent('onAfterRespond'); + } + + // Closes the application. + $this->app->close(); + } + + /** + * Does the current application state allow for caching? + * + * The following conditions must be met: + * * This is the frontend application. This plugin does not apply to other applications. + * * This is a GET request. This plugin does not apply to POST, PUT etc. + * * There is no currently logged in user (pages might have user–specific content). + * * The message queue is empty. + * + * The first two tests are cached to make early returns possible; these conditions cannot change + * throughout the lifetime of the request. + * + * The other two tests MUST NOT be cached because auto–login plugins may fire anytime within + * the application lifetime logging in a user and messages can be generated anytime within the + * application's lifetime. + * + * @return boolean + * @since __DEPLOY_VERSION__ + */ + private function appStateSupportsCaching(): bool + { + static $isSite = null; + static $isGET = null; + + if ($isSite === null) + { + $isSite = ($this->app instanceof CMSApplicationInterface) + && $this->app->isClient('site'); + $isGET = $this->app->input->getMethod() === 'GET'; + } + + // Boolean short–circuit evaluation means this returns fast false when $isSite is false. + return $isSite + && $isGET + && $this->app->getIdentity()->guest + && empty($this->app->getMessageQueue()); + } + + /** + * Get the cache controller + * + * @return CacheController + * @since __DEPLOY_VERSION__ + */ + private function getCacheController(): CacheController + { + if (!empty($this->cache)) + { + return $this->cache; + } + + // Set the cache options. + $options = [ + 'defaultgroup' => 'page', + 'browsercache' => $this->params->get('browsercache', 0), + 'caching' => false, + ]; + + // Instantiate cache with previous options. + $this->cache = $this->cacheControllerFactory->createCacheController('page', $options); + + return $this->cache; + } + + /** + * Get a cache key for the current page based on the url and possible other factors. + * + * @return string + * + * @since 3.7 + */ + private function getCacheKey(): string + { + static $key; + + if (!$key) + { + PluginHelper::importPlugin('pagecache'); + + $parts = $this->app->triggerEvent('onPageCacheGetKey'); + $parts[] = Uri::getInstance()->toString(); + + $key = md5(serialize($parts)); + } + + return $key; + } + + /** + * After Render Event. Check whether the current page is excluded from cache. + * + * @param Event $event The CMS event we are handling. + * + * @return void + * + * @since 3.9.12 + */ + public function onAfterRender(Event $event) + { + if (!$this->appStateSupportsCaching() || $this->getCacheController()->getCaching() === false) + { + return; + } + + if ($this->isExcluded() === true) + { + $this->getCacheController()->setCaching(false); + + return; + } + + // Disable compression before caching the page. + $this->app->set('gzip', false); + } + + /** + * Check if the page is excluded from the cache or not. + * + * @return boolean True if the page is excluded else false + * + * @since 3.5 + */ + private function isExcluded(): bool + { + // Check if menu items have been excluded. + $excludedMenuItems = $this->params->get('exclude_menu_items', []); + + if ($excludedMenuItems) + { + // Get the current menu item. + $active = $this->app->getMenu()->getActive(); + + if ($active && $active->id && in_array((int) $active->id, (array) $excludedMenuItems)) + { + return true; + } + } + + // Check if regular expressions are being used. + $exclusions = $this->params->get('exclude', ''); + + if ($exclusions) + { + // Convert the exclusions into a normalised array + $exclusions = str_replace(["\r\n", "\r"], "\n", $exclusions); + $exclusions = explode("\n", $exclusions); + $filterExpression = function ($x) + { + return $x !== ''; + }; + $exclusions = array_filter($exclusions, $filterExpression); + + // Gets the internal (non-SEF) and the external (possibly SEF) URIs. + $internalUrl = '/index.php?' + . Uri::getInstance()->buildQuery($this->router->getVars()); + $externalUrl = Uri::getInstance()->toString(); + + $reduceCallback + = function (bool $carry, string $exclusion) use ($internalUrl, $externalUrl) + { + // Test both external and internal URIs + return $carry && preg_match( + '#' . $exclusion . '#i', + $externalUrl . ' ' . $internalUrl, $match + ); + }; + $excluded = array_reduce($exclusions, $reduceCallback, false); + + if ($excluded) + { + return true; + } + } + + // If any pagecache plugins return true for onPageCacheIsExcluded, exclude. + PluginHelper::importPlugin('pagecache'); + + $results = $this->app->triggerEvent('onPageCacheIsExcluded'); + + return in_array(true, $results, true); + } + + /** + * After Respond Event. Stores page in cache. + * + * @param Event $event The application event we are handling. + * + * @return void + * + * @since 1.5 + */ + public function onAfterRespond(Event $event) + { + if (!$this->appStateSupportsCaching() || $this->getCacheController()->getCaching() === false) + { + return; + } + + // Saves current page in cache. + $this->getCacheController()->store($this->app->getBody(), $this->getCacheKey()); + } +}