/ index.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
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
namespace seekquarry\yioop;

use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\atto as A;

/**
 * Used to make using Yioop through this index page rather than as part of
 * a library
 */
const BASE_INDEX_ENTRY = true;
/**
 * This will in turn load Config.php
 */
require_once __DIR__ . "/src/library/Utility.php";
/**
 * Sends a request from the outer Yioop index.php file on to the inner one
 * Marks that redirects are on
 *
 * @return bool always true once the inner index page has handled the
 *      request
 */
function passthruYioopRequest()
{
    $web_site = $GLOBALS['web_site'];
    $uri = $_SERVER['REQUEST_URI'];
    $new_uri = preg_replace("@/(\.?/)+@", "/", $uri);
    if (!empty($web_site) && $new_uri != $uri) {
        $web_site->header("Location: $new_uri");
        A\webExit();
    }
    require_once __DIR__ . "/src/index.php";
    bootstrap($web_site);
    return true;
}
/**
 * Used to process the command line arguments to yioop when run in CLI
 * mode as its own web server
 */
function processCommandLine()
{
    global $argv;
    define("seekquarry\\yioop\\configs\\IS_OWN_WEB_SERVER", true);
    /*
        secure-terminal / secure-start / secure-stop drive the TLS
        launcher (bind 80 and 443 with a managed certificate) instead
        of the single plain port. secure-terminal runs in this
        process; secure-start daemonizes by re-spawning index.php as a
        'child' (see CrawlDaemon::start), which drops the secure-*
        word. So the secure listen address is set to the self-naming
        marker 'secure-80-443' below: CrawlDaemon forwards it to the
        child, which reads it back here as argv[3] to re-enable secure
        mode, and it also names the daemon (startup line reads
        "secure-80-443-index") so it is clear this is the secure
        server and on which ports. The secure block ignores the
        address otherwise. secure-stop maps to the plain stop, which
        signals the secure daemon through the same lock and stop file.
        The choice is recorded in a constant the listen-construction
        step reads.
     */
    $secure_marker = 'secure-80-443';
    $is_secure_server = (!empty($argv[1]) && in_array($argv[1],
        ['secure-terminal', 'secure-start', 'secure-stop',
        'secure-restart'], true)) ||
        (!empty($argv[1]) && $argv[1] === $secure_marker) ||
        (isset($argv[3]) && $argv[3] === $secure_marker);
    define("seekquarry\\yioop\\configs\\SECURE_SERVER_MARKER",
        $secure_marker);
    if (!empty($argv[1])) {
        if ($argv[1] === 'secure-terminal') {
            $argv[1] = 'terminal';
        } else if ($argv[1] === 'secure-start') {
            $argv[1] = 'start';
        } else if ($argv[1] === 'secure-stop') {
            $argv[1] = 'stop';
        } else if ($argv[1] === 'secure-restart') {
            $argv[1] = 'restart';
        }
    }
    $address = 8080;
    $sleep = 0;
    $session_file = "";
    if (empty($argv[1])) {
        echo "Yioop web server commands:\n\n";
        echo "  php index.php start [port]      " .
            "start in the background on a port\n";
        echo "                                  (default 8080)\n";
        echo "  php index.php terminal [port]   " .
            "run in this terminal, not in the\n";
        echo "                                  background\n";
        echo "  php index.php restart [port]    " .
            "restart a running server, or start\n";
        echo "                                  one if none is running\n";
        echo "  php index.php stop              " .
            "stop the running server\n";
        echo "  php index.php status            " .
            "show which Yioop processes are\n";
        echo "                                  running and on which " .
            "ports\n\n";
        echo "Add 443 as the port to serve HTTPS on the standard " .
            "secure port.\n";
        echo "The secure- forms bind both 80 and 443 with a managed " .
            "certificate:\n";
        echo "  sudo php index.php secure-start\n";
        echo "  sudo php index.php secure-terminal\n";
        echo "  sudo php index.php secure-restart\n";
        echo "  sudo php index.php secure-stop\n";
        exit();
    } else {
        if ($argv[1] == 'stop' || $argv[1] == 'status') {
            return;
        }
        $possible_address = $argv[1];
        $possible_sleep = 0;
        if (empty($argv[2])) {
            if (in_array($argv[1], ['start', 'terminal', 'child', 'restart'])) {
                $possible_address = 8080;
            } else {
                $argv[1] = 'start';
            }
        } else {
            $possible_sleep = $argv[2];
            if (in_array($argv[1], ['start', 'terminal', 'child', 'restart'])) {
                $possible_address = $argv[2];
                if (empty($argv[3])) {
                    $possible_sleep = 0;
                    if ($argv[2] == 'none') {
                        $possible_address = 8080;
                    }
                } else {
                    $possible_sleep = $argv[3];
                    if ($argv[2] == 'none') {
                        $possible_address = $argv[3];
                        if (empty($argv[4])) {
                            $possible_sleep = 0;
                        } else {
                            $possible_sleep = $argv[4];
                        }
                        if (!empty($argv[5])) {
                            $session_file = $argv[5];
                        }
                    } else if (!empty($argv[4])) {
                        $session_file = $argv[4];
                    }
                }
            } else if (!empty($argv[3])) {
                if (intval($possible_sleep) > 0) {
                    $argv[1] = 'restart';
                } else {
                    $argv[1] = 'start';
                }
                $session_file = $argv[3];
            }
        }
        $tmp_address = intval($possible_address);
        if ($tmp_address > 0 && $tmp_address < 65535) {
            $address = $possible_address;
        } else {
            $url_parts = @parse_url($possible_address);
            if(!empty($url_parts)) {
                $address = $possible_address;
            }
        }
        if (intval($possible_sleep) > 0 ) {
            $sleep = $possible_sleep;
        }
    }
    /* A bare "443" port (terminal 443 / start 443) means the user
       wants HTTPS on the standard secure port. Turn on secure serving
       for that one port: a self-signed certificate is written if none
       exists and the port is served over TLS. This is the single-port
       counterpart to secure-terminal / secure-start, which instead
       bind both 80 and 443 for the full public set-up (HTTP-to-HTTPS
       redirect and the ACME challenge on 80). Here only the one port
       the user named is served. */
    $is_single_secure_port = !$is_secure_server &&
        intval($address) === 443;
    define("seekquarry\\yioop\\configs\\IS_SECURE_SERVER",
        $is_secure_server);
    define("seekquarry\\yioop\\configs\\SECURE_SINGLE_PORT",
        $is_single_secure_port);
    $argv[2] = 'none';
    $argv[3] = ($is_secure_server) ? $secure_marker : $address;
    $argv[4] = $sleep;
    $argv[5] = $session_file;
}
/**
 * Creates a  website object and specify some common routes.
 * If run in CLI mode this will start the server linstening for connect
 * web connections
 */
$web_site = new A\WebSite('.');
$web_site->get('/*', 'seekquarry\\yioop\\passthruYioopRequest');
$web_site->head('/*', 'seekquarry\\yioop\\passthruYioopRequest');
$web_site->post('/*', 'seekquarry\\yioop\\passthruYioopRequest');
$web_site->put('/*', 'seekquarry\\yioop\\passthruYioopRequest');
$web_site->delete('/*', 'seekquarry\\yioop\\passthruYioopRequest');
$web_site->options('/*', 'seekquarry\\yioop\\passthruYioopRequest');
$web_site->propfind('/*', 'seekquarry\\yioop\\passthruYioopRequest');
$web_site->mkcol('/*', 'seekquarry\\yioop\\passthruYioopRequest');
$web_site->move('/*', 'seekquarry\\yioop\\passthruYioopRequest');
$web_site->lock('/*', 'seekquarry\\yioop\\passthruYioopRequest');
$web_site->unlock('/*', 'seekquarry\\yioop\\passthruYioopRequest');
if ($web_site->isCli()) {
    if ($argv[1] == 'status') {
        /* Report what Yioop processes are running and, for the web
           server, which port(s) it is listening on. Reads the same
           lock files the server uses to track running daemons, so the
           report reflects what is actually alive right now. */
        $running = L\CrawlDaemon::statuses();
        $labels = ["index" => "Web server",
            "MediaUpdater" => "Media Updater",
            "MailServer" => "Mail Server",
            "QueueServer" => "Queue Server", "Fetcher" => "Fetcher"];
        if (empty($running)) {
            echo "No Yioop processes appear to be running.\n";
        } else {
            echo "Running Yioop processes:\n";
            foreach ($running as $name => $instances) {
                $label = $labels[$name] ?? $name;
                $line = "  " . $label;
                if ($name == "index") {
                    $port_file = C\DATA_DIR . "/IndexServerPort.txt";
                    if (file_exists($port_file)) {
                        $line .= " (listening on port " . trim(
                            (string)file_get_contents($port_file)) .
                            ")";
                    }
                }
                $count = count($instances);
                if ($count > 1) {
                    $line .= " x" . $count;
                }
                echo $line . "\n";
            }
        }
        exit();
    }
    $web_site->setTimer(10, function () use ($web_site) {
        if (!C\PROFILE) {
            return;
        }
        static $start_time = 0;
        $global_stop_file = L\CrawlDaemon::GLOBAL_STOP_FILE_NAME;
        if ($start_time == 0) {
            $start_time = time();
        }
        if (file_exists($global_stop_file)) {
            $stop_time = intval(file_get_contents($global_stop_file));
            if ($stop_time > $start_time) {
                echo "Stopping Server...";
                $web_site->stop = true;
                $web_site->restart = false;
                /* clear this server's lock as part of honoring the
                   stop, so a follow-up start is not refused for
                   PROCESS_TIMEOUT because a request handled between
                   the stop command's unlink and shutdown recreated
                   the lock with a fresh timestamp
                 */
                L\CrawlDaemon::releaseLock();
                $port_file = C\DATA_DIR . "/IndexServerPort.txt";
                if (file_exists($port_file)) {
                    unlink($port_file);
                }
            } else {
                unlink($global_stop_file);
            }
        }
        $restart_request_file = C\DATA_DIR . "/RestartServer.txt";
        if (file_exists($restart_request_file)) {
            $request_time = intval(
                file_get_contents($restart_request_file));
            unlink($restart_request_file);
            if ($request_time > $start_time) {
                /* A command-line restart asked for this. Take the
                   background daemons down and record them for relaunch,
                   then restart this server the same way the admin
                   Restart Server action does, so there is one restart
                   path to maintain. */
                echo "Restarting Server...";
                L\CrawlDaemon::saveDaemonStateForRestart();
                $session_path = C\DATA_DIR . "/PreviousSession.txt";
                $web_site->saveSessionsForRestart($session_path);
                $web_site->restart = "restart " . $session_path;
                $web_site->stop = true;
            }
        }
        $log_msg = "Current Memory Usage: " . memory_get_usage() .
            " Peak usage:" . memory_get_peak_usage() . " " .
            L\IndexManager::cacheReport() . " max-posting-read=" .
            L\IndexDocumentBundle::$max_postings_read . "B";
        L\crawlLog($log_msg);
    });
    if (file_exists(C\LOG_DIR)) {
        // don't use logging until after log directory built on first run
        $web_site->middleware(function () use ($web_site)
        {
            $lock_file = L\CrawlDaemon::getLockFileName("index", '');
            /*  in first run case, lock file won't be created by outer
                instance as folder not created yet; skip the recreate
                while stopping so a shutdown-window request does not
                resurrect a lock the stop command just removed
             */
            if (empty($web_site->stop) && !file_exists($lock_file)) {
                L\CrawlDaemon::writeLockFile($lock_file, time());
            }
            $log_msg = $_SERVER['REMOTE_ADDR'] . " " .
                $_SERVER['REQUEST_URI'] . " " . $web_site->requestProtocol() .
                ":" . ($_SERVER['SERVER_PORT'] ?? "");
            L\crawlLog($log_msg);
        });
        $web_site->logResponses(function ($status, $size) use ($web_site) {
            /* The arrival line above shows a request came in; this
               companion line records how it turned out, so an oversized
               or failing reply is recognizable by its status and byte
               size rather than only by the path requested. */
            $access_line = $_SERVER['REMOTE_ADDR'] . " " .
                $_SERVER['REQUEST_URI'] . " " .
                $web_site->requestProtocol() . ":" .
                ($_SERVER['SERVER_PORT'] ?? "") . " " . $status . " " .
                $size . "B";
            L\crawlLog($access_line);
        });
        register_shutdown_function(function () {
            /* If the always-on server dies on a fatal error, a memory
               exhaustion most of all, record which request was in flight
               and the peak memory reached. A request that exhausts memory
               while still building its reply never reaches the response
               logger above, so without this the crashing request can only
               be guessed at from the surrounding arrival lines. */
            $fatal = error_get_last();
            if ($fatal === null || !in_array($fatal['type'],
                [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
                return;
            }
            $remote = $_SERVER['REMOTE_ADDR'] ?? "?";
            $uri = $_SERVER['REQUEST_URI'] ?? "?";
            L\crawlLog("Fatal while serving " . $remote . " " . $uri .
                " peak_memory=" . memory_get_peak_usage() . " " .
                $fatal['message']);
        });
    }
    $session_info = null;
    if (!empty($argv[5]) && file_exists($argv[5])) {
        $session_info = unserialize(file_get_contents($argv[5]));
        if ($argv[1] == 'restart') {
            if(!empty($session_info['SESSIONS']['TERMINAL'])) {
                $argv[1] = 'start';
            } else {
                $argv[1] = 'child';
            }
        }
    } else if ($argv[1] == 'restart') {
        /* Command-line restart. If a server is already running, leave
           the work to it: drop a request file that it picks up on its
           next tick and then restarts itself the same way the admin
           Restart Server action does (stopping and relaunching the
           background daemons, handing sessions across so people stay
           signed in). Talking to it through a file rather than a web
           request avoids opening an unauthenticated restart endpoint.
           If nothing is running, say so and start a fresh server. */
        $index_lock = L\CrawlDaemon::getLockFileName("index", '');
        $index_lock_alt = L\CrawlDaemon::getLockFileName("index", '0');
        $running = false;
        foreach ([$index_lock, $index_lock_alt] as $lock) {
            if (file_exists($lock)) {
                $lock_time = intval(file_get_contents($lock));
                $lock_process = L\CrawlDaemon::lockProcessId($lock);
                if (time() - $lock_time < C\PROCESS_TIMEOUT &&
                    L\CrawlDaemon::isProcessAlive($lock_process)) {
                    $running = true;
                    break;
                }
            }
        }
        if ($running) {
            $restart_request_file = C\DATA_DIR . "/RestartServer.txt";
            file_put_contents($restart_request_file, time());
            chmod($restart_request_file, 0777);
            echo "Asked the running server to restart.\n";
            /* Follow along until the restart finishes. First wait for
               the running server to notice and remove the request file
               (it polls on a timer, so this can take a few seconds);
               that confirms the restart is under way. Then print each
               restart phase the server records (stopping services,
               waiting for and restarting the mail server, and so on)
               as it changes, and finish once the phase has settled
               back to empty, which the server does when it is serving
               again. A generous overall cap stops the wait if no
               progress is seen. */
            $last_phase = "";
            $waited = 0;
            $max_wait = 120;
            $picked_up = false;
            $clear_streak = 0;
            $done = false;
            $relaunched = [];
            $daemon_labels = ["MediaUpdater" => "Media Updater",
                "MailServer" => "Mail Server"];
            while ($waited < $max_wait) {
                sleep(1);
                $waited++;
                clearstatcache(true, $restart_request_file);
                if (!$picked_up) {
                    if (!file_exists($restart_request_file)) {
                        $picked_up = true;
                        echo "  Restart under way...\n";
                        /* The server has recorded which background
                           services it is bringing back; read that list
                           now so every one can be named at the end,
                           even any whose progress message flicks past
                           between these once-a-second checks. */
                        $marker = C\DATA_DIR . "/RestartDaemons.txt";
                        if (file_exists($marker)) {
                            foreach (array_filter(array_map('trim',
                                explode("\n", (string)file_get_contents(
                                $marker)))) as $entry) {
                                $name = explode(" ", $entry)[0];
                                $relaunched[] = $daemon_labels[$name] ??
                                    $name;
                            }
                        }
                    }
                    continue;
                }
                $phase = L\CrawlDaemon::getRestartStatus();
                if ($phase !== "") {
                    $clear_streak = 0;
                    if ($phase !== $last_phase) {
                        echo "  " . $phase . "\n";
                        $last_phase = $phase;
                    }
                } else {
                    $clear_streak++;
                    /* a couple of clear readings in a row means the
                       server has finished and is serving again */
                    if ($clear_streak >= 3) {
                        $done = true;
                        break;
                    }
                }
            }
            if ($done) {
                echo "Restart complete; the web server is back up.\n";
                if (!empty($relaunched)) {
                    echo "Services brought back up: " .
                        implode(", ", $relaunched) . ".\n";
                }
            } else if (!$picked_up) {
                echo "The running server did not pick up the restart " .
                    "request; check the server log.\n";
            } else {
                echo "Restart did not confirm within " . $max_wait .
                    " seconds; check the server log.\n";
            }
            exit();
        }
        echo "No server appears to be running; starting a fresh one.\n";
        $argv[1] = 'start';
    }
    if ($argv[1] == 'start' || $argv[1] == 'terminal') {
        /* Yioop runs a single index web server. If one is already up, a
           second start or terminal run (even on a different port) cannot
           bind alongside it, so say so clearly and point at the right
           next step rather than leaving the impression that two servers
           on two ports are now running. A lock left behind by a server
           that crashed or was stopped with Ctrl-C is recognized as stale
           through its recorded process id and removed, so the next start
           or terminal run is not blocked and needs no separate stop
           first. */
        $index_lock = L\CrawlDaemon::getLockFileName("index", '');
        $index_lock_alt = L\CrawlDaemon::getLockFileName("index", '0');
        foreach ([$index_lock, $index_lock_alt] as $lock) {
            if (!file_exists($lock)) {
                continue;
            }
            if (L\CrawlDaemon::lockHeldByLiveProcess($lock)) {
                $port_file = C\DATA_DIR . "/IndexServerPort.txt";
                $where = (file_exists($port_file)) ? trim(
                    (string)file_get_contents($port_file)) : "";
                $on_port = ($where === "") ? "" :
                    " on port " . $where;
                echo "An index web server is already running" .
                    $on_port . ". Yioop runs one index web server " .
                    "at a time, so it cannot also start on " .
                    "another port.\n";
                echo "To move it to a different port, stop it " .
                    "first (php index.php stop), then start on " .
                    "the port you want. To restart it in place, " .
                    "use php index.php restart.\n";
                exit();
            }
            unlink($lock);
        }
    }
    L\CrawlDaemon::init($argv, "index");
    L\crawlLog("\n\nInitialize logger..", "index", true);
    /* Write this server's lock now, at startup. The per-request
       middleware also writes it, but a server that has just
       (re)started in the background as a daemon may sit with no
       inbound request for several seconds; the periodic crawlLog
       in the stop-watch timer calls CrawlDaemon::processHandler,
       which exits a daemon whose lock file is missing. Creating the
       lock here keeps the freshly restarted server alive until the
       first request arrives. */
    if (file_exists(C\SCHEDULES_DIR)) {
        $index_lock_file = L\CrawlDaemon::getLockFileName("index", '');
        if (!file_exists($index_lock_file)) {
            L\CrawlDaemon::writeLockFile($index_lock_file, time());
        }
        /* Record which port(s) this server is listening on so that a
           later start attempt can name them when it explains that one
           index server is already running. */
        if (C\nsdefined("IS_SECURE_SERVER") && C\IS_SECURE_SERVER) {
            $listening_on = "80 and 443";
        } else if (C\nsdefined("SECURE_SINGLE_PORT") &&
            C\SECURE_SINGLE_PORT) {
            $listening_on = "443";
        } else {
            $listening_on = (string)$argv[3];
        }
        file_put_contents(C\DATA_DIR . "/IndexServerPort.txt",
            $listening_on);
    }
    if ($argv[4] > 0) {
        sleep($argv[4]);
    }
    if (empty($session_info)) {
        $session_info = ['SESSIONS' => []];
    }
    if ($argv[1] == 'terminal') {
        $session_info['SESSIONS']['TERMINAL'] = true;
    }
    $context = [];
    /* Let the server accept a request body as large as PHP itself is
       configured to accept, rather than capping it at the atto
       default (10MB). Without this, a file upload bigger than 10MB —
       well within a typical PHP post_max_size — is refused before it
       reaches Yioop and the browser shows a bare 400 Bad Request.
       PHP's post_max_size is read and converted to a byte count; a
       value of 0 means "no limit" in PHP, and any value below the
       atto default is raised to it so a small or unset ini setting
       cannot shrink what the server will take. */
    $php_post_max = L\metricToInt(ini_get('post_max_size'));
    $atto_default_max_request = 10000000;
    if ($php_post_max <= 0) {
        $context['MAX_REQUEST_LEN'] = PHP_INT_MAX;
    } else if ($php_post_max < $atto_default_max_request) {
        $context['MAX_REQUEST_LEN'] = $atto_default_max_request;
    } else {
        $context['MAX_REQUEST_LEN'] = $php_post_max;
    }
    if (C\nsdefined("SERVER_CONTEXT")) {
        $context['SERVER_CONTEXT'] = C\SERVER_CONTEXT;
    }
    /* The names this server answers to, so the WebSite can set
       $_SERVER['SERVER_NAME'] per request to the domain a visitor
       actually used (validated against this list) rather than the
       single name it bound to. Outbound mail links read SERVER_NAME,
       so a visitor on one of several served domains gets links back
       to that same domain. Passed as a plain list to keep the atto
       WebSite free of Yioop config; an empty list leaves the
       per-request naming off. */
    $configured_domains = trim((string)C\SECURE_DOMAINS);
    if ($configured_domains !== "") {
        $context['SECURE_DOMAINS'] = array_values(array_filter(
            array_map('trim', preg_split('/[\s,]+/',
            $configured_domains))));
    }
    /* How often (in whole seconds) the running web server should write
       a memory-usage line to the error log, or 0 to leave it off. It
       is a switchable diagnostic for tracking slow memory growth in the
       always-on server. Passed as a plain server value so the atto
       WebSite stays free of Yioop config; the WebSite takes it back out
       of its globals so it does not leak into $_SERVER. */
    $context['WEBSITE_MEMORY_SAMPLE_PERIOD'] =
        (int)C\WEBSITE_MEMORY_SAMPLE_PERIOD;
    $context['SESSION_INFO'] = $session_info;
    /* On a restart the WebSite re-spawns itself; for the secure
       server it must come back binding 80 and 443, which the
       launcher selects from this marker as argv[1] rather than the
       single parsed address. Passing it as a server global lets the
       atto WebSite reuse it without knowing anything Yioop-specific
       about secure mode. */
    if (C\IS_SECURE_SERVER) {
        $context['RESTART_ADDRESS'] = C\SECURE_SERVER_MARKER;
    }
    $listen_address = $argv[3];
    if (C\IS_SECURE_SERVER || C\SECURE_SINGLE_PORT) {
        /*
            Secure launcher: bind 80 and 443 from this one process
            rather than the single port the plain modes use. Port
            443 carries the managed certificate from SECURITY_DIR
            (not SERVER_CONTEXT); port 80 is plain HTTP, used to
            answer the ACME HTTP-01 challenge and otherwise redirect
            to HTTPS. The privileged binds need root; WWW_USER (when
            set) is the user the server drops to afterward, so the
            long-running process does not hold root. Cert reload on
            renewal updates the live listener context in place and
            needs no rebind, so the drop does not block renewal.
         */
        unset($context['SERVER_CONTEXT']);
        $cert_file = C\SECURE_CERT_FILE;
        $key_file = C\SECURE_KEY_FILE;
        $has_cert = file_exists($cert_file) && file_exists($key_file);
        if (!$has_cert) {
            /* No managed certificate yet (for example a fresh
               install). Generate a self-signed bootstrap certificate
               so port 443 binds with a working certificate rather
               than a missing one, which would accept connections and
               then fail every TLS handshake. The server runs (with a
               browser trust warning); when ACME is on, the HTTP-01
               challenge on port 80 then replaces it with a real
               certificate. Covers the configured secure domains so
               the placeholder at least names the right hosts. */
            $secure_domains = C\SECURE_DOMAINS;
            $boot_domains = ($secure_domains === '') ? [] :
                array_filter(array_map('trim',
                explode(',', $secure_domains)));
            $self_signed = L\CsrBuilder::selfSigned($boot_domains);
            if ($self_signed !== null) {
                $installer = new L\CertInstaller($cert_file,
                    $key_file);
                $has_cert = $installer->writeFiles(
                    $self_signed['cert_pem'],
                    $self_signed['key_pem']);
            }
            if ($has_cert) {
                L\crawlLog("Secure server: no managed certificate " .
                    "found; wrote a self-signed bootstrap " .
                    "certificate so 443 can bind. Browsers will warn " .
                    "until a real certificate is installed" .
                    (C\ACME_ON ? " (ACME will obtain one)." : "."));
            } else {
                L\crawlLog("Secure server: no managed certificate " .
                    "and could not write a self-signed bootstrap " .
                    "certificate; TLS on 443 will fail until a " .
                    "certificate is installed.");
            }
        }
        $alpn = C\ALPN_PROTOCOLS;
        $alpn_list = ($alpn === '') ? [] :
            array_filter(array_map('trim', explode(',', $alpn)));
        $ssl_context = [
            'local_cert' => $cert_file,
            'local_pk' => $key_file,
            'allow_self_signed' => true,
            'verify_peer' => false,
            'alpn_protocols' => $alpn,
        ];
        if (C\SECURE_SINGLE_PORT) {
            /* single secure port: serve TLS on 443 only, with no
               port-80 companion, since the user asked for one port. */
            $listen_address = [
                ['address' => 'tcp://0.0.0.0:443',
                 'context' => ['ssl' => $ssl_context]],
            ];
        } else {
            $listen_address = [
                ['address' => 'tcp://0.0.0.0:443',
                 'context' => ['ssl' => $ssl_context]],
                ['address' => 'tcp://0.0.0.0:80'],
            ];
        }
        /* When h3 is among the selected protocols, add a QUIC
           listener on UDP 443 carrying the same managed certificate.
           It coexists with the TCP 443 listener: HTTP/2 and
           HTTP/1.1 over TCP, HTTP/3 over QUIC, all advertised in the
           TCP listener's ALPN list. The H3 listener opens only if
           the optional QUIC pieces are present; otherwise it falls
           through and the server keeps serving over TCP. */
        $h3_on = in_array('h3', $alpn_list);
        if ($h3_on) {
            $listen_address[] = [
                'address' => 'tcp://0.0.0.0:443',
                'context' => ['ssl' => $ssl_context],
                'protocol' => 'h3',
            ];
        }
        if (!empty(C\WWW_USER)) {
            $context['USER'] = C\WWW_USER;
        }
        if (!C\ACME_ON) {
            L\crawlLog("Secure server: ACME is OFF (ACME_ON is " .
                "false); certificates will not be obtained or renewed " .
                "automatically.");
        } else {
            L\crawlLog("Secure server: ACME is ON. Certificate " .
                "renewal runs as a media job, so MediaUpdater must be " .
                "running for certificates to update.");
        }
        if ($h3_on) {
            L\crawlLog("Secure server: HTTP/3 selected; opening a " .
                "QUIC listener on UDP 443. If the optional QUIC " .
                "extensions are missing the server keeps serving " .
                "over TCP (HTTP/2 and HTTP/1.1).");
        }
    }
    $gc_window = max(intval(C\AUTOLOGOUT),
        intval(ini_get('session.gc_maxlifetime')));
    ini_set('session.gc_maxlifetime', (string)$gc_window);
    if (C\IS_SECURE_SERVER || C\SECURE_SINGLE_PORT) {
        /*
            Watch the managed certificate file and refresh the live
            TLS listener context whenever it changes, so a renewal
            written by the AcmeRenew media job (which runs in the
            MediaUpdater process, not this one) is served without a
            restart. The once-a-minute stat is cheap; new handshakes
            pick up the refreshed certificate while in-flight
            connections finish on the one they started with.
         */
        $secure_cert_file = C\SECURE_CERT_FILE;
        $secure_key_file = C\SECURE_KEY_FILE;
        $last_cert_mtime = file_exists($secure_cert_file)
            ? filemtime($secure_cert_file) : 0;
        $web_site->setTimer(C\ONE_MINUTE,
            function () use ($web_site, $secure_cert_file,
                $secure_key_file, &$last_cert_mtime) {
                clearstatcache(true, $secure_cert_file);
                if (!file_exists($secure_cert_file)) {
                    return;
                }
                $mtime = filemtime($secure_cert_file);
                if ($mtime <= $last_cert_mtime) {
                    return;
                }
                $last_cert_mtime = $mtime;
                $web_site->reloadSecureCertificate($secure_cert_file,
                    $secure_key_file);
                L\crawlLog("Secure server: certificate file changed; " .
                    "refreshed the live TLS listener context. New " .
                    "connections use the renewed certificate.");
            });
    }
    $restart_daemons_marker = C\DATA_DIR . "/RestartDaemons.txt";
    if (file_exists($restart_daemons_marker)) {
        /* a Restart Server action or profile change stopped the
           background daemons (MediaUpdater, MailServer) that had
           been started through Manage Machines and recorded them
           here; wait for each to finish exiting then relaunch it.
           The waiting is done across repeating-timer ticks rather
           than in one blocking loop so the freshly bound web server
           keeps serving (and answering the restart-status poll)
           while a daemon that is mid-download takes its time to
           stop. The marker is removed up front so this fires once. */
        $wanted = array_filter(array_map('trim',
            explode("\n", (string) file_get_contents(
            $restart_daemons_marker))));
        unlink($restart_daemons_marker);
        $relaunch_state = ["index" => 0, "waited" => 0, "id" => null];
        $relaunch_timer = $web_site->setTimer(1,
            function () use ($web_site, $wanted, &$relaunch_state) {
                while ($relaunch_state["index"] < count($wanted)) {
                    $entry_parts = explode(" ",
                        $wanted[$relaunch_state["index"]]);
                    $daemon = $entry_parts[0];
                    $old_process_id = isset($entry_parts[1]) ?
                        intval($entry_parts[1]) : 0;
                    if (!in_array($daemon,
                        ["MediaUpdater", "MailServer"], true)) {
                        $relaunch_state["index"]++;
                        $relaunch_state["waited"] = 0;
                        continue;
                    }
                    $daemon_label = ($daemon == "MediaUpdater") ?
                        "Media Updater" : "Mail Server";
                    $step = L\CrawlDaemon::drainDaemonStep($daemon,
                        $old_process_id, $relaunch_state["waited"]);
                    if ($step == "waiting") {
                        L\CrawlDaemon::setRestartStatus(
                            "Waiting for " . $daemon_label .
                            " to stop...");
                        $relaunch_state["waited"]++;
                        return;
                    }
                    $statuses = L\CrawlDaemon::statuses();
                    if (empty($statuses[$daemon])) {
                        L\CrawlDaemon::setRestartStatus(
                            "Starting " . $daemon_label . "...");
                        L\CrawlDaemon::start($daemon, "none", "", -1);
                    }
                    $relaunch_state["index"]++;
                    $relaunch_state["waited"] = 0;
                }
                L\CrawlDaemon::setRestartStatus("");
                if ($relaunch_state["id"] !== null) {
                    $web_site->clearTimer($relaunch_state["id"]);
                }
            });
        $relaunch_state["id"] = $relaunch_timer;
    }
    $web_site->listen($listen_address, $context);
} else {
    $web_site->process();
}
X