package handlers import ( "bufio" "bytes" "context" "fmt" "io" "net/http" "os/exec" "path/filepath" "strconv" "strings" "github.com/go-chi/chi/v5" "golang.org/x/crypto/bcrypt" "xorm.io/xorm" "github.com/forgeo/forgebucket/internal/config" "github.com/forgeo/forgebucket/internal/models" ) type GitHTTPHandler struct { db *xorm.Engine cfg *config.Config } func NewGitHTTPHandler(db *xorm.Engine, cfg *config.Config) *GitHTTPHandler { return &GitHTTPHandler{db: db, cfg: cfg} } // ServeGit is the entry point for all git smart-HTTP requests. // URL shape: /{owner}/{repo}.git/{service} func (h *GitHTTPHandler) ServeGit(w http.ResponseWriter, r *http.Request) { owner := chi.URLParam(r, "owner") repoGit := chi.URLParam(r, "repoGit") // e.g. "myrepo.git" repoName := strings.TrimSuffix(repoGit, ".git") // Resolve repo from DB var ownerUser models.User if found, _ := h.db.Where("username = ?", owner).Get(&ownerUser); !found { http.NotFound(w, r) return } var repo models.Repository if found, _ := h.db.Where("owner_id = ? AND name = ?", ownerUser.ID, repoName).Get(&repo); !found { http.NotFound(w, r) return } // Determine service from URL / query string service := r.URL.Query().Get("service") if service == "" { // POST bodies encode the service in the path path := r.URL.Path if strings.HasSuffix(path, "/git-upload-pack") { service = "git-upload-pack" } else if strings.HasSuffix(path, "/git-receive-pack") { service = "git-receive-pack" } } // Authenticate and enforce permission checks. // Priority: user account → deploy key → anonymous (public repos only). var authedUser string var authedReadOnly bool if _, p, hasAuth := r.BasicAuth(); hasAuth { if user, ok := h.basicAuth(r); ok { authedUser = user // User account: enforce member permissions. if service == "git-receive-pack" && !HasPermission(h.db, &repo, user, "write") { http.Error(w, "forbidden: you do not have write access to this repository", http.StatusForbidden) return } if repo.IsPrivate && !HasPermission(h.db, &repo, user, "read") { http.Error(w, "forbidden: you do not have read access to this repository", http.StatusForbidden) return } } else if rdOnly, ok := AuthenticateDeployKey(h.db, repo.ID, p); ok { // Deploy key: the password field carries the raw token; username is ignored. authedUser = "deploy-key" authedReadOnly = rdOnly if service == "git-receive-pack" && rdOnly { http.Error(w, "forbidden: this deploy key is read-only", http.StatusForbidden) return } } else if _, repoID, hasWrite, ok := LookupAccessToken(h.db, p); ok && repoID == repo.ID { // Access token used as git credential (username ignored, password = token). authedUser = "access-token" if service == "git-receive-pack" && !hasWrite { http.Error(w, "forbidden: this access token has read-only scope", http.StatusForbidden) return } } else { // Credentials provided but invalid. w.Header().Set("WWW-Authenticate", `Basic realm="ForgeBucket"`) http.Error(w, "invalid credentials", http.StatusUnauthorized) return } } else if service == "git-receive-pack" || repo.IsPrivate { w.Header().Set("WWW-Authenticate", `Basic realm="ForgeBucket"`) http.Error(w, "authentication required", http.StatusUnauthorized) return } _ = authedReadOnly // Branch protection check: parse pkt-lines from the receive-pack body, // check each ref against stored protection rules, then restore the body. if service == "git-receive-pack" { if reason, newBody := checkProtectionsFromBody(h.db, repo.ID, authedUser, r.Body); reason != "" { http.Error(w, reason, http.StatusForbidden) return } else { r.Body = io.NopCloser(newBody) } } // Build PATH_INFO: /{reponame}.git/{suffix} // Strip the /{owner}/{repoGit} prefix from the raw URL path to get the suffix. prefix := "/" + owner + "/" + repoGit suffix := strings.TrimPrefix(r.URL.Path, prefix) if suffix == "" { suffix = "/" } pathInfo := "/" + repoGit + suffix projectRoot := filepath.Dir(repo.DiskPath) env := []string{ "GIT_PROJECT_ROOT=" + projectRoot, "GIT_HTTP_EXPORT_ALL=1", "PATH_INFO=" + pathInfo, "QUERY_STRING=" + r.URL.RawQuery, "REQUEST_METHOD=" + r.Method, "SERVER_PROTOCOL=HTTP/1.1", "SERVER_SOFTWARE=ForgeBucket/1.0", "REMOTE_ADDR=" + r.RemoteAddr, "HOME=/tmp", "GIT_TERMINAL_PROMPT=0", } if ct := r.Header.Get("Content-Type"); ct != "" { env = append(env, "CONTENT_TYPE="+ct) } if cl := r.ContentLength; cl > 0 { env = append(env, "CONTENT_LENGTH="+strconv.FormatInt(cl, 10)) } if authedUser != "" { env = append(env, "REMOTE_USER="+authedUser) } gitExec, err := exec.LookPath("git") if err != nil { http.Error(w, "git not found on server", http.StatusInternalServerError) return } if err := runGitBackend(r.Context(), w, r.Body, gitExec, env); err != nil { http.Error(w, fmt.Sprintf("git http-backend: %v", err), http.StatusInternalServerError) } } // basicAuth validates HTTP Basic Auth credentials against the DB. func (h *GitHTTPHandler) basicAuth(r *http.Request) (username string, ok bool) { u, p, hasAuth := r.BasicAuth() if !hasAuth { return "", false } var user models.User found, _ := h.db.Where("username = ?", u).Get(&user) if !found { return "", false } if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(p)) != nil { return "", false } return u, true } // runGitBackend executes `git http-backend` as a CGI subprocess and forwards // its response (headers + body) to the HTTP response writer. func runGitBackend(ctx context.Context, w http.ResponseWriter, body io.Reader, gitExec string, env []string) error { cmd := exec.CommandContext(ctx, gitExec, "http-backend") cmd.Env = env pr, pw := io.Pipe() cmd.Stdout = pw cmd.Stdin = body var stderrBuf strings.Builder cmd.Stderr = &stderrBuf if err := cmd.Start(); err != nil { return fmt.Errorf("start: %w", err) } // Close write-end of pipe once git finishes done := make(chan error, 1) go func() { err := cmd.Wait() pw.Close() done <- err }() // Parse CGI response: headers then body br := bufio.NewReader(pr) statusCode := http.StatusOK for { line, err := br.ReadString('\n') line = strings.TrimRight(line, "\r\n") if line == "" { break } if strings.HasPrefix(line, "Status:") { rest := strings.TrimSpace(strings.TrimPrefix(line, "Status:")) if parts := strings.SplitN(rest, " ", 2); len(parts) >= 1 { if code, e := strconv.Atoi(parts[0]); e == nil { statusCode = code } } } else if idx := strings.Index(line, ":"); idx > 0 { key := strings.TrimSpace(line[:idx]) val := strings.TrimSpace(line[idx+1:]) w.Header().Set(key, val) } if err != nil { break } } w.WriteHeader(statusCode) io.Copy(w, br) //nolint:errcheck waitErr := <-done if waitErr != nil && stderrBuf.Len() > 0 { return fmt.Errorf("%w: %s", waitErr, stderrBuf.String()) } return waitErr } // checkProtectionsFromBody parses git pkt-line ref updates from a receive-pack body, // checks each ref against stored branch protection rules, and returns a denial reason // (or "") plus a restored reader so the body can still be passed to http-backend. func checkProtectionsFromBody(db *xorm.Engine, repoID int64, pusher string, body io.Reader) (reason string, restored io.Reader) { var buf bytes.Buffer zeroOID := strings.Repeat("0", 40) for { // Every pkt-line starts with a 4-hex-digit length that includes itself. lenBuf := make([]byte, 4) if _, err := io.ReadFull(body, lenBuf); err != nil { break } buf.Write(lenBuf) pktLen64, err := strconv.ParseInt(string(lenBuf), 16, 32) if err != nil { break } if pktLen64 == 0 { // Flush packet — end of ref-update list. break } dataLen := int(pktLen64) - 4 if dataLen <= 0 { break } data := make([]byte, dataLen) if _, err := io.ReadFull(body, data); err != nil { break } buf.Write(data) // Strip NUL-separated capabilities (only on first pkt-line) and trailing newline. line := strings.TrimRight(strings.SplitN(string(data), "\x00", 2)[0], "\n") parts := strings.SplitN(line, " ", 3) if len(parts) != 3 { continue } oldRev, newRev, refname := parts[0], parts[1], parts[2] // New branches (oldRev all zeros) are not subject to protection. if oldRev == zeroOID { continue } // Detect force push: if newRev is all zeros it's a branch deletion. isForcePush := newRev == zeroOID if msg := CheckBranchProtection(db, repoID, pusher, refname, isForcePush); msg != "" { return msg, io.MultiReader(&buf, body) } } return "", io.MultiReader(&buf, body) }