/ src / controllers / components / SystemComponent.php
<?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\components;

use seekquarry\yioop as B;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\mail as ML;
use seekquarry\yioop\models\datasources as D;
use seekquarry\yioop\library\FetchUrl;
use seekquarry\yioop\library\UrlParser;

/**
 * This component is used to handle activities related to the configuration
 * of a Yioop installation, translations of text ging in the installation,
 * as well as control of specifying what machines make up the installation
 * and which processes they run.
 *
 * @author Chris Pollett
 */
class SystemComponent extends Component
{
    /**
     * Appearance profile fields a custom-route domain may override
     * with its own value. A domain's saved overrides for these
     * fields are applied at render time over the default Appearance
     * settings; the rest of the profile is always site-wide. The themes
     * themselves belong to the site and are shared by every domain, so
     * what a domain overrides here is only AUXILIARY_CSS_NAME, which of
     * them it wears.
     */
    const APPEARANCE_DOMAIN_FIELDS = ['BACKGROUND_COLOR',
        'FOREGROUND_COLOR', 'SIDEBAR_COLOR', 'TOPBAR_COLOR', 'SITE_NAME',
        'SITE_MOTTO',
        'BACKGROUND_IMAGE', 'LOGO_SMALL', 'LOGO_MEDIUM',
        'LOGO_LARGE', 'FAVICON', 'AUXILIARY_CSS_NAME'];
    /**
     * Column names of the per-role group limits, in the order they appear on
     * the User, Roles, Groups screen. Each names a cap stored in ROLE_LIMITS,
     * with -1 meaning unlimited.
     */
    const ROLE_LIMIT_COLUMNS = ['MAX_GROUPS_OWNED', 'MAX_GROUP_MEMBERS',
        'MAX_GROUP_WIKI_PAGES', 'MAX_PAGE_RESOURCE_MEMORY',
        'MAX_GROUP_THREADS', 'MAX_THREAD_RESOURCES', 'MAX_THREAD_POSTS'];
    /**
     * Handles admin request related to the managing the machines which perform
     * crawls
     *
     * With this activity an admin can add/delete machines to manage. For each
     * managed machine, the admin can stop and start fetchers/queue_servers
     * as well as look at their log files
     *
     * @return array $data MACHINES, their MACHINE_NAMES, data for
     *     FETCHER_NUMBERS drop-down
     */
    public function manageMachines()
    {
        $parent = $this->parent;
        $machine_model = $parent->model("machine");
        $profile_model = $parent->model("profile");
        $data = [];
        $data["ELEMENT"] = "managemachines";
        $possible_arguments = ["addmachine",  "deletemachine", "disablejob",
            "enablejob", "log", "mediajobs", "update", "updatemode"];
        $data['SCRIPT'] = (empty($_REQUEST['arg'])|| !in_array($_REQUEST['arg'],
            ["disablejob", "enablejob", "mediajobs", "updatemode"])) ?
            "doUpdate();" : "";
        $data["leftorright"]=(L\getLocaleDirection() == 'ltr') ? "right":
            "left";
        $data['MACHINE_NAMES'] = [];
        $data['CHANNELS'] = range(0, C\MAX_CHANNELS -1);
        $data['CHANNELS'][-1 ] = tl('system_component_is_replica');
        ksort($data['CHANNELS']);
        $data['FETCHER_NUMBERS'] = [
            0 => 0,
            1 => 1,
            2 => 2,
            3 => 3,
            4 => 4,
            5 => 5,
            6 => 6,
            7 => 7,
            8 => 8,
            16 => 16
        ];
        $machine_names = $machine_model->getQueueServerNames();
        $data['PARENT_MACHINES'] = array_combine($machine_names,
            $machine_names);
        $data['PARENT'] = $machine_names[0] ?? "";
        $tmp = tl('system_component_select_machine');
        if (isset($_REQUEST['channel']) && $_REQUEST['channel'] == -1) {
            $_REQUEST['num_fetchers'] = 0;
        } else {
            $_REQUEST['parent'] = "";
        }
        $request_fields = [
            "name" => "string",
            "url" => "web-url",
            "channel" => array_keys($data['CHANNELS']),
            "num_fetchers" => array_keys($data['FETCHER_NUMBERS']),
            "parent" => "string"
        ];
        $r = [];
        $allset = true;
        foreach ($request_fields as $field => $type) {
            if (isset($_REQUEST[$field])) {
                $r[$field] = $parent->clean($_REQUEST[$field], $type);
                if ($type == "string") {
                    $r[$field] = trim($r[$field]);
                    if ($r[$field] == "" && $field != "parent") {
                        $allset = false;
                    }
                }
                if ($field == "url") {
                    if (isset($r[$field][strlen($r[$field]) - 1]) &&
                        $r[$field][strlen($r[$field])-1] != "/") {
                        $r[$field] .= "/";
                    }
                    $r[$field] = UrlParser::canonicalLink($r[$field],
                        C\p('NAME_SERVER'));
                    if (!$r[$field]) {
                        $allset = false;
                    }
                }
            } else {
                $allset = false;
            }
        }
        if (isset($r["num_fetchers"])) {
            $data['FETCHER_NUMBER'] = $r["num_fetchers"];
        } else {
            $data['FETCHER_NUMBER'] = 0;
        }
        if (isset($r["channel"])) {
            $data['CHANNEL'] = $r["channel"];
        } else {
            $data['CHANNEL'] = 0;
        }
        $machine_exists = (isset($r["name"]) &&
            $machine_model->checkMachineExists("NAME", $r["name"]) ) ||
            (isset($r["url"])  &&  isset($r["channel"]) &&
            $machine_model->checkMachineExists(["URL", "CHANNEL"],
            [$r["url"], $r["channel"]]));
        if (isset($_REQUEST['arg']) &&
            in_array($_REQUEST['arg'], $possible_arguments)) {
            switch ($_REQUEST['arg']) {
                case "addmachine":
                    if ($allset == true && !$machine_exists) {
                        $machine_model->addMachine(
                            $r["name"], $r["url"], $r["channel"],
                            $r["num_fetchers"], $r["parent"]);
                        return $parent->redirectWithMessage(
                            tl('system_component_machine_added'),
                            ["start_row", "end_row", "num_show"]);
                    } else if ($allset && $machine_exists ) {
                        return $parent->redirectWithMessage(
                            tl('system_component_machine_exists'),
                            ["start_row", "end_row", "num_show"]);
                    } else {
                        return $parent->redirectWithMessage(
                            tl('system_component_machine_incomplete'),
                            ["start_row", "end_row", "num_show"]);
                    }
                    break;
                case "deletemachine":
                    if (!$machine_exists) {
                        return $parent->redirectWithMessage(
                            tl('system_component_machine_doesnt_exists'),
                            ["start_row", "end_row", "num_show"]);
                    } else {
                        $machines = $machine_model->getRows(0, 1,
                            $total_rows, [
                                ["name", "=", $r["name"], ""]]);
                        $service_in_use = false;
                        foreach ($machines as $machine) {
                            if ($machine['NAME'] == $r["name"]) {
                                if (isset($machine['STATUSES']) &&
                                    is_array($machine['STATUSES'])) {
                                    unset($machine['STATUSES']['index']);
                                    if ($machine['STATUSES'] != []) {
                                        $service_in_use = true;
                                    }
                                    break;
                                } else {
                                    break;
                                }
                            }
                        }
                        if ($service_in_use) {
                            return $parent->redirectWithMessage(
                                tl('system_component_stop_service_first'),
                                ["start_row", "end_row", "num_show"]);
                            break;
                        }
                        $machine_model->deleteMachine($r["name"]);
                        return $parent->redirectWithMessage(
                            tl('system_component_machine_deleted'),
                            ["start_row", "end_row", "num_show"]);
                    }
                    break;
                case "disablejob":
                    $data["ELEMENT"] = "mediajobs";
                    $_REQUEST['arg'] = 'mediajobs';
                    $jobs_list = $machine_model->getJobsList();
                    $job_name = $_REQUEST["job_name"] ?? "";
                    if (!isset($jobs_list[$job_name])) {
                        return $parent->redirectWithMessage(
                            tl('system_component_job_doesnt_exist'),
                            ["arg", "start_row", "end_row", "num_show"]);
                    }
                    $machine_model->setJobStatus($job_name, false);
                    return $parent->redirectWithMessage(
                        tl('system_component_job_disabled'),
                        ["arg", "start_row", "end_row", "num_show"]);
                    break;
                case "enablejob":
                    $data["ELEMENT"] = "mediajobs";
                    $_REQUEST['arg'] = 'mediajobs';
                    $jobs_list = $machine_model->getJobsList();
                    $job_name = $_REQUEST["job_name"] ?? "";
                    if (!isset($jobs_list[$job_name])) {
                        return $parent->redirectWithMessage(
                            tl('system_component_job_doesnt_exist'),
                            ["arg", "start_row", "end_row", "num_show"]);
                    }
                    $machine_model->setJobStatus($job_name, true);
                    return $parent->redirectWithMessage(
                        tl('system_component_job_enabled'),
                        ["arg", "start_row", "end_row", "num_show"]);
                    break;
                case "log":
                    $log_fields = ["id" => "int", "name"=>"string",
                        "channel" => "int", "f" => "string",
                        "type"=>"string"
                    ];
                    foreach ($log_fields as $field => $type) {
                        if (isset($_REQUEST[$field])) {
                            $r[$field] =
                                $parent->clean($_REQUEST[$field], $type);
                        }
                    }
                    $filter = $r['f'] ?? "";
                    $r['channel'] = $r['channel'] ?? 0;
                    if (isset($_REQUEST["time"])) {
                        $data["time"] =
                            $parent->clean($_REQUEST["time"], "int") + 30;
                    } else {
                        $data["time"] = 30;
                    }
                    if (isset($_REQUEST["NO_REFRESH"])) {
                        $data["NO_REFRESH"] = $parent->clean(
                            $_REQUEST["NO_REFRESH"], "bool");
                    } else {
                        $data["NO_REFRESH"] = false;
                    }
                    $data["ELEMENT"] = "machinelog";
                    $data['filter'] = $filter;
                    $data["REFRESH_LOG"] = "&time=". $data["time"];
                    $data["LOG_TYPE"] = "";
                    if (isset($r['id']) && isset($r['name']) &&
                        isset($r['type'])) {
                        $data["LOG_FILE_DATA"] = $machine_model->getLog(
                            $r["name"], $r["id"], $r["type"], $filter);
                        $data["LOG_TYPE"] = $r['name'] . " ". $r["type"];
                        if ($r["type"] == "fetcher") {
                            $data["LOG_TYPE"] .= " ". $r['id'];
                        }
                        $data['LOG_NAME'] = $r['name'];
                        $data['LOG_ID'] = $r['id'];
                        $data['LOG_CHANNEL'] = $r['LOG_CHANNEL'] ?? 0;
                        $data['LOG_MACHINE_TYPE'] = $r["type"];
                        $data["REFRESH_LOG"] .= "&arg=log&name=".$r['name'].
                            "&id=" . $r['id'] . "&type=" . $r["type"] .
                            "&channel=" . $r['channel'];
                    }
                    if ($data["time"] >= C\ONE_HOUR/3) {
                        $data["REFRESH_LOG"] = "";
                    }
                    if (empty($data["LOG_FILE_DATA"])){
                        $data["LOG_FILE_DATA"] =
                            tl('system_component_no_machine_log');
                    }
                    $lines = array_reverse(
                        explode("\n", $data["LOG_FILE_DATA"]));
                    $data["LOG_FILE_DATA"] = implode("\n", $lines);
                    break;
                case "mediajobs":
                    $data["ELEMENT"] = "mediajobs";
                    $profile =  $profile_model->getProfile(C\WORK_DIRECTORY);
                    $data['MEDIA_MODE'] = $profile['MEDIA_MODE'] ??
                        "name_server";
                    $data['JOBS_LIST'] = $machine_model->getJobsList();
                    break;
                case "update":
                    if (isset($_REQUEST["id"])) {
                        $r["id"] =
                            $parent->clean($_REQUEST["id"], "int");
                    } else {
                        $r["id"] = 0;
                    }
                    $available_actions = ["start", "stop"];
                    $available_types = ["QueueServer", "MediaUpdater",
                        "Mirror", "Fetcher", "MailServer"];
                    if (isset($r["name"]) && isset($_REQUEST["action"]) &&
                        in_array($_REQUEST["action"], $available_actions)
                        && isset($_REQUEST["type"]) && in_array(
                        $_REQUEST["type"], $available_types)) {
                        $action = $_REQUEST["action"];
                        $machine_model->update($r["name"],
                            $_REQUEST["action"], $r["id"], $_REQUEST["type"]);
                        return $parent->redirectWithMessage(
                            tl('system_component_machine_servers_updated'),
                            ["start_row", "end_row", "num_show"]);
                    } else {
                        return $parent->redirectWithMessage(
                            tl('system_component_machine_no_action'),
                            ["start_row", "end_row", "num_show"]);
                    }
                    break;
                case "updatemode":
                    $data["ELEMENT"] = "mediajobs";
                    $profile =  $profile_model->getProfile(C\WORK_DIRECTORY);
                    if (isset($profile['MEDIA_MODE']) &&
                        $profile['MEDIA_MODE'] == "name_server") {
                        $profile['MEDIA_MODE'] = "distributed";
                    } else {
                        $profile['MEDIA_MODE'] = "name_server";
                    }
                    $profile_model->updateProfile(C\WORK_DIRECTORY, [],
                        $profile);
                    $_REQUEST['arg'] = 'mediajobs';
                    return $parent->redirectWithMessage(
                        tl('system_component_updatemode_toggled'),
                        ["arg", "start_row", "end_row", "num_show"]);
                    break;
            }
        }
        $data['INCLUDE_SCRIPTS'][] = 'help';
        $this->initCrawlBadges($_SESSION["USER_ID"], $data);
        $parent->pagingLogic($data, $machine_model, "MACHINE",
            C\DEFAULT_ADMIN_PAGING_NUM);
        return $data;
    }
    /**
     * Handles admin request related to the manage locale activity
     *
     * The manage locale activity allows a user to add/delete locales, view
     * statistics about a locale as well as edit the string for that locale
     *
     * @param array $modifiers that affect acces to this activity
     * @return array $data info about current locales, statistics for each
     *     locale as well as potentially the currently set string of a
     *     locale and any messages about the success or failure of a
     *     sub activity.
     */
    public function manageLocales($modifiers)
    {
        $parent = $this->parent;
        $locale_model = $parent->model("locale");
        if (in_array("all", $modifiers)) {
            $possible_arguments = ["addlocale", "deletelocale", "editlocale",
                "editstrings", "search"];
        } else if (in_array("edit_strings_only", $modifiers)) {
            $possible_arguments = ["editstrings", "search"];
        }
        $search_array = [];
        $data['SCRIPT'] = "";
        $data["ELEMENT"] = "managelocales";
        $data['CURRENT_LOCALE'] = ["localename" => "",
            'localetag' => "", 'writingmode' => '-1', 'active' => 1];
        $data['WRITING_MODES'] = [
            -1 => tl('system_component_select_mode'),
            "lr-tb" => "lr-tb",
            "rl-tb" => "rl-tb",
            "tb-rl" => "tb-rl",
            "tb-lr" => "tb-lr"
        ];
        $data['FORM_TYPE'] = "addlocale";
        $paging = true;
        if (isset($_REQUEST['arg']) &&
            in_array($_REQUEST['arg'], $possible_arguments)) {
            $clean_fields = ['localename', 'localetag', 'writingmode',
                'selectlocale', 'active'];
            $edit_preserve_fields = ["selectlocale", "arg",
                "start_row", "end_row", "num_show", "previous_activity",
                "filter", "show", "context"];
            $preserve_fields = ["start_row", "end_row", "num_show",
                 "context"];
            $incomplete = false;
            $required = ['localename', 'localetag'];
            foreach ($clean_fields as $field) {
                $$field = "";
                if ($field == 'active') {
                    $active = 0;
                }
                if (isset($_REQUEST[$field])) {
                    $tmp = trim($parent->clean($_REQUEST[$field], "string"));
                    if ($field == "writingmode" && ($tmp == -1 ||
                        !isset($data['WRITING_MODES'][$tmp]))) {
                        $tmp = "lr-tb";
                    }
                    if ($tmp == "" && in_array($field, $required)) {
                        $incomplete = true;
                    }
                    $$field = $tmp;
                } else if (in_array($field, $required)) {
                    $incomplete = true;
                }
            }
            switch ($_REQUEST['arg']) {
                case "addlocale":
                    if ($incomplete && isset($_REQUEST['update'])) {
                        return $parent->redirectWithMessage(
                            tl('system_component_locale_missing_info'),
                            $preserve_fields);
                    } else if (isset($_REQUEST['update'])) {
                        $locale_model->addLocale(
                            $localename, $localetag, $writingmode, $active);
                        $locale_model->extractMergeLocales();
                        return $parent->redirectWithMessage(
                            tl('system_component_locale_added'),
                            $preserve_fields);
                    }
                    break;
                case "deletelocale":
                    if (!$locale_model->checkLocaleExists($selectlocale)) {
                        return $parent->redirectWithMessage(
                            tl('system_component_localename_doesnt_exists'),
                            $preserve_fields);
                    }
                    $locale_model->deleteLocale($selectlocale);
                    return $parent->redirectWithMessage(
                        tl('system_component_localename_deleted'),
                        $preserve_fields);
                    break;
                case "editlocale":
                    if (!$locale_model->checkLocaleExists($selectlocale)) {
                        return $parent->redirectWithMessage(
                            tl('system_component_localename_doesnt_exists'),
                            $preserve_fields);
                    }
                    if (!empty($_REQUEST['context']) &&
                        $_REQUEST['context']=='search') {
                        $data['context'] = 'search';
                    }
                    $data['FORM_TYPE'] = "editlocale";
                    $info = $locale_model->getLocaleInfo($selectlocale);
                    $change = false;
                    if (isset($localetag) && $localetag != "") {
                        $info["LOCALE_TAG"] = $localetag;
                        $change = true;
                    }
                    if (isset($writingmode) && $writingmode != "") {
                        $info["WRITING_MODE"] = $writingmode;
                        $change = true;
                    }
                    if (isset($_REQUEST['update']) &&
                        $active != $info['ACTIVE']) {
                        $info['ACTIVE'] =  $active;
                        $change = true;
                    }
                    $data['CURRENT_LOCALE']['active'] = $info['ACTIVE'];
                    $data['CURRENT_LOCALE']['localename'] =
                        $info["LOCALE_NAME"];
                    $data['CURRENT_LOCALE']['localetag'] =
                        $selectlocale;
                    $data['CURRENT_LOCALE']['writingmode'] =
                        $info["WRITING_MODE"];
                    $data['SCRIPT'] .= "elt('focus-button').focus();";
                    if ($change) {
                        $locale_model->updateLocaleInfo($info);
                        return $parent->redirectWithMessage(
                            tl('system_component_locale_updated'),
                            $edit_preserve_fields);
                    }
                    break;
                case "editstrings":
                    if (!isset($selectlocale)) {
                        break;
                    }
                    if (!empty($_REQUEST['context']) &&
                        $_REQUEST['context']=='search') {
                        $data['context'] = 'search';
                    }
                    $paging = false;
                    $data["leftorright"] =
                        (L\getLocaleDirection() == 'ltr') ? "right": "left";
                    $data['PREVIOUS_ACTIVITY'] = "manageLocales";
                    if (isset($_REQUEST['previous_activity']) &&
                        in_array($_REQUEST['previous_activity'], [
                        "security", "searchSources"])) {
                            $data['PREVIOUS_ACTIVITY'] =
                                $_REQUEST['previous_activity'];
                            if ($_REQUEST['previous_activity'] ==
                                'searchSources') {
                                $data['PREVIOUS_ACTIVITY'] .=
                                    "&arg=showsubsearch";
                            }
                    }
                    $data["ELEMENT"] = "editlocales";
                    $data['CURRENT_LOCALE_NAME'] =
                        $locale_model->getLocaleName($selectlocale);
                    $data['CURRENT_LOCALE_TAG'] = $selectlocale;
                    if (isset($_REQUEST['STRINGS'])) {
                        $safe_strings = [];
                        foreach ($_REQUEST['STRINGS'] as $key => $value) {
                            $clean_key = $parent->clean($key, "string" );
                            $clean_value = $parent->clean($value, "string");
                            $safe_strings[$clean_key] = $clean_value;
                        }
                        $locale_model->updateStringData(
                            $selectlocale, $safe_strings);
                        return $parent->redirectWithMessage(
                            tl('system_component_localestrings_updated'),
                            $edit_preserve_fields);
                    } else {
                        $locale_model->extractMergeLocales();
                    }
                    $data['STRINGS'] =
                        $locale_model->getStringData($selectlocale);
                    $data['DEFAULT_STRINGS'] =
                        $locale_model->getStringData(C\p('DEFAULT_LOCALE'));
                    $data['show'] = "all";
                    $data["show_strings"] =
                        [   "all" => tl('system_component_all_strings'),
                            "missing" => tl('system_component_missing_strings')
                        ];
                    if (isset($_REQUEST['show']) &&
                        $_REQUEST['show'] == "missing") {
                        $data["show"]= "missing";
                        foreach ($data['STRINGS'] as
                            $string_id => $translation) {
                            if ($translation != "") {
                                unset($data['STRINGS'][$string_id]);
                                unset($data['DEFAULT_STRINGS'][$string_id]);
                            }
                        }
                    }
                    $data["filter"] = "";
                    if (isset($_REQUEST['filter']) && $_REQUEST['filter']) {
                        $filter = $parent->clean($_REQUEST['filter'], "string");
                        $data["filter"] = $filter;
                        foreach ($data['STRINGS'] as
                            $string_id => $translation) {
                            if (mb_stripos($string_id, $filter) === false &&
                                mb_stripos($translation ?? "", $filter) ===
                                false) {
                                unset($data['STRINGS'][$string_id]);
                                unset($data['DEFAULT_STRINGS'][$string_id]);
                            }
                        }
                    }
                    $data['NUM_STRINGS_SHOW'] = 100;
                    $data['TOTAL_STRINGS'] = count($data['STRINGS']);
                    $data['LIMIT'] = (isset($_REQUEST['limit'])) ?
                        min($parent->clean($_REQUEST['limit'], 'int'),
                        $data['TOTAL_STRINGS']) : 0;
                    $data['STRINGS'] = array_slice($data['STRINGS'],
                        $data['LIMIT'], $data['NUM_STRINGS_SHOW']);
                    break;
                case "search":
                    $search_array = $parent->tableSearchRequestHandler($data,
                        "manageLocales", ['ALL_FIELDS' =>
                        ['name', 'tag', 'mode', 'active'],
                        'EQUAL_COMPARISON_TYPES' => ['active']]);
                    if (empty($_SESSION['LAST_SEARCH']["manageLocales"]) ||
                        isset($_REQUEST['name'])) {
                        $_SESSION['LAST_SEARCH']["manageLocales"] =
                            $_SESSION['SEARCH']["manageLocales"];
                        unset($_SESSION['SEARCH']["manageLocales"]);
                    } else {
                        $default_search = true;
                    }
                    break;
            }
        }
        if ($search_array == [] || !empty($default_search)) {
            if (!empty($_SESSION['LAST_SEARCH']["manageLocales"])) {
                if (!empty($_REQUEST['arg']) && $_REQUEST['arg'] == 'search') {
                    $search_array =
                        $parent->restoreLastSearchFromSession($data,
                        "manageLocales");
                } else if (!empty($_REQUEST['context'])) {
                    $search_array =
                        $_SESSION['LAST_SEARCH']["manageLocales"][
                        'SEARCH_ARRAY'];
                    $data['PAGING'] =
                        $_SESSION['LAST_SEARCH']["manageLocales"]['PAGING'];
                }
            }
            if ($search_array == []) {
                $search_array = [["tag", "", "", "ASC"]];;
            }
        }
        if ($paging) {
            $parent->pagingLogic($data, $locale_model,
                "LOCALES", C\DEFAULT_ADMIN_PAGING_NUM, $search_array);
        }
        if ($data['FORM_TYPE'] == 'addlocale') {
            $data['SCRIPT'] .= "setDisplay('admin-form-row', false);";
        }
        return $data;
    }
    /**
     * What modifiers of the manageLocales activity can be added in
     * manageRoles
     * @return array key value pairs modifier_name => localized description
     */
    public function manageLocalesModifiers()
    {
        return [
                "edit_strings_only" => tl("system_component_edit_strings_only")
            ];
    }
    /**
     * Builds the choices for one of the git size dropdowns on the Server
     * Settings page. Each choice pairs a stored size, written as a metric
     * string like 10M where 0 means no limit, with a label naming the
     * project scale that size suits. The size currently saved is added as
     * its own choice when it is not one of the standard sizes so it is not
     * lost.
     *
     * @param array $sizes metric size strings from smallest to largest, the
     *      first being "0" for the no limit choice
     * @param string $current the size currently saved for this setting
     * @return array map from stored size to the label shown in the dropdown
     */
    private function gitSizeChoices($sizes, $current)
    {
        $names = [tl("system_component_size_unlimited"),
            tl("system_component_size_micro"),
            tl("system_component_size_small"),
            tl("system_component_size_medium"),
            tl("system_component_size_large"),
            tl("system_component_size_xlarge")];
        $choices = [];
        foreach ($sizes as $index => $size) {
            $name = $names[$index] ?? $size;
            $choices[$size] = ($size === "0") ? $name :
                $name . " (" . $size . ")";
        }
        if (!isset($choices[$current])) {
            $choices[$current] = $current;
        }
        return $choices;
    }
    /**
     * Builds the value-to-label option map for one role group limit select,
     * offering Unlimited plus the four size tiers. The stored value is the
     * number itself, with -1 meaning unlimited.
     *
     * @param array $values the four tier values, smallest to largest
     * @param array $displays human-readable text for each tier, matching
     *      $values by position (for example a byte count shown as "25 MB")
     * @return array option value => shown label, ready for the options helper
     */
    private function roleLimitChoices($values, $displays)
    {
        $names = [tl("system_component_size_micro"),
            tl("system_component_size_small"),
            tl("system_component_size_medium"),
            tl("system_component_size_large"),
            tl("system_component_size_xlarge")];
        $choices = ["-1" => tl("system_component_size_unlimited")];
        foreach ($values as $index => $value) {
            $name = $names[$index] ?? strval($value);
            $choices[strval($value)] = $name . " (" . $displays[$index] . ")";
        }
        return $choices;
    }
    /**
     * Builds the option map for the charge-frequency drop-down: how often a
     * role's cost is charged. When the TEST_FORM_VALUES config constant is
     * defined and true, a short every-minute choice is added so time-based
     * charging can be exercised without waiting for a real period to pass.
     *
     * @return array frequency key => shown label
     */
    private function chargeFrequencyChoices()
    {
        $choices = [
            'never' => tl('userrolesgroups_element_frequency_never'),
            'once' => tl('userrolesgroups_element_frequency_once'),
            'monthly' => tl('userrolesgroups_element_frequency_monthly'),
            'semi_annual' =>
                tl('userrolesgroups_element_frequency_semi_annual'),
            'yearly' => tl('userrolesgroups_element_frequency_yearly')];
        if (C\nsdefined('TEST_FORM_VALUES') && C\TEST_FORM_VALUES) {
            $choices['test_minute'] =
                tl('userrolesgroups_element_frequency_test_minute');
        }
        return $choices;
    }
    /**
     * Handles admin panel requests for mail, database, tor, proxy server
     * settings
     *
     * @return array $data data for the view concerning the current settings
     *     so they can be displayed
     */
    public function servers()
    {
        $parent = $this->parent;
        $profile_model = $parent->model("profile");
        $user_id = $_SESSION['USER_ID'];
        $data = [];
        $profile = [];
        $arg = "";
        if (isset($_REQUEST['arg'])) {
            $arg = $_REQUEST['arg'];
        }
        $data['SCRIPT'] = "";
        $data["ELEMENT"] = "servers";
        switch ($arg) {
            case "clearcache":
                L\IndexManager::clearCache();
                $phrase_model = $parent->model("phrase");
                if (!empty($phrase_model::$cache)) {
                    $phrase_model::$cache->clear();
                }
                $crawl_model = $parent->model("crawl");
                $crawl_model->clearCrawlCaches();
                return $parent->redirectWithMessage(
                    tl('system_component_cache_cleared'));
            break;
            case "restart":
                return $parent->redirectWithMessage(
                    tl('system_component_server_restarted'), [], true);
            break;
            case "update":
                $parent->updateProfileFields($data, $profile,
                    ['MAIL_EXTERNAL',
                    'MAIL_LOG_ENABLED', 'MAIL_TEST_MODE',
                    'MAIL_USE_STARTTLS',
                    'MAIL_DMARC_ENFORCE', 'ACME_ON',
                    'USE_FILECACHE', 'USE_PROXY']);
                $old_profile =
                    $profile_model->getProfile(C\WORK_DIRECTORY);
                if (!empty($data['ACME_ON']) &&
                    isset($profile['SECURE_DOMAINS']) &&
                    trim((string)$profile['SECURE_DOMAINS']) !==
                    trim((string)($old_profile['SECURE_DOMAINS'] ??
                    ''))) {
                    /* the certificate must cover the new domain
                       list; clear the renewal throttle so the renew
                       job checks on its next tick (its renewal
                       decision sees the certificate no longer
                       matches the configured domains) rather than
                       after the current wait */
                    L\media_jobs\AcmeRenewJob::requestImmediateCheck();
                }
                $db_problem = false;
                if ((isset($profile['DBMS']) &&
                    $profile['DBMS'] != $old_profile['DBMS']) ||
                    (isset($profile['DB_NAME']) &&
                    $profile['DB_NAME'] != $old_profile['DB_NAME']) ||
                    (isset($profile['DB_HOST']) &&
                    $profile['DB_HOST'] != $old_profile['DB_HOST'])) {
                    if (!$profile_model->migrateDatabaseIfNecessary(
                        $profile)) {
                        $db_problem = true;
                    }
                } else if ((isset($profile['DB_USER']) &&
                    $profile['DB_USER'] != $old_profile['DB_USER']) ||
                    (isset($profile['DB_PASSWORD']) &&
                    $profile['DB_PASSWORD'] != $old_profile['DB_PASSWORD'])) {
                    if ($profile_model->testDatabaseManager(
                        $profile) !== true) {
                        $db_problem = true;
                    }
                }
                if ($db_problem) {
                    return $parent->redirectWithMessage(
                        tl('system_component_configure_no_change_db'));
                }
                /* Per-domain landing routes are managed by the
                   updateroute action below, which writes the
                   database directly; the profile save only retires
                   routes whose domain was removed from the saved
                   list. */
                $group_model = $parent->model("group");
                $secure_csv = trim((string)
                    ($profile['SECURE_DOMAINS'] ?? ''));
                $secure_entries = ($secure_csv === '') ? [] :
                    array_filter(array_map('trim',
                    explode(',', $secure_csv)));
                $secure_entries = array_map('strtolower',
                    $secure_entries);
                $old_routes = $group_model->getDomainRoutes();
                foreach ($old_routes as $route_domain => $route_group) {
                    if (!in_array($route_domain, $secure_entries)) {
                        $group_model->deleteDomainRoute($route_domain);
                    }
                }
                if ($profile_model->updateProfile(
                    C\WORK_DIRECTORY, $profile, $old_profile)) {
                    return $parent->redirectWithMessage(
                        tl('system_component_configure_profile_change'),
                        false, false);
                } else {
                    return $parent->redirectWithMessage(
                        tl('system_component_configure_no_change_profile'));
                }
                break;
            case "updateroute":
                /* Sets or clears the landing route of one saved
                   secure domain. This manipulates only the
                   DOMAIN_ROUTE table (no profile rewrite and no
                   restart involved), so it has its own action and
                   status message rather than riding the profile
                   save. An empty group clears the route, returning
                   the domain to the site default landing. */
                $group_model = $parent->model("group");
                $route_domain = "";
                if (isset($_REQUEST['route_domain'])) {
                    $route_domain = strtolower(trim($parent->clean(
                        $_REQUEST['route_domain'], 'string')));
                }
                $route_group = "";
                if (isset($_REQUEST['route_group'])) {
                    $route_group = trim($parent->clean(
                        $_REQUEST['route_group'], 'string'));
                }
                $profile = $profile_model->getProfile(
                    C\WORK_DIRECTORY);
                $secure_csv = trim((string)
                    ($profile['SECURE_DOMAINS'] ?? ''));
                $secure_entries = ($secure_csv === '') ? [] :
                    array_filter(array_map('trim',
                    explode(',', $secure_csv)));
                $secure_entries = array_map('strtolower',
                    $secure_entries);
                if ($route_domain === '' ||
                    !in_array($route_domain, $secure_entries)) {
                    return $parent->redirectWithMessage(
                        tl('system_component_route_unknown_domain'));
                }
                if ($route_group === '') {
                    $group_model->deleteDomainRoute($route_domain);
                    return $parent->redirectWithMessage(
                        tl('system_component_route_updated'));
                }
                $route_group_id = $group_model->getGroupId(
                    $route_group);
                if ($route_group_id <= 0) {
                    return $parent->redirectWithMessage(
                        tl('system_component_route_unknown_group'));
                }
                $group_model->setDomainRoute($route_domain,
                    $route_group_id);
                $register_type = $group_model->getRegisterType(
                    $route_group_id);
                if ($route_group_id != C\PUBLIC_GROUP_ID &&
                    !in_array($register_type,
                    [C\PUBLIC_BROWSE_REQUEST_JOIN, C\PUBLIC_JOIN])) {
                    /* the route is stored, but anonymous visitors
                       can only read groups whose Register setting is
                       publicly browsable; warn so a signed-out test
                       landing on the login page is not a surprise */
                    return $parent->redirectWithMessage(
                        tl('system_component_route_not_public'));
                }
                return $parent->redirectWithMessage(
                    tl('system_component_route_updated'));
                break;
            case "robots":
                /* Edits, previews, or saves one secure domain's
                   robots.txt choices and humans.txt text. The choices
                   and text live in that domain's appearance overrides;
                   the served files are built from them by the robots.txt
                   and humans.txt routes. Preview streams back the
                   robots.txt the current form would produce so it can
                   open in a new tab. */
                if (isset($_REQUEST['cancel'])) {
                    return $parent->redirectWithMessage("");
                }
                $group_model = $parent->model("group");
                $domain = "";
                if (isset($_REQUEST['domain'])) {
                    $domain = strtolower(trim($parent->clean(
                        $_REQUEST['domain'], 'string')));
                }
                $profile = $profile_model->getProfile(C\WORK_DIRECTORY);
                $secure_csv = trim((string)
                    ($profile['SECURE_DOMAINS'] ?? ''));
                $secure_entries = ($secure_csv === '') ? [] :
                    array_filter(array_map('trim',
                    explode(',', $secure_csv)));
                $secure_entries = array_map('strtolower',
                    $secure_entries);
                if ($domain === '' ||
                    !in_array($domain, $secure_entries)) {
                    return $parent->redirectWithMessage(
                        tl('system_component_route_unknown_domain'));
                }
                if (isset($_REQUEST['preview']) ||
                    isset($_REQUEST['save'])) {
                    $robots_config = [
                        'disallow_all' =>
                            !empty($_REQUEST['disallow_all']),
                        'crawl_delay' => intval(
                            $_REQUEST['crawl_delay'] ?? 0),
                        'search' => !empty($_REQUEST['search']),
                        'feeds' => !empty($_REQUEST['feeds']),
                        'wiki' => !empty($_REQUEST['wiki']),
                        'page_source' =>
                            !empty($_REQUEST['page_source']),
                        'page_feed' => !empty($_REQUEST['page_feed']),
                        'page_list' => !empty($_REQUEST['page_list']),
                        'additional' => mb_convert_encoding(
                            $_REQUEST['additional'] ?? "", "UTF-8")];
                    if (isset($_REQUEST['preview'])) {
                        $body = L\RobotsTxtGenerator::generate(
                            $robots_config);
                        $parent->web_site->header(
                            "Content-Type: text/plain; charset=utf-8");
                        $parent->web_site->header(
                            "Content-Length: " . strlen($body));
                        echo $body;
                        \seekquarry\atto\webExit();
                    }
                    $appearance = $group_model->getDomainAppearance(
                        $domain);
                    $appearance['ROBOTS'] = $robots_config;
                    $appearance['HUMANS'] = mb_convert_encoding(
                        $_REQUEST['humans'] ?? "", "UTF-8");
                    $group_model->setDomainAppearance($domain,
                        $appearance);
                    return $parent->redirectWithMessage(
                        tl('system_component_robots_saved'));
                }
                $appearance = $group_model->getDomainAppearance($domain);
                $saved = (isset($appearance['ROBOTS']) &&
                    is_array($appearance['ROBOTS'])) ?
                    $appearance['ROBOTS'] : [];
                $data['ROBOTS'] = array_merge(
                    L\RobotsTxtGenerator::DEFAULT_CONFIG, $saved);
                $data['ROBOTS']['additional'] = $parent->clean(
                    $data['ROBOTS']['additional'] ?? "", 'string');
                $data['HUMANS'] = $parent->clean(
                    $appearance['HUMANS'] ?? "", 'string');
                $data['ROBOTS_DOMAIN'] = $domain;
                $data["ELEMENT"] = "robotssettings";
                $data['SCRIPT'] .=
                    "elt('disallow-all').onchange = function () {\n" .
                    "    setDisplay('robots-inputs', " .
                    "!elt('disallow-all').checked);\n};\n" .
                    "setDisplay('robots-inputs', " .
                    "!elt('disallow-all').checked);\n" .
                    "elt('wiki-crawlable').onchange = function () {\n" .
                    "    setDisplay('wiki-sub', " .
                    "elt('wiki-crawlable').checked);\n};\n" .
                    "setDisplay('wiki-sub', " .
                    "elt('wiki-crawlable').checked);\n";
                return $data;
            break;
        }
        $data = array_merge($data,
            $profile_model->getProfile(C\WORK_DIRECTORY));
        if ($data['DBMS'] == lcfirst($data['DBMS'])) {
            //using old name, migrate to new
            $_REQUEST['DBMS'] = ucfirst($data['DBMS']);
            $parent->updateProfileFields($data, $profile,
                ['AD_LOCATION', 'MAIL_EXTERNAL',
                'MAIL_LOG_ENABLED', 'MAIL_TEST_MODE',
                'MAIL_USE_STARTTLS',
                'MAIL_DMARC_ENFORCE',
                'SEND_MAIL_MEDIA_UPDATER', 'MAIL_BOT_HANDLED',
                'USE_FILECACHE', 'USE_PROXY']);
            $old_profile = $profile_model->getProfile(C\WORK_DIRECTORY);
            $profile_model->updateProfile(C\WORK_DIRECTORY, $profile,
                $old_profile);
        }
        $data['PROXY_SERVERS'] = str_replace(
            "|Z|","\n", $data['PROXY_SERVERS']);
        $data['DBMSS'] = [];
        $data['SCRIPT'] .= "logindbms = [];\n";
        foreach ($profile_model->getDbmsList() as $dbms) {
            $data['DBMSS'][$dbms] = $dbms;
            if ($profile_model->loginDbms($dbms)) {
                $data['SCRIPT'] .= "logindbms['$dbms'] = true;\n";
            } else {
                $data['SCRIPT'] .= "logindbms['$dbms'] = false;\n";
            }
        }
        $data['SCRIPT'] .= <<< EOD
    elt('database-system').onchange = function () {
        setDisplay('login-dbms', self.logindbms[elt('database-system').value]);
    };
    setDisplay('login-dbms', logindbms[elt('database-system').value]);
    elt('use-proxy').onchange = function () {
        setDisplay('proxy', (elt('use-proxy').checked) ? true : false);
    };
    setDisplay('proxy', (elt('use-proxy').checked) ? true : false);
EOD;
        $data['MAIL_MODES'] = [
            'disabled' =>
                tl('system_component_mail_mode_disabled'),
            'mailsite' =>
                tl('system_component_mail_mode_mailsite'),
            'external_mail' =>
                tl('system_component_mail_mode_external_mail'),
            'both' =>
                tl('system_component_mail_mode_both'),
        ];
        $data['show_mail_services_mailsite'] = (isset($data['MAIL_MODE']) &&
            in_array($data['MAIL_MODE'], ['mailsite', 'both'])) ?
            "true" : "false";
        $data['show_mail_services_external'] = (isset($data['MAIL_MODE']) &&
            in_array($data['MAIL_MODE'], ['external_mail', 'both'])) ?
            "true" : "false";
        /* Run As User and ALPN only apply when Yioop serves over
           its own WebSite HTTP server, where the secure launcher
           binds the ports and negotiates the protocol; under Apache
           or nginx those are the front server's concern, so hide
           them. Auto SSL Certs still applies in both cases (renewal
           runs as a media job regardless of front server), so it is
           always shown. IS_OWN_WEB_SERVER is defined by the WebSite
           launcher process and absent under Apache or nginx. */
        $data['show_web_server_atto'] =
            (C\nsdefined('IS_OWN_WEB_SERVER') && C\IS_OWN_WEB_SERVER) ?
            "true" : "false";
        $data['ALPN_PROTOCOL_OPTIONS'] = ['h3', 'h2', 'http/1.1'];
        /* Run As User defaults to the user the instance is running
           as, so the field is prefilled with a sensible value
           rather than blank; the operator can change it. Applied
           only when no value has been saved. */
        if (empty($data['WWW_USER'])) {
            $run_as_user = "";
            if (function_exists("posix_getuid") &&
                function_exists("posix_getpwuid")) {
                $user_info = posix_getpwuid(posix_getuid());
                $run_as_user = $user_info['name'] ?? "";
            }
            if ($run_as_user === "") {
                $run_as_user = $_SERVER['USER'] ??
                    (function_exists("get_current_user") ?
                    get_current_user() : "");
            }
            $data['WWW_USER'] = $run_as_user;
        }
        /* Secure Domains defaults to the site's own domain plus its
           conventional www and mta-sts subdomains so a new install
           does not have to know to add them by hand. The base host
           is taken from the current request -- the host this machine
           is actually answering on, which is what needs a
           certificate -- rather than NAME_SERVER, since in a
           multi-machine setup NAME_SERVER points at the coordinating
           name server, not necessarily this TLS-serving machine.
           Only suggested when nothing has been saved; the operator
           can edit the list. Subdomains are skipped for a localhost
           or bare-IP host, where www and mta-sts make no sense. Like
           Run As User, this only prefills the displayed value -- the
           stored value stays empty until saved. */
        if (empty($data['SECURE_DOMAINS'])) {
            $request_host = $_SERVER['HTTP_HOST'] ??
                ($_SERVER['SERVER_NAME'] ?? '');
            if ($request_host === '') {
                $name_server = $data['NAME_SERVER'] ??
                    (C\nsdefined('NAME_SERVER') ? C\p('NAME_SERVER') : '');
                $request_host = (string) parse_url($name_server,
                    PHP_URL_HOST);
            }
            $host = strtolower(trim($request_host));
            $colon = strrpos($host, ':');
            if ($colon !== false && strpos($host, ']') === false) {
                $host = substr($host, 0, $colon);
            }
            /* Strip brackets from an IPv6 literal so the IP check
               below catches it, and drop a leading www. or mta-sts.
               so the apex is the base regardless of which of the
               site's conventional hosts the admin connected through
               (reaching settings via mta-sts.example.com should
               still suggest example.com, not mta-sts.example.com and
               www.mta-sts.example.com). */
            $bare = $host;
            if ($bare !== '' && $bare[0] === '[') {
                $close = strpos($bare, ']');
                $bare = ($close !== false)
                    ? substr($bare, 1, $close - 1) : $bare;
            }
            if (strncmp($host, 'www.', 4) === 0) {
                $host = substr($host, 4);
            } else if (strncmp($host, 'mta-sts.', 8) === 0) {
                $host = substr($host, 8);
            }
            $suggested = [];
            if ($host !== '' && $host !== 'localhost' &&
                filter_var($bare, FILTER_VALIDATE_IP) === false) {
                $suggested[] = $host;
                if (strncmp($host, 'www.', 4) !== 0) {
                    $suggested[] = 'www.' . $host;
                }
                if (strncmp($host, 'mta-sts.', 8) !== 0) {
                    $suggested[] = 'mta-sts.' . $host;
                }
            }
            $data['SECURE_DOMAINS'] = implode(',', $suggested);
        }
        $server_context = C\nsdefined('SERVER_CONTEXT') ?
            C\SERVER_CONTEXT : [];
        $tls_available = !empty($server_context['ssl']);
        $data['MAIL_TLS_AVAILABLE'] = $tls_available ? "true" : "";
        $data['MAIL_REDIRECTS_ON'] = C\REDIRECTS_ON ? "true" : "";
        /* The secure postures need both a certificate (so the
           secure ports can bind and offer TLS) and clean URLs (so
           the MTA-STS policy file can be served at its rooted
           path). When neither secure posture is available the
           dropdown is forced to "insecure" and disabled. */
        $secure_available = $tls_available && C\REDIRECTS_ON;
        $data['MAIL_SECURITY_AVAILABLE'] =
            $secure_available ? "true" : "";
        $security_options = [
            'insecure' =>
                tl('system_component_mail_security_insecure'),
        ];
        if ($secure_available) {
            $security_options['spam'] =
                tl('system_component_mail_security_spam');
            $security_options['require'] =
                tl('system_component_mail_security_require');
        }
        $data['MAIL_SECURITY_OPTIONS'] = $security_options;
        /* Suggested DNS records for the helper panel. The DMARC
           aggregate-report address defaults to the configured mail
           sender when one is set; the policy id is the current time
           so a fresh fetch is prompted whenever settings change. */
        $current_security = C\nsdefined('MAIL_DELIVERY_SECURITY') ?
            C\p('MAIL_DELIVERY_SECURITY') : 'insecure';
        $report_address = C\nsdefined('MAIL_SENDER') ?
            (string) C\p('MAIL_SENDER') : '';
        $dkim_selector = C\nsdefined('MAIL_DKIM_SELECTOR') ?
            (string) C\MAIL_DKIM_SELECTOR : '';
        ML\DkimKey::ensureKeyPair();
        $dkim_record = ML\DkimKey::publicKeyRecord();
        $data['MAIL_DNS_RECORDS'] =
            ML\MailSiteFactory::suggestedDnsRecords(
            ML\MailSiteFactory::localDomains(), $current_security,
            time(), $report_address, $dkim_selector, $dkim_record);
        /* Banner / EHLO host name field, and a suggested reverse-DNS
           (PTR) row folded into the DNS panel: resolve the host to
           the IP its forward record points at and suggest mapping
           that IP back to the host, the pairing receivers check.
           Skipped for localhost and when the lookup yields no IP. */
        $banner_host = ML\MailSiteFactory::mailHostName();
        $data['MAIL_HOST_NAME'] = (string) C\p('MAIL_HOST_NAME');
        $data['MAIL_HOST_NAME_DEFAULT'] = $banner_host;
        $banner_ip = gethostbyname($banner_host);
        if ($banner_host !== 'localhost' &&
            $banner_ip !== $banner_host &&
            filter_var($banner_ip, FILTER_VALIDATE_IP) !== false) {
            if (!isset($data['MAIL_DNS_RECORDS'][$banner_host])) {
                $data['MAIL_DNS_RECORDS'][$banner_host] = [];
            }
            $data['MAIL_DNS_RECORDS'][$banner_host][] = [
                'host' => $banner_ip, 'type' => 'PTR',
                'value' => $banner_host];
        }
        /* domain => group name map of saved per-domain landing
           routes, used to prefill the routing panel of each secure
           domain row */
        $data['DOMAIN_ROUTES'] =
            $parent->model("group")->getDomainRoutes();
        $data['SCRIPT'] .= <<< EOD
    elt('mail-mode').onchange = function () {
        var mode = elt('mail-mode').value;
        setDisplay('mailsite-config',
            (mode == 'mailsite' || mode == 'both'));
        setDisplay('external-config',
            (mode == 'external_mail' || mode == 'both'));
        syncRegistrationInfo(false);
    };
    setDisplay('mailsite-config', {$data['show_mail_services_mailsite']});
    setDisplay('external-config', {$data['show_mail_services_external']});
    elt('mail-test-mode').onchange = function () {
        setDisplay('mail-test-mode-port-row',
            (elt('mail-test-mode').checked) ? true : false);
    };
    setDisplay('mail-test-mode-port-row',
        (elt('mail-test-mode').checked) ? true : false);
EOD;
        return $data;
    }
    /**
     * Draws the User, Roles, Groups settings screen and saves it. This is the
     * account-and-content policy split off the former combined Server
     * Settings screen: account registration, monetization, bot
     * configuration, and Git wiki page limits. A save writes only these
     * fields into the site profile (the profile save keeps every field this
     * screen does not send), grants or revokes the advertisement and credit
     * activities when the monetization type changes, and, when the site runs
     * its own mail, makes the bot sender an alias of the root account so its
     * mail lands in the root mailbox.
     *
     * @return array $data field values and display flags for the
     *      UserrolesgroupsElement view
     */
    public function userRolesGroups()
    {
        $parent = $this->parent;
        $profile_model = $parent->model("profile");
        $role_model = $parent->model("role");
        $activity_model = $parent->model("activity");
        $data = [];
        $profile = [];
        $arg = "";
        if (isset($_REQUEST['arg'])) {
            $arg = $_REQUEST['arg'];
        }
        $data['SCRIPT'] = "";
        $data["ELEMENT"] = "userrolesgroups";
        switch ($arg) {
            case "update":
                $parent->updateProfileFields($data, $profile,
                    ['AD_LOCATION', 'SEND_MAIL_MEDIA_UPDATER',
                    'MAIL_BOT_HANDLED', 'USE_MODERATION']);
                if (intval($profile['MODERATION_FLAG_THRESHOLD'] ?? 0) < 1) {
                    $profile['MODERATION_FLAG_THRESHOLD'] =
                        C\MODERATION_FLAG_THRESHOLD_DEFAULT;
                }
                $old_profile =
                    $profile_model->getProfile(C\WORK_DIRECTORY);
                if (strcmp($old_profile["MONETIZATION_TYPE"],
                        $data["MONETIZATION_TYPE"]) !== 0) {
                    $user_role_id = $role_model->getRoleId('User');
                    $admin_role_id = $role_model->getRoleId('Admin');
                    $ad_id = $activity_model->getActivityIdFromMethodName(
                        'manageAdvertisements');
                    $credit_id = $activity_model->getActivityIdFromMethodName(
                        'manageCredits');
                    if (isset($data['MONETIZATION_TYPE']) &&
                        in_array($data['MONETIZATION_TYPE'],
                        ['no_monetization', 'external_advertisements'])) {
                        if ($user_role_id) {
                            $role_model->deleteActivityRole($user_role_id,
                                $ad_id);
                            $role_model->deleteActivityRole($user_role_id,
                                $credit_id);
                        }
                        $role_model->deleteActivityRole($admin_role_id,
                            $ad_id);
                        $role_model->deleteActivityRole($admin_role_id,
                            $credit_id);
                    } else if (isset($data['MONETIZATION_TYPE']) &&
                        in_array($data['MONETIZATION_TYPE'],['group_fees'])) {
                        if ($user_role_id) {
                            $role_model->deleteActivityRole($user_role_id,
                                $ad_id);
                            $role_model->addActivityRole($user_role_id,
                                $credit_id);
                        }
                        $role_model->deleteActivityRole($admin_role_id,
                            $ad_id);
                        $role_model->addActivityRole($admin_role_id,
                            $credit_id);
                    } else {
                        if ($user_role_id) {
                            $role_model->addActivityRole($user_role_id, $ad_id);
                            $role_model->addActivityRole($user_role_id,
                                $credit_id);
                        }
                        $role_model->addActivityRole($admin_role_id, $ad_id);
                        $role_model->addActivityRole($admin_role_id,
                            $credit_id);
                    }
                }
                $mail_mode = $profile['MAIL_MODE'] ??
                    ($old_profile['MAIL_MODE'] ?? '');
                if (in_array($mail_mode, ['mailsite', 'both'])) {
                    $sender = trim((string)($profile['MAIL_SENDER']
                        ?? ''));
                    $bot_local = ML\MailSiteFactory::botLocalPart($sender);
                    $bot_domains = array_map('strtolower',
                        ML\MailSiteFactory::localDomains());
                    $bot_domain = (string)($bot_domains[0] ?? '');
                    $sender_at = strrpos($sender, '@');
                    if ($sender_at !== false && !in_array(strtolower(
                        substr($sender, $sender_at + 1)), $bot_domains)) {
                        return $parent->redirectWithMessage(
                            tl('system_component_mail_sender_domain'));
                    }
                    if ($bot_local !== '' &&
                        $parent->model("user")->getUser($bot_local)) {
                        return $parent->redirectWithMessage(
                            tl('system_component_mail_sender_in_use'));
                    }
                    if ($bot_local !== '' &&
                        $parent->model("user")->getUserByEmail(
                        ML\MailSiteFactory::botAddress($sender))) {
                        return $parent->redirectWithMessage(
                            tl('system_component_mail_sender_in_use'));
                    }
                    /* When the site runs its own mail, the bot sender
                       address is made an alias of the root account so
                       mail to the bot (replies, unsubscribes, admin
                       notices) is delivered into root's mailbox where
                       the admin can read it. */
                    if ($bot_local !== '' && $bot_domain !== '') {
                        $alias_model = $parent->model("mailAlias");
                        if (!$alias_model->aliasInUse($bot_local,
                            $bot_domain)) {
                            $alias_model->addAlias(C\ROOT_ID,
                                $bot_local, $bot_domain);
                        }
                    }
                }
                $frequency_choices = $this->chargeFrequencyChoices();
                foreach ($role_model->getRoleLimits() as $role_id =>
                    $role_row) {
                    $caps = [];
                    $changed = false;
                    foreach (self::ROLE_LIMIT_COLUMNS as $column) {
                        $field = "role_limit_" . $role_id . "_" . $column;
                        $value = isset($_REQUEST[$field]) ?
                            intval($_REQUEST[$field]) : -1;
                        $caps[$column] = $value;
                        $stored = is_null($role_row[$column]) ? -1 :
                            intval($role_row[$column]);
                        if ($value !== $stored) {
                            $changed = true;
                        }
                    }
                    $cost_field = "role_cost_" . $role_id;
                    $cost = isset($_REQUEST[$cost_field]) ?
                        max(0, intval($_REQUEST[$cost_field])) : 0;
                    $caps['ROLE_COST'] = $cost;
                    $stored_cost = is_null($role_row['ROLE_COST']) ? 0 :
                        intval($role_row['ROLE_COST']);
                    if ($cost !== $stored_cost) {
                        $changed = true;
                    }
                    $frequency_field = "role_frequency_" . $role_id;
                    $frequency = $_REQUEST[$frequency_field] ?? 'never';
                    if (!isset($frequency_choices[$frequency])) {
                        $frequency = 'never';
                    }
                    $caps['CHARGE_FREQUENCY'] = $frequency;
                    $stored_frequency = $role_row['CHARGE_FREQUENCY'] ??
                        'never';
                    if ($frequency !== $stored_frequency) {
                        $changed = true;
                    }
                    if ($changed) {
                        $role_model->setRoleLimits($role_id, $caps);
                    }
                }
                if ($profile_model->updateProfile(
                    C\WORK_DIRECTORY, $profile, $old_profile)) {
                    return $parent->redirectWithMessage(
                        tl('system_component_configure_profile_change'),
                        ["selected_role"], false);
                } else {
                    return $parent->redirectWithMessage(
                        tl('system_component_configure_no_change_profile'),
                        ["selected_role"]);
                }
                break;
        }
        $data = array_merge($data,
            $profile_model->getProfile(C\WORK_DIRECTORY));
        $data['GIT_MAX_BLOB_SIZE_OPTIONS'] = $this->gitSizeChoices(
            ["0", "1M", "10M", "50M", "100M", "500M"],
            $data['GIT_MAX_BLOB_SIZE'] ?? "0");
        $data['GIT_MAX_PUSH_SIZE_OPTIONS'] = $this->gitSizeChoices(
            ["0", "10M", "50M", "250M", "1G", "2G"],
            $data['GIT_MAX_PUSH_SIZE'] ?? "0");
        $data['ROLE_LIMITS'] = $role_model->getRoleLimits();
        $selected_role = $_REQUEST['selected_role'] ?? "";
        if (!isset($data['ROLE_LIMITS'][$selected_role])) {
            $selected_role = array_key_first($data['ROLE_LIMITS']);
        }
        $data['SELECTED_ROLE'] = $selected_role;
        $data['ROLE_LIMIT_FIELDS'] = [
            ['column' => 'MAX_GROUPS_OWNED',
                'label' => tl('userrolesgroups_element_max_groups_owned'),
                'options' => $this->roleLimitChoices([1, 3, 10, 50, 250],
                    ["1", "3", "10", "50", "250"])],
            ['column' => 'MAX_GROUP_MEMBERS',
                'label' => tl('userrolesgroups_element_max_group_members'),
                'options' => $this->roleLimitChoices(
                    [10, 50, 250, 2000, 10000],
                    ["10", "50", "250", "2000", "10000"])],
            ['column' => 'MAX_GROUP_WIKI_PAGES',
                'label' => tl('userrolesgroups_element_max_wiki_pages'),
                'options' => $this->roleLimitChoices(
                    [25, 100, 500, 5000, 25000],
                    ["25", "100", "500", "5000", "25000"])],
            ['column' => 'MAX_PAGE_RESOURCE_MEMORY',
                'label' => tl('userrolesgroups_element_max_resource_memory'),
                'options' => $this->roleLimitChoices(
                    [52428800, 262144000, 1073741824, 5368709120,
                    21474836480],
                    ["50M", "250M", "1G", "5G", "20G"])],
            ['column' => 'MAX_GROUP_THREADS',
                'label' => tl('userrolesgroups_element_max_threads'),
                'options' => $this->roleLimitChoices(
                    [50, 500, 5000, 50000, 250000],
                    ["50", "500", "5000", "50000", "250000"])],
            ['column' => 'MAX_THREAD_RESOURCES',
                'label' =>
                    tl('userrolesgroups_element_max_thread_resources'),
                'options' => $this->roleLimitChoices(
                    [5, 25, 100, 500, 2500],
                    ["5", "25", "100", "500", "2500"])],
            ['column' => 'MAX_THREAD_POSTS',
                'label' => tl('userrolesgroups_element_max_thread_posts'),
                'options' => $this->roleLimitChoices(
                    [50, 500, 5000, 50000, 250000],
                    ["50", "500", "5000", "50000", "250000"])],
        ];
        $data['CHARGE_FREQUENCIES'] = $this->chargeFrequencyChoices();
        $data['USE_MODERATION'] = $data['USE_MODERATION'] ??
            C\p('USE_MODERATION');
        if (intval($data['MODERATION_FLAG_THRESHOLD'] ?? 0) < 1) {
            $data['MODERATION_FLAG_THRESHOLD'] =
                C\MODERATION_FLAG_THRESHOLD_DEFAULT;
        }
        $data['REGISTRATION_TYPES'] = [
                'disable_registration' =>
                    tl('system_component_configure_disable_registration'),
                'no_activation' =>
                    tl('system_component_configure_no_activation'),
                'email_registration' =>
                    tl('system_component_configure_email_activation'),
                'admin_activation' =>
                    tl('system_component_configure_admin_activation'),
            ];
        $data['MONETIZATION_TYPES'] = [
            'no_monetization' =>
                tl('system_component_configure_none'),
            'external_advertisements' =>
                tl('system_component_configure_external_advertisements'),
            'group_fees' =>
                tl('system_component_configure_group_fees'),
            'keyword_advertisements' =>
                tl('system_component_configure_keyword_advertisements'),
            'fees_and_keywords' =>
                tl('system_component_configure_fees_and_keywords'),
             ];
        $data['CONFIGURE_BOTS'] = [
            'enable_bot_users' =>
                tl('system_component_enable_bot_users'),
            'disable_bot_users' =>
                tl('system_component_disable_bot_users')
             ];
        $data['show_mail_info'] = "false";
        if (isset($data['REGISTRATION_TYPE']) &&
            in_array($data['REGISTRATION_TYPE'],
            ['email_registration', 'admin_activation'])) {
            $data['show_mail_info'] = "true";
        }
        $data['mailsite_for_registration'] = (isset($data['MAIL_MODE']) &&
            in_array($data['MAIL_MODE'], ['mailsite', 'both'])) ?
            "true" : "false";
        /* Show the sender as a full address, so an empty or bare-name
           setting appears as the bot address on the first local domain
           (the default bot@<primary-domain>) rather than blank. */
        $data['MAIL_SENDER'] = ML\MailSiteFactory::botAddress(
            $data['MAIL_SENDER'] ?? '');
        $data['SCRIPT'] .= <<< EOD
    function syncRegistrationInfo(reg_changed)
    {
        var reg_type = elt('account-registration').value;
        var wants_mail = (reg_type != 'disable_registration' &&
            reg_type != 'no_activation');
        var mailsite = {$data['mailsite_for_registration']};
        var handled = elt('mailsite-handled');
        handled.disabled = !mailsite;
        if (!mailsite) {
            handled.checked = false;
        } else if (reg_changed && wants_mail) {
            handled.checked = true;
            elt('send-media-updater').checked = true;
        }
        setDisplay('registration-info', wants_mail);
        setDisplay('mail-relay-fields', !handled.checked);
    }
    elt('account-registration').onchange = function () {
        syncRegistrationInfo(true);
    };
    elt('mailsite-handled').onchange = function () {
        syncRegistrationInfo(false);
    };
    syncRegistrationInfo(false);
    elt('monetization-type').onchange = function () {
        var monetization_type = elt('monetization-type').value;
        setDisplay('ad-location-info',
            (monetization_type == 'external_advertisements'));
        setDisplay('payment-processing',
            (monetization_type == 'fees_and_keywords' ||
            monetization_type == 'group_fees' ||
            monetization_type == 'keywords_advertisements'));
    };
EOD;
        return $data;
    }
    /**
     * Responsible for the Captcha Settings and managing Captcha/Recovery
     * questions.
     *
     * @return array $data field variables for the SecurityElement view
     *      (CAPTCHA settings, recovery questions, autologout options)
     */
    public function security()
    {
        $parent = $this->parent;
        $possible_arguments = ["updatequestions", "updatetypes"];
        $not_null_fields = [
            'TIMEZONE' => 'America/Los_Angeles',
            'SESSION_NAME' => "yioopbiscuit",
            'CSRF_TOKEN' => "YIOOP_TOKEN",
            'DIFFERENTIAL_PRIVACY' => false,
            'GROUP_ANALYTICS_MODE' => true
        ];
        $data = [];
        $data['AUTOLOGOUT_TIMES'] = [
            2 * C\ONE_MINUTE => tl('system_component_two_minutes'),
            15 * C\ONE_MINUTE => tl('system_component_fifteen_minutes'),
            30 * C\ONE_MINUTE => tl('system_component_half_hour'),
            C\ONE_HOUR => tl('system_component_one_hour'),
            2 * C\ONE_HOUR => tl('system_component_two_hours'),
            4 * C\ONE_HOUR => tl('system_component_four_hours'),
            8 * C\ONE_HOUR => tl('system_component_eight_hours'),
            12 * C\ONE_HOUR => tl('system_component_twelve_hours'),
            C\ONE_DAY => tl('system_component_one_day'),
        ];
        $data['COOKIE_LIFETIMES'] = [
            -1 => tl('system_component_consent_disabled'),
            C\ONE_MONTH => tl('system_component_one_month'),
            6 * C\ONE_MONTH => tl('system_component_six_month'),
            C\ONE_YEAR => tl('system_component_one_year'),
            2 * C\ONE_YEAR => tl('system_component_two_years'),
        ];
        $data['CAN_LOCALIZE'] = $parent->model("user")->isAllowedUserActivity(
            $_SESSION['USER_ID'], "manageLocales");
        $profile_model = $parent->model("profile");
        $profile = $profile_model->getProfile(C\WORK_DIRECTORY);
        $signin_model = $parent->model("signin");
        if (isset($_REQUEST['arg']) &&
            $_REQUEST['arg'] == "downloadconflicts") {
            $lines = [tl('system_component_ldap_conflict_email_column') .
                "\t" . tl('system_component_ldap_conflict_users_column')];
            foreach ($signin_model->emailConflicts() as $conflict_email =>
                $conflict_names) {
                $lines[] = $conflict_email . "\t" .
                    implode(", ", $conflict_names);
            }
            $body = implode("\n", $lines) . "\n";
            $parent->web_site->header(
                "Content-Type: text/plain; charset=utf-8");
            $parent->web_site->header("Content-Disposition: " .
                "attachment; filename=\"ldap-email-conflicts.txt\"");
            $parent->web_site->header("Content-Length: " . strlen($body));
            echo $body;
            \seekquarry\atto\webExit();
        }
        $data['SCRIPT'] = "";
        $data["ELEMENT"] = "security";
        $data["CURRENT_LOCALE"] = L\getLocaleTag();
        $data['RECOVERY_MODES'] = [
           C\NO_RECOVERY =>
               tl('system_component_no_recovery'),
           C\EMAIL_RECOVERY =>
               tl('system_component_email_recovery'),
           C\QUESTIONS_RECOVERY =>
               tl('system_component_questions_recovery'),
            ];
        $data['AUTH_METHODS'] = [
           C\LOCAL_AUTHENTICATION =>
               tl('system_component_local_authentication'),
           C\LDAP_AUTHENTICATION =>
               tl('system_component_ldap_authentication'),
            ];
        $data['PRIVACY_MODES'] = [
           true =>
               tl('system_component_enable'),
           false =>
               tl('system_component_disable'),
            ];
        $data['GROUP_ANALYTICS_MODES'] = $data['PRIVACY_MODES'];
        $data['SEARCH_ANALYTICS_MODES'] = $data['PRIVACY_MODES'];
        if (empty($profile['AUTOLOGOUT'])) {
            $profile['AUTOLOGOUT'] = C\ONE_HOUR;
            $change = true;
        }
        if (empty($profile['COOKIE_LIFETIME'])) {
            $profile['COOKIE_LIFETIME'] = C\ONE_YEAR;
            $change = true;
        }
        if (isset($_REQUEST['arg']) &&
            in_array($_REQUEST['arg'], $possible_arguments)) {
            switch ($_REQUEST['arg']) {
                case "updatetypes":
                    $change = false;
                    /* Remember the method in force before this save so we can
                       tell an actual switch into LDAP (which must be checked
                       against the directory) from a re-save while LDAP is
                       already active (which keeps running without re-asking
                       for the root sign-in). */
                    $old_auth_method = $profile['AUTH_METHOD'] ??
                        C\p('AUTH_METHOD');
                    $mode_fields = ['AUTOLOGOUT',
                        'COOKIE_LIFETIME', 'DIFFERENTIAL_PRIVACY',
                        'GROUP_ANALYTICS_MODE', 'RECOVERY_MODE',
                        'AUTH_METHOD', 'SEARCH_ANALYTICS_MODE'];
                    foreach ($mode_fields as $mode) {
                        $modes = ($mode == 'DIFFERENTIAL_PRIVACY') ?
                            'PRIVACY_MODES': (($mode == 'AUTOLOGOUT')
                            ? "AUTOLOGOUT_TIMES" : $mode . "S");
                        if (in_array($_REQUEST[$mode],
                            array_keys($data[$modes]))) {
                            $profile[$mode] = $_REQUEST[$mode];
                            $change = true;
                        }
                    }
                    foreach ($not_null_fields as $field => $value) {
                        if (!empty($_REQUEST[$field])) {
                            $clean_value = $parent->clean($_REQUEST[$field],
                                "string");
                            $profile[$field] = $clean_value;
                            $change = true;
                        }
                        if (!isset($profile[$field]) ||
                            $profile[$field] === "") {
                            $profile[$field] = $value;
                            $change = true;
                        }
                    }
                    $profile['PASSWORD_MIN_LEN'] = max(1, min(
                        C\LONG_NAME_LEN, (int)($_REQUEST['PASSWORD_MIN_LEN']
                        ?? C\p('PASSWORD_MIN_LEN'))));
                    $password_requirements = ['PASSWORD_REQUIRE_LOWERCASE',
                        'PASSWORD_REQUIRE_UPPERCASE', 'PASSWORD_REQUIRE_DIGIT',
                        'PASSWORD_REQUIRE_SYMBOL'];
                    foreach ($password_requirements as $requirement) {
                        $profile[$requirement] =
                            !empty($_REQUEST[$requirement]);
                    }
                    foreach (['LDAP_ACCOUNT_SUFFIX', 'LDAP_BASE_DN',
                        'LDAP_CONTROLLERS'] as $ldap_field) {
                        $profile[$ldap_field] = $parent->clean(
                            $_REQUEST[$ldap_field] ?? "", "string");
                        $change = true;
                    }
                    $ldap_root_problem = "";
                    $ldap_config_problems = [];
                    if ($profile['AUTH_METHOD'] == C\LDAP_AUTHENTICATION) {
                        /* Under LDAP the directory owns passwords, so Yioop
                           performs no account recovery; pin it off whatever
                           the form sent (the dropdown is shown disabled). */
                        $profile['RECOVERY_MODE'] = C\NO_RECOVERY;
                        $change = true;
                        /* If LDAP is chosen but its settings are not yet
                           complete, store the pending value instead of LDAP
                           so that a later restart keeps using local passwords
                           rather than trying to reach a directory it cannot
                           use. The panel still shows LDAP selected and lists
                           what to fix; saving again once valid makes it
                           active. */
                        $ldap_config_problems =
                            $signin_model->ldapConfigProblems(
                            $profile['LDAP_CONTROLLERS'],
                            $profile['LDAP_ACCOUNT_SUFFIX'],
                            $profile['LDAP_BASE_DN']);
                        if (!empty($ldap_config_problems)) {
                            $profile['AUTH_METHOD'] =
                                C\LDAP_AUTHENTICATION_PENDING;
                        } else if ($old_auth_method !=
                            C\LDAP_AUTHENTICATION) {
                            /* Switching into LDAP from off or pending: confirm
                               the root account can actually sign in to the
                               directory and that the directory holds the same
                               email before going live, so the administrator is
                               not locked out. The root password is read here
                               only for this check and never stored. A re-save
                               while LDAP is already active skips this. */
                            $ldap_root_problem =
                                $signin_model->ldapRootValidationProblem(
                                $profile['LDAP_CONTROLLERS'],
                                $profile['LDAP_ACCOUNT_SUFFIX'],
                                $profile['LDAP_BASE_DN'],
                                $parent->clean(
                                $_REQUEST['LDAP_ROOT_NAME'] ?? "", "string"),
                                $_REQUEST['LDAP_ROOT_PASSWORD'] ?? "");
                            if ($ldap_root_problem !== "") {
                                $profile['AUTH_METHOD'] =
                                    C\LDAP_AUTHENTICATION_PENDING;
                            }
                        }
                    }
                    if ($change) {
                        /* The session cookie name and the CSRF token
                           field name are saved here. Before the save
                           takes effect, migrate the browser's session
                           cookie to the new name and carry the submitted
                           token to the new field name, so a change to
                           either name keeps the admin signed in and their
                           next request valid rather than signing them
                           out. The old names come from the live profile
                           the save is about to replace. */
                        $old_session_name = C\p('SESSION_NAME');
                        $new_session_name = $profile['SESSION_NAME'] ??
                            $old_session_name;
                        if ($new_session_name !== $old_session_name &&
                            !empty($parent->web_site)) {
                            $parent->web_site->renameSessionCookie(
                                $old_session_name, $new_session_name);
                        }
                        $old_token_name = C\p('CSRF_TOKEN');
                        $new_token_name = $profile['CSRF_TOKEN'] ??
                            $old_token_name;
                        if ($new_token_name !== $old_token_name &&
                            isset($_REQUEST[$old_token_name])) {
                            $_REQUEST[$new_token_name] =
                                $_REQUEST[$old_token_name];
                        }
                        $profile_model->updateProfile(C\WORK_DIRECTORY,
                            [], $profile);
                        /* Mark, for the next render of the panel only, which
                           fields stopped LDAP from going live so a star can
                           appear beside each. This is set only on a failed
                           switch and cleared once shown. */
                        unset($_SESSION['AUTHENTICATION_MISSING']);
                        if ($profile['AUTH_METHOD'] ==
                            C\LDAP_AUTHENTICATION_PENDING) {
                            $problem_field = [
                                'no_servers' => 'LDAP_CONTROLLERS',
                                'no_suffix' => 'LDAP_ACCOUNT_SUFFIX',
                                'no_base_dn' => 'LDAP_BASE_DN'];
                            $missing = [];
                            foreach ($ldap_config_problems as $problem) {
                                if (isset($problem_field[$problem])) {
                                    $missing[] = $problem_field[$problem];
                                }
                            }
                            if ($ldap_root_problem == 'bind_failed') {
                                $missing[] = 'LDAP_ROOT_NAME';
                                $missing[] = 'LDAP_ROOT_PASSWORD';
                            }
                            if (!empty($missing)) {
                                $_SESSION['AUTHENTICATION_MISSING'] = $missing;
                            }
                        }
                        /* When LDAP was chosen but is not ready the stored
                           method is the pending value, so report that the
                           site still uses local passwords rather than a plain
                           "saved": either the panel settings need fixing, or
                           the root directory check did not pass. */
                        if ($profile['AUTH_METHOD'] !=
                            C\LDAP_AUTHENTICATION_PENDING) {
                            $saved_message = tl(
                                'system_component_settings_updated');
                        } else if ($ldap_root_problem == 'bind_failed') {
                            $saved_message = tl(
                                'system_component_ldap_root_bind_failed');
                        } else if ($ldap_root_problem == 'email_mismatch') {
                            $saved_message = tl(
                                'system_component_ldap_root_email_mismatch');
                        } else {
                            $saved_message = tl(
                                'system_component_ldap_saved_not_ready');
                        }
                        return $parent->redirectWithMessage(
                            $saved_message, false, false);
                    } else {
                        return $parent->redirectWithMessage(
                            tl('system_component_no_update_settings'));
                    }
                    break;
            }
        }
        $data = array_merge($data,
            $profile_model->getProfile(C\WORK_DIRECTORY));
        $data['AUTOLOGOUT'] = $profile['AUTOLOGOUT'];
        $data["RECOVERY_MODE"] = $profile["RECOVERY_MODE"];
        /* A stored pending value means LDAP is the chosen method but is not
           ready yet; show it in the panel as LDAP so the dropdown, the LDAP
           fields, and the problem list all appear, while sign-in itself keeps
           treating only the plain LDAP value as active. */
        $stored_auth_method = $profile["AUTH_METHOD"] ?? C\p('AUTH_METHOD');
        $data["AUTH_METHOD"] =
            ($stored_auth_method == C\LDAP_AUTHENTICATION_PENDING) ?
            C\LDAP_AUTHENTICATION : $stored_auth_method;
        $data["PASSWORD_MIN_LEN"] = $profile["PASSWORD_MIN_LEN"] ??
            C\p('PASSWORD_MIN_LEN');
        foreach (['PASSWORD_REQUIRE_LOWERCASE', 'PASSWORD_REQUIRE_UPPERCASE',
            'PASSWORD_REQUIRE_DIGIT', 'PASSWORD_REQUIRE_SYMBOL']
            as $requirement) {
            $data[$requirement] = !empty($profile[$requirement]);
        }
        $data["LDAP_CONTROLLERS"] = $profile["LDAP_CONTROLLERS"] ??
            C\p('LDAP_CONTROLLERS');
        $data["LDAP_ACCOUNT_SUFFIX"] = $profile["LDAP_ACCOUNT_SUFFIX"] ??
            C\p('LDAP_ACCOUNT_SUFFIX');
        $data["LDAP_BASE_DN"] = $profile["LDAP_BASE_DN"] ??
            C\p('LDAP_BASE_DN');
        $data["LDAP_PROBLEMS"] =
            ($data["AUTH_METHOD"] == C\LDAP_AUTHENTICATION) ?
            $signin_model->ldapConfigProblems($data["LDAP_CONTROLLERS"],
            $data["LDAP_ACCOUNT_SUFFIX"], $data["LDAP_BASE_DN"]) : [];
        /* Fields a failed switch to LDAP flagged on the previous save, used
           once to put a star beside each, then cleared so the stars do not
           linger on a fresh view of the panel. */
        $data["AUTHENTICATION_MISSING"] =
            $_SESSION['AUTHENTICATION_MISSING'] ?? [];
        unset($_SESSION['AUTHENTICATION_MISSING']);
        return $data;
    }
    /**
     * Responsible for handling admin request related to the appearance activity
     *
     * The activity is used to control the look and feel of the Yioop instance
     * such as foreground, background color, icons, etc.
     *
     * @return array $data fields for current appearance settings
     */
    public function appearance()
    {
        $parent = $this->parent;
        $profile_model = $parent->model("profile");
        $group_model = $parent->model("group");
        $data = [];
        $profile = [];
        $data["ELEMENT"] = "appearance";
        $data['SCRIPT'] = "";
        $arg = "";
        if (isset($_REQUEST['arg'])) {
            $arg = $_REQUEST['arg'];
        }
        switch ($arg) {
            case "delete":
                $appearance_domain = $this->appearanceDomain($group_model);
                $themes = $profile_model->getThemeNames();
                $auxiliary_css_name = $_REQUEST['AUXILIARY_CSS_NAME'];
                if (in_array($auxiliary_css_name, $themes)) {
                    $profile_model->deleteTheme($auxiliary_css_name);
                    if ($appearance_domain !== "") {
                        /* the theme is gone for every domain, but only
                           the domain being customized stops naming it
                           here; the rest of that domain's look, and the
                           site's own theme, are left as they were */
                        $appearance = $group_model->getDomainAppearance(
                            $appearance_domain);
                        $appearance['AUXILIARY_CSS_NAME'] = "";
                        $group_model->setDomainAppearance(
                            $appearance_domain, $appearance);
                    } else {
                        $old_profile =
                            $profile_model->getProfile(C\WORK_DIRECTORY);
                        $profile = $old_profile;
                        $profile['AUXILIARY_CSS_NAME'] = "";
                        unset($profile['AUXILIARY_CSS']);
                        $profile_model->updateProfile(
                            C\WORK_DIRECTORY, $profile, $old_profile);
                    }
                    return $parent->redirectWithMessage(
                        tl('system_component_theme_deleted'),
                        ['advanced', 'lang', 'APPEARANCE_DOMAIN',
                        'scroll_top', 'caret']);
                }
                return $parent->redirectWithMessage(
                    tl('system_component_theme_error_deleting_theme'),
                    ['advanced', 'lang', 'APPEARANCE_DOMAIN',
                    'scroll_top', 'caret']);
            case "profile":
                $appearance_domain = $this->appearanceDomain(
                    $group_model);
                if (!empty($_REQUEST['edit_theme']) ) {
                    $theme_name = $_REQUEST['AUXILIARY_CSS_NAME'];
                    if (empty(trim($theme_name))) {
                        unset($_REQUEST['edit_theme']);
                        $_REQUEST['add_theme'] = true;
                        $parent->redirectWithMessage(
                            tl('system_component_empty_theme_name'),
                            ['advanced', 'lang', 'add_theme',
                            'APPEARANCE_DOMAIN', 'AUXILIARY_CSS',
                            'scroll_top', 'caret'], false);
                    }
                    $clean_theme_name = $parent->clean($theme_name,
                        'file_name');
                    if ($clean_theme_name != $theme_name ||
                        str_contains($clean_theme_name, "/")) {
                        unset($_REQUEST['edit_theme']);
                        $_REQUEST['add_theme'] = true;
                        $parent->redirectWithMessage(
                            tl('system_component_no_special_chars_in_name'),
                            ['advanced', 'lang', 'add_theme',
                            'APPEARANCE_DOMAIN', 'AUXILIARY_CSS',
                            'scroll_top', 'caret'], false);
                    }
                    if (mb_strlen($theme_name) > C\NAME_LEN) {
                        unset($_REQUEST['edit_theme']);
                        $_REQUEST['add_theme'] = true;
                        $parent->redirectWithMessage(
                            tl('system_component_name_too_long'),
                            ['advanced', 'lang', 'add_theme',
                            'APPEARANCE_DOMAIN', 'AUXILIARY_CSS',
                            'scroll_top', 'caret'], false);
                    }
                }
                if ($appearance_domain !== "") {
                    if (!empty($_REQUEST['edit_theme']) &&
                        isset($_REQUEST['AUXILIARY_CSS'])) {
                        /* the theme is the site's, so its rules go in
                           the one theme folder; the domain save below
                           records that this domain uses it */
                        $profile_model->saveThemeStylesheet(
                            $parent->clean($_REQUEST['AUXILIARY_CSS_NAME'],
                            'file_name'), $_REQUEST['AUXILIARY_CSS']);
                    }
                    return $this->saveDomainAppearance($data,
                        $appearance_domain, $group_model);
                }
                $parent->updateProfileFields($data, $profile,
                    ['LANDING_PAGE']);
                $old_profile =
                    $profile_model->getProfile(C\WORK_DIRECTORY);
                $folder = C\APP_DIR . "/resources";
                if ((!file_exists(C\APP_DIR) && !mkdir(C\APP_DIR)) ||
                    (!file_exists($folder) && !mkdir($folder))) {
                    return $parent->redirectWithMessage(
                        tl('system_component_no_resource_folder'),
                        ['advanced', 'lang', 'scroll_top', 'caret']);
                }
                foreach (['BACKGROUND_IMAGE', 'LOGO_SMALL',
                    'LOGO_MEDIUM', 'LOGO_LARGE', 'FAVICON'] as $field) {
                    if (isset($_FILES[$field]['name']) &&
                        $_FILES[$field]['name'] != "") {
                        if (!in_array($_FILES[$field]['type'],
                            ['image/png', 'image/gif', 'image/jpeg',
                            'image/webp', 'image/x-icon'])) {
                            return $parent->redirectWithMessage(
                                tl('system_component_invalid_filetype'),
                                ['advanced', 'lang', 'scroll_top', 'caret']);
                        }
                        if ($_FILES[$field]['size'] > C\THUMB_SIZE) {
                            return $parent->redirectWithMessage(
                                tl('system_component_file_too_big'),
                                ['advanced', 'lang']);
                        }
                        $profile[$field] = [];
                        $profile[$field]['name'] = $_FILES[$field]['name'];
                        $profile[$field]['tmp_name'] =
                            $_FILES[$field]['tmp_name'];
                        if (!empty($_FILES[$field]['data'])) {
                            $profile[$field]['data'] =
                                $_FILES[$field]['data'];
                        }
                        if (C\REDIRECTS_ON) {
                            $data[$field] =
                                "wd/resources/" . $profile[$field]['name'];
                        } else {
                            $data[$field] =
                                "?c=resource&a=get&" .
                                "f=resources&n=" . $profile[$field]['name'];
                        }
                    }
                }
                if ($profile_model->updateProfile(
                    C\WORK_DIRECTORY, $profile, $old_profile)) {
                    return $parent->redirectWithMessage(
                        tl('system_component_configure_profile_change'),
                        ['advanced', 'lang', 'edit_theme',
                        'scroll_top', 'caret'], false);
                } else {
                    return $parent->redirectWithMessage(
                        tl('system_component_configure_no_change_profile'),
                        ['advanced', 'lang', 'edit_theme',
                        'scroll_top', 'caret']);
                }
                break;
            case "reset":
                $appearance_domain = $this->appearanceDomain(
                    $group_model);
                if ($appearance_domain !== "") {
                    /* Reset for a selected custom-route domain:
                       remove that domain's saved appearance
                       overrides and its uploaded per-domain images,
                       leaving the global profile untouched. Unlike
                       the global reset below, no profile rewrite
                       happens, so no server restart is needed. */
                    $group_model->deleteDomainAppearance(
                        $appearance_domain);
                    $subfolder = $parent->clean($appearance_domain,
                        "file_name");
                    $folder = C\APP_DIR . "/resources/" . $subfolder;
                    if ($subfolder !== "" && file_exists($folder)) {
                        $group_model->db->unlinkRecursive($folder);
                    }
                    return $parent->redirectWithMessage(
                        tl('system_component_configure_reset_completed'),
                        ['advanced', 'lang', 'APPEARANCE_DOMAIN',
                        'scroll_top', 'caret']);
                }
                $profile = [
                    'LANDING_PAGE' => false,
                    'BACKGROUND_COLOR' => "#FFFFFF",
                    'BACKGROUND_IMAGE' => "",
                    'FOREGROUND_COLOR' => "#FFFFFF",
                    'SIDEBAR_COLOR' => "#F0F0F0",
                    'TOPBAR_COLOR' => "#EEEEFF",
                    'LOGO_SMALL' => "resources/yioop-small.png",
                    'LOGO_MEDIUM' => "resources/yioop-medium.png",
                    'LOGO_LARGE' => "resources/yioop-large.png",
                    'FAVICON' => "favicon.ico",
                    'TIMEZONE' => 'America/Los_Angeles',
                    'SESSION_NAME' => "yioopbiscuit",
                    'CSRF_TOKEN' => "YIOOP_TOKEN",
                    'AUXILIARY_CSS_NAME' => "",
                    'AUXILIARY_CSS' => ""
                ];
                $old_profile = $profile_model->getProfile(C\WORK_DIRECTORY);
                foreach ($old_profile as $key => $value) {
                    $data[$key] = $value;
                }
                $tmp_image = $old_profile['BACKGROUND_IMAGE'];
                $old_profile['BACKGROUND_IMAGE'] = "";
                if ($profile_model->updateProfile(
                    C\WORK_DIRECTORY, $profile, $old_profile,
                    true)) {
                    $old_profile['BACKGROUND_IMAGE'] = $tmp_image;
                    foreach ($profile as $key => $value) {
                        $data[$key] = $value;
                        if (in_array($key, ['BACKGROUND_IMAGE',
                            'LOGO_SMALL', 'LOGO_MEDIUM', 'LOGO_LARGE',
                            'FAVICON'] )
                            && $old_profile[$key] != "") {
                            $resource_name = C\APP_DIR ."/resources/".
                                $old_profile[$key];
                            if (file_exists($resource_name)) {
                                unlink($resource_name);
                            }
                        }
                    }
                    $_REQUEST['advanced'] = "true";
                    /* The layout overlays the request host's saved
                       domain appearance on top of the global
                       profile, so a host with its own appearance row
                       would still show its overrides after a global
                       reset. Clear that host's domain appearance and
                       per-domain resources too, so resetting the
                       global look actually takes effect on the host
                       the admin is viewing. */
                    $host = $parent->requestHost();
                    if ($host !== "") {
                        $group_model->deleteDomainAppearance($host);
                        $subfolder = $parent->clean($host,
                            "file_name");
                        $folder = C\APP_DIR . "/resources/" .
                            $subfolder;
                        if ($subfolder !== "" &&
                            file_exists($folder)) {
                            $group_model->db->unlinkRecursive(
                                $folder);
                        }
                    }
                    return $parent->redirectWithMessage(
                        tl('system_component_configure_reset_completed'),
                        false, false);
                } else {
                    return $parent->redirectWithMessage(
                        tl('system_component_configure_no_change_profile'));
                }
                break;
            case "switch":
                return $this->switchAppearanceDomain($group_model);
            default:
                $data = array_merge($data,
                    $profile_model->getProfile(C\WORK_DIRECTORY));
                /* every domain picks from the one list the site keeps */
                $themes = $profile_model->getThemeNames();
                $data['themes'] = array_merge(
                    ["" => tl('system_component_no_auxiliary_theme')],
                    array_combine($themes, $themes));
                $this->setupAppearanceDomains($data, $group_model);
                /* the theme named may be one this scope does not have:
                   the profile's own when a domain is being customized,
                   or one a domain still names after it was deleted. The
                   check comes after the domain has had its say so that
                   either way the form offers no theme rather than one
                   that is not there */
                if (empty($data['AUXILIARY_CSS_NAME']) ||
                    !in_array($data['AUXILIARY_CSS_NAME'],
                    $data['themes'] )) {
                    $data['AUXILIARY_CSS_NAME'] = "";
                    $data['AUXILIARY_CSS'] = "";
                }
        }
        $locale_tag = L\getLocaleTag();
        $not_null_fields = [
            'SITE_NAME' => C\p('SITE_NAME'),
            'SITE_MOTTO' => C\p('SITE_MOTTO'),
            'LOGO_SMALL' => "resources/yioop-small.png",
            'LOGO_MEDIUM' => "resources/yioop-medium.png",
            'LOGO_LARGE' => "resources/yioop-large.png",
            'FAVICON' => "favicon.ico",
        ];
        foreach ($not_null_fields as $field => $default) {
            if (!$data[$field]) {
                $data[$field] = $default;
            }
        }
        $data["USE_THEME_FORM"] = !empty($_REQUEST['edit_theme']) ||
            !empty($_REQUEST['add_theme']);
        $data['ADD_THEME'] = !empty($_REQUEST['add_theme']);
        if (isset($_REQUEST['caret']) &&
           isset($_REQUEST['scroll_top'])
                && !isset($page)) {
            $caret = $parent->clean($_REQUEST['caret'],
                'int');
            $scroll_top = $parent->clean($_REQUEST['scroll_top'],
                'int');
            $data['SCRIPT'] .= "auxiliary_css = elt('auxiliary-css');".
                "if (auxiliary_css.setSelectionRange) { " .
                "   auxiliary_css.focus();" .
                "   auxiliary_css.setSelectionRange($caret, $caret);".
                "} ".
                "auxiliary_css.scrollTop = $scroll_top;";
        }
        if (!empty($_REQUEST['add_theme'])) {
            $data['AUXILIARY_CSS_NAME'] = "";
        }
        if (isset($_REQUEST["AUXILIARY_CSS"])) {
            $data["AUXILIARY_CSS"] =
                $parent->clean($_REQUEST["AUXILIARY_CSS"] ?? "", "string");
        } else {
            $data["AUXILIARY_CSS"] =
                $parent->clean($data["AUXILIARY_CSS"] ?? "", "string");
        }
        if (!empty($_REQUEST["AUXILIARY_CSS_NAME"])) {
            $data["AUXILIARY_CSS_NAME"] =
                $parent->clean($_REQUEST["AUXILIARY_CSS_NAME"] ?? "", "string");
        }
        /* The layout dresses this page from the same $data fields the
           form fills in, and when the page is served from a routed
           domain that domain's own look is laid over them just before
           rendering. The form therefore keeps its values in a set of its
           own, which nothing else writes, so it shows the scope the
           admin picked rather than the look of whichever domain the
           admin happens to be browsing. */
        $data['APPEARANCE_FORM'] = [];
        foreach (array_merge(self::APPEARANCE_DOMAIN_FIELDS,
            ['AUXILIARY_CSS']) as $field) {
            $data['APPEARANCE_FORM'][$field] = $data[$field] ?? "";
        }
        return $data;
    }
    /**
     * Returns the selected appearance-edit domain from the request,
     * validated against the set of current custom-route domains.
     * The empty string means the default Appearance scope, which is
     * also returned when no value is selected or the selected value
     * is not a current custom-route domain.
     *
     * @param object $group_model group model used to read the
     *      current custom-route domains
     * @return string a current custom-route domain name, or the
     *      empty string for the default scope
     */
    public function appearanceDomain($group_model)
    {
        $domains = array_keys($group_model->getDomainRoutes());
        if (isset($_REQUEST['APPEARANCE_DOMAIN'])) {
            $selected = $this->parent->clean(
                $_REQUEST['APPEARANCE_DOMAIN'], "string");
            return in_array($selected, $domains) ? $selected : "";
        }
        /* With no explicit choice, the domain being customized defaults
           to the host in the browser's address bar when that host is
           one of the configured route domains, so the page opens on the
           look of the domain the admin is viewing; otherwise the default
           look is shown. */
        $host = $this->currentHost();
        return in_array($host, $domains) ? $host : "";
    }
    /**
     * Returns the host in the browser's address bar for this request,
     * lowercased and with any port removed, in the form used to match a
     * request against the configured route domains.
     *
     * @return string the current request host, or the empty string when
     *      no host is present on the request
     */
    protected function currentHost()
    {
        $host = $_SERVER['HTTP_HOST'] ?? ($_SERVER['SERVER_NAME'] ?? "");
        $colon = strpos($host, ":");
        if ($colon !== false) {
            $host = substr($host, 0, $colon);
        }
        return strtolower($host);
    }
    /**
     * Switches the Appearance activity to a chosen scope by moving the
     * browser to the host that serves it: a chosen route domain's own
     * host, or the name-server host for the global scope. So the admin
     * stays signed in across the move, a single-use handoff token tied
     * to the signed-in user is minted and carried in the target url,
     * where AdminController redeems it to restore the session under the
     * target host's own session cookie. When the target host is already
     * the one in the address bar, the appearance page is simply
     * reopened with no token minted.
     *
     * @param object $group_model group model used to read the current
     *      route domains
     * @return mixed redirect result sending the browser to the target
     *      host's appearance page
     */
    public function switchAppearanceDomain($group_model)
    {
        $parent = $this->parent;
        $target = strtolower(trim($parent->clean(
            $_REQUEST['APPEARANCE_TARGET'] ?? "", "string")));
        $domains = array_keys($group_model->getDomainRoutes());
        if ($target !== "" && !in_array($target, $domains)) {
            $target = "";
        }
        $name_url = C\p('NAME_SERVER');
        $name_host = strtolower((string)parse_url($name_url,
            PHP_URL_HOST));
        $target_host = ($target === "") ? $name_host : $target;
        $query = "a=appearance&" . C\p('CSRF_TOKEN') . "=" .
            $parent->generateCSRFToken($_SESSION['USER_ID'] ?? "");
        $path = B\controllerUrl("admin", true);
        if ($target_host === "" || $target_host === $this->currentHost()) {
            return $parent->redirectLocation($path . $query);
        }
        $handoff = bin2hex(random_bytes(32));
        $parent->model("user")->setSessionUser($handoff,
            $_SESSION['USER_ID'] ?? "", time() + C\p('AUTOLOGOUT'));
        /* SHORT_BASE_URL, and so the path controllerUrl builds, is
           host-relative in a request and carries no scheme or host; the
           other domain needs an absolute url, so take the scheme this
           request arrived over and join it to the target host, letting a
           plain-http test box switch over http and a live site over
           https */
        $scheme = parse_url(C\baseUrl(), PHP_URL_SCHEME);
        if (empty($scheme)) {
            $scheme = "https";
        }
        $origin = ($target === "") ? rtrim($name_url, "/") :
            $scheme . "://" . $target_host;
        return $parent->redirectLocation($origin . $path . $query .
            "&domain_handoff=" . $handoff);
    }
    /**
     * Adds the appearance domain picker data to $data: the list of
     * selectable scopes (global plus every current custom-route
     * domain) and the currently selected scope. When a custom-route
     * domain is selected, overlays that domain's saved appearance
     * overrides onto $data so the form shows the domain's look;
     * with no domains or the global scope selected, $data keeps the
     * global Appearance values unchanged.
     *
     * @param array &$data field data being prepared for the
     *      appearance view, modified in place
     * @param object $group_model group model used to read the
     *      custom-route domains and their saved appearance
     */
    public function setupAppearanceDomains(&$data, $group_model)
    {
        $domains = array_keys($group_model->getDomainRoutes());
        $data['APPEARANCE_DOMAINS'] = [
            "" => tl('system_component_appearance_default')];
        foreach ($domains as $domain) {
            $data['APPEARANCE_DOMAINS'][$domain] = $domain;
        }
        $selected = $this->appearanceDomain($group_model);
        $data['APPEARANCE_DOMAIN'] = $selected;
        if (!empty($domains)) {
            $switch_url = B\controllerUrl("admin", true) .
                "a=appearance&arg=switch&" . C\p('CSRF_TOKEN') .
                "=" . $this->parent->generateCSRFToken(
                $_SESSION['USER_ID'] ?? "") . "&APPEARANCE_TARGET=";
            $switch_url_js = json_encode($switch_url,
                JSON_UNESCAPED_SLASHES);
            $confirm_js = json_encode(
                tl('appearance_element_domain_switch_confirm'));
            $selector_js = json_encode(
                "#appearanceForm input:not(#appearance-domain), " .
                "#appearanceForm select:not(#appearance-domain), " .
                "#appearanceForm textarea");
            $data['SCRIPT'] = ($data['SCRIPT'] ?? "") . "\n" . <<<EOD
(function() {
    var picker = elt('appearance-domain');
    if (!picker) {
        return;
    }
    var switch_url = $switch_url_js;
    var confirm_switch = $confirm_js;
    var prior_value = picker.value;
    var unsaved = false;
    listenAll($selector_js, 'change', function() {
        unsaved = true;
    });
    listen(picker, 'change', function() {
        if (unsaved && !window.confirm(confirm_switch)) {
            picker.value = prior_value;
            return;
        }
        window.location = switch_url + encodeURIComponent(picker.value);
    });
})();
EOD;
        }
        if ($selected === "") {
            return;
        }
        $appearance = $group_model->getDomainAppearance($selected);
        foreach (self::APPEARANCE_DOMAIN_FIELDS as $field) {
            if (isset($appearance[$field]) &&
                $appearance[$field] !== "") {
                $data[$field] = $appearance[$field];
            }
        }
        if (!empty($appearance['AUXILIARY_CSS_NAME'])) {
            /* the form's stylesheet box was filled from the profile, so
               it holds the rules of a default theme; this domain has
               themes of its own, and the box must show the rules of the
               one it uses or an edit here would save them over the
               wrong theme */
            $data['AUXILIARY_CSS'] = $this->parent->model("profile")
                ->getThemeStylesheet($appearance['AUXILIARY_CSS_NAME']);
        }
    }
    /**
     * Saves the appearance overrides for one custom-route domain to
     * the DOMAIN_APPEARANCE store. Color, theme-name, landing-page,
     * and searchbar fields come from the submitted form; uploaded
     * image files (logos, favicon, background image) are stored in a
     * per-domain subfolder of resources so domains do not share or
     * overwrite each other's images, and are referenced by a
     * subfolder-qualified resource URL. Fields the form leaves
     * unchanged keep the domain's previously saved value.
     *
     * @param array $data appearance field data assembled for the
     *      view (unused once the redirect fires, present for parity
     *      with the global save path)
     * @param string $domain custom-route domain being saved
     * @param object $group_model group model used to read and write
     *      the domain's saved appearance
     * @return array redirect result for the appearance activity
     */
    public function saveDomainAppearance($data, $domain,
        $group_model)
    {
        $parent = $this->parent;
        $appearance = $group_model->getDomainAppearance($domain);
        $color_fields = ['BACKGROUND_COLOR', 'FOREGROUND_COLOR',
            'SIDEBAR_COLOR', 'TOPBAR_COLOR'];
        foreach ($color_fields as $field) {
            if (isset($_REQUEST[$field])) {
                $appearance[$field] =
                    $parent->clean($_REQUEST[$field], "color");
            }
        }
        if (isset($_REQUEST['AUXILIARY_CSS_NAME'])) {
            $appearance['AUXILIARY_CSS_NAME'] = $parent->clean(
                $_REQUEST['AUXILIARY_CSS_NAME'], "string");
        }
        if (isset($_REQUEST['SITE_NAME'])) {
            $appearance['SITE_NAME'] = $parent->clean(
                $_REQUEST['SITE_NAME'], "string");
        }
        if (isset($_REQUEST['SITE_MOTTO'])) {
            $appearance['SITE_MOTTO'] = $parent->clean(
                $_REQUEST['SITE_MOTTO'], "string");
        }
        $subfolder = $parent->clean($domain, "file_name");
        $folder = C\APP_DIR . "/resources/" . $subfolder;
        foreach (['BACKGROUND_IMAGE', 'LOGO_SMALL', 'LOGO_MEDIUM',
            'LOGO_LARGE', 'FAVICON'] as $field) {
            if (empty($_FILES[$field]['name'])) {
                continue;
            }
            if (!in_array($_FILES[$field]['type'],
                ['image/png', 'image/gif', 'image/jpeg',
                'image/webp', 'image/x-icon'])) {
                return $parent->redirectWithMessage(
                    tl('system_component_invalid_filetype'),
                    ['advanced', 'lang', 'APPEARANCE_DOMAIN',
                    'scroll_top', 'caret']);
            }
            if ($_FILES[$field]['size'] > C\THUMB_SIZE) {
                return $parent->redirectWithMessage(
                    tl('system_component_file_too_big'),
                    ['advanced', 'lang', 'APPEARANCE_DOMAIN']);
            }
            if ((!file_exists(C\APP_DIR) && !mkdir(C\APP_DIR)) ||
                (!file_exists($folder) && !mkdir($folder, 0777,
                true))) {
                return $parent->redirectWithMessage(
                    tl('system_component_no_resource_folder'),
                    ['advanced', 'lang', 'APPEARANCE_DOMAIN',
                    'scroll_top', 'caret']);
            }
            $resource_name = $_FILES[$field]['name'];
            $parent->web_site->moveUploadedFile(
                $_FILES[$field]['tmp_name'],
                "$folder/$resource_name");
            if (C\REDIRECTS_ON) {
                $appearance[$field] = "wd/resources/" . $subfolder
                    . "/" . $resource_name;
            } else {
                $appearance[$field] = "?c=resource&a=get&"
                    . "f=resources&sf=" . $subfolder . "&n="
                    . $resource_name;
            }
        }
        $group_model->setDomainAppearance($domain, $appearance);
        return $parent->redirectWithMessage(
            tl('system_component_configure_profile_change'),
            ['advanced', 'lang', 'APPEARANCE_DOMAIN',
            'scroll_top', 'caret'], false);
    }
    /**
     * Responsible for handling admin request related to the configure activity
     *
     * The configure activity allows a user to set the work directory for
     * storing data local to this SeekQuarry/Yioop instance. It also allows one
     * to set the default language of the installation, debug info, robot info,
     * test info, etc.
     *
     * @return array $data fields for available language, debug level,
     *      etc as well as results of processing sub activity if any
     */
    public function configure()
    {
        $parent = $this->parent;
        $profile_model = $parent->model("profile");
        $group_model = $parent->model("group");
        $data = [];
        $profile = [];
        $data['SYSTEM_CHECK'] = $this->systemCheck();
        $languages = $parent->model("locale")->getLocaleList();
        foreach ($languages as $language) {
            $data['LANGUAGES'][$language['LOCALE_TAG']] =
                $language['LOCALE_NAME'];
        }
        if (isset($_REQUEST['lang']) && $_REQUEST['lang']) {
            $data['lang'] = $parent->clean($_REQUEST['lang'], "string");
            $profile['DEFAULT_LOCALE'] = $data['lang'];
            L\setLocaleObject($data['lang']);
        }
        $data["ELEMENT"] = "configure";
        $data['SCRIPT'] = "";
        $data['PROFILE'] = false;
        if (isset($_REQUEST['WORK_DIRECTORY']) ||
            (C\nsdefined('WORK_DIRECTORY') &&
            C\nsdefined('FIX_NAME_SERVER') && C\FIX_NAME_SERVER) ) {
            if (C\nsdefined('WORK_DIRECTORY') && C\nsdefined('FIX_NAME_SERVER')
                && C\FIX_NAME_SERVER && !isset($_REQUEST['WORK_DIRECTORY'])) {
                $_REQUEST['WORK_DIRECTORY'] = C\WORK_DIRECTORY;
                $_REQUEST['arg'] = "directory";
                @unlink($_REQUEST['WORK_DIRECTORY'] . "/" .
                    C\PROFILE_FILE_NAME);
            }
            $dir =
                $parent->clean($_REQUEST['WORK_DIRECTORY'], "string");
            $data['PROFILE'] = true;
            if (strstr(PHP_OS, "WIN")) {
                //convert to forward slashes so consistent with rest of code
                $dir = str_replace("\\", "/", $dir);
                if ($dir[0] != "/" && $dir[1] != ":") {
                    $data['PROFILE'] = false;
                }
            } else if ($dir[0] != "/") {
                    $data['PROFILE'] = false;
            }
            if ($data['PROFILE'] == false) {
                return $parent->redirectWithMessage(
                    tl('system_component_configure_use_absolute_path'),
                    ['lang']);
            }
            if (strstr($dir."/", C\BASE_DIR)) {
                return $parent->redirectWithMessage(
                    tl('system_component_configure_configure_diff_base_dir'),
                    ['lang']);
            }
            $data['WORK_DIRECTORY'] = $dir;
        } else if (C\nsdefined("WORK_DIRECTORY") &&
            strlen(C\WORK_DIRECTORY) > 0 &&
            strcmp(realpath(C\WORK_DIRECTORY), realpath(C\BASE_DIR)) != 0 &&
            (is_dir(C\WORK_DIRECTORY) || is_dir(C\WORK_DIRECTORY."../"))) {
            $data['WORK_DIRECTORY'] = C\WORK_DIRECTORY;
            $data['PROFILE'] = true;
            if (C\WORK_DIRECTORY == C\DEFAULT_WORK_DIRECTORY &&
                is_writable(C\WORK_DIRECTORY) &&
                !file_exists(C\WORK_DIRECTORY. C\PROFILE_FILE_NAME) ) {
                $_REQUEST['arg'] = 'directory';
            }
        }
        $arg = "";
        if (isset($_REQUEST['arg'])) {
            $arg = $_REQUEST['arg'];
        }
        switch ($arg) {
            case "directory":
                if (!isset($data['WORK_DIRECTORY'])) {
                    break;
                }
                if ($data['PROFILE'] &&
                    file_exists($data['WORK_DIRECTORY'] . "/" .
                        C\PROFILE_FILE_NAME)) {
                    $data = array_merge($data, $profile_model->getProfile(
                            $data['WORK_DIRECTORY']));
                    $profile_model->setWorkDirectoryConfigFile(
                        $data['WORK_DIRECTORY']);
                    return $parent->redirectWithMessage(
                        tl('system_component_configure_work_dir_set'),
                        ['lang'], true);
                } else if ($data['PROFILE'] &&
                    strlen($data['WORK_DIRECTORY']) > 0) {
                    if ($profile_model->makeWorkDirectory(
                        $data['WORK_DIRECTORY'])) {
                        $profile['DBMS'] = 'sqlite3';
                        $data['DBMS'] = 'sqlite3';
                        $profile['DB_NAME'] = 'public_default';
                        $data['DB_NAME'] = 'public_default';
                        $profile['PRIVATE_DBMS'] = 'sqlite3';
                        $data['PRIVATE_DBMS'] = 'sqlite3';
                        $profile['PRIVATE_DB_NAME'] = 'private_default';
                        $data['PRIVATE_DB_NAME'] = 'private_default';
                        $profile['USER_AGENT_SHORT'] =
                            tl('system_component_name_your_bot');
                        $data['USER_AGENT_SHORT'] =
                            $profile['USER_AGENT_SHORT'];
                        $profile['NAME_SERVER'] = C\baseUrl();
                        $data['NAME_SERVER'] = $profile['NAME_SERVER'];
                        $profile['AUTH_KEY'] =
                            L\base64Hash(random_bytes(C\AUTH_KEY_NUM_BYTES));
                        $data['AUTH_KEY'] = $profile['AUTH_KEY'];
                        $robot_instance = str_replace(".", "_",
                            $_SERVER['SERVER_NAME'] ?? '') . "-" . time();
                        $profile['ROBOT_INSTANCE'] = $robot_instance;
                        $data['ROBOT_INSTANCE'] = $profile['ROBOT_INSTANCE'];
                        if ($profile_model->updateProfile(
                            $data['WORK_DIRECTORY'], [], $profile)) {
                            if ((defined('WORK_DIRECTORY') &&
                                $data['WORK_DIRECTORY'] == C\WORK_DIRECTORY) ||
                                $profile_model->setWorkDirectoryConfigFile(
                                    $data['WORK_DIRECTORY'])) {
                                /*
                                    Create an initial machine to be used
                                    for crawling
                                */
                                $machine_model = $parent->model('machine');
                                $machine_model->db =
                                    new D\Sqlite3Manager();
                                $machine_model->db->connect("", "", "",
                                    $data['WORK_DIRECTORY'] .
                                    "/data/public_default.db");
                                return $parent->redirectWithMessage(
                            tl('system_component_configure_work_profile_made'),
                                    ['lang'], true);
                            } else {
                                return $parent->redirectWithMessage(
                                tl('system_component_configure_no_set_config'),
                                    ['lang']);
                            }
                        } else {
                            $profile_model->setWorkDirectoryConfigFile(
                                $data['WORK_DIRECTORY']);
                            return $parent->redirectWithMessage(
                            tl('system_component_configure_no_create_profile'),
                                ['lang']);
                        }
                    } else {
                        $profile_model->setWorkDirectoryConfigFile(
                            $data['WORK_DIRECTORY']);
                        return $parent->redirectWithMessage(
                            tl('system_component_configure_work_dir_invalid'),
                            ['lang']);
                    }
                } else {
                    $profile_model->setWorkDirectoryConfigFile(
                        $data['WORK_DIRECTORY']);
                    return $parent->redirectWithMessage(
                        tl('system_component_configure_work_dir_invalid'),
                        ['lang']);
                }
                break;
            case "profile":
                $parent->updateProfileFields($data, $profile,
                    ['WEB_ACCESS', 'RSS_ACCESS', 'API_ACCESS']);
                $data['DEBUG_LEVEL'] = 0;
                $data['DEBUG_LEVEL'] |=
                    (isset($_REQUEST["ERROR_INFO"])) ? C\ERROR_INFO : 0;
                $data['DEBUG_LEVEL'] |=
                    (isset($_REQUEST["QUERY_INFO"])) ? C\QUERY_INFO : 0;
                $data['DEBUG_LEVEL'] |=
                    (isset($_REQUEST["TEST_INFO"])) ? C\TEST_INFO : 0;
                $profile['DEBUG_LEVEL'] = $data['DEBUG_LEVEL'];
                $old_profile =
                    $profile_model->getProfile($data['WORK_DIRECTORY']);
                if ($profile_model->updateProfile(
                    $data['WORK_DIRECTORY'], $profile, $old_profile)) {
                    if (isset($_REQUEST['ROBOT_DESCRIPTION'])) {
                        $locale_tag = L\getLocaleTag();
                        $robot_description = substr(
                            $parent->clean($_REQUEST['ROBOT_DESCRIPTION'],
                            "string"), 0, C\MAX_GROUP_PAGE_LEN);
                        $group_model->setPageName(C\ROOT_ID, C\PUBLIC_GROUP_ID,
                            "bot", $robot_description, $locale_tag,
                            "", "", "", "");
                    }
                    return $parent->redirectWithMessage(
                        tl('system_component_configure_profile_change'),
                        ['lang'], false);
                } else {
                    return $parent->redirectWithMessage(
                        tl('system_component_configure_no_change_profile'),
                        ['lang']);
                }
                break;
            default:
                if (isset($data['WORK_DIRECTORY']) &&
                    file_exists($data['WORK_DIRECTORY'] ."/" .
                    C\PROFILE_FILE_NAME)){
                    $data = array_merge($data,
                        $profile_model->getProfile($data['WORK_DIRECTORY']));
                } else {
                    $data['WORK_DIRECTORY'] = "";
                    $data['PROFILE'] = false;
                }
        }
        if ($data['PROFILE']) {
            $locale_tag = L\getLocaleTag();
            $robot_info = $group_model->getPageInfoByName(
                 C\PUBLIC_GROUP_ID, "bot", $locale_tag, "edit");
            $data['ROBOT_DESCRIPTION'] = isset($robot_info["PAGE"]) ?
                $robot_info["PAGE"] : tl('system_component_describe_robot');
        }
        $data['SCRIPT'] .=
            "\nelt('locale').onchange = ".
            "function () { elt('configureProfileForm').submit();};\n";

        return $data;
    }
    /**
     * Checks to see if the current machine has php configured in a way
     * Yioop! can run.
     *
     * @return string a message indicatign which required and optional
     *     components are missing; or "Passed" if nothing missing.
     */
     public function systemCheck()
     {
        $parent = $this->parent;
        $required_items = [
            [   "name" => "Multi-Curl",
                "check"=>"curl_multi_init", "type"=>"function"],
            [   "name" => "GD Graphics Library",
                "check"=>"imagecreate", "type"=>"function"],
            [   "name" => "Multibyte Character Library",
                "check"=>"mb_internal_encoding", "type"=>"function"],
            [   "name" => "PDO SQLite3 Library",
                "check"=>"\PDO", "type"=>"class"],
            [   "name" => "PHP intl",
                "check"=>"datefmt_create", "type"=>"function"],
            [   "name" =>
                    "Process Creation Functions (popen, pclose, and exec".
                    " needed for crawling)",
                "check"=>"popen", "type"=>"function"],
            [   "name" => "ZipArchive Class",
                "check"=>"\ZipArchive", "type"=>"class"],
            [   "name" => "OpenSSL Library",
                "check"=>"openssl_encrypt", "type"=>"function"],
            [   "name" => "Sodium Library",
                "check"=>"sodium_crypto_box_keypair", "type"=>"function"],
        ];
        $optional_items = [
            [   "name" =>
                    "POSIX Functions (posix_kill, used for graceful".
                    " daemon shutdown and to clear stale locks; without".
                    " it a stuck daemon must be ended by hand)",
                "check"=>"posix_kill", "type"=>"function"],
        ];
        $missing_required = "";
        $comma = "";
        foreach ($required_items as $item) {
            $check_function = $item["type"]."_exists";
            $check_parts = explode("|", $item["check"]);
            $check_flag = true;
            foreach ($check_parts as $check) {
                if ($check_function($check)) {
                    $check_flag = false;
                }
            }
            if ($check_flag) {
                $missing_required .= $comma.$item["name"];
                $comma = ",<br>";
            }
        }
        if (!defined('PHP_VERSION_ID') || PHP_VERSION_ID < 80100) {
            $missing_required .= $comma . tl("system_component_php_version");
            $comma = ", ";
        }

        $out = "";
        $br = "";

        if (!is_writable(C\BASE_DIR."/configs/Config.php")) {
            $out .= tl('system_component_no_write_config_php');
            $br = "<br>";
        }

        if (defined(C\WORK_DIRECTORY) && !is_writable(C\WORK_DIRECTORY)) {
            $out .= $br. tl('system_component_no_write_work_dir');
            $br = "<br>";
        }

        if (intval(ini_get("post_max_size")) < 2) {
            $out .= $br. tl('system_component_post_size_small');
            $br = "<br>";
        }

        if ($missing_required != "") {
            $out .= $br.
                tl('system_component_missing_required'). "<br>".
                $missing_required;
            $br = "<br>";
        }

        $missing_optional = "";
        $comma = "";
        foreach ($optional_items as $item) {
            $check_function = $item["type"]."_exists";
            $check_parts = explode("|", $item["check"]);
            $check_flag = true;
            foreach ($check_parts as $check) {
                if ($check_function($check)) {
                    $check_flag = false;
                }
            }
            if ($check_flag) {
                $missing_optional .= $comma . $item["name"];
                $comma = ", ";
            }
        }
        if ($missing_optional != "") {
            $out .= $br.
                tl('system_component_missing_optional') . "<br>".
                $missing_optional;
            $br = "<br>";
        }
        if ($out == "") {
            $out = tl('system_component_check_passed');
        } else {
            $out = "<span class='red'>$out</span>";
        }
        if (file_exists(C\BASE_DIR."/configs/LocalConfig.php")) {
            $out .= "<br>".tl('system_component_using_local_config');
        }
        return $out;
     }
}
X