src/application.cpp
raw ·
blame ·
history
/*
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 <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");
// Page-level CSP: all scripts are served from /static, so 'self' blocks any
// injected inline script. Handlers serving non-HTML (raw, archives) override
// this with their own policy. img-src allows remote images so READMEs that
// embed badges/screenshots still render.
response().set_header(
"Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: http: https:; object-src 'none'; base-uri 'none'; "
"frame-ancestors 'none'");
response().set_header("X-Content-Type-Options", "nosniff");
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");
// Raw blobs are untrusted and must never execute as active content. The
// `sandbox` directive neutralises scripts in an SVG opened directly in
// the browser, while still allowing <img> embeds to render it.
response().set_header("Content-Security-Policy",
"default-src 'none'; style-src 'unsafe-inline'; sandbox");
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());
}
}