diff options
Diffstat (limited to 'httpgit.go')
| -rw-r--r-- | httpgit.go | 175 |
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" +} |