<?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;
/**
* Provides the group-controller activity that serves a git wiki page's
* repository over the wire so a client can clone or fetch it. Git is a group
* activity, so this lives as a component on the group controller: it works
* out which page's repository is being asked for, checks that the requester
* is allowed to read the group (a repository is only as cloneable as its
* group is readable), and then serves the requested file straight from the
* repository folder. The two small listing files a cloning client asks for
* are generated fresh each time by the git model. Reading, decompressing,
* and parsing the repository itself stays in the model and the git library;
* this component only decides what to do and checks permission.
*
* A git client carries no Yioop cross-site request token, so like the
* resource controller these requests are allowed through without one and
* gated by the group read check instead. Pushing, which changes the
* repository, is handled separately and requires the requester to
* authenticate.
*
* @author Chris Pollett
*/
class GitComponent extends Component
{
/**
* Serves one request for a file in a git page's repository. The request
* names the group, the page, and the path within the repository. If the
* requester may read the group and the page is a git repository, the
* file (or a freshly built listing) is sent; otherwise a not-found reply
* is returned. In every case the reply is sent and the request ends
* here.
*/
public function gitService()
{
$parent = $this->parent;
$group_model = $parent->model("group");
$git_model = $parent->model("git");
$group_id = $parent->clean($_REQUEST['group_id'] ?? "", "int");
$page_name = $parent->clean($_REQUEST['page_name'] ?? "", "string");
$sub_path = (empty($_REQUEST['sf'])) ? "" :
$parent->clean($_REQUEST['sf'], "string");
$method = $_SERVER['REQUEST_METHOD'] ?? "GET";
if ($method == "GET" || $method == "HEAD") {
$user_id = $_SESSION['USER_ID'] ?? C\PUBLIC_USER_ID;
if (!$this->groupIsReadable($parent, $group_model, $group_id,
$user_id)) {
$this->sendGitStatus($parent, "404 Not Found");
}
$repo_folder = $this->repositoryFolder($group_model, $group_id,
$page_name);
if ($repo_folder == "") {
$this->sendGitStatus($parent, "404 Not Found");
}
$this->serveRepositoryPath($parent, $git_model, $repo_folder,
$sub_path);
}
$this->serveWrite($parent, $group_model, $git_model, $group_id,
$page_name, $sub_path, $method);
}
/**
* Handles a request that changes the repository, which is how a client
* pushes. These come over the write half of the WebDAV protocol, so
* unlike a clone they require the requester to sign in and to have edit
* access to the group. Once allowed, the specific WebDAV method is
* carried out against the repository folder, confined to that folder so
* a request cannot reach or change anything outside it. The request ends
* here in every case.
*
* @param object $parent the group controller, used to send the reply
* @param object $group_model model used to resolve the page and access
* @param object $git_model model that locates repository files safely
* @param int $group_id group the repository's page belongs to
* @param string $page_name name of the page holding the repository
* @param string $sub_path path within the repository being acted on
* @param string $method the WebDAV request method
*/
private function serveWrite($parent, $group_model, $git_model, $group_id,
$page_name, $sub_path, $method)
{
$user_id = $this->authenticatedEditor($parent, $group_model,
$group_id);
if ($user_id === false) {
$parent->web_site->header("HTTP/1.1 401 Unauthorized");
$parent->web_site->header(
'WWW-Authenticate: Basic realm="Yioop Git"');
\seekquarry\atto\webExit();
}
$repo_folder = $this->repositoryFolder($group_model, $group_id,
$page_name);
if ($repo_folder == "") {
$this->sendGitStatus($parent, "404 Not Found");
}
$base_href = "/group/" . $group_id . "/" . $page_name . ".git";
$parent->web_site->header("DAV: 1, 2");
if ($method == "OPTIONS") {
$this->serveOptions($parent);
} else if ($method == "PROPFIND") {
$this->servePropfind($parent, $git_model, $repo_folder,
$sub_path, $base_href);
} else if ($method == "MKCOL") {
$this->serveMkcol($parent, $git_model, $repo_folder, $sub_path);
} else if ($method == "PUT") {
$this->servePut($parent, $git_model, $repo_folder, $sub_path);
} else if ($method == "MOVE") {
$this->serveMove($parent, $git_model, $repo_folder, $sub_path);
} else if ($method == "DELETE") {
$this->serveDelete($parent, $git_model, $repo_folder, $sub_path);
} else if ($method == "LOCK") {
$this->serveLock($parent);
} else if ($method == "UNLOCK") {
$this->sendGitStatus($parent, "204 No Content");
} else {
$this->sendGitStatus($parent, "405 Method Not Allowed");
}
}
/**
* Reports whether a group with the given register type holds content
* the public may read, and so may be cloned over git without signing
* in. These are the register types Yioop treats as publicly viewable
* throughout: the browse-and-request-to-join type shown as "By Request"
* and the open "Anyone" type.
*
* @param int $register_type the group's REGISTER_TYPE
* @return bool whether the public may read the group's content
*/
public static function publiclyReadableGroup($register_type)
{
return in_array($register_type,
[C\PUBLIC_BROWSE_REQUEST_JOIN, C\PUBLIC_JOIN]);
}
/**
* Reports whether the requester is allowed to read the given group, the
* same test the resource controller uses. The group is looked up for the
* requester; a group whose content is publicly viewable counts as
* readable by the public, and any other group is readable only when the
* requester
* presented a valid request token, which an anonymous client has not. So
* a repository in a group that is not publicly readable cannot be cloned
* without signing in.
*
* @param object $parent the group controller, used to check the request
* token
* @param object $group_model model used to look the group up
* @param int $group_id group being requested
* @param int $user_id requester, or the public user when not signed in
* @return bool true if the requester may read the group
*/
private function groupIsReadable($parent, $group_model, $group_id,
$user_id)
{
$token_okay = $parent->checkCSRFToken(C\p('CSRF_TOKEN'), $user_id);
$group = $group_model->getGroupById($group_id, $user_id);
if (empty($group)) {
$group_as_root = $group_model->getGroupById($group_id,
C\ROOT_ID);
if (!empty($group_as_root) && self::publiclyReadableGroup(
$group_as_root['REGISTER_TYPE'])) {
$group = $group_as_root;
$token_okay = true;
}
}
if (empty($group) || (!$token_okay &&
!self::publiclyReadableGroup($group['REGISTER_TYPE'] ?? -1))) {
return false;
}
return true;
}
/**
* Returns the folder holding the bare repository for a group's page, or
* the empty string when the page does not exist or is not a git
* repository.
*
* @param object $group_model model used to resolve the page and folder
* @param int $group_id group the page belongs to
* @param string $page_name name of the page
* @return string the repository folder, or "" if there is none
*/
private function repositoryFolder($group_model, $group_id, $page_name)
{
$page_id = $group_model->getPageId($group_id, $page_name,
L\getLocaleTag());
if (empty($page_id)) {
return "";
}
$folders = $group_model->getGroupPageResourcesFolders($group_id,
$page_id, "", false, true, true);
$repo_folder = $folders[0] ?? "";
if ($repo_folder == "") {
return "";
}
$repository = new L\GitRepository($repo_folder);
$state = $repository->folderState();
if ($state === "repository") {
return $repo_folder;
}
if ($state === "empty") {
/* the page's resource path points at an empty folder, which
happens when a repository page's resource path has just been
set to a fresh location; a legitimate git request starts a
bare repository there rather than failing, so the repository
works without a separate set-up step */
$repository->initBare($this->configuredGitBranch());
return ($repository->isRepository()) ? $repo_folder : "";
}
/* the folder already holds content that is not a git repository, so
it is left untouched and this request cannot be served from it;
the page read view warns about the misconfigured resource path */
return "";
}
/**
* Gives the branch name a freshly started repository should point its
* HEAD at, taken from the configured default branch and falling back to
* the built in default when the configured value has no usable
* characters.
*
* @return string the default branch name for a new repository
*/
private function configuredGitBranch()
{
$branch = preg_replace("/[^A-Za-z0-9._\/-]/", "",
(string)C\p('GIT_DEFAULT_BRANCH'));
if ($branch === "") {
$branch = L\GitRepository::DEFAULT_BRANCH;
}
return $branch;
}
/**
* Sends the file at a path within a repository to the client, or one of
* the two listing files built fresh, then ends the request. The path is
* confined to the repository folder by the git model, so a request
* cannot reach a file outside it.
*
* @param object $parent the group controller, used to send the reply
* @param object $git_model model that reads the repository files
* @param string $repo_folder folder holding the bare repository
* @param string $sub_path path within the repository being requested
*/
private function serveRepositoryPath($parent, $git_model, $repo_folder,
$sub_path)
{
$is_head = (($_SERVER['REQUEST_METHOD'] ?? "GET") == "HEAD");
if ($sub_path == "info/refs") {
$this->sendGitBody($parent, $git_model->infoRefs($repo_folder),
"text/plain; charset=utf-8", $is_head);
}
if ($sub_path == "objects/info/packs") {
$this->sendGitBody($parent,
$git_model->objectsInfoPacks($repo_folder),
"text/plain; charset=utf-8", $is_head);
}
$file = $git_model->repositoryFilePath($repo_folder, $sub_path);
if ($file === false) {
$this->sendGitStatus($parent, "404 Not Found");
}
$parent->web_site->header("Content-Type: application/octet-stream");
$parent->web_site->header("Content-Length: " . filesize($file));
if (!$is_head) {
readfile($file);
}
\seekquarry\atto\webExit();
}
/**
* Sends a generated listing body to the client and ends the request.
*
* @param object $parent the group controller, used to send the reply
* @param string $body the listing to send
* @param string $content_type content type header for the listing
* @param bool $is_head whether this is a HEAD request, which sends the
* headers but not the body
*/
private function sendGitBody($parent, $body, $content_type, $is_head)
{
$parent->web_site->header("Content-Type: " . $content_type);
$parent->web_site->header("Content-Length: " . strlen($body));
if (!$is_head) {
echo $body;
}
\seekquarry\atto\webExit();
}
/**
* Works out who is making a write request and whether they may edit the
* group, reading the user name and password from the request's basic
* authorization header. Returns the user id when the sign-in is valid
* and the user owns the group, is the root account, or has full member
* access; returns false otherwise, which leads the client to be asked to
* sign in.
*
* @param object $parent the group controller, used to reach the models
* @param object $group_model model used to look up group access
* @param int $group_id group being written to
* @return int|bool the editing user's id, or false if not allowed
*/
private function authenticatedEditor($parent, $group_model, $group_id)
{
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? "";
if (!str_starts_with($header, "Basic ")) {
return false;
}
$decoded = base64_decode(substr($header, strlen("Basic ")), true);
if ($decoded === false || !str_contains($decoded, ":")) {
return false;
}
list($username, $password) = explode(":", $decoded, 2);
if (!$this->passwordAccepted($parent, $username, $password)) {
return false;
}
$user_id = $group_model->getUserId($username);
if (empty($user_id)) {
return false;
}
$group = $group_model->getGroupById($group_id, $user_id);
if (empty($group)) {
return false;
}
$can_edit = $group['OWNER_ID'] == $user_id ||
$user_id == C\ROOT_ID ||
$group['MEMBER_ACCESS'] == C\GROUP_READ_WIKI;
return ($can_edit) ? $user_id : false;
}
/**
* Checks a person's Git application code, which they send in place of a
* password when pushing, against the code saved for their account. The
* code is accepted only while it has not expired; a code set never to
* expire always passes the time test. A successful check is remembered
* for a short time so a single git push, which resends the same
* credentials with each object it uploads, does not repeat the lookup on
* every one of its hundreds of requests. The remembered result lives
* only in this long-running server's memory, keyed by a hash of the
* credentials so the code itself is not held, and only a successful
* check is kept. Expired entries are dropped whenever a fresh check is
* needed.
*
* @param object $parent the group controller, used to reach the models
* @param string &$username account name offered for the push
* @param string $password the application code offered in place of a
* password
* @return bool whether the code is accepted for the account
*/
private function passwordAccepted($parent, &$username, $password)
{
static $accepted = [];
$now = time();
$key = hash('sha256', $username . "\x00" . $password);
if (isset($accepted[$key]) && $accepted[$key]["expires"] > $now) {
$username = $accepted[$key]["username"];
return true;
}
foreach ($accepted as $cached_key => $entry) {
if ($entry["expires"] <= $now) {
unset($accepted[$cached_key]);
}
}
$git_model = $parent->model("git");
$user_id = $git_model->getUserId($username);
if (empty($user_id)) {
return false;
}
$record = $git_model->getAppCode($user_id);
if (empty($record['APP_CODE'])) {
return false;
}
$unexpired = ($record['EXPIRES'] == C\FOREVER ||
$record['EXPIRES'] > $now);
if (!$unexpired || !hash_equals($record['APP_CODE'], $password)) {
return false;
}
$cache_expires = $now + C\GIT_AUTH_CACHE_TIMEOUT;
if ($record['EXPIRES'] != C\FOREVER &&
$record['EXPIRES'] < $cache_expires) {
$cache_expires = $record['EXPIRES'];
}
$accepted[$key] = ["username" => $username,
"expires" => $cache_expires];
return true;
}
/**
* Answers a capabilities request, telling the client which methods the
* repository accepts and that it speaks the WebDAV protocol a push
* needs, then ends the request.
*
* @param object $parent the group controller, used to send the reply
*/
private function serveOptions($parent)
{
$parent->web_site->header("HTTP/1.1 200 OK");
$parent->web_site->header("Allow: GET, HEAD, PUT, DELETE, MKCOL, " .
"MOVE, PROPFIND, OPTIONS, LOCK, UNLOCK");
$parent->web_site->header("Content-Length: 0");
\seekquarry\atto\webExit();
}
/**
* Writes the request body to a file in the repository, used when a
* client uploads a new object or updates a ref, then ends the request.
*
* @param object $parent the group controller, used to send the reply
* @param object $git_model model that resolves the write path safely
* @param string $repo_folder folder holding the bare repository
* @param string $sub_path path within the repository to write
*/
private function servePut($parent, $git_model, $repo_folder, $sub_path)
{
$path = $git_model->repositoryWritePath($repo_folder, $sub_path);
if ($path === false) {
$this->sendGitStatus($parent, "403 Forbidden");
}
$content = $_SERVER['CONTENT'] ?? "";
$content_length = strlen($content);
$blob_cap = L\metricToInt(C\p('GIT_MAX_BLOB_SIZE'));
$push_cap = L\metricToInt(C\p('GIT_MAX_PUSH_SIZE'));
$repo_cap = C\MAX_PAGE_RESOURCE_MEMORY;
$is_pack = (strpos($sub_path, "objects/pack") !== false);
$repo_size = ($repo_cap > 0) ?
$this->repositorySize($repo_folder) : 0;
if (!L\GitRepository::withinPushLimits($content_length, $repo_size,
$is_pack, $blob_cap, $push_cap, $repo_cap)) {
$this->sendGitStatus($parent, "413 Payload Too Large");
}
$existed = file_exists($path);
file_put_contents($path, $content);
$this->sendGitStatus($parent,
($existed) ? "204 No Content" : "201 Created");
}
/**
* Adds up the sizes of every file inside a repository folder so the
* maximum repository size cap can be checked before a new object is
* written into it. A folder that does not exist counts as empty.
*
* @param string $repo_folder folder holding the bare repository
* @return int total number of bytes the repository currently occupies
*/
private function repositorySize($repo_folder)
{
if (!is_dir($repo_folder)) {
return 0;
}
$total = 0;
$items = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($repo_folder,
\FilesystemIterator::SKIP_DOTS));
foreach ($items as $item) {
if ($item->isFile()) {
$total += $item->getSize();
}
}
return $total;
}
/**
* Creates a folder in the repository, used when a client makes an object
* folder before pushing objects into it, then ends the request.
*
* @param object $parent the group controller, used to send the reply
* @param object $git_model model that resolves the folder path safely
* @param string $repo_folder folder holding the bare repository
* @param string $sub_path folder path within the repository to make
*/
private function serveMkcol($parent, $git_model, $repo_folder, $sub_path)
{
$path = $git_model->repositoryFolderPath($repo_folder, $sub_path);
if ($path === false) {
$this->sendGitStatus($parent, "403 Forbidden");
}
if (file_exists($path)) {
$this->sendGitStatus($parent, "405 Method Not Allowed");
}
mkdir($path, 0777, true);
$this->sendGitStatus($parent, "201 Created");
}
/**
* Renames a file in the repository to the path named in the request's
* destination header, used when a client moves a freshly uploaded object
* into its final place, then ends the request. Both paths are confined
* to the repository folder.
*
* @param object $parent the group controller, used to send the reply
* @param object $git_model model that resolves the paths safely
* @param string $repo_folder folder holding the bare repository
* @param string $sub_path source path within the repository
*/
private function serveMove($parent, $git_model, $repo_folder, $sub_path)
{
$source = $git_model->repositoryWritePath($repo_folder, $sub_path);
$destination = $git_model->repositoryWritePath($repo_folder,
$this->destinationSubPath($_SERVER['HTTP_DESTINATION'] ?? ""));
if ($source === false || $destination === false ||
!file_exists($source)) {
$this->sendGitStatus($parent, "409 Conflict");
}
$existed = file_exists($destination);
rename($source, $destination);
$this->sendGitStatus($parent,
($existed) ? "204 No Content" : "201 Created");
}
/**
* Reads the path within the repository out of a WebDAV destination
* header, which gives the whole address a file is being moved to; the
* part after the ".git/" marker is the path inside the repository.
*
* @param string $destination the destination header value
* @return string the path within the repository, or "" if none found
*/
private function destinationSubPath($destination)
{
$marker = ".git/";
$at = strpos($destination, $marker);
if ($at === false) {
return "";
}
return substr($destination, $at + strlen($marker));
}
/**
* Removes a file or empty folder from the repository, then ends the
* request.
*
* @param object $parent the group controller, used to send the reply
* @param object $git_model model that resolves the path safely
* @param string $repo_folder folder holding the bare repository
* @param string $sub_path path within the repository to remove
*/
private function serveDelete($parent, $git_model, $repo_folder, $sub_path)
{
$path = $git_model->repositoryWritePath($repo_folder, $sub_path);
if ($path === false || !file_exists($path)) {
$this->sendGitStatus($parent, "404 Not Found");
}
if (is_dir($path)) {
rmdir($path);
} else {
unlink($path);
}
$this->sendGitStatus($parent, "204 No Content");
}
/**
* Grants a lock and returns the lock token the client quotes on its
* following writes, then ends the request. The lock is granted without
* being tracked, which is enough for a client that locks only to follow
* the protocol during its own push.
*
* @param object $parent the group controller, used to send the reply
*/
private function serveLock($parent)
{
$token = "opaquelocktoken:" . bin2hex(random_bytes(16));
$body = '<?xml version="1.0" encoding="utf-8"?>' .
'<D:prop xmlns:D="DAV:"><D:lockdiscovery><D:activelock>' .
'<D:locktype><D:write/></D:locktype>' .
'<D:lockscope><D:exclusive/></D:lockscope>' .
'<D:depth>0</D:depth><D:timeout>Second-3600</D:timeout>' .
'<D:locktoken><D:href>' . $token . '</D:href></D:locktoken>' .
'</D:activelock></D:lockdiscovery></D:prop>';
$parent->web_site->header("HTTP/1.1 200 OK");
$parent->web_site->header("Lock-Token: <" . $token . ">");
$parent->web_site->header("Content-Type: text/xml; charset=utf-8");
$parent->web_site->header("Content-Length: " . strlen($body));
echo $body;
\seekquarry\atto\webExit();
}
/**
* Lists a file or folder in the repository, and the immediate contents
* of a folder when the client asks for one level, in the multi-status
* form the WebDAV protocol uses. A client reads this while pushing to
* learn which objects are already present. The request ends here.
*
* @param object $parent the group controller, used to send the reply
* @param object $git_model model that resolves the path safely
* @param string $repo_folder folder holding the bare repository
* @param string $sub_path path within the repository being listed
* @param string $base_href address of the repository, used to build the
* address of each listed item
*/
private function servePropfind($parent, $git_model, $repo_folder,
$sub_path, $base_href)
{
$sub_path = rtrim($sub_path, "/");
$target = $git_model->repositoryWritePath($repo_folder, $sub_path);
if ($target === false || !file_exists($target)) {
$this->sendGitStatus($parent, "404 Not Found");
}
$responses = $this->propfindEntry($base_href, $sub_path, $target);
$depth = $_SERVER['HTTP_DEPTH'] ?? "0";
if (is_dir($target) && $depth != "0") {
foreach (scandir($target) as $child) {
if ($child == "." || $child == "..") {
continue;
}
$child_sub = ($sub_path == "") ? $child :
$sub_path . "/" . $child;
$responses .= $this->propfindEntry($base_href, $child_sub,
$target . "/" . $child);
}
}
$body = '<?xml version="1.0" encoding="utf-8"?>' .
'<D:multistatus xmlns:D="DAV:">' . $responses .
'</D:multistatus>';
$parent->web_site->header("HTTP/1.1 207 Multi-Status");
$parent->web_site->header("Content-Type: text/xml; charset=utf-8");
$parent->web_site->header("Content-Length: " . strlen($body));
echo $body;
\seekquarry\atto\webExit();
}
/**
* Builds the multi-status entry describing one file or folder: its
* address, whether it is a folder, and, for a file, its size.
*
* @param string $base_href address of the repository
* @param string $sub_path path within the repository of this item
* @param string $target the item's path on disk
* @return string the multi-status entry
*/
private function propfindEntry($base_href, $sub_path, $target)
{
$is_dir = is_dir($target);
$href = ($sub_path == "") ? $base_href . "/" :
$base_href . "/" . $sub_path . (($is_dir) ? "/" : "");
$resource_type = ($is_dir) ? "<D:collection/>" : "";
$length = ($is_dir) ? "" :
"<D:getcontentlength>" . filesize($target) .
"</D:getcontentlength>";
$supported_lock = "<D:supportedlock><D:lockentry>" .
"<D:lockscope><D:exclusive/></D:lockscope>" .
"<D:locktype><D:write/></D:locktype>" .
"</D:lockentry></D:supportedlock>";
return "<D:response><D:href>" . $href . "</D:href>" .
"<D:propstat><D:prop><D:resourcetype>" . $resource_type .
"</D:resourcetype>" . $length . $supported_lock . "</D:prop>" .
"<D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>";
}
/**
* Sends a bare status line, used to refuse a request, and ends the
* request.
*
* @param object $parent the group controller, used to send the reply
* @param string $status the HTTP status text, for example "404 Not
* Found"
*/
private function sendGitStatus($parent, $status)
{
$parent->web_site->header("HTTP/1.1 " . $status);
\seekquarry\atto\webExit();
}
}