1c3437056SNickeau<?php 2c3437056SNickeau 304fd306cSNickeauuse ComboStrap\ExceptionCompile; 404fd306cSNickeauuse ComboStrap\ExceptionNotFound; 504fd306cSNickeauuse ComboStrap\ExecutionContext; 604fd306cSNickeauuse ComboStrap\FetcherRaster; 7c3437056SNickeauuse ComboStrap\FileSystems; 8c3437056SNickeauuse ComboStrap\Http; 9c3437056SNickeauuse ComboStrap\HttpResponse; 1004fd306cSNickeauuse ComboStrap\HttpResponseStatus; 114cadd4f8SNickeauuse ComboStrap\Identity; 1204fd306cSNickeauuse ComboStrap\IFetcher; 13c3437056SNickeauuse ComboStrap\LocalPath; 144cadd4f8SNickeauuse ComboStrap\LogUtility; 1504fd306cSNickeauuse ComboStrap\Mime; 16c3437056SNickeauuse ComboStrap\Path; 17c3437056SNickeauuse ComboStrap\PluginUtility; 18*ad79af66SNicouse ComboStrap\Site; 1904fd306cSNickeauuse ComboStrap\SiteConfig; 2004fd306cSNickeauuse ComboStrap\Web\Url; 21*ad79af66SNicouse ComboStrap\Web\UrlRewrite; 2204fd306cSNickeauuse ComboStrap\WikiPath; 23c3437056SNickeauuse dokuwiki\Utf8\PhpString; 24c3437056SNickeau 25c3437056SNickeaurequire_once(__DIR__ . '/../ComboStrap/PluginUtility.php'); 26c3437056SNickeau 27c3437056SNickeau/** 28c3437056SNickeau * Modify the serving of static resource via fetch.php 29c3437056SNickeau */ 30c3437056SNickeauclass action_plugin_combo_staticresource extends DokuWiki_Action_Plugin 31c3437056SNickeau{ 32c3437056SNickeau 33c3437056SNickeau 34c3437056SNickeau /** 35c3437056SNickeau * https://www.ietf.org/rfc/rfc2616.txt 36c3437056SNickeau * To mark a response as "never expires," an origin server sends an Expires date approximately one year 37c3437056SNickeau * from the time the response is sent. 38c3437056SNickeau * HTTP/1.1 servers SHOULD NOT send Expires dates more than one year in the future. 39c3437056SNickeau * 40c3437056SNickeau * In seconds = 365*24*60*60 41c3437056SNickeau */ 42c3437056SNickeau const INFINITE_MAX_AGE = 31536000; 43c3437056SNickeau 44c3437056SNickeau const CANONICAL = "cache"; 45c3437056SNickeau 46c3437056SNickeau /** 4704fd306cSNickeau * Enable an infinite cache on static resources (image, script, ...) with a {@link IFetcher::CACHE_BUSTER_KEY} 48c3437056SNickeau */ 49c3437056SNickeau public const CONF_STATIC_CACHE_ENABLED = "staticCacheEnabled"; 5004fd306cSNickeau const NO_TRANSFORM = "no-transform"; 51c3437056SNickeau 52c3437056SNickeau 53c3437056SNickeau /** 54c3437056SNickeau * @param Doku_Event_Handler $controller 55c3437056SNickeau */ 56c3437056SNickeau function register(Doku_Event_Handler $controller) 57c3437056SNickeau { 58c3437056SNickeau 59c3437056SNickeau /** 60c3437056SNickeau * Redirect the combo resources to the good file path 61c3437056SNickeau * https://www.dokuwiki.org/devel:event:fetch_media_status 62c3437056SNickeau */ 63c3437056SNickeau $controller->register_hook('FETCH_MEDIA_STATUS', 'BEFORE', $this, 'handleMediaStatus', array()); 64c3437056SNickeau 65c3437056SNickeau /** 66c3437056SNickeau * Serve the image and static resources with HTTP cache control 67c3437056SNickeau * https://www.dokuwiki.org/devel:event:media_sendfile 68c3437056SNickeau */ 69c3437056SNickeau $controller->register_hook('MEDIA_SENDFILE', 'BEFORE', $this, 'handleSendFile', array()); 70c3437056SNickeau 71c3437056SNickeau 72c3437056SNickeau } 73c3437056SNickeau 7404fd306cSNickeau /** 7504fd306cSNickeau * @param Doku_Event $event 7604fd306cSNickeau * https://www.dokuwiki.org/devel:event:fetch_media_status 7704fd306cSNickeau */ 78c3437056SNickeau function handleMediaStatus(Doku_Event $event, $params) 79c3437056SNickeau { 80c3437056SNickeau 8170bbd7f1Sgerardnico $drive = $_GET[WikiPath::DRIVE_ATTRIBUTE] ?? null; 8270bbd7f1Sgerardnico $fetcher = $_GET[IFetcher::FETCHER_KEY] ?? null; 8304fd306cSNickeau if ($drive === null && $fetcher === null) { 84c3437056SNickeau return; 85c3437056SNickeau } 8604fd306cSNickeau if ($fetcher === FetcherRaster::CANONICAL) { 8704fd306cSNickeau // not yet implemented 88c3437056SNickeau return; 89c3437056SNickeau } 9004fd306cSNickeau 9104fd306cSNickeau /** 9204fd306cSNickeau * Security 9304fd306cSNickeau */ 9404fd306cSNickeau if ($drive === WikiPath::CACHE_DRIVE) { 954cadd4f8SNickeau $event->data['download'] = false; 964cadd4f8SNickeau if (!Identity::isManager()) { 9704fd306cSNickeau $event->data['status'] = HttpResponseStatus::NOT_AUTHORIZED; 9804fd306cSNickeau return; 994cadd4f8SNickeau } 1004cadd4f8SNickeau } 101c3437056SNickeau 10204fd306cSNickeau 10304fd306cSNickeau /** 10404fd306cSNickeau * Add the extra attributes 10504fd306cSNickeau */ 10604fd306cSNickeau $fetchUrl = Url::createFromGetOrPostGlobalVariable(); 10704fd306cSNickeau $executionContext = ExecutionContext::getActualOrCreateFromEnv(); 10804fd306cSNickeau try { 10904fd306cSNickeau 11004fd306cSNickeau $fetcher = $executionContext->createPathMainFetcherFromUrl($fetchUrl); 11104fd306cSNickeau $fetchPath = $fetcher->getFetchPath(); 112*ad79af66SNico $filePath = $fetchPath->toAbsoluteId(); 113*ad79af66SNico /** 114*ad79af66SNico * Bug 115*ad79af66SNico * 116*ad79af66SNico * We have a bug with {@link WikiPath::toValidAbsolutePath} that uses {@link cleanID()}. 117*ad79af66SNico * `/` becomes `_` if the useSlash conf is not enabled with web server useRewrite 118*ad79af66SNico * and the file then does not exists. 119*ad79af66SNico * 120*ad79af66SNico * Furthermore, passing a file that does not exist, will break dokuwiki and returns a 500 121*ad79af66SNico */ 122*ad79af66SNico if (!file_exists($fetchPath)) { 123*ad79af66SNico $useRewrite = Site::getUrlRewrite(); 124*ad79af66SNico $useSlash = Site::getUseSlash(); 125*ad79af66SNico if($useRewrite == UrlRewrite::WEB_SERVER_REWRITE && !$useSlash){ 126*ad79af66SNico $executionContext 127*ad79af66SNico ->response() 128*ad79af66SNico ->setStatus(400) 129*ad79af66SNico ->setBodyAsJsonMessage("The `useSlash` configuration should be enabled when the `useRewrite` is `htaccess` (ie web server), otherwise the file is not found.") 130*ad79af66SNico ->end(); 131*ad79af66SNico } else { 132*ad79af66SNico $executionContext 133*ad79af66SNico ->response() 134*ad79af66SNico ->setStatus(404) 135*ad79af66SNico ->end(); 136*ad79af66SNico } 137*ad79af66SNico return; 138*ad79af66SNico } 139*ad79af66SNico $event->data['file'] = $filePath; 14004fd306cSNickeau $event->data['status'] = HttpResponseStatus::ALL_GOOD; 14104fd306cSNickeau $mime = $fetcher->getMime(); 14204fd306cSNickeau $event->data["mime"] = $mime->toString(); 14304fd306cSNickeau /** 14404fd306cSNickeau * TODO: set download as parameter of the fetch url 14504fd306cSNickeau */ 14604fd306cSNickeau if ($mime->isImage() || in_array($mime->toString(), [Mime::JAVASCRIPT, Mime::CSS])) { 14704fd306cSNickeau $event->data['download'] = false; 14804fd306cSNickeau } else { 14904fd306cSNickeau $event->data['download'] = true; 15004fd306cSNickeau } 15104fd306cSNickeau $event->data['statusmessage'] = ''; 15204fd306cSNickeau } catch (\Exception $e) { 15304fd306cSNickeau 15404fd306cSNickeau $executionContext 15504fd306cSNickeau ->response() 15604fd306cSNickeau ->setException($e) 15704fd306cSNickeau ->setStatusAndBodyFromException($e) 15804fd306cSNickeau ->end(); 15904fd306cSNickeau 16004fd306cSNickeau //$event->data['file'] = WikiPath::createComboResource("images:error-bad-format.svg")->toLocalPath()->toAbsolutePath()->toQualifiedId(); 16104fd306cSNickeau $event->data['file'] = "error.json"; 16204fd306cSNickeau $event->data['statusmessage'] = $e->getMessage(); 16304fd306cSNickeau //$event->data['status'] = $httpResponse->getStatus(); 16404fd306cSNickeau $event->data['mime'] = Mime::JSON; 16504fd306cSNickeau 16604fd306cSNickeau 16704fd306cSNickeau } 16804fd306cSNickeau 169c3437056SNickeau } 170c3437056SNickeau 171c3437056SNickeau function handleSendFile(Doku_Event $event, $params) 172c3437056SNickeau { 173c3437056SNickeau 17404fd306cSNickeau if (ExecutionContext::getActualOrCreateFromEnv()->response()->hasEnded()) { 17504fd306cSNickeau // when there is an error for instance 17604fd306cSNickeau return; 17704fd306cSNickeau } 178c3437056SNickeau /** 1794cadd4f8SNickeau * If there is no buster key, the infinite cache is off 180c3437056SNickeau */ 1810763546aSNico $busterKey = $_GET[IFetcher::CACHE_BUSTER_KEY] ?? null; 1824cadd4f8SNickeau if ($busterKey === null) { 1834cadd4f8SNickeau return; 1844cadd4f8SNickeau } 1854cadd4f8SNickeau 1864cadd4f8SNickeau /** 1874cadd4f8SNickeau * The media to send 1884cadd4f8SNickeau */ 1894cadd4f8SNickeau $originalFile = $event->data["orig"]; // the original file 19004fd306cSNickeau $physicalFile = $event->data["file"]; // the file modified or the file to send 1914cadd4f8SNickeau if (empty($physicalFile)) { 1924cadd4f8SNickeau $physicalFile = $originalFile; 1934cadd4f8SNickeau } 19404fd306cSNickeau $mediaToSend = LocalPath::createFromPathString($physicalFile); 1954cadd4f8SNickeau if (!FileSystems::exists($mediaToSend)) { 19604fd306cSNickeau if (PluginUtility::isDevOrTest()) { 19704fd306cSNickeau LogUtility::internalError("The media ($mediaToSend) does not exist", self::CANONICAL); 19804fd306cSNickeau } 1994cadd4f8SNickeau return; 2004cadd4f8SNickeau } 201c3437056SNickeau 202c3437056SNickeau /** 203c3437056SNickeau * Combo Media 204c3437056SNickeau * (Static file from the combo resources are always taken over) 205c3437056SNickeau */ 20670bbd7f1Sgerardnico $drive = $_GET[WikiPath::DRIVE_ATTRIBUTE] ?? null; 2074cadd4f8SNickeau if ($drive === null) { 208c3437056SNickeau 20904fd306cSNickeau $confValue = SiteConfig::getConfValue(self::CONF_STATIC_CACHE_ENABLED, 1); 2104cadd4f8SNickeau if (!$confValue) { 211c3437056SNickeau return; 212c3437056SNickeau } 213c3437056SNickeau 2144cadd4f8SNickeau try { 21504fd306cSNickeau $dokuPath = $mediaToSend->toWikiPath(); 21604fd306cSNickeau } catch (ExceptionCompile $e) { 2174cadd4f8SNickeau // not a dokuwiki file ? 2184cadd4f8SNickeau LogUtility::msg("Error: {$e->getMessage()}"); 2194cadd4f8SNickeau return; 2204cadd4f8SNickeau } 2214cadd4f8SNickeau if (!$dokuPath->isPublic()) { 2224cadd4f8SNickeau return; // Infinite static is only for public media 2234cadd4f8SNickeau } 2244cadd4f8SNickeau 2254cadd4f8SNickeau } 2264cadd4f8SNickeau 227c3437056SNickeau /** 228c3437056SNickeau * We take over the complete {@link sendFile()} function and exit 229c3437056SNickeau * 230c3437056SNickeau * in {@link sendFile()}, DokuWiki set the `Cache-Control` and 231c3437056SNickeau * may exit early / send a 304 (not modified) with the function {@link http_conditionalRequest()} 232c3437056SNickeau * Meaning that the AFTER event is never reached 233c3437056SNickeau * that we can't send a cache control as below 234c3437056SNickeau * header("Cache-Control: public, max-age=$infiniteMaxAge, s-maxage=$infiniteMaxAge"); 235c3437056SNickeau * 236c3437056SNickeau * We take the control over then 237c3437056SNickeau */ 238c3437056SNickeau 239c3437056SNickeau 240c3437056SNickeau /** 241c3437056SNickeau * The cache instructions 242c3437056SNickeau */ 243c3437056SNickeau $infiniteMaxAge = self::INFINITE_MAX_AGE; 244c3437056SNickeau $expires = time() + $infiniteMaxAge; 245c3437056SNickeau header('Expires: ' . gmdate("D, d M Y H:i:s", $expires) . ' GMT'); 246c3437056SNickeau $cacheControlDirective = ["public", "max-age=$infiniteMaxAge", "immutable"]; 24704fd306cSNickeau try { 248c3437056SNickeau if ($mediaToSend->getExtension() === "js") { 249c3437056SNickeau // if a SRI is given and that a proxy is 250c3437056SNickeau // reducing javascript, it will not match 251c3437056SNickeau // no-transform will avoid that 25204fd306cSNickeau $cacheControlDirective[] = self::NO_TRANSFORM; 25304fd306cSNickeau } 25404fd306cSNickeau } catch (ExceptionNotFound $e) { 25504fd306cSNickeau LogUtility::warning("The media ($mediaToSend) does not have any extension."); 256c3437056SNickeau } 257c3437056SNickeau header("Cache-Control: " . implode(", ", $cacheControlDirective)); 258c3437056SNickeau Http::removeHeaderIfPresent("Pragma"); 259c3437056SNickeau 26004fd306cSNickeau $excutingContext = ExecutionContext::getActualOrCreateFromEnv(); 261c3437056SNickeau /** 262c3437056SNickeau * The Etag cache validator 263c3437056SNickeau * 264c3437056SNickeau * Dokuwiki {@link http_conditionalRequest()} uses only the datetime of 265c3437056SNickeau * the file but we need to add the parameters also because they 266c3437056SNickeau * are generated image 267c3437056SNickeau * 268c3437056SNickeau * Last-Modified is not needed for the same reason 269c3437056SNickeau * 270c3437056SNickeau */ 27104fd306cSNickeau try { 272c3437056SNickeau $etag = self::getEtagValue($mediaToSend, $_REQUEST); 273c3437056SNickeau header("ETag: $etag"); 27404fd306cSNickeau } catch (ExceptionNotFound $e) { 27504fd306cSNickeau // internal error 27604fd306cSNickeau $excutingContext->response() 27704fd306cSNickeau ->setStatus(HttpResponseStatus::INTERNAL_ERROR) 27804fd306cSNickeau ->setEvent($event) 27904fd306cSNickeau ->setCanonical(self::CANONICAL) 28004fd306cSNickeau ->setBodyAsJsonMessage("We were unable to get the etag because the media was not found. Error: {$e->getMessage()}") 28104fd306cSNickeau ->end(); 28204fd306cSNickeau return; 28304fd306cSNickeau } 28404fd306cSNickeau 285c3437056SNickeau 286c3437056SNickeau /** 287c3437056SNickeau * Conditional Request ? 288c3437056SNickeau * We don't check on HTTP_IF_MODIFIED_SINCE because this is useless 289c3437056SNickeau */ 290c3437056SNickeau if (isset($_SERVER['HTTP_IF_NONE_MATCH'])) { 291c3437056SNickeau $ifNoneMatch = stripslashes($_SERVER['HTTP_IF_NONE_MATCH']); 292c3437056SNickeau if ($ifNoneMatch && $ifNoneMatch === $etag) { 29334c746fbSgerardnico /** 29434c746fbSgerardnico * Don't add a body 29534c746fbSgerardnico */ 29604fd306cSNickeau $excutingContext 29704fd306cSNickeau ->response() 29804fd306cSNickeau ->setStatus(HttpResponseStatus::NOT_MODIFIED) 299c3437056SNickeau ->setEvent($event) 300c3437056SNickeau ->setCanonical(self::CANONICAL) 30104fd306cSNickeau ->end(); 302c3437056SNickeau return; 303c3437056SNickeau } 304c3437056SNickeau } 305c3437056SNickeau 306c3437056SNickeau 307c3437056SNickeau /** 308c3437056SNickeau * Download or display feature 309c3437056SNickeau * (Taken over from SendFile) 310c3437056SNickeau */ 31104fd306cSNickeau try { 31204fd306cSNickeau $mime = FileSystems::getMime($mediaToSend); 31304fd306cSNickeau } catch (ExceptionNotFound $e) { 31404fd306cSNickeau $excutingContext->response() 31504fd306cSNickeau ->setStatus(HttpResponseStatus::INTERNAL_ERROR) 31604fd306cSNickeau ->setEvent($event) 31704fd306cSNickeau ->setCanonical(self::CANONICAL) 31804fd306cSNickeau ->setBodyAsJsonMessage("Mime not found") 31904fd306cSNickeau ->end(); 32004fd306cSNickeau return; 32104fd306cSNickeau } 322c3437056SNickeau $download = $event->data["download"]; 323c3437056SNickeau if ($download && $mime->toString() !== "image/svg+xml") { 324c3437056SNickeau header('Content-Disposition: attachment;' . rfc2231_encode( 325c3437056SNickeau 'filename', PhpString::basename($originalFile)) . ';' 326c3437056SNickeau ); 327c3437056SNickeau } else { 328c3437056SNickeau header('Content-Disposition: inline;' . rfc2231_encode( 329c3437056SNickeau 'filename', PhpString::basename($originalFile)) . ';' 330c3437056SNickeau ); 331c3437056SNickeau } 332c3437056SNickeau 333c3437056SNickeau /** 334c3437056SNickeau * The vary header avoid caching 335c3437056SNickeau * Delete it 336c3437056SNickeau */ 337c3437056SNickeau action_plugin_combo_cache::deleteVaryHeader(); 338c3437056SNickeau 339c3437056SNickeau /** 340c3437056SNickeau * Use x-sendfile header to pass the delivery to compatible web servers 341c3437056SNickeau * (Taken over from SendFile) 342c3437056SNickeau */ 34304fd306cSNickeau http_sendfile($mediaToSend->toAbsolutePath()->toAbsoluteId()); 344c3437056SNickeau 345c3437056SNickeau /** 346c3437056SNickeau * Send the file 347c3437056SNickeau */ 34804fd306cSNickeau $filePointer = @fopen($mediaToSend->toAbsolutePath()->toAbsoluteId(), "rb"); 349c3437056SNickeau if ($filePointer) { 350c3437056SNickeau http_rangeRequest($filePointer, FileSystems::getSize($mediaToSend), $mime->toString()); 351c3437056SNickeau /** 352c3437056SNickeau * The {@link http_rangeRequest} exit not on test 353c3437056SNickeau * Trying to stop the dokuwiki processing of {@link sendFile()} 354c3437056SNickeau * Until {@link HttpResponse} can send resource 355c3437056SNickeau * TODO: integrate it in {@link HttpResponse} 356c3437056SNickeau */ 357c3437056SNickeau if (PluginUtility::isDevOrTest()) { 358c3437056SNickeau /** 359c3437056SNickeau * Add test info into the request 360c3437056SNickeau */ 361c3437056SNickeau $testRequest = TestRequest::getRunning(); 362c3437056SNickeau 363c3437056SNickeau if ($testRequest !== null) { 364c3437056SNickeau $testRequest->addData(HttpResponse::EXIT_KEY, "File Send"); 365c3437056SNickeau } 366c3437056SNickeau if ($event !== null) { 367c3437056SNickeau $event->stopPropagation(); 368c3437056SNickeau $event->preventDefault(); 369c3437056SNickeau } 370c3437056SNickeau } 371c3437056SNickeau } else { 37204fd306cSNickeau $excutingContext->response() 37304fd306cSNickeau ->setStatus(HttpResponseStatus::INTERNAL_ERROR) 37404fd306cSNickeau ->setBodyAsJsonMessage("Could not read $mediaToSend - bad permissions?") 37504fd306cSNickeau ->end(); 376c3437056SNickeau } 377c3437056SNickeau 378c3437056SNickeau } 379c3437056SNickeau 380c3437056SNickeau /** 381c3437056SNickeau * @param Path $mediaFile 382c3437056SNickeau * @param Array $properties - the query properties 383c3437056SNickeau * @return string 38404fd306cSNickeau * @throws ExceptionNotFound 385c3437056SNickeau */ 386c3437056SNickeau public 387c3437056SNickeau static function getEtagValue(Path $mediaFile, array $properties): string 388c3437056SNickeau { 38904fd306cSNickeau clearstatcache(); 390c3437056SNickeau $etagString = FileSystems::getModifiedTime($mediaFile)->format('r'); 391c3437056SNickeau ksort($properties); 392c3437056SNickeau foreach ($properties as $key => $value) { 39304fd306cSNickeau 394c3437056SNickeau /** 395c3437056SNickeau * Media is already on the URL 396c3437056SNickeau * tok is just added when w and h are on the url 397c3437056SNickeau * Buster is the timestamp 398c3437056SNickeau */ 39904fd306cSNickeau if (in_array($key, ["media", "tok", IFetcher::CACHE_BUSTER_KEY])) { 400c3437056SNickeau continue; 401c3437056SNickeau } 402c3437056SNickeau /** 403c3437056SNickeau * If empty means not used 404c3437056SNickeau */ 40504fd306cSNickeau if (trim($value) === "") { 406c3437056SNickeau continue; 407c3437056SNickeau } 408c3437056SNickeau $etagString .= "$key=$value"; 409c3437056SNickeau } 410c3437056SNickeau return '"' . md5($etagString) . '"'; 411c3437056SNickeau } 412c3437056SNickeau 413c3437056SNickeau 414c3437056SNickeau} 415