1<?php 2 3use ComboStrap\CacheMedia; 4use ComboStrap\DokuPath; 5use ComboStrap\FileSystems; 6use ComboStrap\Http; 7use ComboStrap\HttpResponse; 8use ComboStrap\LocalPath; 9use ComboStrap\Path; 10use ComboStrap\PluginUtility; 11use dokuwiki\Utf8\PhpString; 12 13require_once(__DIR__ . '/../ComboStrap/PluginUtility.php'); 14 15/** 16 * Modify the serving of static resource via fetch.php 17 */ 18class action_plugin_combo_staticresource extends DokuWiki_Action_Plugin 19{ 20 21 22 /** 23 * https://www.ietf.org/rfc/rfc2616.txt 24 * To mark a response as "never expires," an origin server sends an Expires date approximately one year 25 * from the time the response is sent. 26 * HTTP/1.1 servers SHOULD NOT send Expires dates more than one year in the future. 27 * 28 * In seconds = 365*24*60*60 29 */ 30 const INFINITE_MAX_AGE = 31536000; 31 32 const CANONICAL = "cache"; 33 34 /** 35 * Enable an infinite cache on static resources (image, script, ...) with a {@link CacheMedia::CACHE_BUSTER_KEY} 36 */ 37 public const CONF_STATIC_CACHE_ENABLED = "staticCacheEnabled"; 38 39 40 /** 41 * @param Doku_Event_Handler $controller 42 */ 43 function register(Doku_Event_Handler $controller) 44 { 45 46 /** 47 * Redirect the combo resources to the good file path 48 * https://www.dokuwiki.org/devel:event:fetch_media_status 49 */ 50 $controller->register_hook('FETCH_MEDIA_STATUS', 'BEFORE', $this, 'handleMediaStatus', array()); 51 52 /** 53 * Serve the image and static resources with HTTP cache control 54 * https://www.dokuwiki.org/devel:event:media_sendfile 55 */ 56 $controller->register_hook('MEDIA_SENDFILE', 'BEFORE', $this, 'handleSendFile', array()); 57 58 59 } 60 61 62 function handleMediaStatus(Doku_Event $event, $params) 63 { 64 65 if (!isset($_GET[DokuPath::WIKI_FS_TYPE])) { 66 return; 67 } 68 $type = $_GET[DokuPath::WIKI_FS_TYPE]; 69 if ($type !== DokuPath::RESOURCE_TYPE) { 70 // The other resources have ACL 71 // and this endpoint is normally only for 72 $event->data['status'] = HttpResponse::STATUS_NOT_AUTHORIZED; 73 return; 74 } 75 $mediaId = $event->data['media']; 76 $mediaPath = DokuPath::createDokuPath($mediaId, $type); 77 $event->data['file'] = $mediaPath->toLocalPath()->toAbsolutePath()->toString(); 78 if (FileSystems::exists($mediaPath)) { 79 $event->data['status'] = HttpResponse::STATUS_ALL_GOOD; 80 $event->data['statusmessage'] = ''; 81 $event->data['mime'] = $mediaPath->getMime(); 82 } 83 84 } 85 86 function handleSendFile(Doku_Event $event, $params) 87 { 88 89 $mediaId = $event->data["media"]; 90 91 /** 92 * Do we send this file 93 */ 94 $isStaticFileManaged = false; 95 96 /** 97 * Combo Media 98 * (Static file from the combo resources are always taken over) 99 */ 100 if (isset($_GET[DokuPath::WIKI_FS_TYPE])) { 101 102 $isStaticFileManaged = $_GET[DokuPath::WIKI_FS_TYPE] === DokuPath::RESOURCE_TYPE; 103 104 } 105 106 /** 107 * DokuWiki Resource media 108 */ 109 if (!$isStaticFileManaged) { 110 111 /** 112 * If there is the buster key, the infinite cache is on 113 */ 114 if (isset($_GET[CacheMedia::CACHE_BUSTER_KEY])) { 115 116 /** 117 * To avoid buggy code, we check that the value is not empty 118 */ 119 $cacheKey = $_GET[CacheMedia::CACHE_BUSTER_KEY]; 120 if (!empty($cacheKey)) { 121 122 if (PluginUtility::getConfValue(self::CONF_STATIC_CACHE_ENABLED, 1)) { 123 124 $dokuPath = DokuPath::createMediaPathFromId($mediaId); 125 if ($dokuPath->isPublic()) { 126 /** 127 * Only for public media 128 */ 129 $isStaticFileManaged = true; 130 } 131 132 } 133 } 134 } 135 } 136 137 if (!$isStaticFileManaged) { 138 return; 139 } 140 141 /** 142 * We take over the complete {@link sendFile()} function and exit 143 * 144 * in {@link sendFile()}, DokuWiki set the `Cache-Control` and 145 * may exit early / send a 304 (not modified) with the function {@link http_conditionalRequest()} 146 * Meaning that the AFTER event is never reached 147 * that we can't send a cache control as below 148 * header("Cache-Control: public, max-age=$infiniteMaxAge, s-maxage=$infiniteMaxAge"); 149 * 150 * We take the control over then 151 */ 152 153 /** 154 * Send the file 155 */ 156 $originalFile = $event->data["orig"]; // the original file 157 $physicalFile = $event->data["file"]; // the file modified 158 if (empty($physicalFile)) { 159 $physicalFile = $originalFile; 160 } 161 $mediaToSend = LocalPath::createFromPath($physicalFile); 162 163 164 /** 165 * The cache instructions 166 */ 167 $infiniteMaxAge = self::INFINITE_MAX_AGE; 168 $expires = time() + $infiniteMaxAge; 169 header('Expires: ' . gmdate("D, d M Y H:i:s", $expires) . ' GMT'); 170 $cacheControlDirective = ["public", "max-age=$infiniteMaxAge", "immutable"]; 171 if ($mediaToSend->getExtension() === "js") { 172 // if a SRI is given and that a proxy is 173 // reducing javascript, it will not match 174 // no-transform will avoid that 175 $cacheControlDirective[] = "no-transform"; 176 } 177 header("Cache-Control: " . implode(", ", $cacheControlDirective)); 178 Http::removeHeaderIfPresent("Pragma"); 179 180 /** 181 * The Etag cache validator 182 * 183 * Dokuwiki {@link http_conditionalRequest()} uses only the datetime of 184 * the file but we need to add the parameters also because they 185 * are generated image 186 * 187 * Last-Modified is not needed for the same reason 188 * 189 */ 190 $etag = self::getEtagValue($mediaToSend, $_REQUEST); 191 header("ETag: $etag"); 192 193 /** 194 * Conditional Request ? 195 * We don't check on HTTP_IF_MODIFIED_SINCE because this is useless 196 */ 197 if (isset($_SERVER['HTTP_IF_NONE_MATCH'])) { 198 $ifNoneMatch = stripslashes($_SERVER['HTTP_IF_NONE_MATCH']); 199 if ($ifNoneMatch && $ifNoneMatch === $etag) { 200 201 HttpResponse::create(HttpResponse::STATUS_NOT_MODIFIED) 202 ->setEvent($event) 203 ->setCanonical(self::CANONICAL) 204 ->sendMessage("File not modified"); 205 return; 206 } 207 } 208 209 210 /** 211 * Download or display feature 212 * (Taken over from SendFile) 213 */ 214 $mime = $mediaToSend->getMime(); 215 $download = $event->data["download"]; 216 if ($download && $mime->toString() !== "image/svg+xml") { 217 header('Content-Disposition: attachment;' . rfc2231_encode( 218 'filename', PhpString::basename($originalFile)) . ';' 219 ); 220 } else { 221 header('Content-Disposition: inline;' . rfc2231_encode( 222 'filename', PhpString::basename($originalFile)) . ';' 223 ); 224 } 225 226 /** 227 * The vary header avoid caching 228 * Delete it 229 */ 230 action_plugin_combo_cache::deleteVaryHeader(); 231 232 /** 233 * Use x-sendfile header to pass the delivery to compatible web servers 234 * (Taken over from SendFile) 235 */ 236 http_sendfile($mediaToSend->toAbsolutePath()->toString()); 237 238 /** 239 * Send the file 240 */ 241 $filePointer = @fopen($mediaToSend->toAbsolutePath()->toString(), "rb"); 242 if ($filePointer) { 243 http_rangeRequest($filePointer, FileSystems::getSize($mediaToSend), $mime->toString()); 244 /** 245 * The {@link http_rangeRequest} exit not on test 246 * Trying to stop the dokuwiki processing of {@link sendFile()} 247 * Until {@link HttpResponse} can send resource 248 * TODO: integrate it in {@link HttpResponse} 249 */ 250 if(PluginUtility::isDevOrTest()) { 251 /** 252 * Add test info into the request 253 */ 254 $testRequest = TestRequest::getRunning(); 255 256 if ($testRequest !== null) { 257 $testRequest->addData(HttpResponse::EXIT_KEY, "File Send"); 258 } 259 if ($event !== null) { 260 $event->stopPropagation(); 261 $event->preventDefault(); 262 } 263 } 264 } else { 265 HttpResponse::create(HttpResponse::STATUS_INTERNAL_ERROR) 266 ->sendMessage("Could not read $mediaToSend - bad permissions?"); 267 } 268 269 } 270 271 /** 272 * @param Path $mediaFile 273 * @param Array $properties - the query properties 274 * @return string 275 */ 276 public 277 static function getEtagValue(Path $mediaFile, array $properties): string 278 { 279 $etagString = FileSystems::getModifiedTime($mediaFile)->format('r'); 280 ksort($properties); 281 foreach ($properties as $key => $value) { 282 /** 283 * Media is already on the URL 284 * tok is just added when w and h are on the url 285 * Buster is the timestamp 286 */ 287 if (in_array($key, ["media", "tok", CacheMedia::CACHE_BUSTER_KEY])) { 288 continue; 289 } 290 /** 291 * If empty means not used 292 */ 293 if (empty($value)) { 294 continue; 295 } 296 $etagString .= "$key=$value"; 297 } 298 return '"' . md5($etagString) . '"'; 299 } 300 301 302} 303