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

use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\version_control as LVC;

/**
 * Provides the group-controller activity that serves a git wiki page's
 * repository over the wire so a client can clone or fetch it. Git is a group
 * activity, so this lives as a component on the group controller: it works
 * out which page's repository is being asked for, checks that the requester
 * is allowed to read the group (a repository is only as cloneable as its
 * group is readable), and then serves the requested file straight from the
 * repository folder. The two small listing files a cloning client asks for
 * are generated fresh each time by the git model. Reading, decompressing,
 * and parsing the repository itself stays in the model and the git library;
 * this component only decides what to do and checks permission.
 *
 * A git client carries no Yioop cross-site request token, so like the
 * resource controller these requests are allowed through without one and
 * gated by the group read check instead. Pushing, which changes the
 * repository, is handled separately and requires the requester to
 * authenticate.
 *
 * @author Chris Pollett
 */
class GitComponent extends SocialComponent
{

    /**
     * Serves one request for a file in a git page's repository. The request
     * names the group, the page, and the path within the repository. If the
     * requester may read the group and the page is a git repository, the
     * file (or a freshly built listing) is sent; otherwise a not-found reply
     * is returned. In every case the reply is sent and the request ends
     * here.
     */
    public function gitService()
    {
        $parent = $this->parent;
        $group_model = $parent->model("group");
        $wiki_model = $parent->model("wiki");
        $git_model = $parent->model("git");
        $group_id = $parent->clean($_REQUEST['group_id'] ?? "", "int");
        $page_name = $parent->clean($_REQUEST['page_name'] ?? "", "string");
        $sub_path = (empty($_REQUEST['sf'])) ? "" :
            $parent->clean($_REQUEST['sf'], "string");
        $method = $_SERVER['REQUEST_METHOD'] ?? "GET";
        if ($method == "POST" &&
            $sub_path == LVC\GitUploadPack::UPLOAD_PACK_SERVICE) {
            $user_id = $_SESSION['USER_ID'] ?? C\PUBLIC_USER_ID;
            if (!$this->groupIsReadable($parent, $group_model, $group_id,
                $user_id)) {
                $this->sendGitStatus($parent, "404 Not Found");
            }
            $repo_folder = $this->repositoryFolder($group_model, $group_id,
                $page_name);
            if ($repo_folder == "") {
                $this->sendGitStatus($parent, "404 Not Found");
            }
            $this->serveUploadPack($parent, $repo_folder);
        }
        if ($method == "GET" || $method == "HEAD") {
            $user_id = $_SESSION['USER_ID'] ?? C\PUBLIC_USER_ID;
            if (!$this->groupIsReadable($parent, $group_model, $group_id,
                $user_id)) {
                $this->sendGitStatus($parent, "404 Not Found");
            }
            $repo_folder = $this->repositoryFolder($group_model, $group_id,
                $page_name);
            if ($repo_folder == "") {
                $this->sendGitStatus($parent, "404 Not Found");
            }
            $this->serveRepositoryPath($parent, $git_model, $repo_folder,
                $sub_path);
        }
        $this->serveWrite($parent, $group_model, $git_model, $group_id,
            $page_name, $sub_path, $method);
    }
    /**
     * Handles a request that changes the repository, which is how a client
     * pushes. These come over the write half of the WebDAV protocol, so
     * unlike a clone they require the requester to sign in and to have edit
     * access to the group. Once allowed, the specific WebDAV method is
     * carried out against the repository folder, confined to that folder so
     * a request cannot reach or change anything outside it. The request ends
     * here in every case.
     *
     * @param object $parent the group controller, used to send the reply
     * @param object $group_model model used to resolve the page and access
     * @param object $git_model model that locates repository files safely
     * @param int $group_id group the repository's page belongs to
     * @param string $page_name name of the page holding the repository
     * @param string $sub_path path within the repository being acted on
     * @param string $method the WebDAV request method
     */
    private function serveWrite($parent, $group_model, $git_model, $group_id,
        $page_name, $sub_path, $method)
    {
        $user_id = $this->authenticatedEditor($parent, $group_model,
            $group_id);
        if ($user_id === false) {
            $parent->web_site->header("HTTP/1.1 401 Unauthorized");
            $parent->web_site->header(
                'WWW-Authenticate: Basic realm="Yioop Git"');
            \seekquarry\atto\webExit();
        }
        $repo_folder = $this->repositoryFolder($group_model, $group_id,
            $page_name);
        if ($repo_folder == "") {
            $this->sendGitStatus($parent, "404 Not Found");
        }
        $base_href = "/group/" . $group_id . "/" . $page_name . ".git";
        $parent->web_site->header("DAV: 1, 2");
        if ($method == "OPTIONS") {
            $this->serveOptions($parent);
        } else if ($method == "PROPFIND") {
            $this->servePropfind($parent, $git_model, $repo_folder,
                $sub_path, $base_href);
        } else if ($method == "MKCOL") {
            $this->serveMkcol($parent, $git_model, $repo_folder, $sub_path);
        } else if ($method == "PUT") {
            $this->servePut($parent, $git_model, $repo_folder, $sub_path);
        } else if ($method == "MOVE") {
            $this->serveMove($parent, $git_model, $repo_folder, $sub_path);
        } else if ($method == "DELETE") {
            $this->serveDelete($parent, $git_model, $repo_folder, $sub_path);
        } else if ($method == "LOCK") {
            $this->serveLock($parent);
        } else if ($method == "UNLOCK") {
            $this->sendGitStatus($parent, "204 No Content");
        } else {
            $this->sendGitStatus($parent, "405 Method Not Allowed");
        }
    }
    /**
     * Reports whether a group with the given register type holds content
     * the public may read, and so may be cloned over git without signing
     * in. These are the register types Yioop treats as publicly viewable
     * throughout: the browse-and-request-to-join type shown as "By Request"
     * and the open "Anyone" type.
     *
     * @param int $register_type the group's REGISTER_TYPE
     * @return bool whether the public may read the group's content
     */
    public static function publiclyReadableGroup($register_type)
    {
        return in_array($register_type,
            [C\PUBLIC_BROWSE_REQUEST_JOIN, C\PUBLIC_JOIN]);
    }
    /**
     * Reports whether the requester is allowed to read the given group, the
     * same test the resource controller uses. The group is looked up for the
     * requester; a group whose content is publicly viewable counts as
     * readable by the public, and any other group is readable only when the
     * requester
     * presented a valid request token, which an anonymous client has not. So
     * a repository in a group that is not publicly readable cannot be cloned
     * without signing in.
     *
     * @param object $parent the group controller, used to check the request
     *     token
     * @param object $group_model model used to look the group up
     * @param int $group_id group being requested
     * @param int $user_id requester, or the public user when not signed in
     * @return bool true if the requester may read the group
     */
    private function groupIsReadable($parent, $group_model, $group_id,
        $user_id)
    {
        $token_okay = $parent->checkCSRFToken(C\p('CSRF_TOKEN'), $user_id);
        $group = $group_model->getGroupById($group_id, $user_id);
        if (empty($group)) {
            $group_as_root = $group_model->getGroupById($group_id,
                C\ROOT_ID);
            if (!empty($group_as_root) && self::publiclyReadableGroup(
                $group_as_root['REGISTER_TYPE'])) {
                $group = $group_as_root;
                $token_okay = true;
            }
        }
        if (empty($group) || (!$token_okay &&
            !self::publiclyReadableGroup($group['REGISTER_TYPE'] ?? -1))) {
            return false;
        }
        return true;
    }
    /**
     * Returns the folder holding the bare repository for a group's page, or
     * the empty string when the page does not exist or is not a git
     * repository.
     *
     * @param object $group_model model used to resolve the page and folder
     * @param int $group_id group the page belongs to
     * @param string $page_name name of the page
     * @return string the repository folder, or "" if there is none
     */
    private function repositoryFolder($group_model, $group_id, $page_name)
    {
        $wiki_model = $this->parent->model("wiki");
        $page_id = $wiki_model->getPageId($group_id, $page_name,
            L\getLocaleTag());
        if (empty($page_id)) {
            return "";
        }
        $folders = $wiki_model->getGroupPageResourcesFolders($group_id,
            $page_id, "", false, true, true);
        $repo_folder = $folders[0] ?? "";
        if ($repo_folder == "") {
            return "";
        }
        $repository = new LVC\GitRepository($repo_folder);
        $state = $repository->folderState();
        if ($state === "repository") {
            return $repo_folder;
        }
        if ($state === "empty") {
            /* the page's resource path points at an empty folder, which
               happens when a repository page's resource path has just been
               set to a fresh location; a legitimate git request starts a
               bare repository there rather than failing, so the repository
               works without a separate set-up step */
            $repository->initBare($this->configuredGitBranch());
            return ($repository->isRepository()) ? $repo_folder : "";
        }
        /* the folder already holds content that is not a git repository, so
           it is left untouched and this request cannot be served from it;
           the page read view warns about the misconfigured resource path */
        return "";
    }
    /**
     * Gives the branch name a freshly started repository should point its
     * HEAD at, taken from the configured default branch and falling back to
     * the built in default when the configured value has no usable
     * characters.
     *
     * @return string the default branch name for a new repository
     */
    private function configuredGitBranch()
    {
        $branch = preg_replace("/[^A-Za-z0-9._\/-]/", "",
            (string)C\p('GIT_DEFAULT_BRANCH'));
        if ($branch === "") {
            $branch = LVC\GitRepository::DEFAULT_BRANCH;
        }
        return $branch;
    }
    /**
     * Sends the file at a path within a repository to the client, or one of
     * the two listing files built fresh, then ends the request. The path is
     * confined to the repository folder by the git model, so a request
     * cannot reach a file outside it.
     *
     * @param object $parent the group controller, used to send the reply
     * @param object $git_model model that reads the repository files
     * @param string $repo_folder folder holding the bare repository
     * @param string $sub_path path within the repository being requested
     */
    private function serveRepositoryPath($parent, $git_model, $repo_folder,
        $sub_path)
    {
        $is_head = (($_SERVER['REQUEST_METHOD'] ?? "GET") == "HEAD");
        $service = $_GET['service'] ?? "";
        if ($sub_path == "info/refs" &&
            $service == LVC\GitUploadPack::UPLOAD_PACK_SERVICE) {
            $repository = new LVC\GitRepository($repo_folder);
            $upload_pack = new LVC\GitUploadPack($repository);
            $parent->web_site->header("Content-Type: application/" .
                "x-git-upload-pack-advertisement");
            $this->sendGitAdvertisement($parent,
                $upload_pack->advertiseRefs(), $is_head);
        }
        if ($sub_path == "info/refs") {
            $this->sendGitBody($parent, $git_model->infoRefs($repo_folder),
                "text/plain; charset=utf-8", $is_head);
        }
        if ($sub_path == "objects/info/packs") {
            $this->sendGitBody($parent,
                $git_model->objectsInfoPacks($repo_folder),
                "text/plain; charset=utf-8", $is_head);
        }
        $file = $git_model->repositoryFilePath($repo_folder, $sub_path);
        if ($file === false) {
            $this->sendGitStatus($parent, "404 Not Found");
        }
        $parent->web_site->header("Content-Type: application/octet-stream");
        $parent->web_site->header("Content-Length: " . filesize($file));
        if (!$is_head) {
            readfile($file);
        }
        \seekquarry\atto\webExit();
    }
    /**
     * Sends a generated listing body to the client and ends the request.
     *
     * @param object $parent the group controller, used to send the reply
     * @param string $body the listing to send
     * @param string $content_type content type header for the listing
     * @param bool $is_head whether this is a HEAD request, which sends the
     *     headers but not the body
     */
    private function sendGitBody($parent, $body, $content_type, $is_head)
    {
        $parent->web_site->header("Content-Type: " . $content_type);
        $parent->web_site->header("Content-Length: " . strlen($body));
        if (!$is_head) {
            echo $body;
        }
        \seekquarry\atto\webExit();
    }
    /**
     * Sends the smart-protocol ref advertisement and ends the request. The
     * caller has already set the advertisement's content type; this sets the
     * length and writes the body, or just the headers for a HEAD request.
     *
     * @param object $parent the group controller, used to send the reply
     * @param string $body the advertisement pkt-lines to send
     * @param bool $is_head whether this is a HEAD request, which sends the
     *     headers but not the body
     */
    private function sendGitAdvertisement($parent, $body, $is_head)
    {
        $parent->web_site->header("Cache-Control: no-cache");
        $parent->web_site->header("Content-Length: " . strlen($body));
        if (!$is_head) {
            echo $body;
        }
        \seekquarry\atto\webExit();
    }
    /**
     * Records the shape of what was built for a client, when the site is
     * set to do so. A reply the client rejects says nothing in the log
     * about what it was, so this gives its length, its first bytes, how
     * many things the client asked for and how many it said it already
     * held, and where the pack begins within it. A reply that is short, or
     * whose pack never begins, or that answers a request carrying held
     * commits differently from one without, all look different here.
     *
     * @param string $request_body the client's request as read
     * @param string $reply_path the file the reply was built into
     */
    private function traceReply($request_body, $reply_path)
    {
        if (!C\GIT_SERVICE_TRACE) {
            return;
        }
        $lines = LVC\GitUploadPack::decodePktLines($request_body);
        $wants = 0;
        $haves = 0;
        foreach ($lines as $line) {
            if ($line === false) {
                continue;
            }
            if (str_starts_with($line, "want ")) {
                $wants++;
            }
            if (str_starts_with($line, "have ")) {
                $haves++;
            }
        }
        $size = filesize($reply_path);
        $head = (string)file_get_contents($reply_path, false, null, 0, 32);
        $at = strpos((string)file_get_contents($reply_path, false, null, 0,
            4096), "PACK");
        $line = date("c") . " reply length=" . $size . " wants=" . $wants .
            " haves=" . $haves . " firstbytes=" . bin2hex($head) .
            " packstartsat=" . (($at === false) ? "notfound" : $at) . "\n";
        file_put_contents(C\WORK_DIRECTORY . "/log/git_trace.log", $line,
            FILE_APPEND);
    }
    /**
     * Answers a client whose pack could not be built, and adds what was
     * being asked for to the record of the failure. Without this the
     * request ends with no headers and no body at all, which reaches the
     * client as a connection that stops without warning or as a complaint
     * that the pack has no header, neither of which says anything about
     * the cause. The server already records that a deferred request
     * failed, but with the message alone; the line written here adds where
     * it was thrown and which objects were being asked for. It goes to the
     * ordinary error log beside that one rather than to the trace file,
     * since a failed pack is rare, is worth recording whether or not
     * anyone has turned tracing on, and belongs where the rest of the
     * request's trouble is already written.
     *
     * @param object $parent the group controller, used to send the reply
     * @param string $request_body the client's request as read
     * @param \Throwable $error what went wrong while the pack was built
     */
    private function sendGitBuildFailure($parent, $request_body, $error)
    {
        $wants = [];
        $haves = 0;
        $pkt_lines = LVC\GitUploadPack::decodePktLines(
            $request_body);
        foreach ($pkt_lines as $line) {
            if ($line === false) {
                continue;
            }
            if (str_starts_with($line, "want ")) {
                $wants[] = substr($line, strlen("want "),
                    LVC\GitUploadPack::SHA_HEX_LENGTH);
            }
            if (str_starts_with($line, "have ")) {
                $haves++;
            }
        }
        error_log("Git pack build failed: " . $error->getMessage() .
            " at " . basename($error->getFile()) . ":" .
            $error->getLine() . " wants=" . implode(",", $wants) .
            " haves=" . $haves);
        $body = LVC\GitUploadPack::pktLine(
            "ERR " . $error->getMessage() .
            "\n");
        $parent->web_site->header("Content-Type: application/" .
            "x-git-upload-pack-result");
        $parent->web_site->header("Cache-Control: no-cache");
        $parent->web_site->header("Content-Length: " . strlen($body));
        echo $body;
        \seekquarry\atto\webExit();
    }
    /**
     * Gives what the client sent in the body of its request, undoing the
     * compression where the client says it compressed it. Git compresses
     * a fetch request once the list of what the client already holds grows
     * past a small size, which happens on any repository with a real
     * history; read without undoing that, the body is a run of compressed
     * bytes rather than the lines it stands for, and nothing in it can be
     * understood.
     *
     * @return string the body as the client wrote it
     */
    private function requestBody()
    {
        $body = $_SERVER['CONTENT'] ?? "";
        $said = strtolower(trim($_SERVER['HTTP_CONTENT_ENCODING'] ?? ""));
        $this->traceRequest($body, $said);
        if ($body === "" || ($said !== "gzip" && $said !== "x-gzip")) {
            return $body;
        }
        $undone = @gzdecode($body);
        if ($undone === false) {
            return $body;
        }
        return $undone;
    }
    /**
     * Records the shape of what a client sent, when the site is set to do
     * so. A request that arrives as something other than the lines it
     * should be leaves nothing in the log to say what it was instead, so a
     * failure can only be guessed at. One line per request says how long
     * the body was, its first bytes, what the client said it encoded them
     * as, what the length header claimed, and how the first line of the
     * body reads, which together tell a body that is compressed from one
     * that is short from one that is fine.
     *
     * @param string $body what the client sent
     * @param string $encoding what the client said it encoded the body as
     */
    private function traceRequest($body, $encoding)
    {
        if (!C\GIT_SERVICE_TRACE) {
            return;
        }
        $first = substr($body, 0, 16);
        $line = date("c") . " path=" . ($_SERVER['REQUEST_URI'] ?? "") .
            " method=" . ($_SERVER['REQUEST_METHOD'] ?? "") .
            " bodylength=" . strlen($body) .
            " saidlength=" . ($_SERVER['HTTP_CONTENT_LENGTH'] ?? "none") .
            " encoding=" . ($encoding === "" ? "none" : $encoding) .
            " firstbytes=" . bin2hex($first) .
            " firstline=" . substr($body, 0, 4) .
            " deferred=" . (empty($_SERVER['CONTENT']) ? "nobody" : "body") .
            "\n";
        file_put_contents(C\WORK_DIRECTORY . "/log/git_trace.log", $line,
            FILE_APPEND);
    }
    /**
     * Answers a client's clone or fetch request over the smart protocol. The
     * client has posted which commits it wants, which it already has, and how
     * much history to include; this reads that request and has GitUploadPack
     * produce the reply &mdash; the acknowledgments and the packfile of just
     * the objects the client is missing &mdash; a piece at a time, each
     * written to a temporary file as it is made, so a long history never has
     * to be held in memory while it is built. The finished file's size is
     * what lets the reply state its length, which an HTTP/2 response must
     * do, and the file is then sent and removed. Building before sending
     * does mean a very large clone occupies the server while its packfile is
     * made.
     *
     * @param object $parent the group controller, used to send the reply
     * @param string $repo_folder folder holding the bare repository
     */
    private function serveUploadPack($parent, $repo_folder)
    {
        $repository = new LVC\GitRepository($repo_folder);
        $upload_pack = new LVC\GitUploadPack($repository);
        $request_body = $this->requestBody();
        $reply_path = tempnam(sys_get_temp_dir(), 'yioop_git_pack_');
        $handle = fopen($reply_path, "wb");
        try {
            foreach ($upload_pack->uploadPackChunks($request_body)
                as $chunk) {
                fwrite($handle, $chunk);
            }
        } catch (\Throwable $error) {
            fclose($handle);
            unlink($reply_path);
            $this->sendGitBuildFailure($parent, $request_body, $error);
        }
        fclose($handle);
        $this->traceReply($request_body, $reply_path);
        $parent->web_site->header("Content-Type: application/" .
            "x-git-upload-pack-result");
        $parent->web_site->header("Cache-Control: no-cache");
        $parent->web_site->header("Content-Length: " .
            filesize($reply_path));
        /* Where the request is being run as a deferred task the reply is
           handed over as a file, so the server drains it from disk;
           printing it would gather the whole pack in memory. Where it is
           not, nothing has been handed over and the file is printed here
           as before. */
        $handed_over = method_exists($parent->web_site,
            "deferBodyFromFile") &&
            $parent->web_site->deferBodyFromFile($reply_path, true);
        if ($handed_over) {
            \seekquarry\atto\webExit();
        }
        readfile($reply_path);
        unlink($reply_path);
        \seekquarry\atto\webExit();
    }
    /**
     * Works out who is making a write request and whether they may edit the
     * group, reading the user name and password from the request's basic
     * authorization header. Returns the user id when the sign-in is valid
     * and the user owns the group, is the root account, or has full member
     * access; returns false otherwise, which leads the client to be asked to
     * sign in.
     *
     * @param object $parent the group controller, used to reach the models
     * @param object $group_model model used to look up group access
     * @param int $group_id group being written to
     * @return int|bool the editing user's id, or false if not allowed
     */
    private function authenticatedEditor($parent, $group_model, $group_id)
    {
        $header = $_SERVER['HTTP_AUTHORIZATION'] ?? "";
        if (!str_starts_with($header, "Basic ")) {
            return false;
        }
        $decoded = base64_decode(substr($header, strlen("Basic ")), true);
        if ($decoded === false || !str_contains($decoded, ":")) {
            return false;
        }
        list($username, $password) = explode(":", $decoded, 2);
        if (!$this->passwordAccepted($parent, $username, $password)) {
            return false;
        }
        $user_id = $group_model->getUserId($username);
        if (empty($user_id)) {
            return false;
        }
        $group = $group_model->getGroupById($group_id, $user_id);
        if (empty($group)) {
            return false;
        }
        $can_edit = $group['OWNER_ID'] == $user_id ||
            $user_id == C\ROOT_ID ||
            $group['MEMBER_ACCESS'] == C\GROUP_READ_WIKI;
        return ($can_edit) ? $user_id : false;
    }
    /**
     * Checks a person's Git application code, which they send in place of a
     * password when pushing, against the code saved for their account. The
     * code is accepted only while it has not expired; a code set never to
     * expire always passes the time test. A successful check is remembered
     * for a short time so a single git push, which resends the same
     * credentials with each object it uploads, does not repeat the lookup on
     * every one of its hundreds of requests. The remembered result lives
     * only in this long-running server's memory, keyed by a hash of the
     * credentials so the code itself is not held, and only a successful
     * check is kept. Expired entries are dropped whenever a fresh check is
     * needed.
     *
     * @param object $parent the group controller, used to reach the models
     * @param string &$username account name offered for the push
     * @param string $password the application code offered in place of a
     *      password
     * @return bool whether the code is accepted for the account
     */
    private function passwordAccepted($parent, &$username, $password)
    {
        static $accepted = [];
        $now = time();
        $key = hash('sha256', $username . "\x00" . $password);
        if (isset($accepted[$key]) && $accepted[$key]["expires"] > $now) {
            $username = $accepted[$key]["username"];
            return true;
        }
        foreach ($accepted as $cached_key => $entry) {
            if ($entry["expires"] <= $now) {
                unset($accepted[$cached_key]);
            }
        }
        $git_model = $parent->model("git");
        $user_id = $git_model->getUserId($username);
        if (empty($user_id)) {
            return false;
        }
        $record = $git_model->getAppCode($user_id);
        if (empty($record['APP_CODE'])) {
            return false;
        }
        $unexpired = ($record['EXPIRES'] == C\FOREVER ||
            $record['EXPIRES'] > $now);
        if (!$unexpired || !hash_equals($record['APP_CODE'], $password)) {
            return false;
        }
        $cache_expires = $now + C\GIT_AUTH_CACHE_TIMEOUT;
        if ($record['EXPIRES'] != C\FOREVER &&
            $record['EXPIRES'] < $cache_expires) {
            $cache_expires = $record['EXPIRES'];
        }
        $accepted[$key] = ["username" => $username,
            "expires" => $cache_expires];
        return true;
    }
    /**
     * Answers a capabilities request, telling the client which methods the
     * repository accepts and that it speaks the WebDAV protocol a push
     * needs, then ends the request.
     *
     * @param object $parent the group controller, used to send the reply
     */
    private function serveOptions($parent)
    {
        $parent->web_site->header("HTTP/1.1 200 OK");
        $parent->web_site->header("Allow: GET, HEAD, PUT, DELETE, MKCOL, " .
            "MOVE, PROPFIND, OPTIONS, LOCK, UNLOCK");
        $parent->web_site->header("Content-Length: 0");
        \seekquarry\atto\webExit();
    }
    /**
     * Writes the request body to a file in the repository, used when a
     * client uploads a new object or updates a ref, then ends the request.
     *
     * @param object $parent the group controller, used to send the reply
     * @param object $git_model model that resolves the write path safely
     * @param string $repo_folder folder holding the bare repository
     * @param string $sub_path path within the repository to write
     */
    private function servePut($parent, $git_model, $repo_folder, $sub_path)
    {
        $path = $git_model->repositoryWritePath($repo_folder, $sub_path);
        if ($path === false) {
            $this->sendGitStatus($parent, "403 Forbidden");
        }
        $content = $_SERVER['CONTENT'] ?? "";
        $content_length = strlen($content);
        $blob_cap = L\metricToInt(C\p('GIT_MAX_BLOB_SIZE'));
        $push_cap = L\metricToInt(C\p('GIT_MAX_PUSH_SIZE'));
        $repo_cap = C\MAX_PAGE_RESOURCE_MEMORY;
        $is_pack = (strpos($sub_path, "objects/pack") !== false);
        $repo_size = ($repo_cap > 0) ?
            $this->repositorySize($repo_folder) : 0;
        if (!LVC\GitRepository::withinPushLimits(
            $content_length, $repo_size,
            $is_pack, $blob_cap, $push_cap, $repo_cap)) {
            $this->sendGitStatus($parent, "413 Payload Too Large");
        }
        $existed = file_exists($path);
        file_put_contents($path, $content);
        $this->sendGitStatus($parent,
            ($existed) ? "204 No Content" : "201 Created");
    }
    /**
     * Adds up the sizes of every file inside a repository folder so the
     * maximum repository size cap can be checked before a new object is
     * written into it. A folder that does not exist counts as empty.
     *
     * @param string $repo_folder folder holding the bare repository
     * @return int total number of bytes the repository currently occupies
     */
    private function repositorySize($repo_folder)
    {
        if (!is_dir($repo_folder)) {
            return 0;
        }
        $total = 0;
        $items = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($repo_folder,
            \FilesystemIterator::SKIP_DOTS));
        foreach ($items as $item) {
            if ($item->isFile()) {
                $total += $item->getSize();
            }
        }
        return $total;
    }
    /**
     * Creates a folder in the repository, used when a client makes an object
     * folder before pushing objects into it, then ends the request.
     *
     * @param object $parent the group controller, used to send the reply
     * @param object $git_model model that resolves the folder path safely
     * @param string $repo_folder folder holding the bare repository
     * @param string $sub_path folder path within the repository to make
     */
    private function serveMkcol($parent, $git_model, $repo_folder, $sub_path)
    {
        $path = ($git_model->repositoryWritePath($repo_folder, $sub_path));
        if ($path === false) {
            $this->sendGitStatus($parent, "403 Forbidden");
        }
        if (file_exists($path)) {
            $this->sendGitStatus($parent, "405 Method Not Allowed");
        }
        mkdir($path, 0777, true);
        $this->sendGitStatus($parent, "201 Created");
    }
    /**
     * Renames a file in the repository to the path named in the request's
     * destination header, used when a client moves a freshly uploaded object
     * into its final place, then ends the request. Both paths are confined
     * to the repository folder.
     *
     * @param object $parent the group controller, used to send the reply
     * @param object $git_model model that resolves the paths safely
     * @param string $repo_folder folder holding the bare repository
     * @param string $sub_path source path within the repository
     */
    private function serveMove($parent, $git_model, $repo_folder, $sub_path)
    {
        $source = $git_model->repositoryWritePath($repo_folder, $sub_path);
        $destination = $git_model->repositoryWritePath($repo_folder,
            $this->destinationSubPath($_SERVER['HTTP_DESTINATION'] ?? ""));
        if ($source === false || $destination === false ||
            !file_exists($source)) {
            $this->sendGitStatus($parent, "409 Conflict");
        }
        $existed = file_exists($destination);
        rename($source, $destination);
        $this->sendGitStatus($parent,
            ($existed) ? "204 No Content" : "201 Created");
    }
    /**
     * Reads the path within the repository out of a WebDAV destination
     * header, which gives the whole address a file is being moved to; the
     * part after the ".git/" marker is the path inside the repository.
     *
     * @param string $destination the destination header value
     * @return string the path within the repository, or "" if none found
     */
    private function destinationSubPath($destination)
    {
        $marker = ".git/";
        $at = strpos($destination, $marker);
        if ($at === false) {
            return "";
        }
        return substr($destination, $at + strlen($marker));
    }
    /**
     * Removes a file or empty folder from the repository, then ends the
     * request.
     *
     * @param object $parent the group controller, used to send the reply
     * @param object $git_model model that resolves the path safely
     * @param string $repo_folder folder holding the bare repository
     * @param string $sub_path path within the repository to remove
     */
    private function serveDelete($parent, $git_model, $repo_folder, $sub_path)
    {
        $path = $git_model->repositoryWritePath($repo_folder, $sub_path);
        if ($path === false || !file_exists($path)) {
            $this->sendGitStatus($parent, "404 Not Found");
        }
        if (is_dir($path)) {
            rmdir($path);
        } else {
            unlink($path);
        }
        $this->sendGitStatus($parent, "204 No Content");
    }
    /**
     * Grants a lock and returns the lock token the client quotes on its
     * following writes, then ends the request. The lock is granted without
     * being tracked, which is enough for a client that locks only to follow
     * the protocol during its own push.
     *
     * @param object $parent the group controller, used to send the reply
     */
    private function serveLock($parent)
    {
        $token = "opaquelocktoken:" . bin2hex(random_bytes(16));
        $body = '<?xml version="1.0" encoding="utf-8"?>' .
            '<D:prop xmlns:D="DAV:"><D:lockdiscovery><D:activelock>' .
            '<D:locktype><D:write/></D:locktype>' .
            '<D:lockscope><D:exclusive/></D:lockscope>' .
            '<D:depth>0</D:depth><D:timeout>Second-3600</D:timeout>' .
            '<D:locktoken><D:href>' . $token . '</D:href></D:locktoken>' .
            '</D:activelock></D:lockdiscovery></D:prop>';
        $parent->web_site->header("HTTP/1.1 200 OK");
        $parent->web_site->header("Lock-Token: <" . $token . ">");
        $parent->web_site->header("Content-Type: text/xml; charset=utf-8");
        $parent->web_site->header("Content-Length: " . strlen($body));
        echo $body;
        \seekquarry\atto\webExit();
    }
    /**
     * Lists a file or folder in the repository, and the immediate contents
     * of a folder when the client asks for one level, in the multi-status
     * form the WebDAV protocol uses. A client reads this while pushing to
     * learn which objects are already present. The request ends here.
     *
     * @param object $parent the group controller, used to send the reply
     * @param object $git_model model that resolves the path safely
     * @param string $repo_folder folder holding the bare repository
     * @param string $sub_path path within the repository being listed
     * @param string $base_href address of the repository, used to build the
     *     address of each listed item
     */
    private function servePropfind($parent, $git_model, $repo_folder,
        $sub_path, $base_href)
    {
        $sub_path = rtrim($sub_path, "/");
        $target = $git_model->repositoryWritePath($repo_folder, $sub_path);
        if ($target === false || !file_exists($target)) {
            $this->sendGitStatus($parent, "404 Not Found");
        }
        $responses = $this->propfindEntry($base_href, $sub_path, $target);
        $depth = $_SERVER['HTTP_DEPTH'] ?? "0";
        if (is_dir($target) && $depth != "0") {
            foreach (scandir($target) as $child) {
                if ($child == "." || $child == "..") {
                    continue;
                }
                $child_sub = ($sub_path == "") ? $child :
                    $sub_path . "/" . $child;
                $responses .= $this->propfindEntry($base_href, $child_sub,
                    $target . "/" . $child);
            }
        }
        $body = '<?xml version="1.0" encoding="utf-8"?>' .
            '<D:multistatus xmlns:D="DAV:">' . $responses .
            '</D:multistatus>';
        $parent->web_site->header("HTTP/1.1 207 Multi-Status");
        $parent->web_site->header("Content-Type: text/xml; charset=utf-8");
        $parent->web_site->header("Content-Length: " . strlen($body));
        echo $body;
        \seekquarry\atto\webExit();
    }
    /**
     * Builds the multi-status entry describing one file or folder: its
     * address, whether it is a folder, and, for a file, its size.
     *
     * @param string $base_href address of the repository
     * @param string $sub_path path within the repository of this item
     * @param string $target the item's path on disk
     * @return string the multi-status entry
     */
    private function propfindEntry($base_href, $sub_path, $target)
    {
        $is_dir = is_dir($target);
        $href = ($sub_path == "") ? $base_href . "/" :
            $base_href . "/" . $sub_path . (($is_dir) ? "/" : "");
        $resource_type = ($is_dir) ? "<D:collection/>" : "";
        $length = ($is_dir) ? "" :
            "<D:getcontentlength>" . filesize($target) .
            "</D:getcontentlength>";
        $supported_lock = "<D:supportedlock><D:lockentry>" .
            "<D:lockscope><D:exclusive/></D:lockscope>" .
            "<D:locktype><D:write/></D:locktype>" .
            "</D:lockentry></D:supportedlock>";
        return "<D:response><D:href>" . $href . "</D:href>" .
            "<D:propstat><D:prop><D:resourcetype>" . $resource_type .
            "</D:resourcetype>" . $length . $supported_lock . "</D:prop>" .
            "<D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>";
    }
    /**
     * Sends a bare status line, used to refuse a request, and ends the
     * request.
     *
     * @param object $parent the group controller, used to send the reply
     * @param string $status the HTTP status text, for example "404 Not
     *     Found"
     */
    private function sendGitStatus($parent, $status)
    {
        $parent->web_site->header("HTTP/1.1 " . $status);
        \seekquarry\atto\webExit();
    }
}
