<?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
 *
 * Main web interface entry point for Yioop!
 * search site. Used to both get and display
 * search results. Also used for inter-machine
 * communication during crawling
 *
 * @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;

use seekquarry\atto as A;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\mail as ML;
use seekquarry\yioop\models as M;
use seekquarry\yioop\library\certificates as LC;

/**
 * Main entry point to the Yioop web app.
 *
 * Initialization is done in  a function to avoid polluting the global
 * namespace with variables.
 * @param object $web_site optional WebSite instance when running
 *      under the bundled CLI server (atto_servers/WebSite); null
 *      under a conventional PHP-FPM / Apache deployment
 * @param bool $start_new_session whether to start a session or not
 */
function bootstrap($web_site = null, $start_new_session = true)
{
    /* check if mobile css and formatting should be used or not */
    if (isset($_SERVER['HTTP_USER_AGENT'])) {
        $agent = $_SERVER['HTTP_USER_AGENT'];
        if (stristr($agent, "mobile") &&
            !stristr($agent, "ipad") ) {
            $_SERVER["MOBILE"] = true;
        } else {
            $_SERVER["MOBILE"] = false;
        }
    } else {
        $_SERVER["MOBILE"] = false;
    }
    /**
     * Did we come to this index.php from ../index.php? If so, rewriting
     * must be on
     */
    if (!C\nsdefined("REDIRECTS_ON")) {
        C\nsdefine("REDIRECTS_ON", false);
    }
    /**
     * Check if doing url rewriting, and if so, do initial routing
     */
    configureRewrites($web_site);
    if ((C\p('DEBUG_LEVEL') & C\ERROR_INFO) == C\ERROR_INFO) {
        set_error_handler(C\NS_CONFIGS . "yioop_error_handler");
    }
    /**
     * Load global functions related to localization
     */
    require_once __DIR__ . "/library/LocaleFunctions.php";
    ini_set("memory_limit", C\INDEX_FILE_MEMORY_LIMIT);
    if (!empty($web_site)) {
        if ((empty($_REQUEST['c']) || $_REQUEST['c'] != 'resource')) {
            $web_site->header(
                "Content-Security-Policy: frame-ancestors 'self'");
                /* prevent click-jacking */
        }
        $web_site->header("X-Content-Type-Options: nosniff"); /*
        Let browsers know that we should be setting the mimetype correctly --
        For non dumb browsers this should help prevent against XSS attacks
        to images containing HTML. Also, might help against PRSSI attacks.
        */
        if ($start_new_session) {
            if (checkCookieConsent()) {
                /*
                    Keep PHP from garbage-collecting session data
                    before Yioop's own AUTOLOGOUT window elapses.
                    The PHP default for session.gc_maxlifetime is
                    1440 seconds (24 minutes); when AUTOLOGOUT is
                    longer than that (it defaults to 1 hour), an
                    idle session can be GC'd server-side while
                    its cookie is still valid, kicking the user
                    back to the signin screen well before
                    AUTOLOGOUT actually expires. Setting it here
                    so the session file's GC window matches.
                 */
                $gc_window = max(intval(C\p('AUTOLOGOUT')),
                    intval(ini_get('session.gc_maxlifetime')));
                if (!headers_sent()) {
                    ini_set('session.gc_maxlifetime',
                        (string)$gc_window);
                }
                $options = ['name' => C\p('SESSION_NAME'),
                    'cookie_lifetime' => C\p('COOKIE_LIFETIME'),
                    'cookie_httponly' => true,
                    'cookie_samesite' => C\SAME_SITE_COOKIE];
                if (C\nsdefined("SECURE_COOKIE") && C\SECURE_COOKIE) {
                    $options["cookie_secure"] = true;
                }
                if (intval(C\p('COOKIE_LIFETIME')) <= 0) {
                    $options['cookie_lifetime'] = C\p('AUTOLOGOUT');
                }
                if (isset($_REQUEST['cookieconsent'])) {
                    if (empty($_REQUEST['cookieconsent']) ||
                        $_REQUEST['cookieconsent'] == "false") {
                        /* remove cookie from browser if consent revoked */
                        $options["cookie_lifetime"] = - C\ONE_HOUR;
                    }
                }
                $web_site->sessionStart($options);
                if (!empty($_GET['cookieconsent'])) {
                    $uri = preg_replace("/cookieconsent\=[a-zA-Z]+(\&+)?/", "",
                        $_SERVER['REQUEST_URI']);
                    if (strlen($uri) > 1 && str_ends_with($uri, "?")) {
                        $uri = substr($uri, 0, -1);
                    }
                    $web_site->header("HTTP/1.0 301 Moved Permanently");
                    $web_site->header("Location:" . $uri);
                    A\webExit();
                }
            }
        }
    }
    /**
     * Load global functions related to checking Yioop! version
     */
    require_once C\BASE_DIR . "/library/UpgradeFunctions.php";
    if (!function_exists('mb_internal_encoding')) {
        echo "PHP Zend Multibyte Support must be enabled for Yioop! to run.";
        exit();
    }
    /**
     * Make an initial setting of controllers. This can be overridden in
     * configs/LocalConfig.php
     */
    $available_controllers = ["admin", "api", "archive",  "cache",
        "classifier", "crawl", "fetch", "group", "jobs", "machine", "resource",
        "search", "static"];
    /* The debug level is a setting a site owner changes while the
       server runs, so whether the test pages are offered is worked out
       from it here rather than taken from the constant settled when
       this process started. */
    if ((intval(C\p('DEBUG_LEVEL')) & C\TEST_INFO) == C\TEST_INFO) {
        $available_controllers[] = "tests";
    }
    if (function_exists(C\NS_CONFIGS . "localControllers")) {
        $available_controllers = array_merge($available_controllers,
            C\localControllers());
    }
    if (in_array(C\p('REGISTRATION_TYPE'), ['no_activation',
        'email_registration',
        'admin_activation'])) {
        $available_controllers[] = "register";
    }
    if (!C\p('WEB_ACCESS')) {
        $available_controllers = ["admin", "archive", "cache", "crawl",
            "fetch", "jobs", "machine"];
    }
    if (C\PROFILE && L\upgradeDatabaseWorkDirectoryCheck()) {
        /**
         * Load global functions needed to upgrade between versions
         * (note only do this if need to upgrade). Run before the
         * controller dispatch below, which may consult tables (such
         * as DOMAIN_ROUTE) that a pending upgrade introduces.
         */
        require_once C\BASE_DIR . "/library/VersionFunctions.php";
        L\upgradeDatabaseWorkDirectory();
    }
    /* the request variable c is used to determine the controller */
    if (!isset($_REQUEST['c'])) {
        $controller_name = "search";
        $route_group_id = 0;
        if (C\PROFILE && !isset($_REQUEST['q']) &&
            empty($_REQUEST['a'])) {
            /* a secure domain can be routed to serve a particular
               group's Main wiki page as its landing page (set in
               Server Settings); look the request host up before the
               site-wide landing page default is applied. Only a bare
               landing request is rewritten: requests carrying an
               action (for example /?a=signout, the sign out link)
               must keep their meaning on a routed domain
             */
            $host = requestHostNoPort();
            if ($host !== "") {
                $group_model = new M\GroupModel();
                $route_group_id =
                    $group_model->getDomainRouteGroupId($host);
            }
        }
        if ($route_group_id > 0) {
            /* a routed domain's landing page is served through the
               static controller so it renders as a forward-facing page
               like the site's own Main, rather than through the group
               controller's membership chrome. This holds for the Public
               group (world readable) and for any custom landing group an
               admin points a domain at in the Web Server field set. The
               resolved group id rides on a server variable the browser
               cannot set, since it is not an HTTP_ header, so only an
               admin-set DOMAIN_ROUTE, never a request parameter, can
               choose which group the static controller reads.
             */
            $controller_name = "static";
            $_REQUEST['c'] = "static";
            $_REQUEST['p'] = "Main";
            $_SERVER['ROUTE_GROUP_ID'] = $route_group_id;
        } else if (C\nsdefined('LANDING_PAGE') && C\p('LANDING_PAGE') &&
            !isset($_REQUEST['q'])) {
            $controller_name = "static";
            $_REQUEST['c'] = "static";
            $_REQUEST['p'] = "Main";
        }
    } else {
        $controller_name = $_REQUEST['c'];
    }
    if ($controller_name == "static" && C\PROFILE &&
        empty($_SERVER['ROUTE_GROUP_ID'])) {
        /* a routed domain serves not only its landing page but all of
           its static wiki pages through the static controller, so a link
           such as p/Bob on that domain must read the routed group's Bob
           page and not the site's Public one. Resolve the request host
           to its landing group here, covering the p/ clean urls and
           ?c=static requests the landing block above does not. The group
           comes from the admin-set DOMAIN_ROUTE table, so a request
           parameter cannot choose it.
         */
        $host = requestHostNoPort();
        if ($host !== "") {
            $group_model = $group_model ?? new M\GroupModel();
            $route_group_id = $group_model->getDomainRouteGroupId($host);
            if ($route_group_id > 0) {
                $_SERVER['ROUTE_GROUP_ID'] = $route_group_id;
            }
        }
    }
    if (!in_array($controller_name, $available_controllers))
    {
        $controller_name = "static";
        $_REQUEST['c'] = "static";
        $_REQUEST['p'] = "404";
    }
    /* if no profile exists we force the page to be the configuration page */
    if (!C\PROFILE || (C\nsdefined("FIX_NAME_SERVER") && C\FIX_NAME_SERVER)) {
        $controller_name = "admin";
    }
    $locale_tag = L\getLocaleTag();
    if (C\PROFILE && L\upgradeLocalesCheck($locale_tag)) {
        L\upgradeLocales();
        /* upgrade manipulations might mess with global local,
            so set it back here
         */
        L\setLocaleObject($locale_tag);
    }
    /**
     * Loads controller responsible for calculating
     * the data needed to render the scene
     *
     */
    $controller_class = C\NS_CONTROLLERS . ucfirst($controller_name) .
        "Controller";
    $controller = new $controller_class($web_site);
    /*
        Web mail pages read IMAP, signin verifies a password with a
        deliberately slow bcrypt hash, a wiki page can assemble a media
        list of a thousand or more resources, and registration and group
        actions send mail over SMTP. Managing machines fetches the status
        and logs of every configured machine, and a search query may fan
        out across index shards on other machines. Each can take long
        enough to freeze a single-process server, so run them through the
        cooperative path: the request runs inside a fiber that yields to
        the loop while the slow work happens, so one mailbox fetch, one
        login, one big page, one mail send, one machine round-up, or one
        search does not stall every other connection. deferResponse runs
        the work straight through under Apache or any other
        non-cooperative caller, so behavior there is unchanged.
        Attachment downloads stay on the ordinary path: they stream their
        bytes rather than render a page, and a streamed body is produced as
        the client reads it, which already leaves the loop free between
        pieces. A git service goes through the cooperative path as well:
        building a pack walks a repository's history, a long stretch with
        no waiting in it, and left on the ordinary path one pull held the
        whole site for minutes. Its reply is a file, which it names rather
        than prints, so the server drains it from disk instead of gathering
        the whole pack in memory.
     */
    $reads_mailbox = ($_REQUEST['a'] ?? '') === 'userMail' &&
        !in_array($_REQUEST['arg'] ?? '',
            ['downloadAttachment', 'downloadAllAttachments']);
    $checks_credentials = isset($_REQUEST['u']) && isset($_REQUEST['p']);
    $renders_wiki = ($_REQUEST['a'] ?? '') === 'wiki';
    $sends_mail = ($_REQUEST['c'] ?? '') === 'register' ||
        in_array($_REQUEST['a'] ?? '', ['groupFeeds', 'manageGroups']);
    $manages_machines = ($_REQUEST['a'] ?? '') === 'manageMachines';
    $runs_search = $controller_name === 'search';
    $serves_git = ($_REQUEST['a'] ?? '') === 'gitService';
    /* A whole large file asked for without a byte range is read out by
       the request itself under HTTP/1.1, which held the one process for
       as long as the download took: a hundred megabyte video stopped the
       site answering anything else for half a minute. Sent from the
       cooperative path the file is named rather than printed, and the
       loop drains it a piece at a time between serving everyone else. */
    $sends_resource = ($_REQUEST['c'] ?? '') === 'resource' &&
        empty($_SERVER['HTTP_RANGE']);
    if ($web_site !== null && ($reads_mailbox || $checks_credentials ||
        $renders_wiki || $sends_mail || $manages_machines ||
        $runs_search || $serves_git || $sends_resource)) {
        /* Building a pack takes longer than rendering a page, so it asks
           for more running time rather than every request being given a
           looser limit. */
        $budget = $serves_git ? C\GIT_SERVICE_WORK_SECONDS : null;
        $web_site->deferResponse(function () use ($controller) {
            $controller->processRequest();
        }, $budget);
    } else {
        $controller->processRequest();
    }
}
/**
 * Checks if a cookie consent form was obtained. This
 * This function returns true if a session cookie
 * was received from the browser, or a form variable
 * saying cookies are okay was received, or the cookie
 * Yioop profile says the consent mechanism is disabled.
 * An Appearance domain switch counts too: it carries a
 * handoff token for an admin who is already signed in and
 * using cookies on the domain they came from, and the
 * domain they land on needs a session to sign them into.
 *
 * @return bool cookie consent (true) else false
 */
function checkCookieConsent()
{
    if (C\PROFILE && intval(C\p('COOKIE_LIFETIME')) > 0 &&
        empty($_COOKIE[C\p('SESSION_NAME')])
        && empty($_REQUEST['cookieconsent'])
        && empty($_REQUEST['domain_handoff'])) {
        $_SERVER["COOKIE_CONSENT"] = false;
        return false;
    }
    return true;
}
/**
 * Used to setup and handles url rewriting for the Yioop Web app
 *
 * Developers can add new routes by creating a Routes class in
 * the app_dir with a static method getRoutes which should return
 * an associating array of incoming_path => handler function
 * @param object $web_site used to send error pages if configuration
 *  fails
 */
function configureRewrites($web_site)
{
    /*
        In secure-server mode the plain port-80 listener exists only
        to answer the ACME HTTP-01 challenge and to send everyone
        else to HTTPS. A request that arrived on 80 has no HTTPS
        server flag (the 443 listener sets it). Redirect such
        requests to the https form of the same host and path, except
        the acme-challenge path, which the certificate authority
        must reach over plain HTTP during validation.
     */
    if (C\nsdefined("IS_SECURE_SERVER") && C\IS_SECURE_SERVER &&
        empty($_SERVER['HTTPS'])) {
        $request_uri = $_SERVER['REQUEST_URI'] ?? '/';
        $is_acme = strpos($request_uri,
            '/.well-known/acme-challenge/') !== false;
        if (!$is_acme) {
            $host = $_SERVER['HTTP_HOST'] ??
                ($_SERVER['SERVER_NAME'] ?? '');
            /*
                Drop any port from the Host header (for example a
                literal host:80) so the redirect target is the bare
                https host, not https://host:80. An IPv6 literal in
                brackets keeps its colons; only a trailing :port is
                removed.
             */
            if ($host !== '' && $host[0] !== '[') {
                $colon = strrpos($host, ':');
                if ($colon !== false) {
                    $host = substr($host, 0, $colon);
                }
            } else if ($host !== '' && $host[0] === '[') {
                $close = strpos($host, ']');
                if ($close !== false) {
                    $host = substr($host, 0, $close + 1);
                }
            }
            $web_site->header("HTTP/1.1 301 Moved Permanently");
            $web_site->header("Location: https://" . $host .
                $request_uri);
            A\webExit();
            return;
        }
    }
    /*
        Under the command-line server, which can hold a socket open, offer
        a WebSocket channel for the conversation refresh so a new message
        reaches the browser the moment it lands instead of on the reconnect
        cycle the event-stream path waits through. The route is registered
        once; a server that cannot hold a socket does not reach this, and
        its browser keeps to the event-stream path. registerMessageWs holds
        the handler.
     */
    $route_map = [
        'wd' => 'routeAppFile',
        'favicon.ico' => 'routeBaseFile',
        'robots.txt' => 'routeRobotsTxt',
        'humans.txt' => 'routeHumansTxt',
        '.well-known' => 'routeWellKnown',
        'yioopbar.xml' => 'routeBaseFile',
        'css' => 'routeBaseFile',
        'locale' => 'routeBaseFile',
        'resources' => 'routeBaseFile',
        'scripts' => 'routeBaseFile',
        'blog' => 'routeBlog',
        'admin' => 'routeController',
        'register' => 'routeController',
        'tests' => 'routeController',
        'advertise' => 'routeDirect',
        'bot' => 'routeDirect',
        'privacy' => 'routeDirect',
        'terms' => 'routeDirect',
        'group' => 'routeFeeds',
        'thread' => 'routeFeeds',
        'user' => 'routeFeeds',
        'user_messages' => 'routeUserMessages',
        'user_mail' => 'routeUserMail',
        's' => "routeSubsearch",
        'serp_icon' => 'routeSerpIcon',
        'suggest' => 'routeSuggest',
        'p' => 'routeWiki'
    ];
    if (class_exists(C\NS . "Routes")) {
        $route_map = array_merge($route_map, Routes::getRoutes());
    }
    /**
     * Check for paths of the form index.php/something which yioop doesn't
     * support
     */
    $s_name = $_SERVER['SCRIPT_NAME'] . "/";
    $path_name = substr($_SERVER["REQUEST_URI"], 0, strlen($s_name));
    if (strcmp($path_name, $s_name) == 0) {
        $_SERVER["PATH_TRANSLATED"] = C\BASE_DIR;
        $script_info = pathinfo($s_name);
        $_SERVER["PATH_INFO"] = ($script_info["dirname"] == "/") ? "" :
            $script_info["dirname"] ;
        $error = directUrl("error", false, true);
        $web_site->header("Location: $error");
        A\webExit();
    }
    if (!isset($_SERVER["PATH_INFO"])) {
        $_SERVER["PATH_INFO"] = ".";
    }
    if (!C\REDIRECTS_ON) {
        return;
    }
    /**
     * Now look for and handle routes
     */
    $index_php = "index.php";
    if ((php_sapi_name() == 'cli')) {
        $script_path = "/";
    } else {
        $script_path = substr($_SERVER['PHP_SELF'], 0, -strlen($index_php));
    }
    $request_script = "";
    if (empty($_SERVER['QUERY_STRING'])) {
        $request_script = rtrim(
            substr($_SERVER['REQUEST_URI'], strlen($script_path)), "?");
    } else {
        $q_pos = strpos($_SERVER['REQUEST_URI'], "?");
        if ($q_pos !== false) {
            $request_script = substr($_SERVER['REQUEST_URI'], 0,
                $q_pos);
        }
        $request_script = substr($request_script, strlen($script_path));
    }
    $request_script = ($request_script == "") ? $index_php : $request_script;
    if (in_array($request_script, ['', '/', $index_php])) {
        return;
    }
    $request_parts = explode("/", $request_script);
    $handled = false;
    if (isGitRepositoryRequest($request_parts)) {
        $handled = routeGit($request_parts);
    }
    if (!$handled && isset($route_map[$request_parts[0]])) {
        if (empty($_REQUEST['c']) || $_REQUEST['c'] == $request_parts[0]) {
            $route = C\NS . $route_map[$request_parts[0]];
            $handled = $route($request_parts, $web_site);
        } else if (!empty($_REQUEST['c'])) {
            $handled = true;
        }
    }
    if (!$handled) {
        $error = directUrl("error", false, true);
        $error_location = "Location:" . $error;
        if (!C\REDIRECTS_ON) {
            $error_location .= ".php";
        }
        if ($request_parts[0] != "error" && $request_parts[0] != "error.php") {
            $web_site->header($error_location);
            return;
        }
        /* A request can say which of the site's error pages it wants,
           so that somewhere turning a reader away for a reason of its
           own, such as a page being none of their business, can say that
           reason rather than let everything read as not there. Only a
           page the site keeps for the purpose can be asked for, so this
           cannot be used to reach an ordinary page or to claim a status
           the site does not send. */
        $error_page = "404";
        $wanted_page = $_REQUEST['p'] ?? "";
        if (is_string($wanted_page) &&
            isset(C\ERROR_PAGE_STATUSES[$wanted_page])) {
            $error_page = $wanted_page;
        }
        $web_site->header(C\ERROR_PAGE_STATUSES[$error_page]);
        $route_args = [$error_page];
        routeDirect($route_args);
    }
}
/**
 * Used to handle routes that will eventually just serve
 * files from either the APP_DIR
 * These include files like css, scripts, suggest tries, images, and videos.
 * @param array $route_args of url parts (split on slash)
 * @return bool whether was able to compute a route or not
 */
function routeAppFile($route_args)
{
    if (empty($route_args[2])) {
        return false;
    }
    $num_args = count($route_args);
    $_REQUEST['route']['c'] = true;
    $_REQUEST['c'] = "resource";
    $_REQUEST['route']['a'] = true;
    $_REQUEST['a'] = "get";
    if ($route_args[1] == 'suggest') {
        $_REQUEST['a'] = "suggest";
        $_REQUEST['route']['locale'] = true;
        $_REQUEST['locale'] = $route_args[2];
        return true;
    } else if ($route_args[1] == 'users') {
        $_REQUEST['route']['f'] = true;
        $_REQUEST['f'] = "resources";
        $_REQUEST['route']['s'] = true;
        $_REQUEST['s'] = $route_args[2];
        $_REQUEST['n'] = 'user_icon.webp';
        $_REQUEST['route']['n'] = true;
        return true;
    } else if (in_array($route_args[1], ['css', 'scripts', 'locale'])) {
        $_REQUEST['route']['f'] = true;
        $_REQUEST['f'] = $route_args[1];
        $_REQUEST['route']['n'] = true;
        $rest_args = array_slice($route_args, 2);
        if ($route_args[1] == 'css' && count($rest_args) > 1) {
            /* the themes an admin has added sit in a folder of their
               own under the app css folder, so the part of a theme's url
               before the file name is the folder to read it from */
            $_REQUEST['route']['sf'] = true;
            $_REQUEST['sf'] = array_shift($rest_args);
        }
        $_REQUEST['n'] = implode("/", $rest_args);
        return true;
    } else if (in_array($route_args[1], ["athumbs", "resources", "thumbs"])) {
        $_REQUEST['route']['f'] = true;
        $_REQUEST['f'] = "resources";
        if (in_array($route_args[1], ["thumbs", "athumbs"])) {
            if ($num_args < 6) {
                return false;
            }
            $_REQUEST['route']['t'] = true;
            $_REQUEST['t'] = rtrim($route_args[1], "s");
        }
        if ($num_args == 3) {
            $_REQUEST['route']['n'] = true;
            $_REQUEST['n'] = $route_args[2];
            return true;
        } else if ($num_args == 4 && $route_args[1] == "resources") {
            /* /wd/resources/<subfolder>/<file>: a resource in a
               named subfolder of the resources directory, used by
               per-domain appearance images which live in a
               per-domain subfolder. The thumbs/athumbs forms always
               carry the group/page token shape (num_args >= 6), so
               this shorter form is resources-only. */
            $_REQUEST['route']['sf'] = true;
            $_REQUEST['sf'] = urldecode($route_args[2]);
            $_REQUEST['route']['n'] = true;
            $_REQUEST['n'] = $route_args[3];
            return true;
        } else if ($num_args >= 6) {
            /* The token segment takes three forms: "-" for no token,
               the full "YIOOP_TOKEN=value" pair, and the bare value
               alone. The bare form is what the message renderer writes
               into a recording's address, since a name=value pair
               sitting in a path segment read as an invalid address
               and the recording would not play. */
            $token_parts = explode("=", $route_args[2]);
            if (count($token_parts) == 1 && $route_args[2] != "-") {
                $token_parts = [C\p('CSRF_TOKEN'), $route_args[2]];
            }
            if (count($token_parts) != 2 && $route_args[2] != "-") {
                return false;
            }
            $_REQUEST['route'][$token_parts[0]] = true;
            if (count($token_parts) == 2) {
                $_REQUEST[$token_parts[0]] = $token_parts[1];
            }
            $_REQUEST['route']['g'] = true;
            $_REQUEST['g'] = $route_args[3];
            $_REQUEST['route']['p'] = true;
            $_REQUEST['p'] = $route_args[4];
            if ($num_args == 6) {
                $_REQUEST['route']['n'] = true;
                $_REQUEST['n'] = $route_args[5];
                return true;
            }
            if ($num_args >= 7) {
                $_REQUEST['route']['n'] = true;
                $_REQUEST['n'] = array_pop($route_args);
                $path = implode("/", array_slice($route_args, 5));
                $_REQUEST['route']['sf'] = true;
                $_REQUEST['sf'] = urldecode(urldecode($path));
                return true;
            }
        }
    }
    return false;
}
/**
 * Used to handle routes that will eventually just serve
 * files from either the BASE_DIR
 * These include files like css, scripts, images, and robots.txt.
 * @param array $route_args of url parts (split on slash).
 * @return bool whether was able to compute a route or not
 */
function routeBaseFile($route_args)
{
    $_REQUEST['route']['c'] = true;
    $_REQUEST['c'] = "resource";
    $_REQUEST['route']['a'] = true;
    $_REQUEST['a'] = "get";
    $_REQUEST['route']['b'] = true;
    $_REQUEST['b'] = $route_args[0];
    if (count($route_args) == 1) {
        $_REQUEST['route']['n'] = true;
        $_REQUEST['n'] = '-';
    } else {
        $_REQUEST['route']['n'] = true;
        array_shift($route_args);
        $_REQUEST['n'] = implode("/", $route_args);
    }
    return true;
}
/**
 * Serves the MTA-STS policy file (RFC 8461) at the well-known
 * path /.well-known/mta-sts.txt. Sending mail servers fetch this
 * over HTTPS from the dedicated host mta-sts.<domain> to learn
 * whether this site asks them to require TLS when delivering to
 * <domain>. This route is only reached when clean URLs
 * (REDIRECTS_ON) are on, since the rooted path depends on
 * rewriting; that matches the Server Settings rule that the
 * secure delivery postures need clean URLs.
 *
 * The policy is emitted directly as text/plain and the script
 * exits, rather than dispatched to a controller, because the body
 * is a few generated lines with no template, session, or CSRF
 * involvement. The mode comes from the delivery-security posture
 * ('spam' publishes testing, 'require' publishes enforce,
 * 'insecure' publishes nothing); the policy domain comes from the
 * request host (mta-sts.<configured-domain>, or the first
 * configured domain for a loopback test host). When the path is
 * not mta-sts.txt, the host does not resolve to a configured
 * domain, or the posture publishes no policy, this returns false
 * so the normal not-found handling applies.
 *
 * @param array $route_args url parts split on slash; the first is
 *      '.well-known' and the second should be 'mta-sts.txt'
 * @param object $web_site the WebSite server, used for its header
 *      method so the response is buffered and flushed correctly
 * @return bool false when no policy is served (fall through to the
 *      404 path); on the served path the response is emitted and
 *      webExit (atto) ends the request
 */
function routeWellKnown($route_args, $web_site)
{
    /*
        ACME HTTP-01 challenge: the certificate authority fetches
        /.well-known/acme-challenge/<token> over port 80 during
        validation and expects the token's key authorization back as
        plain text. The token is the path segment after
        acme-challenge; AcmeChallengeStore maps it to the key
        authorization the issuing process recorded. An unknown or
        malformed token returns 404 (the store rejects any token
        that is not a base64url string, so no path traversal is
        possible).
     */
    if (!empty($route_args[1]) && $route_args[1] === 'acme-challenge') {
        $token = $route_args[2] ?? '';
        $store = new LC\AcmeChallengeStore();
        $key_authorization = $store->get($token);
        if ($key_authorization === null) {
            $web_site->header("HTTP/1.1 404 Not Found");
            A\webExit();
            return true;
        }
        $web_site->header("HTTP/1.1 200 OK");
        $web_site->header("Content-Type: application/octet-stream");
        $web_site->header("Content-Length: " .
            strlen($key_authorization));
        echo $key_authorization;
        A\webExit();
        return true;
    }
    if (empty($route_args[1]) || $route_args[1] !== 'mta-sts.txt') {
        return false;
    }
    $domains = ML\MailSiteFactory::localDomains();
    $host = $_SERVER['HTTP_HOST'] ?? '';
    $domain = ML\MailSiteFactory::mtaStsDomainForHost($host,
        $domains);
    if ($domain === '') {
        return false;
    }
    $delivery_security = C\nsdefined("MAIL_DELIVERY_SECURITY") ?
        C\p('MAIL_DELIVERY_SECURITY') : 'insecure';
    $mode = ML\MailSiteFactory::mtaStsMode($delivery_security);
    $policy = ML\MailSiteFactory::mtaStsPolicy($mode, $domain);
    if ($policy === '') {
        return false;
    }
    /* Use the WebSite object's header method and webExit so the
       response goes through its output buffer; when Yioop runs as
       its own web server the headers are buffered and flushed with
       the body, so PHP's bare header()/exit would arrive after
       output had already started. */
    $web_site->header("Content-Type: text/plain; charset=utf-8");
    $web_site->header("Content-Length: " . strlen($policy));
    echo $policy;
    A\webExit();
}
/**
 * Looks up the saved robots or humans setting for the domain in the
 * current request. Reads the per-domain appearance overrides and
 * returns whatever is stored under the given key, or an empty value
 * when the domain has nothing saved.
 *
 * @param string $key either 'ROBOTS' (the saved crawl choices array)
 *      or 'HUMANS' (the humans.txt text)
 * @return mixed the saved value, or [] for ROBOTS and "" for HUMANS
 *      when the domain has no saved value
 */
function robotsHumansForHost($key)
{
    $default = ($key == 'ROBOTS') ? [] : "";
    $host = strtolower($_SERVER['HTTP_HOST'] ?? "");
    $colon = strrpos($host, ':');
    if ($colon !== false && strpos($host, ']') === false) {
        $host = substr($host, 0, $colon);
    }
    if ($host === "") {
        return $default;
    }
    $group_model = new M\GroupModel();
    $appearance = $group_model->getDomainAppearance($host);
    return $appearance[$key] ?? $default;
}
/**
 * Serves the robots.txt file for the domain in the request. The body
 * is built fresh from the crawl choices saved for that domain (a
 * domain with no saved choices gets a sensible default that keeps
 * crawlers out of search-result addresses). The text is written
 * through the WebSite object so its headers buffer with the body when
 * Yioop is running as its own web server.
 *
 * @param array $route_args path pieces of the request (not used; the
 *      domain comes from the request host)
 * @param object $web_site WebSite object used to send headers and end
 *      the response
 * @return bool always true, the request is fully handled here
 */
function routeRobotsTxt($route_args, $web_site)
{
    $config = robotsHumansForHost('ROBOTS');
    $body = L\RobotsTxtGenerator::generate($config);
    $web_site->header("Content-Type: text/plain; charset=utf-8");
    $web_site->header("Content-Length: " . strlen($body));
    echo $body;
    A\webExit();
    return true;
}
/**
 * Serves the humans.txt file for the domain in the request. The body
 * is the free-form humans.txt text saved for that domain, or empty
 * when nothing is saved. The text is written through the WebSite
 * object so its headers buffer with the body when Yioop is running as
 * its own web server.
 *
 * @param array $route_args path pieces of the request (not used; the
 *      domain comes from the request host)
 * @param object $web_site WebSite object used to send headers and end
 *      the response
 * @return bool always true, the request is fully handled here
 */
function routeHumansTxt($route_args, $web_site)
{
    $humans = robotsHumansForHost('HUMANS');
    $body = is_string($humans) ? $humans : "";
    $web_site->header("Content-Type: text/plain; charset=utf-8");
    $web_site->header("Content-Length: " . strlen($body));
    echo $body;
    A\webExit();
    return true;
}
/**
 * Used to route page requests to pages that are fixed Public Group wiki
 * that should always be present. For example, 404 page.
 *
 * @param array $route_args of url parts (split on slash).
 * @return bool whether was able to compute a route or not
 */
function routeDirect($route_args)
{
    $_REQUEST['route']['c'] = true;
    $_REQUEST['c'] = "static";
    $_REQUEST['route']['p'] = true;
    $_REQUEST['p'] = $route_args[0];
    return true;
}
/**
 * Given the name of a fixed public group static page creates the url
 * where it can be accessed in this instance of Yioop, making use of the
 * defined variable REDIRECTS_ON.
 *
 * @param string $name of static page
 * @param bool $with_delim whether it should be terminated with nothing or
 *      ? or &
 * @param bool $with_base_url whether to use SHORT_BASE_URL or BASE_URL (true).
 * @return string url for the page in question
 */
function directUrl($name, $with_delim = false, $with_base_url = false)
{
    $base_url = ($with_base_url) ? C\baseUrl() : C\SHORT_BASE_URL;
    if (C\REDIRECTS_ON) {
        $delim = ($with_delim) ? "?" : "";
        return $base_url . $name . $delim;
    } else {
        $delim = ($with_delim) ? "&" : "";
        return "$base_url$name.php$delim";
    }
}
/**
 * Used to route page requests to for the website's public blog
 *
 * @param array $route_args of url parts (split on slash).
 * @return bool whether was able to compute a route or not
 */
function routeBlog($route_args)
{
    $_REQUEST['route']['c'] = true;
    $_REQUEST['c'] = "group";
    $_REQUEST['route']['a'] = true;
    $_REQUEST['a'] = "groupFeeds";
    $_REQUEST['route']['just_group_id'] = true;
    $_REQUEST['just_group_id'] = 2;
    return true;
}
/**
 * Reports whether a request path looks like a request for a git wiki page's
 * repository. Such a path is a numeric group id then a page name ending in
 * ".git", for example 212/Main.git/info/refs, and may also carry a leading
 * "group" segment to match the wiki page's own address, as in
 * group/212/Main.git/info/refs.
 *
 * @param array $request_parts request path split on slashes
 * @return bool true if the path is for a git repository
 */
function isGitRepositoryRequest($request_parts)
{
    if (isset($request_parts[0]) && $request_parts[0] === "group" &&
        isset($request_parts[1]) && ctype_digit($request_parts[1]) &&
        isset($request_parts[2]) &&
        str_ends_with($request_parts[2], ".git")) {
        return true;
    }
    return isset($request_parts[0]) && isset($request_parts[1]) &&
        $request_parts[0] !== "" && ctype_digit($request_parts[0]) &&
        str_ends_with($request_parts[1], ".git");
}
/**
 * Turns a git repository request path into the group-controller git activity
 * request that serves it. Any leading "group" segment is dropped first, then
 * the group id, the page name with its ".git" suffix removed, and the path
 * within the repository are read out of the path and placed where the git
 * activity expects to find them.
 *
 * @param array $request_parts request path split on slashes
 * @return bool always true, since a git path is handled here
 */
function routeGit($request_parts)
{
    if (isset($request_parts[0]) && $request_parts[0] === "group") {
        array_shift($request_parts);
    }
    $_REQUEST['c'] = "group";
    $_REQUEST['route']['c'] = true;
    $_REQUEST['a'] = "gitService";
    $_REQUEST['route']['a'] = true;
    $_REQUEST['group_id'] = $request_parts[0];
    $_REQUEST['route']['group_id'] = true;
    $_REQUEST['page_name'] = substr($request_parts[1], 0, -strlen(".git"));
    $_REQUEST['route']['page_name'] = true;
    $_REQUEST['sf'] = implode("/", array_slice($request_parts, 2));
    $_REQUEST['route']['sf'] = true;
    return true;
}
/**
 * Used to route requests for a group or thread feed.
 *
 * @param array $route_args of url parts (split on slash)
 * @return bool whether was able to compute a route or not
 */
function routeFeeds($route_args)
{
    $handled = true;
    if (isset($route_args[1]) && $route_args[1] == intval($route_args[1])) {
        $_REQUEST['c'] = "group";
        if (!empty($route_args[2])) {
            $_REQUEST['a'] = 'wiki';
            if ($route_args[2] == 'pages') {
                $_REQUEST['arg'] = 'pages';
                $_REQUEST['route']['arg'] = true;
                /* A name after pages narrows that same list to one
                   category. It travels as the rest of the path, the way
                   every other router carries what follows an address, so
                   it reaches the activity like any other path part. */
                if (empty($_REQUEST['sf']) && !empty($route_args[3])) {
                    $_REQUEST['sf'] = implode("/",
                        array_slice($route_args, 3));
                    $_REQUEST['route']['sf'] = true;
                }
                /* A name after pages says which category the list is
                   narrowed to, so a reader can reach everything a group
                   files one way from one address. */
                if (!empty($route_args[3])) {
                    /* The name is not marked as having come from the
                       address: a field so marked is left out of any
                       address the site builds back, and a stranger's
                       first request is answered with such an address, so
                       the category was lost before the list was drawn. */
                    $_REQUEST['list_category'] = $route_args[3];
                }
            } else {
                if (empty($_REQUEST['page_name'])) {
                    $_REQUEST['page_name'] = $route_args[2];
                    $_REQUEST['route']['page_name'] = true;
                }
                if (empty($_REQUEST['sf']) && !empty($route_args[3]) ) {
                    $rest = array_slice($route_args, 3);
                    $_REQUEST['sf'] = implode("/", $rest);
                    $_REQUEST['route']['sf'] = true;
                }
            }
        }
        $_REQUEST['a'] = (isset($_REQUEST['a']) &&
            $_REQUEST['a'] == 'wiki') ? 'wiki' : "groupFeeds";
        $_REQUEST['route']['c'] = true;
        $_REQUEST['route']['a'] = true;
        $end = ($route_args[0] == 'thread') ? "" : "_id";
        if ($_REQUEST['a'] == 'wiki') {
            $_REQUEST['group_id'] = $route_args[1];
            $_REQUEST['route']['group_id'] = true;
        } else {
            $just_id = "just_" . $route_args[0] . $end;
            $_REQUEST[$just_id] = $route_args[1];
            $_REQUEST['route'][$just_id] = true;
        }
    } else if (!isset($route_args[1])) {
        $_REQUEST['c'] = "group";
        $_REQUEST['a'] = (isset($_REQUEST['a']) &&
            $_REQUEST['a'] == 'wiki') ? $_REQUEST['a'] : "groupFeeds";
        $_REQUEST['route']['c'] = true;
        $_REQUEST['route']['a'] = true;
    } else {
        $handled = false;
    }
    return $handled;
}
/**
 * Given the type of feed, the identifier of the feed instance, and which
 * controller is being used creates the url where that feed item can be
 * accessed from the instance of Yioop. It makes use of the
 * defined variable REDIRECTS_ON.
 *
 * @param string $type of feed: group, user, user messages, thread
 * @param int $id the identifier for that feed.
 * @param bool $with_delim whether it should be terminated with nothing or
 *      ? or &
 * @param string $controller which controller is being used to access the
 *      feed: usually admin or group
 * @param bool $use_short_base_url whether to create the url as a relative
 *   url using C\SHORT_BASE_URL or as a full url using  C\baseUrl()
 *   (the latter is useful for mail notifications)
 * @return string url for the page in question
 */
function feedsUrl($type, $id, $with_delim = false, $controller = "group",
    $use_short_base_url = true)
{
    $base_url = ($use_short_base_url) ? C\SHORT_BASE_URL : C\baseUrl();
    if (C\REDIRECTS_ON && $controller == 'group') {
        $delim = ($with_delim) ? "?" : "";
        $path = ($type == "") ? "group" : "$type/$id";
        return "$base_url$path$delim";
    } else {
        $delim = ($with_delim) ? "&" : "";
        $begin = (C\REDIRECTS_ON && $controller == "admin") ?
            "admin?" : "?c=$controller&";
        $query = "{$begin}a=groupFeeds";
        $end = ($type == 'thread') ? "" : "_id";
        if ($type != "") {
            if ($begin == "admin?" && $type == "group") {
                $query = "admin/$id";
                $delim = "?";
            } else {
                $query .= "&just_{$type}$end=$id";
            }
        }
        return "$base_url$query$delim";
    }
}
/**
 *
 * @param array $route_args of url parts (split on slash).
 * @return bool whether was able to compute a route or not
 */
function routeUserMessages($route_args)
{
    $handled = true;
    $_REQUEST['c'] = "group";
    $_REQUEST['a'] = 'userMessages';
    $_REQUEST['route']['c'] = true;
    $_REQUEST['route']['a'] = true;
    if (isset($route_args[1]) && $route_args[1] == intval($route_args[1])) {
        $_REQUEST['contact_id'] = $route_args[1];
        $_REQUEST['route']['contact_id'] = true;
    }
    return $handled;
}
/**
 * Used to route static file (or short alias) requests for the user_mail
 * URL to the GroupController's userMail activity. Mirrors routeUserMessages.
 *
 * @param array $route_args of url parts (split on slash)
 * @return bool whether was able to compute a route or not
 */
function routeUserMail($route_args)
{
    $handled = true;
    $_REQUEST['c'] = "admin";
    $_REQUEST['a'] = 'userMail';
    $_REQUEST['route']['c'] = true;
    $_REQUEST['route']['a'] = true;
    return $handled;
}
/**
 * Used to route page requests to end-user controllers such as
 * register, admin. urls ending with /controller_name will
 * be routed to that controller.
 *
 * @param array $route_args of url parts (split on slash).
 * @return bool whether was able to compute a route or not
 */
function routeController($route_args)
{
    $_REQUEST['c'] = $route_args[0];
    $_REQUEST['route']['c'] = true;
    if (isset($route_args[1]) && intval($route_args[1]) == $route_args[1]) {
        if (isset($_REQUEST['a']) && $_REQUEST['a'] == 'wiki') {
            $_REQUEST['group_id'] = $route_args[1];
        } else if (!empty($route_args[2])) {
            $_REQUEST['a'] = 'wiki';
            $_REQUEST['group_id'] = $route_args[1];
            if ($route_args[2] == 'pages') {
                $_REQUEST['arg'] = 'pages';
                $_REQUEST['route']['arg'] = true;
            } else {
                $_REQUEST['page_name'] = $route_args[2];
                if (empty($_REQUEST['sf']) && !empty($route_args[3]) ) {
                    $rest = array_slice($route_args, 3);
                    $_REQUEST['sf'] = implode("/", $rest);
                    $_REQUEST['route']['sf'] = true;
                }
                $_REQUEST['route']['page_name'] = true;
            }
            $_REQUEST['route']['page_name'] = true;
            $_REQUEST['route']['a'] = true;
        } else {
            $_REQUEST['a'] = 'groupFeeds';
            $_REQUEST['just_group_id'] = $route_args[1];
        }
        $_REQUEST['route']['group_id'] = true;
    }
    return true;
}
/**
 * Given the name of a controller for which an easy end-user link is useful
 * creates the url where it can be accessed on this instance of Yioop,
 * making use of the defined variable REDIRECTS_ON. Examples of end-user
 * controllers would be the admin, and register controllers.
 *
 * @param string $name of controller
 * @param bool $with_delim whether it should be terminated with nothing or
 *      ? or &
 * @return string url for the page in question
 */
function controllerUrl($name, $with_delim = false)
{
    $base_url = C\SHORT_BASE_URL;
    if (C\REDIRECTS_ON) {
        $delim = ($with_delim) ? "?" : "";
        return $base_url . $name . $delim;
    } else {
        $delim = ($with_delim) ? "&" : "";
        return $base_url . "?c=$name$delim";
    }
}
/**
 * Used to route page requests for subsearches such as news, video, and images
 * (site owner can define other). Urls of the form /s/subsearch will
 * go the page handling the subsearch.
 *
 * @param array $route_args of url parts (split on slash).
 * @return bool whether was able to compute a route or not
 */
function routeSubsearch($route_args)
{
    $handled = true;
    if (isset($route_args[1])) {
        $_REQUEST['route']['c'] = true;
        $_REQUEST['route']['s'] = true;
        $_REQUEST['c'] = "search";
        $_REQUEST['s'] = $route_args[1];
    } else {
        $handled = false;
    }
    return $handled;
}
/**
 * Given the name of a subsearch  creates the url where it can be accessed
 * on this instance of Yioop, making use of the defined variable REDIRECTS_ON.
 * Examples of subsearches include news, video, and images. A site owner
 * can add to these and delete from these.
 *
 * @param string $name of subsearch
 * @param bool $with_delim whether it should be terminated with nothing or
 *      ? or &
 * @return string url for the page in question
 */
function subsearchUrl($name, $with_delim = false)
{
    $base_url = C\SHORT_BASE_URL;
    if (C\REDIRECTS_ON) {
        $delim = ($with_delim) ? "?" : "";
        return $base_url ."s/$name$delim";
    } else {
        $delim = ($with_delim) ? "&" : "";
        return "$base_url?s=$name$delim";
    }
}
/**
 * Used to route requests for favicons for pages in search results
 *
 * @param array $route_args of url parts (split on slash).
 * @return bool whether was able to compute a route or not
 */
function routeSerpIcon($route_args)
{
    $_REQUEST['c'] = "search";
    $_REQUEST['a'] = "cache";
    $_REQUEST['field'] = "favicon";
    $_REQUEST['arg'] = L\webdecode($route_args[2] ?? "");
    $_REQUEST['its'] = $route_args[1] ?? 0;
    $_REQUEST['route']['c'] = true;
    $_REQUEST['route']['a'] = true;
    $_REQUEST['route']['arg'] = true;
    $_REQUEST['route']['field'] = true;
    $_REQUEST['route']['its'] = true;
    return true;
}
/**
 * Return the url to repquest the favicon for a page in the search resutls,
 * making use of the defined variable REDIRECTS_ON.
 *
 * @param string $url URL of the page whose favicon URL should be
 *      built (will be URL-encoded into the returned link)
 * @param int|string $crawl_time crawl timestamp the icon should be
 *      pulled out of
 * @param bool $with_delim whether it should be terminated with nothing or
 *      ? or &
 * @return string url for the page in question
 */
function serpIconUrl($url, $crawl_time, $with_delim = false)
{
    $base_url = C\SHORT_BASE_URL;
    if (C\REDIRECTS_ON) {
        $delim = ($with_delim) ? "?" : "";
        return $base_url . "serp_icon/$crawl_time/" . L\webencode($url) .
            "$delim";
    } else {
        $delim = ($with_delim) ? "&" : "";
        return "$base_url?c=search&a=cache&field=favicon&its=$crawl_time&".
            "arg=" . urlencode($url) . $delim;
    }
}
/**
 * Used to route requests for the suggest-a-url link on the tools page.
 * If redirects on, then /suggest routes to this suggest-a-url page.
 *
 * @param array $route_args of url parts (split on slash).
 * @return bool whether was able to compute a route or not
 */
function routeSuggest($route_args)
{
    $_REQUEST['c'] = "register";
    $_REQUEST['a'] = "suggestUrl";
    return true;
}
/**
 * Return the url for the suggest-a-url link on the more tools page, making use
 * of the defined variable REDIRECTS_ON.
 *
 * @param bool $with_delim whether it should be terminated with nothing or
 *      ? or &
 * @return string url for the page in question
 */
function suggestUrl($with_delim = false)
{
    $base_url = C\SHORT_BASE_URL;
    if (C\REDIRECTS_ON) {
        $delim = ($with_delim) ? "?" : "";
        return $base_url ."suggest$delim";
    } else {
        $delim = ($with_delim) ? "&" : "";
        return "$base_url?c=register&a=suggestUrl$delim";
    }
}
/**
 * Used to route page requests for pages corresponding to a wiki page of
 * group. If it is a wiki page for the public group viewed without being
 * logged in, the route might come in as yioop_instance/p/page_name if
 * redirects are on. If it is for a non-public wiki or page accessed with
 * logged in the url will look like either:
 * yioop_instance/group/group_id?a=wiki&page_name=some_name
 * or
 * yioop_instance/admin/group_id?a=wiki&page_name=some_name&csrf_token_string
 *
 * @param array $route_args of url parts (split on slash).
 * @return bool whether was able to compute a route or not
 */
function routeWiki($route_args)
{
    $handled = true;
    if (isset($route_args[1])) {
        if ($route_args[1] == 'pages') {
            $_REQUEST['c'] = "group";
            $_REQUEST['a'] = 'wiki';
            $_REQUEST['arg'] = 'pages';
            $_REQUEST['route']['c'] = true;
            $_REQUEST['route']['a'] = true;
            $_REQUEST['route']['arg'] = true;
            /* As above, a name after pages narrows the list to one
               category and travels as the rest of the path. */
            if (empty($_REQUEST['sf']) && !empty($route_args[2])) {
                $_REQUEST['sf'] = implode("/", array_slice($route_args, 2));
                $_REQUEST['route']['sf'] = true;
            }
            /* A routed domain serves one group's wiki, so a list of its
               pages is that group's list, and whether a reader may see
               one at all is that group's say. Everything else under p/ is
               read through the static controller, which works out for
               itself which group a domain serves; this is read through
               the group controller instead, which has to be told, and
               without being told it asked the site's own public group,
               which answered with its pages and its own permission
               whatever domain was being read. The group comes from the
               routing an admin set, so a request cannot ask for another
               group by naming one. */
            if (empty($_REQUEST['group_id'])) {
                $route_group_id = domainRouteGroupId();
                if ($route_group_id > 0) {
                    $_REQUEST['group_id'] = $route_group_id;
                    $_REQUEST['route']['group_id'] = true;
                }
            }
        } else {
            $_REQUEST['c'] = "static";
            $_REQUEST['p'] = $route_args[1];
            $_REQUEST['route']['c'] = true;
            $_REQUEST['route']['p'] = true;
            if (empty($_REQUEST['sf']) && !empty($route_args[2]) ) {
                $rest = array_slice($route_args, 2);
                $_REQUEST['sf'] = implode("/", $rest);
                $_REQUEST['route']['sf'] = true;
            }
        }
    } else {
        $handled = false;
    }
    return $handled;
}
/**
 * Reads the host name of the current request with any port number
 * dropped, lower-cased, so it can be matched against the domain routing
 * table. An IPv6 host in brackets keeps its colons; only a trailing
 * ":port" on an ordinary host is removed.
 *
 * @return string the request host without a port, or the empty string
 *      when the request carries no host
 */
function requestHostNoPort()
{
    $host = strtolower($_SERVER['HTTP_HOST'] ?? "");
    $colon = strrpos($host, ':');
    if ($colon !== false && strpos($host, ']') === false) {
        $host = substr($host, 0, $colon);
    }
    return $host;
}
/**
 * Reads which group, if any, the host of the current request is routed
 * to serve. A domain can be pointed at one group's wiki, and what that
 * domain serves is then read out of that group rather than out of the
 * site's own public one.
 *
 * @return int the group the request's host is routed to serve, or 0 when
 *      the host is routed nowhere or the site has no profile yet
 */
function domainRouteGroupId()
{
    if (!C\PROFILE) {
        return 0;
    }
    $host = requestHostNoPort();
    if ($host === "") {
        return 0;
    }
    $group_model = new M\GroupModel();
    $group_id = $group_model->getDomainRouteGroupId($host);
    return ($group_id > 0) ? $group_id : 0;
}
/**
 * Given the name of a wiki page, the group it belongs to, and which
 * controller is being used creates the url where that wiki item can be
 * accessed from the instance of Yioop. It makes use of the
 * defined variable REDIRECTS_ON.
 *
 * @param string $name of wiki page
 * @param bool $with_delim whether it should be terminated with nothing or
 *      ? or &
 * @param string $controller which controller is being used to access the
 *      feed: usually static (for the public group), admin, or group
 * @param int $id the group the wiki page belongs to
 * @return string url for the page in question
 */
function wikiUrl($name, $with_delim = false, $controller = "static", $id =
    C\PUBLIC_GROUP_ID)
{
    $first_mark = ($with_delim) ? "?" : "";
    $next_mark = ($with_delim) ? "&" : "";
    $is_static = ($controller == "static");
    $base_url = C\SHORT_BASE_URL;
    if (C\REDIRECTS_ON && $controller != 'api') {
        $first_mark = ($with_delim) ? "?" : "";
        if ($is_static) {
            if ($name == "") {
                $name = "Main";
            }
            return $base_url ."p/$name$first_mark";
        } else {
            $page = ($name== "") ? "?a=wiki$next_mark" : "/$name$first_mark";
            return $base_url .
                "$controller/$id$page";
        }
    } else {
        $delim = ($with_delim) ? "&" : "";
        if ($name == 'pages') {
            if ($is_static) {
                $controller = "group";
            }
            return "$base_url?c=$controller&a=wiki&arg=pages" .
                "&group_id=$id$next_mark";
        } else {
            if ($is_static) {
                if ($name == "") {
                    $name = "main";
                }
                return "$base_url?c=static&p=$name$next_mark";
            } else {
                $page = ($name== "") ? "" : "&page_name=$name";
                return "$base_url?c=$controller&a=wiki" .
                    "&group_id=$id$page$next_mark";
            }
        }
    }
}
if (php_sapi_name() != 'cli' &&
    (empty($web_site) &&
    !defined("seekquarry\\yioop\\configs\\REDIRECTS_ON") &&
    !defined("seekquarry\\yioop\\configs\\LIBRARY"))) {
    /**
     * For error function and yioop constants if we are in non-cli
     * non-redirects situation
     */
    require_once __DIR__ . "/library/Utility.php";
    $web_site =  new A\WebSite();
    bootstrap($web_site);
}
/**
 * Handles one conversation-refresh WebSocket connection, pushing the
 * conversation its new messages the moment they arrive. It works out who
 * the visitor is from the session their upgrade request carried and which
 * contact's conversation they are watching from the request's query, then
 * sets a repeating timer that asks the social component for anything newer
 * than the last thing it sent and, when there is something, sends it down
 * the socket in the same status envelope the browser already handles for
 * the event-stream path. The socket stays open, so a message shows without
 * the reconnect the event-stream path waits through. The timer is dropped
 * when the socket closes. The route this serves is registered at startup
 * in the root index.php.
 *
 * @param object $ws the WebSocket the client opened
 * @param object $web_site the command-line WebSite server holding the
 *      socket and the session store
 */
function messageWsConnect($ws, $web_site)
{
    /* What the socket was opened with is read from the socket itself
       rather than from the arrays an ordinary request uses, since the
       socket outlives that request. */
    $contact_id = isset($ws->query['contact_id']) ?
        intval($ws->query['contact_id']) : 0;
    $user_id = messageWsUserId($web_site, $ws);
    if ($user_id <= 0 || $contact_id <= 0) {
        $ws->close();
        return;
    }
    $group_controller_class = C\NS_CONTROLLERS . "GroupController";
    $controller = new $group_controller_class($web_site);
    $messages = $controller->component('message');
    /*
       The time of the newest message already pushed is kept in this local,
       closed over by the timer, rather than on the socket object, so no
       extra property is put on the atto WebSocket class.
     */
    $since_time = time();
    /* Call-signaling events are timed in microtime, a finer clock than
       the whole-second message time, and are read against their own
       baseline so a call's offers, answers and candidates ride this
       same socket rather than falling to the event stream. */
    $call_since_micro_time = L\microTimestamp();
    $timer_id = $web_site->setTimer(C\USER_MESSAGE_WS_PUSH_TIME,
        function () use ($ws, $messages, $user_id, $contact_id,
        &$since_time, &$call_since_micro_time) {
        if ($ws->closed) {
            return;
        }
        $payload = $messages->newMessageStatusPayload($user_id,
            $contact_id, $since_time);
        $since_time = $payload['time'];
        if (!empty($payload['envelope'])) {
            $ws->send($payload['envelope']);
        }
        /* Every waiting call event is drained on each pass, not just
           one, since a call swaps several candidates in quick
           succession and holding them back a push-interval apart would
           slow a call to connect. */
        do {
            $call_payload = $messages->nextCallEventEnvelope($user_id,
                $contact_id, $call_since_micro_time);
            $moved = $call_payload['time'] != $call_since_micro_time;
            $call_since_micro_time = $call_payload['time'];
            if (!empty($call_payload['envelope'])) {
                $ws->send($call_payload['envelope']);
            }
        } while ($moved && !empty($call_payload['envelope']));
    }, true);
    $ws->onClose(function ($ws) use ($web_site, $timer_id) {
        $web_site->clearTimer($timer_id);
    });
}
/**
 * Works out which signed-in user a socket belongs to by reading the
 * session named by the cookie the socket was opened with. The cookies
 * are read from the socket rather than from the arrays an ordinary
 * request uses, since the socket outlives that request and its cookie
 * has no business reaching work that has nothing to do with it. Zero is
 * returned when the cookie names no live session or that session has no
 * signed-in user, so the caller can refuse the socket.
 *
 * @param object $web_site the WebSite server holding the session store
 * @param object $ws the socket, holding the cookies it was opened with
 * @return int the signed-in user's id, or 0 when none can be determined
 */
function messageWsUserId($web_site, $ws)
{
    $session_name = C\p('SESSION_NAME');
    $session_id = $ws->cookies[$session_name] ?? '';
    if ($session_id === '') {
        return 0;
    }
    $session_data = $web_site->sessionData($session_id);
    if (empty($session_data['USER_ID'])) {
        return 0;
    }
    return intval($session_data['USER_ID']);
}
