xref: /template/strap/action/cache.php (revision 1fa8c418ed5809db58049141be41b7738471dd32)
137748cd8SNickeau<?php
237748cd8SNickeau
337748cd8SNickeauuse ComboStrap\CacheManager;
4*1fa8c418SNickeauuse ComboStrap\CacheMedia;
5*1fa8c418SNickeauuse ComboStrap\DokuPath;
6*1fa8c418SNickeauuse ComboStrap\Http;
737748cd8SNickeauuse ComboStrap\Iso8601Date;
837748cd8SNickeauuse ComboStrap\PluginUtility;
9*1fa8c418SNickeauuse dokuwiki\Cache\CacheRenderer;
10*1fa8c418SNickeauuse dokuwiki\Utf8\PhpString;
1137748cd8SNickeau
1237748cd8SNickeaurequire_once(__DIR__ . '/../ComboStrap/PluginUtility.php');
1337748cd8SNickeau
1437748cd8SNickeau/**
1537748cd8SNickeau * Can we use the parser cache
1637748cd8SNickeau */
1737748cd8SNickeauclass action_plugin_combo_cache extends DokuWiki_Action_Plugin
1837748cd8SNickeau{
1937748cd8SNickeau    const COMBO_CACHE_PREFIX = "combo:cache:";
2037748cd8SNickeau
2137748cd8SNickeau    /**
22*1fa8c418SNickeau     * https://www.ietf.org/rfc/rfc2616.txt
23*1fa8c418SNickeau     * To mark a response as "never expires," an origin server sends an Expires date approximately one year
24*1fa8c418SNickeau     * from the time the response is sent.
25*1fa8c418SNickeau     * HTTP/1.1 servers SHOULD NOT send Expires dates more than one year in the future.
26*1fa8c418SNickeau     *
27*1fa8c418SNickeau     * In seconds = 365*24*60*60
28*1fa8c418SNickeau     */
29*1fa8c418SNickeau    const INFINITE_MAX_AGE = 31536000;
30*1fa8c418SNickeau
31*1fa8c418SNickeau    /**
32*1fa8c418SNickeau     * Enable an infinite cache on image URL with the {@link CacheMedia::CACHE_BUSTER_KEY}
33*1fa8c418SNickeau     * present
34*1fa8c418SNickeau     */
35*1fa8c418SNickeau    const CONF_STATIC_CACHE_ENABLED = "staticCacheEnabled";
36*1fa8c418SNickeau    const CANONICAL = "cache";
37*1fa8c418SNickeau    const STATIC_SCRIPT_NAMES = ["/lib/exe/jquery.php", "/lib/exe/js.php", "/lib/exe/css.php"];
38*1fa8c418SNickeau
39*1fa8c418SNickeau    /**
4037748cd8SNickeau     * @param Doku_Event_Handler $controller
4137748cd8SNickeau     */
4237748cd8SNickeau    function register(Doku_Event_Handler $controller)
4337748cd8SNickeau    {
4437748cd8SNickeau
4537748cd8SNickeau        /**
4637748cd8SNickeau         * Log the cache usage and also
4737748cd8SNickeau         */
4837748cd8SNickeau        $controller->register_hook('PARSER_CACHE_USE', 'AFTER', $this, 'logCacheUsage', array());
4937748cd8SNickeau
5037748cd8SNickeau        $controller->register_hook('PARSER_CACHE_USE', 'BEFORE', $this, 'purgeIfNeeded', array());
5137748cd8SNickeau
5237748cd8SNickeau        /**
53*1fa8c418SNickeau         * Control the HTTP cache of the image
54*1fa8c418SNickeau         */
55*1fa8c418SNickeau        $controller->register_hook('MEDIA_SENDFILE', 'BEFORE', $this, 'imageHTTPCacheBefore', array());
56*1fa8c418SNickeau
57*1fa8c418SNickeau        /**
5837748cd8SNickeau         * To add the cache result in the header
5937748cd8SNickeau         */
6037748cd8SNickeau        $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'addMeta', array());
6137748cd8SNickeau
6237748cd8SNickeau        /**
6337748cd8SNickeau         * To reset the cache manager
6437748cd8SNickeau         * between two run in the test
6537748cd8SNickeau         */
6637748cd8SNickeau        $controller->register_hook('DOKUWIKI_DONE', 'BEFORE', $this, 'close', array());
6737748cd8SNickeau
68*1fa8c418SNickeau        /**
69*1fa8c418SNickeau         * To delete the VARY on css.php, jquery.php, js.php
70*1fa8c418SNickeau         */
71*1fa8c418SNickeau        $controller->register_hook('INIT_LANG_LOAD', 'BEFORE', $this, 'deleteVaryFromStaticGeneratedResources', array());
72*1fa8c418SNickeau
73*1fa8c418SNickeau
7437748cd8SNickeau    }
7537748cd8SNickeau
7637748cd8SNickeau    /**
7737748cd8SNickeau     *
7837748cd8SNickeau     * @param Doku_Event $event
7937748cd8SNickeau     * @param $params
8037748cd8SNickeau     */
8137748cd8SNickeau    function logCacheUsage(Doku_Event $event, $params)
8237748cd8SNickeau    {
8337748cd8SNickeau
8437748cd8SNickeau        /**
8537748cd8SNickeau         * To log the cache used by bar
8637748cd8SNickeau         * @var \dokuwiki\Cache\CacheParser $data
8737748cd8SNickeau         */
8837748cd8SNickeau        $data = $event->data;
8937748cd8SNickeau        $result = $event->result;
9037748cd8SNickeau        $pageId = $data->page;
9137748cd8SNickeau        $cacheManager = PluginUtility::getCacheManager();
9237748cd8SNickeau        $cacheManager->addSlot($pageId, $result, $data);
9337748cd8SNickeau
9437748cd8SNickeau
9537748cd8SNickeau    }
9637748cd8SNickeau
9737748cd8SNickeau    /**
9837748cd8SNickeau     *
9937748cd8SNickeau     * @param Doku_Event $event
10037748cd8SNickeau     * @param $params
10137748cd8SNickeau     */
10237748cd8SNickeau    function purgeIfNeeded(Doku_Event $event, $params)
10337748cd8SNickeau    {
10437748cd8SNickeau
10537748cd8SNickeau        /**
10637748cd8SNickeau         * No cache for all mode
10737748cd8SNickeau         * (ie xhtml, instruction)
10837748cd8SNickeau         */
10937748cd8SNickeau        $data = &$event->data;
11037748cd8SNickeau        $pageId = $data->page;
111*1fa8c418SNickeau
112*1fa8c418SNickeau        /**
113*1fa8c418SNickeau         * For whatever reason, the cache file of XHMTL
114*1fa8c418SNickeau         * may be empty - No error found on the web server or the log.
115*1fa8c418SNickeau         *
116*1fa8c418SNickeau         * We just delete it then.
117*1fa8c418SNickeau         *
118*1fa8c418SNickeau         * It has been seen after the creation of a new page or a `move` of the page.
119*1fa8c418SNickeau         */
120*1fa8c418SNickeau        if ($data instanceof CacheRenderer) {
121*1fa8c418SNickeau            if ($data->mode === "xhtml") {
122*1fa8c418SNickeau                if (file_exists($data->cache)) {
123*1fa8c418SNickeau                    if (filesize($data->cache) === 0) {
124*1fa8c418SNickeau                        $data->depends["purge"] = true;
125*1fa8c418SNickeau                    }
126*1fa8c418SNickeau                }
127*1fa8c418SNickeau            }
128*1fa8c418SNickeau        }
12937748cd8SNickeau        /**
13037748cd8SNickeau         * Because of the recursive nature of rendering
13137748cd8SNickeau         * inside dokuwiki, we just handle the first
13237748cd8SNickeau         * rendering for a request.
13337748cd8SNickeau         *
13437748cd8SNickeau         * The first will be purged, the other one not
13537748cd8SNickeau         * because they can use the first one
13637748cd8SNickeau         */
13737748cd8SNickeau        if (!PluginUtility::getCacheManager()->isCacheLogPresent($pageId, $data->mode)) {
13837748cd8SNickeau            $expirationStringDate = p_get_metadata($pageId, CacheManager::DATE_CACHE_EXPIRATION_META_KEY, METADATA_DONT_RENDER);
13937748cd8SNickeau            if ($expirationStringDate !== null) {
14037748cd8SNickeau
14137748cd8SNickeau                $expirationDate = Iso8601Date::create($expirationStringDate)->getDateTime();
14237748cd8SNickeau                $actualDate = new DateTime();
14337748cd8SNickeau                if ($expirationDate < $actualDate) {
14437748cd8SNickeau                    /**
14537748cd8SNickeau                     * As seen in {@link Cache::makeDefaultCacheDecision()}
14637748cd8SNickeau                     * We request a purge
14737748cd8SNickeau                     */
14837748cd8SNickeau                    $data->depends["purge"] = true;
14937748cd8SNickeau                }
15037748cd8SNickeau            }
15137748cd8SNickeau        }
15237748cd8SNickeau
15337748cd8SNickeau
15437748cd8SNickeau    }
15537748cd8SNickeau
15637748cd8SNickeau    /**
15737748cd8SNickeau     * Add HTML meta to be able to debug
15837748cd8SNickeau     * @param Doku_Event $event
15937748cd8SNickeau     * @param $params
16037748cd8SNickeau     */
16137748cd8SNickeau    function addMeta(Doku_Event $event, $params)
16237748cd8SNickeau    {
16337748cd8SNickeau
16437748cd8SNickeau        $cacheManager = PluginUtility::getCacheManager();
16537748cd8SNickeau        $slots = $cacheManager->getCacheSlotResults();
16637748cd8SNickeau        foreach ($slots as $slotId => $modes) {
16737748cd8SNickeau
16837748cd8SNickeau            $cachedMode = [];
16937748cd8SNickeau            foreach ($modes as $mode => $values) {
17037748cd8SNickeau                if ($values[CacheManager::RESULT_STATUS] === true) {
17137748cd8SNickeau                    $metaContentData = $mode;
17237748cd8SNickeau                    if (!PluginUtility::isTest()) {
17337748cd8SNickeau                        /**
17437748cd8SNickeau                         * @var DateTime $dateModified
17537748cd8SNickeau                         */
17637748cd8SNickeau                        $dateModified = $values[CacheManager::DATE_MODIFIED];
17737748cd8SNickeau                        $metaContentData .= ":" . $dateModified->format('Y-m-d\TH:i:s');
17837748cd8SNickeau                    }
17937748cd8SNickeau                    $cachedMode[] = $metaContentData;
18037748cd8SNickeau                }
18137748cd8SNickeau            }
18237748cd8SNickeau
18337748cd8SNickeau            if (sizeof($cachedMode) === 0) {
18437748cd8SNickeau                $value = "nocache";
18537748cd8SNickeau            } else {
18637748cd8SNickeau                sort($cachedMode);
18737748cd8SNickeau                $value = implode(",", $cachedMode);
18837748cd8SNickeau            }
18937748cd8SNickeau
19037748cd8SNickeau            // Add cache information into the head meta
19137748cd8SNickeau            // to test
19237748cd8SNickeau            $event->data["meta"][] = array("name" => self::COMBO_CACHE_PREFIX . $slotId, "content" => hsc($value));
19337748cd8SNickeau        }
19437748cd8SNickeau
19537748cd8SNickeau    }
19637748cd8SNickeau
19737748cd8SNickeau    function close(Doku_Event $event, $params)
19837748cd8SNickeau    {
19937748cd8SNickeau        CacheManager::close();
20037748cd8SNickeau    }
20137748cd8SNickeau
202*1fa8c418SNickeau    function imageHttpCacheBefore(Doku_Event $event, $params)
203*1fa8c418SNickeau    {
204*1fa8c418SNickeau
205*1fa8c418SNickeau        if (PluginUtility::getConfValue(self::CONF_STATIC_CACHE_ENABLED, 1)) {
206*1fa8c418SNickeau            /**
207*1fa8c418SNickeau             * If there is the buster key, the infinite cache is on
208*1fa8c418SNickeau             */
209*1fa8c418SNickeau            if (isset($_GET[CacheMedia::CACHE_BUSTER_KEY])) {
210*1fa8c418SNickeau
211*1fa8c418SNickeau                /**
212*1fa8c418SNickeau                 * To avoid buggy code, we check that the value is not empty
213*1fa8c418SNickeau                 */
214*1fa8c418SNickeau                $cacheKey = $_GET[CacheMedia::CACHE_BUSTER_KEY];
215*1fa8c418SNickeau                if (!empty($cacheKey)) {
216*1fa8c418SNickeau
217*1fa8c418SNickeau                    /**
218*1fa8c418SNickeau                     * Only for Image
219*1fa8c418SNickeau                     */
220*1fa8c418SNickeau                    $mediaPath = DokuPath::createMediaPathFromId($event->data["media"]);
221*1fa8c418SNickeau                    if ($mediaPath->isImage()) {
222*1fa8c418SNickeau
223*1fa8c418SNickeau                        /**
224*1fa8c418SNickeau                         * Only for public images
225*1fa8c418SNickeau                         */
226*1fa8c418SNickeau                        if (!$mediaPath->isPublic()) {
227*1fa8c418SNickeau                            return;
228*1fa8c418SNickeau                        }
229*1fa8c418SNickeau
230*1fa8c418SNickeau                        /**
231*1fa8c418SNickeau                         * We take over the complete {@link sendFile()} function and exit
232*1fa8c418SNickeau                         *
233*1fa8c418SNickeau                         * in {@link sendFile()}, DokuWiki set the `Cache-Control` and
234*1fa8c418SNickeau                         * may exit early / send a 304 (not modified) with the function {@link http_conditionalRequest()}
235*1fa8c418SNickeau                         * Meaning that the AFTER event is never reached
236*1fa8c418SNickeau                         * that we can't send a cache control as below
237*1fa8c418SNickeau                         * header("Cache-Control: public, max-age=$infiniteMaxAge, s-maxage=$infiniteMaxAge");
238*1fa8c418SNickeau                         *
239*1fa8c418SNickeau                         * We take the control over then
240*1fa8c418SNickeau                         */
241*1fa8c418SNickeau
242*1fa8c418SNickeau                        /**
243*1fa8c418SNickeau                         * The mime
244*1fa8c418SNickeau                         */
245*1fa8c418SNickeau                        $mime = $mediaPath->getMime();
246*1fa8c418SNickeau                        header("Content-Type: {$mime}");
247*1fa8c418SNickeau
248*1fa8c418SNickeau                        /**
249*1fa8c418SNickeau                         * The cache instructions
250*1fa8c418SNickeau                         */
251*1fa8c418SNickeau                        $infiniteMaxAge = self::INFINITE_MAX_AGE;
252*1fa8c418SNickeau                        $expires = time() + $infiniteMaxAge;
253*1fa8c418SNickeau                        header('Expires: ' . gmdate("D, d M Y H:i:s", $expires) . ' GMT');
254*1fa8c418SNickeau                        header("Cache-Control: public, max-age=$infiniteMaxAge, immutable");
255*1fa8c418SNickeau                        Http::removeHeaderIfPresent("Pragma");
256*1fa8c418SNickeau
257*1fa8c418SNickeau                        /**
258*1fa8c418SNickeau                         * The Etag cache validator
259*1fa8c418SNickeau                         *
260*1fa8c418SNickeau                         * Dokuwiki {@link http_conditionalRequest()} uses only the datetime of
261*1fa8c418SNickeau                         * the file but we need to add the parameters also because they
262*1fa8c418SNickeau                         * are generated image
263*1fa8c418SNickeau                         *
264*1fa8c418SNickeau                         * Last-Modified is not needed for the same reason
265*1fa8c418SNickeau                         *
266*1fa8c418SNickeau                         */
267*1fa8c418SNickeau                        $etag = self::getEtagValue($mediaPath, $_REQUEST);
268*1fa8c418SNickeau                        header("ETag: $etag");
269*1fa8c418SNickeau
270*1fa8c418SNickeau                        /**
271*1fa8c418SNickeau                         * Conditional Request ?
272*1fa8c418SNickeau                         * We don't check on HTTP_IF_MODIFIED_SINCE because this is useless
273*1fa8c418SNickeau                         */
274*1fa8c418SNickeau                        if (isset($_SERVER['HTTP_IF_NONE_MATCH'])) {
275*1fa8c418SNickeau                            $ifNoneMatch = stripslashes($_SERVER['HTTP_IF_NONE_MATCH']);
276*1fa8c418SNickeau                            if ($ifNoneMatch && $ifNoneMatch === $etag) {
277*1fa8c418SNickeau
278*1fa8c418SNickeau                                header('HTTP/1.0 304 Not Modified');
279*1fa8c418SNickeau
280*1fa8c418SNickeau                                /**
281*1fa8c418SNickeau                                 * Clean the buffer to not produce any output
282*1fa8c418SNickeau                                 */
283*1fa8c418SNickeau                                @ob_end_clean();
284*1fa8c418SNickeau
285*1fa8c418SNickeau                                /**
286*1fa8c418SNickeau                                 * Exit
287*1fa8c418SNickeau                                 */
288*1fa8c418SNickeau                                PluginUtility::softExit("File not modified");
289*1fa8c418SNickeau                            }
290*1fa8c418SNickeau                        }
291*1fa8c418SNickeau
292*1fa8c418SNickeau                        /**
293*1fa8c418SNickeau                         * Send the file
294*1fa8c418SNickeau                         */
295*1fa8c418SNickeau                        $originalFile = $event->data["orig"]; // the original file
296*1fa8c418SNickeau                        $physicalFile = $event->data["file"]; // the file modified
297*1fa8c418SNickeau                        if (empty($physicalFile)) {
298*1fa8c418SNickeau                            $physicalFile = $originalFile;
299*1fa8c418SNickeau                        }
300*1fa8c418SNickeau
301*1fa8c418SNickeau                        /**
302*1fa8c418SNickeau                         * Download or display feature
303*1fa8c418SNickeau                         * (Taken over from SendFile)
304*1fa8c418SNickeau                         */
305*1fa8c418SNickeau                        $download = $event->data["download"];
306*1fa8c418SNickeau                        if ($download && $mime !== "image/svg+xml") {
307*1fa8c418SNickeau                            header('Content-Disposition: attachment;' . rfc2231_encode(
308*1fa8c418SNickeau                                    'filename', PhpString::basename($originalFile)) . ';'
309*1fa8c418SNickeau                            );
310*1fa8c418SNickeau                        } else {
311*1fa8c418SNickeau                            header('Content-Disposition: inline;' . rfc2231_encode(
312*1fa8c418SNickeau                                    'filename', PhpString::basename($originalFile)) . ';'
313*1fa8c418SNickeau                            );
314*1fa8c418SNickeau                        }
315*1fa8c418SNickeau
316*1fa8c418SNickeau                        /**
317*1fa8c418SNickeau                         * The vary header avoid caching
318*1fa8c418SNickeau                         * Delete it
319*1fa8c418SNickeau                         */
320*1fa8c418SNickeau                        self::deleteVaryHeader();
321*1fa8c418SNickeau
322*1fa8c418SNickeau                        /**
323*1fa8c418SNickeau                         * Use x-sendfile header to pass the delivery to compatible web servers
324*1fa8c418SNickeau                         * (Taken over from SendFile)
325*1fa8c418SNickeau                         */
326*1fa8c418SNickeau                        http_sendfile($physicalFile);
327*1fa8c418SNickeau
328*1fa8c418SNickeau                        /**
329*1fa8c418SNickeau                         * Send the file
330*1fa8c418SNickeau                         */
331*1fa8c418SNickeau                        $filePointer = @fopen($physicalFile, "rb");
332*1fa8c418SNickeau                        if ($filePointer) {
333*1fa8c418SNickeau                            http_rangeRequest($filePointer, filesize($physicalFile), $mime);
334*1fa8c418SNickeau                        } else {
335*1fa8c418SNickeau                            http_status(500);
336*1fa8c418SNickeau                            print "Could not read $physicalFile - bad permissions?";
337*1fa8c418SNickeau                        }
338*1fa8c418SNickeau
339*1fa8c418SNickeau                        /**
340*1fa8c418SNickeau                         * Stop the propagation
341*1fa8c418SNickeau                         * Unfortunately, you can't stop the default ({@link sendFile()})
342*1fa8c418SNickeau                         * because the event in fetch.php does not allow it
343*1fa8c418SNickeau                         * We exit only if not test
344*1fa8c418SNickeau                         */
345*1fa8c418SNickeau                        $event->stopPropagation();
346*1fa8c418SNickeau                        PluginUtility::softExit("File Send");
347*1fa8c418SNickeau
348*1fa8c418SNickeau                    }
349*1fa8c418SNickeau                }
350*1fa8c418SNickeau
351*1fa8c418SNickeau            }
352*1fa8c418SNickeau        }
353*1fa8c418SNickeau    }
354*1fa8c418SNickeau
355*1fa8c418SNickeau    /**
356*1fa8c418SNickeau     * @param DokuPath $mediaPath
357*1fa8c418SNickeau     * @param Array $properties - the query properties
358*1fa8c418SNickeau     * @return string
359*1fa8c418SNickeau     */
360*1fa8c418SNickeau    public static function getEtagValue(DokuPath $mediaPath, array $properties): string
361*1fa8c418SNickeau    {
362*1fa8c418SNickeau        $etagString = $mediaPath->getModifiedTime()->format('r');
363*1fa8c418SNickeau        ksort($properties);
364*1fa8c418SNickeau        foreach ($properties as $key => $value) {
365*1fa8c418SNickeau            /**
366*1fa8c418SNickeau             * Media is already on the URL
367*1fa8c418SNickeau             * tok is just added when w and h are on the url
368*1fa8c418SNickeau             * Buster is the timestamp
369*1fa8c418SNickeau             */
370*1fa8c418SNickeau            if (in_array($key, ["media","tok",CacheMedia::CACHE_BUSTER_KEY])) {
371*1fa8c418SNickeau                continue;
372*1fa8c418SNickeau            }
373*1fa8c418SNickeau            /**
374*1fa8c418SNickeau             * If empty means not used
375*1fa8c418SNickeau             */
376*1fa8c418SNickeau            if(empty($value)){
377*1fa8c418SNickeau                continue;
378*1fa8c418SNickeau            }
379*1fa8c418SNickeau            $etagString .= "$key=$value";
380*1fa8c418SNickeau        }
381*1fa8c418SNickeau        return '"' . md5($etagString) . '"';
382*1fa8c418SNickeau    }
383*1fa8c418SNickeau
384*1fa8c418SNickeau
385*1fa8c418SNickeau    /**
386*1fa8c418SNickeau     * Delete the Vary header
387*1fa8c418SNickeau     * @param Doku_Event $event
388*1fa8c418SNickeau     * @param $params
389*1fa8c418SNickeau     */
390*1fa8c418SNickeau    public static function deleteVaryFromStaticGeneratedResources(Doku_Event $event, $params)
391*1fa8c418SNickeau    {
392*1fa8c418SNickeau
393*1fa8c418SNickeau        $script = $_SERVER["SCRIPT_NAME"];
394*1fa8c418SNickeau        if (in_array($script, self::STATIC_SCRIPT_NAMES)) {
395*1fa8c418SNickeau            // To be extra sure, they must have a tseed
396*1fa8c418SNickeau            if (isset($_REQUEST["tseed"])) {
397*1fa8c418SNickeau                self::deleteVaryHeader();
398*1fa8c418SNickeau            }
399*1fa8c418SNickeau        }
400*1fa8c418SNickeau
401*1fa8c418SNickeau    }
402*1fa8c418SNickeau
403*1fa8c418SNickeau    /**
404*1fa8c418SNickeau     *
405*1fa8c418SNickeau     * No Vary: Cookie
406*1fa8c418SNickeau     * Introduced at
407*1fa8c418SNickeau     * https://github.com/splitbrain/dokuwiki/issues/1594
408*1fa8c418SNickeau     * But cache problem at:
409*1fa8c418SNickeau     * https://github.com/splitbrain/dokuwiki/issues/2520
410*1fa8c418SNickeau     *
411*1fa8c418SNickeau     */
412*1fa8c418SNickeau    public static function deleteVaryHeader(): void
413*1fa8c418SNickeau    {
414*1fa8c418SNickeau        if (PluginUtility::getConfValue(self::CONF_STATIC_CACHE_ENABLED, 1)) {
415*1fa8c418SNickeau            Http::removeHeaderIfPresent("Vary");
416*1fa8c418SNickeau        }
417*1fa8c418SNickeau    }
418*1fa8c418SNickeau
41937748cd8SNickeau
42037748cd8SNickeau}
421