git repos / sago_web_git

commit e6fed232

Poul Sander · 2026-06-27 16:51
e6fed232320fc1bd4c7f35df8325080183937ba4 patch · browse files
parent b3dc7f091b847fa69d7e1f25a269779f6936753b

Add the option to checkout a copy from the mirror. Requires ALLOW_GIT_CLONE set.

Changed files

M CMakeLists.txt before
M Dockerfile before
M config.json before
M src/application.cpp before
M src/application.h before
A src/cgi.cpp
A src/cgi.h
M src/content.h before
M templates/skin.tmpl before
diff --git a/CMakeLists.txt b/CMakeLists.txt index 8242d02..280f4db 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt
@@ -48,6 +48,7 @@ add_executable(sago_web_git
src/archive.cpp
src/repo_scanner.cpp
src/util.cpp
+ src/cgi.cpp
${VIEWS_CPP})
target_include_directories(sago_web_git PRIVATE
diff --git a/Dockerfile b/Dockerfile index c363485..be66f8b 100644 --- a/Dockerfile +++ b/Dockerfile
@@ -45,6 +45,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libssl3 \
libicu74 \
libgit2-1.7 \
+ git \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /usr/local/lib/libcppcms.so* /usr/local/lib/
diff --git a/config.json b/config.json index 36ad587..2dab077 100644 --- a/config.json +++ b/config.json
@@ -11,6 +11,7 @@
"root": "",
"static_root": "./static",
"cache_root": "",
- "github_enterprise_hosts": ""
+ "github_enterprise_hosts": "",
+ "allow_clone": false
}
}
diff --git a/src/application.cpp b/src/application.cpp index 4aa0ec1..ec1bf48 100644 --- a/src/application.cpp +++ b/src/application.cpp
@@ -1,5 +1,6 @@
#include "application.h"
+#include "cgi.h"
#include "content.h"
#include "git_repo.h"
#include "repo_scanner.h"
@@ -14,8 +15,12 @@
#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";
@@ -62,6 +67,14 @@ std::string resolve_github_hosts(const std::string &configured) {
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('.');
@@ -81,9 +94,11 @@ GitApp::GitApp(cppcms::service &srv, const RepoScanner *scanner)
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);
@@ -97,6 +112,11 @@ GitApp::GitApp(cppcms::service &srv, const RepoScanner *scanner)
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);
}
@@ -120,6 +140,102 @@ void GitApp::static_file(std::string 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()) {
@@ -156,6 +272,12 @@ void GitApp::frontpage() {
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";
@@ -178,6 +300,15 @@ void GitApp::repo_home(std::string name) {
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;
diff --git a/src/application.h b/src/application.h index 48bd586..0dba75c 100644 --- a/src/application.h +++ b/src/application.h
@@ -21,6 +21,7 @@ public:
private:
void frontpage();
+ void about();
void repos_list();
void repo_home(std::string name);
void tree(std::string name, std::string path);
@@ -36,6 +37,10 @@ private:
void commit_patch(std::string name, std::string oid);
void static_file(std::string rel);
+ // Serves Git's Smart HTTP protocol (clone/fetch) by proxying to
+ // `git http-backend`. `endpoint` is "info/refs" or "git-upload-pack".
+ void git_http(std::string name, std::string endpoint);
+
// Opens the named repo, or writes a 404 and returns nullptr.
std::unique_ptr<GitRepo> open(const std::string &name);
@@ -49,4 +54,5 @@ private:
std::string static_root_;
std::string cache_root_; // where generated tag archives are cached
std::vector<std::string> github_hosts_; // extra GitHub Enterprise hostnames
+ bool allow_clone_ = false; // serve git clone over HTTP (ALLOW_GIT_CLONE)
};
diff --git a/src/cgi.cpp b/src/cgi.cpp new file mode 100644 index 0000000..85b6aa9 --- /dev/null +++ b/src/cgi.cpp
@@ -0,0 +1,146 @@
+#ifndef _GNU_SOURCE
+#define _GNU_SOURCE // for execvpe
+#endif
+
+#include "cgi.h"
+
+#include <cstdlib>
+#include <cstring>
+#include <stdexcept>
+
+#include <fcntl.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+namespace {
+constexpr size_t CHUNK = 64 * 1024;
+
+// Builds a null-terminated char* array from strings for execvp/environ. The
+// pointers borrow from `strings`, which must outlive the returned vector.
+std::vector<char *> to_c_array(const std::vector<std::string> &strings) {
+ std::vector<char *> out;
+ out.reserve(strings.size() + 1);
+ for (const std::string &s : strings)
+ out.push_back(const_cast<char *>(s.c_str()));
+ out.push_back(nullptr);
+ return out;
+}
+
+// Splits one CGI header line "Name: value" into a trimmed pair. Returns false
+// for malformed lines (no colon).
+bool parse_header_line(const std::string &line, std::string &name,
+ std::string &value) {
+ const size_t colon = line.find(':');
+ if (colon == std::string::npos)
+ return false;
+ name = line.substr(0, colon);
+ size_t start = colon + 1;
+ while (start < line.size() && (line[start] == ' ' || line[start] == '\t'))
+ ++start;
+ value = line.substr(start);
+ return true;
+}
+
+// Parses the accumulated CGI header block (already stripped of the trailing
+// blank line) into `result`. A "Status: NNN ..." header sets the HTTP status;
+// every other header is preserved verbatim.
+void parse_headers(const std::string &block, CgiResult &result) {
+ size_t pos = 0;
+ while (pos < block.size()) {
+ size_t end = block.find('\n', pos);
+ if (end == std::string::npos)
+ end = block.size();
+ std::string line = block.substr(pos, end - pos);
+ pos = end + 1;
+ if (!line.empty() && line.back() == '\r')
+ line.pop_back();
+ if (line.empty())
+ continue;
+ std::string name, value;
+ if (!parse_header_line(line, name, value))
+ continue;
+ if (name == "Status")
+ result.status = std::atoi(value.c_str());
+ else
+ result.headers.emplace_back(std::move(name), std::move(value));
+ }
+}
+} // namespace
+
+void run_cgi(const std::vector<std::string> &argv,
+ const std::vector<std::string> &env, int input_fd,
+ const std::function<std::ostream &(const CgiResult &)> &on_headers) {
+ int out_pipe[2];
+ if (pipe(out_pipe) != 0)
+ throw std::runtime_error("cgi: pipe failed");
+
+ const pid_t pid = fork();
+ if (pid < 0) {
+ close(out_pipe[0]);
+ close(out_pipe[1]);
+ throw std::runtime_error("cgi: fork failed");
+ }
+
+ if (pid == 0) {
+ // Child: wire up stdin/stdout, then exec.
+ const int in = input_fd >= 0 ? input_fd : open("/dev/null", O_RDONLY);
+ if (in >= 0)
+ dup2(in, STDIN_FILENO);
+ dup2(out_pipe[1], STDOUT_FILENO);
+ close(out_pipe[0]);
+ close(out_pipe[1]);
+
+ std::vector<char *> c_argv = to_c_array(argv);
+ std::vector<char *> c_env = to_c_array(env);
+ execvpe(c_argv[0], c_argv.data(), c_env.data());
+ _exit(127); // exec failed
+ }
+
+ // Parent: read the child's stdout.
+ close(out_pipe[1]);
+
+ std::string header_block;
+ std::ostream *body = nullptr; // set once headers are parsed
+ char buf[CHUNK];
+ ssize_t n;
+ while ((n = read(out_pipe[0], buf, sizeof(buf))) > 0) {
+ if (body != nullptr) {
+ body->write(buf, n);
+ continue;
+ }
+ header_block.append(buf, n);
+ const size_t sep_crlf = header_block.find("\r\n\r\n");
+ const size_t sep_lf = header_block.find("\n\n");
+ size_t sep = std::string::npos;
+ size_t skip = 0;
+ if (sep_crlf != std::string::npos &&
+ (sep_lf == std::string::npos || sep_crlf <= sep_lf)) {
+ sep = sep_crlf;
+ skip = 4;
+ } else if (sep_lf != std::string::npos) {
+ sep = sep_lf;
+ skip = 2;
+ }
+ if (sep != std::string::npos) {
+ const std::string rest = header_block.substr(sep + skip);
+ header_block.resize(sep);
+ CgiResult result;
+ parse_headers(header_block, result);
+ body = &on_headers(result);
+ body->write(rest.data(), rest.size());
+ }
+ }
+ close(out_pipe[0]);
+
+ // No blank line ever arrived: still surface whatever headers we have.
+ if (body == nullptr) {
+ CgiResult result;
+ parse_headers(header_block, result);
+ on_headers(result);
+ }
+
+ int wstatus = 0;
+ waitpid(pid, &wstatus, 0);
+ if (!WIFEXITED(wstatus) || WEXITSTATUS(wstatus) != 0)
+ throw std::runtime_error("cgi: child exited abnormally");
+}
diff --git a/src/cgi.h b/src/cgi.h new file mode 100644 index 0000000..41ff78f --- /dev/null +++ b/src/cgi.h
@@ -0,0 +1,25 @@
+#pragma once
+
+#include <functional>
+#include <ostream>
+#include <string>
+#include <utility>
+#include <vector>
+
+// Parsed CGI header block (the body is streamed separately).
+struct CgiResult {
+ int status = 200; // from a "Status:" header, or 200 if absent
+ std::vector<std::pair<std::string, std::string>> headers;
+};
+
+// Forks and execs `argv` with exactly the environment in `env` (each entry
+// "KEY=value"). The child's stdin is connected to `input_fd` (or /dev/null when
+// input_fd < 0). Its stdout is parsed as a CGI response: once the header block
+// up to the first blank line is parsed, `on_headers` is called with it and must
+// return the stream to receive the body; the remaining bytes are then streamed
+// there. Splitting it this way lets the caller set HTTP status/headers before
+// any body is written. Throws std::runtime_error if the child cannot be spawned
+// or exits non-zero.
+void run_cgi(const std::vector<std::string> &argv,
+ const std::vector<std::string> &env, int input_fd,
+ const std::function<std::ostream &(const CgiResult &)> &on_headers);
diff --git a/src/content.h b/src/content.h index 90586c3..258e95c 100644 --- a/src/content.h +++ b/src/content.h
@@ -33,6 +33,7 @@ struct repo_home : public master {
std::string readme_text; // raw markdown, rendered client-side
std::string clone_ssh; // "" when origin is not a github.com remote
std::string clone_https; // "" when origin is not a github.com remote
+ std::string clone_http; // self-served clone URL; "" when clone is disabled
};
struct tree_view : public master {
diff --git a/templates/skin.tmpl b/templates/skin.tmpl index 23eb34c..2454759 100644 --- a/templates/skin.tmpl +++ b/templates/skin.tmpl
@@ -16,6 +16,9 @@
<header class="topbar">
<a class="brand" href="/">git</a>
<a href="/repos">repos</a>
+<% if (content.repo.length() == 0) %>
+<a href="/about">about</a>
+<% end %>
<% if (content.repo.length() > 0) %>
<span class="sep">/</span>
<a href="/repos/<%= repo %><%= query %>"><%= repo %></a>
@@ -47,6 +50,13 @@
<% end template %>
<% end view %>
+<% view about uses content::master extends master %>
+<% template content_body() %>
+<h1>About</h1>
+<p>A lightweight, read-only web frontend for browsing and cloning local git repositories.</p>
+<% end template %>
+<% end view %>
+
<% view repo_list uses content::repo_list extends master %>
<% template content_body() %>
<h1>Repositories</h1>
@@ -68,11 +78,16 @@
<a href="/repos/<%= repo %>/tree/<%= query %>">files</a> &middot;
<a href="/repos/<%= repo %>/log/<%= query %>">log</a>
</p>
-<% if (content.clone_https.length() > 0) %>
+<% if (content.clone_http.length() > 0 or content.clone_https.length() > 0) %>
<div class="checkout">
<h2>Checkout</h2>
+<% if (content.clone_http.length() > 0) %>
+<p class="clone-line"><span class="clone-label">Non-GitHub</span> <code>git clone <%= clone_http %></code></p>
+<% end %>
+<% if (content.clone_https.length() > 0) %>
<p class="clone-line"><span class="clone-label">HTTPS</span> <code>git clone <%= clone_https %></code></p>
<p class="clone-line"><span class="clone-label">SSH</span> <code>git clone <%= clone_ssh %></code></p>
+<% end %>
</div>
<% end %>
<% if (content.readme_name.length() > 0) %>