1<?php
2
3use ComboStrap\CacheManager;
4use ComboStrap\CacheMedia;
5use ComboStrap\DokuPath;
6use ComboStrap\Http;
7use ComboStrap\Iso8601Date;
8use ComboStrap\PluginUtility;
9use dokuwiki\Cache\CacheRenderer;
10use dokuwiki\Utf8\PhpString;
11
12require_once(__DIR__ . '/../ComboStrap/PluginUtility.php');
13
14/**
15 * Can we use the parser cache
16 */
17class action_plugin_combo_cache extends DokuWiki_Action_Plugin
18{
19    const COMBO_CACHE_PREFIX = "combo:cache:";
20
21    /**
22     * https://www.ietf.org/rfc/rfc2616.txt
23     * To mark a response as "never expires," an origin server sends an Expires date approximately one year
24     * from the time the response is sent.
25     * HTTP/1.1 servers SHOULD NOT send Expires dates more than one year in the future.
26     *
27     * In seconds = 365*24*60*60
28     */
29    const INFINITE_MAX_AGE = 31536000;
30
31    /**
32     * Enable an infinite cache on image URL with the {@link CacheMedia::CACHE_BUSTER_KEY}
33     * present
34     */
35    const CONF_STATIC_CACHE_ENABLED = "staticCacheEnabled";
36    const CANONICAL = "cache";
37    const STATIC_SCRIPT_NAMES = ["/lib/exe/jquery.php", "/lib/exe/js.php", "/lib/exe/css.php"];
38
39    /**
40     * @param Doku_Event_Handler $controller
41     */
42    function register(Doku_Event_Handler $controller)
43    {
44
45        /**
46         * Log the cache usage and also
47         */
48        $controller->register_hook('PARSER_CACHE_USE', 'AFTER', $this, 'logCacheUsage', array());
49
50        $controller->register_hook('PARSER_CACHE_USE', 'BEFORE', $this, 'purgeIfNeeded', array());
51
52        /**
53         * Control the HTTP cache of the image
54         */
55        $controller->register_hook('MEDIA_SENDFILE', 'BEFORE', $this, 'imageHTTPCacheBefore', array());
56
57        /**
58         * To add the cache result in the header
59         */
60        $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'addMeta', array());
61
62        /**
63         * To reset the cache manager
64         * between two run in the test
65         */
66        $controller->register_hook('DOKUWIKI_DONE', 'BEFORE', $this, 'close', array());
67
68        /**
69         * To delete the VARY on css.php, jquery.php, js.php
70         */
71        $controller->register_hook('INIT_LANG_LOAD', 'BEFORE', $this, 'deleteVaryFromStaticGeneratedResources', array());
72
73
74    }
75
76    /**
77     *
78     * @param Doku_Event $event
79     * @param $params
80     */
81    function logCacheUsage(Doku_Event $event, $params)
82    {
83
84        /**
85         * To log the cache used by bar
86         * @var \dokuwiki\Cache\CacheParser $data
87         */
88        $data = $event->data;
89        $result = $event->result;
90        $pageId = $data->page;
91        $cacheManager = PluginUtility::getCacheManager();
92        $cacheManager->addSlot($pageId, $result, $data);
93
94
95    }
96
97    /**
98     *
99     * @param Doku_Event $event
100     * @param $params
101     */
102    function purgeIfNeeded(Doku_Event $event, $params)
103    {
104
105        /**
106         * No cache for all mode
107         * (ie xhtml, instruction)
108         */
109        $data = &$event->data;
110        $pageId = $data->page;
111
112        /**
113         * For whatever reason, the cache file of XHMTL
114         * may be empty - No error found on the web server or the log.
115         *
116         * We just delete it then.
117         *
118         * It has been seen after the creation of a new page or a `move` of the page.
119         */
120        if ($data instanceof CacheRenderer) {
121            if ($data->mode === "xhtml") {
122                if (file_exists($data->cache)) {
123                    if (filesize($data->cache) === 0) {
124                        $data->depends["purge"] = true;
125                    }
126                }
127            }
128        }
129        /**
130         * Because of the recursive nature of rendering
131         * inside dokuwiki, we just handle the first
132         * rendering for a request.
133         *
134         * The first will be purged, the other one not
135         * because they can use the first one
136         */
137        if (!PluginUtility::getCacheManager()->isCacheLogPresent($pageId, $data->mode)) {
138            $expirationStringDate = p_get_metadata($pageId, CacheManager::DATE_CACHE_EXPIRATION_META_KEY, METADATA_DONT_RENDER);
139            if ($expirationStringDate !== null) {
140
141                $expirationDate = Iso8601Date::create($expirationStringDate)->getDateTime();
142                $actualDate = new DateTime();
143                if ($expirationDate < $actualDate) {
144                    /**
145                     * As seen in {@link Cache::makeDefaultCacheDecision()}
146                     * We request a purge
147                     */
148                    $data->depends["purge"] = true;
149                }
150            }
151        }
152
153
154    }
155
156    /**
157     * Add HTML meta to be able to debug
158     * @param Doku_Event $event
159     * @param $params
160     */
161    function addMeta(Doku_Event $event, $params)
162    {
163
164        $cacheManager = PluginUtility::getCacheManager();
165        $slots = $cacheManager->getCacheSlotResults();
166        foreach ($slots as $slotId => $modes) {
167
168            $cachedMode = [];
169            foreach ($modes as $mode => $values) {
170                if ($values[CacheManager::RESULT_STATUS] === true) {
171                    $metaContentData = $mode;
172                    if (!PluginUtility::isTest()) {
173                        /**
174                         * @var DateTime $dateModified
175                         */
176                        $dateModified = $values[CacheManager::DATE_MODIFIED];
177                        $metaContentData .= ":" . $dateModified->format('Y-m-d\TH:i:s');
178                    }
179                    $cachedMode[] = $metaContentData;
180                }
181            }
182
183            if (sizeof($cachedMode) === 0) {
184                $value = "nocache";
185            } else {
186                sort($cachedMode);
187                $value = implode(",", $cachedMode);
188            }
189
190            // Add cache information into the head meta
191            // to test
192            $event->data["meta"][] = array("name" => self::COMBO_CACHE_PREFIX . $slotId, "content" => hsc($value));
193        }
194
195    }
196
197    function close(Doku_Event $event, $params)
198    {
199        CacheManager::close();
200    }
201
202    function imageHttpCacheBefore(Doku_Event $event, $params)
203    {
204
205        if (PluginUtility::getConfValue(self::CONF_STATIC_CACHE_ENABLED, 1)) {
206            /**
207             * If there is the buster key, the infinite cache is on
208             */
209            if (isset($_GET[CacheMedia::CACHE_BUSTER_KEY])) {
210
211                /**
212                 * To avoid buggy code, we check that the value is not empty
213                 */
214                $cacheKey = $_GET[CacheMedia::CACHE_BUSTER_KEY];
215                if (!empty($cacheKey)) {
216
217                    /**
218                     * Only for Image
219                     */
220                    $mediaPath = DokuPath::createMediaPathFromId($event->data["media"]);
221                    if ($mediaPath->isImage()) {
222
223                        /**
224                         * Only for public images
225                         */
226                        if (!$mediaPath->isPublic()) {
227                            return;
228                        }
229
230                        /**
231                         * We take over the complete {@link sendFile()} function and exit
232                         *
233                         * in {@link sendFile()}, DokuWiki set the `Cache-Control` and
234                         * may exit early / send a 304 (not modified) with the function {@link http_conditionalRequest()}
235                         * Meaning that the AFTER event is never reached
236                         * that we can't send a cache control as below
237                         * header("Cache-Control: public, max-age=$infiniteMaxAge, s-maxage=$infiniteMaxAge");
238                         *
239                         * We take the control over then
240                         */
241
242                        /**
243                         * The mime
244                         */
245                        $mime = $mediaPath->getMime();
246                        header("Content-Type: {$mime}");
247
248                        /**
249                         * The cache instructions
250                         */
251                        $infiniteMaxAge = self::INFINITE_MAX_AGE;
252                        $expires = time() + $infiniteMaxAge;
253                        header('Expires: ' . gmdate("D, d M Y H:i:s", $expires) . ' GMT');
254                        header("Cache-Control: public, max-age=$infiniteMaxAge, immutable");
255                        Http::removeHeaderIfPresent("Pragma");
256
257                        /**
258                         * The Etag cache validator
259                         *
260                         * Dokuwiki {@link http_conditionalRequest()} uses only the datetime of
261                         * the file but we need to add the parameters also because they
262                         * are generated image
263                         *
264                         * Last-Modified is not needed for the same reason
265                         *
266                         */
267                        $etag = self::getEtagValue($mediaPath, $_REQUEST);
268                        header("ETag: $etag");
269
270                        /**
271                         * Conditional Request ?
272                         * We don't check on HTTP_IF_MODIFIED_SINCE because this is useless
273                         */
274                        if (isset($_SERVER['HTTP_IF_NONE_MATCH'])) {
275                            $ifNoneMatch = stripslashes($_SERVER['HTTP_IF_NONE_MATCH']);
276                            if ($ifNoneMatch && $ifNoneMatch === $etag) {
277
278                                header('HTTP/1.0 304 Not Modified');
279
280                                /**
281                                 * Clean the buffer to not produce any output
282                                 */
283                                @ob_end_clean();
284
285                                /**
286                                 * Exit
287                                 */
288                                PluginUtility::softExit("File not modified");
289                            }
290                        }
291
292                        /**
293                         * Send the file
294                         */
295                        $originalFile = $event->data["orig"]; // the original file
296                        $physicalFile = $event->data["file"]; // the file modified
297                        if (empty($physicalFile)) {
298                            $physicalFile = $originalFile;
299                        }
300
301                        /**
302                         * Download or display feature
303                         * (Taken over from SendFile)
304                         */
305                        $download = $event->data["download"];
306                        if ($download && $mime !== "image/svg+xml") {
307                            header('Content-Disposition: attachment;' . rfc2231_encode(
308                                    'filename', PhpString::basename($originalFile)) . ';'
309                            );
310                        } else {
311                            header('Content-Disposition: inline;' . rfc2231_encode(
312                                    'filename', PhpString::basename($originalFile)) . ';'
313                            );
314                        }
315
316                        /**
317                         * The vary header avoid caching
318                         * Delete it
319                         */
320                        self::deleteVaryHeader();
321
322                        /**
323                         * Use x-sendfile header to pass the delivery to compatible web servers
324                         * (Taken over from SendFile)
325                         */
326                        http_sendfile($physicalFile);
327
328                        /**
329                         * Send the file
330                         */
331                        $filePointer = @fopen($physicalFile, "rb");
332                        if ($filePointer) {
333                            http_rangeRequest($filePointer, filesize($physicalFile), $mime);
334                        } else {
335                            http_status(500);
336                            print "Could not read $physicalFile - bad permissions?";
337                        }
338
339                        /**
340                         * Stop the propagation
341                         * Unfortunately, you can't stop the default ({@link sendFile()})
342                         * because the event in fetch.php does not allow it
343                         * We exit only if not test
344                         */
345                        $event->stopPropagation();
346                        PluginUtility::softExit("File Send");
347
348                    }
349                }
350
351            }
352        }
353    }
354
355    /**
356     * @param DokuPath $mediaPath
357     * @param Array $properties - the query properties
358     * @return string
359     */
360    public static function getEtagValue(DokuPath $mediaPath, array $properties): string
361    {
362        $etagString = $mediaPath->getModifiedTime()->format('r');
363        ksort($properties);
364        foreach ($properties as $key => $value) {
365            /**
366             * Media is already on the URL
367             * tok is just added when w and h are on the url
368             * Buster is the timestamp
369             */
370            if (in_array($key, ["media","tok",CacheMedia::CACHE_BUSTER_KEY])) {
371                continue;
372            }
373            /**
374             * If empty means not used
375             */
376            if(empty($value)){
377                continue;
378            }
379            $etagString .= "$key=$value";
380        }
381        return '"' . md5($etagString) . '"';
382    }
383
384
385    /**
386     * Delete the Vary header
387     * @param Doku_Event $event
388     * @param $params
389     */
390    public static function deleteVaryFromStaticGeneratedResources(Doku_Event $event, $params)
391    {
392
393        $script = $_SERVER["SCRIPT_NAME"];
394        if (in_array($script, self::STATIC_SCRIPT_NAMES)) {
395            // To be extra sure, they must have a tseed
396            if (isset($_REQUEST["tseed"])) {
397                self::deleteVaryHeader();
398            }
399        }
400
401    }
402
403    /**
404     *
405     * No Vary: Cookie
406     * Introduced at
407     * https://github.com/splitbrain/dokuwiki/issues/1594
408     * But cache problem at:
409     * https://github.com/splitbrain/dokuwiki/issues/2520
410     *
411     */
412    public static function deleteVaryHeader(): void
413    {
414        if (PluginUtility::getConfValue(self::CONF_STATIC_CACHE_ENABLED, 1)) {
415            Http::removeHeaderIfPresent("Vary");
416        }
417    }
418
419
420}
421