<?PHP
#
#   FILE:  Developer.php
#
#   Part of the Collection Workflow Integration System (CWIS)
#   Copyright 2012-2014 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.
    */
    public function Register()
    {
        $this->Name = "Developer Support";
        $this->Version = "1.1.4";
        $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" => "3.1.0");
        $this->EnabledByDefault = FALSE;

        $this->Instructions = <<<EOT
        <b>Usage Note:</b>  For Page Load Info to be displayed, the currently-active
        interface must signal <code>EVENT_HTML_INSERTION_POINT</code> with a location
        of <code>"After Page Footer"</code>.  (See <code>StdPageEnd.html</code> in
        the <code>default</code> interface for an example.)
EOT;

        $this->CfgSetup[] = array(
                "Type" => "Heading",
                "Label" => "Variable Monitor",
                );
        $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,
                "AllowMultiple" => TRUE,
                "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[] = array(
                "Type" => "Heading",
                "Label" => "Page Load Info",
                );
        $this->CfgSetup["PageLoadInfoEnabled"] = array(
                "Label" => "Page Load Info Display",
                "Type" => "Flag",
                "Default" => TRUE,
                "OnLabel" => "Enabled",
                "OffLabel" => "Disabled",
                "Help" => "When enabled, page load information is displayed"
                        ." (usually at the bottom of the page, though location"
                        ." and whether it's displayed at all may depend on the"
                        ." currently-active interface).",
                );
        $this->CfgSetup["PageLoadInfoPrivilege"] = array(
                "Type" => "Privileges",
                "Label" => "Display Page Load Info For",
                "Default" => PRIV_SYSADMIN,
                "AllowMultiple" => TRUE,
                "Help" => "The page load info 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[] = array(
                "Type" => "Heading",
                "Label" => "Auto-Upgrade",
                );
        $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[] = array(
                "Type" => "Heading",
                "Label" => "PHP Configuration",
                );
        $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",
                        ),
                );
        $this->CfgSetup[] = array(
                "Type" => "Heading",
                "Label" => "Other",
                );
        $this->CfgSetup["UseFileUrlFallbacks"] = array(
                "Type" => "Flag",
                "Label" => "Use File URL Fallbacks",
                "Default" => FALSE,
                "Help" => "When this option is enabled, uploaded images that"
                        ." are not found locally will have the external file"
                        ." URL prefix prepended to their URL."
                        ." <br/><b>PLEASE NOTE:</b>"
                        ." This mechanism adds a high overhead to every page"
                        ." load, and is NOT recommended for use on a"
                        ." production site.",
                );
        $this->CfgSetup["FileUrlFallbackPrefix"] = array(
                "Type" => "URL",
                "Label" => "File URL Fallback Prefix",
                "Help" => "Prefix for uploaded image files that are not found"
                        ." in the local directory structure.  The purpose of"
                        ." this option is to allow a copy of a database from"
                        ." a live site to be used in a test installation and"
                        ." still have images appear as needed for development.",
                "SettingFilter" => "NormalizeFileUrlFallbackPrefix",
                );
        $this->CfgSetup["UseEmailWhitelist"] = array(
                "Type" => "Flag",
                "Label" => "Use Email Whitelist",
                "Default" => FALSE,
                "Help" => "When this option is enabled and one or more email"
                        ." whitelist patterns are set, outgoing email will"
                        ." only be sent to addresses that match one of the"
                        ." patterns.",
                );
        $this->CfgSetup["EmailWhitelist"] = array(
                "Type" => "Paragraph",
                "Label" => "Email Whitelist",
                "Help" => "List of regular expression patterns that must be"
                        ." matched for outgoing email to be set (when <i>Use"
                        ." Email Whitelist</i> is enabled).  Patterns should"
                        ." be specified one per line, and use the syntax"
                        ." expected by the PHP function preg_match().",
                );

        # 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.
    */
    public 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.
    */
    public 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.
    */
    public function Initialize()
    {
        # load local developer settings if available
        $this->LoadLocalSettings();

        # 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);
        }

        # if email whitelisting is turned on
        if ($this->ConfigSetting("UseEmailWhitelist"))
        {
            # set whitelist addresses if any
            $Whitelist = explode("\n", $this->ConfigSetting("EmailWhitelist"));
            Email::ToWhitelist($Whitelist);
        }

        # 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.
    */
    public function HookEvents()
    {
        $Hooks = array();
        if ($this->ConfigSetting("VariableMonitorEnabled"))
        {
            $Hooks["EVENT_IN_HTML_HEADER"] = "AddVariableMonitorStyles";
            $Hooks["EVENT_PHP_FILE_LOAD_COMPLETE"] = "DisplayVariableMonitor";
        }
        if ($this->ConfigSetting("PageLoadInfoEnabled"))
        {
            $Hooks["EVENT_HTML_INSERTION_POINT"] = "DisplayPageLoadInfo";
        }
        if ($this->ConfigSetting("AutoUpgradeDatabase"))
        {
            $Hooks["EVENT_PERIODIC"][] = "CheckForDBUpgrades";
        }
        if ($this->ConfigSetting("AutoUpgradeSite"))
        {
            $Hooks["EVENT_PERIODIC"][] = "CheckForSiteUpgrades";
        }
        if ($this->ConfigSetting("UseFileUrlFallbacks"))
        {
            $Hooks["EVENT_PAGE_OUTPUT_FILTER"][] = "InsertImageUrlFallbackPrefixes";
        }
        return $Hooks;
    }


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

    /**
    * HOOKED METHOD:  Check for any database upgrade SQL files that have
    * changed and perform upgrades as needed.
    * @param string $LastRunAt When task was last run.
    * @return Number of minutes until task should run again.
    */
    public function CheckForDBUpgrades($LastRunAt)
    {
        # if user is logged in with sys admin privileges
        if ($this->AutoUpgradeShouldRun())
        {
            $DB = new Database();

            # 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",
                        "/DROP TABLE /i" => "/Unknown table '[a-z0-9_]+'/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->SetQueryErrorsToIgnore($SqlErrorsWeCanIgnore);

                # for each changed file
                foreach ($ChangedFiles as $FilePath)
                {
                    # log database upgrade
                    $GLOBALS["AF"]->LogMessage(
                            ApplicationFramework::LOGLVL_INFO,
                            "Running database upgrade (".basename($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());
                    }
                }
            }

            # get a list of all the indexes for our database
            $DB->Query("SELECT TABLE_NAME, INDEX_TYPE, INDEX_NAME, COLUMN_NAME "
                       ."FROM information_schema.statistics "
                       ."WHERE table_schema='"
                       .$GLOBALS["G_Config"]["Database"]["DatabaseName"]."'");
            $Rows = $DB->FetchRows();

            # build a list of columns for each type * index_name * table
            $IndexByName = array();
            foreach ($Rows as $Row)
            {
                $RowKey = $Row["TABLE_NAME"]."-".$Row["INDEX_TYPE"];
                $IndexByName[$RowKey][$Row["INDEX_NAME"]][]= $Row["COLUMN_NAME"];
            }

            # use the above to determine which indexes cover distinct sets of
            # columns
            $IndexByContents = array();
            foreach ($IndexByName as $Table => $IndexData)
            {
                foreach ($IndexData as $IxName => $IxColumns)
                {
                    sort($IxColumns);
                    $Key = implode("-", $IxColumns);
                    $IndexByContents[$Table][$Key][]= $IxName;
                }
            }

            # iterate through our indexes and remove those which cover the same
            #  set of columns with the same index type
            foreach ($IndexByContents as $Table => $IndexData)
            {
                foreach ($IndexData as $IxCols => $IxNames)
                {
                    if (count($IxNames) > 1)
                    {
                        # shift the array to keep the first one
                        array_shift($IxNames);

                        $TableName = explode("-", $Table);
                        $TableName = array_shift($TableName);
                        foreach ($IxNames as $Tgt)
                        {
                            if ($GLOBALS["AF"]->GetSecondsBeforeTimeout() > 30)
                            {
                                $DB->Query("ALTER TABLE ".$TableName
                                           ." DROP INDEX ".$Tgt);
                            }
                        }
                    }
                }
            }
        }

        # 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 string $LastRunAt When task was last run.
    * @return Number of minutes until task should run again.
    */
    public 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"))
                {
                    // @codingStandardsIgnoreStart
                    function Msg($VerbosityLevel, $Message)
                    {
                        // @codingStandardsIgnoreEnd
                        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)
                {
                    # log system upgrade
                    $GLOBALS["AF"]->LogMessage(
                            ApplicationFramework::LOGLVL_INFO,
                            "Running system upgrade (".basename($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.
    */
    public function AddVariableMonitorStyles()
    {
        // @codingStandardsIgnoreStart
        ?><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
        // @codingStandardsIgnoreEnd
    }

    /**
    * HOOKED METHOD:  Add Variable Monitor to end of page.
    * @param string $PageName Name of page loaded.
    * @param string $Context Context within which page was loaded.
    */
    public 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
        // @codingStandardsIgnoreStart
        ?><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
        // @codingStandardsIgnoreEnd
        # retrieve all variables
        $Vars = $Context["Variables"];

        # list page variables
        $VarIndex = 0;
        print "<td><h3>Page Variables (H_)</h3><div>";
        $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/>";  }
        print "</div></td>";

        # list global variables
        print "<td><h3>Global Variables (G_)</h3><div>";
        $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/>";  }
        print "</div></td>";

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

        # end Variable Monitor display
        print "</tr></table>";
    }

    /**
    * HOOKED METHOD:  Add fallback URL prefix to URLs for any images that
    * don't seem to be available locally.
    * @param string $PageOutput Whole page output.
    * @return array Array of incoming arguments, possibly modified.
    */
    public function InsertImageUrlFallbackPrefixes($PageOutput)
    {
        $PageOutput = preg_replace_callback(array(
                "%src=\"/?([^?*:;{}\\\\\" ]+)\.(gif|png|jpg)\"%i",
                "%src='/?([^?*:;{}\\\\' ]+)\.(gif|png|jpg)'%i"),
                array($this, "InsertImageUrlFallbackPrefixes_ReplacementCallback"),
                $PageOutput);
        return array(
                "PageOutput" => $PageOutput);
    }

    /**
    * Pattern replacement callback for fallback URL prefix insertion,
    * intended to be used with preg_replace_callback().
    * @param array $Matches Matching segments from replace function.
    * @return string Replacement string.
    * @see Developer::InsertImageUrlFallbackPrefixes()
    */
    public function InsertImageUrlFallbackPrefixes_ReplacementCallback($Matches)
    {
        $Url = $Matches[1].".".$Matches[2];
        if (!is_readable($Url))
        {
            static $Prefix;
            if (!isset($Prefix))
            {
                $Prefix = $this->ConfigSetting("FileUrlFallbackPrefix");
            }
            $Url = $Prefix.$Url;
        }
        return "src=\"".$Url."\"";
    }

    /**
    * HOOKED METHOD:  Add Variable Monitor to end of page.
    * @param srting $PageName Name of page loaded.
    * @param string $Location Location of insertion point on page.
    */
    public function DisplayPageLoadInfo($PageName, $Location)
    {
        # bail out if this is not the right location on the page
        if ($Location != "After Page Footer") {  return;  }

        # bail out if user does not have needed privilege
        if (count($this->ConfigSetting("PageLoadInfoPrivilege")))
        {
            if (!$GLOBALS["G_User"]->HasPriv(
                    $this->ConfigSetting("PageLoadInfoPrivilege")))
            {
                return;
            }
        }

        // @codingStandardsIgnoreStart
        ?><dl id="cw-content-pagestats" class="cw-list cw-list-horizontal">
            <dt>Gen Time</dt>
            <dd><?PHP printf("%.2f", $GLOBALS["AF"]->GetElapsedExecutionTime());
                    ?> seconds</dd>
            <dt>Version</dt>
            <dd><?PHP print($GLOBALS["SPT_SoftwareVersionToDisplay"]); ?></dd>
            <dt>DB</dt>
            <dd><?PHP  $DB = new Database();  print($DB->DBName());  ?></dd>
            <dt>DB Cache Rate</dt>
            <dd><?PHP  printf("%d%% (%d/%d)",
                    $DB->CacheHitRate(), $DB->NumCacheHits(),
                    $DB->NumQueries()); ?></dd>
            <dt>Mem Usage</dt>
            <dd><?PHP
            $MemUsed = function_exists("memory_get_peak_usage")
                    ? memory_get_peak_usage(TRUE) : memory_get_usage();
            $MemLimit = ApplicationFramework::GetPhpMemoryLimit();
            $MemPercent = round(($MemUsed * 100) / $MemLimit);
            printf("%d%% (%4.2fM/%dM)", $MemPercent, ($MemUsed / (1024 * 1024)),
                    ($MemLimit / (1024 * 1024)));
            ?></dd>
            <?PHP
        if (is_readable("LASTSITEUPDATE"))
        {
            $Lines = file("LASTSITEUPDATE");
            $Line = array_shift($Lines);
            $LastUpdate = strtotime($Line);
            ?>
            <dt>Last Update</dt>
            <dd><?PHP  print GetPrettyTimeStamp($LastUpdate);  ?></dd>
            <?PHP
        }
            ?>
        </dl><?PHP
        // @codingStandardsIgnoreEnd
    }


    # ---- 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 $Suffix Filename suffix to check (e.g., .sql).
    * @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;
    }

    /**
    * Normalize the file URL fallback prefix value when it's set.
    * @param string $SettingName Name of configuration setting.
    * @param string $NewValue New value of configuration setting.
    * @return string Updated value of configuration setting.
    */
    protected function NormalizeFileUrlFallbackPrefix($SettingName, $NewValue)
    {
        # add a trailing slash to the new value if not present
        $NewValue = trim($NewValue);
        if (strlen($NewValue) && (substr($NewValue, -1) != "/"))
        {
            $NewValue = $NewValue."/";
        }

        # return updated value to caller
        return $NewValue;
    }

    /**
    * Load settings from local file if available.
    */
    private function LoadLocalSettings()
    {
        # look for developer settings file
        if (is_readable("local/developer.ini"))
        {
            $SettingsFile = "local/developer.ini";
        }
        elseif (is_readable("developer.ini"))
        {
            $SettingsFile = "developer.ini";
        }

        # if settings file found
        if (isset($SettingsFile))
        {
            # read settings from file
            $Settings = parse_ini_file($SettingsFile, TRUE);

            # error out if syntax error
            if ($Settings === FALSE)
            {
                $this->Instructions .= "<br><br>Syntax error encountered in"
                        ." developer settings file ".$SettingsFile;
                return;
            }

            # for each setting section found
            foreach ($Settings as $SectionName => $Section)
            {
                # load plugin for section if available
                try
                {
                    $Plugin = $GLOBALS["G_PluginManager"]->GetPlugin(
                            $SectionName, TRUE);
                }
                catch (Exception $Exception)
                {
                    $Plugin = NULL;
                }
                if ($Plugin == NULL)
                {
                    $ErrorMsgs[] = "Skipped section <i>".$SectionName
                            ."</i> in ".$SettingsFile." because"
                            ." corresponding plugin was not available.";
                    continue;
                }

                # for each setting found
                foreach ($Section as $Param => $Value)
                {
                    # handle special case settings
                    $FullParam = $SectionName.":".$Param;
                    $HandledAsSpecialCase = FALSE;
                    switch ($Param)
                    {
                        case "Enabled":
                            $Plugin->IsEnabled($Value ? TRUE : FALSE, FALSE);
                            $HandledAsSpecialCase = TRUE;
                            $SetMsgs[] = "<i>".$SectionName."</i> plugin "
                                    .($Value ? "ENABLED" : "DISABLED");
                            break;
                    }
                    switch ($FullParam)
                    {
                        case "Developer:AutoUpgradeIPMask":
                        case "Developer:EmailWhitelist":
                            $SetMsgs[] = "<i>".$FullParam."</i> set to <i>".$Value."</i>";
                            $Value = str_replace(",", "\n", $Value);
                            $Plugin->ConfigSettingOverride($Param, $Value);
                            $HandledAsSpecialCase = TRUE;
                            break;
                    }
                    if ($HandledAsSpecialCase)
                    {
                        continue;
                    }

                    # handle other settings based on type
                    switch ($Plugin->GetConfigSettingType($Param))
                    {
                        case "Flag":
                            $SetMsgs[] = "<i>".$FullParam."</i> set to "
                                    .($Value ? "TRUE" : "FALSE");
                            $Plugin->ConfigSettingOverride($Param, $Value);
                            break;

                        case "Text":
                        case "URL":
                        case "Number":
                        case "Paragraph":
                            $SetMsgs[] = "<i>".$FullParam."</i> set to <i>"
                                    .$Value."</i>";
                            $Plugin->ConfigSettingOverride($Param, $Value);
                            break;

                        case "Option":
                            $SParams = $Plugin->GetConfigSettingParameters($Param);
                            $Pieces = explode(",", $Value);
                            $SelectedOpts = array();
                            $NoErrors = TRUE;
                            foreach ($Pieces as $Piece)
                            {
                                $Piece = trim($Piece);
                                if ($OptKey = array_search(
                                        $Piece, $SParams["Options"]))
                                {
                                    $SelectedOpts[$OptKey] = $Piece;
                                }
                                else
                                {
                                    $ErrorMsgs[] = "Unknown value <i>".$Piece
                                            ."</i> found for setting <i>".$Param
                                            ."</i> for plugin <i>".$SectionName
                                            ."</i> in ".$SettingsFile;
                                    $NoErrors = FALSE;
                                }
                            }
                            if ($NoErrors || count($SelectedOpts))
                            {
                                $SetMsgs[] = "<i>".$FullParam."</i> set to <i>"
                                        .implode(", ", $SelectedOpts)."</i>";
                                $Plugin->ConfigSettingOverride($Param,
                                        array_keys($SelectedOpts));
                            }
                            break;

                        default:
                            $ErrorMsgs[] = "Unknown or unsupported setting <i>"
                                    .$Param."</i> found for plugin <i>"
                                    .$SectionName."</i> in ".$SettingsFile;
                            break;
                    }
                }
            }
        }

        # add any results to plugin instructions message
        if (isset($ErrorMsgs) || isset($SetMsgs))
        {
            $this->Instructions .= "<br><br>\n";
            if (isset($ErrorMsgs))
            {
                foreach ($ErrorMsgs as $Msg)
                {
                    $this->Instructions .= "<b>ERROR:</b> ".$Msg."<br>\n";
                }
                $this->Instructions .= "<br>\n";
            }
            if (isset($SetMsgs))
            {
                $this->Instructions .= "Values forced via ".$SettingsFile
                        .":<br>\n<ul>\n";
                foreach ($SetMsgs as $Msg)
                {
                    $this->Instructions .= "<li>".$Msg."</li>\n";
                }
                $this->Instructions .= "</ul>\n";
            }
        }
    }

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