git repos / sago_web_git

src/application.cpp

browsing at commit = b3dc7f091b847fa69d7e1f25a269779f6936753b

raw · blame · history

#include "application.h"

#include "content.h"
#include "git_repo.h"
#include "repo_scanner.h"
#include "util.h"

#include <cppcms/http_request.h>
#include <cppcms/http_response.h>
#include <cppcms/service.h>
#include <cppcms/url_dispatcher.h>

#include <atomic>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <thread>

namespace {
constexpr int LOG_LIMIT = 200;
const std::string SKIN = "git";

// Cache directory resolution order: GIT_CACHE_ROOT env (e.g. set by the
// Dockerfile), config key "git.cache_root", then XDG_CACHE_HOME/sago_web_git,
// then $HOME/.cache/sago_web_git, falling back to a temp directory.
std::string resolve_cache_root(const std::string &configured) {
    if (const char *env = std::getenv("GIT_CACHE_ROOT"); env && *env)
        return env;
    if (!configured.empty())
        return configured;
    if (const char *xdg = std::getenv("XDG_CACHE_HOME"); xdg && *xdg)
        return std::string(xdg) + "/sago_web_git";
    if (const char *home = std::getenv("HOME"); home && *home)
        return std::string(home) + "/.cache/sago_web_git";
    return "/tmp/sago_web_git";
}

// Splits a comma- or whitespace-separated host list into trimmed, non-empty
// entries. Used for the GitHub Enterprise host allowlist.
std::vector<std::string> split_hosts(const std::string &list) {
    std::vector<std::string> hosts;
    std::string current;
    for (const char c : list) {
        if (c == ',' || c == ' ' || c == '\t' || c == '\n') {
            if (!current.empty())
                hosts.push_back(current);
            current.clear();
        } else {
            current += c;
        }
    }
    if (!current.empty())
        hosts.push_back(current);
    return hosts;
}

// GitHub Enterprise hosts come from the GITHUB_ENTERPRISE_HOSTS env var (e.g.
// set by the Dockerfile) or the config key "git.github_enterprise_hosts".
std::string resolve_github_hosts(const std::string &configured) {
    if (const char *env = std::getenv("GITHUB_ENTERPRISE_HOSTS"); env && *env)
        return env;
    return configured;
}

// Minimal content-type table for the assets we ship.
std::string mime_for(const std::string &path) {
    const size_t dot = path.find_last_of('.');
    const std::string ext = dot == std::string::npos ? "" : path.substr(dot + 1);
    if (ext == "css") return "text/css";
    if (ext == "js") return "application/javascript";
    if (ext == "svg") return "image/svg+xml";
    if (ext == "png") return "image/png";
    if (ext == "ico") return "image/x-icon";
    return "application/octet-stream";
}
} // namespace

GitApp::GitApp(cppcms::service &srv, const RepoScanner *scanner)
    : cppcms::application(srv), scanner_(scanner) {
    static_root_ = settings().get<std::string>("git.static_root", "./static");
    cache_root_ = resolve_cache_root(settings().get<std::string>("git.cache_root", ""));
    github_hosts_ = split_hosts(resolve_github_hosts(
        settings().get<std::string>("git.github_enterprise_hosts", "")));

    // Root arrives as "" because script_name "/" is stripped; accept both.
    dispatcher().assign("^/?$", &GitApp::frontpage, this);
    dispatcher().assign("^/static/(.+)$", &GitApp::static_file, this, 1);
    dispatcher().assign("^/repos/?$", &GitApp::repos_list, this);
    dispatcher().assign("^/repos/([^/]+)/tree/?(.*)$", &GitApp::tree, this, 1, 2);
    dispatcher().assign("^/repos/([^/]+)/raw/(.+)$", &GitApp::raw, this, 1, 2);
    dispatcher().assign("^/repos/([^/]+)/blame/(.+)$", &GitApp::blame, this, 1, 2);
    dispatcher().assign("^/repos/([^/]+)/object/([0-9a-f]+)$", &GitApp::object, this, 1, 2);
    dispatcher().assign("^/repos/([^/]+)/log/?$", &GitApp::log, this, 1);
    dispatcher().assign("^/repos/([^/]+)/commit/([0-9a-f]+)\\.patch$", &GitApp::commit_patch, this, 1, 2);
    dispatcher().assign("^/repos/([^/]+)/commit/([0-9a-f]+)$", &GitApp::commit, this, 1, 2);
    dispatcher().assign("^/repos/([^/]+)/tags/?$", &GitApp::tags, this, 1);
    dispatcher().assign("^/repos/([^/]+)/tags/(.+)\\.tar\\.gz$", &GitApp::tag_archive, this, 1, 2);
    dispatcher().assign("^/repos/([^/]+)/tags/(.+)$", &GitApp::tag, this, 1, 2);
    dispatcher().assign("^/repos/([^/]+)/branches/?$", &GitApp::branches, this, 1);
    dispatcher().assign("^/repos/([^/]+)/?$", &GitApp::repo_home, this, 1);
}

void GitApp::main(std::string url) {
    response().content_type("text/html; charset=utf-8");
    cppcms::application::main(url);
}

void GitApp::static_file(std::string rel) {
    if (rel.find("..") != std::string::npos) {
        show_error(403, "Forbidden");
        return;
    }
    const std::string full = static_root_ + "/" + rel;
    std::ifstream file(full, std::ios::binary);
    if (!file) {
        show_error(404, "Not found: " + rel);
        return;
    }
    response().content_type(mime_for(rel));
    response().out() << file.rdbuf();
}

std::unique_ptr<GitRepo> GitApp::open(const std::string &name) {
    const std::string path = scanner_->path_for(name);
    if (path.empty()) {
        show_error(404, "No such repository: " + name);
        return nullptr;
    }
    try {
        return std::make_unique<GitRepo>(path);
    } catch (const GitError &e) {
        show_error(500, e.what());
        return nullptr;
    }
}

std::string GitApp::commit_from_request(const GitRepo &repo) {
    const std::string id = request().get("id");
    if (id.empty())
        return repo.resolve_commit("");
    if (!util::is_hex_oid(id))
        throw GitError("invalid id parameter");
    return repo.resolve_commit(id);
}

void GitApp::show_error(int code, const std::string &message) {
    response().status(code);
    response().content_type("text/plain; charset=utf-8");
    response().out() << message << "\n";
}

void GitApp::frontpage() {
    content::frontpage c;
    c.title = "Multi Git Frontend";
    c.repo_count = scanner_->names().size();
    render(SKIN, "frontpage", c);
}

void GitApp::repos_list() {
    content::repo_list c;
    c.title = "Repositories";
    c.repos = scanner_->names();
    render(SKIN, "repo_list", c);
}

void GitApp::repo_home(std::string name) {
    std::unique_ptr<GitRepo> repo = open(name);
    if (!repo)
        return;
    try {
        content::repo_home c;
        c.title = name;
        c.repo = name;
        c.commit_oid = commit_from_request(*repo);
        c.query = request().get("id").empty() ? "" : "?id=" + c.commit_oid;
        c.default_branch = repo->default_branch();
        const util::GitHubClone clone =
            util::github_clone_urls(repo->remote_url(), github_hosts_);
        c.clone_ssh = clone.ssh;
        c.clone_https = clone.https;
        c.readme_name = repo->find_readme(c.commit_oid);
        if (!c.readme_name.empty()) {
            bool is_binary = false;
            repo->read_file(c.commit_oid, c.readme_name, c.readme_text, is_binary);
            if (is_binary)
                c.readme_text.clear();
        }
        render(SKIN, "repo_home", c);
    } catch (const GitError &e) {
        show_error(404, e.what());
    }
}

void GitApp::tree(std::string name, std::string path) {
    std::unique_ptr<GitRepo> repo = open(name);
    if (!repo)
        return;
    try {
        const std::string commit = commit_from_request(*repo);
        const std::string query = request().get("id").empty() ? "" : "?id=" + commit;
        const std::string clean = util::trim_slashes(path);

        // A path pointing at a file shows the file; otherwise it is a directory.
        std::string text;
        bool is_binary = false;
        if (!clean.empty() && repo->read_file(commit, clean, text, is_binary)) {
            content::file_view c;
            c.title = name + "/" + clean;
            c.repo = name;
            c.query = query;
            c.commit_oid = commit;
            c.path = clean;
            c.filename = util::basename(clean);
            c.is_binary = is_binary;
            c.is_image = !util::image_mime_type(clean).empty();
            c.is_markdown = !is_binary && !c.is_image && util::is_markdown_path(clean);
            // Binary images carry no displayable source; text images (SVG) keep
            // their source so it can be shown below the preview.
            c.text = is_binary ? "" : text;
            c.size = text.size();
            c.tags = repo->tags_for_commit(commit);
            c.branches = repo->branches_for_commit(commit);
            for (const BranchInfo &b : c.branches) {
                if (b.is_default) {
                    c.on_default_branch = true;
                    break;
                }
            }
            render(SKIN, "file_view", c);
            return;
        }

        content::tree_view c;
        c.title = name + (clean.empty() ? "" : "/" + clean);
        c.repo = name;
        c.query = query;
        c.commit_oid = commit;
        c.path = clean;
        c.is_root = clean.empty();
        c.parent_path = util::parent_path(clean);
        c.entries = repo->list_tree(commit, clean);
        c.tags = repo->tags_for_commit(commit);
        c.branches = repo->branches_for_commit(commit);
        for (const BranchInfo &b : c.branches) {
            if (b.is_default) {
                c.on_default_branch = true;
                break;
            }
        }
        render(SKIN, "tree_view", c);
    } catch (const GitError &e) {
        show_error(404, e.what());
    }
}

void GitApp::raw(std::string name, std::string path) {
    std::unique_ptr<GitRepo> repo = open(name);
    if (!repo)
        return;
    try {
        const std::string commit = commit_from_request(*repo);
        const std::string clean = util::trim_slashes(path);
        std::string content;
        bool is_binary = false;
        if (!repo->read_file(commit, clean, content, is_binary)) {
            show_error(404, "No such file: " + path);
            return;
        }
        const std::string image_mime = util::image_mime_type(clean);
        if (!image_mime.empty())
            response().content_type(image_mime);
        else
            response().content_type(is_binary ? "application/octet-stream"
                                               : "text/plain; charset=utf-8");
        response().out().write(content.data(), static_cast<std::streamsize>(content.size()));
    } catch (const GitError &e) {
        show_error(404, e.what());
    }
}

void GitApp::blame(std::string name, std::string path) {
    std::unique_ptr<GitRepo> repo = open(name);
    if (!repo)
        return;
    try {
        const std::string commit = commit_from_request(*repo);
        content::blame_view c;
        c.repo = name;
        c.query = request().get("id").empty() ? "" : "?id=" + commit;
        c.commit_oid = commit;
        c.path = util::trim_slashes(path);
        c.filename = util::basename(c.path);
        c.title = "blame " + name + "/" + c.path;
        c.lines = repo->blame(commit, c.path);
        render(SKIN, "blame_view", c);
    } catch (const GitError &e) {
        show_error(404, e.what());
    }
}

void GitApp::log(std::string name) {
    std::unique_ptr<GitRepo> repo = open(name);
    if (!repo)
        return;
    try {
        const std::string commit = commit_from_request(*repo);
        content::log_view c;
        c.repo = name;
        c.query = request().get("id").empty() ? "" : "?id=" + commit;
        c.commit_oid = commit;
        c.title = "log " + name;
        c.commits = repo->log(commit, LOG_LIMIT);
        for (const CommitInfo &ci : c.commits) {
            if (!ci.body.empty()) {
                c.has_bodies = true;
                break;
            }
        }
        render(SKIN, "log_view", c);
    } catch (const GitError &e) {
        show_error(404, e.what());
    }
}

void GitApp::object(std::string name, std::string oid) {
    std::unique_ptr<GitRepo> repo = open(name);
    if (!repo)
        return;
    try {
        content::object_view c;
        c.repo = name;
        c.title = "object " + oid;
        c.object = repo->inspect(oid);
        render(SKIN, "object_view", c);
    } catch (const GitError &e) {
        show_error(404, e.what());
    }
}

void GitApp::tags(std::string name) {
    std::unique_ptr<GitRepo> repo = open(name);
    if (!repo)
        return;
    try {
        content::tags_view c;
        c.repo = name;
        c.title = "tags " + name;
        c.tags = repo->tags();
        render(SKIN, "tags_view", c);
    } catch (const GitError &e) {
        show_error(404, e.what());
    }
}

void GitApp::tag(std::string name, std::string tag_name) {
    std::unique_ptr<GitRepo> repo = open(name);
    if (!repo)
        return;
    try {
        content::tag_view c;
        c.repo = name;
        c.title = "tag " + tag_name;
        c.tag = repo->tag_detail(tag_name);
        render(SKIN, "tag_view", c);
    } catch (const GitError &e) {
        show_error(404, e.what());
    }
}

void GitApp::tag_archive(std::string name, std::string tag_name) {
    std::unique_ptr<GitRepo> repo = open(name);
    if (!repo)
        return;

    std::string oid;
    try {
        // tag_detail validates that the tag exists and resolves its commit.
        oid = repo->tag_detail(tag_name).target_oid;
    } catch (const GitError &e) {
        show_error(404, e.what());
        return;
    }
    if (oid.empty()) {
        show_error(404, "tag does not resolve to a commit: " + tag_name);
        return;
    }

    const std::string base =
        util::sanitize_filename(name) + "_" + util::sanitize_filename(tag_name);
    const std::string download_name = base + ".tar.gz";
    // The commit oid is part of the cache key so a moved tag never serves stale
    // contents.
    const std::string cache_file = cache_root_ + "/" + base + "_" + oid + ".tar.gz";

    std::error_code ec;
    std::filesystem::create_directories(cache_root_, ec);
    if (ec) {
        show_error(500, "cannot create cache directory: " + ec.message());
        return;
    }

    if (!std::filesystem::exists(cache_file)) {
        // Build to a unique temp file, then rename atomically so a crashed or
        // concurrent build never leaves a truncated archive at the cache path.
        static std::atomic<uint64_t> counter{0};
        const std::string tmp = cache_file + ".tmp." +
                                std::to_string(counter.fetch_add(1)) + "." +
                                std::to_string(std::hash<std::thread::id>{}(
                                    std::this_thread::get_id()));
        try {
            repo->write_archive(oid, base, tmp);
        } catch (const std::exception &e) {
            std::filesystem::remove(tmp, ec);
            show_error(500, std::string("failed to build archive: ") + e.what());
            return;
        }
        std::filesystem::rename(tmp, cache_file, ec);
        if (ec) {
            // A concurrent request may have produced it first; drop our copy.
            std::filesystem::remove(tmp, ec);
            if (!std::filesystem::exists(cache_file)) {
                show_error(500, "failed to store archive");
                return;
            }
        }
    }

    std::ifstream file(cache_file, std::ios::binary);
    if (!file) {
        show_error(500, "archive unavailable");
        return;
    }
    response().content_type("application/gzip");
    response().set_header("Content-Disposition",
                          "attachment; filename=\"" + download_name + "\"");
    response().out() << file.rdbuf();
}

void GitApp::branches(std::string name) {
    std::unique_ptr<GitRepo> repo = open(name);
    if (!repo)
        return;
    try {
        content::branches_view c;
        c.repo = name;
        c.title = "branches " + name;
        c.branches = repo->branches();
        render(SKIN, "branches_view", c);
    } catch (const GitError &e) {
        show_error(404, e.what());
    }
}

void GitApp::commit(std::string name, std::string oid) {
    std::unique_ptr<GitRepo> repo = open(name);
    if (!repo)
        return;
    try {
        content::commit_view c;
        c.repo = name;
        c.title = "commit " + oid;
        c.commit = repo->commit_detail(oid);
        c.branches = repo->branches_for_commit(c.commit.oid);
        for (const BranchInfo &b : c.branches) {
            if (b.is_default) {
                c.on_default_branch = true;
                break;
            }
        }
        render(SKIN, "commit_view", c);
    } catch (const GitError &e) {
        show_error(404, e.what());
    }
}

void GitApp::commit_patch(std::string name, std::string oid) {
    std::unique_ptr<GitRepo> repo = open(name);
    if (!repo)
        return;
    try {
        const std::string patch = repo->commit_patch(oid);
        response().content_type("text/plain; charset=utf-8");
        response().out().write(patch.data(), static_cast<std::streamsize>(patch.size()));
    } catch (const GitError &e) {
        show_error(404, e.what());
    }
}