aboutsummaryrefslogtreecommitdiff
path: root/httpgit.go
diff options
context:
space:
mode:
Diffstat (limited to 'httpgit.go')
-rw-r--r--httpgit.go175
1 files changed, 175 insertions, 0 deletions
diff --git a/httpgit.go b/httpgit.go
new file mode 100644
index 0000000..f8244ed
--- /dev/null
+++ b/httpgit.go
@@ -0,0 +1,175 @@
+package main
+
+import (
+ "log"
+ "net/http"
+ "net/http/cgi"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+)
+
+// gitBackend is git's own CGI, located by asking git. On first use, not at
+// startup: the filter subcommands must not fork git.
+var gitBackend = sync.OnceValue(func() string {
+ out, err := git("", nil, "--exec-path")
+ if err != nil {
+ log.Printf("git: no exec path, so no push or pull over http: %v", err)
+ return ""
+ }
+ return filepath.Join(strings.TrimSpace(string(out)), "git-http-backend")
+})
+
+var gitProtocolRE = regexp.MustCompile(`^[a-zA-Z0-9=:._-]+$`)
+
+// transferring bounds the git-http-backend children in flight: an upload-pack
+// packs with a thread per core.
+var transferring = make(chan struct{}, 8)
+
+// What one push may weigh; git has no bound of its own. The wiki is held to
+// far less, because its knob can be set to any account at all.
+const (
+ maxPushCode = 1 << 30
+ maxPushWiki = 16 << 20
+)
+
+// handleGitHTTP authorises before anything is exec'd. No form token and no
+// session cookie: a push authenticated by a cookie could be driven from
+// another site.
+func (f *Forge) handleGitHTTP(w http.ResponseWriter, r *http.Request, path, kind, rest string) {
+ backend := gitBackend()
+ if backend == "" {
+ http.Error(w, "git-http-backend is not installed\n", http.StatusInternalServerError)
+ return
+ }
+ v, ok := f.gitViewer(w, r, path)
+ if !ok {
+ return
+ }
+ // What the viewer may not read does not exist, as on the web. Anonymous is
+ // challenged instead, so git sends credentials, and challenged whether or
+ // not the repository is there: a 401 beside a 404 would name the private
+ // ones.
+ repo, exists := f.store.Repo(path)
+ if !exists || !v.CanRead(repo) {
+ if v.SignedIn() {
+ http.NotFound(w, r)
+ } else {
+ f.gitChallenge(w, "authentication required")
+ }
+ return
+ }
+ // A feature turned off keeps its repository on disk.
+ if (kind == "issues" && repo.Issues == featDisabled) || (kind == "wiki" && repo.Wiki == featDisabled) {
+ http.NotFound(w, r)
+ return
+ }
+ dir := path + gitSuffix(kind)
+ if _, err := os.Stat(filepath.Join(f.store.reposDir, dir)); err != nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ write := isPush(r, rest)
+ if write && kind == "issues" {
+ // The tracker is well formed because the server is its only writer.
+ http.Error(w, "the issue tracker is written by the forge, not by push. clone and read it, but file issues through the web\n",
+ http.StatusForbidden)
+ return
+ }
+ if write {
+ // The wiki knob, not the code write grant: documentation fixes and
+ // code are separate permissions.
+ allowed := v.CanWrite(repo)
+ if kind == "wiki" {
+ allowed = v.CanWiki(repo)
+ }
+ switch {
+ case allowed:
+ case v.SignedIn():
+ http.Error(w, "you do not have that access to this repository\n", http.StatusForbidden)
+ return
+ default:
+ f.gitChallenge(w, "authentication required")
+ return
+ }
+ }
+
+ weight := maxPushCode
+ if kind == "wiki" {
+ weight = maxPushWiki
+ }
+ // PATH_INFO comes from the resolved repository, not the URL. The receive
+ // settings come through the environment: nothing the forge writes goes
+ // inside a repository.
+ env := []string{
+ "GIT_PROJECT_ROOT=" + f.store.reposDir,
+ "GIT_HTTP_EXPORT_ALL=1",
+ "REMOTE_USER=" + v.Name,
+ "PATH_INFO=/" + dir + "/" + rest,
+ "SCRIPT_NAME=",
+ "GIT_CONFIG_NOSYSTEM=1",
+ "GIT_CONFIG_GLOBAL=/dev/null",
+ "GIT_CONFIG_COUNT=2",
+ "GIT_CONFIG_KEY_0=receive.unpackLimit",
+ "GIT_CONFIG_VALUE_0=1",
+ "GIT_CONFIG_KEY_1=receive.maxInputSize",
+ "GIT_CONFIG_VALUE_1=" + strconv.Itoa(weight),
+ }
+ // As HTTP_GIT_PROTOCOL git-http-backend would not read it, and every
+ // client would fall back to protocol v0.
+ if p := r.Header.Get("Git-Protocol"); p != "" && gitProtocolRE.MatchString(p) {
+ env = append(env, "GIT_PROTOCOL="+p)
+ }
+ // git sends a push past http.postBuffer chunked, which net/http/cgi
+ // refuses; the framing is already decoded here.
+ r.TransferEncoding = nil
+ release, ok := childSlot(transferring, r)
+ if !ok {
+ return
+ }
+ defer release()
+ (&cgi.Handler{Path: backend, Env: env, Stderr: os.Stdout}).ServeHTTP(w, r)
+
+ if write {
+ // A push that failed leaves nothing to collect.
+ gcAuto(filepath.Join(f.store.reposDir, dir))
+ }
+}
+
+// gitViewer reads Basic auth carrying a scoped push token, never a password:
+// git cannot be asked for a second factor, and a token is separately scoped
+// and separately revoked. No credential is the anonymous viewer.
+func (f *Forge) gitViewer(w http.ResponseWriter, r *http.Request, path string) (Viewer, bool) {
+ given, secret, sent := r.BasicAuth()
+ if !sent {
+ return Viewer{}, true
+ }
+ name, u, ok := f.checkToken(given, secret, path)
+ if !ok {
+ f.gitChallenge(w, "bad account name or token")
+ return Viewer{}, false
+ }
+ // adminInEffect, not the raw flag: the web and git have to agree.
+ return Viewer{Name: name, Admin: u.adminInEffect()}, true
+}
+
+func (f *Forge) gitChallenge(w http.ResponseWriter, msg string) {
+ w.Header().Set("WWW-Authenticate", `Basic realm="`+f.siteName()+`", charset="UTF-8"`)
+ http.Error(w, msg+"\n", http.StatusUnauthorized)
+}
+
+// isPush reports whether this request is the start or the body of a push.
+func isPush(r *http.Request, rest string) bool {
+ return rest == "git-receive-pack" || r.URL.Query().Get("service") == "git-receive-pack"
+}
+
+func gitSuffix(kind string) string {
+ if kind == "" {
+ return ".git"
+ }
+ return "." + kind + ".git"
+}