1<?php
2/**
3 * RRDGraph Plugin: Helper classes
4 *
5 * @author Daniel Goß <developer@flashsystems.de>
6 * @license MIT
7 */
8
9require_once ('inc/errorimage.php');
10require_once ('inc/rpncomputer.php');
11require_once ('inc/svgbinding.php');
12require_once ('inc/contenttypes.php');
13
14/**
15 * Base class for all cache implementations within the RRDGraph plugin.
16 * This class is derived from the DokuWiki cache class.
17 * It implements the dependency handling mechanism that is needed for the
18 * rrd INCLUDE tag.
19 */
20abstract class cache_rrdgraphbase extends cache {
21	/** @var String Page-Number of the page that is managed by this cache instance. */
22    private $pageId;
23
24    /** @var String Name of the plugin using this cache. This value is used to get the dependencies metadata. */
25    private $pluginName;
26
27    /**
28     * C'tor
29     * @param String $pluginName The name of the plugin. This can be retrieved by getPluginName() and makes the plugin more robust to renaming.
30     * @param String $pageId The wiki id of the page the cached content is on.
31     * @param String $key Uniq value identifying the cached content on the page provied by $pageId. The identifier is hashed before being used.
32     * @param String $ext The extension of the cache file.
33     */
34    public function __construct($pluginName, $pageId, $key, $ext) {
35        $this->pageId = $pageId;
36        $this->pluginName = $pluginName;
37
38        parent::__construct($pageId . '/' . $key, $ext);
39    }
40
41    /**
42     * Adds the dependencies from the plugin_[name] -> dependencies metadata element.
43     * This way the included dependencies of the rrd graphs on a page can be tracked.
44     */
45    protected function _addDependencies() {
46        $files = array (
47                wikiFN($this->pageId)
48        );
49
50        //-- We oversimplify this a litte and add all dependencies of the current page to very image
51        //   without distinction between the recipies.
52        //   But if one include is changed recalculating all images only generates litte overhead because
53        //   they are regenerated every time after a cache timeout.
54        $dependencies = p_get_metadata($this->pageId, 'plugin_' . $this->pluginName . ' dependencies');
55
56        if (! empty($dependencies)) {
57            foreach ($dependencies as $dependency) {
58                $files[] = wikiFN($dependency);
59            }
60        }
61
62        if (! array_key_exists('files', $this->depends))
63            $this->depends['files'] = $files;
64        else
65            $this->depends['files'] = array_merge($files, $this->depends['files']);
66
67        parent::_addDependencies();
68    }
69}
70
71/**
72 * This cache class manages the rrd recipe cache.
73 * This cache only times out if the recipe changes.
74 *
75 */
76class cache_rrdgraph extends cache_rrdgraphbase {
77    /**
78     * C'tor
79     * @param String $pluginName The name of the plugin. This can be retrieved by getPluginName() and makes the plugin more robust to renaming.
80     * @param String $pageId The wiki id of the page the cached content is on.
81     * @param String $recipeName An identifier used to identify the cache recipe on the page provied by pageId. The identifier is hashed before being used.
82     */
83    public function __construct($pluginName, $pageId, $recipeName) {
84        $this->pluginName = $pluginName;
85
86        parent::__construct($pluginName, $pageId, $recipeName, ".rrd");
87    }
88}
89
90/**
91 * This cache class manages the images generated by the plugin.
92 * The cached images are used as long as the recipe does not change and the maximum age (config) is not reached.
93 *
94 */
95class cache_rrdgraphimage extends cache_rrdgraphbase {
96    /** @var Integer Maximum age of the image to be considered usable. */
97    private $maxAge;
98
99    /**
100     * C'tor
101     * @param String $pluginName The name of the plugin. This can be retrieved by getPluginName() and makes the plugin more robust to renaming.
102     * @param String $pageId The wiki id of the page the cached content is on.
103     * @param String $extension The extension of the image file without a trailing dot.
104     * @param String $recipeName An identifier used to identify the cache recipe on the page provied by pageId. The identifier is hashed before being used.
105     * @param Integer $rangeNr ID of the time range this image is cached for.
106     * @param String $conditions An identifier for the conditions used for creating the image (fullscreen, etc.).
107     * @param Integer $maxAge Maximum age of the image in seconds. If the image is older than the given age, it is not used and must be recreated.
108     */
109    public function __construct($pluginName, $pageId, $extension, $recipeName, $rangeNr, $conditions, $maxAge) {
110        $this->maxAge = $maxAge;
111        $extension = strtolower($extension);
112
113        parent::__construct($pluginName, $pageId, $recipeName . '/' . $conditions . '/' . $rangeNr, "." . $extension);
114    }
115
116    /**
117     * Determins the name of the file used for caching. This name can be used to pass it to other functions to update the content of the cache.
118     * @returns Returns the name and path of the cache file.
119     */
120    public function getCacheFileName() {
121        return $this->cache;
122    }
123
124    /**
125     * (non-PHPdoc)
126     * @see cache_rrdgraphbase::_addDependencies()
127     */
128    protected function _addDependencies() {
129        //-- Set maximum age.
130        $this->depends['age'] = $this->maxAge;
131
132        parent::_addDependencies();
133    }
134
135    /**
136     * Returns the time until this image is valid.
137     * If the cache file does not exist (the data was never cached) 0 is returned.
138     * @return Integer Unix timestamp when this image is no longer valid.
139     */
140    public function getValidUntil() {
141        if ($this->useCache()) {
142            return $this->_time + $this->maxAge;
143        } else {
144            return 0;
145        }
146    }
147
148    /**
149     * Determins the last modification time of the cache data.
150     * If the cache file does not exist (the data was never cached) the current time is returned.
151     * @return Integer Unix timestamp of the last modification time of the cached file.
152     */
153    public function getLastModified() {
154        if (empty($this->_time))
155            return time();
156        else
157            return $this->_time;
158    }
159}
160
161/**
162 * Stores information about a rrd image. This information can be used to update the
163 * cache image file. To load the image file and to construct HTTP headers for tramsmission.
164 *
165 */
166class rrdgraph_image_info {
167	/** @var String Name of the rrd image file within the cache. */
168    private $fileName;
169
170    /** @var Resource File handle used to lock the file. */
171    private $fileHandle;
172
173    /** @var Integer Timestamp until the file named by $fileName ist considered valid. */
174    private $validUntil;
175
176    /** @var Integer Timestamp when the file named by $fileName was last updated. */
177    private $lastModified;
178
179    /**
180     * C'tor
181     * @param String $fileName Sets the $fileName value.
182     * @param Integer $validUntil Sets the $validUntil value.
183     * @param Integer $lastModified Sets the $lastModfiied value.
184     */
185    public function __construct($fileName, $validUntil, $lastModified) {
186        $this->fileName = $fileName;
187        $this->validUntil = $validUntil;
188        $this->lastModified = $lastModified;
189
190        //-- Get a shared lock on the lock-file.
191        $this->fileHandle = fopen($fileName . ".lock", "w+");
192        flock($this->fileHandle, LOCK_SH);
193    }
194
195    /**
196     * D'tor
197     */
198    public function __destruct() {
199        fclose($this->fileHandle);
200    }
201
202    /**
203     * @see cache_rrdgraphimage::getCacheFileName()
204     */
205    public function getFileName() {
206        return $this->fileName;
207    }
208
209    /**
210     * @see cache_rrdgraphimage::getValidUntil()
211     */
212    public function getValidUntil() {
213        return $this->validUntil;
214    }
215
216    /**
217     * @see cache_rrdgraphimage::getLastModified()
218     */
219    public function getLastModified() {
220        return $this->lastModified;
221    }
222
223    /**
224     * Checks if the cached file returned by getFileName() is still valid.
225     * @return boolean Returns "true" if the cached file should still be used or "false" if it must be recreated.
226     */
227    public function isValid() {
228        return $this->validUntil > time();
229    }
230
231    public function upgradeLock() {
232        flock($this->fileHandle, LOCK_EX);
233    }
234}
235
236/**
237 * DokiWuki helper plugin class. This class supplies some methods used throughout the other RRDGraph plugin modules.
238 *
239 */
240class helper_plugin_rrdgraph extends DokuWiki_Plugin {
241    /** @var string Mode for embedding the graph into a rendered HTML page. */
242    const MODE_GRAPH_EMBEDDED = 'e';
243    /** @var string Mode for showing the graph fullscreen. */
244    const MODE_GRAPH_FULLSCREEN = 'fs';
245    /** @var string Mode for generating a SVG image with data binding.. */
246    const MODE_BINDSVG = 'b';
247
248    /** @var Array Cache for already loaded and inflated recipes. This speeds up loading the same recipe multiple times on the same wiki page */
249    private $localRecipeCache;
250
251    /**
252     * Returns an array of method declarations for docuwiki.
253     * @see https://www.dokuwiki.org/devel:helper_plugins
254     * @return Returns the declaration array.
255     */
256    public function getMethods() {
257        //-- Non of the contained functions are for public use!
258        return array();
259    }
260
261    /**
262     * Stores a rrd recipe for the given page.
263     * @param String $pageId Wiki page id.
264     * @param String $recipeName Name of the recipe to store.
265     * @param Array $recipeData Array of recipe data to be stored.
266     */
267    public function storeRecipe($pageId, $recipeName, $recipeData) {
268        //-- Put the file into the cache.
269        $cache = new cache_rrdgraph($this->getPluginName(), $pageId, $recipeName);
270        $cache->storeCache(serialize($recipeData));
271
272        $this->localRecipeCache[$pageId . "/" . $recipeName] = $recipeData;
273    }
274
275    /**
276     * Load a gieven rrd recipe. If the recipe is not available within the cache or needs to be updated the wiki page is rendered
277     * to give the syntax plugin a chance to create and cache the rrd data.
278     * @param String $pageId Wiki page id.
279     * @param String $recipeName Name of the recipe to load.
280     * @returns Array Returns an array containing an rrd recipe. If the recipe can not be found or recreated this method returns null.
281     */
282    public function fetchRecipe($pageId, $recipeName) {
283        if (! isset($this->localRecipeCache[$pageId . "/" . $recipeName])) {
284            $cache = new cache_rrdgraph($this->getPluginName(), $pageId, $recipeName);
285            if ($cache->useCache()) {
286                $this->localRecipeCache[$pageId . "/" . $recipeName] = unserialize($cache->retrieveCache());
287            } else {
288                //-- The rrd-information is not cached. Render the page
289                //   to refresh the stored rrd information.
290                p_wiki_xhtml($pageId);
291
292                //-- Try again to get the data
293                $this->localRecipeCache[$pageId . "/" . $recipeName] = unserialize($cache->retrieveCache());
294            }
295        }
296
297        if (empty($this->localRecipeCache[$pageId . "/" . $recipeName])) $this->localRecipeCache[$pageId . "/" . $recipeName] = null;
298
299        return $this->localRecipeCache[$pageId . "/" . $recipeName];
300    }
301
302    /**
303     * Inflates a given recipe.
304     * When a recipe is inflated, included recipes are automatically loaded (and rendered if necessary) and included into the given recipe.
305     * @param Array $recipe A rrd recipe. If this value is not an array, null is returned.
306     * @return Array If the recipe could be successfully inflate, the recipe is returned with all includes replaced by the included elements.
307     * @throws Exception If an error occures (if the ACL does not allow loading an included recpipe) an exception is thrown.
308     */
309    public function inflateRecipe($recipe) {
310        if (! is_array($recipe)) return null;
311
312        //-- Cache the setting if ACLs should be checked for includes.
313        $checkACL = ($this->getConf('include_acl') > 0);
314
315        //-- Resolve includes
316        $inflatedRecipe = array ();
317        $includeDone = false;
318        foreach ($recipe as $element) {
319            switch (strtoupper($element[1])) {
320            case 'INCLUDE' :
321                list ($incPageId, $incTmplName) = explode('>', $element[2], 2);
322                $incPageId = trim($incPageId);
323                $incTmplName = trim($incTmplName);
324
325                if ($checkACL) {
326                    if (auth_quickaclcheck($incPageId) < AUTH_READ) throw new Exception("Access denied by ACL.");
327                }
328
329                $includedPageRecipe = $this->fetchRecipe($incPageId, $incTmplName);
330                if ($includedPageRecipe !== null) {
331                    $inflatedRecipe = array_merge($inflatedRecipe, $includedPageRecipe);
332                }
333                break;
334            default :
335                $inflatedRecipe[] = $element;
336            }
337        }
338
339        $recipe = $inflatedRecipe;
340
341        return $recipe;
342    }
343
344    /**
345     * Parses a recipe and returns the wiki page ids of all included recipes.
346     * @param Array $recipe The rrd recipe to parse.
347     * @return Array A string array continaing all page ids included by the given recipe.
348     */
349    public function getDependencies($recipe) {
350        $depPageIds = array ();
351
352        foreach ($recipe as $element) {
353            if (strcasecmp($element[1], 'INCLUDE') == 0) {
354                list ($incPageId, $incTmplName) = explode('>', $element[2], 2);
355                $incPageId = trim($incPageId);
356
357                $depPageIds[$incPageId] = $incPageId;
358                break;
359            }
360        }
361
362        return array_values($depPageIds);
363    }
364
365    /**
366     * Returns a rrdgraph_image_info instance contianing the information needed to deliver or recreate the given png rrd image.
367     * @param String $pageId The wiki id of the page the cached content is on.
368     * @param String $recipeName An identifier used to identify the cache recipe on the page provied by pageId. The identifier is hashed before being used.
369     * @param String $extension The extension of the image file without a trailing dot.
370     * @param Integer $rangeNr ID of the time range this image is cached for.
371     * @param String $conditions An identifier for the conditions used for creating the image (fullscreen, etc.).
372     */
373    public function getImageCacheInfo($pageId, $recipeName, $extension, $rangeNr, $conditions) {
374        $cache = new cache_rrdgraphimage($this->getPluginName(), $pageId, $extension, $recipeName, $rangeNr, $conditions, $this->getConf('cache_timeout'));
375
376        return new rrdgraph_image_info($cache->getCacheFileName(), $cache->getValidUntil(), $cache->getLastModified());
377    }
378
379    /**
380     * Sends the Graph specified by its parameters to the webbrowser. Make sure that after calling this function no
381     * other output is transmitted or the image will be corrupted.
382     * This function does cache management and all other stuff, too.
383     * @param string $pageId The wiki id of the page the rrd graph is defined in.
384     * @param string $graphId The identifier of the rrd graph to render and send.
385     * @param integer $rangeNr ID of the time range to send the graph for.
386     * @param string $mode Mode to use for generating the graph (MODE_GRAPH_EMBEDDED, MODE_GRAPH_FULLSCREEN, MODE_BINDSVG).
387     * @param string $bindingSource For MODE_BINDSVG the source SVG image for data binding must be specified as a DokuWiki media ressource.
388     * @throws Exception
389     * @return Ambigous <>
390     */
391    public function sendRrdImage($pageId, $graphId, $rangeNr = 0, $mode = helper_plugin_rrdgraph::MODE_GRAPH_EMBEDDED, $bindingSource = null)
392    {
393        //-- User abort must be ignored because we're building new images for the cache. If the
394        //   user aborts this process, the cache may be corrupted.
395        @ignore_user_abort(true);
396
397        try {
398            //-- ACL-Check
399            if (auth_quickaclcheck($pageId) < AUTH_READ) throw new Exception("Access denied by ACL.");
400
401            //-- Currently only fs, b and e are supported modes.
402            if (($mode != helper_plugin_rrdgraph::MODE_GRAPH_FULLSCREEN) && ($mode != helper_plugin_rrdgraph::MODE_BINDSVG)) $mode = helper_plugin_rrdgraph::MODE_GRAPH_EMBEDDED;
403
404            //-- If the mode is "b" then $bindingSource must be set and accessible
405            if ($mode == helper_plugin_rrdgraph::MODE_BINDSVG) {
406                if ($bindingSource == null) throw new Exception("Binding source missing.");
407                if (auth_quickaclcheck($bindingSource) < AUTH_READ) throw new Exception("Access denied by ACL.");
408            }
409
410            //-- Check if the cached image is still valid. If this is not the case, recreate it.
411            $cacheInfo = $this->getImageCacheInfo($pageId, $graphId, ($mode == helper_plugin_rrdgraph::MODE_BINDSVG)?"svg":"png", $rangeNr, $mode);
412            if (! $cacheInfo->isValid()) {
413
414                //-- We found we should update the file. Upgrade our lock to an exclusive one.
415                //   This way we OWN the lockfile and nobody else can get confused while we do our thing.
416                $cacheInfo->upgradeLock();
417
418                $recipe = $this->fetchRecipe($pageId, $graphId);
419                if ($recipe === null) throw new Exception("The graph " . $graphId . " is not defined on page " . $pageId);
420
421                $recipe = $this->inflateRecipe($recipe);
422                if ($recipe === null) throw new Exception("Inflating the graph " . $graphId . " on page " . $pageId . " failed.");
423
424                //-- Initialize the RPN-Computer for conditions
425                $rpncomp = new RPNComputer();
426                $rpncomp->addConst("true", true);
427                $rpncomp->addConst("false", false);
428                $rpncomp->addConst("fullscreen", $mode == helper_plugin_rrdgraph::MODE_GRAPH_FULLSCREEN);
429                $rpncomp->addConst("range", $rangeNr);
430                $rpncomp->addConst("page", $pageId);
431
432                $options = array ();
433                $graphCommands = array ();
434                $ranges = array ();
435                $variables = array();
436                if ($mode == helper_plugin_rrdgraph::MODE_BINDSVG) $svgBinding = new SvgBinding();
437                foreach ($recipe as $element) {
438
439                    //-- If a condition was supplied, check it.
440                    if ((! empty($element[0])) && (! ($rpncomp->compute($element[0])))) {
441                        continue;
442                    }
443
444                    //-- Replace variable references with values
445                    if (! empty($element[2])) {
446                        $element[2] = preg_replace_callback("/{{([^}]+)}}/", function ($match) use ($variables) {
447                            if (array_key_exists($match[1], $variables)) {
448                                return $variables[$match[1]];
449                            } else {
450                                throw new Exception('Variable "' . $match[1] . '" not set.');
451                            }
452                        }, $element[2]);
453                    }
454
455                    //-- Process the special options and pass the rest on to rrdtool.
456                    switch (strtoupper($element[1])) {
457                        //-- RANGE:[Range Name]:[Start time]:[End time]
458                        case 'RANGE' :
459                            if (($mode == helper_plugin_rrdgraph::MODE_BINDSVG) && (count($ranges) == 1)) throw new Exception("For SVG binding only one RANGE can be specified.");
460                            $parts = explode(':', $element[2], 3);
461                            if (count($parts) == 3) $ranges[] = $parts;
462                            break;
463
464                            //-- SET:[Variable name]=[Veriable value]
465                        case 'SET' :
466                            $parts = explode('=', $element[2], 2);
467                            $key = trim($parts[0]);
468                            $value = trim($parts[1]);
469
470                            $variables[$key]=$value;
471                            break;
472
473                            //-- OPT:[Option]=[Optional value]
474                        case 'OPT' :
475                            $parts = explode('=', $element[2], 2);
476                            $key = trim($parts[0]);
477                            $value = trim($parts[1]);
478
479                            if (strlen($value) == 0)
480                                $options[$key] = null;
481                            else
482                                $options[$key] = $value;
483
484                            break;
485
486                            //-- BDEF:[Binding]=[Variable]:[Aggregation function]
487                        case 'BDEF':
488                            if ($mode != helper_plugin_rrdgraph::MODE_BINDSVG) throw new Exception("BDEF only allowed if the recipe is used for binding.");
489                            $parts = explode('=', $element[2], 2);
490                            if (count($parts) != 2) throw new Exception("BDEF is missing r-value.");
491                            $rparts = explode(':', $parts[1], 2);
492                            if (count($rparts) != 2) throw new Exception("BDEF is missing aggregation function");
493                            $binding = $parts[0];
494                            $variable = $rparts[0];
495                            $aggFkt = $rparts[1];
496
497                            //-- Put the binding into the list of the SvgBinding class and output an XPORT command
498                            //   for RRDtool to export the used variable.
499                            $svgBinding->setAggregate($binding, $aggFkt);
500                            $graphCommands[] = "XPORT:" . $variable . ':' . $binding;
501
502                            break;
503
504                            //-- The XPORT-Keyword is not allowed.
505                        case 'XPORT':
506                            throw new Exception("The XPORT statement must no be used. Use BDEF instead.");
507                            break;
508
509                            //-- INCLUDE:[Wiki Page]>[Template]
510                        case 'INCLUDE' :
511                            throw new Exception("Recursive inclusion detected. Only graphs can contain inclusions.");
512                            break;
513
514                        default :
515                            $graphCommands[] = $element[1] . ":" . $element[2];
516                            break;
517                    }
518                }
519
520                //-- Bounds-Check for Ranges
521                if (count($ranges) == 0) throw new Exception("No time ranges defined for this graph.");
522                if (($rangeNr < 0) || ($rangeNr >= count($ranges))) $rangeNr = 0;
523
524                //-- The following options are not allowed because they disturbe the function of the plugin.
525                //   They are filtered.
526                $badOptions = array (
527                        'a',
528                        'imgformat',
529                        'lazy',
530                        'z'
531                );
532
533                $options = array_diff_key($options, array_flip($badOptions));
534
535                //-- Set/overwrite some of the options
536                $options['start'] = $ranges[$rangeNr][1];
537                $options['end'] = $ranges[$rangeNr][2];
538
539                //-- If we're not only doing SVG-Binding some more defaults have to be set.
540                if ($mode != helper_plugin_rrdgraph::MODE_BINDSVG)
541                {
542                    $options['imgformat'] = 'PNG';
543                    $options['999color'] = "SHADEA#C0C0C0";
544                    $options['998color'] = "SHADEB#C0C0C0";
545                    $options['border'] = 1;
546                }
547
548                //-- Encode the options
549                $commandLine = array ();
550                foreach ($options as $option => $value) {
551                    $option = ltrim($option, "0123456789");
552                    if (strlen($option) == 1)
553                        $dashes = '-';
554                    else
555                        $dashes = '--';
556
557                    $commandLine[] = $dashes . $option;
558
559                    if ($value != null) {
560                        $value = trim($value, " \"\t\r\n");
561                        $commandLine[] .= $value;
562                    }
563                }
564
565                //-- Correct the filename of the graph in case the rangeNr was modified by the range check.
566                unset($cacheInfo);
567                $cacheInfo = $this->getImageCacheInfo($pageId, $graphId, ($mode == helper_plugin_rrdgraph::MODE_BINDSVG)?"svg":"png", $rangeNr, $mode);
568
569                //-- We've to reupgrade the lock, because we got a new cacheInfo instance.
570                $cacheInfo->UpgradeLock();
571
572                //-- Depending on the current mode create a new PNG or SVG image.
573                switch ($mode) {
574                    case helper_plugin_rrdgraph::MODE_GRAPH_EMBEDDED:
575                    case helper_plugin_rrdgraph::MODE_GRAPH_FULLSCREEN:
576                        //-- Render the RRD-Graph
577                        if (rrd_graph($cacheInfo->getFilename(), array_merge($commandLine, $graphCommands)) === false) throw new Exception(rrd_error());
578                        break;
579
580                    case helper_plugin_rrdgraph::MODE_BINDSVG:
581                        $bindingSourceFile = mediaFN(cleanID($bindingSource));
582                        $svgBinding->createSVG($cacheInfo->getFileName(), array_merge($commandLine, $graphCommands), $bindingSourceFile);
583                        break;
584                }
585
586                //-- Get the new cache info of the image to send the correct headers.
587                unset($cacheInfo);
588                $cacheInfo = $this->getImageCacheInfo($pageId, $graphId, ($mode == helper_plugin_rrdgraph::MODE_BINDSVG)?"svg":"png", $rangeNr, $mode);
589            }
590
591            if (is_file($cacheInfo->getFilename())) {
592                //-- Output the image. The content length is determined via the output buffering because
593                //   on newly generated images (and with the cache on some non standard filesystem) the
594                //   size given by filesize is incorrect
595                $contentType = ContentType::get_content_type($cacheInfo->getFilename());
596                if ($contentType === null) throw new Exception("Unexpected file extension.");
597                header("Content-Type: " . $contentType);
598
599                header('Expires: ' . gmdate('D, d M Y H:i:s', $cacheInfo->getValidUntil()) . ' GMT');
600                header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $cacheInfo->getLastModified()) . ' GMT');
601
602                ob_start();
603                readfile($cacheInfo->getFilename());
604                header("Content-Length: " . ob_get_length());
605                ob_end_flush();
606            } else {
607                ErrorImage::outputErrorImage("File not found", $cacheInfo->getFilename());
608            }
609        }
610        catch (Exception $ex) {
611            ErrorImage::outputErrorImage("Graph generation failed", $ex->getMessage());
612        }
613
614        if (isset($cacheInfo)) unset($cacheInfo);
615    }
616}