1c3437056SNickeau<?php 2c3437056SNickeau 3*04fd306cSNickeauuse ComboStrap\ExceptionCompile; 4*04fd306cSNickeauuse ComboStrap\ExceptionNotFound; 5*04fd306cSNickeauuse ComboStrap\ExecutionContext; 6*04fd306cSNickeauuse ComboStrap\FetcherRaster; 7c3437056SNickeauuse ComboStrap\FileSystems; 8c3437056SNickeauuse ComboStrap\Http; 9c3437056SNickeauuse ComboStrap\HttpResponse; 10*04fd306cSNickeauuse ComboStrap\HttpResponseStatus; 114cadd4f8SNickeauuse ComboStrap\Identity; 12*04fd306cSNickeauuse ComboStrap\IFetcher; 13c3437056SNickeauuse ComboStrap\LocalPath; 144cadd4f8SNickeauuse ComboStrap\LogUtility; 15*04fd306cSNickeauuse ComboStrap\Mime; 16c3437056SNickeauuse ComboStrap\Path; 17c3437056SNickeauuse ComboStrap\PluginUtility; 18*04fd306cSNickeauuse ComboStrap\SiteConfig; 19*04fd306cSNickeauuse ComboStrap\Web\Url; 20*04fd306cSNickeauuse 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 /** 45*04fd306cSNickeau * 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"; 48*04fd306cSNickeau 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 72*04fd306cSNickeau /** 73*04fd306cSNickeau * @param Doku_Event $event 74*04fd306cSNickeau * https://www.dokuwiki.org/devel:event:fetch_media_status 75*04fd306cSNickeau */ 76c3437056SNickeau function handleMediaStatus(Doku_Event $event, $params) 77c3437056SNickeau { 78c3437056SNickeau 79*04fd306cSNickeau $drive = $_GET[WikiPath::DRIVE_ATTRIBUTE]; 80*04fd306cSNickeau $fetcher = $_GET[IFetcher::FETCHER_KEY]; 81*04fd306cSNickeau if ($drive === null && $fetcher === null) { 82c3437056SNickeau return; 83c3437056SNickeau } 84*04fd306cSNickeau if ($fetcher === FetcherRaster::CANONICAL) { 85*04fd306cSNickeau // not yet implemented 86c3437056SNickeau return; 87c3437056SNickeau } 88*04fd306cSNickeau 89*04fd306cSNickeau /** 90*04fd306cSNickeau * Security 91*04fd306cSNickeau */ 92*04fd306cSNickeau if ($drive === WikiPath::CACHE_DRIVE) { 934cadd4f8SNickeau $event->data['download'] = false; 944cadd4f8SNickeau if (!Identity::isManager()) { 95*04fd306cSNickeau $event->data['status'] = HttpResponseStatus::NOT_AUTHORIZED; 96*04fd306cSNickeau return; 974cadd4f8SNickeau } 984cadd4f8SNickeau } 99c3437056SNickeau 100*04fd306cSNickeau 101*04fd306cSNickeau /** 102*04fd306cSNickeau * Add the extra attributes 103*04fd306cSNickeau */ 104*04fd306cSNickeau $fetchUrl = Url::createFromGetOrPostGlobalVariable(); 105*04fd306cSNickeau $executionContext = ExecutionContext::getActualOrCreateFromEnv(); 106*04fd306cSNickeau try { 107*04fd306cSNickeau 108*04fd306cSNickeau $fetcher = $executionContext->createPathMainFetcherFromUrl($fetchUrl); 109*04fd306cSNickeau $fetchPath = $fetcher->getFetchPath(); 110*04fd306cSNickeau $event->data['file'] = $fetchPath->toAbsoluteId(); 111*04fd306cSNickeau $event->data['status'] = HttpResponseStatus::ALL_GOOD; 112*04fd306cSNickeau $mime = $fetcher->getMime(); 113*04fd306cSNickeau $event->data["mime"] = $mime->toString(); 114*04fd306cSNickeau /** 115*04fd306cSNickeau * TODO: set download as parameter of the fetch url 116*04fd306cSNickeau */ 117*04fd306cSNickeau if ($mime->isImage() || in_array($mime->toString(), [Mime::JAVASCRIPT, Mime::CSS])) { 118*04fd306cSNickeau $event->data['download'] = false; 119*04fd306cSNickeau } else { 120*04fd306cSNickeau $event->data['download'] = true; 121*04fd306cSNickeau } 122*04fd306cSNickeau $event->data['statusmessage'] = ''; 123*04fd306cSNickeau } catch (\Exception $e) { 124*04fd306cSNickeau 125*04fd306cSNickeau $executionContext 126*04fd306cSNickeau ->response() 127*04fd306cSNickeau ->setException($e) 128*04fd306cSNickeau ->setStatusAndBodyFromException($e) 129*04fd306cSNickeau ->end(); 130*04fd306cSNickeau 131*04fd306cSNickeau //$event->data['file'] = WikiPath::createComboResource("images:error-bad-format.svg")->toLocalPath()->toAbsolutePath()->toQualifiedId(); 132*04fd306cSNickeau $event->data['file'] = "error.json"; 133*04fd306cSNickeau $event->data['statusmessage'] = $e->getMessage(); 134*04fd306cSNickeau //$event->data['status'] = $httpResponse->getStatus(); 135*04fd306cSNickeau $event->data['mime'] = Mime::JSON; 136*04fd306cSNickeau 137*04fd306cSNickeau 138*04fd306cSNickeau } 139*04fd306cSNickeau 140c3437056SNickeau } 141c3437056SNickeau 142c3437056SNickeau function handleSendFile(Doku_Event $event, $params) 143c3437056SNickeau { 144c3437056SNickeau 145*04fd306cSNickeau if (ExecutionContext::getActualOrCreateFromEnv()->response()->hasEnded()) { 146*04fd306cSNickeau // when there is an error for instance 147*04fd306cSNickeau return; 148*04fd306cSNickeau } 149c3437056SNickeau /** 1504cadd4f8SNickeau * If there is no buster key, the infinite cache is off 151c3437056SNickeau */ 152*04fd306cSNickeau $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 161*04fd306cSNickeau $physicalFile = $event->data["file"]; // the file modified or the file to send 1624cadd4f8SNickeau if (empty($physicalFile)) { 1634cadd4f8SNickeau $physicalFile = $originalFile; 1644cadd4f8SNickeau } 165*04fd306cSNickeau $mediaToSend = LocalPath::createFromPathString($physicalFile); 1664cadd4f8SNickeau if (!FileSystems::exists($mediaToSend)) { 167*04fd306cSNickeau if (PluginUtility::isDevOrTest()) { 168*04fd306cSNickeau LogUtility::internalError("The media ($mediaToSend) does not exist", self::CANONICAL); 169*04fd306cSNickeau } 1704cadd4f8SNickeau return; 1714cadd4f8SNickeau } 172c3437056SNickeau 173c3437056SNickeau /** 174c3437056SNickeau * Combo Media 175c3437056SNickeau * (Static file from the combo resources are always taken over) 176c3437056SNickeau */ 177*04fd306cSNickeau $drive = $_GET[WikiPath::DRIVE_ATTRIBUTE]; 1784cadd4f8SNickeau if ($drive === null) { 179c3437056SNickeau 180*04fd306cSNickeau $confValue = SiteConfig::getConfValue(self::CONF_STATIC_CACHE_ENABLED, 1); 1814cadd4f8SNickeau if (!$confValue) { 182c3437056SNickeau return; 183c3437056SNickeau } 184c3437056SNickeau 1854cadd4f8SNickeau try { 186*04fd306cSNickeau $dokuPath = $mediaToSend->toWikiPath(); 187*04fd306cSNickeau } 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"]; 218*04fd306cSNickeau 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 223*04fd306cSNickeau $cacheControlDirective[] = self::NO_TRANSFORM; 224*04fd306cSNickeau } 225*04fd306cSNickeau } catch (ExceptionNotFound $e) { 226*04fd306cSNickeau LogUtility::warning("The media ($mediaToSend) does not have any extension."); 227c3437056SNickeau } 228c3437056SNickeau header("Cache-Control: " . implode(", ", $cacheControlDirective)); 229c3437056SNickeau Http::removeHeaderIfPresent("Pragma"); 230c3437056SNickeau 231*04fd306cSNickeau $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 */ 242*04fd306cSNickeau try { 243c3437056SNickeau $etag = self::getEtagValue($mediaToSend, $_REQUEST); 244c3437056SNickeau header("ETag: $etag"); 245*04fd306cSNickeau } catch (ExceptionNotFound $e) { 246*04fd306cSNickeau // internal error 247*04fd306cSNickeau $excutingContext->response() 248*04fd306cSNickeau ->setStatus(HttpResponseStatus::INTERNAL_ERROR) 249*04fd306cSNickeau ->setEvent($event) 250*04fd306cSNickeau ->setCanonical(self::CANONICAL) 251*04fd306cSNickeau ->setBodyAsJsonMessage("We were unable to get the etag because the media was not found. Error: {$e->getMessage()}") 252*04fd306cSNickeau ->end(); 253*04fd306cSNickeau return; 254*04fd306cSNickeau } 255*04fd306cSNickeau 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) { 264*04fd306cSNickeau $excutingContext 265*04fd306cSNickeau ->response() 266*04fd306cSNickeau ->setStatus(HttpResponseStatus::NOT_MODIFIED) 267c3437056SNickeau ->setEvent($event) 268c3437056SNickeau ->setCanonical(self::CANONICAL) 269*04fd306cSNickeau ->setBodyAsJsonMessage("File not modified") 270*04fd306cSNickeau ->end(); 271c3437056SNickeau return; 272c3437056SNickeau } 273c3437056SNickeau } 274c3437056SNickeau 275c3437056SNickeau 276c3437056SNickeau /** 277c3437056SNickeau * Download or display feature 278c3437056SNickeau * (Taken over from SendFile) 279c3437056SNickeau */ 280*04fd306cSNickeau try { 281*04fd306cSNickeau $mime = FileSystems::getMime($mediaToSend); 282*04fd306cSNickeau } catch (ExceptionNotFound $e) { 283*04fd306cSNickeau $excutingContext->response() 284*04fd306cSNickeau ->setStatus(HttpResponseStatus::INTERNAL_ERROR) 285*04fd306cSNickeau ->setEvent($event) 286*04fd306cSNickeau ->setCanonical(self::CANONICAL) 287*04fd306cSNickeau ->setBodyAsJsonMessage("Mime not found") 288*04fd306cSNickeau ->end(); 289*04fd306cSNickeau return; 290*04fd306cSNickeau } 291c3437056SNickeau $download = $event->data["download"]; 292c3437056SNickeau if ($download && $mime->toString() !== "image/svg+xml") { 293c3437056SNickeau header('Content-Disposition: attachment;' . rfc2231_encode( 294c3437056SNickeau 'filename', PhpString::basename($originalFile)) . ';' 295c3437056SNickeau ); 296c3437056SNickeau } else { 297c3437056SNickeau header('Content-Disposition: inline;' . rfc2231_encode( 298c3437056SNickeau 'filename', PhpString::basename($originalFile)) . ';' 299c3437056SNickeau ); 300c3437056SNickeau } 301c3437056SNickeau 302c3437056SNickeau /** 303c3437056SNickeau * The vary header avoid caching 304c3437056SNickeau * Delete it 305c3437056SNickeau */ 306c3437056SNickeau action_plugin_combo_cache::deleteVaryHeader(); 307c3437056SNickeau 308c3437056SNickeau /** 309c3437056SNickeau * Use x-sendfile header to pass the delivery to compatible web servers 310c3437056SNickeau * (Taken over from SendFile) 311c3437056SNickeau */ 312*04fd306cSNickeau http_sendfile($mediaToSend->toAbsolutePath()->toAbsoluteId()); 313c3437056SNickeau 314c3437056SNickeau /** 315c3437056SNickeau * Send the file 316c3437056SNickeau */ 317*04fd306cSNickeau $filePointer = @fopen($mediaToSend->toAbsolutePath()->toAbsoluteId(), "rb"); 318c3437056SNickeau if ($filePointer) { 319c3437056SNickeau http_rangeRequest($filePointer, FileSystems::getSize($mediaToSend), $mime->toString()); 320c3437056SNickeau /** 321c3437056SNickeau * The {@link http_rangeRequest} exit not on test 322c3437056SNickeau * Trying to stop the dokuwiki processing of {@link sendFile()} 323c3437056SNickeau * Until {@link HttpResponse} can send resource 324c3437056SNickeau * TODO: integrate it in {@link HttpResponse} 325c3437056SNickeau */ 326c3437056SNickeau if (PluginUtility::isDevOrTest()) { 327c3437056SNickeau /** 328c3437056SNickeau * Add test info into the request 329c3437056SNickeau */ 330c3437056SNickeau $testRequest = TestRequest::getRunning(); 331c3437056SNickeau 332c3437056SNickeau if ($testRequest !== null) { 333c3437056SNickeau $testRequest->addData(HttpResponse::EXIT_KEY, "File Send"); 334c3437056SNickeau } 335c3437056SNickeau if ($event !== null) { 336c3437056SNickeau $event->stopPropagation(); 337c3437056SNickeau $event->preventDefault(); 338c3437056SNickeau } 339c3437056SNickeau } 340c3437056SNickeau } else { 341*04fd306cSNickeau $excutingContext->response() 342*04fd306cSNickeau ->setStatus(HttpResponseStatus::INTERNAL_ERROR) 343*04fd306cSNickeau ->setBodyAsJsonMessage("Could not read $mediaToSend - bad permissions?") 344*04fd306cSNickeau ->end(); 345c3437056SNickeau } 346c3437056SNickeau 347c3437056SNickeau } 348c3437056SNickeau 349c3437056SNickeau /** 350c3437056SNickeau * @param Path $mediaFile 351c3437056SNickeau * @param Array $properties - the query properties 352c3437056SNickeau * @return string 353*04fd306cSNickeau * @throws ExceptionNotFound 354c3437056SNickeau */ 355c3437056SNickeau public 356c3437056SNickeau static function getEtagValue(Path $mediaFile, array $properties): string 357c3437056SNickeau { 358*04fd306cSNickeau clearstatcache(); 359c3437056SNickeau $etagString = FileSystems::getModifiedTime($mediaFile)->format('r'); 360c3437056SNickeau ksort($properties); 361c3437056SNickeau foreach ($properties as $key => $value) { 362*04fd306cSNickeau 363c3437056SNickeau /** 364c3437056SNickeau * Media is already on the URL 365c3437056SNickeau * tok is just added when w and h are on the url 366c3437056SNickeau * Buster is the timestamp 367c3437056SNickeau */ 368*04fd306cSNickeau if (in_array($key, ["media", "tok", IFetcher::CACHE_BUSTER_KEY])) { 369c3437056SNickeau continue; 370c3437056SNickeau } 371c3437056SNickeau /** 372c3437056SNickeau * If empty means not used 373c3437056SNickeau */ 374*04fd306cSNickeau if (trim($value) === "") { 375c3437056SNickeau continue; 376c3437056SNickeau } 377c3437056SNickeau $etagString .= "$key=$value"; 378c3437056SNickeau } 379c3437056SNickeau return '"' . md5($etagString) . '"'; 380c3437056SNickeau } 381c3437056SNickeau 382c3437056SNickeau 383c3437056SNickeau} 384