git.go
17044 bytes
1package web
2
3import (
4 "bytes"
5 "compress/gzip"
6 "context"
7 "errors"
8 "fmt"
9 "io"
10 "net/http"
11 "os"
12 "path/filepath"
13 "strconv"
14 "strings"
15 "time"
16
17 "charm.land/log/v2"
18 gitb "github.com/charmbracelet/soft-serve/git"
19 "github.com/charmbracelet/soft-serve/pkg/access"
20 "github.com/charmbracelet/soft-serve/pkg/backend"
21 "github.com/charmbracelet/soft-serve/pkg/config"
22 "github.com/charmbracelet/soft-serve/pkg/git"
23 "github.com/charmbracelet/soft-serve/pkg/lfs"
24 "github.com/charmbracelet/soft-serve/pkg/proto"
25 "github.com/charmbracelet/soft-serve/pkg/utils"
26 "github.com/gorilla/mux"
27 "github.com/prometheus/client_golang/prometheus"
28 "github.com/prometheus/client_golang/prometheus/promauto"
29)
30
31// GitRoute is a route for git services.
32type GitRoute struct {
33 method []string
34 handler http.HandlerFunc
35 path string
36}
37
38var _ http.Handler = GitRoute{}
39
40// ServeHTTP implements http.Handler.
41func (g GitRoute) ServeHTTP(w http.ResponseWriter, r *http.Request) {
42 var hasMethod bool
43 for _, m := range g.method {
44 if m == r.Method {
45 hasMethod = true
46 break
47 }
48 }
49
50 if !hasMethod {
51 renderMethodNotAllowed(w, r)
52 return
53 }
54
55 g.handler(w, r)
56}
57
58var (
59 //nolint:revive
60 gitHttpReceiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{
61 Namespace: "soft_serve",
62 Subsystem: "http",
63 Name: "git_receive_pack_total",
64 Help: "The total number of git push requests",
65 }, []string{"repo"})
66
67 //nolint:revive
68 gitHttpUploadCounter = promauto.NewCounterVec(prometheus.CounterOpts{
69 Namespace: "soft_serve",
70 Subsystem: "http",
71 Name: "git_upload_pack_total",
72 Help: "The total number of git fetch/pull requests",
73 }, []string{"repo", "file"})
74)
75
76func withParams(next http.Handler) http.Handler {
77 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
78 ctx := r.Context()
79 cfg := config.FromContext(ctx)
80 vars := mux.Vars(r)
81 repo := vars["repo"]
82
83 // Construct "file" param from path
84 vars["file"] = strings.TrimPrefix(r.URL.Path, "/"+repo+"/")
85
86 // Set service type
87 switch {
88 case strings.HasSuffix(r.URL.Path, git.UploadPackService.String()):
89 vars["service"] = git.UploadPackService.String()
90 case strings.HasSuffix(r.URL.Path, git.ReceivePackService.String()):
91 vars["service"] = git.ReceivePackService.String()
92 }
93
94 repo = utils.SanitizeRepo(repo)
95 vars["repo"] = repo
96 vars["dir"] = filepath.Join(cfg.DataPath, "repos", repo+".git")
97
98 // Add repo suffix (.git)
99 r.URL.Path = fmt.Sprintf("%s.git/%s", repo, vars["file"])
100 r = mux.SetURLVars(r, vars)
101
102 next.ServeHTTP(w, r)
103 })
104}
105
106// GitController is a router for git services.
107func GitController(_ context.Context, r *mux.Router) {
108 basePrefix := "/{repo:.*}"
109 for _, route := range gitRoutes {
110 // NOTE: withParam must always be the outermost wrapper, otherwise the
111 // request vars will not be set.
112 r.Handle(basePrefix+route.path, withParams(withAccess(route)))
113 }
114}
115
116var gitRoutes = []GitRoute{
117 // Git services
118 // These routes don't handle authentication/authorization.
119 // This is handled through wrapping the handlers for each route.
120 // See below (withAccess).
121 {
122 method: []string{http.MethodPost},
123 handler: serviceRpc,
124 path: "/{service:(?:git-upload-archive|git-upload-pack|git-receive-pack)$}",
125 },
126 {
127 method: []string{http.MethodGet},
128 handler: getInfoRefs,
129 path: "/info/refs",
130 },
131 {
132 method: []string{http.MethodGet},
133 handler: getTextFile,
134 path: "/{_:(?:HEAD|objects/info/alternates|objects/info/http-alternates|objects/info/[^/]*)$}",
135 },
136 {
137 method: []string{http.MethodGet},
138 handler: getInfoPacks,
139 path: "/objects/info/packs",
140 },
141 {
142 method: []string{http.MethodGet},
143 handler: getLooseObject,
144 path: "/objects/{_:[0-9a-f]{2}/[0-9a-f]{38}$}",
145 },
146 {
147 method: []string{http.MethodGet},
148 handler: getPackFile,
149 path: "/objects/pack/{_:pack-[0-9a-f]{40}\\.pack$}",
150 },
151 {
152 method: []string{http.MethodGet},
153 handler: getIdxFile,
154 path: "/objects/pack/{_:pack-[0-9a-f]{40}\\.idx$}",
155 },
156 // Git LFS
157 {
158 method: []string{http.MethodPost},
159 handler: serviceLfsBatch,
160 path: "/info/lfs/objects/batch",
161 },
162 {
163 // Git LFS basic object handler
164 method: []string{http.MethodGet, http.MethodPut},
165 handler: serviceLfsBasic,
166 path: "/info/lfs/objects/basic/{oid:[0-9a-f]{64}$}",
167 },
168 {
169 method: []string{http.MethodPost},
170 handler: serviceLfsBasicVerify,
171 path: "/info/lfs/objects/basic/verify",
172 },
173 // Git LFS locks
174 {
175 method: []string{http.MethodPost, http.MethodGet},
176 handler: serviceLfsLocks,
177 path: "/info/lfs/locks",
178 },
179 {
180 method: []string{http.MethodPost},
181 handler: serviceLfsLocksVerify,
182 path: "/info/lfs/locks/verify",
183 },
184 {
185 method: []string{http.MethodPost},
186 handler: serviceLfsLocksDelete,
187 path: "/info/lfs/locks/{lock_id:[0-9]+}/unlock",
188 },
189}
190
191func askCredentials(w http.ResponseWriter, _ *http.Request) {
192 w.Header().Set("WWW-Authenticate", `Basic realm="Git" charset="UTF-8", Token, Bearer`)
193 w.Header().Set("LFS-Authenticate", `Basic realm="Git LFS" charset="UTF-8", Token, Bearer`)
194}
195
196// withAccess handles auth.
197func withAccess(next http.Handler) http.HandlerFunc {
198 return func(w http.ResponseWriter, r *http.Request) {
199 ctx := r.Context()
200 cfg := config.FromContext(ctx)
201 logger := log.FromContext(ctx)
202 be := backend.FromContext(ctx)
203
204 // Store repository in context
205 // We're not checking for errors here because we want to allow
206 // repo creation on the fly.
207 repoName := mux.Vars(r)["repo"]
208 repo, _ := be.Repository(ctx, repoName)
209 ctx = proto.WithRepositoryContext(ctx, repo)
210 r = r.WithContext(ctx)
211
212 user, err := authenticate(r)
213 if err != nil {
214 switch {
215 case errors.Is(err, ErrInvalidToken):
216 case errors.Is(err, proto.ErrUserNotFound):
217 default:
218 logger.Error("failed to authenticate", "err", err)
219 }
220 }
221
222 if user == nil && !be.AllowKeyless(ctx) {
223 askCredentials(w, r)
224 renderUnauthorized(w, r)
225 return
226 }
227
228 // Store user in context
229 ctx = proto.WithUserContext(ctx, user)
230 r = r.WithContext(ctx)
231
232 if user != nil {
233 logger.Debug("authenticated", "username", user.Username())
234 }
235
236 service := git.Service(mux.Vars(r)["service"])
237 if service == "" {
238 // Get service from request params
239 service = getServiceType(r)
240 }
241
242 accessLevel := be.AccessLevelForUser(ctx, repoName, user)
243 ctx = access.WithContext(ctx, accessLevel)
244 r = r.WithContext(ctx)
245
246 file := mux.Vars(r)["file"]
247
248 // We only allow these services to proceed any other services should return 403
249 // - git-upload-pack
250 // - git-receive-pack
251 // - git-lfs
252 //
253 // The LFS case must stay ahead of the service cases. "file" is derived
254 // from the request path by withParams, but "service" can come from a
255 // query parameter the caller controls, and withParams only fills it in
256 // for paths ending in git-upload-pack or git-receive-pack, so it is
257 // always caller-supplied on an LFS route. Matching the path first means
258 // an LFS request is authorized as LFS no matter what service it claims
259 // to be.
260 switch {
261 case strings.HasPrefix(file, "info/lfs"):
262 if !cfg.LFS.Enabled {
263 logger.Debug("LFS is not enabled, skipping")
264 renderNotFound(w, r)
265 return
266 }
267
268 switch {
269 case strings.HasPrefix(file, "info/lfs/locks"):
270 switch {
271 case strings.HasSuffix(file, "lfs/locks"), strings.HasSuffix(file, "/unlock") && r.Method == http.MethodPost:
272 // Create lock, list locks, and delete lock require write access
273 fallthrough
274 case strings.HasSuffix(file, "lfs/locks/verify"):
275 // Locks verify requires write access
276 // https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md#unauthorized-response-2
277 if accessLevel < access.ReadWriteAccess {
278 renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
279 Message: "write access required",
280 })
281 return
282 }
283 }
284 case strings.HasPrefix(file, "info/lfs/objects/basic"):
285 switch r.Method {
286 case http.MethodPut:
287 // Basic upload
288 if accessLevel < access.ReadWriteAccess {
289 renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
290 Message: "write access required",
291 })
292 return
293 }
294 case http.MethodGet:
295 // Basic download
296 case http.MethodPost:
297 // Basic verify
298 }
299 }
300
301 if accessLevel < access.ReadOnlyAccess {
302 if repo == nil {
303 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
304 Message: "repository not found",
305 })
306 } else if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) {
307 renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
308 Message: "bad credentials",
309 })
310 } else {
311 askCredentials(w, r)
312 renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{
313 Message: "credentials needed",
314 })
315 }
316 return
317 }
318
319 case service == git.ReceivePackService:
320 if accessLevel < access.ReadWriteAccess {
321 askCredentials(w, r)
322 renderUnauthorized(w, r)
323 return
324 }
325
326 // Create the repo if it doesn't exist.
327 if repo == nil {
328 repo, err = be.CreateRepository(ctx, repoName, user, proto.RepositoryOptions{})
329 if err != nil {
330 logger.Error("failed to create repository", "repo", repoName, "err", err)
331 renderInternalServerError(w, r)
332 return
333 }
334
335 ctx = proto.WithRepositoryContext(ctx, repo)
336 r = r.WithContext(ctx)
337 }
338
339 fallthrough
340 case service == git.UploadPackService || service == git.UploadArchiveService:
341 if repo == nil {
342 // If the repo doesn't exist, return 404
343 renderNotFound(w, r)
344 return
345 } else if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) {
346 // return 403 when bad credentials are provided
347 renderForbidden(w, r)
348 return
349 } else if accessLevel < access.ReadOnlyAccess {
350 askCredentials(w, r)
351 renderUnauthorized(w, r)
352 return
353 }
354 }
355
356 switch {
357 case r.URL.Query().Get("go-get") == "1" && accessLevel >= access.ReadOnlyAccess:
358 // Allow go-get requests to passthrough.
359 break
360 case errors.Is(err, ErrInvalidToken), errors.Is(err, ErrInvalidPassword):
361 // return 403 when bad credentials are provided
362 renderForbidden(w, r)
363 return
364 case repo == nil, accessLevel < access.ReadOnlyAccess:
365 // Don't hint that the repo exists if the user doesn't have access
366 renderNotFound(w, r)
367 return
368 }
369
370 next.ServeHTTP(w, r)
371 }
372}
373
374//nolint:revive
375func serviceRpc(w http.ResponseWriter, r *http.Request) {
376 ctx := r.Context()
377 cfg := config.FromContext(ctx)
378 logger := log.FromContext(ctx)
379 service, dir, repoName := git.Service(mux.Vars(r)["service"]), mux.Vars(r)["dir"], mux.Vars(r)["repo"]
380
381 if !isSmart(r, service) {
382 renderForbidden(w, r)
383 return
384 }
385
386 if service == git.ReceivePackService {
387 gitHttpReceiveCounter.WithLabelValues(repoName)
388 }
389
390 w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-result", service))
391 w.Header().Set("Connection", "Keep-Alive")
392 w.Header().Set("Transfer-Encoding", "chunked")
393 w.Header().Set("X-Content-Type-Options", "nosniff")
394 w.WriteHeader(http.StatusOK)
395
396 version := r.Header.Get("Git-Protocol")
397
398 cmd := git.ServiceCommand{
399 Dir: dir,
400 }
401
402 switch service {
403 case git.UploadPackService, git.ReceivePackService:
404 cmd.Args = append(cmd.Args, "--stateless-rpc")
405 }
406
407 user := proto.UserFromContext(ctx)
408 cmd.Env = cfg.Environ()
409 cmd.Env = append(cmd.Env, []string{
410 "SOFT_SERVE_REPO_NAME=" + repoName,
411 "SOFT_SERVE_REPO_PATH=" + dir,
412 "SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),
413 }...)
414 if user != nil {
415 cmd.Env = append(cmd.Env, []string{
416 "SOFT_SERVE_USERNAME=" + user.Username(),
417 }...)
418 }
419 if len(version) != 0 {
420 cmd.Env = append(cmd.Env, []string{
421 fmt.Sprintf("GIT_PROTOCOL=%s", version),
422 }...)
423 }
424
425 var (
426 err error
427 reader io.ReadCloser
428 )
429
430 // Handle gzip encoding
431 reader = r.Body
432 switch r.Header.Get("Content-Encoding") {
433 case "gzip":
434 reader, err = gzip.NewReader(reader)
435 if err != nil {
436 logger.Errorf("failed to create gzip reader: %v", err)
437 renderInternalServerError(w, r)
438 return
439 }
440 defer reader.Close() //nolint: errcheck
441 }
442
443 cmd.Stdin = reader
444 cmd.Stdout = w
445
446 if err := service.Handler(ctx, cmd); err != nil {
447 logger.Errorf("failed to handle service: %v", err)
448 return
449 }
450
451 if service == git.ReceivePackService {
452 if err := git.EnsureDefaultBranch(ctx, cmd.Dir); err != nil {
453 logger.Errorf("failed to ensure default branch: %s", err)
454 }
455 }
456}
457
458func getInfoRefs(w http.ResponseWriter, r *http.Request) {
459 ctx := r.Context()
460 cfg := config.FromContext(ctx)
461 dir, repoName, file := mux.Vars(r)["dir"], mux.Vars(r)["repo"], mux.Vars(r)["file"]
462 service := getServiceType(r)
463 protocol := r.Header.Get("Git-Protocol")
464
465 gitHttpUploadCounter.WithLabelValues(repoName, file).Inc()
466
467 if service != "" && (service == git.UploadPackService || service == git.ReceivePackService) {
468 // Smart HTTP
469 var refs bytes.Buffer
470 cmd := git.ServiceCommand{
471 Stdout: &refs,
472 Dir: dir,
473 Args: []string{"--stateless-rpc", "--advertise-refs"},
474 }
475
476 user := proto.UserFromContext(ctx)
477 cmd.Env = cfg.Environ()
478 cmd.Env = append(cmd.Env, []string{
479 "SOFT_SERVE_REPO_NAME=" + repoName,
480 "SOFT_SERVE_REPO_PATH=" + dir,
481 "SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),
482 }...)
483 if user != nil {
484 cmd.Env = append(cmd.Env, []string{
485 "SOFT_SERVE_USERNAME=" + user.Username(),
486 }...)
487 }
488 if len(protocol) != 0 {
489 cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", protocol))
490 }
491
492 var version int
493 for _, p := range strings.Split(protocol, ":") {
494 if strings.HasPrefix(p, "version=") {
495 if v, _ := strconv.Atoi(p[8:]); v > version {
496 version = v
497 }
498 }
499 }
500
501 if err := service.Handler(ctx, cmd); err != nil {
502 renderNotFound(w, r)
503 return
504 }
505
506 hdrNocache(w)
507 w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-advertisement", service))
508 w.WriteHeader(http.StatusOK)
509 if version < 2 {
510 git.WritePktline(w, "# service="+service.String()) //nolint: errcheck
511 }
512 w.Write(refs.Bytes()) //nolint: errcheck
513 } else {
514 // Dumb HTTP
515 updateServerInfo(ctx, dir) //nolint: errcheck
516 hdrNocache(w)
517 sendFile("text/plain; charset=utf-8", w, r)
518 }
519}
520
521func getInfoPacks(w http.ResponseWriter, r *http.Request) {
522 hdrCacheForever(w)
523 sendFile("text/plain; charset=utf-8", w, r)
524}
525
526func getLooseObject(w http.ResponseWriter, r *http.Request) {
527 hdrCacheForever(w)
528 sendFile("application/x-git-loose-object", w, r)
529}
530
531func getPackFile(w http.ResponseWriter, r *http.Request) {
532 hdrCacheForever(w)
533 sendFile("application/x-git-packed-objects", w, r)
534}
535
536func getIdxFile(w http.ResponseWriter, r *http.Request) {
537 hdrCacheForever(w)
538 sendFile("application/x-git-packed-objects-toc", w, r)
539}
540
541func getTextFile(w http.ResponseWriter, r *http.Request) {
542 hdrNocache(w)
543 sendFile("text/plain", w, r)
544}
545
546func sendFile(contentType string, w http.ResponseWriter, r *http.Request) {
547 dir, file := mux.Vars(r)["dir"], mux.Vars(r)["file"]
548 reqFile := filepath.Join(dir, file)
549
550 f, err := os.Stat(reqFile)
551 if os.IsNotExist(err) {
552 renderNotFound(w, r)
553 return
554 }
555
556 w.Header().Set("Content-Type", contentType)
557 w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size()))
558 w.Header().Set("Last-Modified", f.ModTime().Format(http.TimeFormat))
559 http.ServeFile(w, r, reqFile)
560}
561
562func getServiceType(r *http.Request) git.Service {
563 service := r.FormValue("service")
564 if !strings.HasPrefix(service, "git-") {
565 return ""
566 }
567
568 return git.Service(service)
569}
570
571func isSmart(r *http.Request, service git.Service) bool {
572 contentType := r.Header.Get("Content-Type")
573 return strings.HasPrefix(contentType, fmt.Sprintf("application/x-%s-request", service))
574}
575
576func updateServerInfo(ctx context.Context, dir string) error {
577 return gitb.UpdateServerInfo(ctx, dir)
578}
579
580// HTTP error response handling functions
581
582func renderBadRequest(w http.ResponseWriter, r *http.Request) {
583 renderStatus(http.StatusBadRequest)(w, r)
584}
585
586func renderMethodNotAllowed(w http.ResponseWriter, r *http.Request) {
587 if r.Proto == "HTTP/1.1" {
588 renderStatus(http.StatusMethodNotAllowed)(w, r)
589 } else {
590 renderBadRequest(w, r)
591 }
592}
593
594func renderNotFound(w http.ResponseWriter, r *http.Request) {
595 renderStatus(http.StatusNotFound)(w, r)
596}
597
598func renderUnauthorized(w http.ResponseWriter, r *http.Request) {
599 renderStatus(http.StatusUnauthorized)(w, r)
600}
601
602func renderForbidden(w http.ResponseWriter, r *http.Request) {
603 renderStatus(http.StatusForbidden)(w, r)
604}
605
606func renderInternalServerError(w http.ResponseWriter, r *http.Request) {
607 renderStatus(http.StatusInternalServerError)(w, r)
608}
609
610// Header writing functions
611
612func hdrNocache(w http.ResponseWriter) {
613 w.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT")
614 w.Header().Set("Pragma", "no-cache")
615 w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
616}
617
618func hdrCacheForever(w http.ResponseWriter) {
619 now := time.Now().Unix()
620 expires := now + 31536000
621 w.Header().Set("Date", fmt.Sprintf("%d", now))
622 w.Header().Set("Expires", fmt.Sprintf("%d", expires))
623 w.Header().Set("Cache-Control", "public, max-age=31536000")
624}