xref: /plugin/combo/action/cache.php (revision c3437056399326d621a01da73b649707fbb0ae69)
1<?php
2
3use ComboStrap\AnalyticsDocument;
4use ComboStrap\CacheExpirationDate;
5use ComboStrap\CacheManager;
6use ComboStrap\CacheMedia;
7use ComboStrap\Cron;
8use ComboStrap\ExceptionCombo;
9use ComboStrap\File;
10use ComboStrap\Http;
11use ComboStrap\Iso8601Date;
12use ComboStrap\LogUtility;
13use ComboStrap\MetadataDokuWikiStore;
14use ComboStrap\Page;
15use ComboStrap\PageDescription;
16use ComboStrap\PageH1;
17use ComboStrap\ResourceName;
18use ComboStrap\PageTitle;
19use ComboStrap\PluginUtility;
20use ComboStrap\TplUtility;
21use dokuwiki\Cache\CacheRenderer;
22
23require_once(__DIR__ . '/../ComboStrap/PluginUtility.php');
24
25/**
26 * Can we use the parser cache
27 */
28class action_plugin_combo_cache extends DokuWiki_Action_Plugin
29{
30    const COMBO_CACHE_PREFIX = "combo:cache:";
31
32
33    const CANONICAL = "cache";
34    const STATIC_SCRIPT_NAMES = ["/lib/exe/jquery.php", "/lib/exe/js.php", "/lib/exe/css.php"];
35
36    /**
37     * @var string[]
38     */
39    private static $sideSlotNames;
40
41
42    private static function getSideSlotNames(): array
43    {
44        if (self::$sideSlotNames === null) {
45            global $conf;
46
47            self::$sideSlotNames = [
48                $conf['sidebar']
49            ];
50
51            /**
52             * @see {@link \ComboStrap\TplConstant::CONF_SIDEKICK}
53             */
54            $loaded = PluginUtility::loadStrapUtilityTemplateIfPresentAndSameVersion();
55            if ($loaded) {
56
57                $sideKickSlotPageName = TplUtility::getSideKickSlotPageName();
58                if (!empty($sideKickSlotPageName)) {
59                    self::$sideSlotNames[] = $sideKickSlotPageName;
60                }
61
62            }
63        }
64        return self::$sideSlotNames;
65    }
66
67    private static function removeSideSlotCache()
68    {
69        $sidebars = self::getSideSlotNames();
70
71
72        /**
73         * Delete the cache for the sidebar
74         */
75        foreach ($sidebars as $sidebarRelativePath) {
76
77            $page = Page::createPageFromNonQualifiedPath($sidebarRelativePath);
78            $page->deleteCache();
79
80        }
81    }
82
83    /**
84     * @param Doku_Event_Handler $controller
85     */
86    function register(Doku_Event_Handler $controller)
87    {
88
89        /**
90         * Log the cache usage and also
91         */
92        $controller->register_hook('PARSER_CACHE_USE', 'AFTER', $this, 'logCacheUsage', array());
93
94        $controller->register_hook('PARSER_CACHE_USE', 'BEFORE', $this, 'pageCacheExpiration', array());
95
96        /**
97         * To add the cache result in the HTML
98         */
99        $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'addCacheLogHtmlDataBlock', array());
100
101        /**
102         * To reset the cache manager
103         * between two run in the test
104         */
105        $controller->register_hook('DOKUWIKI_DONE', 'BEFORE', $this, 'close', array());
106
107        /**
108         * To delete the VARY on css.php, jquery.php, js.php
109         */
110        $controller->register_hook('INIT_LANG_LOAD', 'BEFORE', $this, 'deleteVaryFromStaticGeneratedResources', array());
111
112        /**
113         * To delete sidebar (cache) cache when a page was modified in a namespace
114         * https://combostrap.com/sideslots
115         */
116        $controller->register_hook(MetadataDokuWikiStore::PAGE_METADATA_MUTATION_EVENT, 'AFTER', $this, 'sideSlotsCacheBurstingForMetadataMutation', array());
117        $controller->register_hook('IO_WIKIPAGE_WRITE', 'BEFORE', $this, 'sideSlotsCacheBurstingForPageCreationAndDeletion', array());
118
119    }
120
121    /**
122     *
123     * @param Doku_Event $event
124     * @param $params
125     */
126    function logCacheUsage(Doku_Event $event, $params)
127    {
128
129        /**
130         * To log the cache used by bar
131         * @var \dokuwiki\Cache\CacheParser $data
132         */
133        $data = $event->data;
134        $result = $event->result;
135        $slotId = $data->page;
136        $cacheManager = PluginUtility::getCacheManager();
137        $cacheManager->addSlotForRequestedPage($slotId, $result, $data);
138
139
140    }
141
142    /**
143     *
144     * Purge the cache if needed
145     * @param Doku_Event $event
146     * @param $params
147     */
148    function pageCacheExpiration(Doku_Event $event, $params)
149    {
150
151        /**
152         * No cache for all mode
153         * (ie xhtml, instruction)
154         */
155        $data = &$event->data;
156        $pageId = $data->page;
157
158        /**
159         * For whatever reason, the cache file of XHTML
160         * may be empty - No error found on the web server or the log.
161         *
162         * We just delete it then.
163         *
164         * It has been seen after the creation of a new page or a `move` of the page.
165         */
166        if ($data instanceof CacheRenderer) {
167            if ($data->mode === "xhtml") {
168                if (file_exists($data->cache)) {
169                    if (filesize($data->cache) === 0) {
170                        $data->depends["purge"] = true;
171                    }
172                }
173            }
174        }
175        /**
176         * Because of the recursive nature of rendering
177         * inside dokuwiki, we just handle the first
178         * rendering for a request.
179         *
180         * The first will be purged, the other one not
181         * because they can't use the first one
182         */
183        if (!PluginUtility::getCacheManager()->isCacheLogPresentForSlot($pageId, $data->mode)) {
184            $page = Page::createPageFromId($pageId);
185            $cacheExpirationFrequency = $page->getCacheExpirationFrequency();
186            if ($cacheExpirationFrequency === null) {
187                return;
188            }
189
190            $expirationDate = CacheExpirationDate::createForPage($page)
191                ->getValue();
192
193            if ($expirationDate === null) {
194                try {
195                    $expirationDate = Cron::getDate($cacheExpirationFrequency);
196                    $page->setCacheExpirationDate($expirationDate);
197                } catch (ExceptionCombo $e) {
198                    LogUtility::msg("The cache expiration frequency ($cacheExpirationFrequency) is not a valid cron expression");
199                }
200            }
201            if ($expirationDate !== null) {
202
203                $actualDate = new DateTime();
204                if ($expirationDate < $actualDate) {
205                    /**
206                     * As seen in {@link Cache::makeDefaultCacheDecision()}
207                     * We request a purge
208                     */
209                    $data->depends["purge"] = true;
210
211                    /**
212                     * Calculate a new expiration date
213                     */
214                    try {
215                        $newDate = Cron::getDate($cacheExpirationFrequency);
216                        if ($newDate < $actualDate) {
217                            LogUtility::msg("The new calculated date cache expiration frequency ({$newDate->format(Iso8601Date::getFormat())}) is lower than the current date ({$actualDate->format(Iso8601Date::getFormat())})");
218                        }
219                        $page->setCacheExpirationDate($newDate);
220                    } catch (ExceptionCombo $e) {
221                        LogUtility::msg("The cache expiration frequency ($cacheExpirationFrequency) is not a value cron expression");
222                    }
223                }
224            }
225        }
226
227
228    }
229
230    /**
231     * Add HTML meta to be able to debug
232     * @param Doku_Event $event
233     * @param $params
234     */
235    function addCacheLogHtmlDataBlock(Doku_Event $event, $params)
236    {
237
238        $cacheManager = PluginUtility::getCacheManager();
239        $cacheSlotResults = $cacheManager->getCacheSlotResultsAsHtmlDataBlockArray();
240        $cacheJson = \ComboStrap\Json::createFromArray($cacheSlotResults);
241
242        if (PluginUtility::isDevOrTest()) {
243            $result = $cacheJson->toPrettyJsonString();
244        } else {
245            $result = $cacheJson->toMinifiedJsonString();
246        }
247
248        $event->data["script"][] = array(
249            "type" => CacheManager::APPLICATION_COMBO_CACHE_JSON,
250            "_data" => $result,
251        );
252
253    }
254
255    function close(Doku_Event $event, $params)
256    {
257        CacheManager::reset();
258    }
259
260
261    /**
262     * Delete the Vary header
263     * @param Doku_Event $event
264     * @param $params
265     */
266    public static function deleteVaryFromStaticGeneratedResources(Doku_Event $event, $params)
267    {
268
269        $script = $_SERVER["SCRIPT_NAME"];
270        if (in_array($script, self::STATIC_SCRIPT_NAMES)) {
271            // To be extra sure, they must have the buster key
272            if (isset($_REQUEST[CacheMedia::CACHE_BUSTER_KEY])) {
273                self::deleteVaryHeader();
274            }
275        }
276
277    }
278
279    /**
280     *
281     * No Vary: Cookie
282     * Introduced at
283     * https://github.com/splitbrain/dokuwiki/issues/1594
284     * But cache problem at:
285     * https://github.com/splitbrain/dokuwiki/issues/2520
286     *
287     */
288    public static function deleteVaryHeader(): void
289    {
290        if (PluginUtility::getConfValue(action_plugin_combo_staticresource::CONF_STATIC_CACHE_ENABLED, 1)) {
291            Http::removeHeaderIfPresent("Vary");
292        }
293    }
294
295    function sideSlotsCacheBurstingForMetadataMutation($event)
296    {
297
298        $data = $event->data;
299        /**
300         * The side slot cache is deleted only when the
301         * below property are updated
302         */
303        $descriptionProperties = [PageTitle::PROPERTY_NAME, ResourceName::PROPERTY_NAME, PageH1::PROPERTY_NAME, PageDescription::DESCRIPTION_PROPERTY];
304        if (!in_array($data["name"], $descriptionProperties)) return;
305
306        self::removeSideSlotCache();
307
308    }
309
310    /**
311     * @param $event
312     * @throws Exception
313     * @link https://www.dokuwiki.org/devel:event:io_wikipage_write
314     */
315    function sideSlotsCacheBurstingForPageCreationAndDeletion($event)
316    {
317
318        $data = $event->data;
319        $pageName = $data[2];
320
321        /**
322         * Modification to the side slot is not processed further
323         */
324        if (in_array($pageName, self::getSideSlotNames())) return;
325
326        /**
327         * Pointer to see if we need to delete the cache
328         */
329        $doWeNeedToDeleteTheSideSlotCache = false;
330
331        /**
332         * File creation
333         *
334         * ```
335         * Page creation may be detected by checking if the file already exists and the revision is false.
336         * ```
337         * From https://www.dokuwiki.org/devel:event:io_wikipage_write
338         *
339         */
340        $rev = $data[3];
341        $filePath = $data[0][0];
342        $file = File::createFromPath($filePath);
343        if (!$file->exists() && $rev === false) {
344            $doWeNeedToDeleteTheSideSlotCache = true;
345        }
346
347        /**
348         * File deletion
349         * (No content)
350         *
351         * ```
352         * Page deletion may be detected by checking for empty page content.
353         * On update to an existing page this event is called twice, once for the transfer of the old version to the attic (rev will have a value)
354         * and once to write the new version of the page into the wiki (rev is false)
355         * ```
356         * From https://www.dokuwiki.org/devel:event:io_wikipage_write
357         */
358        $append = $data[0][2];
359        if (!$append) {
360
361            $content = $data[0][1];
362            if (empty($content) && $rev === false) {
363                // Deletion
364                $doWeNeedToDeleteTheSideSlotCache = true;
365            }
366
367        }
368
369        if ($doWeNeedToDeleteTheSideSlotCache) self::removeSideSlotCache();
370
371    }
372
373}
374