* @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 { /** * Cache instance. * * @var CacheController * @since 1.5 */ private $cache; /** * The application's document factory interface * * @var DocumentFactoryInterface * @since 4.2.0 */ private $documentFactory; /** * Cache controller factory interface * * @var CacheControllerFactoryInterface * @since 4.2.0 */ private $cacheControllerFactory; /** * The application profiler, used when Debug Site is set to Yes in Global Configuration. * * @var Profiler|null * @since 4.2.0 */ private $profiler; /** * The frontend router, injected by the service provider. * * @var SiteRouter|null * @since 4.2.0 */ 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 4.2.0 */ 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 4.2.0 */ 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->getApplication()->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->getApplication()->setBody($data); echo $this->getApplication()->toString((bool) $this->getApplication()->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->getApplication()->input->get('format', 'html')); $this->getApplication()->loadDocument($document); if ($this->profiler) { $this->profiler->mark('afterCache'); } $this->getApplication()->triggerEvent('onAfterRespond'); } // Closes the application. $this->getApplication()->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 4.2.0 */ private function appStateSupportsCaching(): bool { static $isSite = null; static $isGET = null; if ($isSite === null) { $isSite = $this->getApplication()->isClient('site'); $isGET = $this->getApplication()->input->getMethod() === 'GET'; } // Boolean short–circuit evaluation means this returns fast false when $isSite is false. return $isSite && $isGET && $this->getApplication()->getIdentity()->guest && empty($this->getApplication()->getMessageQueue()); } /** * Get the cache controller * * @return CacheController * @since 4.2.0 */ 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->getApplication()->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->getApplication()->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->getApplication()->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->getApplication()->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->getApplication()->getBody(), $this->getCacheKey()); } }