SeekquarrySeekquarry - Yioop Repo - Seekquarry

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

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

/**
 * GitComponent lets a git client clone, fetch from and push to the
 * repository behind a Git Repository wiki page, over the same web address
 * as the page. 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. A clone of a group the public may
 * not read asks the client to sign in with a user name and Git application
 * code, and is served when that user belongs to the group. Pushing, which
 * changes the repository, is handled separately and always requires the
 * requester to sign in.
 *
 * @author Chris Pollett
 */
class GitComponent extends SocialComponent
{
    /**
     * REPLY_STEP is how many bytes of a clone's reply serveUploadPack
     * writes before it gives the web server a turn, under the atto server,
     * so the part already written is sent and other requests are served
     * while the rest is built. Giving a turn after every object would
     * spend more time switching than building on a pack of many small
     * objects.
     */
    const REPLY_STEP = 1048576;
    /**
     * gitService is where every git request for a group's repository page
     * arrives, whether a clone, a fetch or a push. The request names the
     * group, the page, and the path within the repository. A clone or
     * fetch first goes through checkReader, which
     * lets a reader of a group that is not public sign in with their user
     * name and Git application code. 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. A request
     * that changes the repository goes to serveWrite. 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";
        $wants_receive_pack = ($method == "POST" &&
            $sub_path == LVC\GitReceivePack::RECEIVE_PACK_SERVICE) ||
            ($sub_path == "info/refs" && ($_GET['service'] ?? "") ==
            LVC\GitReceivePack::RECEIVE_PACK_SERVICE);
        if ($wants_receive_pack) {
            $this->serveReceivePack($parent, $group_model, $group_id,
                $page_name, $method);
        }
        $is_upload_pack = ($method == "POST" &&
            $sub_path == LVC\GitUploadPack::UPLOAD_PACK_SERVICE);
        if ($is_upload_pack || $method == "GET" || $method == "HEAD") {
            $this->checkReader($parent, $group_model, $group_id, $page_name);
            $repo_folder = $this->repositoryFolder($group_model, $group_id,
                $page_name);
            if ($repo_folder == "") {
                $this->sendGitStatus($parent, "404 Not Found");
            }
            if ($is_upload_pack) {
                $this->serveUploadPack($parent, $repo_folder);
            }
            $this->serveRepositoryPath($parent, $git_model, $repo_folder,
                $sub_path);
        }
        $this->serveWrite($parent, $group_model, $git_model, $group_id,
            $page_name, $sub_path, $method);
    }
    /**
     * serveWrite carries out the requests of the older "dumb" push, each of
     * which changes the repository. git falls back to it only when a
     * server does not offer git-receive-pack, which serveReceivePack now
     * offers, so it serves older clients. A dumb push uses WebDAV, the
     * protocol for creating, moving and deleting files on a web server.
     * Unlike a clone, each such request needs the requester to
     * sign in and to have edit access to the group. Once allowed, the
     * WebDAV method asked for 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, $page_name);
        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");
        }
    }
    /**
     * publiclyReadableGroup takes a group's register type and says if
     * anyone, signed in or not, may read the group, and so clone its
     * repositories over git. 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]);
    }
    /**
     * groupIsReadable applies to a git request the same read test the
     * resource controller applies to a page's files. 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;
    }
    /**
     * repositoryFolder finds where on disk a Git Repository page keeps its
     * repository: the page's resource folder, or the folder its resource
     * path names. That folder holds a bare repository, which is git's own
     * files with no checked-out copy of the project. An empty folder is
     * made into a new bare repository. The empty string comes back when
     * the page does not exist or its folder holds something else.
     *
     * @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,
           or it does not exist, so it is left untouched and this request
           cannot be served from it; the page read view warns about the
           misconfigured resource path */
        return "";
    }
    /**
     * configuredGitBranch picks the branch a new repository starts on. It
     * is the site's GIT_DEFAULT_BRANCH setting with any character git does
     * not allow in a branch name taken out. When nothing usable is left,
     * GitRepository::DEFAULT_BRANCH is used. The repository's HEAD file,
     * which names the current branch, then points at it.
     *
     * @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;
    }
    /**
     * serveRepositoryPath answers a clone or fetch that asks for one path
     * in the repository. For info/refs it builds the list of branches and
     * tags, in the smart protocol's form when the client asks for
     * git-upload-pack; for objects/info/packs it builds the list of pack
     * files; for any other path it sends that file. The request then
     * ends. 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();
    }
    /**
     * sendGitBody writes one of the listings serveRepositoryPath builds,
     * with its content type and length, and then 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();
    }
    /**
     * sendGitAdvertisement writes the first reply of a clone or fetch over
     * the smart protocol: the list of branches and tags and the commit each
     * names, which git calls the ref advertisement. The caller has already
     * set its content type; this sets the length and writes the body, or
     * only the headers for a HEAD request, and ends the 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();
    }
    /**
     * traceReply writes one log line about a reply built for a clone or
     * fetch, when the site's settings ask for it. 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");
        L\crawlLog("git reply length=" . $size . " wants=" . $wants .
            " haves=" . $haves . " firstbytes=" . bin2hex($head) .
            " packstartsat=" . (($at === false) ? "notfound" : $at));
    }
    /**
     * sendGitBuildFailure is called when the pack file a clone or fetch
     * asked for could not be built. It sends the client an error reply and
     * logs what the client was asking for. 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
     * @param bool $headers_set whether serveUploadPack already set the
     *      reply's content type and cache headers, which are then not set
     *      a second time
     */
    private function sendGitBuildFailure($parent, $request_body, $error,
        $headers_set = false)
    {
        $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");
        if (!$headers_set) {
            $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();
    }
    /**
     * requestBody reads the body of a clone or fetch request, and
     * uncompresses it when the client's Content-Encoding header says it is
     * gzip compressed. 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;
    }
    /**
     * traceRequest writes one log line about the body of a clone or fetch
     * request, when the site's settings ask for it. 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);
        L\crawlLog("git 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"));
    }
    /**
     * serveUploadPack does the main work of a clone or fetch over git's
     * smart protocol, the one that sends only the objects a client lacks.
     * The client has posted which commits it wants, which it already has,
     * and how much history to include. serveUploadPack reads that request
     * and has GitUploadPack produce the reply: the acknowledgments and the
     * packfile of just the objects the client is missing. The reply is
     * made a piece at a time, and each piece is written to a temporary
     * file, so a long history is never held in memory.
     *
     * Under the atto server the reply starts going out once its first
     * piece is written, after the walk of the history that decides which
     * objects to send. The file is handed to the server as a growing file
     * with deferBodyFromFile, and the server sends what has been written
     * while the rest is built. A turn is given to the server every
     * REPLY_STEP bytes when the request runs in a fiber, a part of the
     * server process that can pause and be picked up again; a worker
     * process builds without pausing while the front process sends. Git
     * then sees data within the time the walk takes, not after the whole
     * pack is built. Under another web server, or on a protocol that
     * cannot send a body of unknown length, the whole file is built first
     * and sent with its length.
     *
     * @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();
        L\makePath(C\TEMP_DIR);
        $reply_path = tempnam(C\TEMP_DIR, 'yioop_git_pack_');
        $handle = fopen($reply_path, "wb");
        $headers_set = false;
        $growing = false;
        $since_turn = 0;
        try {
            foreach ($upload_pack->uploadPackChunks($request_body)
                as $chunk) {
                fwrite($handle, $chunk);
                $since_turn += strlen($chunk);
                if (!$headers_set) {
                    fflush($handle);
                    $parent->web_site->header("Content-Type: application/" .
                        "x-git-upload-pack-result");
                    $parent->web_site->header("Cache-Control: no-cache");
                    $headers_set = true;
                    $growing = method_exists($parent->web_site,
                        "deferBodyFromFile") &&
                        $parent->web_site->deferBodyFromFile($reply_path,
                        true, true);
                }
                if ($growing && $since_turn >= self::REPLY_STEP) {
                    fflush($handle);
                    $since_turn = 0;
                    if (\Fiber::getCurrent() !== null) {
                        \Fiber::suspend();
                    }
                }
            }
        } catch (\Throwable $error) {
            fclose($handle);
            if ($growing) {
                /* Part of the reply has gone out, so no error reply can
                   follow it; the client sees the pack end early. */
                error_log("Git pack build failed after its reply began: " .
                    $error->getMessage() . " at " .
                    basename($error->getFile()) . ":" . $error->getLine());
                \seekquarry\atto\webExit();
            }
            unlink($reply_path);
            $this->sendGitBuildFailure($parent, $request_body, $error,
                $headers_set);
        }
        fclose($handle);
        $this->traceReply($request_body, $reply_path);
        if ($growing) {
            \seekquarry\atto\webExit();
        }
        if (!$headers_set) {
            $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();
    }
    /**
     * authenticatedEditor decides whether a push may change the group's
     * repository. authenticatedUser finds who signed in with the request.
     * That user may push when they own the group, are root, or the group
     * lets members edit its wiki. The user's id comes back in that case,
     * and false otherwise, which leads serveWrite to ask the client 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
     * @param string $page_name name of the page holding the repository,
     *      whose Git application code the request must carry
     * @return int|bool the editing user's id, or false if not allowed
     */
    private function authenticatedEditor($parent, $group_model, $group_id,
        $page_name)
    {
        $user_id = $this->authenticatedUser($parent, $group_model, $group_id,
            $page_name);
        if ($user_id === false) {
            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;
    }
    /**
     * serveReceivePack answers the two requests of a push over git's smart
     * protocol: the GET of info/refs with service=git-receive-pack, which
     * gets GitReceivePack::advertiseRefs, and the POST to git-receive-pack,
     * which carries the push's commands and pack file. Both need a user
     * who may edit the group, as authenticatedEditor says; without one the
     * client gets 401 Unauthorized and a WWW-Authenticate header, so git
     * sends the user name and Git application code from the url or asks
     * the person for them. A push larger than the site's push size limit,
     * or one that would take the repository past its size limit, gets 413
     * Payload Too Large. The request ends here.
     *
     * @param object $parent the group controller, used to send the reply
     * @param object $group_model model used to look up group access
     * @param int $group_id group the repository's page belongs to
     * @param string $page_name name of the page holding the repository
     * @param string $method GET or HEAD for the advertisement, POST for the
     *      push itself
     */
    private function serveReceivePack($parent, $group_model, $group_id,
        $page_name, $method)
    {
        if ($this->authenticatedEditor($parent, $group_model, $group_id,
            $page_name) === 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");
        }
        $receive_pack = new LVC\GitReceivePack(
            new LVC\GitRepository($repo_folder));
        if ($method != "POST") {
            $parent->web_site->header("Content-Type: application/" .
                "x-git-receive-pack-advertisement");
            $this->sendGitAdvertisement($parent,
                $receive_pack->advertiseRefs(), $method == "HEAD");
        }
        $body = $this->requestBody();
        $repo_cap = C\MAX_PAGE_RESOURCE_MEMORY;
        $repo_size = ($repo_cap > 0) ? $this->repositorySize($repo_folder) :
            0;
        /* the body is a whole pack, so the cap on one object, 0 here, is
           not the one that applies */
        if (!LVC\GitRepository::withinPushLimits(strlen($body), $repo_size,
            0, $repo_cap)) {
            $this->sendGitStatus($parent, "413 Payload Too Large");
        }
        $report = $receive_pack->receive($body);
        $parent->web_site->header("Content-Type: application/" .
            "x-git-receive-pack-result");
        $parent->web_site->header("Cache-Control: no-cache");
        $parent->web_site->header("Content-Length: " . strlen($report));
        echo $report;
        \seekquarry\atto\webExit();
    }
    /**
     * authenticatedUser works out who sent a git request, from the user
     * name and password in the request's basic authorization header. A git
     * client sends that header after the server answers 401 Unauthorized,
     * using the name and password in the clone url, such as
     * https://root:code@host/group/10/page.git, or ones it asks the person
     * for. The password is the person's Git application code for this
     * repository page, which passwordAccepted checks; the page is looked up
     * by name in the site's default locale, as initializeGitAppCode does
     * when it makes the code. checkReader calls this for a clone or fetch,
     * and authenticatedEditor for a push.
     *
     * @param object $parent the group controller, used to reach the models
     * @param object $group_model model used to look up the user's id
     * @param int $group_id group the repository's page belongs to
     * @param string $page_name name of the page holding the repository
     * @return int|bool the user's id, or false when the header is missing,
     *      cannot be read, or names a user whose code is not accepted
     */
    private function authenticatedUser($parent, $group_model, $group_id,
        $page_name)
    {
        $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);
        $page_id = $parent->model("wiki")->getPageId($group_id, $page_name,
            C\p('DEFAULT_LOCALE'));
        if (empty($page_id) || !$this->passwordAccepted($parent, $username,
            $password, $group_id, $page_id)) {
            return false;
        }
        $user_id = $group_model->getUserId($username);
        return (empty($user_id)) ? false : $user_id;
    }
    /**
     * checkReader lets a clone or fetch go on only when the requester may
     * read the group, and otherwise ends the request. A requester signed in
     * to Yioop in a browser session, or any requester of a group the public
     * may read, passes as groupIsReadable says. A git client has no
     * session, so for a group the public may not read, such as an Invite
     * Only group, it is asked to sign in: with no accepted user name and
     * Git application code it gets 401 Unauthorized and a
     * WWW-Authenticate header, which makes git send the name and code from
     * the clone url, or ask the person for them. A signed-in user who
     * belongs to the group, or root, passes; anyone else gets 404 Not Found,
     * so a group's existence is not shown to someone who may not read it.
     *
     * @param object $parent the group controller, used to send the reply
     * @param object $group_model model used to look the group up
     * @param int $group_id group being read
     * @param string $page_name name of the page holding the repository,
     *      whose Git application code a signed-in request must carry
     */
    private function checkReader($parent, $group_model, $group_id,
        $page_name)
    {
        $user_id = $_SESSION['USER_ID'] ?? C\PUBLIC_USER_ID;
        if ($this->groupIsReadable($parent, $group_model, $group_id,
            $user_id)) {
            return;
        }
        $user_id = $this->authenticatedUser($parent, $group_model, $group_id,
            $page_name);
        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();
        }
        if (empty($group_model->getGroupById($group_id, $user_id))) {
            $this->sendGitStatus($parent, "404 Not Found");
        }
    }
    /**
     * passwordAccepted compares the password sent with a git request to the
     * Git application code saved for that account and that repository page,
     * as GitModel::getAppCode gives it. A person sends this code, not their
     * Yioop password, when they push, or when they clone a group the public
     * may not read. 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
     * @param int $group_id group the repository's page belongs to
     * @param int $page_id the repository page's id in the default locale
     * @return bool whether the code is accepted for the account and page
     */
    private function passwordAccepted($parent, &$username, $password,
        $group_id, $page_id)
    {
        static $accepted = [];
        $now = time();
        $key = hash('sha256', $username . "\x00" . $password . "\x00" .
            $group_id . "\x00" . $page_id);
        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, $group_id, $page_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;
    }
    /**
     * serveOptions answers the OPTIONS request a git client sends before a
     * push. The reply lists the request methods the repository accepts and
     * says the server speaks WebDAV, the protocol for creating, moving and
     * deleting files on a web server, which a push over plain HTTP uses.
     * The request then ends.
     *
     * @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();
    }
    /**
     * servePut stores what a pushing client uploads, such as a new object
     * or the new commit a branch name points at, as a file in the
     * repository. A write that would take the repository past its size
     * limit is refused. The request then ends.
     *
     * @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'));
        $repo_cap = C\MAX_PAGE_RESOURCE_MEMORY;
        $repo_size = ($repo_cap > 0) ?
            $this->repositorySize($repo_folder) : 0;
        if (!LVC\GitRepository::withinPushLimits(
            $content_length, $repo_size,
            $blob_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");
    }
    /**
     * repositorySize totals the bytes of all files in a repository folder.
     * servePut uses it to check the repository size limit before writing
     * a new object. 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;
    }
    /**
     * serveMkcol answers the MKCOL request of WebDAV (the protocol for
     * writing files on a web server), which makes a folder. A pushing
     * client sends it to make an object folder before it uploads objects
     * into it. The request then ends.
     *
     * @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");
    }
    /**
     * serveMove answers the MOVE request of WebDAV (the protocol for
     * writing files on a web server). A pushing client uploads an
     * object under a temporary name and then moves it to its final path,
     * which the request's Destination header names. The request then ends.
     * 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");
    }
    /**
     * destinationSubPath takes the Destination header of a MOVE request,
     * which a pushing client sends over WebDAV, the protocol for writing
     * files on a web server, to rename a file.
     * The header holds the whole address the file is moving to, and the
     * part after ".git/" 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));
    }
    /**
     * serveDelete answers the DELETE request of WebDAV (the protocol for
     * writing files on a web server), used by a pushing client
     * to take away a file or an empty folder in the repository. The
     * request then ends.
     *
     * @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");
    }
    /**
     * serveLock answers the LOCK request of WebDAV (the protocol for
     * writing files on a web server) with a lock token, which the
     * pushing client then quotes on its writes. The request then ends. 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();
    }
    /**
     * servePropfind answers the PROPFIND request of WebDAV (the protocol
     * for writing files on a web server). PROPFIND asks about a
     * file or folder in the repository. The reply describes it and, when
     * the client asks for one level, each thing directly inside a folder,
     * as a WebDAV multi-status document. 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();
    }
    /**
     * propfindEntry makes the part of a PROPFIND reply that covers one file
     * or folder. PROPFIND is the request of WebDAV (the protocol for
     * writing files on a web server) that asks about files. The part gives
     * the address, whether it is a folder, and the size of a file.
     *
     * @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>";
    }
    /**
     * sendGitStatus ends a git request with only an HTTP status line and no
     * body. It is how a request is refused, as with 404 Not Found.
     *
     * @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();
    }
}