git.go

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