<?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\models;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
/**
* Handles reading the files that make up the bare Git repository stored in
* a git wiki page's resource folder, and building the small listing files a
* cloning client asks for. All the on-disk work for serving a repository
* over the wire lives here, keeping that work out of the controller, which
* only decides what to do and checks permissions. The actual reading of Git
* objects is left to the GitRepository library; this model just locates
* files safely and assembles the listings.
*
* @author Chris Pollett
*/
class GitModel extends Model
{
/**
* Given the folder a git page's bare repository lives in and a path
* within that repository, returns the absolute path to write to if that
* path really sits inside the repository folder, or false otherwise.
* Unlike the read version this allows a file that does not exist yet, so
* a client can push a new object, but it still refuses any path that
* would climb out of the repository folder with "..". The parent folder
* must already exist, which it does because a client makes the object
* folders before writing objects into them.
*
* @param string $repo_folder folder holding the bare repository
* @param string $sub_path path within the repository being written
* @return string|bool absolute path to write, or false if the path is
* outside the repository folder or its parent is missing
*/
public function repositoryWritePath($repo_folder, $sub_path)
{
$base = realpath($repo_folder);
if ($base === false) {
return false;
}
$normalized = str_replace("\\", "/", $sub_path);
if ($normalized == "") {
return $base;
}
if ($normalized[0] == "/" || str_contains($normalized, "..") ||
str_contains($normalized, "\0")) {
return false;
}
$target = $base . "/" . $normalized;
$parent = realpath(dirname($target));
if ($parent === false || ($parent !== $base &&
strncmp($parent, $base . "/", strlen($base) + 1) !== 0)) {
return false;
}
return $parent . "/" . basename($target);
}
/**
* Given the folder a git page's bare repository lives in and a path
* within that repository, returns the absolute path of a folder to
* create if that path really sits inside the repository folder, or
* false otherwise. Used when a client makes an object folder before
* pushing objects into it.
*
* @param string $repo_folder folder holding the bare repository
* @param string $sub_path folder path within the repository to create
* @return string|bool absolute path to create, or false if outside the
* repository folder
*/
public function repositoryFolderPath($repo_folder, $sub_path)
{
return $this->repositoryWritePath($repo_folder, $sub_path);
}
/**
* Given the folder a git page's bare repository lives in and a path
* within that repository, returns the absolute path to read to if that
* path really sits inside the repository folder, or false otherwise.
* Resolving the real path first means a request cannot use ".." to
* reach a file outside the repository folder.
*
* @param string $repo_folder folder holding the bare repository
* @param string $sub_path path within the repository being requested
* @return string|bool absolute path of the file, or false if the path
* is outside the repository folder or is not a file
*/
public function repositoryFilePath($repo_folder, $sub_path)
{
$base = realpath($repo_folder);
if ($base === false) {
return false;
}
$target = realpath($repo_folder . "/" . $sub_path);
if ($target === false || !is_file($target)) {
return false;
}
if ($target !== $base &&
strncmp($target, $base . "/", strlen($base) + 1) !== 0) {
return false;
}
return $target;
}
/**
* Builds the info/refs listing a cloning client reads to discover the
* repository's branches and tags. Each line gives an object name and
* the ref that points at it. It is generated fresh from the repository
* each time it is asked for, so it is always current without the server
* ever running git to update it.
*
* @param string $repo_folder folder holding the bare repository
* @return string the info/refs listing
*/
public function infoRefs($repo_folder)
{
$repository = new L\GitRepository($repo_folder);
$listing = "";
foreach ($repository->allRefs() as $name => $object_name) {
$listing .= $object_name . "\t" . $name . "\n";
$peeled = $repository->peeledTarget($object_name);
if ($peeled != "") {
$listing .= $peeled . "\t" . $name . "^{}\n";
}
}
return $listing;
}
/**
* Builds the objects/info/packs listing a cloning client reads to find
* the repository's packfiles. Each line names one packfile. Like the
* ref listing it is generated fresh each time so it always matches what
* is on disk.
*
* @param string $repo_folder folder holding the bare repository
* @return string the objects/info/packs listing
*/
public function objectsInfoPacks($repo_folder)
{
$listing = "";
$pattern = $repo_folder . "/objects/pack/pack-*.pack";
foreach (glob($pattern) as $pack_path) {
$listing .= "P " . basename($pack_path) . "\n";
}
return $listing . "\n";
}
/**
* Looks up the Git application code that belongs to a given person. This
* is the code, kept in the private database, that they use in place of
* their account password when pushing to a Git repository wiki page. Its
* expiry time is returned alongside it so the caller can tell whether the
* code is still good.
*
* @param int $user_id which person's code to look up
* @return array the code and its expiry as APP_CODE and EXPIRES, or false
* when the person has no code yet
*/
public function getAppCode($user_id)
{
$private_db = $this->private_db;
$sql = "SELECT APP_CODE, EXPIRES FROM GIT_APP_CODE " .
"WHERE USER_ID = ?";
$result = $private_db->execute($sql, [$user_id]);
if (!$result) {
return false;
}
return $private_db->fetchArray($result);
}
/**
* Saves a new Git application code for a person, replacing whatever code
* they had before. The code is stored in the private database together
* with the time at which it stops working.
*
* @param int $user_id which person the code belongs to
* @param string $app_code the code to store
* @param int $expires unix time in seconds when the code stops working
*/
public function setAppCode($user_id, $app_code, $expires)
{
$private_db = $this->private_db;
$private_db->execute("DELETE FROM GIT_APP_CODE WHERE USER_ID = ?",
[$user_id]);
$private_db->execute("INSERT INTO GIT_APP_CODE " .
"(USER_ID, APP_CODE, EXPIRES) VALUES (?, ?, ?)",
[$user_id, $app_code, $expires]);
}
/**
* Reads back a repository's saved statistics if they were worked out
* for the commit currently at the tip of the shown branch. Saving the
* newest commit's name alongside the numbers lets this tell whether the
* saved numbers still describe the repository or whether they are out of
* date and must be worked out again.
*
* @param string $cache_path file the statistics were saved to
* @param string $head_sha object name the saved numbers were worked out
* for
* @return array the saved statistics when they match the given commit,
* or false when there is nothing usable saved
*/
public function readStatisticsCache($cache_path, $head_sha)
{
if (!file_exists($cache_path)) {
return false;
}
$saved = json_decode(file_get_contents($cache_path), true);
if (!is_array($saved) || ($saved["head"] ?? "") !== $head_sha ||
empty($saved["stats"])) {
return false;
}
return $saved["stats"];
}
/**
* Saves a repository's statistics along with the newest commit's name,
* so a later read can tell whether the saved numbers are still current.
*
* @param string $cache_path file to save the statistics to
* @param string $head_sha object name the numbers were worked out for
* @param array $stats the statistics to save
*/
public function writeStatisticsCache($cache_path, $head_sha, $stats)
{
file_put_contents($cache_path,
json_encode(["head" => $head_sha, "stats" => $stats]));
}
}