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