* @license MIT */ require_once ('inc/errorimage.php'); require_once ('inc/rpncomputer.php'); require_once ('inc/svgbinding.php'); require_once ('inc/contenttypes.php'); /** * Base class for all cache implementations within the RRDGraph plugin. * This class is derived from the DokuWiki cache class. * It implements the dependency handling mechanism that is needed for the * rrd INCLUDE tag. */ abstract class cache_rrdgraphbase extends cache { /** @var String Page-Number of the page that is managed by this cache instance. */ private $pageId; /** @var String Name of the plugin using this cache. This value is used to get the dependencies metadata. */ private $pluginName; /** * C'tor * @param String $pluginName The name of the plugin. This can be retrieved by getPluginName() and makes the plugin more robust to renaming. * @param String $pageId The wiki id of the page the cached content is on. * @param String $key Uniq value identifying the cached content on the page provied by $pageId. The identifier is hashed before being used. * @param String $ext The extension of the cache file. */ public function __construct($pluginName, $pageId, $key, $ext) { $this->pageId = $pageId; $this->pluginName = $pluginName; parent::__construct($pageId . '/' . $key, $ext); } /** * Adds the dependencies from the plugin_[name] -> dependencies metadata element. * This way the included dependencies of the rrd graphs on a page can be tracked. */ protected function _addDependencies() { $files = array ( wikiFN($this->pageId) ); //-- We oversimplify this a litte and add all dependencies of the current page to very image // without distinction between the recipies. // But if one include is changed recalculating all images only generates litte overhead because // they are regenerated every time after a cache timeout. $dependencies = p_get_metadata($this->pageId, 'plugin_' . $this->pluginName . ' dependencies'); if (! empty($dependencies)) { foreach ($dependencies as $dependency) { $files[] = wikiFN($dependency); } } if (! array_key_exists('files', $this->depends)) $this->depends['files'] = $files; else $this->depends['files'] = array_merge($files, $this->depends['files']); parent::_addDependencies(); } } /** * This cache class manages the rrd recipe cache. * This cache only times out if the recipe changes. * */ class cache_rrdgraph extends cache_rrdgraphbase { /** * C'tor * @param String $pluginName The name of the plugin. This can be retrieved by getPluginName() and makes the plugin more robust to renaming. * @param String $pageId The wiki id of the page the cached content is on. * @param String $recipeName An identifier used to identify the cache recipe on the page provied by pageId. The identifier is hashed before being used. */ public function __construct($pluginName, $pageId, $recipeName) { $this->pluginName = $pluginName; parent::__construct($pluginName, $pageId, $recipeName, ".rrd"); } } /** * This cache class manages the images generated by the plugin. * The cached images are used as long as the recipe does not change and the maximum age (config) is not reached. * */ class cache_rrdgraphimage extends cache_rrdgraphbase { /** @var Integer Maximum age of the image to be considered usable. */ private $maxAge; /** * C'tor * @param String $pluginName The name of the plugin. This can be retrieved by getPluginName() and makes the plugin more robust to renaming. * @param String $pageId The wiki id of the page the cached content is on. * @param String $extension The extension of the image file without a trailing dot. * @param String $recipeName An identifier used to identify the cache recipe on the page provied by pageId. The identifier is hashed before being used. * @param Integer $rangeNr ID of the time range this image is cached for. * @param String $conditions An identifier for the conditions used for creating the image (fullscreen, etc.). * @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. */ public function __construct($pluginName, $pageId, $extension, $recipeName, $rangeNr, $conditions, $maxAge) { $this->maxAge = $maxAge; $extension = strtolower($extension); parent::__construct($pluginName, $pageId, $recipeName . '/' . $conditions . '/' . $rangeNr, "." . $extension); } /** * 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. * @returns Returns the name and path of the cache file. */ public function getCacheFileName() { return $this->cache; } /** * (non-PHPdoc) * @see cache_rrdgraphbase::_addDependencies() */ protected function _addDependencies() { //-- Set maximum age. $this->depends['age'] = $this->maxAge; parent::_addDependencies(); } /** * Returns the time until this image is valid. * If the cache file does not exist (the data was never cached) 0 is returned. * @return Integer Unix timestamp when this image is no longer valid. */ public function getValidUntil() { if ($this->useCache()) { return $this->_time + $this->maxAge; } else { return 0; } } /** * Determins the last modification time of the cache data. * If the cache file does not exist (the data was never cached) the current time is returned. * @return Integer Unix timestamp of the last modification time of the cached file. */ public function getLastModified() { if (empty($this->_time)) return time(); else return $this->_time; } } /** * Stores information about a rrd image. This information can be used to update the * cache image file. To load the image file and to construct HTTP headers for tramsmission. * */ class rrdgraph_image_info { /** @var String Name of the rrd image file within the cache. */ private $fileName; /** @var Resource File handle used to lock the file. */ private $fileHandle; /** @var Integer Timestamp until the file named by $fileName ist considered valid. */ private $validUntil; /** @var Integer Timestamp when the file named by $fileName was last updated. */ private $lastModified; /** * C'tor * @param String $fileName Sets the $fileName value. * @param Integer $validUntil Sets the $validUntil value. * @param Integer $lastModified Sets the $lastModfiied value. */ public function __construct($fileName, $validUntil, $lastModified) { $this->fileName = $fileName; $this->validUntil = $validUntil; $this->lastModified = $lastModified; //-- Get a shared lock on the lock-file. $this->fileHandle = fopen($fileName . ".lock", "w+"); flock($this->fileHandle, LOCK_SH); } /** * D'tor */ public function __destruct() { fclose($this->fileHandle); } /** * @see cache_rrdgraphimage::getCacheFileName() */ public function getFileName() { return $this->fileName; } /** * @see cache_rrdgraphimage::getValidUntil() */ public function getValidUntil() { return $this->validUntil; } /** * @see cache_rrdgraphimage::getLastModified() */ public function getLastModified() { return $this->lastModified; } /** * Checks if the cached file returned by getFileName() is still valid. * @return boolean Returns "true" if the cached file should still be used or "false" if it must be recreated. */ public function isValid() { return $this->validUntil > time(); } public function upgradeLock() { flock($this->fileHandle, LOCK_EX); } } /** * DokiWuki helper plugin class. This class supplies some methods used throughout the other RRDGraph plugin modules. * */ class helper_plugin_rrdgraph extends DokuWiki_Plugin { /** @var string Mode for embedding the graph into a rendered HTML page. */ const MODE_GRAPH_EMBEDDED = 'e'; /** @var string Mode for showing the graph fullscreen. */ const MODE_GRAPH_FULLSCREEN = 'fs'; /** @var string Mode for generating a SVG image with data binding.. */ const MODE_BINDSVG = 'b'; /** @var Array Cache for already loaded and inflated recipes. This speeds up loading the same recipe multiple times on the same wiki page */ private $localRecipeCache; /** * Returns an array of method declarations for docuwiki. * @see https://www.dokuwiki.org/devel:helper_plugins * @return Returns the declaration array. */ public function getMethods() { //-- Non of the contained functions are for public use! return array(); } /** * Stores a rrd recipe for the given page. * @param String $pageId Wiki page id. * @param String $recipeName Name of the recipe to store. * @param Array $recipeData Array of recipe data to be stored. */ public function storeRecipe($pageId, $recipeName, $recipeData) { //-- Put the file into the cache. $cache = new cache_rrdgraph($this->getPluginName(), $pageId, $recipeName); $cache->storeCache(serialize($recipeData)); $this->localRecipeCache[$pageId . "/" . $recipeName] = $recipeData; } /** * Load a gieven rrd recipe. If the recipe is not available within the cache or needs to be updated the wiki page is rendered * to give the syntax plugin a chance to create and cache the rrd data. * @param String $pageId Wiki page id. * @param String $recipeName Name of the recipe to load. * @returns Array Returns an array containing an rrd recipe. If the recipe can not be found or recreated this method returns null. */ public function fetchRecipe($pageId, $recipeName) { if (! isset($this->localRecipeCache[$pageId . "/" . $recipeName])) { $cache = new cache_rrdgraph($this->getPluginName(), $pageId, $recipeName); if ($cache->useCache()) { $this->localRecipeCache[$pageId . "/" . $recipeName] = unserialize($cache->retrieveCache()); } else { //-- The rrd-information is not cached. Render the page // to refresh the stored rrd information. p_wiki_xhtml($pageId); //-- Try again to get the data $this->localRecipeCache[$pageId . "/" . $recipeName] = unserialize($cache->retrieveCache()); } } if (empty($this->localRecipeCache[$pageId . "/" . $recipeName])) $this->localRecipeCache[$pageId . "/" . $recipeName] = null; return $this->localRecipeCache[$pageId . "/" . $recipeName]; } /** * Inflates a given recipe. * When a recipe is inflated, included recipes are automatically loaded (and rendered if necessary) and included into the given recipe. * @param Array $recipe A rrd recipe. If this value is not an array, null is returned. * @return Array If the recipe could be successfully inflate, the recipe is returned with all includes replaced by the included elements. * @throws Exception If an error occures (if the ACL does not allow loading an included recpipe) an exception is thrown. */ public function inflateRecipe($recipe) { if (! is_array($recipe)) return null; //-- Cache the setting if ACLs should be checked for includes. $checkACL = ($this->getConf('include_acl') > 0); //-- Resolve includes $inflatedRecipe = array (); $includeDone = false; foreach ($recipe as $element) { switch (strtoupper($element[1])) { case 'INCLUDE' : list ($incPageId, $incTmplName) = explode('>', $element[2], 2); $incPageId = trim($incPageId); $incTmplName = trim($incTmplName); if ($checkACL) { if (auth_quickaclcheck($incPageId) < AUTH_READ) throw new Exception("Access denied by ACL."); } $includedPageRecipe = $this->fetchRecipe($incPageId, $incTmplName); if ($includedPageRecipe !== null) { $inflatedRecipe = array_merge($inflatedRecipe, $includedPageRecipe); } break; default : $inflatedRecipe[] = $element; } } $recipe = $inflatedRecipe; return $recipe; } /** * Parses a recipe and returns the wiki page ids of all included recipes. * @param Array $recipe The rrd recipe to parse. * @return Array A string array continaing all page ids included by the given recipe. */ public function getDependencies($recipe) { $depPageIds = array (); foreach ($recipe as $element) { if (strcasecmp($element[1], 'INCLUDE') == 0) { list ($incPageId, $incTmplName) = explode('>', $element[2], 2); $incPageId = trim($incPageId); $depPageIds[$incPageId] = $incPageId; break; } } return array_values($depPageIds); } /** * Returns a rrdgraph_image_info instance contianing the information needed to deliver or recreate the given png rrd image. * @param String $pageId The wiki id of the page the cached content is on. * @param String $recipeName An identifier used to identify the cache recipe on the page provied by pageId. The identifier is hashed before being used. * @param String $extension The extension of the image file without a trailing dot. * @param Integer $rangeNr ID of the time range this image is cached for. * @param String $conditions An identifier for the conditions used for creating the image (fullscreen, etc.). */ public function getImageCacheInfo($pageId, $recipeName, $extension, $rangeNr, $conditions) { $cache = new cache_rrdgraphimage($this->getPluginName(), $pageId, $extension, $recipeName, $rangeNr, $conditions, $this->getConf('cache_timeout')); return new rrdgraph_image_info($cache->getCacheFileName(), $cache->getValidUntil(), $cache->getLastModified()); } /** * Sends the Graph specified by its parameters to the webbrowser. Make sure that after calling this function no * other output is transmitted or the image will be corrupted. * This function does cache management and all other stuff, too. * @param string $pageId The wiki id of the page the rrd graph is defined in. * @param string $graphId The identifier of the rrd graph to render and send. * @param integer $rangeNr ID of the time range to send the graph for. * @param string $mode Mode to use for generating the graph (MODE_GRAPH_EMBEDDED, MODE_GRAPH_FULLSCREEN, MODE_BINDSVG). * @param string $bindingSource For MODE_BINDSVG the source SVG image for data binding must be specified as a DokuWiki media ressource. * @throws Exception * @return Ambigous <> */ public function sendRrdImage($pageId, $graphId, $rangeNr = 0, $mode = helper_plugin_rrdgraph::MODE_GRAPH_EMBEDDED, $bindingSource = null) { //-- User abort must be ignored because we're building new images for the cache. If the // user aborts this process, the cache may be corrupted. @ignore_user_abort(true); try { //-- ACL-Check if (auth_quickaclcheck($pageId) < AUTH_READ) throw new Exception("Access denied by ACL."); //-- Currently only fs, b and e are supported modes. if (($mode != helper_plugin_rrdgraph::MODE_GRAPH_FULLSCREEN) && ($mode != helper_plugin_rrdgraph::MODE_BINDSVG)) $mode = helper_plugin_rrdgraph::MODE_GRAPH_EMBEDDED; //-- If the mode is "b" then $bindingSource must be set and accessible if ($mode == helper_plugin_rrdgraph::MODE_BINDSVG) { if ($bindingSource == null) throw new Exception("Binding source missing."); if (auth_quickaclcheck($bindingSource) < AUTH_READ) throw new Exception("Access denied by ACL."); } //-- Check if the cached image is still valid. If this is not the case, recreate it. $cacheInfo = $this->getImageCacheInfo($pageId, $graphId, ($mode == helper_plugin_rrdgraph::MODE_BINDSVG)?"svg":"png", $rangeNr, $mode); if (! $cacheInfo->isValid()) { //-- We found we should update the file. Upgrade our lock to an exclusive one. // This way we OWN the lockfile and nobody else can get confused while we do our thing. $cacheInfo->upgradeLock(); $recipe = $this->fetchRecipe($pageId, $graphId); if ($recipe === null) throw new Exception("The graph " . $graphId . " is not defined on page " . $pageId); $recipe = $this->inflateRecipe($recipe); if ($recipe === null) throw new Exception("Inflating the graph " . $graphId . " on page " . $pageId . " failed."); //-- Initialize the RPN-Computer for conditions $rpncomp = new RPNComputer(); $rpncomp->addConst("true", true); $rpncomp->addConst("false", false); $rpncomp->addConst("fullscreen", $mode == helper_plugin_rrdgraph::MODE_GRAPH_FULLSCREEN); $rpncomp->addConst("range", $rangeNr); $rpncomp->addConst("page", $pageId); $options = array (); $graphCommands = array (); $ranges = array (); $variables = array(); if ($mode == helper_plugin_rrdgraph::MODE_BINDSVG) $svgBinding = new SvgBinding(); foreach ($recipe as $element) { //-- If a condition was supplied, check it. if ((! empty($element[0])) && (! ($rpncomp->compute($element[0])))) { continue; } //-- Replace variable references with values if (! empty($element[2])) { $element[2] = preg_replace_callback("/{{([^}]+)}}/", function ($match) use ($variables) { if (array_key_exists($match[1], $variables)) { return $variables[$match[1]]; } else { throw new Exception('Variable "' . $match[1] . '" not set.'); } }, $element[2]); } //-- Process the special options and pass the rest on to rrdtool. switch (strtoupper($element[1])) { //-- RANGE:[Range Name]:[Start time]:[End time] case 'RANGE' : if (($mode == helper_plugin_rrdgraph::MODE_BINDSVG) && (count($ranges) == 1)) throw new Exception("For SVG binding only one RANGE can be specified."); $parts = explode(':', $element[2], 3); if (count($parts) == 3) $ranges[] = $parts; break; //-- SET:[Variable name]=[Veriable value] case 'SET' : $parts = explode('=', $element[2], 2); $key = trim($parts[0]); $value = trim($parts[1]); $variables[$key]=$value; break; //-- OPT:[Option]=[Optional value] case 'OPT' : $parts = explode('=', $element[2], 2); $key = trim($parts[0]); $value = trim($parts[1]); if (strlen($value) == 0) $options[$key] = null; else $options[$key] = $value; break; //-- BDEF:[Binding]=[Variable]:[Aggregation function] case 'BDEF': if ($mode != helper_plugin_rrdgraph::MODE_BINDSVG) throw new Exception("BDEF only allowed if the recipe is used for binding."); $parts = explode('=', $element[2], 2); if (count($parts) != 2) throw new Exception("BDEF is missing r-value."); $rparts = explode(':', $parts[1], 2); if (count($rparts) != 2) throw new Exception("BDEF is missing aggregation function"); $binding = $parts[0]; $variable = $rparts[0]; $aggFkt = $rparts[1]; //-- Put the binding into the list of the SvgBinding class and output an XPORT command // for RRDtool to export the used variable. $svgBinding->setAggregate($binding, $aggFkt); $graphCommands[] = "XPORT:" . $variable . ':' . $binding; break; //-- The XPORT-Keyword is not allowed. case 'XPORT': throw new Exception("The XPORT statement must no be used. Use BDEF instead."); break; //-- INCLUDE:[Wiki Page]>[Template] case 'INCLUDE' : throw new Exception("Recursive inclusion detected. Only graphs can contain inclusions."); break; default : $graphCommands[] = $element[1] . ":" . $element[2]; break; } } //-- Bounds-Check for Ranges if (count($ranges) == 0) throw new Exception("No time ranges defined for this graph."); if (($rangeNr < 0) || ($rangeNr >= count($ranges))) $rangeNr = 0; //-- The following options are not allowed because they disturbe the function of the plugin. // They are filtered. $badOptions = array ( 'a', 'imgformat', 'lazy', 'z' ); $options = array_diff_key($options, array_flip($badOptions)); //-- Set/overwrite some of the options $options['start'] = $ranges[$rangeNr][1]; $options['end'] = $ranges[$rangeNr][2]; //-- If we're not only doing SVG-Binding some more defaults have to be set. if ($mode != helper_plugin_rrdgraph::MODE_BINDSVG) { $options['imgformat'] = 'PNG'; $options['999color'] = "SHADEA#C0C0C0"; $options['998color'] = "SHADEB#C0C0C0"; $options['border'] = 1; } //-- Encode the options $commandLine = array (); foreach ($options as $option => $value) { $option = ltrim($option, "0123456789"); if (strlen($option) == 1) $dashes = '-'; else $dashes = '--'; $commandLine[] = $dashes . $option; if ($value != null) { $value = trim($value, " \"\t\r\n"); $commandLine[] .= $value; } } //-- Correct the filename of the graph in case the rangeNr was modified by the range check. unset($cacheInfo); $cacheInfo = $this->getImageCacheInfo($pageId, $graphId, ($mode == helper_plugin_rrdgraph::MODE_BINDSVG)?"svg":"png", $rangeNr, $mode); //-- We've to reupgrade the lock, because we got a new cacheInfo instance. $cacheInfo->UpgradeLock(); //-- Depending on the current mode create a new PNG or SVG image. switch ($mode) { case helper_plugin_rrdgraph::MODE_GRAPH_EMBEDDED: case helper_plugin_rrdgraph::MODE_GRAPH_FULLSCREEN: //-- Render the RRD-Graph if (rrd_graph($cacheInfo->getFilename(), array_merge($commandLine, $graphCommands)) === false) throw new Exception(rrd_error()); break; case helper_plugin_rrdgraph::MODE_BINDSVG: $bindingSourceFile = mediaFN(cleanID($bindingSource)); $svgBinding->createSVG($cacheInfo->getFileName(), array_merge($commandLine, $graphCommands), $bindingSourceFile); break; } //-- Get the new cache info of the image to send the correct headers. unset($cacheInfo); $cacheInfo = $this->getImageCacheInfo($pageId, $graphId, ($mode == helper_plugin_rrdgraph::MODE_BINDSVG)?"svg":"png", $rangeNr, $mode); } if (is_file($cacheInfo->getFilename())) { //-- Output the image. The content length is determined via the output buffering because // on newly generated images (and with the cache on some non standard filesystem) the // size given by filesize is incorrect $contentType = ContentType::get_content_type($cacheInfo->getFilename()); if ($contentType === null) throw new Exception("Unexpected file extension."); header("Content-Type: " . $contentType); header('Expires: ' . gmdate('D, d M Y H:i:s', $cacheInfo->getValidUntil()) . ' GMT'); header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $cacheInfo->getLastModified()) . ' GMT'); ob_start(); readfile($cacheInfo->getFilename()); header("Content-Length: " . ob_get_length()); ob_end_flush(); } else { ErrorImage::outputErrorImage("File not found", $cacheInfo->getFilename()); } } catch (Exception $ex) { ErrorImage::outputErrorImage("Graph generation failed", $ex->getMessage()); } if (isset($cacheInfo)) unset($cacheInfo); } }