<?php
/**
 * RRDGraph Plugin: Helper classes
 * 
 * @author Daniel Goß <developer@flashsystems.de>
 * @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);    
    }
}