1<?php 2 3use ComboStrap\CacheManager; 4use ComboStrap\CacheMedia; 5use ComboStrap\DokuPath; 6use ComboStrap\Http; 7use ComboStrap\Iso8601Date; 8use ComboStrap\PluginUtility; 9use dokuwiki\Cache\CacheRenderer; 10use dokuwiki\Utf8\PhpString; 11 12require_once(__DIR__ . '/../ComboStrap/PluginUtility.php'); 13 14/** 15 * Can we use the parser cache 16 */ 17class action_plugin_combo_cache extends DokuWiki_Action_Plugin 18{ 19 const COMBO_CACHE_PREFIX = "combo:cache:"; 20 21 /** 22 * https://www.ietf.org/rfc/rfc2616.txt 23 * To mark a response as "never expires," an origin server sends an Expires date approximately one year 24 * from the time the response is sent. 25 * HTTP/1.1 servers SHOULD NOT send Expires dates more than one year in the future. 26 * 27 * In seconds = 365*24*60*60 28 */ 29 const INFINITE_MAX_AGE = 31536000; 30 31 /** 32 * Enable an infinite cache on image URL with the {@link CacheMedia::CACHE_BUSTER_KEY} 33 * present 34 */ 35 const CONF_STATIC_CACHE_ENABLED = "staticCacheEnabled"; 36 const CANONICAL = "cache"; 37 const STATIC_SCRIPT_NAMES = ["/lib/exe/jquery.php", "/lib/exe/js.php", "/lib/exe/css.php"]; 38 39 /** 40 * @param Doku_Event_Handler $controller 41 */ 42 function register(Doku_Event_Handler $controller) 43 { 44 45 /** 46 * Log the cache usage and also 47 */ 48 $controller->register_hook('PARSER_CACHE_USE', 'AFTER', $this, 'logCacheUsage', array()); 49 50 $controller->register_hook('PARSER_CACHE_USE', 'BEFORE', $this, 'purgeIfNeeded', array()); 51 52 /** 53 * Control the HTTP cache of the image 54 */ 55 $controller->register_hook('MEDIA_SENDFILE', 'BEFORE', $this, 'imageHTTPCacheBefore', array()); 56 57 /** 58 * To add the cache result in the header 59 */ 60 $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'addMeta', array()); 61 62 /** 63 * To reset the cache manager 64 * between two run in the test 65 */ 66 $controller->register_hook('DOKUWIKI_DONE', 'BEFORE', $this, 'close', array()); 67 68 /** 69 * To delete the VARY on css.php, jquery.php, js.php 70 */ 71 $controller->register_hook('INIT_LANG_LOAD', 'BEFORE', $this, 'deleteVaryFromStaticGeneratedResources', array()); 72 73 74 } 75 76 /** 77 * 78 * @param Doku_Event $event 79 * @param $params 80 */ 81 function logCacheUsage(Doku_Event $event, $params) 82 { 83 84 /** 85 * To log the cache used by bar 86 * @var \dokuwiki\Cache\CacheParser $data 87 */ 88 $data = $event->data; 89 $result = $event->result; 90 $pageId = $data->page; 91 $cacheManager = PluginUtility::getCacheManager(); 92 $cacheManager->addSlot($pageId, $result, $data); 93 94 95 } 96 97 /** 98 * 99 * @param Doku_Event $event 100 * @param $params 101 */ 102 function purgeIfNeeded(Doku_Event $event, $params) 103 { 104 105 /** 106 * No cache for all mode 107 * (ie xhtml, instruction) 108 */ 109 $data = &$event->data; 110 $pageId = $data->page; 111 112 /** 113 * For whatever reason, the cache file of XHMTL 114 * may be empty - No error found on the web server or the log. 115 * 116 * We just delete it then. 117 * 118 * It has been seen after the creation of a new page or a `move` of the page. 119 */ 120 if ($data instanceof CacheRenderer) { 121 if ($data->mode === "xhtml") { 122 if (file_exists($data->cache)) { 123 if (filesize($data->cache) === 0) { 124 $data->depends["purge"] = true; 125 } 126 } 127 } 128 } 129 /** 130 * Because of the recursive nature of rendering 131 * inside dokuwiki, we just handle the first 132 * rendering for a request. 133 * 134 * The first will be purged, the other one not 135 * because they can use the first one 136 */ 137 if (!PluginUtility::getCacheManager()->isCacheLogPresent($pageId, $data->mode)) { 138 $expirationStringDate = p_get_metadata($pageId, CacheManager::DATE_CACHE_EXPIRATION_META_KEY, METADATA_DONT_RENDER); 139 if ($expirationStringDate !== null) { 140 141 $expirationDate = Iso8601Date::create($expirationStringDate)->getDateTime(); 142 $actualDate = new DateTime(); 143 if ($expirationDate < $actualDate) { 144 /** 145 * As seen in {@link Cache::makeDefaultCacheDecision()} 146 * We request a purge 147 */ 148 $data->depends["purge"] = true; 149 } 150 } 151 } 152 153 154 } 155 156 /** 157 * Add HTML meta to be able to debug 158 * @param Doku_Event $event 159 * @param $params 160 */ 161 function addMeta(Doku_Event $event, $params) 162 { 163 164 $cacheManager = PluginUtility::getCacheManager(); 165 $slots = $cacheManager->getCacheSlotResults(); 166 foreach ($slots as $slotId => $modes) { 167 168 $cachedMode = []; 169 foreach ($modes as $mode => $values) { 170 if ($values[CacheManager::RESULT_STATUS] === true) { 171 $metaContentData = $mode; 172 if (!PluginUtility::isTest()) { 173 /** 174 * @var DateTime $dateModified 175 */ 176 $dateModified = $values[CacheManager::DATE_MODIFIED]; 177 $metaContentData .= ":" . $dateModified->format('Y-m-d\TH:i:s'); 178 } 179 $cachedMode[] = $metaContentData; 180 } 181 } 182 183 if (sizeof($cachedMode) === 0) { 184 $value = "nocache"; 185 } else { 186 sort($cachedMode); 187 $value = implode(",", $cachedMode); 188 } 189 190 // Add cache information into the head meta 191 // to test 192 $event->data["meta"][] = array("name" => self::COMBO_CACHE_PREFIX . $slotId, "content" => hsc($value)); 193 } 194 195 } 196 197 function close(Doku_Event $event, $params) 198 { 199 CacheManager::close(); 200 } 201 202 function imageHttpCacheBefore(Doku_Event $event, $params) 203 { 204 205 if (PluginUtility::getConfValue(self::CONF_STATIC_CACHE_ENABLED, 1)) { 206 /** 207 * If there is the buster key, the infinite cache is on 208 */ 209 if (isset($_GET[CacheMedia::CACHE_BUSTER_KEY])) { 210 211 /** 212 * To avoid buggy code, we check that the value is not empty 213 */ 214 $cacheKey = $_GET[CacheMedia::CACHE_BUSTER_KEY]; 215 if (!empty($cacheKey)) { 216 217 /** 218 * Only for Image 219 */ 220 $mediaPath = DokuPath::createMediaPathFromId($event->data["media"]); 221 if ($mediaPath->isImage()) { 222 223 /** 224 * Only for public images 225 */ 226 if (!$mediaPath->isPublic()) { 227 return; 228 } 229 230 /** 231 * We take over the complete {@link sendFile()} function and exit 232 * 233 * in {@link sendFile()}, DokuWiki set the `Cache-Control` and 234 * may exit early / send a 304 (not modified) with the function {@link http_conditionalRequest()} 235 * Meaning that the AFTER event is never reached 236 * that we can't send a cache control as below 237 * header("Cache-Control: public, max-age=$infiniteMaxAge, s-maxage=$infiniteMaxAge"); 238 * 239 * We take the control over then 240 */ 241 242 /** 243 * The mime 244 */ 245 $mime = $mediaPath->getMime(); 246 header("Content-Type: {$mime}"); 247 248 /** 249 * The cache instructions 250 */ 251 $infiniteMaxAge = self::INFINITE_MAX_AGE; 252 $expires = time() + $infiniteMaxAge; 253 header('Expires: ' . gmdate("D, d M Y H:i:s", $expires) . ' GMT'); 254 header("Cache-Control: public, max-age=$infiniteMaxAge, immutable"); 255 Http::removeHeaderIfPresent("Pragma"); 256 257 /** 258 * The Etag cache validator 259 * 260 * Dokuwiki {@link http_conditionalRequest()} uses only the datetime of 261 * the file but we need to add the parameters also because they 262 * are generated image 263 * 264 * Last-Modified is not needed for the same reason 265 * 266 */ 267 $etag = self::getEtagValue($mediaPath, $_REQUEST); 268 header("ETag: $etag"); 269 270 /** 271 * Conditional Request ? 272 * We don't check on HTTP_IF_MODIFIED_SINCE because this is useless 273 */ 274 if (isset($_SERVER['HTTP_IF_NONE_MATCH'])) { 275 $ifNoneMatch = stripslashes($_SERVER['HTTP_IF_NONE_MATCH']); 276 if ($ifNoneMatch && $ifNoneMatch === $etag) { 277 278 header('HTTP/1.0 304 Not Modified'); 279 280 /** 281 * Clean the buffer to not produce any output 282 */ 283 @ob_end_clean(); 284 285 /** 286 * Exit 287 */ 288 PluginUtility::softExit("File not modified"); 289 } 290 } 291 292 /** 293 * Send the file 294 */ 295 $originalFile = $event->data["orig"]; // the original file 296 $physicalFile = $event->data["file"]; // the file modified 297 if (empty($physicalFile)) { 298 $physicalFile = $originalFile; 299 } 300 301 /** 302 * Download or display feature 303 * (Taken over from SendFile) 304 */ 305 $download = $event->data["download"]; 306 if ($download && $mime !== "image/svg+xml") { 307 header('Content-Disposition: attachment;' . rfc2231_encode( 308 'filename', PhpString::basename($originalFile)) . ';' 309 ); 310 } else { 311 header('Content-Disposition: inline;' . rfc2231_encode( 312 'filename', PhpString::basename($originalFile)) . ';' 313 ); 314 } 315 316 /** 317 * The vary header avoid caching 318 * Delete it 319 */ 320 self::deleteVaryHeader(); 321 322 /** 323 * Use x-sendfile header to pass the delivery to compatible web servers 324 * (Taken over from SendFile) 325 */ 326 http_sendfile($physicalFile); 327 328 /** 329 * Send the file 330 */ 331 $filePointer = @fopen($physicalFile, "rb"); 332 if ($filePointer) { 333 http_rangeRequest($filePointer, filesize($physicalFile), $mime); 334 } else { 335 http_status(500); 336 print "Could not read $physicalFile - bad permissions?"; 337 } 338 339 /** 340 * Stop the propagation 341 * Unfortunately, you can't stop the default ({@link sendFile()}) 342 * because the event in fetch.php does not allow it 343 * We exit only if not test 344 */ 345 $event->stopPropagation(); 346 PluginUtility::softExit("File Send"); 347 348 } 349 } 350 351 } 352 } 353 } 354 355 /** 356 * @param DokuPath $mediaPath 357 * @param Array $properties - the query properties 358 * @return string 359 */ 360 public static function getEtagValue(DokuPath $mediaPath, array $properties): string 361 { 362 $etagString = $mediaPath->getModifiedTime()->format('r'); 363 ksort($properties); 364 foreach ($properties as $key => $value) { 365 /** 366 * Media is already on the URL 367 * tok is just added when w and h are on the url 368 * Buster is the timestamp 369 */ 370 if (in_array($key, ["media","tok",CacheMedia::CACHE_BUSTER_KEY])) { 371 continue; 372 } 373 /** 374 * If empty means not used 375 */ 376 if(empty($value)){ 377 continue; 378 } 379 $etagString .= "$key=$value"; 380 } 381 return '"' . md5($etagString) . '"'; 382 } 383 384 385 /** 386 * Delete the Vary header 387 * @param Doku_Event $event 388 * @param $params 389 */ 390 public static function deleteVaryFromStaticGeneratedResources(Doku_Event $event, $params) 391 { 392 393 $script = $_SERVER["SCRIPT_NAME"]; 394 if (in_array($script, self::STATIC_SCRIPT_NAMES)) { 395 // To be extra sure, they must have a tseed 396 if (isset($_REQUEST["tseed"])) { 397 self::deleteVaryHeader(); 398 } 399 } 400 401 } 402 403 /** 404 * 405 * No Vary: Cookie 406 * Introduced at 407 * https://github.com/splitbrain/dokuwiki/issues/1594 408 * But cache problem at: 409 * https://github.com/splitbrain/dokuwiki/issues/2520 410 * 411 */ 412 public static function deleteVaryHeader(): void 413 { 414 if (PluginUtility::getConfValue(self::CONF_STATIC_CACHE_ENABLED, 1)) { 415 Http::removeHeaderIfPresent("Vary"); 416 } 417 } 418 419 420} 421