1c3437056SNickeau<?php 2c3437056SNickeau 3c3437056SNickeauuse ComboStrap\CacheMedia; 4c3437056SNickeauuse ComboStrap\DokuPath; 5*4cadd4f8SNickeauuse ComboStrap\ExceptionCombo; 6c3437056SNickeauuse ComboStrap\FileSystems; 7c3437056SNickeauuse ComboStrap\Http; 8c3437056SNickeauuse ComboStrap\HttpResponse; 9*4cadd4f8SNickeauuse ComboStrap\Identity; 10c3437056SNickeauuse ComboStrap\LocalPath; 11*4cadd4f8SNickeauuse ComboStrap\LogUtility; 12c3437056SNickeauuse ComboStrap\Path; 13c3437056SNickeauuse ComboStrap\PluginUtility; 14c3437056SNickeauuse dokuwiki\Utf8\PhpString; 15c3437056SNickeau 16c3437056SNickeaurequire_once(__DIR__ . '/../ComboStrap/PluginUtility.php'); 17c3437056SNickeau 18c3437056SNickeau/** 19c3437056SNickeau * Modify the serving of static resource via fetch.php 20c3437056SNickeau */ 21c3437056SNickeauclass action_plugin_combo_staticresource extends DokuWiki_Action_Plugin 22c3437056SNickeau{ 23c3437056SNickeau 24c3437056SNickeau 25c3437056SNickeau /** 26c3437056SNickeau * https://www.ietf.org/rfc/rfc2616.txt 27c3437056SNickeau * To mark a response as "never expires," an origin server sends an Expires date approximately one year 28c3437056SNickeau * from the time the response is sent. 29c3437056SNickeau * HTTP/1.1 servers SHOULD NOT send Expires dates more than one year in the future. 30c3437056SNickeau * 31c3437056SNickeau * In seconds = 365*24*60*60 32c3437056SNickeau */ 33c3437056SNickeau const INFINITE_MAX_AGE = 31536000; 34c3437056SNickeau 35c3437056SNickeau const CANONICAL = "cache"; 36c3437056SNickeau 37c3437056SNickeau /** 38c3437056SNickeau * Enable an infinite cache on static resources (image, script, ...) with a {@link CacheMedia::CACHE_BUSTER_KEY} 39c3437056SNickeau */ 40c3437056SNickeau public const CONF_STATIC_CACHE_ENABLED = "staticCacheEnabled"; 41c3437056SNickeau 42c3437056SNickeau 43c3437056SNickeau /** 44c3437056SNickeau * @param Doku_Event_Handler $controller 45c3437056SNickeau */ 46c3437056SNickeau function register(Doku_Event_Handler $controller) 47c3437056SNickeau { 48c3437056SNickeau 49c3437056SNickeau /** 50c3437056SNickeau * Redirect the combo resources to the good file path 51c3437056SNickeau * https://www.dokuwiki.org/devel:event:fetch_media_status 52c3437056SNickeau */ 53c3437056SNickeau $controller->register_hook('FETCH_MEDIA_STATUS', 'BEFORE', $this, 'handleMediaStatus', array()); 54c3437056SNickeau 55c3437056SNickeau /** 56c3437056SNickeau * Serve the image and static resources with HTTP cache control 57c3437056SNickeau * https://www.dokuwiki.org/devel:event:media_sendfile 58c3437056SNickeau */ 59c3437056SNickeau $controller->register_hook('MEDIA_SENDFILE', 'BEFORE', $this, 'handleSendFile', array()); 60c3437056SNickeau 61c3437056SNickeau 62c3437056SNickeau } 63c3437056SNickeau 64c3437056SNickeau 65c3437056SNickeau function handleMediaStatus(Doku_Event $event, $params) 66c3437056SNickeau { 67c3437056SNickeau 68*4cadd4f8SNickeau if (!isset($_GET[DokuPath::DRIVE_ATTRIBUTE])) { 69c3437056SNickeau return; 70c3437056SNickeau } 71*4cadd4f8SNickeau $drive = $_GET[DokuPath::DRIVE_ATTRIBUTE]; 72*4cadd4f8SNickeau if (!in_array($drive, DokuPath::DRIVES)) { 73c3437056SNickeau // The other resources have ACL 74c3437056SNickeau // and this endpoint is normally only for 75c3437056SNickeau $event->data['status'] = HttpResponse::STATUS_NOT_AUTHORIZED; 76c3437056SNickeau return; 77c3437056SNickeau } 78c3437056SNickeau $mediaId = $event->data['media']; 79*4cadd4f8SNickeau $mediaPath = DokuPath::createDokuPath($mediaId, $drive); 80c3437056SNickeau $event->data['file'] = $mediaPath->toLocalPath()->toAbsolutePath()->toString(); 81c3437056SNickeau if (FileSystems::exists($mediaPath)) { 82c3437056SNickeau $event->data['status'] = HttpResponse::STATUS_ALL_GOOD; 83c3437056SNickeau $event->data['statusmessage'] = ''; 84c3437056SNickeau $event->data['mime'] = $mediaPath->getMime(); 85c3437056SNickeau } 86*4cadd4f8SNickeau if ($drive === DokuPath::CACHE_DRIVE) { 87*4cadd4f8SNickeau $event->data['download'] = false; 88*4cadd4f8SNickeau if (!Identity::isManager()) { 89*4cadd4f8SNickeau $event->data['status'] = HttpResponse::STATUS_NOT_AUTHORIZED; 90*4cadd4f8SNickeau } 91*4cadd4f8SNickeau } 92c3437056SNickeau 93c3437056SNickeau } 94c3437056SNickeau 95c3437056SNickeau function handleSendFile(Doku_Event $event, $params) 96c3437056SNickeau { 97c3437056SNickeau 98c3437056SNickeau 99c3437056SNickeau /** 100*4cadd4f8SNickeau * If there is no buster key, the infinite cache is off 101c3437056SNickeau */ 102*4cadd4f8SNickeau $busterKey = $_GET[CacheMedia::CACHE_BUSTER_KEY]; 103*4cadd4f8SNickeau if ($busterKey === null) { 104*4cadd4f8SNickeau return; 105*4cadd4f8SNickeau } 106*4cadd4f8SNickeau 107*4cadd4f8SNickeau /** 108*4cadd4f8SNickeau * The media to send 109*4cadd4f8SNickeau */ 110*4cadd4f8SNickeau $originalFile = $event->data["orig"]; // the original file 111*4cadd4f8SNickeau $physicalFile = $event->data["file"]; // the file modified 112*4cadd4f8SNickeau if (empty($physicalFile)) { 113*4cadd4f8SNickeau $physicalFile = $originalFile; 114*4cadd4f8SNickeau } 115*4cadd4f8SNickeau $mediaToSend = LocalPath::createFromPath($physicalFile); 116*4cadd4f8SNickeau if (!FileSystems::exists($mediaToSend)) { 117*4cadd4f8SNickeau return; 118*4cadd4f8SNickeau } 119c3437056SNickeau 120c3437056SNickeau /** 121c3437056SNickeau * Combo Media 122c3437056SNickeau * (Static file from the combo resources are always taken over) 123c3437056SNickeau */ 124*4cadd4f8SNickeau $drive = $_GET[DokuPath::DRIVE_ATTRIBUTE]; 125*4cadd4f8SNickeau if ($drive === null) { 126c3437056SNickeau 127*4cadd4f8SNickeau $confValue = PluginUtility::getConfValue(self::CONF_STATIC_CACHE_ENABLED, 1); 128*4cadd4f8SNickeau if (!$confValue) { 129c3437056SNickeau return; 130c3437056SNickeau } 131c3437056SNickeau 132*4cadd4f8SNickeau try { 133*4cadd4f8SNickeau $dokuPath = $mediaToSend->toDokuPath(); 134*4cadd4f8SNickeau } catch (ExceptionCombo $e) { 135*4cadd4f8SNickeau // not a dokuwiki file ? 136*4cadd4f8SNickeau LogUtility::msg("Error: {$e->getMessage()}"); 137*4cadd4f8SNickeau return; 138*4cadd4f8SNickeau } 139*4cadd4f8SNickeau if (!$dokuPath->isPublic()) { 140*4cadd4f8SNickeau return; // Infinite static is only for public media 141*4cadd4f8SNickeau } 142*4cadd4f8SNickeau 143*4cadd4f8SNickeau } 144*4cadd4f8SNickeau 145c3437056SNickeau /** 146c3437056SNickeau * We take over the complete {@link sendFile()} function and exit 147c3437056SNickeau * 148c3437056SNickeau * in {@link sendFile()}, DokuWiki set the `Cache-Control` and 149c3437056SNickeau * may exit early / send a 304 (not modified) with the function {@link http_conditionalRequest()} 150c3437056SNickeau * Meaning that the AFTER event is never reached 151c3437056SNickeau * that we can't send a cache control as below 152c3437056SNickeau * header("Cache-Control: public, max-age=$infiniteMaxAge, s-maxage=$infiniteMaxAge"); 153c3437056SNickeau * 154c3437056SNickeau * We take the control over then 155c3437056SNickeau */ 156c3437056SNickeau 157c3437056SNickeau 158c3437056SNickeau /** 159c3437056SNickeau * The cache instructions 160c3437056SNickeau */ 161c3437056SNickeau $infiniteMaxAge = self::INFINITE_MAX_AGE; 162c3437056SNickeau $expires = time() + $infiniteMaxAge; 163c3437056SNickeau header('Expires: ' . gmdate("D, d M Y H:i:s", $expires) . ' GMT'); 164c3437056SNickeau $cacheControlDirective = ["public", "max-age=$infiniteMaxAge", "immutable"]; 165c3437056SNickeau if ($mediaToSend->getExtension() === "js") { 166c3437056SNickeau // if a SRI is given and that a proxy is 167c3437056SNickeau // reducing javascript, it will not match 168c3437056SNickeau // no-transform will avoid that 169c3437056SNickeau $cacheControlDirective[] = "no-transform"; 170c3437056SNickeau } 171c3437056SNickeau header("Cache-Control: " . implode(", ", $cacheControlDirective)); 172c3437056SNickeau Http::removeHeaderIfPresent("Pragma"); 173c3437056SNickeau 174c3437056SNickeau /** 175c3437056SNickeau * The Etag cache validator 176c3437056SNickeau * 177c3437056SNickeau * Dokuwiki {@link http_conditionalRequest()} uses only the datetime of 178c3437056SNickeau * the file but we need to add the parameters also because they 179c3437056SNickeau * are generated image 180c3437056SNickeau * 181c3437056SNickeau * Last-Modified is not needed for the same reason 182c3437056SNickeau * 183c3437056SNickeau */ 184c3437056SNickeau $etag = self::getEtagValue($mediaToSend, $_REQUEST); 185c3437056SNickeau header("ETag: $etag"); 186c3437056SNickeau 187c3437056SNickeau /** 188c3437056SNickeau * Conditional Request ? 189c3437056SNickeau * We don't check on HTTP_IF_MODIFIED_SINCE because this is useless 190c3437056SNickeau */ 191c3437056SNickeau if (isset($_SERVER['HTTP_IF_NONE_MATCH'])) { 192c3437056SNickeau $ifNoneMatch = stripslashes($_SERVER['HTTP_IF_NONE_MATCH']); 193c3437056SNickeau if ($ifNoneMatch && $ifNoneMatch === $etag) { 194c3437056SNickeau 195c3437056SNickeau HttpResponse::create(HttpResponse::STATUS_NOT_MODIFIED) 196c3437056SNickeau ->setEvent($event) 197c3437056SNickeau ->setCanonical(self::CANONICAL) 198c3437056SNickeau ->sendMessage("File not modified"); 199c3437056SNickeau return; 200c3437056SNickeau } 201c3437056SNickeau } 202c3437056SNickeau 203c3437056SNickeau 204c3437056SNickeau /** 205c3437056SNickeau * Download or display feature 206c3437056SNickeau * (Taken over from SendFile) 207c3437056SNickeau */ 208c3437056SNickeau $mime = $mediaToSend->getMime(); 209c3437056SNickeau $download = $event->data["download"]; 210c3437056SNickeau if ($download && $mime->toString() !== "image/svg+xml") { 211c3437056SNickeau header('Content-Disposition: attachment;' . rfc2231_encode( 212c3437056SNickeau 'filename', PhpString::basename($originalFile)) . ';' 213c3437056SNickeau ); 214c3437056SNickeau } else { 215c3437056SNickeau header('Content-Disposition: inline;' . rfc2231_encode( 216c3437056SNickeau 'filename', PhpString::basename($originalFile)) . ';' 217c3437056SNickeau ); 218c3437056SNickeau } 219c3437056SNickeau 220c3437056SNickeau /** 221c3437056SNickeau * The vary header avoid caching 222c3437056SNickeau * Delete it 223c3437056SNickeau */ 224c3437056SNickeau action_plugin_combo_cache::deleteVaryHeader(); 225c3437056SNickeau 226c3437056SNickeau /** 227c3437056SNickeau * Use x-sendfile header to pass the delivery to compatible web servers 228c3437056SNickeau * (Taken over from SendFile) 229c3437056SNickeau */ 230c3437056SNickeau http_sendfile($mediaToSend->toAbsolutePath()->toString()); 231c3437056SNickeau 232c3437056SNickeau /** 233c3437056SNickeau * Send the file 234c3437056SNickeau */ 235c3437056SNickeau $filePointer = @fopen($mediaToSend->toAbsolutePath()->toString(), "rb"); 236c3437056SNickeau if ($filePointer) { 237c3437056SNickeau http_rangeRequest($filePointer, FileSystems::getSize($mediaToSend), $mime->toString()); 238c3437056SNickeau /** 239c3437056SNickeau * The {@link http_rangeRequest} exit not on test 240c3437056SNickeau * Trying to stop the dokuwiki processing of {@link sendFile()} 241c3437056SNickeau * Until {@link HttpResponse} can send resource 242c3437056SNickeau * TODO: integrate it in {@link HttpResponse} 243c3437056SNickeau */ 244c3437056SNickeau if (PluginUtility::isDevOrTest()) { 245c3437056SNickeau /** 246c3437056SNickeau * Add test info into the request 247c3437056SNickeau */ 248c3437056SNickeau $testRequest = TestRequest::getRunning(); 249c3437056SNickeau 250c3437056SNickeau if ($testRequest !== null) { 251c3437056SNickeau $testRequest->addData(HttpResponse::EXIT_KEY, "File Send"); 252c3437056SNickeau } 253c3437056SNickeau if ($event !== null) { 254c3437056SNickeau $event->stopPropagation(); 255c3437056SNickeau $event->preventDefault(); 256c3437056SNickeau } 257c3437056SNickeau } 258c3437056SNickeau } else { 259c3437056SNickeau HttpResponse::create(HttpResponse::STATUS_INTERNAL_ERROR) 260c3437056SNickeau ->sendMessage("Could not read $mediaToSend - bad permissions?"); 261c3437056SNickeau } 262c3437056SNickeau 263c3437056SNickeau } 264c3437056SNickeau 265c3437056SNickeau /** 266c3437056SNickeau * @param Path $mediaFile 267c3437056SNickeau * @param Array $properties - the query properties 268c3437056SNickeau * @return string 269c3437056SNickeau */ 270c3437056SNickeau public 271c3437056SNickeau static function getEtagValue(Path $mediaFile, array $properties): string 272c3437056SNickeau { 273c3437056SNickeau $etagString = FileSystems::getModifiedTime($mediaFile)->format('r'); 274c3437056SNickeau ksort($properties); 275c3437056SNickeau foreach ($properties as $key => $value) { 276c3437056SNickeau /** 277c3437056SNickeau * Media is already on the URL 278c3437056SNickeau * tok is just added when w and h are on the url 279c3437056SNickeau * Buster is the timestamp 280c3437056SNickeau */ 281c3437056SNickeau if (in_array($key, ["media", "tok", CacheMedia::CACHE_BUSTER_KEY])) { 282c3437056SNickeau continue; 283c3437056SNickeau } 284c3437056SNickeau /** 285c3437056SNickeau * If empty means not used 286c3437056SNickeau */ 287c3437056SNickeau if (empty($value)) { 288c3437056SNickeau continue; 289c3437056SNickeau } 290c3437056SNickeau $etagString .= "$key=$value"; 291c3437056SNickeau } 292c3437056SNickeau return '"' . md5($etagString) . '"'; 293c3437056SNickeau } 294c3437056SNickeau 295c3437056SNickeau 296c3437056SNickeau} 297