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