register_hook('PARSER_CACHE_USE', 'AFTER', $this, 'logCacheUsage', array()); $controller->register_hook('PARSER_CACHE_USE', 'BEFORE', $this, 'purgeIfNeeded', array()); /** * Control the HTTP cache of the image */ $controller->register_hook('MEDIA_SENDFILE', 'BEFORE', $this, 'imageHTTPCacheBefore', array()); /** * To add the cache result in the header */ $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'addMeta', array()); /** * To reset the cache manager * between two run in the test */ $controller->register_hook('DOKUWIKI_DONE', 'BEFORE', $this, 'close', array()); /** * To delete the VARY on css.php, jquery.php, js.php */ $controller->register_hook('INIT_LANG_LOAD', 'BEFORE', $this, 'deleteVaryFromStaticGeneratedResources', array()); } /** * * @param Doku_Event $event * @param $params */ function logCacheUsage(Doku_Event $event, $params) { /** * To log the cache used by bar * @var \dokuwiki\Cache\CacheParser $data */ $data = $event->data; $result = $event->result; $pageId = $data->page; $cacheManager = PluginUtility::getCacheManager(); $cacheManager->addSlot($pageId, $result, $data); } /** * * @param Doku_Event $event * @param $params */ function purgeIfNeeded(Doku_Event $event, $params) { /** * No cache for all mode * (ie xhtml, instruction) */ $data = &$event->data; $pageId = $data->page; /** * For whatever reason, the cache file of XHMTL * may be empty - No error found on the web server or the log. * * We just delete it then. * * It has been seen after the creation of a new page or a `move` of the page. */ if ($data instanceof CacheRenderer) { if ($data->mode === "xhtml") { if (file_exists($data->cache)) { if (filesize($data->cache) === 0) { $data->depends["purge"] = true; } } } } /** * Because of the recursive nature of rendering * inside dokuwiki, we just handle the first * rendering for a request. * * The first will be purged, the other one not * because they can use the first one */ if (!PluginUtility::getCacheManager()->isCacheLogPresent($pageId, $data->mode)) { $expirationStringDate = p_get_metadata($pageId, CacheManager::DATE_CACHE_EXPIRATION_META_KEY, METADATA_DONT_RENDER); if ($expirationStringDate !== null) { $expirationDate = Iso8601Date::create($expirationStringDate)->getDateTime(); $actualDate = new DateTime(); if ($expirationDate < $actualDate) { /** * As seen in {@link Cache::makeDefaultCacheDecision()} * We request a purge */ $data->depends["purge"] = true; } } } } /** * Add HTML meta to be able to debug * @param Doku_Event $event * @param $params */ function addMeta(Doku_Event $event, $params) { $cacheManager = PluginUtility::getCacheManager(); $slots = $cacheManager->getCacheSlotResults(); foreach ($slots as $slotId => $modes) { $cachedMode = []; foreach ($modes as $mode => $values) { if ($values[CacheManager::RESULT_STATUS] === true) { $metaContentData = $mode; if (!PluginUtility::isTest()) { /** * @var DateTime $dateModified */ $dateModified = $values[CacheManager::DATE_MODIFIED]; $metaContentData .= ":" . $dateModified->format('Y-m-d\TH:i:s'); } $cachedMode[] = $metaContentData; } } if (sizeof($cachedMode) === 0) { $value = "nocache"; } else { sort($cachedMode); $value = implode(",", $cachedMode); } // Add cache information into the head meta // to test $event->data["meta"][] = array("name" => self::COMBO_CACHE_PREFIX . $slotId, "content" => hsc($value)); } } function close(Doku_Event $event, $params) { CacheManager::close(); } function imageHttpCacheBefore(Doku_Event $event, $params) { if (PluginUtility::getConfValue(self::CONF_STATIC_CACHE_ENABLED, 1)) { /** * If there is the buster key, the infinite cache is on */ if (isset($_GET[CacheMedia::CACHE_BUSTER_KEY])) { /** * To avoid buggy code, we check that the value is not empty */ $cacheKey = $_GET[CacheMedia::CACHE_BUSTER_KEY]; if (!empty($cacheKey)) { /** * Only for Image */ $mediaPath = DokuPath::createMediaPathFromId($event->data["media"]); if ($mediaPath->isImage()) { /** * Only for public images */ if (!$mediaPath->isPublic()) { return; } /** * We take over the complete {@link sendFile()} function and exit * * in {@link sendFile()}, DokuWiki set the `Cache-Control` and * may exit early / send a 304 (not modified) with the function {@link http_conditionalRequest()} * Meaning that the AFTER event is never reached * that we can't send a cache control as below * header("Cache-Control: public, max-age=$infiniteMaxAge, s-maxage=$infiniteMaxAge"); * * We take the control over then */ /** * The mime */ $mime = $mediaPath->getMime(); header("Content-Type: {$mime}"); /** * The cache instructions */ $infiniteMaxAge = self::INFINITE_MAX_AGE; $expires = time() + $infiniteMaxAge; header('Expires: ' . gmdate("D, d M Y H:i:s", $expires) . ' GMT'); header("Cache-Control: public, max-age=$infiniteMaxAge, immutable"); Http::removeHeaderIfPresent("Pragma"); /** * The Etag cache validator * * Dokuwiki {@link http_conditionalRequest()} uses only the datetime of * the file but we need to add the parameters also because they * are generated image * * Last-Modified is not needed for the same reason * */ $etag = self::getEtagValue($mediaPath, $_REQUEST); header("ETag: $etag"); /** * Conditional Request ? * We don't check on HTTP_IF_MODIFIED_SINCE because this is useless */ if (isset($_SERVER['HTTP_IF_NONE_MATCH'])) { $ifNoneMatch = stripslashes($_SERVER['HTTP_IF_NONE_MATCH']); if ($ifNoneMatch && $ifNoneMatch === $etag) { header('HTTP/1.0 304 Not Modified'); /** * Clean the buffer to not produce any output */ @ob_end_clean(); /** * Exit */ PluginUtility::softExit("File not modified"); } } /** * Send the file */ $originalFile = $event->data["orig"]; // the original file $physicalFile = $event->data["file"]; // the file modified if (empty($physicalFile)) { $physicalFile = $originalFile; } /** * Download or display feature * (Taken over from SendFile) */ $download = $event->data["download"]; if ($download && $mime !== "image/svg+xml") { header('Content-Disposition: attachment;' . rfc2231_encode( 'filename', PhpString::basename($originalFile)) . ';' ); } else { header('Content-Disposition: inline;' . rfc2231_encode( 'filename', PhpString::basename($originalFile)) . ';' ); } /** * The vary header avoid caching * Delete it */ self::deleteVaryHeader(); /** * Use x-sendfile header to pass the delivery to compatible web servers * (Taken over from SendFile) */ http_sendfile($physicalFile); /** * Send the file */ $filePointer = @fopen($physicalFile, "rb"); if ($filePointer) { http_rangeRequest($filePointer, filesize($physicalFile), $mime); } else { http_status(500); print "Could not read $physicalFile - bad permissions?"; } /** * Stop the propagation * Unfortunately, you can't stop the default ({@link sendFile()}) * because the event in fetch.php does not allow it * We exit only if not test */ $event->stopPropagation(); PluginUtility::softExit("File Send"); } } } } } /** * @param DokuPath $mediaPath * @param Array $properties - the query properties * @return string */ public static function getEtagValue(DokuPath $mediaPath, array $properties): string { $etagString = $mediaPath->getModifiedTime()->format('r'); ksort($properties); foreach ($properties as $key => $value) { /** * Media is already on the URL * tok is just added when w and h are on the url * Buster is the timestamp */ if (in_array($key, ["media","tok",CacheMedia::CACHE_BUSTER_KEY])) { continue; } /** * If empty means not used */ if(empty($value)){ continue; } $etagString .= "$key=$value"; } return '"' . md5($etagString) . '"'; } /** * Delete the Vary header * @param Doku_Event $event * @param $params */ public static function deleteVaryFromStaticGeneratedResources(Doku_Event $event, $params) { $script = $_SERVER["SCRIPT_NAME"]; if (in_array($script, self::STATIC_SCRIPT_NAMES)) { // To be extra sure, they must have a tseed if (isset($_REQUEST["tseed"])) { self::deleteVaryHeader(); } } } /** * * No Vary: Cookie * Introduced at * https://github.com/splitbrain/dokuwiki/issues/1594 * But cache problem at: * https://github.com/splitbrain/dokuwiki/issues/2520 * */ public static function deleteVaryHeader(): void { if (PluginUtility::getConfValue(self::CONF_STATIC_CACHE_ENABLED, 1)) { Http::removeHeaderIfPresent("Vary"); } } }