<?php
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
namespace seekquarry\yioop\controllers;

use seekquarry\atto as A;
use seekquarry\yioop as B;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\AnalyticsManager;
use seekquarry\yioop\library\UrlParser;
use seekquarry\yioop\models\Model;
use seekquarry\yioop\controllers\components\Component;
use seekquarry\yioop\views\View;
use seekquarry\yioop\library\wiki as LW;

/**
 * Load crawlHash  and timing functions
 */
require_once C\BASE_DIR . "/library/Utility.php";
/** For guessing which locale to use and writing a date the way that
 *  locale writes one
 */
require_once C\BASE_DIR . "/library/LocaleFunctions.php";
/**
 * tl translates the words handed to it into the current locale.
 *
 * It is a copy of the function of the same name in the library, put
 * here so this sub-namespace can reach it without naming the library
 * every time.
 *
 * @param string string_identifier  identifier to be translated
 * @param mixed additional_args  used for interpolation in translated string
 * @return string  translated string
 */
function tl()
{
    return call_user_func_array(C\NS_LIB . "tl", func_get_args());
}
/**
 * e writes what it is given out; a shorthand for echo
 *
 * @param string $text string to send to the current output
 */
function e($text)
{
    echo $text;
}
/**
 * Base controller class for all controllers on
 * the SeekQuarry site.
 *
 * @author Chris Pollett
 */
abstract class Controller
{
    /**
     * How long, in seconds, a proof-of-work challenge stays valid before
     * the browser's answer is considered stale.
     */
    const HASH_TIMESTAMP_TIMEOUT = 300;
    /**
     * How many leading zeroes the proof-of-work hash must have. A larger
     * number makes the browser work harder before it can submit a form.
     */
    const HASH_CAPTCHA_LEVEL = 2;
    /**
     * $web_site holds the web server when Yioop runs in CLI mode,
     *  it acts as request router in non-CLI  mode.
     *  In CLI, mode it is useful for caching files in RAM as they are read
     * @var seekquarry\atto\WebSite
     */
    public $web_site;
    /**
     * $component_instances holds one of each component whose activities
     * this controller offers, built when the controller is built.
     * @var array
     */
    public $component_instances;
    /**
     * $view_instances holds one of each view this controller has drawn
     * so far, since a view is built the first time it is asked for.
     * @var array
     */
    public $view_instances = [];
    /**
     * $model_instances holds one of each model this controller has read
     * through so far, built the first time each is asked for.
     * @var array
     */
    public $model_instances;
    /**
     * $plugin_instances holds the indexing plugins used by this controller
     * @var array
     */
    public $plugin_instances;
    /**
     * Says which activities (roughly methods invoke from the web) this
     * controller will respond to
     * @var array
     */
    public $activities = [];
    /**
     * activity_component says, for each activity, which component activity is
     * on, used
     * by @see Controller::call method to actually invoke a given activity
     * on a given component
     * @var array
     */
    public $activity_component = [];
    /**
     * $component_activities says which activities belong to which
     * component for this controller
     * Components are collections of activities (a little like traits) which
     * can be reused.
     *
     * @var array
     */
    public static $component_activities = [];
    /**
     * __construct sets up the component activities, the instance
     * arrays, and plugins.
     *
     * @param seekquarry\atto\WebSite $web_site is the web server
     *      when Yioop runs in CLI mode, it acts as request router in non-CLI
     *      mode. In CLI, mode it is useful for caching files in RAM as they
     *      are read
     * @return mixed redirectWithMessage call result on a "signout"
     *      request (under CLI it returns the request data array);
     *      void on the normal path
     */
    public function __construct($web_site = null)
    {
        $this->web_site = $web_site; /* for redirects to work this needs to
            be initialized here */
        $this->component_instances = [];
        $this->plugin_instances = [];
        $this->model_instances = [];
        $this->view_instances = [];
        if (!empty($_SESSION['USER_ID'])) {
            if ($this->model('user')->getUserStatus($_SESSION['USER_ID']) ===
                false) {
                $_REQUEST['a'] = "signout";
            }
        }
        // this is to force a signout request to signout in all controllers
        if (isset($_REQUEST['a']) && $_REQUEST['a'] == "signout") {
            if (!empty($_COOKIE[C\p('SESSION_NAME')])) {
                $this->model("user")->deleteSessionUser(
                    $_COOKIE[C\p('SESSION_NAME')]);
            }
            $_SESSION = [];
            /* Emptied rather than done away with. This server keeps its
               process between requests, so taking the array itself away
               leaves later work looking at an array that is no longer
               there. */
            $_REQUEST = [];
            return $this->redirectWithMessage(
                tl('search_controller_logout_successful'));
        }
        /* if no signout was made, get on with the business of building
           this controller
         */
        mb_internal_encoding("UTF-8");
        mb_regex_encoding("UTF-8");
        $class = get_class($this);
        foreach ($class::$component_activities as $component => $activities) {
            foreach ($activities as $activity) {
                $this->activity_component[$activity] = $component;
                $this->activities[] = $activity;
            }
        }
    }
    /**
     * processRequest is what each controller writes for itself to
     * handle requests
     */
    public abstract function processRequest();
    /**
     * Dynamic loader for Component objects which might live on the current
     * Component
     *
     * @param string $component name of model to return
     * @return Component the singleton instance for this controller of
     *      the given component, instantiated lazily on first request
     */
    public function component($component)
    {
        if (!isset($this->component_instances[$component])) {
            $component_name = C\NS_COMPONENTS . ucfirst($component) .
                "Component";
            $this->component_instances[$component] =
                new $component_name($this);
        }
        return $this->component_instances[$component];
    }
    /**
     * Dynamic loader for Model objects which might live on the current
     * Controller
     *
     * @param string $model name of model to return
     * @return Model the singleton instance for this controller of the
     *      given model, instantiated lazily on first request
     */
    public function model($model)
    {
        if (!isset($this->model_instances[$model])) {
            $model_name = C\NS_MODELS . ucfirst($model) . "Model";
            $this->model_instances[$model] = new $model_name(C\p('DB_NAME'),
                true, $this->web_site);
        }
        return $this->model_instances[$model];
    }
    /**
     * Dynamic loader for Plugin objects which might live on the current
     * Controller
     *
     * @param string $plugin name of Plugin to return
     * @return object the singleton instance for this controller of the
     *      given plugin, instantiated lazily on first request
     */
    public function plugin($plugin)
    {
        if (!isset($this->plugin_instances[$plugin])) {
            $plugin_name = C\NS_PLUGINS .
                ucfirst($plugin)."Plugin";
            $this->plugin_instances[$plugin] = new $plugin_name();
        }
        return $this->plugin_instances[$plugin];
    }
    /**
     * getIndexingPluginList gives a list of the indexing plugins for this Yioop
     * instance.
     *
     * @return array names of indexing plugins discovered under
     *      BASE_DIR/library/indexing_plugins/ and APP_DIR/library/
     *      indexing_plugins/
     */
    public function getIndexingPluginList()
    {
        $dir_prefixes = [C\BASE_DIR, C\APP_DIR];
        foreach ($dir_prefixes as $dir_prefix) {
            $plugin_dir = $dir_prefix . "/library/indexing_plugins/";
            $plugin_dir_len = strlen($plugin_dir);
            $plugin_ext_len = strlen("Plugin.php");
            foreach (glob("$plugin_dir*Plugin.php") as $filename) {
                $tmp_plug_name = substr($filename, $plugin_dir_len,
                    -$plugin_ext_len);
                if ($tmp_plug_name != "Indexing") {
                    $indexing_plugins[] = $tmp_plug_name;
                }
            }
        }
        return $indexing_plugins;
    }
    /**
     * Dynamic loader for View objects which might live on the current
     * Controller
     *
     * @param string $view name of view to return
     * @return View the singleton instance for this controller of the
     *      given view, instantiated lazily on first request
     */
    public function view($view)
    {
        if (!isset($this->view_instances[$view])) {
            $view_name = C\NS_VIEWS . ucfirst($view)."View";
            $this->view_instances[$view] = new $view_name($this);
        }
        return $this->view_instances[$view];
    }
    /**
     * displayView sends the view given to output, drawing it with the given
     * data variable, using the current locale for translation, and
     * writing mode
     *
     * @param string $view   the name of the view to draw
     * @param array $data   an array of values to use in drawing the view
     */
    public function displayView($view, $data)
    {
        $data['LOCALE_TAG'] = L\getLocaleTag();
        $data['LOCALE_DIR'] = L\getLocaleDirection();
        $data['WRITING_MODE'] = L\getWritingMode();
        if (C\QUERY_STATISTICS) {
            $data['QUERY_STATISTICS'] = [];
            $machine =  isset($_SERVER["HTTP_HOST"]) ?
                htmlentities($_SERVER["HTTP_HOST"]) : "localhost";
            $machine_uri = isset($_SERVER['REQUEST_URI']) ?
                htmlentities($_SERVER['REQUEST_URI']) : "/";
            $protocol = (isset($_SERVER["HTTPS"])) ? "https://" : "http://";
            if ($machine == '::1') { //IPv6 :(
                $machine = "[::1]/";
                //used if the fetching and queue serving on the same machine
            }
            $data['YIOOP_INSTANCE'] = $protocol . $machine . $machine_uri;
            $data['TOTAL_ELAPSED_TIME'] = 0;
            foreach ($this->model_instances as $model_name => $model) {
                $data['QUERY_STATISTICS'] = array_merge(
                    $model->db->query_log,
                    $data['QUERY_STATISTICS']
                    );
                $data['TOTAL_ELAPSED_TIME'] +=
                    $model->db->total_time;
            }
            $locale_info = L\getLocaleQueryStatistics();
            $data['QUERY_STATISTICS'] = array_merge(
                    $locale_info['QUERY_LOG'],
                    $data['QUERY_STATISTICS']
                    );
            $data['TOTAL_ELAPSED_TIME'] +=
                    $locale_info['TOTAL_ELAPSED_TIME'];
            $mail_total_time = AnalyticsManager::get("MAIL_TOTAL_TIME");
            $mail_messages = AnalyticsManager::get("MAIL_MESSAGES");
            if ($mail_total_time && $mail_messages) {
                $data['QUERY_STATISTICS'] = array_merge($mail_messages,
                    $data['QUERY_STATISTICS']
                    );
                $data['TOTAL_ELAPSED_TIME'] += $mail_total_time;
            }
        }
        $data['c'] = isset($_REQUEST['c']) ? $_REQUEST['c'] : null;
        $this->applyDomainAppearance($data);
        if (isset($_SESSION['DISPLAY_MESSAGE'])) {
            $data['DISPLAY_MESSAGE'] = $_SESSION['DISPLAY_MESSAGE'];
            unset($_SESSION['DISPLAY_MESSAGE']);
        }
        /* The help mark drawn on a page carries where the reader is and
           where they came from, so they can be brought back. Those
           values come out of the address, and an address is whatever
           the person asking sent, so they are made safe here, in the
           controller, and handed over. Nothing past this point cleans
           anything. */
        $view_object = $this->view($view);
        $view_object->helper("helpbutton")->safe_request =
            $this->safeHelpRequest();
        $view_object->render($data);
    }
    /**
     * safeHelpRequest gives the values the help mark needs, taken
     * from the address and
     * made safe to write into a page: which controller and activity the
     * reader is on, which help page to open where one was asked for,
     * and the rest of the address under "back" so a reader can be
     * returned to where they were.
     *
     * An address may write one name more than once, or write it with
     * brackets, and what arrives is then a list rather than a word.
     * Those are left out: where a reader came from is written as flat
     * pairs, and a list has no place among them.
     *
     * @return array the values, each already made safe
     */
    public function safeHelpRequest()
    {
        /* A reader following the pencil on a help article that is not
           written yet is starting a page, and where it will be started
           is worth saying, since the screen they are on belongs to some
           other group. Which number the help group has depends on how
           the site was set up, so it is asked for by name, and the
           number in the settings stands only as a last resort. */
        $group_model = $this->model("group");
        $help_group = C\p('HELP_GROUP_NAME');
        $help_group_id = $group_model->getGroupId($help_group);
        if ($help_group_id <= 0) {
            $help_group_id = C\HELP_GROUP_ID;
            $help_group = $group_model->getGroupName($help_group_id);
        }
        $safe = ["controller" => $this->clean($_REQUEST['c'] ?? "",
            "string"), "activity" => $this->clean($_REQUEST['a'] ?? "",
            "string"), "open_help_page" => "", "back" => [],
            "help_group" => $this->clean($help_group ?: "", "string"),
            "help_group_id" => $help_group_id];
        if (isset($_REQUEST['open_help_page']) &&
            is_string($_REQUEST['open_help_page'])) {
            $safe['open_help_page'] = $this->clean(
                $_REQUEST['open_help_page'], "string");
        }
        /* What names a page or a group is left out along with the
           rest: the way back is carried into a link that edits the help
           page itself, and a group or page from the screen behind names
           something else entirely, so the edit landed in the wrong
           group and saved there. */
        $leave_out = ["a", "c", C\p('CSRF_TOKEN'), "open_help_page",
            "KWIKI_PAGE", "modify_add", "group_id", "page_id",
            "page_name", "arg", "sub_path", "settings",
            C\p('SESSION_NAME'), "route", "YIOOP_TOKEN"];
        foreach ($_GET as $name => $value) {
            if (in_array($name, $leave_out) || !is_string($value)) {
                continue;
            }
            $safe['back'][$this->clean($name, "string")] =
                $this->clean($value, "string");
        }
        return $safe;
    }
    /**
     * siteName gives the name the site goes by on the host this request came in
     * on: the name a routed domain has been given when it has one, and
     * the site's own name otherwise. A page's title and the label on its
     * logo say who the site is, and a site answering on several domains
     * is a different who on each of them, so asking the host rather than
     * naming the site once is what lets one Yioop be several sites.
     *
     * @return string name to call the site on this request's host
     */
    public function siteName()
    {
        $host = $this->requestHost();
        if ($host !== '') {
            $appearance =
                $this->model('group')->getDomainAppearance($host);
            if (!empty($appearance['SITE_NAME'])) {
                return $appearance['SITE_NAME'];
            }
        }
        return C\p('SITE_NAME');
    }
    /**
     * requestHost gives the request host in lower case with any port suffix
     * removed, matching the bare host names stored as custom-route
     * domains. An IPv6 literal's bracketed colons are left in place.
     * Returns the empty string when no host is available.
     *
     * @return string normalized request host, or "" when none
     */
    public function requestHost()
    {
        $host = strtolower($_SERVER['HTTP_HOST'] ??
            ($_SERVER['SERVER_NAME'] ?? ''));
        $colon = strrpos($host, ':');
        if ($colon !== false && strpos($host, ']') === false) {
            $host = substr($host, 0, $colon);
        }
        return $host;
    }
    /**
     * applyDomainAppearance lays the current request domain's saved
     * appearance fields
     * (colors, logos, favicon, background image, auxiliary CSS,
     * searchbar path) onto $data so the layout renders
     * that domain's look. A domain with no saved appearance is left
     * untouched and keeps the global Appearance settings, as does
     * every request when no custom domain routes exist. The values
     * land in the same $data keys the layout already reads, so the
     * existing "use $data value when set, else the global constant"
     * logic applies them with no further view changes.
     *
     * @param array &$data field data being sent to the view, modified
     *      in place with the domain's appearance overrides
     */
    public function applyDomainAppearance(&$data)
    {
        $host = $this->requestHost();
        if ($host === '') {
            return;
        }
        $appearance =
            $this->model('group')->getDomainAppearance($host);
        if (empty($appearance)) {
            return;
        }
        foreach ($appearance as $field => $value) {
            if ($field === 'LANDING_PAGE') {
                /* landing behavior is global, or per-domain through
                   the domain's route; never overlaid from a saved
                   appearance row even if an older one still holds it */
                continue;
            }
            if ($field === 'SEARCHBAR_PATH') {
                /* the search toolbar names the site once for a whole
                   browser, so it is set for the site on Page Options and
                   never overlaid from a saved appearance row, which may
                   still hold one from when it was set per domain */
                continue;
            }
            if ($value !== '' && $value !== null) {
                $data[$field] = $value;
            }
        }
        if (!empty($appearance['AUXILIARY_CSS_NAME'])) {
            /* Name the theme this page is dressed in separately from the
               field the appearance form fills in, which may be showing
               the theme of whichever scope is being customized rather
               than of the host being browsed. Which theme a domain wears
               is the domain's own, but the theme itself is one of the
               site's, so there is nowhere else to read it from. */
            $data['PAGE_THEME_NAME'] = $appearance['AUXILIARY_CSS_NAME'];
        }
    }
    /**
     * redirectWithMessage redirects to the location given, and sets a session
     * variable
     * to display a message when get there.
     *
     * @param string $message message to write
     * @param string $copy_fields $_REQUEST fields to copy for redirect
     * @param bool $restart if yioop is being run as its own server rather than
     *      under apache whether to restart this server.
     * @param bool $use_base_url set true if the base_url should
     *   be included in the redirect
     * @return mixed under CLI without an own-web-server attached, returns
     *      the original $_REQUEST data array (used by configure_tool); in
     *      every normal HTTP path the function calls header()/exit and
     *      does not return
     */
    public function redirectWithMessage($message, $copy_fields = false,
        $restart = false, $use_base_url = false)
    {
        $default_fields = ["c", "a", C\p('CSRF_TOKEN'), "just_thread",
            "just_group_id", "just_user_id", "group_id", "user_id", "role_id",
            "limit", "num", "v"];
        if ($copy_fields) {
            $copy_fields = array_merge($default_fields, $copy_fields);
        } else {
            $copy_fields = $default_fields;
        }
        if (!empty($_REQUEST['context']) && !in_array("context", $copy_fields)){
            switch ($_REQUEST['context']) {
                case "account":
                    $use_base_url = true;
                    unset($_REQUEST['route']['c']);
                    $_REQUEST['c'] = "admin";
                    $_REQUEST['a'] = "manageAccount";
                    unset($_REQUEST['context']);
                    break;
                case "groups":
                    $use_base_url = true;
                    unset($_REQUEST['route']['c']);
                    $_REQUEST['c'] = "group";
                    $_REQUEST['a'] = "groupFeed";
                    unset($_REQUEST['context']);
                    break;
                case "join_groups":
                    $use_base_url = true;
                    unset($_REQUEST['route']['c']);
                    $_REQUEST['c'] = "admin";
                    $_REQUEST['a'] = "manageGroups";
                    $_REQUEST['browse'] = "true";
                    $copy_fields[] = "browse";
                    unset($_REQUEST['context']);
                    break;
            }
        }
        $query_array = [];
        foreach ($copy_fields as $field) {
            if (isset($_REQUEST[$field])) {
                if (is_array($_REQUEST[$field])) {
                    $array_params_cleaned = $_REQUEST[$field];
                    foreach ($array_params_cleaned as $key => $value) {
                        $query_array[$field][$this->clean($key, "string")] =
                            $this->clean($value, "string") ;
                    }
                } else {
                    $query_array[$field] = $this->clean($_REQUEST[$field],
                        "string");
                }
            }
        }
        if (isset($_REQUEST['route'])) {
            foreach ($query_array as $field => $value) {
                if (!empty($_REQUEST['route'][$field])) {
                    unset($query_array[$field]);
                }
            }
        }
        $query_array = array_filter($query_array);
        $url = ($use_base_url) ? C\baseUrl() : "";
        /* the query-less fallback (mainly signout, which empties
           $_REQUEST) is host-relative so that signing out on a
           domain with its own landing route stays on that domain;
           on the base host it resolves to the same address as
           before
         */
        $location = ($query_array == []) ? C\SHORT_BASE_URL :
            $url . "?" . http_build_query($query_array);
        $data = [];
        if ($message) {
            $data['MESSAGE'] = $message;
            $_SESSION['DISPLAY_MESSAGE'] = $message;
        } else {
            unset($_SESSION['DISPLAY_MESSAGE']);
        }
        if (empty($this->web_site) && php_sapi_name() == 'cli') {
            //this case happens for configure_tool.php
            return $data;
        }
        if (isset($_SERVER['HTTP_X_REQUESTED_WITH']) &&
            $_SERVER['HTTP_X_REQUESTED_WITH'] == "XMLHttpRequest") {
            e("go$location");
        } else {
            if (!empty($this->web_site)) {
                if ($restart && C\nsdefined("IS_OWN_WEB_SERVER")) {
                    L\CrawlDaemon::saveDaemonStateForRestart();
                    $sessions = $this->web_site->getSessions();
                    if (!empty($sessions["TERMINAL"])) {
                        e(tl("controller_manual_restart_required"));
                        A\webExit("stop");
                    }
                    /* Point the poll at the admin configure status
                       route. controllerUrl gives the right form
                       whether the site uses pretty URLs or query
                       strings, and the right separator, so this works
                       in the first-configuration case where the
                       redirect target itself is just the site root
                       with no query string to append to. */
                    $status_location = B\controllerUrl("admin", true) .
                        "a=configure&arg=restart_status";
                    $this->web_site->header("Connection:close");
                    e($this->restartStatusPage($location,
                        $status_location));
                    A\webExit("restart " . C\WORK_DIRECTORY .
                        "/PreviousSession.txt");
                } else {
                    $this->web_site->header("Location:$location");
                }
            }
        }
        $_FILES = []; // this is for when Yioop run as own web server
        A\webExit();
    }
    /**
     * restartStatusPage builds the page shown after a Restart Server action.
     * The page
     * polls the restart-status route every few seconds and reports
     * the current phase. Fetches fail during the brief window when the
     * server is itself restarting; the script keeps trying, then sends
     * the browser back to $location once the status reports the
     * restart is finished.
     *
     * @param string $location url to return to when the restart is
     *      done
     * @param string $status_location url of the restart-status route
     *      that returns the current phase as JSON
     * @return string the html of the restart progress page
     */
    public function restartStatusPage($location, $status_location)
    {
        $location = html_entity_decode($location);
        $waiting = tl("controller_restart_server");
        $poll = C\RESTART_STATUS_POLL_TIME;
        $page = "<!DOCTYPE html><html><head><meta charset='utf-8'>".
            "<title>" . $waiting . "</title></head><body>".
            "<p id='restart-message'>" . $waiting . "</p><script>".
            "var status_url = " . json_encode($status_location) . ";".
            "var done_url = " . json_encode($location) . ";".
            "var poll_time = " . intval($poll) . ";".
            "var tries = 0;".
            "var max_tries = 120;".
            "function setMessage(text) {".
            "  var node = document.getElementById('restart-message');".
            "  if (node) { node.textContent = text; }".
            "}".
            "function checkRestart() {".
            "  tries++;".
            "  if (tries > max_tries) {".
            "    window.location = done_url;".
            "    return;".
            "  }".
            "  fetch(status_url, {credentials: 'same-origin'}).then(".
            "    function (response) { return response.json(); }).then(".
            "    function (info) {".
            "      if (!info.phase) {".
            "        window.location = done_url;".
            "        return;".
            "      }".
            "      setMessage(info.phase);".
            "    }).catch(function () {});".
            "}".
            "setInterval(checkRestart, poll_time);".
            "checkRestart();".
            "</script></body></html>";
        return $page;
    }
    /**
     * redirectLocation performs a redirect to the location in both under web
     * server
     *  and CLI setting
     *
     * @param string $location url to redirect to
     */
    public function redirectLocation($location)
    {
        $this->web_site->header("Location:$location");
        $_FILES = []; // this is for when Yioop run as own web server
        A\webExit();
    }
    /**
     * pagingLogic works out, where an activity shows tabular data (such as
     * rows of
     * users, groups, etc), this method might be called to set up $data
     * fields for next, prev, and page links, it also makes the call to the
     * model to get the row data sorted and restricted as desired. For some
     * data sources, rather than directly make a call to the model to get the
     * data it might be passed directly to this method.
     *
     * @param array &$data used to send data to the view will be updated by
     *     this method with row and paging data
     * @param mixed $field_or_model if an object, this is assumed to be a model
     *     and so the getRows method of this model is called to get row data,
     *     sorted and restricted according to $search_array; if a string
     *     then the row data is assumed to be in $data[$field_or_model] and
     *     pagingLogic itself does the sorting and restricting.
     * @param string $output_field output rows for the view will be stored in
     *     $data[$output_field]
     * @param int $default_show if not specified by $_REQUEST, then this will
     *     be used to determine the maximum number of rows that will be
     *     written to $data[$output_field]
     * @param array $search_array used to sort and restrict in
     *     the getRows call or the data from $data[$field_or_model].
     *     Each element of this is a quadruple name of a field, what comparison
     *     to perform, a value to check, and an order (ascending/descending)
     *     to sort by
     * @param string $var_prefix if there are multiple uses of pagingLogic
     *     presented on the same view then $var_prefix can be prepended to
     *     to the $data field variables like num_show, start_row, end_row
     *     to distinguish between them
     * @param array $args additional arguments that are passed to getRows and
     *     in turn to selectCallback, fromCallback, and whereCallback that
     *     might provide user_id, etc to further control which rows are
     *     returned
     */
     public function pagingLogic(&$data, $field_or_model, $output_field,
        $default_show, $search_array = [], $var_prefix = "", $args = null)
    {
        $data_fields = [];
        $paged = [];
        $request_fields = ['num_show' => $default_show,
            'start_row' => 0, 'end_row' => $default_show];
        foreach ($request_fields as $field => $default) {
            if (isset($_REQUEST[$var_prefix . $field])) {
                $paged[$field] = $_REQUEST[$var_prefix . $field];
            } else {
                $paged[$field] = $default;
            }
        }
        if ($paged['start_row'] + $paged['num_show'] != $paged['end_row']) {
            $paged['end_row'] = $paged['start_row'] + $paged['num_show'];
        }
        $names = [];
        $data_fields = ['NUM_TOTAL', 'NUM_SHOW', 'START_ROW', 'END_ROW',
            'NEXT_START', 'NEXT_END', 'PREV_START', 'PREV_END'];
        $var_field = strtoupper($var_prefix);
        foreach ($data_fields as $field) {
            $names[$field] = $var_prefix . $field;
        }
        $num_show = (isset($paged['num_show']) &&
            isset($this->view("admin")->helper("pagingtable")->show_choices[
                $paged['num_show']])) ? $paged['num_show'] : $default_show;
        $data[$names['NUM_SHOW']] = $num_show;
        $data[$names['START_ROW']] = isset($paged['start_row']) ?
             max(0, $this->clean($paged['start_row'],"int")) : 0;
        if (is_object($field_or_model)) {
            $data[$output_field] = $field_or_model->getRows(
                $data[$names['START_ROW']], $num_show, $num_rows, $search_array,
                $args);
        } else {
            $num_rows = count($data[$field_or_model]);
            if ($search_array != []) {
                $out_data = [];
                foreach ($data[$field_or_model] as $name => $field_data) {
                    $checks_passed = true;
                    foreach ($search_array as $search_data) {
                        list($column_name, $comparison, $search_value, $sort) =
                            $search_data;
                        if ($search_value === "") {
                            continue;
                        }
                        if (isset($args[$column_name])) {
                            $column_name = $args[$column_name];
                        }
                        $row_value = is_object($field_data) ?
                            $field_data->$column_name:
                            $field_data[$column_name];
                        $cmp = strcmp($search_value, $row_value);
                        if (($cmp == 0 && $comparison == "=") ||
                            ($cmp != 0 && $comparison == "!=")
                            ) {
                            continue;
                        }
                        $pos = strpos($row_value, $search_value);
                        $len_row = strlen($row_value);
                        $len_search = strlen($search_value);
                        if (($comparison == "CONTAINS" && $pos !== false) ||
                            ($comparison == "BEGINS WITH" && $pos === 0) ||
                            ($comparison == "ENDS WITH" && $pos === $len_row -
                            $len_search)) {
                            continue;
                        }
                        $checks_passed = false;
                        break;
                    }
                    if ($checks_passed) {
                        $out_data[$name] = $field_data;
                    }
                }
                foreach ($search_array as $search_data) {
                    list($column_name, $comparison, $search_value, $sort) =
                        $search_data;
                    if ($sort == "NONE") {
                        continue;
                    }
                    if (isset($args[$column_name])) {
                        $column_name = $args[$column_name];
                    }
                    $values = [];
                    foreach ($out_data as $name => $field_data) {
                        $values[$name] = is_object($field_data) ?
                            $field_data->$column_name:
                            $field_data[$column_name];
                    }
                    $sort = ($sort=="DESC") ? SORT_DESC: SORT_ASC;
                    array_multisort($values, $sort, $out_data);
                }
            } else {
                $out_data = $data[$field_or_model];
            }
            $data[$output_field] = array_slice($out_data,
                $data[$names['START_ROW']], $num_show);
        }
        $data[$names['START_ROW']] = min($data[$names['START_ROW']], $num_rows);
        $data[$names['END_ROW']] = min($data[$names['START_ROW']] + $num_show,
            $num_rows);
        if (isset($paged['start_row'])) {
            $data[$names['END_ROW']] = max($data[$names['START_ROW']],
                    min($this->clean($paged['end_row'],"int"), $num_rows));
        }
        $data[$names['NEXT_START']] = $data[$names['END_ROW']];
        $data[$names['NEXT_END']] = min($data[$names['NEXT_START']] + $num_show,
            $num_rows);
        $data[$names['PREV_START']] = max(0,
            $data[$names['START_ROW']] - $num_show);
        $data[$names['PREV_END']] = $data[$names['START_ROW']];
        $data[$names['NUM_TOTAL']] = $num_rows;
     }
    /**
     * call invokes an activity method of the current controller or one
     * its components
     *
     * @param string $activity method to invoke
     * @param string $modifiers access modifiers to executing this method
     * @return mixed whatever the dispatched activity method returns:
     *      either a controller's own activity, or the activity on the
     *      registered Component for that activity
     */
     public function call($activity, $modifiers = [])
     {
        if (isset($this->activity_component[$activity])) {
            return $this->component(
                $this->activity_component[$activity])->$activity($modifiers);
        }
        return $this->$activity();
     }
    /**
     * getAccessModifiers gives the possible modifiers to the access to the
     * activity
     * in question.
     *
     * @param string $activity method to get access modifier list for
     * @return array of string names => translated names of the access modifiers
     *      for the method in question (if any exist).
     */
     public function getAccessModifiers($activity)
     {
        $modifiers_method = $activity . "Modifiers";
        $activity_object = $this;
        if (isset($this->activity_component[$activity])) {
            $activity_object = $this->component(
                $this->activity_component[$activity]);
        }
        if (method_exists($activity_object, $modifiers_method)) {
            return $activity_object->$modifiers_method();
        }
        return [];
     }
    /**
     * generateCSRFToken generates a cross site request forgery
     * preventing token based on the
     * provided user name, the current time and the hidden AUTH_KEY
     *
     * @param string $user username to use to generate token
     * @return string a csrf token
     */
    public function generateCSRFToken($user)
    {
        $time = time();
        $ip_address = L\remoteAddress();
        /*remember _SESSION loaded from UserModel::getUserSession not just
            cookie
         */
        $_SESSION['OLD_CSRF_TIME'] = (empty($_SESSION['OLD_CSRF_TIME']) ||
            !is_array($_SESSION['OLD_CSRF_TIME'])) ? [] :
            $_SESSION['OLD_CSRF_TIME']; // create empty array if not set
        if (count($_SESSION['OLD_CSRF_TIME']) > C\MAX_DEVICES) {
            $_SESSION['OLD_CSRF_TIME'] = array_slice($_SESSION['OLD_CSRF_TIME'],
                -C\MAX_DEVICES);
        }
        $_SESSION['OLD_CSRF_TIME'][$ip_address] =
            (isset($_SESSION['CSRF_TIME'][$ip_address])) ?
            $_SESSION['CSRF_TIME'][$ip_address] : 0;
        $_SESSION['CSRF_TIME'] = (empty($_SESSION['CSRF_TIME']) ||
            !is_array($_SESSION['CSRF_TIME'])) ? [] :
            $_SESSION['CSRF_TIME']; // create empty array if not set
        if (count($_SESSION['CSRF_TIME']) > C\MAX_DEVICES) {
            $_SESSION['CSRF_TIME'] = array_slice($_SESSION['CSRF_TIME'],
                -C\MAX_DEVICES);
        }
        $_SESSION['CSRF_TIME'][$ip_address] = $time;
        return L\crawlAuthHash($user . $time)."*$time";
    }
    /**
     * checkCSRFToken checks the form's cross-site request forgery preventing)
     * token
     * matches the given user and has not expired (1 hour till expires)
     *
     * @param string $token_name attribute of $_REQUEST containing CSRFToken
     * @param string $user_id  user id of the user to check the token for
     * @param bool $use_name_as_passed whether to use $token_name as the
     *  token (if true) or to use $_REQUEST[$token_name]
     * @return bool whether the CSRF token was valid
     */
    public function checkCSRFToken($token_name, $user_id,
        $use_name_as_passed = false)
    {
        $token_okay = false;
        $token_value = ($use_name_as_passed) ? $token_name :
            $_REQUEST[$token_name] ?? "";
        /*
            Token format is "<43-char crawlAuthHash>*<unix-time>".
            43 + 1 + 10 = 54 chars; reject anything that can't be
            this layout before reaching for hash_equals so that
            malformed input doesn't reach the constant-time path
            (the length itself is not a secret).
         */
        if (strlen($token_value) == 54) {
            $token_parts = explode("*", $token_value);
            if (isset($token_parts[1]) &&
                intval(trim($token_parts[1])) + C\p('AUTOLOGOUT') > time() &&
                hash_equals(
                    L\crawlAuthHash($user_id . $token_parts[1]),
                    $token_parts[0])) {
                $token_okay = true;
            }
        }
        return $token_okay;
    }
    /**
     * checkCSRFTime checks the timestamp in the token name]
     * matches the timestamp of the last CSRF token accessed by this user
     * for the kind of activity for which there might be a conflict.
     * This is to avoid accidental replays of postings etc if the back button
     * used.
     *
     * @param string $token_name name of a $_REQUEST field used to hold a
     *     CSRF_TOKEN
     * @param string $action name of current action to check for conflicts
     * @return bool whether a conflicting action has occurred.
     */
     public function checkCSRFTime($token_name, $action = "")
     {
        $token_okay = false;
        if (isset($_REQUEST[$token_name])) {
            $token_parts = explode("*", $_REQUEST[$token_name]);
            if (isset($token_parts[1])) {
                $timestamp_to_check = $token_parts[1];
                $ip_address = L\remoteAddress();
                /*remember _SESSION loaded from UserModel::getUserSession not
                    just cookie
                 */
                if ($action == "") {
                    if (isset($_SESSION['OLD_CSRF_TIME'][$ip_address]) &&
                        $token_parts[1] ==
                            $_SESSION['OLD_CSRF_TIME'][$ip_address]) {
                        $token_okay = true;
                    }
                } else {
                    if (!isset($_SESSION['OLD_ACTION_STAMPS'][$action]) ||
                        (isset($_SESSION['OLD_ACTION_STAMPS'][$action]) &&
                        $_SESSION['OLD_ACTION_STAMPS'][$action] <=
                            $timestamp_to_check)) {
                        $_SESSION['OLD_ACTION_STAMPS'][$action] =
                            $timestamp_to_check;
                        $token_okay = true;
                        $cull_time = time() - C\p('AUTOLOGOUT');
                        foreach ($_SESSION['OLD_ACTION_STAMPS'] as $act =>
                            $time) {
                            if (intval($time) < $cull_time) {
                                unset($_SESSION['OLD_ACTION_STAMPS'][$act]);
                            }
                        }
                    }
                }
            }
        }
        return $token_okay;
    }
    /**
     * getCSRFTime gives just the timestamp portion of the CSRF token
     * if it exists. 0 if the user has a non-expired session from this
     * ip address, but no CSRF token, and -1 otherwise.
     *
     * @param string $token_name name of a $_REQUEST field used to hold a
     *     CSRF_TOKEN
     * @return int the timestamp portion of the CSRF_TOKEN
     */
    public function getCSRFTime($token_name)
    {
        $timestamp = -1;
        $ip_address = L\remoteAddress();
        if (isset($_REQUEST[$token_name])) {
            $token_parts = explode("*", $_REQUEST[$token_name]);
            if (isset($token_parts[1])) {
                return intval($token_parts[1]);
            }
        } else if (!empty($_SESSION['OLD_CSRF_TIME'][$ip_address])) {
            $old_time = $_SESSION['OLD_CSRF_TIME'][$ip_address];
            if (time() - intval($old_time) < C\p('AUTOLOGOUT')) {
                $timestamp = 0;
            }
        }
        return $timestamp;
    }
    /**
     * setupProofOfWorkViewData prepares a wiki form's automatic
     * proof-of-work check.
     *
     * The visitor's browser is asked to find a number (a nonce) that makes
     * a hash come out with a required number of leading zeroes. This costs
     * the browser a little work but is invisible to the person, so every
     * wiki form is guarded against automated submissions without anyone
     * having to type anything. A separate random word is also kept in the
     * session; the wiki form code hashes it to tell a fresh submission
     * apart from a reload of an already-saved row. The keyword-captcha flag
     * is cleared here and only turned back on if the form also carries an
     * author-supplied keyword captcha.
     *
     * @param array &$data used by the view to draw any dynamic content; the
     *     proof-of-work scripts are added to its INCLUDE_SCRIPTS list and the
     *     findNonce call that starts the browser working is appended to its
     *     SCRIPT, which the page foot runs after those scripts have loaded
     */
    public function setupProofOfWorkViewData(&$data)
    {
        if (empty($_SESSION)) {
            $_SESSION = [];
        }
        unset($_SESSION["captcha_text"]);
        $_SESSION["captcha_keyword_required"] = false;
        $characters_for_captcha = '123456789abcdefghijklmnpqrstuvwxyz'.
            'ABCDEFGHIJKLMNPQRSTUVWXYZ';
        $len = strlen($characters_for_captcha);
        $word = "";
        for ($i = 0; $i < C\CAPTCHA_LEN; $i++) {
            $word = $word . $characters_for_captcha[rand(0, $len - 1)];
        }
        $_SESSION['captcha_text'] = $word;
        $time = time();
        $_SESSION["request_time"] = $time;
        $_SESSION["level"] = self::HASH_CAPTCHA_LEVEL;
        $_SESSION["random_string"] = md5($time . C\p('AUTH_KEY'));
        if (empty($data['INCLUDE_SCRIPTS'])) {
            $data['INCLUDE_SCRIPTS'] = [];
        }
        $data['INCLUDE_SCRIPTS'] = array_unique(array_merge(
            $data['INCLUDE_SCRIPTS'], ["sha1", "hash_captcha"]));
        /*
            findNonce is defined in hash_captcha.js, which the page foot
            loads. Adding the call to SCRIPT (also emitted at the foot,
            after the includes) rather than inline in the form body makes
            sure the function exists by the time it runs.
        */
        if (empty($data['SCRIPT'])) {
            $data['SCRIPT'] = "";
        }
        $data['SCRIPT'] .= "findNonce('nonce_for_string', " .
            "'random_string', 'time', 'level');";
    }
    /**
     * meetsProofOfWork checks whether a browser solved a
     * proof-of-work challenge.
     *
     * It rebuilds the string the browser was asked to hash (the server's
     * random string, the time it was issued, and the browser's chosen
     * nonce) and confirms the hash starts with the required number of
     * zeroes. Pure check with no side effects so any controller or
     * component can call it.
     *
     * @param string $random_string the random value the server handed out
     * @param string $request_time when the challenge was issued
     * @param string $nonce the number the browser found
     * @param int $level how many leading zeroes the hash must have
     * @return bool true when the hash has enough leading zeroes
     */
    public function meetsProofOfWork($random_string, $request_time, $nonce,
        $level)
    {
        $pattern = '/^0{' . intval($level) . '}/';
        return (bool)preg_match($pattern,
            sha1($random_string . ':' . $request_time . ':' . $nonce));
    }
    /**
     * validatePostedHashCode the answer a browser sent back to the work it
     * was set,
     * without needing anything remembered between the two requests. The
     * form carries back the string and the time it was given along with
     * the number it found, and the string is only accepted if it is the
     * one this site would have made for that time, which no one else can
     * work out. The time must also be recent, so an answer cannot be kept
     * and sent again later.
     *
     * This exists beside the remembered version because a visitor who has
     * not accepted cookies has nothing remembered between requests, and
     * that is exactly the visitor who reports a problem without holding an
     * account.
     *
     * @return bool whether the answer sent back does the work asked of it
     */
    public function validatePostedHashCode()
    {
        $nonce = $_REQUEST['nonce_for_string'] ?? "";
        $random_string = $_REQUEST['random_string'] ?? "";
        $request_time = $_REQUEST['time'] ?? "";
        if ($nonce === "" || $random_string === "" ||
            !ctype_digit((string)$request_time)) {
            return false;
        }
        $age = time() - (int)$request_time;
        if ($age < 0 || $age > self::HASH_TIMESTAMP_TIMEOUT) {
            return false;
        }
        $expected = md5($request_time . C\p('AUTH_KEY'));
        if (!hash_equals($expected, (string)$random_string)) {
            return false;
        }
        return $this->meetsProofOfWork($random_string, $request_time,
            $nonce, self::HASH_CAPTCHA_LEVEL);
    }
    /**
     * validateHashCode the answer a browser sent back to the work it was set:
     * that
     * the number it found, put together with the string and time this
     * session was given, hashes to one starting with the required run of
     * zeroes. A fresh string and time are handed out at the same time, so
     * the next submission has to do the work again rather than send the
     * same answer twice.
     *
     * This sits here rather than on one controller because both the
     * account forms and the anonymous issue report ask the same question,
     * and a check on who may submit is not something to keep two copies
     * of.
     *
     * @return bool whether the answer sent back does the work asked of it
     */
    public function validateHashCode()
    {
        $nonce = $_REQUEST['nonce_for_string'] ?? "";
        $random_string = $_SESSION["random_string"] ?? "";
        $request_time = $_SESSION["request_time"] ?? "";
        $level = $_SESSION['level'] ?? self::HASH_CAPTCHA_LEVEL;
        $meets = $this->meetsProofOfWork($random_string, $request_time,
            $nonce, $level);
        $time = time();
        $_SESSION["request_time"] = $time;
        $_SESSION["random_string"] = md5($time . C\p('AUTH_KEY'));
        if ((time() - $_SESSION["request_time"] <
            self::HASH_TIMESTAMP_TIMEOUT) && $meets) {
            return true;
        }
        return false;
    }
    /**
     * normalizePath reduces a folder path given by a person to a plain
     * relative path that
     * cannot reach outside the folder it will be resolved against.
     *
     * Every separator is written the one way, and the path is then read a
     * segment at a time: a segment naming the folder itself is dropped and
     * one naming the parent folder removes the segment before it, with a
     * parent step at the start having nothing to remove and so being
     * dropped. Working through the segments is what makes this safe, where
     * striking the two-dot sequence out of the text is not: a path can spell
     * that sequence in ways that reappear once the surrounding characters
     * are removed.
     *
     * @param string $path the path as given
     * @return string the same path with no way out of where it starts
     */
    public static function normalizePath($path)
    {
        $path = str_replace("\\", "/", $path);
        $kept = [];
        foreach (explode("/", $path) as $segment) {
            if ($segment === "" || $segment === ".") {
                continue;
            }
            if ($segment === "..") {
                array_pop($kept);
                continue;
            }
            $kept[] = $segment;
        }
        return implode("/", $kept);
    }
    /**
     * Used to clean strings that might be tainted as originate from the user
     *
     * @param mixed $value tainted data
     * @param mixed $type type of data in value can be one of the following
     *      strings: bool, color, double, float, int, hash, path, string, or
     *      web-url;
     *      or it can be an array listing allowed values. If the latter, then
     *      if the value is not in the array the cleaned value will be first
     *      element of the array if $default is null
     * @param mixed $default if $value is not set default value is returned,
     *     this isn't used much since if the error_reporting is E_ALL
     *     or -1 you would still get a Notice.
     * @return string the clean input matching the type provided
     */
    public function clean($value, $type, $default = null)
    {
        $clean_value = null;
        if (is_array($type)) {
            if (in_array($value, $type)) {
                return $value;
            } else {
                if (isset($default)) {
                    return $default;
                }
                reset($type);
                return current($type);
            }
        }
        switch ($type) {
            case "boolean":
                // no break
            case "bool":
                if (isset($value)) {
                    if (!is_bool($value)) {
                        $clean_value = false;
                        if ($value == "true" || $value != 0) {
                            $clean_value = true;
                        }
                    }
                } else if (isset($default)) {
                    $clean_value = $default;
                } else {
                    $clean_value = false;
                }
                break;
            case "color":
                if (isset($value)) {
                    $colors = ["black", "silver", "gray", "white",
                        "maroon", "red", "purple", "fuchsia", "green", "lime",
                        "olive", "yellow", "navy", "blue", "teal", "aqua",
                        "orange", "aliceblue", "antiquewhite", "aquamarine",
                        "azure", "beige", "bisque", "blanchedalmond",
                        "blueviolet", "brown", "burlywood", "cadetblue",
                        "chartreuse", "chocolate", "coral", "cornflowerblue",
                        "cornsilk", "crimson", "darkblue", "darkcyan",
                        "darkgoldenrod", "darkgray", "darkgreen", "darkgrey",
                        "darkkhaki", "darkmagenta", "darkolivegreen",
                        "darkorange", "darkorchid", "darkred", "darksalmon",
                        "darkseagreen", "darkslateblue", "darkslategray",
                        "darkslategrey", "darkturquoise", "darkviolet",
                        "deeppink", "deepskyblue", "dimgray", "dodgerblue",
                        "firebrick", "floralwhite", "forestgreen", "gainsboro",
                        "ghostwhite", "gold", "goldenrod", "greenyellow",
                        "gray", "honeydew", "hotpink", "indianred", "indigo",
                        "ivory", "khaki", "lavender", "lavenderblush",
                        "lawngreen", "lemonchiffon", "lightblue", "lightcoral",
                        "lightcyan", "lightgoldenrodyellow", "lightgray",
                        "lightgreen", "lightgrey", "lightpink", "lightsalmon",
                        "lightseagreen", "lightskyblue", "lightslategray",
                        "lightslategrey", "lightsteelblue", "lightyellow",
                        "limegreen", "linen", "mediumaquamarine",
                        "mediumblue", "mediumorchid", "mediumpurple",
                        "mediumseagreen", "mediumslateblue",
                        "mediumspringgreen", "mediumturquoise",
                        "mediumvioletred", "midnightblue", "mintcream",
                        "mistyrose", "moccasin", "navajowhite", "oldlace",
                        "olivedrab", "orangered", "orchid", "palegoldenrod",
                        "palegreen", "paleturquoise", "palevioletred",
                        "papayawhip", "peachpuff", "peru", "pink", "plum",
                        "powderblue", "rosybrown", "royalblue", "saddlebrown",
                        "salmon", "sandybrown", "seagreen", "seashell",
                        "sienna",  "skyblue", "slateblue", "slategray",
                        "slategrey", "snow", "springgreen", "steelblue",
                        "tan", "thistle", "tomato", "turquoise", "violet",
                        "wheat", "whitesmoke", "yellowgreen", "rebeccapurple"
                    ];
                    if (in_array($value, $colors)
                        || preg_match('/^#[a-fA-F0-9][a-fA-F0-9][a-fA-F0-9]'.
                        '([a-fA-F0-9][a-fA-F0-9][a-fA-F0-9])?$/',
                            trim($value))) {
                        $clean_value = trim($value);
                    } else {
                        $clean_value = "#FFF";
                    }
                } else if (isset($default)) {
                    $clean_value = $default;
                } else {
                    $clean_value = "#FFF";
                }
                break;
            case "double":
                if (isset($value)) {
                    $clean_value = doubleval($value);
                } else if (isset($default)) {
                    $clean_value = $default;
                } else {
                    $clean_value = 0;
                }
                break;
            case "float":
                if (isset($value)) {
                    $clean_value = floatval($value);
                } else if (isset($default)) {
                    $clean_value = $default;
                } else {
                    $clean_value = 0;
                }
                break;
            case "file_name":
                if (isset($value)) {
                    $clean_value = strtr($value, ["&amp;" => "&", "\\" => "/",
                        "*" => "-", ":" => "-", ".." => "-", '"' => '-',
                        "'" => '-']);
                } else {
                    $clean_value = $default;
                }
                break;
            case "hash":
                if (isset($value)) {
                    if (strlen($value) == strlen(L\crawlHash("A")) &&
                        base64_decode($value)) {
                        $clean_value = $value;
                    }
                } else {
                    $clean_value = $default;
                }
                break;
            case "imap_arg":
                /* a string about to be embedded in an IMAP command
                   argument. ASCII control characters (CR and LF in
                   particular) would let an attacker smuggle a CRLF
                   into the wire payload and split it into an extra
                   IMAP command, so they are stripped here. The
                   length is clamped to 256 characters since folder
                   names and filter terms have no legitimate need
                   to be longer. Quoting of backslashes and double-
                   quotes stays in userMailImapQuote, since that is
                   IMAP's syntactic quoted-string encoding rather
                   than input sanitization. */
                if (isset($value)) {
                    $clean_value = preg_replace('/[\x00-\x1F\x7F]/',
                        '', (string) $value);
                    if (strlen($clean_value) > 256) {
                        $clean_value = substr($clean_value, 0, 256);
                    }
                } else if (isset($default)) {
                    $clean_value = $default;
                } else {
                    $clean_value = "";
                }
                break;
            case "int":
                if (isset($value)) {
                    $clean_value = intval($value);
                } else if (isset($default)) {
                    $clean_value = $default;
                } else {
                    $clean_value = 0;
                }
                break;
            case "int32":
                if (isset($value)) {
                    $clean_value = intval($value);
                    if ($clean_value < -2147483648) {
                        $clean_value = -2147483648;
                    } else if ($clean_value > 2147483647) {
                        $clean_value = 2147483647;
                    }
                } else if (isset($default)) {
                    $clean_value = $default;
                } else {
                    $clean_value = 0;
                }
                break;
            case "path":
                if (isset($value)) {
                    $clean_value = self::normalizePath($value);
                } else {
                    $clean_value = $default;
                }
                break;
            case "password":
                if (isset($value)) {
                    $clean_value = $value;
                        /* password is always hashed in code
                         before used/stored (make sure to always double-check)*/
                } else {
                    $clean_value = $default;
                }
                break;
            case "string":
                if (isset($value)) {
                    $value2 = str_replace("&amp;", "&", $value);
                    // -CP REMEMBER TO CK THIS!!!!!
                    $value2 = mb_convert_encoding($value2, "UTF-8");
                    $clean_value = $value2;
                    $clean_value = @htmlspecialchars($value2, ENT_QUOTES,
                        "UTF-8");
                } else {
                    $clean_value = $default;
                }
                break;
            case "time":
                if (isset($value)) {
                    $value = trim($value);
                    $value_parts = explode(":", $value);
                    if (isset($value_parts[1])) {
                        $hour = intval($value_parts[0]);
                        $min = intval($value_parts[1]);
                        if ($hour >= 0 && $hour < 24 && $min >= 0 &&
                            $min < 60) {
                            $clean_value = $value;
                        } else {
                            $clean_value = "00:00";
                        }
                    } else {
                        $clean_value = "00:00";
                    }
                } else if (isset($default)) {
                    $clean_value = $default;
                } else {
                    $clean_value = "00:00";
                }
                break;
            case 'web-url':
                if (isset($value)) {
                    $value = trim($value);
                    $start = substr($value, 0, 4);
                    $is_web = ($start == 'http');
                    if (!$is_web && $start != "goph") {
                        $value = "http://$value";
                        $is_web = true;
                    }
                    if ($is_web) {
                        $value = str_replace("&amp;", "&", $value);
                        $clean_value = @htmlentities($value, ENT_QUOTES,
                            "UTF-8");
                    }
                    $clean_value = UrlParser::canonicalLink($clean_value,
                        $default, false);
                } else {
                    $clean_value = $default;
                }
                break;
        }
        return $clean_value;
    }
    /**
     * convertArrayLines converts lines of strings into a single string with
     * proper newlines, each line having been trimmed and potentially
     * cleaned
     *
     * @param array $arr the array of lines to be process
     * @param string $endline_string what string should be used to indicate
     *     the end of a line
     * @param bool $clean whether to clean each line
     * @return string a concatenated string of cleaned lines
     */
    public function convertArrayLines($arr, $endline_string="\n",
        $clean = false)
    {
        $output = "";
        $eol = "";
        foreach ($arr as $line) {
            $output .= $eol;
            $out_line = trim($line);
            if ($clean) {
                $out_line = $this->clean($out_line, "string");
            }
            $output .= trim($out_line);
            $eol = $endline_string;
        }
        return $output;
    }
    /**
     * convertStringCleanArray cleans a string of lines, typically of urls
     * into an array of
     * clean lines. This is used in handling data from the crawl options
     * text areas. # is treated as a comment
     *
     * @param string $str contains the url data
     * @param string $line_type does additional cleaning depending on the type
     *     of the lines. For instance, if is "url" then a line not beginning
     *     with a url scheme will have http:// prepended.
     * @return array $lines an array of clean lines
     */
    public function convertStringCleanArray($str, $line_type="url")
    {
        $pre_lines = preg_split('/\n+/', $str);
        $lines = [];
        foreach ($pre_lines as $line) {
            $pre_line = trim($this->clean($line, "string"));
            if (strlen($pre_line) > 0) {
                if ($line_type == "url") {
                    $start_line = substr($pre_line, 0, 6);
                    if (!in_array($start_line,
                        ["file:/", "http:/", "domain", "https:", 'gopher',
                        "regex:"]) &&
                        $start_line[0] != "#") {
                        $pre_line = "https://". $pre_line;
                    }
                }
                $lines[] = $pre_line;
            }
        }
        return $lines;
    }
    /**
     * checkRequest checks whether the request is for a valid activity and if
     * it uses
     * the correct authorization key
     *
     * @return bool whether the request was valid or not
     */
    public function checkRequest()
    {
        if (empty($_REQUEST['time']) ||
            empty($_REQUEST['session']) || (
            !in_array($_REQUEST['a'], $this->activities)
            && $_REQUEST['c'] != 'jobs' )) {
            return false;
        }
        $time = intval($_REQUEST['time']);
            // request must be within the autologout of this machine's clock
        if (abs(time() - $time) > intval(C\p('AUTOLOGOUT'))) {
            return false;
        }
        $session = $_REQUEST['session'];
        if (md5($time . C\p('AUTH_KEY')) != $session) {
            return false;
        }
        return true;
    }
    /**
     * parsePageHeadVarsView sets up the head variables and the
     * page_data of a wiki or static
     * page associated with a view.
     *
     * @param object $view View on which page data will be rendered
     * @param string $page_id an id (or symbolic key) identifying the page,
     *     used to file its parsed head and body under; for example a numeric
     *     wiki page id, or 404 for the static 404 error page
     * @param string $page_data this is the actual content of a wiki or
     *      static page
     */
    public function parsePageHeadVarsView($view, $page_id, $page_data)
    {
        list($view->head_objects[$page_id], $view->page_objects[$page_id]) =
            LW\WikiParser::parsePageHeadVars($page_data, true);
    }
    /**
     * initializeAdFields writes the script an outside advertisement
     * needs, where such advertisements are present in the output of this
     * controller this function can be used to initialize the field variables
     * used to write the appropriate Javascripts
     *
     * @param array &$data data to be used in drawing the view
     * @param bool $ads_off whether or not ads are turned off so that this
     *      method should do nothing
     */
     public function initializeAdFields(&$data, $ads_off = false)
     {
        if (C\p('AD_LOCATION') != "none") {
            $data["AD_LOCATION"] = ($ads_off) ? "none" : C\p('AD_LOCATION');
            $ad_fields = ['TOP_ADSCRIPT', 'SIDE_ADSCRIPT', 'GLOBAL_ADSCRIPT'];
            foreach ($ad_fields as $ad_field) {
                $ad = html_entity_decode(constant(C\NS_CONFIGS . $ad_field),
                    ENT_QUOTES);
                $ad = preg_replace("[&#40;]","(",$ad);
                $data[$ad_field] = preg_replace("[&#41;]",")",$ad);
            }
        }
     }
    /**
     * addDifferentialPrivacy adds a little noise to a count, drawn
     * from a bell-shaped source centered on the count itself, so that
     * what is published tells a reader about the whole without telling
     * them about any one person in it. How much noise is added is set by
     * the privacy parameter the caller gives.
     *
     * @param int $actual_value number want to make private
     * @return int $fuzzy_value number after noise added
     */
    public function addDifferentialPrivacy($actual_value)
    {
        $sigma = 1 / C\PRIVACY_EPSILON;
        $max_value = (2 * $actual_value) + 1;
        /* Calculation by Integration
         * f: exp(-1 * ((abs($actual_value - $x)) / $sigma))
         * Since function consists of absolute value, break the integration
         * into two to remove absolute from the function.
         * First integral runs from 0 through $actual_value, and second
         * integral runs from $actual_value through $max_value.
         * Then after using substitution rule, first and second integral
         * range change, say [a,b] and [b,c]
         */
        $a = -1 * $actual_value;
        $b = 0;
        $c = -1 * ($max_value - $actual_value);
        $integral_value = $sigma * (2 - exp($a / $sigma) - exp($c / $sigma));
        /* The area under the curve is a fractional quantity, and the point
           drawn beneath it has to be fractional too. Handing it to rand(),
           whose bounds are whole numbers, cut the area down to a whole one
           before drawing: an area of 2.97 became 2, which both threw away
           the remainder and pushed every draw toward the low end. The
           deprecation this raised on a newer PHP was the symptom; the
           skewed draw was the fault. */
        $random = ($integral_value * mt_rand()) / mt_getrandmax();
        $p = 0;
        for ($i = 0; $i < $max_value; $i++) {
            $arg = -1 * (($actual_value - $i) / $sigma);
            $p +=  exp($arg);
            if ($p > $random) {
                break;
            }
        }
        $fuzzy_value = $i;
        return $fuzzy_value;
    }
    /**
     * recordViewSession stores in a session which media list
     * items have been viewed so
     * we can put an indicator by them when the media list is rendered
     *
     * @param int $page_id the id of page with media list
     * @param string $sub_path the resource folder on that page
     * @param string $media_name item to store indiicator into session for
     */
    public function recordViewSession($page_id, $sub_path, $media_name)
    {
        $hash_id = L\crawlHash($page_id . $media_name . $sub_path);
        if (empty($_SESSION['USER_ID'])||
            !L\isPositiveInteger($_SESSION['USER_ID']) ||
            $_SESSION['USER_ID'] == C\PUBLIC_USER_ID || !empty($_REQUEST['t'])){
            return;
        }
        if (empty($_SESSION['seen_media']) ||
            !is_array($_SESSION['seen_media'])) {
            $_SESSION['seen_media'] = [];
        }
        if (in_array($hash_id, $_SESSION['seen_media'])) {
            if (end($_SESSION['seen_media']) == $hash_id) {
                // this item was already the most recently viewed
                return;
            }
            $_SESSION['seen_media'] = array_diff($_SESSION['seen_media'],
                [$hash_id]);
        }
        array_push($_SESSION['seen_media'], $hash_id);
        $keep_deleting = true;
        while ($keep_deleting) {
            if (strlen(serialize($_SESSION)) >= C\MAX_USER_SESSION_SIZE) {
                array_shift($_SESSION['seen_media']);
            } else {
                $keep_deleting = false;
            }
            if (count($_SESSION['seen_media']) <= 0) {
                $keep_deleting = false;
            }
        }
        $this->model("user")->setUserSession($_SESSION['USER_ID'], $_SESSION);
    }
}
