137748cd8SNickeau<?php 237748cd8SNickeau 337748cd8SNickeauuse ComboStrap\CacheManager; 4*1fa8c418SNickeauuse ComboStrap\CacheMedia; 5*1fa8c418SNickeauuse ComboStrap\DokuPath; 6*1fa8c418SNickeauuse ComboStrap\Http; 737748cd8SNickeauuse ComboStrap\Iso8601Date; 837748cd8SNickeauuse ComboStrap\PluginUtility; 9*1fa8c418SNickeauuse dokuwiki\Cache\CacheRenderer; 10*1fa8c418SNickeauuse dokuwiki\Utf8\PhpString; 1137748cd8SNickeau 1237748cd8SNickeaurequire_once(__DIR__ . '/../ComboStrap/PluginUtility.php'); 1337748cd8SNickeau 1437748cd8SNickeau/** 1537748cd8SNickeau * Can we use the parser cache 1637748cd8SNickeau */ 1737748cd8SNickeauclass action_plugin_combo_cache extends DokuWiki_Action_Plugin 1837748cd8SNickeau{ 1937748cd8SNickeau const COMBO_CACHE_PREFIX = "combo:cache:"; 2037748cd8SNickeau 2137748cd8SNickeau /** 22*1fa8c418SNickeau * https://www.ietf.org/rfc/rfc2616.txt 23*1fa8c418SNickeau * To mark a response as "never expires," an origin server sends an Expires date approximately one year 24*1fa8c418SNickeau * from the time the response is sent. 25*1fa8c418SNickeau * HTTP/1.1 servers SHOULD NOT send Expires dates more than one year in the future. 26*1fa8c418SNickeau * 27*1fa8c418SNickeau * In seconds = 365*24*60*60 28*1fa8c418SNickeau */ 29*1fa8c418SNickeau const INFINITE_MAX_AGE = 31536000; 30*1fa8c418SNickeau 31*1fa8c418SNickeau /** 32*1fa8c418SNickeau * Enable an infinite cache on image URL with the {@link CacheMedia::CACHE_BUSTER_KEY} 33*1fa8c418SNickeau * present 34*1fa8c418SNickeau */ 35*1fa8c418SNickeau const CONF_STATIC_CACHE_ENABLED = "staticCacheEnabled"; 36*1fa8c418SNickeau const CANONICAL = "cache"; 37*1fa8c418SNickeau const STATIC_SCRIPT_NAMES = ["/lib/exe/jquery.php", "/lib/exe/js.php", "/lib/exe/css.php"]; 38*1fa8c418SNickeau 39*1fa8c418SNickeau /** 4037748cd8SNickeau * @param Doku_Event_Handler $controller 4137748cd8SNickeau */ 4237748cd8SNickeau function register(Doku_Event_Handler $controller) 4337748cd8SNickeau { 4437748cd8SNickeau 4537748cd8SNickeau /** 4637748cd8SNickeau * Log the cache usage and also 4737748cd8SNickeau */ 4837748cd8SNickeau $controller->register_hook('PARSER_CACHE_USE', 'AFTER', $this, 'logCacheUsage', array()); 4937748cd8SNickeau 5037748cd8SNickeau $controller->register_hook('PARSER_CACHE_USE', 'BEFORE', $this, 'purgeIfNeeded', array()); 5137748cd8SNickeau 5237748cd8SNickeau /** 53*1fa8c418SNickeau * Control the HTTP cache of the image 54*1fa8c418SNickeau */ 55*1fa8c418SNickeau $controller->register_hook('MEDIA_SENDFILE', 'BEFORE', $this, 'imageHTTPCacheBefore', array()); 56*1fa8c418SNickeau 57*1fa8c418SNickeau /** 5837748cd8SNickeau * To add the cache result in the header 5937748cd8SNickeau */ 6037748cd8SNickeau $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'addMeta', array()); 6137748cd8SNickeau 6237748cd8SNickeau /** 6337748cd8SNickeau * To reset the cache manager 6437748cd8SNickeau * between two run in the test 6537748cd8SNickeau */ 6637748cd8SNickeau $controller->register_hook('DOKUWIKI_DONE', 'BEFORE', $this, 'close', array()); 6737748cd8SNickeau 68*1fa8c418SNickeau /** 69*1fa8c418SNickeau * To delete the VARY on css.php, jquery.php, js.php 70*1fa8c418SNickeau */ 71*1fa8c418SNickeau $controller->register_hook('INIT_LANG_LOAD', 'BEFORE', $this, 'deleteVaryFromStaticGeneratedResources', array()); 72*1fa8c418SNickeau 73*1fa8c418SNickeau 7437748cd8SNickeau } 7537748cd8SNickeau 7637748cd8SNickeau /** 7737748cd8SNickeau * 7837748cd8SNickeau * @param Doku_Event $event 7937748cd8SNickeau * @param $params 8037748cd8SNickeau */ 8137748cd8SNickeau function logCacheUsage(Doku_Event $event, $params) 8237748cd8SNickeau { 8337748cd8SNickeau 8437748cd8SNickeau /** 8537748cd8SNickeau * To log the cache used by bar 8637748cd8SNickeau * @var \dokuwiki\Cache\CacheParser $data 8737748cd8SNickeau */ 8837748cd8SNickeau $data = $event->data; 8937748cd8SNickeau $result = $event->result; 9037748cd8SNickeau $pageId = $data->page; 9137748cd8SNickeau $cacheManager = PluginUtility::getCacheManager(); 9237748cd8SNickeau $cacheManager->addSlot($pageId, $result, $data); 9337748cd8SNickeau 9437748cd8SNickeau 9537748cd8SNickeau } 9637748cd8SNickeau 9737748cd8SNickeau /** 9837748cd8SNickeau * 9937748cd8SNickeau * @param Doku_Event $event 10037748cd8SNickeau * @param $params 10137748cd8SNickeau */ 10237748cd8SNickeau function purgeIfNeeded(Doku_Event $event, $params) 10337748cd8SNickeau { 10437748cd8SNickeau 10537748cd8SNickeau /** 10637748cd8SNickeau * No cache for all mode 10737748cd8SNickeau * (ie xhtml, instruction) 10837748cd8SNickeau */ 10937748cd8SNickeau $data = &$event->data; 11037748cd8SNickeau $pageId = $data->page; 111*1fa8c418SNickeau 112*1fa8c418SNickeau /** 113*1fa8c418SNickeau * For whatever reason, the cache file of XHMTL 114*1fa8c418SNickeau * may be empty - No error found on the web server or the log. 115*1fa8c418SNickeau * 116*1fa8c418SNickeau * We just delete it then. 117*1fa8c418SNickeau * 118*1fa8c418SNickeau * It has been seen after the creation of a new page or a `move` of the page. 119*1fa8c418SNickeau */ 120*1fa8c418SNickeau if ($data instanceof CacheRenderer) { 121*1fa8c418SNickeau if ($data->mode === "xhtml") { 122*1fa8c418SNickeau if (file_exists($data->cache)) { 123*1fa8c418SNickeau if (filesize($data->cache) === 0) { 124*1fa8c418SNickeau $data->depends["purge"] = true; 125*1fa8c418SNickeau } 126*1fa8c418SNickeau } 127*1fa8c418SNickeau } 128*1fa8c418SNickeau } 12937748cd8SNickeau /** 13037748cd8SNickeau * Because of the recursive nature of rendering 13137748cd8SNickeau * inside dokuwiki, we just handle the first 13237748cd8SNickeau * rendering for a request. 13337748cd8SNickeau * 13437748cd8SNickeau * The first will be purged, the other one not 13537748cd8SNickeau * because they can use the first one 13637748cd8SNickeau */ 13737748cd8SNickeau if (!PluginUtility::getCacheManager()->isCacheLogPresent($pageId, $data->mode)) { 13837748cd8SNickeau $expirationStringDate = p_get_metadata($pageId, CacheManager::DATE_CACHE_EXPIRATION_META_KEY, METADATA_DONT_RENDER); 13937748cd8SNickeau if ($expirationStringDate !== null) { 14037748cd8SNickeau 14137748cd8SNickeau $expirationDate = Iso8601Date::create($expirationStringDate)->getDateTime(); 14237748cd8SNickeau $actualDate = new DateTime(); 14337748cd8SNickeau if ($expirationDate < $actualDate) { 14437748cd8SNickeau /** 14537748cd8SNickeau * As seen in {@link Cache::makeDefaultCacheDecision()} 14637748cd8SNickeau * We request a purge 14737748cd8SNickeau */ 14837748cd8SNickeau $data->depends["purge"] = true; 14937748cd8SNickeau } 15037748cd8SNickeau } 15137748cd8SNickeau } 15237748cd8SNickeau 15337748cd8SNickeau 15437748cd8SNickeau } 15537748cd8SNickeau 15637748cd8SNickeau /** 15737748cd8SNickeau * Add HTML meta to be able to debug 15837748cd8SNickeau * @param Doku_Event $event 15937748cd8SNickeau * @param $params 16037748cd8SNickeau */ 16137748cd8SNickeau function addMeta(Doku_Event $event, $params) 16237748cd8SNickeau { 16337748cd8SNickeau 16437748cd8SNickeau $cacheManager = PluginUtility::getCacheManager(); 16537748cd8SNickeau $slots = $cacheManager->getCacheSlotResults(); 16637748cd8SNickeau foreach ($slots as $slotId => $modes) { 16737748cd8SNickeau 16837748cd8SNickeau $cachedMode = []; 16937748cd8SNickeau foreach ($modes as $mode => $values) { 17037748cd8SNickeau if ($values[CacheManager::RESULT_STATUS] === true) { 17137748cd8SNickeau $metaContentData = $mode; 17237748cd8SNickeau if (!PluginUtility::isTest()) { 17337748cd8SNickeau /** 17437748cd8SNickeau * @var DateTime $dateModified 17537748cd8SNickeau */ 17637748cd8SNickeau $dateModified = $values[CacheManager::DATE_MODIFIED]; 17737748cd8SNickeau $metaContentData .= ":" . $dateModified->format('Y-m-d\TH:i:s'); 17837748cd8SNickeau } 17937748cd8SNickeau $cachedMode[] = $metaContentData; 18037748cd8SNickeau } 18137748cd8SNickeau } 18237748cd8SNickeau 18337748cd8SNickeau if (sizeof($cachedMode) === 0) { 18437748cd8SNickeau $value = "nocache"; 18537748cd8SNickeau } else { 18637748cd8SNickeau sort($cachedMode); 18737748cd8SNickeau $value = implode(",", $cachedMode); 18837748cd8SNickeau } 18937748cd8SNickeau 19037748cd8SNickeau // Add cache information into the head meta 19137748cd8SNickeau // to test 19237748cd8SNickeau $event->data["meta"][] = array("name" => self::COMBO_CACHE_PREFIX . $slotId, "content" => hsc($value)); 19337748cd8SNickeau } 19437748cd8SNickeau 19537748cd8SNickeau } 19637748cd8SNickeau 19737748cd8SNickeau function close(Doku_Event $event, $params) 19837748cd8SNickeau { 19937748cd8SNickeau CacheManager::close(); 20037748cd8SNickeau } 20137748cd8SNickeau 202*1fa8c418SNickeau function imageHttpCacheBefore(Doku_Event $event, $params) 203*1fa8c418SNickeau { 204*1fa8c418SNickeau 205*1fa8c418SNickeau if (PluginUtility::getConfValue(self::CONF_STATIC_CACHE_ENABLED, 1)) { 206*1fa8c418SNickeau /** 207*1fa8c418SNickeau * If there is the buster key, the infinite cache is on 208*1fa8c418SNickeau */ 209*1fa8c418SNickeau if (isset($_GET[CacheMedia::CACHE_BUSTER_KEY])) { 210*1fa8c418SNickeau 211*1fa8c418SNickeau /** 212*1fa8c418SNickeau * To avoid buggy code, we check that the value is not empty 213*1fa8c418SNickeau */ 214*1fa8c418SNickeau $cacheKey = $_GET[CacheMedia::CACHE_BUSTER_KEY]; 215*1fa8c418SNickeau if (!empty($cacheKey)) { 216*1fa8c418SNickeau 217*1fa8c418SNickeau /** 218*1fa8c418SNickeau * Only for Image 219*1fa8c418SNickeau */ 220*1fa8c418SNickeau $mediaPath = DokuPath::createMediaPathFromId($event->data["media"]); 221*1fa8c418SNickeau if ($mediaPath->isImage()) { 222*1fa8c418SNickeau 223*1fa8c418SNickeau /** 224*1fa8c418SNickeau * Only for public images 225*1fa8c418SNickeau */ 226*1fa8c418SNickeau if (!$mediaPath->isPublic()) { 227*1fa8c418SNickeau return; 228*1fa8c418SNickeau } 229*1fa8c418SNickeau 230*1fa8c418SNickeau /** 231*1fa8c418SNickeau * We take over the complete {@link sendFile()} function and exit 232*1fa8c418SNickeau * 233*1fa8c418SNickeau * in {@link sendFile()}, DokuWiki set the `Cache-Control` and 234*1fa8c418SNickeau * may exit early / send a 304 (not modified) with the function {@link http_conditionalRequest()} 235*1fa8c418SNickeau * Meaning that the AFTER event is never reached 236*1fa8c418SNickeau * that we can't send a cache control as below 237*1fa8c418SNickeau * header("Cache-Control: public, max-age=$infiniteMaxAge, s-maxage=$infiniteMaxAge"); 238*1fa8c418SNickeau * 239*1fa8c418SNickeau * We take the control over then 240*1fa8c418SNickeau */ 241*1fa8c418SNickeau 242*1fa8c418SNickeau /** 243*1fa8c418SNickeau * The mime 244*1fa8c418SNickeau */ 245*1fa8c418SNickeau $mime = $mediaPath->getMime(); 246*1fa8c418SNickeau header("Content-Type: {$mime}"); 247*1fa8c418SNickeau 248*1fa8c418SNickeau /** 249*1fa8c418SNickeau * The cache instructions 250*1fa8c418SNickeau */ 251*1fa8c418SNickeau $infiniteMaxAge = self::INFINITE_MAX_AGE; 252*1fa8c418SNickeau $expires = time() + $infiniteMaxAge; 253*1fa8c418SNickeau header('Expires: ' . gmdate("D, d M Y H:i:s", $expires) . ' GMT'); 254*1fa8c418SNickeau header("Cache-Control: public, max-age=$infiniteMaxAge, immutable"); 255*1fa8c418SNickeau Http::removeHeaderIfPresent("Pragma"); 256*1fa8c418SNickeau 257*1fa8c418SNickeau /** 258*1fa8c418SNickeau * The Etag cache validator 259*1fa8c418SNickeau * 260*1fa8c418SNickeau * Dokuwiki {@link http_conditionalRequest()} uses only the datetime of 261*1fa8c418SNickeau * the file but we need to add the parameters also because they 262*1fa8c418SNickeau * are generated image 263*1fa8c418SNickeau * 264*1fa8c418SNickeau * Last-Modified is not needed for the same reason 265*1fa8c418SNickeau * 266*1fa8c418SNickeau */ 267*1fa8c418SNickeau $etag = self::getEtagValue($mediaPath, $_REQUEST); 268*1fa8c418SNickeau header("ETag: $etag"); 269*1fa8c418SNickeau 270*1fa8c418SNickeau /** 271*1fa8c418SNickeau * Conditional Request ? 272*1fa8c418SNickeau * We don't check on HTTP_IF_MODIFIED_SINCE because this is useless 273*1fa8c418SNickeau */ 274*1fa8c418SNickeau if (isset($_SERVER['HTTP_IF_NONE_MATCH'])) { 275*1fa8c418SNickeau $ifNoneMatch = stripslashes($_SERVER['HTTP_IF_NONE_MATCH']); 276*1fa8c418SNickeau if ($ifNoneMatch && $ifNoneMatch === $etag) { 277*1fa8c418SNickeau 278*1fa8c418SNickeau header('HTTP/1.0 304 Not Modified'); 279*1fa8c418SNickeau 280*1fa8c418SNickeau /** 281*1fa8c418SNickeau * Clean the buffer to not produce any output 282*1fa8c418SNickeau */ 283*1fa8c418SNickeau @ob_end_clean(); 284*1fa8c418SNickeau 285*1fa8c418SNickeau /** 286*1fa8c418SNickeau * Exit 287*1fa8c418SNickeau */ 288*1fa8c418SNickeau PluginUtility::softExit("File not modified"); 289*1fa8c418SNickeau } 290*1fa8c418SNickeau } 291*1fa8c418SNickeau 292*1fa8c418SNickeau /** 293*1fa8c418SNickeau * Send the file 294*1fa8c418SNickeau */ 295*1fa8c418SNickeau $originalFile = $event->data["orig"]; // the original file 296*1fa8c418SNickeau $physicalFile = $event->data["file"]; // the file modified 297*1fa8c418SNickeau if (empty($physicalFile)) { 298*1fa8c418SNickeau $physicalFile = $originalFile; 299*1fa8c418SNickeau } 300*1fa8c418SNickeau 301*1fa8c418SNickeau /** 302*1fa8c418SNickeau * Download or display feature 303*1fa8c418SNickeau * (Taken over from SendFile) 304*1fa8c418SNickeau */ 305*1fa8c418SNickeau $download = $event->data["download"]; 306*1fa8c418SNickeau if ($download && $mime !== "image/svg+xml") { 307*1fa8c418SNickeau header('Content-Disposition: attachment;' . rfc2231_encode( 308*1fa8c418SNickeau 'filename', PhpString::basename($originalFile)) . ';' 309*1fa8c418SNickeau ); 310*1fa8c418SNickeau } else { 311*1fa8c418SNickeau header('Content-Disposition: inline;' . rfc2231_encode( 312*1fa8c418SNickeau 'filename', PhpString::basename($originalFile)) . ';' 313*1fa8c418SNickeau ); 314*1fa8c418SNickeau } 315*1fa8c418SNickeau 316*1fa8c418SNickeau /** 317*1fa8c418SNickeau * The vary header avoid caching 318*1fa8c418SNickeau * Delete it 319*1fa8c418SNickeau */ 320*1fa8c418SNickeau self::deleteVaryHeader(); 321*1fa8c418SNickeau 322*1fa8c418SNickeau /** 323*1fa8c418SNickeau * Use x-sendfile header to pass the delivery to compatible web servers 324*1fa8c418SNickeau * (Taken over from SendFile) 325*1fa8c418SNickeau */ 326*1fa8c418SNickeau http_sendfile($physicalFile); 327*1fa8c418SNickeau 328*1fa8c418SNickeau /** 329*1fa8c418SNickeau * Send the file 330*1fa8c418SNickeau */ 331*1fa8c418SNickeau $filePointer = @fopen($physicalFile, "rb"); 332*1fa8c418SNickeau if ($filePointer) { 333*1fa8c418SNickeau http_rangeRequest($filePointer, filesize($physicalFile), $mime); 334*1fa8c418SNickeau } else { 335*1fa8c418SNickeau http_status(500); 336*1fa8c418SNickeau print "Could not read $physicalFile - bad permissions?"; 337*1fa8c418SNickeau } 338*1fa8c418SNickeau 339*1fa8c418SNickeau /** 340*1fa8c418SNickeau * Stop the propagation 341*1fa8c418SNickeau * Unfortunately, you can't stop the default ({@link sendFile()}) 342*1fa8c418SNickeau * because the event in fetch.php does not allow it 343*1fa8c418SNickeau * We exit only if not test 344*1fa8c418SNickeau */ 345*1fa8c418SNickeau $event->stopPropagation(); 346*1fa8c418SNickeau PluginUtility::softExit("File Send"); 347*1fa8c418SNickeau 348*1fa8c418SNickeau } 349*1fa8c418SNickeau } 350*1fa8c418SNickeau 351*1fa8c418SNickeau } 352*1fa8c418SNickeau } 353*1fa8c418SNickeau } 354*1fa8c418SNickeau 355*1fa8c418SNickeau /** 356*1fa8c418SNickeau * @param DokuPath $mediaPath 357*1fa8c418SNickeau * @param Array $properties - the query properties 358*1fa8c418SNickeau * @return string 359*1fa8c418SNickeau */ 360*1fa8c418SNickeau public static function getEtagValue(DokuPath $mediaPath, array $properties): string 361*1fa8c418SNickeau { 362*1fa8c418SNickeau $etagString = $mediaPath->getModifiedTime()->format('r'); 363*1fa8c418SNickeau ksort($properties); 364*1fa8c418SNickeau foreach ($properties as $key => $value) { 365*1fa8c418SNickeau /** 366*1fa8c418SNickeau * Media is already on the URL 367*1fa8c418SNickeau * tok is just added when w and h are on the url 368*1fa8c418SNickeau * Buster is the timestamp 369*1fa8c418SNickeau */ 370*1fa8c418SNickeau if (in_array($key, ["media","tok",CacheMedia::CACHE_BUSTER_KEY])) { 371*1fa8c418SNickeau continue; 372*1fa8c418SNickeau } 373*1fa8c418SNickeau /** 374*1fa8c418SNickeau * If empty means not used 375*1fa8c418SNickeau */ 376*1fa8c418SNickeau if(empty($value)){ 377*1fa8c418SNickeau continue; 378*1fa8c418SNickeau } 379*1fa8c418SNickeau $etagString .= "$key=$value"; 380*1fa8c418SNickeau } 381*1fa8c418SNickeau return '"' . md5($etagString) . '"'; 382*1fa8c418SNickeau } 383*1fa8c418SNickeau 384*1fa8c418SNickeau 385*1fa8c418SNickeau /** 386*1fa8c418SNickeau * Delete the Vary header 387*1fa8c418SNickeau * @param Doku_Event $event 388*1fa8c418SNickeau * @param $params 389*1fa8c418SNickeau */ 390*1fa8c418SNickeau public static function deleteVaryFromStaticGeneratedResources(Doku_Event $event, $params) 391*1fa8c418SNickeau { 392*1fa8c418SNickeau 393*1fa8c418SNickeau $script = $_SERVER["SCRIPT_NAME"]; 394*1fa8c418SNickeau if (in_array($script, self::STATIC_SCRIPT_NAMES)) { 395*1fa8c418SNickeau // To be extra sure, they must have a tseed 396*1fa8c418SNickeau if (isset($_REQUEST["tseed"])) { 397*1fa8c418SNickeau self::deleteVaryHeader(); 398*1fa8c418SNickeau } 399*1fa8c418SNickeau } 400*1fa8c418SNickeau 401*1fa8c418SNickeau } 402*1fa8c418SNickeau 403*1fa8c418SNickeau /** 404*1fa8c418SNickeau * 405*1fa8c418SNickeau * No Vary: Cookie 406*1fa8c418SNickeau * Introduced at 407*1fa8c418SNickeau * https://github.com/splitbrain/dokuwiki/issues/1594 408*1fa8c418SNickeau * But cache problem at: 409*1fa8c418SNickeau * https://github.com/splitbrain/dokuwiki/issues/2520 410*1fa8c418SNickeau * 411*1fa8c418SNickeau */ 412*1fa8c418SNickeau public static function deleteVaryHeader(): void 413*1fa8c418SNickeau { 414*1fa8c418SNickeau if (PluginUtility::getConfValue(self::CONF_STATIC_CACHE_ENABLED, 1)) { 415*1fa8c418SNickeau Http::removeHeaderIfPresent("Vary"); 416*1fa8c418SNickeau } 417*1fa8c418SNickeau } 418*1fa8c418SNickeau 41937748cd8SNickeau 42037748cd8SNickeau} 421