src/application.cpp
browsing at commit = e6fed232320fc1bd4c7f35df8325080183937ba4
#include "application.h"
#include "cgi.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 <iostream>
#include <thread>
#include <fcntl.h>
#include <unistd.h>
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;
}
// 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<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", "")));
allow_clone_ = resolve_allow_clone(settings().get<bool>("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<void *, size_t> data = request().raw_post_data();
fs::create_directories(cache_root_);
std::string tmpl = cache_root_ + "/upload-pack-XXXXXX";
std::vector<char> 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<const char *>(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<std::string> argv = {"git", "-c", "safe.directory=*",
"http-backend"};
const std::vector<std::string> 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<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::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<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;
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<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());
}
}