git.go
⎇
Raw
1package web
2
3import (
4 "compress/gzip"
5 "context"
6 "encoding/base64"
7 "fmt"
8 "io"
9 "log"
10 "net/http"
11 "os"
12 "os/exec"
13 "strings"
14
15 "github.com/go-chi/chi/v5"
16
17 "hearthforge/internal/ci"
18 "hearthforge/internal/db"
19 "hearthforge/internal/gitcmd"
20)
21
22// gitRoutes serves the git smart HTTP protocol. The repo segment is accepted
23// with or without the `.git` suffix.
24func (s *Server) gitRoutes(r chi.Router) {
25 r.Get("/{repo}/info/refs", s.gitInfoRefs)
26 r.Post("/{repo}/git-upload-pack", s.gitUploadPack)
27 r.Post("/{repo}/git-receive-pack", s.gitReceivePack)
28}
29
30// gitRepo looks up the repository for a URL slug. It returns the row and the
31// bare repository path, or nil when either is missing.
32func (s *Server) gitRepo(r *http.Request) (*db.Repo, string) {
33 name := strings.TrimSuffix(chi.URLParam(r, "repo"), ".git")
34 if !gitcmd.ValidRepoName(name) {
35 return nil, ""
36 }
37 repo, err := s.DB.RepoByName(r.Context(), name)
38 if err != nil {
39 log.Printf("git repo lookup failed for %q: %v", name, err)
40 return nil, ""
41 }
42 if repo == nil {
43 return nil, ""
44 }
45 path := s.Git.RepoPath(repo.Name)
46 if st, err := os.Stat(path); err != nil || !st.IsDir() {
47 return nil, ""
48 }
49 return repo, path
50}
51
52// gitAuthOK verifies HTTP Basic credentials for the admin account. Only the
53// admin may push, and private repos are admin-only too, matching the web UI.
54func (s *Server) gitAuthOK(r *http.Request) bool {
55 header := r.Header.Get("Authorization")
56 if !strings.HasPrefix(header, "Basic ") {
57 return false
58 }
59 raw, err := base64.StdEncoding.DecodeString(header[len("Basic "):])
60 if err != nil {
61 return false
62 }
63 username, password, found := strings.Cut(string(raw), ":")
64 if !found || username != db.AdminUsername {
65 return false
66 }
67 hash, ok, err := s.DB.ActivePasswordHash(r.Context(), username)
68 if err != nil || !ok {
69 return false
70 }
71 valid, err := db.VerifyPassword(hash, password)
72 return err == nil && valid
73}
74
75// gitRequireAuth runs the rate limiter and Basic auth check. It writes the
76// response and returns false when the caller must stop.
77//
78// The limiter counts requests without an Authorization header too. Each
79// attempt costs about 100 ms of argon2 work, so an anonymous caller must not
80// get free retries by omitting the header.
81func (s *Server) gitRequireAuth(w http.ResponseWriter, r *http.Request) bool {
82 if s.limited(w, r, gitAuthLimiter, true) {
83 return false
84 }
85 if s.gitAuthOK(r) {
86 return true
87 }
88 w.Header().Set("WWW-Authenticate", `Basic realm="Hearthforge"`)
89 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
90 w.WriteHeader(http.StatusUnauthorized)
91 w.Write([]byte("Unauthorized"))
92 return false
93}
94
95func (s *Server) gitInfoRefs(w http.ResponseWriter, r *http.Request) {
96 service := r.URL.Query().Get("service")
97 if service != "git-upload-pack" && service != "git-receive-pack" {
98 http.Error(w, "Bad Request", http.StatusBadRequest)
99 return
100 }
101 repo, path := s.gitRepo(r)
102 if repo == nil {
103 http.Error(w, "Not Found", http.StatusNotFound)
104 return
105 }
106 if (service == "git-receive-pack" || repo.IsPrivate) && !s.gitRequireAuth(w, r) {
107 return
108 }
109
110 verb := strings.TrimPrefix(service, "git-")
111 cmd := exec.CommandContext(r.Context(), "git", verb, "--stateless-rpc", "--advertise-refs", path)
112 cmd.Env = gitcmd.EnvWithProtocol(r.Header.Get("Git-Protocol"))
113 // The advertisement is small, so buffer it. That lets a git failure
114 // surface as a 500 instead of a truncated body.
115 out, err := cmd.Output()
116 if err != nil {
117 log.Printf("git %s --advertise-refs failed for %s: %v", verb, repo.Name, err)
118 http.Error(w, "Git backend error", http.StatusInternalServerError)
119 return
120 }
121 w.Header().Set("Content-Type", "application/x-git-"+verb+"-advertisement")
122 w.Header().Set("Cache-Control", "no-cache")
123 head := fmt.Sprintf("# service=%s\n", service)
124 fmt.Fprintf(w, "%04x%s0000", len(head)+4, head)
125 w.Write(out)
126}
127
128func (s *Server) gitUploadPack(w http.ResponseWriter, r *http.Request) {
129 repo, path := s.gitRepo(r)
130 if repo == nil {
131 http.Error(w, "Not Found", http.StatusNotFound)
132 return
133 }
134 if repo.IsPrivate && !s.gitRequireAuth(w, r) {
135 return
136 }
137 body, err := gitRequestBody(r)
138 if err != nil {
139 http.Error(w, "Bad Request", http.StatusBadRequest)
140 return
141 }
142 s.runGitRPC(w, r, "upload-pack", repo.Name, path, body)
143}
144
145func (s *Server) gitReceivePack(w http.ResponseWriter, r *http.Request) {
146 if !s.gitRequireAuth(w, r) {
147 return
148 }
149 repo, path := s.gitRepo(r)
150 if repo == nil {
151 http.Error(w, "Not Found", http.StatusNotFound)
152 return
153 }
154 body, err := gitRequestBody(r)
155 if err != nil {
156 http.Error(w, "Bad Request", http.StatusBadRequest)
157 return
158 }
159 // Keep the start of the stream. The pkt-line ref updates always fit.
160 preamble := &ci.CapWriter{Limit: ci.PreambleMax}
161 pushed := s.runGitRPC(w, r, "receive-pack", repo.Name, path, io.TeeReader(body, preamble))
162 // Even a partly applied push moves refs, so the cache is dropped either way.
163 s.Git.InvalidateRefCache(repo.Name)
164 if !pushed {
165 return
166 }
167 // Run CI detached. The push response must not wait for it.
168 go s.CI.TriggerForPush(context.Background(), repo.Name, preamble.Bytes())
169}
170
171// runGitRPC streams the request body into git and its stdout back to the
172// client. It reports whether git exited successfully.
173func (s *Server) runGitRPC(w http.ResponseWriter, r *http.Request, verb, repoName, path string, body io.Reader) bool {
174 cmd := exec.CommandContext(r.Context(), "git", verb, "--stateless-rpc", path)
175 cmd.Env = gitcmd.EnvWithProtocol(r.Header.Get("Git-Protocol"))
176 cmd.Stdin = body
177 cmd.Stdout = w
178 var stderr strings.Builder
179 cmd.Stderr = &stderr
180 // Headers go out before the first byte of the pack. A later git failure
181 // can only be logged, because the status line is already sent.
182 w.Header().Set("Content-Type", "application/x-git-"+verb+"-result")
183 w.Header().Set("Cache-Control", "no-cache")
184 if err := cmd.Run(); err != nil {
185 log.Printf("git %s failed for %s: %v: %s", verb, repoName, err, strings.TrimSpace(stderr.String()))
186 return false
187 }
188 return true
189}
190
191// gitRequestBody decompresses gzip request bodies. Git compresses the small
192// upload-pack request by default.
193func gitRequestBody(r *http.Request) (io.Reader, error) {
194 if !strings.EqualFold(r.Header.Get("Content-Encoding"), "gzip") {
195 return r.Body, nil
196 }
197 return gzip.NewReader(r.Body)
198}
199