#include "application.h" #include "content.h" #include "git_repo.h" #include "repo_scanner.h" #include "util.h" #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/git_frontend, // then $HOME/.cache/git_frontend, 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) + "/git_frontend"; if (const char *home = std::getenv("HOME"); home && *home) return std::string(home) + "/.cache/git_frontend"; return "/tmp/git_frontend"; } // 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; } // 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", ""))); // 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 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::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; 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()); } }