<?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();
}
}