package web import ( "compress/gzip" "context" "encoding/base64" "fmt" "io" "log" "net/http" "os" "os/exec" "strings" "github.com/go-chi/chi/v5" "hearthforge/internal/ci" "hearthforge/internal/db" "hearthforge/internal/gitcmd" ) // gitRoutes serves the git smart HTTP protocol. The repo segment is accepted // with or without the `.git` suffix. func (s *Server) gitRoutes(r chi.Router) { r.Get("/{repo}/info/refs", s.gitInfoRefs) r.Post("/{repo}/git-upload-pack", s.gitUploadPack) r.Post("/{repo}/git-receive-pack", s.gitReceivePack) } // gitRepo looks up the repository for a URL slug. It returns the row and the // bare repository path, or nil when either is missing. func (s *Server) gitRepo(r *http.Request) (*db.Repo, string) { name := strings.TrimSuffix(chi.URLParam(r, "repo"), ".git") if !gitcmd.ValidRepoName(name) { return nil, "" } repo, err := s.DB.RepoByName(r.Context(), name) if err != nil { log.Printf("git repo lookup failed for %q: %v", name, err) return nil, "" } if repo == nil { return nil, "" } path := s.Git.RepoPath(repo.Name) if st, err := os.Stat(path); err != nil || !st.IsDir() { return nil, "" } return repo, path } // gitAuthOK verifies HTTP Basic credentials for the admin account. Only the // admin may push, and private repos are admin-only too, matching the web UI. func (s *Server) gitAuthOK(r *http.Request) bool { header := r.Header.Get("Authorization") if !strings.HasPrefix(header, "Basic ") { return false } raw, err := base64.StdEncoding.DecodeString(header[len("Basic "):]) if err != nil { return false } username, password, found := strings.Cut(string(raw), ":") if !found || username != db.AdminUsername { return false } hash, ok, err := s.DB.ActivePasswordHash(r.Context(), username) if err != nil || !ok { return false } valid, err := db.VerifyPassword(hash, password) return err == nil && valid } // gitRequireAuth runs the rate limiter and Basic auth check. It writes the // response and returns false when the caller must stop. // // The limiter counts requests without an Authorization header too. Each // attempt costs about 100 ms of argon2 work, so an anonymous caller must not // get free retries by omitting the header. func (s *Server) gitRequireAuth(w http.ResponseWriter, r *http.Request) bool { if s.limited(w, r, gitAuthLimiter, true) { return false } if s.gitAuthOK(r) { return true } w.Header().Set("WWW-Authenticate", `Basic realm="Hearthforge"`) w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusUnauthorized) w.Write([]byte("Unauthorized")) return false } func (s *Server) gitInfoRefs(w http.ResponseWriter, r *http.Request) { service := r.URL.Query().Get("service") if service != "git-upload-pack" && service != "git-receive-pack" { http.Error(w, "Bad Request", http.StatusBadRequest) return } repo, path := s.gitRepo(r) if repo == nil { http.Error(w, "Not Found", http.StatusNotFound) return } if (service == "git-receive-pack" || repo.IsPrivate) && !s.gitRequireAuth(w, r) { return } verb := strings.TrimPrefix(service, "git-") cmd := exec.CommandContext(r.Context(), "git", verb, "--stateless-rpc", "--advertise-refs", path) cmd.Env = gitcmd.EnvWithProtocol(r.Header.Get("Git-Protocol")) // The advertisement is small, so buffer it. That lets a git failure // surface as a 500 instead of a truncated body. out, err := cmd.Output() if err != nil { log.Printf("git %s --advertise-refs failed for %s: %v", verb, repo.Name, err) http.Error(w, "Git backend error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/x-git-"+verb+"-advertisement") w.Header().Set("Cache-Control", "no-cache") head := fmt.Sprintf("# service=%s\n", service) fmt.Fprintf(w, "%04x%s0000", len(head)+4, head) w.Write(out) } func (s *Server) gitUploadPack(w http.ResponseWriter, r *http.Request) { repo, path := s.gitRepo(r) if repo == nil { http.Error(w, "Not Found", http.StatusNotFound) return } if repo.IsPrivate && !s.gitRequireAuth(w, r) { return } body, err := gitRequestBody(r) if err != nil { http.Error(w, "Bad Request", http.StatusBadRequest) return } s.runGitRPC(w, r, "upload-pack", repo.Name, path, body) } func (s *Server) gitReceivePack(w http.ResponseWriter, r *http.Request) { if !s.gitRequireAuth(w, r) { return } repo, path := s.gitRepo(r) if repo == nil { http.Error(w, "Not Found", http.StatusNotFound) return } body, err := gitRequestBody(r) if err != nil { http.Error(w, "Bad Request", http.StatusBadRequest) return } // Keep the start of the stream. The pkt-line ref updates always fit. preamble := &ci.CapWriter{Limit: ci.PreambleMax} pushed := s.runGitRPC(w, r, "receive-pack", repo.Name, path, io.TeeReader(body, preamble)) // Even a partly applied push moves refs, so the cache is dropped either way. s.Git.InvalidateRefCache(repo.Name) if !pushed { return } // Run CI detached. The push response must not wait for it. go s.CI.TriggerForPush(context.Background(), repo.Name, preamble.Bytes()) } // runGitRPC streams the request body into git and its stdout back to the // client. It reports whether git exited successfully. func (s *Server) runGitRPC(w http.ResponseWriter, r *http.Request, verb, repoName, path string, body io.Reader) bool { cmd := exec.CommandContext(r.Context(), "git", verb, "--stateless-rpc", path) cmd.Env = gitcmd.EnvWithProtocol(r.Header.Get("Git-Protocol")) cmd.Stdin = body cmd.Stdout = w var stderr strings.Builder cmd.Stderr = &stderr // Headers go out before the first byte of the pack. A later git failure // can only be logged, because the status line is already sent. w.Header().Set("Content-Type", "application/x-git-"+verb+"-result") w.Header().Set("Cache-Control", "no-cache") if err := cmd.Run(); err != nil { log.Printf("git %s failed for %s: %v: %s", verb, repoName, err, strings.TrimSpace(stderr.String())) return false } return true } // gitRequestBody decompresses gzip request bodies. Git compresses the small // upload-pack request by default. func gitRequestBody(r *http.Request) (io.Reader, error) { if !strings.EqualFold(r.Header.Get("Content-Encoding"), "gzip") { return r.Body, nil } return gzip.NewReader(r.Body) }