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; 1804fd306cSNickeauuse ComboStrap\SiteConfig; 1904fd306cSNickeauuse ComboStrap\Web\Url; 2004fd306cSNickeauuse ComboStrap\WikiPath; 21c3437056SNickeauuse dokuwiki\Utf8\PhpString; 22c3437056SNickeau 23c3437056SNickeaurequire_once(__DIR__ . '/../ComboStrap/PluginUtility.php'); 24c3437056SNickeau 25c3437056SNickeau/** 26c3437056SNickeau * Modify the serving of static resource via fetch.php 27c3437056SNickeau */ 28c3437056SNickeauclass action_plugin_combo_staticresource extends DokuWiki_Action_Plugin 29c3437056SNickeau{ 30c3437056SNickeau 31c3437056SNickeau 32c3437056SNickeau /** 33c3437056SNickeau * https://www.ietf.org/rfc/rfc2616.txt 34c3437056SNickeau * To mark a response as "never expires," an origin server sends an Expires date approximately one year 35c3437056SNickeau * from the time the response is sent. 36c3437056SNickeau * HTTP/1.1 servers SHOULD NOT send Expires dates more than one year in the future. 37c3437056SNickeau * 38c3437056SNickeau * In seconds = 365*24*60*60 39c3437056SNickeau */ 40c3437056SNickeau const INFINITE_MAX_AGE = 31536000; 41c3437056SNickeau 42c3437056SNickeau const CANONICAL = "cache"; 43c3437056SNickeau 44c3437056SNickeau /** 4504fd306cSNickeau * Enable an infinite cache on static resources (image, script, ...) with a {@link IFetcher::CACHE_BUSTER_KEY} 46c3437056SNickeau */ 47c3437056SNickeau public const CONF_STATIC_CACHE_ENABLED = "staticCacheEnabled"; 4804fd306cSNickeau const NO_TRANSFORM = "no-transform"; 49c3437056SNickeau 50c3437056SNickeau 51c3437056SNickeau /** 52c3437056SNickeau * @param Doku_Event_Handler $controller 53c3437056SNickeau */ 54c3437056SNickeau function register(Doku_Event_Handler $controller) 55c3437056SNickeau { 56c3437056SNickeau 57c3437056SNickeau /** 58c3437056SNickeau * Redirect the combo resources to the good file path 59c3437056SNickeau * https://www.dokuwiki.org/devel:event:fetch_media_status 60c3437056SNickeau */ 61c3437056SNickeau $controller->register_hook('FETCH_MEDIA_STATUS', 'BEFORE', $this, 'handleMediaStatus', array()); 62c3437056SNickeau 63c3437056SNickeau /** 64c3437056SNickeau * Serve the image and static resources with HTTP cache control 65c3437056SNickeau * https://www.dokuwiki.org/devel:event:media_sendfile 66c3437056SNickeau */ 67c3437056SNickeau $controller->register_hook('MEDIA_SENDFILE', 'BEFORE', $this, 'handleSendFile', array()); 68c3437056SNickeau 69c3437056SNickeau 70c3437056SNickeau } 71c3437056SNickeau 7204fd306cSNickeau /** 7304fd306cSNickeau * @param Doku_Event $event 7404fd306cSNickeau * https://www.dokuwiki.org/devel:event:fetch_media_status 7504fd306cSNickeau */ 76c3437056SNickeau function handleMediaStatus(Doku_Event $event, $params) 77c3437056SNickeau { 78c3437056SNickeau 79*70bbd7f1Sgerardnico $drive = $_GET[WikiPath::DRIVE_ATTRIBUTE] ?? null; 80*70bbd7f1Sgerardnico $fetcher = $_GET[IFetcher::FETCHER_KEY] ?? null; 8104fd306cSNickeau if ($drive === null && $fetcher === null) { 82c3437056SNickeau return; 83c3437056SNickeau } 8404fd306cSNickeau if ($fetcher === FetcherRaster::CANONICAL) { 8504fd306cSNickeau // not yet implemented 86c3437056SNickeau return; 87c3437056SNickeau } 8804fd306cSNickeau 8904fd306cSNickeau /** 9004fd306cSNickeau * Security 9104fd306cSNickeau */ 9204fd306cSNickeau if ($drive === WikiPath::CACHE_DRIVE) { 934cadd4f8SNickeau $event->data['download'] = false; 944cadd4f8SNickeau if (!Identity::isManager()) { 9504fd306cSNickeau $event->data['status'] = HttpResponseStatus::NOT_AUTHORIZED; 9604fd306cSNickeau return; 974cadd4f8SNickeau } 984cadd4f8SNickeau } 99c3437056SNickeau 10004fd306cSNickeau 10104fd306cSNickeau /** 10204fd306cSNickeau * Add the extra attributes 10304fd306cSNickeau */ 10404fd306cSNickeau $fetchUrl = Url::createFromGetOrPostGlobalVariable(); 10504fd306cSNickeau $executionContext = ExecutionContext::getActualOrCreateFromEnv(); 10604fd306cSNickeau try { 10704fd306cSNickeau 10804fd306cSNickeau $fetcher = $executionContext->createPathMainFetcherFromUrl($fetchUrl); 10904fd306cSNickeau $fetchPath = $fetcher->getFetchPath(); 11004fd306cSNickeau $event->data['file'] = $fetchPath->toAbsoluteId(); 11104fd306cSNickeau $event->data['status'] = HttpResponseStatus::ALL_GOOD; 11204fd306cSNickeau $mime = $fetcher->getMime(); 11304fd306cSNickeau $event->data["mime"] = $mime->toString(); 11404fd306cSNickeau /** 11504fd306cSNickeau * TODO: set download as parameter of the fetch url 11604fd306cSNickeau */ 11704fd306cSNickeau if ($mime->isImage() || in_array($mime->toString(), [Mime::JAVASCRIPT, Mime::CSS])) { 11804fd306cSNickeau $event->data['download'] = false; 11904fd306cSNickeau } else { 12004fd306cSNickeau $event->data['download'] = true; 12104fd306cSNickeau } 12204fd306cSNickeau $event->data['statusmessage'] = ''; 12304fd306cSNickeau } catch (\Exception $e) { 12404fd306cSNickeau 12504fd306cSNickeau $executionContext 12604fd306cSNickeau ->response() 12704fd306cSNickeau ->setException($e) 12804fd306cSNickeau ->setStatusAndBodyFromException($e) 12904fd306cSNickeau ->end(); 13004fd306cSNickeau 13104fd306cSNickeau //$event->data['file'] = WikiPath::createComboResource("images:error-bad-format.svg")->toLocalPath()->toAbsolutePath()->toQualifiedId(); 13204fd306cSNickeau $event->data['file'] = "error.json"; 13304fd306cSNickeau $event->data['statusmessage'] = $e->getMessage(); 13404fd306cSNickeau //$event->data['status'] = $httpResponse->getStatus(); 13504fd306cSNickeau $event->data['mime'] = Mime::JSON; 13604fd306cSNickeau 13704fd306cSNickeau 13804fd306cSNickeau } 13904fd306cSNickeau 140c3437056SNickeau } 141c3437056SNickeau 142c3437056SNickeau function handleSendFile(Doku_Event $event, $params) 143c3437056SNickeau { 144c3437056SNickeau 14504fd306cSNickeau if (ExecutionContext::getActualOrCreateFromEnv()->response()->hasEnded()) { 14604fd306cSNickeau // when there is an error for instance 14704fd306cSNickeau return; 14804fd306cSNickeau } 149c3437056SNickeau /** 1504cadd4f8SNickeau * If there is no buster key, the infinite cache is off 151c3437056SNickeau */ 15204fd306cSNickeau $busterKey = $_GET[IFetcher::CACHE_BUSTER_KEY]; 1534cadd4f8SNickeau if ($busterKey === null) { 1544cadd4f8SNickeau return; 1554cadd4f8SNickeau } 1564cadd4f8SNickeau 1574cadd4f8SNickeau /** 1584cadd4f8SNickeau * The media to send 1594cadd4f8SNickeau */ 1604cadd4f8SNickeau $originalFile = $event->data["orig"]; // the original file 16104fd306cSNickeau $physicalFile = $event->data["file"]; // the file modified or the file to send 1624cadd4f8SNickeau if (empty($physicalFile)) { 1634cadd4f8SNickeau $physicalFile = $originalFile; 1644cadd4f8SNickeau } 16504fd306cSNickeau $mediaToSend = LocalPath::createFromPathString($physicalFile); 1664cadd4f8SNickeau if (!FileSystems::exists($mediaToSend)) { 16704fd306cSNickeau if (PluginUtility::isDevOrTest()) { 16804fd306cSNickeau LogUtility::internalError("The media ($mediaToSend) does not exist", self::CANONICAL); 16904fd306cSNickeau } 1704cadd4f8SNickeau return; 1714cadd4f8SNickeau } 172c3437056SNickeau 173c3437056SNickeau /** 174c3437056SNickeau * Combo Media 175c3437056SNickeau * (Static file from the combo resources are always taken over) 176c3437056SNickeau */ 177*70bbd7f1Sgerardnico $drive = $_GET[WikiPath::DRIVE_ATTRIBUTE] ?? null; 1784cadd4f8SNickeau if ($drive === null) { 179c3437056SNickeau 18004fd306cSNickeau $confValue = SiteConfig::getConfValue(self::CONF_STATIC_CACHE_ENABLED, 1); 1814cadd4f8SNickeau if (!$confValue) { 182c3437056SNickeau return; 183c3437056SNickeau } 184c3437056SNickeau 1854cadd4f8SNickeau try { 18604fd306cSNickeau $dokuPath = $mediaToSend->toWikiPath(); 18704fd306cSNickeau } catch (ExceptionCompile $e) { 1884cadd4f8SNickeau // not a dokuwiki file ? 1894cadd4f8SNickeau LogUtility::msg("Error: {$e->getMessage()}"); 1904cadd4f8SNickeau return; 1914cadd4f8SNickeau } 1924cadd4f8SNickeau if (!$dokuPath->isPublic()) { 1934cadd4f8SNickeau return; // Infinite static is only for public media 1944cadd4f8SNickeau } 1954cadd4f8SNickeau 1964cadd4f8SNickeau } 1974cadd4f8SNickeau 198c3437056SNickeau /** 199c3437056SNickeau * We take over the complete {@link sendFile()} function and exit 200c3437056SNickeau * 201c3437056SNickeau * in {@link sendFile()}, DokuWiki set the `Cache-Control` and 202c3437056SNickeau * may exit early / send a 304 (not modified) with the function {@link http_conditionalRequest()} 203c3437056SNickeau * Meaning that the AFTER event is never reached 204c3437056SNickeau * that we can't send a cache control as below 205c3437056SNickeau * header("Cache-Control: public, max-age=$infiniteMaxAge, s-maxage=$infiniteMaxAge"); 206c3437056SNickeau * 207c3437056SNickeau * We take the control over then 208c3437056SNickeau */ 209c3437056SNickeau 210c3437056SNickeau 211c3437056SNickeau /** 212c3437056SNickeau * The cache instructions 213c3437056SNickeau */ 214c3437056SNickeau $infiniteMaxAge = self::INFINITE_MAX_AGE; 215c3437056SNickeau $expires = time() + $infiniteMaxAge; 216c3437056SNickeau header('Expires: ' . gmdate("D, d M Y H:i:s", $expires) . ' GMT'); 217c3437056SNickeau $cacheControlDirective = ["public", "max-age=$infiniteMaxAge", "immutable"]; 21804fd306cSNickeau try { 219c3437056SNickeau if ($mediaToSend->getExtension() === "js") { 220c3437056SNickeau // if a SRI is given and that a proxy is 221c3437056SNickeau // reducing javascript, it will not match 222c3437056SNickeau // no-transform will avoid that 22304fd306cSNickeau $cacheControlDirective[] = self::NO_TRANSFORM; 22404fd306cSNickeau } 22504fd306cSNickeau } catch (ExceptionNotFound $e) { 22604fd306cSNickeau LogUtility::warning("The media ($mediaToSend) does not have any extension."); 227c3437056SNickeau } 228c3437056SNickeau header("Cache-Control: " . implode(", ", $cacheControlDirective)); 229c3437056SNickeau Http::removeHeaderIfPresent("Pragma"); 230c3437056SNickeau 23104fd306cSNickeau $excutingContext = ExecutionContext::getActualOrCreateFromEnv(); 232c3437056SNickeau /** 233c3437056SNickeau * The Etag cache validator 234c3437056SNickeau * 235c3437056SNickeau * Dokuwiki {@link http_conditionalRequest()} uses only the datetime of 236c3437056SNickeau * the file but we need to add the parameters also because they 237c3437056SNickeau * are generated image 238c3437056SNickeau * 239c3437056SNickeau * Last-Modified is not needed for the same reason 240c3437056SNickeau * 241c3437056SNickeau */ 24204fd306cSNickeau try { 243c3437056SNickeau $etag = self::getEtagValue($mediaToSend, $_REQUEST); 244c3437056SNickeau header("ETag: $etag"); 24504fd306cSNickeau } catch (ExceptionNotFound $e) { 24604fd306cSNickeau // internal error 24704fd306cSNickeau $excutingContext->response() 24804fd306cSNickeau ->setStatus(HttpResponseStatus::INTERNAL_ERROR) 24904fd306cSNickeau ->setEvent($event) 25004fd306cSNickeau ->setCanonical(self::CANONICAL) 25104fd306cSNickeau ->setBodyAsJsonMessage("We were unable to get the etag because the media was not found. Error: {$e->getMessage()}") 25204fd306cSNickeau ->end(); 25304fd306cSNickeau return; 25404fd306cSNickeau } 25504fd306cSNickeau 256c3437056SNickeau 257c3437056SNickeau /** 258c3437056SNickeau * Conditional Request ? 259c3437056SNickeau * We don't check on HTTP_IF_MODIFIED_SINCE because this is useless 260c3437056SNickeau */ 261c3437056SNickeau if (isset($_SERVER['HTTP_IF_NONE_MATCH'])) { 262c3437056SNickeau $ifNoneMatch = stripslashes($_SERVER['HTTP_IF_NONE_MATCH']); 263c3437056SNickeau if ($ifNoneMatch && $ifNoneMatch === $etag) { 26434c746fbSgerardnico /** 26534c746fbSgerardnico * Don't add a body 26634c746fbSgerardnico */ 26704fd306cSNickeau $excutingContext 26804fd306cSNickeau ->response() 26904fd306cSNickeau ->setStatus(HttpResponseStatus::NOT_MODIFIED) 270c3437056SNickeau ->setEvent($event) 271c3437056SNickeau ->setCanonical(self::CANONICAL) 27204fd306cSNickeau ->end(); 273c3437056SNickeau return; 274c3437056SNickeau } 275c3437056SNickeau } 276c3437056SNickeau 277c3437056SNickeau 278c3437056SNickeau /** 279c3437056SNickeau * Download or display feature 280c3437056SNickeau * (Taken over from SendFile) 281c3437056SNickeau */ 28204fd306cSNickeau try { 28304fd306cSNickeau $mime = FileSystems::getMime($mediaToSend); 28404fd306cSNickeau } catch (ExceptionNotFound $e) { 28504fd306cSNickeau $excutingContext->response() 28604fd306cSNickeau ->setStatus(HttpResponseStatus::INTERNAL_ERROR) 28704fd306cSNickeau ->setEvent($event) 28804fd306cSNickeau ->setCanonical(self::CANONICAL) 28904fd306cSNickeau ->setBodyAsJsonMessage("Mime not found") 29004fd306cSNickeau ->end(); 29104fd306cSNickeau return; 29204fd306cSNickeau } 293c3437056SNickeau $download = $event->data["download"]; 294c3437056SNickeau if ($download && $mime->toString() !== "image/svg+xml") { 295c3437056SNickeau header('Content-Disposition: attachment;' . rfc2231_encode( 296c3437056SNickeau 'filename', PhpString::basename($originalFile)) . ';' 297c3437056SNickeau ); 298c3437056SNickeau } else { 299c3437056SNickeau header('Content-Disposition: inline;' . rfc2231_encode( 300c3437056SNickeau 'filename', PhpString::basename($originalFile)) . ';' 301c3437056SNickeau ); 302c3437056SNickeau } 303c3437056SNickeau 304c3437056SNickeau /** 305c3437056SNickeau * The vary header avoid caching 306c3437056SNickeau * Delete it 307c3437056SNickeau */ 308c3437056SNickeau action_plugin_combo_cache::deleteVaryHeader(); 309c3437056SNickeau 310c3437056SNickeau /** 311c3437056SNickeau * Use x-sendfile header to pass the delivery to compatible web servers 312c3437056SNickeau * (Taken over from SendFile) 313c3437056SNickeau */ 31404fd306cSNickeau http_sendfile($mediaToSend->toAbsolutePath()->toAbsoluteId()); 315c3437056SNickeau 316c3437056SNickeau /** 317c3437056SNickeau * Send the file 318c3437056SNickeau */ 31904fd306cSNickeau $filePointer = @fopen($mediaToSend->toAbsolutePath()->toAbsoluteId(), "rb"); 320c3437056SNickeau if ($filePointer) { 321c3437056SNickeau http_rangeRequest($filePointer, FileSystems::getSize($mediaToSend), $mime->toString()); 322c3437056SNickeau /** 323c3437056SNickeau * The {@link http_rangeRequest} exit not on test 324c3437056SNickeau * Trying to stop the dokuwiki processing of {@link sendFile()} 325c3437056SNickeau * Until {@link HttpResponse} can send resource 326c3437056SNickeau * TODO: integrate it in {@link HttpResponse} 327c3437056SNickeau */ 328c3437056SNickeau if (PluginUtility::isDevOrTest()) { 329c3437056SNickeau /** 330c3437056SNickeau * Add test info into the request 331c3437056SNickeau */ 332c3437056SNickeau $testRequest = TestRequest::getRunning(); 333c3437056SNickeau 334c3437056SNickeau if ($testRequest !== null) { 335c3437056SNickeau $testRequest->addData(HttpResponse::EXIT_KEY, "File Send"); 336c3437056SNickeau } 337c3437056SNickeau if ($event !== null) { 338c3437056SNickeau $event->stopPropagation(); 339c3437056SNickeau $event->preventDefault(); 340c3437056SNickeau } 341c3437056SNickeau } 342c3437056SNickeau } else { 34304fd306cSNickeau $excutingContext->response() 34404fd306cSNickeau ->setStatus(HttpResponseStatus::INTERNAL_ERROR) 34504fd306cSNickeau ->setBodyAsJsonMessage("Could not read $mediaToSend - bad permissions?") 34604fd306cSNickeau ->end(); 347c3437056SNickeau } 348c3437056SNickeau 349c3437056SNickeau } 350c3437056SNickeau 351c3437056SNickeau /** 352c3437056SNickeau * @param Path $mediaFile 353c3437056SNickeau * @param Array $properties - the query properties 354c3437056SNickeau * @return string 35504fd306cSNickeau * @throws ExceptionNotFound 356c3437056SNickeau */ 357c3437056SNickeau public 358c3437056SNickeau static function getEtagValue(Path $mediaFile, array $properties): string 359c3437056SNickeau { 36004fd306cSNickeau clearstatcache(); 361c3437056SNickeau $etagString = FileSystems::getModifiedTime($mediaFile)->format('r'); 362c3437056SNickeau ksort($properties); 363c3437056SNickeau foreach ($properties as $key => $value) { 36404fd306cSNickeau 365c3437056SNickeau /** 366c3437056SNickeau * Media is already on the URL 367c3437056SNickeau * tok is just added when w and h are on the url 368c3437056SNickeau * Buster is the timestamp 369c3437056SNickeau */ 37004fd306cSNickeau if (in_array($key, ["media", "tok", IFetcher::CACHE_BUSTER_KEY])) { 371c3437056SNickeau continue; 372c3437056SNickeau } 373c3437056SNickeau /** 374c3437056SNickeau * If empty means not used 375c3437056SNickeau */ 37604fd306cSNickeau if (trim($value) === "") { 377c3437056SNickeau continue; 378c3437056SNickeau } 379c3437056SNickeau $etagString .= "$key=$value"; 380c3437056SNickeau } 381c3437056SNickeau return '"' . md5($etagString) . '"'; 382c3437056SNickeau } 383c3437056SNickeau 384c3437056SNickeau 385c3437056SNickeau} 386