/* MIT License Copyright (c) 2026 Poul Sander Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ #include "application.h" #include "cgi.h" #include "content.h" #include "git_repo.h" #include "repo_scanner.h" #include "util.h" #include #include #include #include #include #include #include #include #include #include #include #include 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 split_hosts(const std::string &list) { std::vector 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; } // Whether to serve `git clone` over HTTP. Opt-in via the ALLOW_GIT_CLONE env // var ("1"/"true") or the config key "git.allow_clone"; disabled by default. bool resolve_allow_clone(bool configured) { if (const char *env = std::getenv("ALLOW_GIT_CLONE"); env && *env) return std::string(env) == "1" || std::string(env) == "true"; 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("git.static_root", "./static"); cache_root_ = resolve_cache_root(settings().get("git.cache_root", "")); github_hosts_ = split_hosts(resolve_github_hosts( settings().get("git.github_enterprise_hosts", ""))); allow_clone_ = resolve_allow_clone(settings().get("git.allow_clone", false)); // Root arrives as "" because script_name "/" is stripped; accept both. dispatcher().assign("^/?$", &GitApp::frontpage, this); dispatcher().assign("^/about/?$", &GitApp::about, 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); // Only wire up the clone endpoints when enabled, so a disabled server never // forks git http-backend (the URLs simply 404 and the client falls back). if (allow_clone_) dispatcher().assign("^/repos/([^/]+)/(info/refs|git-upload-pack)$", &GitApp::git_http, this, 1, 2); 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(); } void GitApp::git_http(std::string name, std::string endpoint) { namespace fs = std::filesystem; if (!allow_clone_) { // defensive: the route is not registered when disabled show_error(404, "Not found"); return; } const std::string repo_path = scanner_->path_for(name); if (repo_path.empty()) { show_error(404, "No such repository: " + name); return; } // Read-only: only the fetch/clone service is exposed; reject pushes. if (endpoint == "info/refs" && request().get("service") != "git-upload-pack") { show_error(403, "Only git-upload-pack (clone/fetch) is supported"); return; } // Resolve the on-disk git directory (bare or non-bare) and express it as // GIT_PROJECT_ROOT + PATH_INFO for git-http-backend. fs::path git_dir = repo_path; if (fs::is_directory(git_dir / ".git")) git_dir /= ".git"; const std::string project_root = git_dir.parent_path().string(); const std::string path_info = "/" + git_dir.filename().string() + "/" + endpoint; // A POST body (upload-pack negotiation) is staged in a temp file used as the // child's stdin; reading from a file rather than a pipe avoids deadlock when // git streams back a large packfile while we are still writing the request. int input_fd = -1; if (request().request_method() == "POST") { const std::pair data = request().raw_post_data(); fs::create_directories(cache_root_); std::string tmpl = cache_root_ + "/upload-pack-XXXXXX"; std::vector path_buf(tmpl.begin(), tmpl.end()); path_buf.push_back('\0'); input_fd = mkstemp(path_buf.data()); if (input_fd < 0) { show_error(500, "cannot create temp file for request body"); return; } unlink(path_buf.data()); // unlink now; the open fd keeps the file alive const char *p = static_cast(data.first); size_t left = data.second; while (left > 0) { const ssize_t w = write(input_fd, p, left); if (w <= 0) break; p += w; left -= w; } lseek(input_fd, 0, SEEK_SET); } // `-c safe.directory=*` is the http-backend analogue of the in-process // libgit2 owner-validation override: git http-backend is a separate process // and would otherwise refuse repos owned by a different UID (Docker mounts). const std::vector argv = {"git", "-c", "safe.directory=*", "http-backend"}; const std::vector env = { "GIT_PROJECT_ROOT=" + project_root, "GIT_HTTP_EXPORT_ALL=1", "PATH_INFO=" + path_info, "REQUEST_METHOD=" + request().request_method(), "QUERY_STRING=" + request().query_string(), "CONTENT_TYPE=" + request().getenv("CONTENT_TYPE"), "HTTP_CONTENT_ENCODING=" + request().getenv("HTTP_CONTENT_ENCODING"), "GIT_PROTOCOL=" + request().getenv("HTTP_GIT_PROTOCOL"), "REMOTE_ADDR=" + request().remote_addr(), }; bool started = false; try { run_cgi(argv, env, input_fd, [this, &started](const CgiResult &r) -> std::ostream & { started = true; response().status(r.status); // set_header (not content_type) so git's exact content types // are sent without a "; charset=..." suffix appended. for (const auto &h : r.headers) response().set_header(h.first, h.second); return response().out(); }); } catch (const std::exception &e) { std::cerr << "git_http: " << e.what() << std::endl; if (!started) show_error(500, "git http-backend failed"); } if (input_fd >= 0) close(input_fd); } std::unique_ptr 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(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::about() { content::master c; c.title = "About"; render(SKIN, "about", 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 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; if (const std::string host = request().getenv("HTTP_HOST"); allow_clone_ && !host.empty()) { // Behind a TLS-terminating proxy the public scheme is https even // though we serve plain http; honor X-Forwarded-Proto when set. std::string scheme = request().getenv("HTTP_X_FORWARDED_PROTO"); if (scheme.empty()) scheme = "http"; c.clone_http = scheme + "://" + host + "/repos/" + name; } 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 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 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(content.size())); } catch (const GitError &e) { show_error(404, e.what()); } } void GitApp::blame(std::string name, std::string path) { std::unique_ptr 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 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 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 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 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 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 counter{0}; const std::string tmp = cache_file + ".tmp." + std::to_string(counter.fetch_add(1)) + "." + std::to_string(std::hash{}( 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 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 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 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(patch.size())); } catch (const GitError &e) { show_error(404, e.what()); } }