1<?php 2 3use ComboStrap\ExceptionCompile; 4use ComboStrap\ExceptionNotFound; 5use ComboStrap\ExecutionContext; 6use ComboStrap\FetcherRaster; 7use ComboStrap\FileSystems; 8use ComboStrap\Http; 9use ComboStrap\HttpResponse; 10use ComboStrap\HttpResponseStatus; 11use ComboStrap\Identity; 12use ComboStrap\IFetcher; 13use ComboStrap\LocalPath; 14use ComboStrap\LogUtility; 15use ComboStrap\Mime; 16use ComboStrap\Path; 17use ComboStrap\PluginUtility; 18use ComboStrap\SiteConfig; 19use ComboStrap\Web\Url; 20use ComboStrap\WikiPath; 21use dokuwiki\Utf8\PhpString; 22 23require_once(__DIR__ . '/../ComboStrap/PluginUtility.php'); 24 25/** 26 * Modify the serving of static resource via fetch.php 27 */ 28class action_plugin_combo_staticresource extends DokuWiki_Action_Plugin 29{ 30 31 32 /** 33 * https://www.ietf.org/rfc/rfc2616.txt 34 * To mark a response as "never expires," an origin server sends an Expires date approximately one year 35 * from the time the response is sent. 36 * HTTP/1.1 servers SHOULD NOT send Expires dates more than one year in the future. 37 * 38 * In seconds = 365*24*60*60 39 */ 40 const INFINITE_MAX_AGE = 31536000; 41 42 const CANONICAL = "cache"; 43 44 /** 45 * Enable an infinite cache on static resources (image, script, ...) with a {@link IFetcher::CACHE_BUSTER_KEY} 46 */ 47 public const CONF_STATIC_CACHE_ENABLED = "staticCacheEnabled"; 48 const NO_TRANSFORM = "no-transform"; 49 50 51 /** 52 * @param Doku_Event_Handler $controller 53 */ 54 function register(Doku_Event_Handler $controller) 55 { 56 57 /** 58 * Redirect the combo resources to the good file path 59 * https://www.dokuwiki.org/devel:event:fetch_media_status 60 */ 61 $controller->register_hook('FETCH_MEDIA_STATUS', 'BEFORE', $this, 'handleMediaStatus', array()); 62 63 /** 64 * Serve the image and static resources with HTTP cache control 65 * https://www.dokuwiki.org/devel:event:media_sendfile 66 */ 67 $controller->register_hook('MEDIA_SENDFILE', 'BEFORE', $this, 'handleSendFile', array()); 68 69 70 } 71 72 /** 73 * @param Doku_Event $event 74 * https://www.dokuwiki.org/devel:event:fetch_media_status 75 */ 76 function handleMediaStatus(Doku_Event $event, $params) 77 { 78 79 $drive = $_GET[WikiPath::DRIVE_ATTRIBUTE] ?? null; 80 $fetcher = $_GET[IFetcher::FETCHER_KEY] ?? null; 81 if ($drive === null && $fetcher === null) { 82 return; 83 } 84 if ($fetcher === FetcherRaster::CANONICAL) { 85 // not yet implemented 86 return; 87 } 88 89 /** 90 * Security 91 */ 92 if ($drive === WikiPath::CACHE_DRIVE) { 93 $event->data['download'] = false; 94 if (!Identity::isManager()) { 95 $event->data['status'] = HttpResponseStatus::NOT_AUTHORIZED; 96 return; 97 } 98 } 99 100 101 /** 102 * Add the extra attributes 103 */ 104 $fetchUrl = Url::createFromGetOrPostGlobalVariable(); 105 $executionContext = ExecutionContext::getActualOrCreateFromEnv(); 106 try { 107 108 $fetcher = $executionContext->createPathMainFetcherFromUrl($fetchUrl); 109 $fetchPath = $fetcher->getFetchPath(); 110 $event->data['file'] = $fetchPath->toAbsoluteId(); 111 $event->data['status'] = HttpResponseStatus::ALL_GOOD; 112 $mime = $fetcher->getMime(); 113 $event->data["mime"] = $mime->toString(); 114 /** 115 * TODO: set download as parameter of the fetch url 116 */ 117 if ($mime->isImage() || in_array($mime->toString(), [Mime::JAVASCRIPT, Mime::CSS])) { 118 $event->data['download'] = false; 119 } else { 120 $event->data['download'] = true; 121 } 122 $event->data['statusmessage'] = ''; 123 } catch (\Exception $e) { 124 125 $executionContext 126 ->response() 127 ->setException($e) 128 ->setStatusAndBodyFromException($e) 129 ->end(); 130 131 //$event->data['file'] = WikiPath::createComboResource("images:error-bad-format.svg")->toLocalPath()->toAbsolutePath()->toQualifiedId(); 132 $event->data['file'] = "error.json"; 133 $event->data['statusmessage'] = $e->getMessage(); 134 //$event->data['status'] = $httpResponse->getStatus(); 135 $event->data['mime'] = Mime::JSON; 136 137 138 } 139 140 } 141 142 function handleSendFile(Doku_Event $event, $params) 143 { 144 145 if (ExecutionContext::getActualOrCreateFromEnv()->response()->hasEnded()) { 146 // when there is an error for instance 147 return; 148 } 149 /** 150 * If there is no buster key, the infinite cache is off 151 */ 152 $busterKey = $_GET[IFetcher::CACHE_BUSTER_KEY]; 153 if ($busterKey === null) { 154 return; 155 } 156 157 /** 158 * The media to send 159 */ 160 $originalFile = $event->data["orig"]; // the original file 161 $physicalFile = $event->data["file"]; // the file modified or the file to send 162 if (empty($physicalFile)) { 163 $physicalFile = $originalFile; 164 } 165 $mediaToSend = LocalPath::createFromPathString($physicalFile); 166 if (!FileSystems::exists($mediaToSend)) { 167 if (PluginUtility::isDevOrTest()) { 168 LogUtility::internalError("The media ($mediaToSend) does not exist", self::CANONICAL); 169 } 170 return; 171 } 172 173 /** 174 * Combo Media 175 * (Static file from the combo resources are always taken over) 176 */ 177 $drive = $_GET[WikiPath::DRIVE_ATTRIBUTE] ?? null; 178 if ($drive === null) { 179 180 $confValue = SiteConfig::getConfValue(self::CONF_STATIC_CACHE_ENABLED, 1); 181 if (!$confValue) { 182 return; 183 } 184 185 try { 186 $dokuPath = $mediaToSend->toWikiPath(); 187 } catch (ExceptionCompile $e) { 188 // not a dokuwiki file ? 189 LogUtility::msg("Error: {$e->getMessage()}"); 190 return; 191 } 192 if (!$dokuPath->isPublic()) { 193 return; // Infinite static is only for public media 194 } 195 196 } 197 198 /** 199 * We take over the complete {@link sendFile()} function and exit 200 * 201 * in {@link sendFile()}, DokuWiki set the `Cache-Control` and 202 * may exit early / send a 304 (not modified) with the function {@link http_conditionalRequest()} 203 * Meaning that the AFTER event is never reached 204 * that we can't send a cache control as below 205 * header("Cache-Control: public, max-age=$infiniteMaxAge, s-maxage=$infiniteMaxAge"); 206 * 207 * We take the control over then 208 */ 209 210 211 /** 212 * The cache instructions 213 */ 214 $infiniteMaxAge = self::INFINITE_MAX_AGE; 215 $expires = time() + $infiniteMaxAge; 216 header('Expires: ' . gmdate("D, d M Y H:i:s", $expires) . ' GMT'); 217 $cacheControlDirective = ["public", "max-age=$infiniteMaxAge", "immutable"]; 218 try { 219 if ($mediaToSend->getExtension() === "js") { 220 // if a SRI is given and that a proxy is 221 // reducing javascript, it will not match 222 // no-transform will avoid that 223 $cacheControlDirective[] = self::NO_TRANSFORM; 224 } 225 } catch (ExceptionNotFound $e) { 226 LogUtility::warning("The media ($mediaToSend) does not have any extension."); 227 } 228 header("Cache-Control: " . implode(", ", $cacheControlDirective)); 229 Http::removeHeaderIfPresent("Pragma"); 230 231 $excutingContext = ExecutionContext::getActualOrCreateFromEnv(); 232 /** 233 * The Etag cache validator 234 * 235 * Dokuwiki {@link http_conditionalRequest()} uses only the datetime of 236 * the file but we need to add the parameters also because they 237 * are generated image 238 * 239 * Last-Modified is not needed for the same reason 240 * 241 */ 242 try { 243 $etag = self::getEtagValue($mediaToSend, $_REQUEST); 244 header("ETag: $etag"); 245 } catch (ExceptionNotFound $e) { 246 // internal error 247 $excutingContext->response() 248 ->setStatus(HttpResponseStatus::INTERNAL_ERROR) 249 ->setEvent($event) 250 ->setCanonical(self::CANONICAL) 251 ->setBodyAsJsonMessage("We were unable to get the etag because the media was not found. Error: {$e->getMessage()}") 252 ->end(); 253 return; 254 } 255 256 257 /** 258 * Conditional Request ? 259 * We don't check on HTTP_IF_MODIFIED_SINCE because this is useless 260 */ 261 if (isset($_SERVER['HTTP_IF_NONE_MATCH'])) { 262 $ifNoneMatch = stripslashes($_SERVER['HTTP_IF_NONE_MATCH']); 263 if ($ifNoneMatch && $ifNoneMatch === $etag) { 264 /** 265 * Don't add a body 266 */ 267 $excutingContext 268 ->response() 269 ->setStatus(HttpResponseStatus::NOT_MODIFIED) 270 ->setEvent($event) 271 ->setCanonical(self::CANONICAL) 272 ->end(); 273 return; 274 } 275 } 276 277 278 /** 279 * Download or display feature 280 * (Taken over from SendFile) 281 */ 282 try { 283 $mime = FileSystems::getMime($mediaToSend); 284 } catch (ExceptionNotFound $e) { 285 $excutingContext->response() 286 ->setStatus(HttpResponseStatus::INTERNAL_ERROR) 287 ->setEvent($event) 288 ->setCanonical(self::CANONICAL) 289 ->setBodyAsJsonMessage("Mime not found") 290 ->end(); 291 return; 292 } 293 $download = $event->data["download"]; 294 if ($download && $mime->toString() !== "image/svg+xml") { 295 header('Content-Disposition: attachment;' . rfc2231_encode( 296 'filename', PhpString::basename($originalFile)) . ';' 297 ); 298 } else { 299 header('Content-Disposition: inline;' . rfc2231_encode( 300 'filename', PhpString::basename($originalFile)) . ';' 301 ); 302 } 303 304 /** 305 * The vary header avoid caching 306 * Delete it 307 */ 308 action_plugin_combo_cache::deleteVaryHeader(); 309 310 /** 311 * Use x-sendfile header to pass the delivery to compatible web servers 312 * (Taken over from SendFile) 313 */ 314 http_sendfile($mediaToSend->toAbsolutePath()->toAbsoluteId()); 315 316 /** 317 * Send the file 318 */ 319 $filePointer = @fopen($mediaToSend->toAbsolutePath()->toAbsoluteId(), "rb"); 320 if ($filePointer) { 321 http_rangeRequest($filePointer, FileSystems::getSize($mediaToSend), $mime->toString()); 322 /** 323 * The {@link http_rangeRequest} exit not on test 324 * Trying to stop the dokuwiki processing of {@link sendFile()} 325 * Until {@link HttpResponse} can send resource 326 * TODO: integrate it in {@link HttpResponse} 327 */ 328 if (PluginUtility::isDevOrTest()) { 329 /** 330 * Add test info into the request 331 */ 332 $testRequest = TestRequest::getRunning(); 333 334 if ($testRequest !== null) { 335 $testRequest->addData(HttpResponse::EXIT_KEY, "File Send"); 336 } 337 if ($event !== null) { 338 $event->stopPropagation(); 339 $event->preventDefault(); 340 } 341 } 342 } else { 343 $excutingContext->response() 344 ->setStatus(HttpResponseStatus::INTERNAL_ERROR) 345 ->setBodyAsJsonMessage("Could not read $mediaToSend - bad permissions?") 346 ->end(); 347 } 348 349 } 350 351 /** 352 * @param Path $mediaFile 353 * @param Array $properties - the query properties 354 * @return string 355 * @throws ExceptionNotFound 356 */ 357 public 358 static function getEtagValue(Path $mediaFile, array $properties): string 359 { 360 clearstatcache(); 361 $etagString = FileSystems::getModifiedTime($mediaFile)->format('r'); 362 ksort($properties); 363 foreach ($properties as $key => $value) { 364 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", IFetcher::CACHE_BUSTER_KEY])) { 371 continue; 372 } 373 /** 374 * If empty means not used 375 */ 376 if (trim($value) === "") { 377 continue; 378 } 379 $etagString .= "$key=$value"; 380 } 381 return '"' . md5($etagString) . '"'; 382 } 383 384 385} 386