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