<?PHP
#
#   FILE:  Developer.php
#
#   Part of the Collection Workflow Integration System (CWIS)
#   Copyright 2012-2013 Edward Almasy and Internet Scout Research Group
#   http://scout.wisc.edu/cwis/
#

/**
* Plugin to provide utilities and conveniences to support CWIS development.
*/
class Developer extends Plugin {

    # ---- STANDARD PLUGIN INTERFACE -----------------------------------------

    /**
    * Set plugin attributes.
    */
    function Register()
    {
        $this->Name = "Developer Support";
        $this->Version = "1.1.3";
        $this->Description = "Provides various conveniences useful during"
                ." CWIS-related software development.";
        $this->Author = "Internet Scout";
        $this->Url = "http://scout.wisc.edu/cwis/";
        $this->Email = "scout@scout.wisc.edu";
        $this->Requires = array(
                "CWISCore" => "2.4.1");
        $this->EnabledByDefault = FALSE;

        $this->CfgSetup["VariableMonitorEnabled"] = array(
                "Label" => "Variable Monitor",
                "Type" => "Flag",
                "Default" => TRUE,
                "OnLabel" => "Enabled",
                "OffLabel" => "Disabled",
                "Help" => "When enabled, the Variable Monitor displays the"
                        ." global (G_), page (H_), and form (F_) variables"
                        ." available to the HTML file for the current page",
                );
        $this->CfgSetup["VariableDisplayThreshold"] = array(
                "Type" => "Number",
                "Label" => "Variable Display Threshold",
                "Default" => 300,
                "Help" => "The Variable Monitor will not attempt to display"
                        ." values where the var_dump() output for the"
                        ." value is more than this number of characters.",
                );
        $this->CfgSetup["VariableMonitorPrivilege"] = array(
                "Type" => "Privileges",
                "Label" => "Display Variable Monitor For",
                "Default" => PRIV_SYSADMIN,
                "Help" => "The Variable Monitor will only be displayed for"
                        ." users with these privilege flags.  If no privileges"
                        ." are specified, it will be displayed for all users,"
                        ." including anonymous users.",
                );
        $this->CfgSetup["AutoUpgradeDatabase"] = array(
                "Type" => "Flag",
                "Label" => "Auto-Upgrade Database",
                "Default" => FALSE,
                "Help" => "When auto-upgrade is enabled, any updated database"
                        ." upgrade SQL files (in install/DBUpgrades) will be"
                        ." executed when user is logged in with PRIV_SYSADMIN"
                        ." or has a matching IP address and a change in an"
                        ." upgrade file is detected.",
                );
        $this->CfgSetup["AutoUpgradeSite"] = array(
                "Type" => "Flag",
                "Label" => "Auto-Upgrade Site",
                "Default" => FALSE,
                "Help" => "When auto-upgrade is enabled, any updated site"
                        ." upgrade PHP files (in install/SiteUpgrades) will be"
                        ." executed when user is logged in with PRIV_SYSADMIN"
                        ." or has a matching IP address and a change in an"
                        ." upgrade file is detected.",
                );
        $this->CfgSetup["AutoUpgradeInterval"] = array(
                "Type" => "Number",
                "Label" => "Auto-Upgrade Interval",
                "Default" => 5,
                "Units" => "minutes",
                "Size" => 4,
                "Help" => "How often to check upgrade files for updates.",
                );
        $this->CfgSetup["AutoUpgradeIPMask"] = array(
                "Type" => "Paragraph",
                "Label" => "Auto-Upgrade IP Addresses",
                "Help" => "When the user's IP address matches any of these values,"
                        ." auto-upgrades will be run, regardless of whether the user"
                        ." is logged in or has specific privileges.  Addresses should"
                        ." be specified one per line, and may be PHP regular"
                        ." expressions framed by /'s.",
                );
        $this->CfgSetup["ErrorReportingFlags"] = array(
                "Label" => "PHP Error Reporting Flags",
                "Type" => "Option",
                "Default" => array(),
                "AllowMultiple" => TRUE,
                "Help" => "Flags controlling PHP error reporting, as defined in the"
                        ." PHP documentation.",
                # (the indexes for "Options" are intentionally strings rather
                #       than the predefined PHP error level constants because
                #       the value of those constants can change from PHP version
                #       to version)
                "Options" => array(
                        "E_WARNING" => "Warning",
                        "E_NOTICE" => "Notice",
                        "E_STRICT" => "Strict",
                        ),
                );

        # add in E_DEPRECATED option if PHP supports it
        if (version_compare(PHP_VERSION, "5.3.0", ">=") >= 0)
        {
            $this->CfgSetup["ErrorReportingFlags"]["Options"]["E_DEPRECATED"]
                    = "Deprecated";
        }
    }

    /**
    * Perform any work needed when the plugin is first installed (for example,
    * creating database tables).
    * @return NULL if installation succeeded, otherwise a string containing
    *       an error message indicating why installation failed.
    */
    function Install()
    {
        # calculate and store upgrade file checksums
        $this->UpdateUpgradeFileChecksums(self::DBUPGRADE_FILEPATH,
                "DBUpgrade", "DBUpgradeFileChecksums");
        $this->UpdateUpgradeFileChecksums(self::SITEUPGRADE_FILEPATH,
                "SiteUpgrade", "SiteUpgradeFileChecksums");

        # report successful execution
        return NULL;
    }

    /**
    * Perform any work needed when the plugin is upgraded to a new version
    * (for example, adding fields to database tables).
    * @param string $PreviousVersion The version number of this plugin that was
    *       previously installed.
    * @return NULL if upgrade succeeded, otherwise a string containing
    *       an error message indicating why upgrade failed.
    */
    function Upgrade($PreviousVersion)
    {
        # if previously-installed version is earlier than 1.1.0
        if (version_compare($PreviousVersion, "1.1.0", "<"))
        {
            # calculate and store database upgrade file checksums
            $this->UpdateUpgradeFileChecksums(self::DBUPGRADE_FILEPATH,
                    "DBUpgrade", "DBUpgradeFileChecksums");
        }

        # if previously-installed version is earlier than 1.1.2
        if (version_compare($PreviousVersion, "1.1.2", "<"))
        {
            # calculate and store site upgrade file checksums
            $this->UpdateUpgradeFileChecksums(self::SITEUPGRADE_FILEPATH,
                    "SiteUpgrade", "SiteUpgradeFileChecksums");
        }

        # report successful execution
        return NULL;
    }

    /**
    * Initialize the plugin.  This is called after all plugins have been loaded
    * but before any methods for this plugin (other than Register() or Initialize())
    * have been called.
    * @return NULL if initialization was successful, otherwise a string containing
    *       an error message indicating why initialization failed.
    */
    function Initialize()
    {
        # set the PHP error reporting level
        $ErrorFlags = $this->ConfigSetting("ErrorReportingFlags");
        if (count($ErrorFlags))
        {
            $CurrentFlags = error_reporting();
            foreach ($ErrorFlags as $Flag)
            {
                switch ($Flag)
                {
                    case "E_WARNING":  $CurrentFlags |= E_WARNING;
                    case "E_NOTICE":  $CurrentFlags |= E_NOTICE;
                    case "E_STRICT":  $CurrentFlags |= E_STRICT;
                    case "E_DEPRECATED":  $CurrentFlags |= E_DEPRECATED;
                }
            }
            error_reporting($CurrentFlags);
        }

        # report that initialization was successful
        return NULL;
    }

    /**
    * Hook methods to be called when specific events occur.
    * For events declared by other plugins the name string should start with
    * the plugin base (class) name followed by "::" and then the event name.
    * @return Array of method names to hook indexed by the event constants
    *       or names to hook them to.
    */
    function HookEvents()
    {
        $Hooks = array();
        if ($this->ConfigSetting("VariableMonitorEnabled"))
        {
            $Hooks["EVENT_IN_HTML_HEADER"] = "AddVariableMonitorStyles";
            $Hooks["EVENT_PHP_FILE_LOAD_COMPLETE"] = "DisplayVariableMonitor";
        }
        if ($this->ConfigSetting("AutoUpgradeDatabase"))
        {
            $Hooks["EVENT_PERIODIC"][] = "CheckForDBUpgrades";
        }
        if ($this->ConfigSetting("AutoUpgradeSite"))
        {
            $Hooks["EVENT_PERIODIC"][] = "CheckForSiteUpgrades";
        }
        return $Hooks;
    }


    # ---- HOOKED METHODS ----------------------------------------------------

    /**
    * HOOKED METHOD:  Check for any database upgrade SQL files that have
    * changed and perform upgrades as needed.
    * @param LastRunAt When task was last run.
    * @return Number of minutes until task should run again.
    */
    function CheckForDBUpgrades($LastRunAt)
    {
        # if user is logged in with sys admin privileges
        if ($this->AutoUpgradeShouldRun())
        {
            # check for changed upgrade files
            $ChangedFiles = $this->CheckForChangedUpgradeFiles(
                    self::DBUPGRADE_FILEPATH,
                    "DBUpgrade", "sql", "DBUpgradeFileChecksums");

            # if changed files found
            if (count($ChangedFiles))
            {
                # set up database error messages to ignore
                # (this list should mirror the one in installcwis.php)
                $SqlErrorsWeCanIgnore = array(
                        "/CREATE TABLE /i" => "/Table '[a-z0-9_]+' already exists/i",
                        "/ALTER TABLE [a-z]+ ADD PRIMARY KEY/i" => "/Multiple primary key/i",
                        "/ALTER TABLE [a-z]+ ADD /i" => "/Duplicate column name/i",
                        "/ALTER TABLE [a-z]+ DROP COLUMN/i" => "/check that column/i",
                        "/ALTER TABLE [a-z]+ CHANGE COLUMN/i" => "/Unknown column/i",
                        "/ALTER TABLE [a-z]+ ADD INDEX/i" => "/Duplicate key name/i",
                        );
                $DB = new Database();
                $DB->SetQueryErrorsToIgnore($SqlErrorsWeCanIgnore);

                # for each changed file
                foreach ($ChangedFiles as $FilePath)
                {
                    # execute queries in file
                    $Result = $DB->ExecuteQueriesFromFile($FilePath);

                    # if queries succeeded
                    if ($Result !== NULL)
                    {
                        # update checksum for file
                        $Checksums = $this->ConfigSetting("DBUpgradeFileChecksums");
                        $Checksums[basename($FilePath)] = md5_file($FilePath);
                        $this->ConfigSetting("DBUpgradeFileChecksums", $Checksums);
                    }
                    else
                    {
                        # log error
                        $GLOBALS["AF"]->LogError(ApplicationFramework::LOGLVL_ERROR,
                                "Database upgrade SQL Error from file ".$FilePath
                                .": ".$DB->QueryErrMsg());
                    }
                }
            }
        }

        # tell caller how many minutes to wait before calling us again
        return $this->ConfigSetting("AutoUpgradeInterval");
    }

    /**
    * HOOKED METHOD:  Check for any site upgrade PHP files that have
    * changed and perform upgrades as needed.
    * @param LastRunAt When task was last run.
    * @return Number of minutes until task should run again.
    */
    function CheckForSiteUpgrades($LastRunAt)
    {
        # if user is logged in with sys admin privileges
        if ($this->AutoUpgradeShouldRun())
        {
            # check for changed upgrade files
            $ChangedFiles = $this->CheckForChangedUpgradeFiles(
                    self::SITEUPGRADE_FILEPATH,
                    "SiteUpgrade", "php", "SiteUpgradeFileChecksums");

            # if changed files found
            if (count($ChangedFiles))
            {
                # set up environment for file
                if (!function_exists("Msg"))
                {
                    function Msg($VerbosityLevel, $Message)
                    {
                        static $FHandle = FALSE;
                        if ($FHandle == FALSE)
                        {
                            $InstallLogFile = "local/logs/install.log";
                            if (!is_dir(dirname($InstallLogFile))
                                    && is_writable(dirname(dirname($InstallLogFile))))
                            {
                                mkdir(dirname(dirname($InstallLogFile)));
                            }
                            if ((file_exists($InstallLogFile)
                                    && is_writable($InstallLogFile))
                                    || (!file_exists($InstallLogFile)
                                            && is_writable(dirname($InstallLogFile))))
                            {
                                $FHandle = fopen($InstallLogFile, "a");
                            }
                        }
                        if ($FHandle)
                        {
                            $LogMsg = date("Y-m-d H:i:s")."  "
                                    .strip_tags($Message)." (DP:"
                                    .$VerbosityLevel.")\n";
                            fwrite($FHandle, $LogMsg);
                            fflush($FHandle);
                        }
                    }
                }

                # for each changed file
                foreach ($ChangedFiles as $FilePath)
                {
                    # execute code in file
                    include($FilePath);

                    # if error encountered
                    if (isset($GLOBALS["G_ErrMsgs"]) && count($GLOBALS["G_ErrMsgs"]))
                    {
                        # log errors
                        foreach ($GLOBALS["G_ErrMsgs"] as $ErrMsg)
                        {
                            $GLOBALS["AF"]->LogError(ApplicationFramework::LOGLVL_ERROR,
                                "Site upgrade SQL Error from file ".$FilePath
                                .": ".$ErrMsg);
                        }

                        # stop executing
                        break;
                    }
                    else
                    {
                        # update checksum for file
                        $Checksums = $this->ConfigSetting("SiteUpgradeFileChecksums");
                        $Checksums[basename($FilePath)] = md5_file($FilePath);
                        $this->ConfigSetting("SiteUpgradeFileChecksums", $Checksums);
                    }
                }
            }
        }

        # tell caller how many minutes to wait before calling us again
        return $this->ConfigSetting("AutoUpgradeInterval");
    }

    /**
    * HOOKED METHOD:  Add CSS styles used by Variable Monitor to page header.
    */
    function AddVariableMonitorStyles()
    {
        ?><style type="text/css">
        .VariableMonitor {
            border:         1px solid #999999;
            background:     #E0E0E0;
            font-family:    verdana, arial, helvetica, sans-serif;
            margin-top:     10px;
            width:          100%;
        }
        .VariableMonitor th {
            padding:        5px;
            text-align:     left;
            vertical-align: center;
        }
        .VariableMonitor th span {
            float:          right;
            font-weight:    normal;
            font-style:     italic;
        }
        .VariableMonitor td {
            padding:        10px;
        }
        .VariableMonitor th {
            background:     #D0D0D0;
        }
        .VariableMonitor h2, h3 {
            margin:         0;
        }
        .VariableMonitor div {
            font-family:    Courier New, Courier, monospace;
            color:          #000000;
        }
        .VarMonValue {
            display:        none;
            background:     #F0F0F0;
            border:         1px solid #FFFFFF;
            padding:        0 10px 0 10px;
        }
        </style><?PHP
    }

    /**
    * HOOKED METHOD:  Add Variable Monitor to end of page.
    * @param PageName Name of page loaded.
    * @param Context Context within which page was loaded.
    */
    function DisplayVariableMonitor($PageName, $Context)
    {
        # bail out if user does not have needed privilege
        if (count($this->ConfigSetting("VariableMonitorPrivilege")))
        {
            if (!$GLOBALS["G_User"]->HasPriv(
                    $this->ConfigSetting("VariableMonitorPrivilege")))
            {
                return;
            }
        }

        # begin Variable Monitor display
        ?><table class="VariableMonitor">
        <tr><th colspan="3">
            <span>(values available at the beginning of HTML file execution)</span>
            <h2>Variable Monitor</h2>
        </th></tr>
        <tr valign="top"><?PHP

        # retrieve all variables
        $Vars = $Context["Variables"];

        # list page variables
        $VarIndex = 0;
        ?><td><h3>Page Variables (H_)</h3><div><?PHP
        $VarFound = FALSE;
        foreach ($Vars as $VarName => $VarValue)
        {
            if (preg_match("/^H_/", $VarName))
            {
                $this->DisplayVariable($VarName, $VarValue, $VarIndex);
                $VarIndex++;
                $VarFound = TRUE;
            }
        }
        if (!$VarFound) {  print "(none)<br/>";  }
        ?></div></td><?PHP

        # list global variables
        ?><td><h3>Global Variables (G_)</h3><div><?PHP
        $VarFound = FALSE;
        foreach ($Vars as $VarName => $VarValue)
        {
            if (preg_match("/^G_/", $VarName))
            {
                $this->DisplayVariable($VarName, $VarValue, $VarIndex);
                $VarIndex++;
                $VarFound = TRUE;
            }
        }
        if (!$VarFound) {  print "(none)<br/>";  }
        ?></div></td><?PHP

        # list form variables
        if (count($_POST))
        {
            ?><td><h3>Form Variables (POST)</h3><div><?PHP
            $VarFound = FALSE;
            foreach ($_POST as $VarName => $VarValue)
            {
                $this->DisplayVariable($VarName, $VarValue, $VarIndex);
                $VarIndex++;
                $VarFound = TRUE;
            }
            if (!$VarFound) {  print "(none)<br/>";  }
            ?></div></td><?PHP
        }

        # end Variable Monitor display
        ?></tr></table><?PHP
    }


    # ---- PRIVATE INTERFACE -------------------------------------------------

    /**
    * Display variable formatted for Variable Monitor.
    * @param string $VarName Name of variable.
    * @param mixed $VarValue Current value of variable.
    * @param int $VarIndex Numerical index into list of variables.
    */
    private function DisplayVariable($VarName, $VarValue, $VarIndex)
    {
        print "<span onclick=\"$('#VMVal".$VarIndex."').toggle()\">"
                .$VarName."</span><br/>\n"
                ."<div id='VMVal".$VarIndex."' class=\"VarMonValue\">";
        ob_start();
        var_dump($VarValue);
        $VarDump = ob_get_contents();
        ob_end_clean();
        if (strlen($VarDump) < $this->ConfigSetting("VariableDisplayThreshold"))
        {
            print $VarDump;
        }
        else
        {
            if (is_object($VarValue))
            {
                print "(<i>".get_class($VarValue)."</i> object)";
            }
            elseif (is_array($VarValue))
            {
                print "(array:".count($VarValue).")";
            }
            elseif (is_string($VarValue))
            {
                print "(string:".strlen($VarValue).")";
            }
            else
            {
                print "(value too long to display - length:".strlen($VarDump).")";
            }
        }
        print "</div>\n";
    }

    /**
    * Calculate and store new checksums for all upgrade files in specified area.
    * @param string $Path Directory containing upgrade files.
    * @param string $Prefix Upgrade file name prefix.
    * @param string $ConfigVar Name of config variable for storing checksums.
    */
    private function UpdateUpgradeFileChecksums($Path, $Prefix, $ConfigVar)
    {
        # retrieve list of database upgrade files
        $FileList = scandir($Path);

        # for each file
        $Checksums = array();
        foreach ($FileList as $FileName)
        {
            # if file appears to be database upgrade SQL file
            if (preg_match("%^".$Prefix."--[0-9.]{5,}\\.sql$%", trim($FileName)))
            {
                # calculate checksum for file
                $Checksums[$FileName] = md5_file($Path.$FileName);
            }
        }

        # store checksums
        $this->ConfigSetting($ConfigVar, $Checksums);
    }

    /**
    * Compare checksums of upgrade files against stored values and return
    * names of any files that have changed.
    * @param string $Path Directory containing upgrade files.
    * @param string $Prefix Upgrade file name prefix.
    * @param string $ConfigVar Name of config variable for storing checksums.
    * @return Array with full relative paths for any files that have changed.
    */
    private function CheckForChangedUpgradeFiles($Path, $Prefix, $Suffix, $ConfigVar)
    {
        # retrieve current list of checksums
        $Checksums = $this->ConfigSetting($ConfigVar);

        # retrieve list of database upgrade files
        $FileList = scandir($Path);

        # for each file
        $ChangedFiles = array();
        foreach ($FileList as $FileName)
        {
            # if file appears to be database upgrade SQL file
            if (preg_match("%^".$Prefix."--[0-9.]{5,}\\.".$Suffix."$%", trim($FileName)))
            {
                # calculate checksum for file
                $Checksum = md5_file($Path.$FileName);

                # if checksum is different
                if (!array_key_exists($FileName, $Checksums)
                        || ($Checksum != $Checksums[$FileName]))
                {
                    # add file to list
                    $ChangedFiles[] = $Path.$FileName;
                }
            }
        }

        # return list of any changed files to caller
        return $ChangedFiles;
    }

    /**
    * Check whether auto-grade should run, based in user privilege and
    * user IP address.
    * @return bool TRUE if upgrade should be run.
    */
    private function AutoUpgradeShouldRun()
    {
        # if user is logged in with Sys Admin privileges
        if ($GLOBALS["G_User"]->HasPriv(PRIV_SYSADMIN))
        {
            # report that upgrade should run
            return TRUE;
        }
        else
        {
            # for each auto-upgrade IP address
            $IPAddresses = explode("\n", $this->ConfigSetting("AutoUpgradeIPMask"));
            foreach ($IPAddresses as $IPAddress)
            {
                # if address looks like regular expression
                $IPAddress = trim($IPAddress);
                if (preg_match("%^/.+/\$%", $IPAddress))
                {
                    # if expression matches user's IP address
                    if (preg_match($IPAddress, $_SERVER["REMOTE_ADDR"]))
                    {
                        # report that upgrade should run
                        return TRUE;
                    }
                }
                else
                {
                    # if address matches user's IP address
                    if ($_SERVER["REMOTE_ADDR"] == $IPAddress)
                    {
                        # report that upgrade should run
                        return TRUE;
                    }
                }
            }
        }

        # report that upgrade should not run
        return FALSE;
    }

    const DBUPGRADE_FILEPATH = "install/DBUpgrades/";
    const SITEUPGRADE_FILEPATH = "install/SiteUpgrades/";
}
