Parent directory

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}