register_hook('FETCH_MEDIA_STATUS', 'BEFORE', $this, 'handleMediaStatus', array()); /** * Serve the image and static resources with HTTP cache control * https://www.dokuwiki.org/devel:event:media_sendfile */ $controller->register_hook('MEDIA_SENDFILE', 'BEFORE', $this, 'handleSendFile', array()); } /** * @param Doku_Event $event * https://www.dokuwiki.org/devel:event:fetch_media_status */ function handleMediaStatus(Doku_Event $event, $params) { $drive = $_GET[WikiPath::DRIVE_ATTRIBUTE] ?? null; $fetcher = $_GET[IFetcher::FETCHER_KEY] ?? null; if ($drive === null && $fetcher === null) { return; } if ($fetcher === FetcherRaster::CANONICAL) { // not yet implemented return; } /** * Security */ if ($drive === WikiPath::CACHE_DRIVE) { $event->data['download'] = false; if (!Identity::isManager()) { $event->data['status'] = HttpResponseStatus::NOT_AUTHORIZED; return; } } /** * Add the extra attributes */ $fetchUrl = Url::createFromGetOrPostGlobalVariable(); $executionContext = ExecutionContext::getActualOrCreateFromEnv(); try { $fetcher = $executionContext->createPathMainFetcherFromUrl($fetchUrl); $fetchPath = $fetcher->getFetchPath(); $event->data['file'] = $fetchPath->toAbsoluteId(); $event->data['status'] = HttpResponseStatus::ALL_GOOD; $mime = $fetcher->getMime(); $event->data["mime"] = $mime->toString(); /** * TODO: set download as parameter of the fetch url */ if ($mime->isImage() || in_array($mime->toString(), [Mime::JAVASCRIPT, Mime::CSS])) { $event->data['download'] = false; } else { $event->data['download'] = true; } $event->data['statusmessage'] = ''; } catch (\Exception $e) { $executionContext ->response() ->setException($e) ->setStatusAndBodyFromException($e) ->end(); //$event->data['file'] = WikiPath::createComboResource("images:error-bad-format.svg")->toLocalPath()->toAbsolutePath()->toQualifiedId(); $event->data['file'] = "error.json"; $event->data['statusmessage'] = $e->getMessage(); //$event->data['status'] = $httpResponse->getStatus(); $event->data['mime'] = Mime::JSON; } } function handleSendFile(Doku_Event $event, $params) { if (ExecutionContext::getActualOrCreateFromEnv()->response()->hasEnded()) { // when there is an error for instance return; } /** * If there is no buster key, the infinite cache is off */ $busterKey = $_GET[IFetcher::CACHE_BUSTER_KEY]; if ($busterKey === null) { return; } /** * The media to send */ $originalFile = $event->data["orig"]; // the original file $physicalFile = $event->data["file"]; // the file modified or the file to send if (empty($physicalFile)) { $physicalFile = $originalFile; } $mediaToSend = LocalPath::createFromPathString($physicalFile); if (!FileSystems::exists($mediaToSend)) { if (PluginUtility::isDevOrTest()) { LogUtility::internalError("The media ($mediaToSend) does not exist", self::CANONICAL); } return; } /** * Combo Media * (Static file from the combo resources are always taken over) */ $drive = $_GET[WikiPath::DRIVE_ATTRIBUTE] ?? null; if ($drive === null) { $confValue = SiteConfig::getConfValue(self::CONF_STATIC_CACHE_ENABLED, 1); if (!$confValue) { return; } try { $dokuPath = $mediaToSend->toWikiPath(); } catch (ExceptionCompile $e) { // not a dokuwiki file ? LogUtility::msg("Error: {$e->getMessage()}"); return; } if (!$dokuPath->isPublic()) { return; // Infinite static is only for public media } } /** * We take over the complete {@link sendFile()} function and exit * * in {@link sendFile()}, DokuWiki set the `Cache-Control` and * may exit early / send a 304 (not modified) with the function {@link http_conditionalRequest()} * Meaning that the AFTER event is never reached * that we can't send a cache control as below * header("Cache-Control: public, max-age=$infiniteMaxAge, s-maxage=$infiniteMaxAge"); * * We take the control over then */ /** * The cache instructions */ $infiniteMaxAge = self::INFINITE_MAX_AGE; $expires = time() + $infiniteMaxAge; header('Expires: ' . gmdate("D, d M Y H:i:s", $expires) . ' GMT'); $cacheControlDirective = ["public", "max-age=$infiniteMaxAge", "immutable"]; try { if ($mediaToSend->getExtension() === "js") { // if a SRI is given and that a proxy is // reducing javascript, it will not match // no-transform will avoid that $cacheControlDirective[] = self::NO_TRANSFORM; } } catch (ExceptionNotFound $e) { LogUtility::warning("The media ($mediaToSend) does not have any extension."); } header("Cache-Control: " . implode(", ", $cacheControlDirective)); Http::removeHeaderIfPresent("Pragma"); $excutingContext = ExecutionContext::getActualOrCreateFromEnv(); /** * The Etag cache validator * * Dokuwiki {@link http_conditionalRequest()} uses only the datetime of * the file but we need to add the parameters also because they * are generated image * * Last-Modified is not needed for the same reason * */ try { $etag = self::getEtagValue($mediaToSend, $_REQUEST); header("ETag: $etag"); } catch (ExceptionNotFound $e) { // internal error $excutingContext->response() ->setStatus(HttpResponseStatus::INTERNAL_ERROR) ->setEvent($event) ->setCanonical(self::CANONICAL) ->setBodyAsJsonMessage("We were unable to get the etag because the media was not found. Error: {$e->getMessage()}") ->end(); return; } /** * Conditional Request ? * We don't check on HTTP_IF_MODIFIED_SINCE because this is useless */ if (isset($_SERVER['HTTP_IF_NONE_MATCH'])) { $ifNoneMatch = stripslashes($_SERVER['HTTP_IF_NONE_MATCH']); if ($ifNoneMatch && $ifNoneMatch === $etag) { /** * Don't add a body */ $excutingContext ->response() ->setStatus(HttpResponseStatus::NOT_MODIFIED) ->setEvent($event) ->setCanonical(self::CANONICAL) ->end(); return; } } /** * Download or display feature * (Taken over from SendFile) */ try { $mime = FileSystems::getMime($mediaToSend); } catch (ExceptionNotFound $e) { $excutingContext->response() ->setStatus(HttpResponseStatus::INTERNAL_ERROR) ->setEvent($event) ->setCanonical(self::CANONICAL) ->setBodyAsJsonMessage("Mime not found") ->end(); return; } $download = $event->data["download"]; if ($download && $mime->toString() !== "image/svg+xml") { header('Content-Disposition: attachment;' . rfc2231_encode( 'filename', PhpString::basename($originalFile)) . ';' ); } else { header('Content-Disposition: inline;' . rfc2231_encode( 'filename', PhpString::basename($originalFile)) . ';' ); } /** * The vary header avoid caching * Delete it */ action_plugin_combo_cache::deleteVaryHeader(); /** * Use x-sendfile header to pass the delivery to compatible web servers * (Taken over from SendFile) */ http_sendfile($mediaToSend->toAbsolutePath()->toAbsoluteId()); /** * Send the file */ $filePointer = @fopen($mediaToSend->toAbsolutePath()->toAbsoluteId(), "rb"); if ($filePointer) { http_rangeRequest($filePointer, FileSystems::getSize($mediaToSend), $mime->toString()); /** * The {@link http_rangeRequest} exit not on test * Trying to stop the dokuwiki processing of {@link sendFile()} * Until {@link HttpResponse} can send resource * TODO: integrate it in {@link HttpResponse} */ if (PluginUtility::isDevOrTest()) { /** * Add test info into the request */ $testRequest = TestRequest::getRunning(); if ($testRequest !== null) { $testRequest->addData(HttpResponse::EXIT_KEY, "File Send"); } if ($event !== null) { $event->stopPropagation(); $event->preventDefault(); } } } else { $excutingContext->response() ->setStatus(HttpResponseStatus::INTERNAL_ERROR) ->setBodyAsJsonMessage("Could not read $mediaToSend - bad permissions?") ->end(); } } /** * @param Path $mediaFile * @param Array $properties - the query properties * @return string * @throws ExceptionNotFound */ public static function getEtagValue(Path $mediaFile, array $properties): string { clearstatcache(); $etagString = FileSystems::getModifiedTime($mediaFile)->format('r'); ksort($properties); foreach ($properties as $key => $value) { /** * Media is already on the URL * tok is just added when w and h are on the url * Buster is the timestamp */ if (in_array($key, ["media", "tok", IFetcher::CACHE_BUSTER_KEY])) { continue; } /** * If empty means not used */ if (trim($value) === "") { continue; } $etagString .= "$key=$value"; } return '"' . md5($etagString) . '"'; } }