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