/ src / models / GitModel.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\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]));
    }
}
X