Parent directory

git.go

17434 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// identify stores the requested repository and the authenticated caller in
197// the request context, and returns the authentication error, if any.
198func identify(r *http.Request) (*http.Request, proto.Repository, error) {
199	ctx := r.Context()
200	logger := log.FromContext(ctx)
201	be := backend.FromContext(ctx)
202
203	// A missing repository is not an error here; pushes create it.
204	repo, _ := be.Repository(ctx, mux.Vars(r)["repo"])
205	ctx = proto.WithRepositoryContext(ctx, repo)
206	r = r.WithContext(ctx)
207
208	user, err := authenticate(r)
209	if err != nil {
210		switch {
211		case errors.Is(err, ErrInvalidToken):
212		case errors.Is(err, proto.ErrUserNotFound):
213		default:
214			logger.Error("failed to authenticate", "err", err)
215		}
216	}
217
218	if user != nil {
219		logger.Debug("authenticated", "username", user.Username())
220	}
221
222	return r.WithContext(proto.WithUserContext(ctx, user)), repo, err
223}
224
225// badCredentials reports whether the caller sent credentials that failed.
226func badCredentials(err error) bool {
227	return errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword)
228}
229
230// denyRead answers a caller who may not read the repository. Missing and
231// unreadable repositories get the same answer so neither is revealed.
232func denyRead(w http.ResponseWriter, r *http.Request, err error, lfsJSON bool) {
233	switch {
234	case badCredentials(err) && lfsJSON:
235		renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{Message: "bad credentials"})
236	case badCredentials(err):
237		renderForbidden(w, r)
238	case proto.UserFromContext(r.Context()) == nil && lfsJSON:
239		askCredentials(w, r)
240		renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{Message: "credentials needed"})
241	case proto.UserFromContext(r.Context()) == nil:
242		askCredentials(w, r)
243		renderUnauthorized(w, r)
244	case lfsJSON:
245		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{Message: "repository not found"})
246	default:
247		renderNotFound(w, r)
248	}
249}
250
251// lfsNeedsWrite reports whether an LFS request needs write access.
252// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md
253func lfsNeedsWrite(file, method string) bool {
254	switch {
255	case strings.HasPrefix(file, "info/lfs/locks"):
256		return strings.HasSuffix(file, "lfs/locks") ||
257			strings.HasSuffix(file, "lfs/locks/verify") ||
258			strings.HasSuffix(file, "/unlock") && method == http.MethodPost
259	case strings.HasPrefix(file, "info/lfs/objects/basic"):
260		return method == http.MethodPut
261	}
262	return false
263}
264
265// requireCaller rejects anonymous requests when keyless access is disabled.
266func requireCaller(w http.ResponseWriter, r *http.Request) bool {
267	ctx := r.Context()
268	if proto.UserFromContext(ctx) == nil && !backend.FromContext(ctx).AllowKeyless(ctx) {
269		askCredentials(w, r)
270		renderUnauthorized(w, r)
271		return false
272	}
273	return true
274}
275
276// withCaller authenticates the caller without authorizing a repository.
277// Handlers behind it must check access to any repository they reveal.
278func withCaller(next http.Handler) http.HandlerFunc {
279	return func(w http.ResponseWriter, r *http.Request) {
280		r, _, err := identify(r)
281		if !requireCaller(w, r) {
282			return
283		}
284
285		if badCredentials(err) {
286			renderForbidden(w, r)
287			return
288		}
289
290		next.ServeHTTP(w, r)
291	}
292}
293
294// withAccess authenticates the caller and authorizes them for the requested
295// repository.
296func withAccess(next http.Handler) http.HandlerFunc {
297	return func(w http.ResponseWriter, r *http.Request) {
298		r, repo, err := identify(r)
299		if !requireCaller(w, r) {
300			return
301		}
302
303		ctx := r.Context()
304		cfg := config.FromContext(ctx)
305		logger := log.FromContext(ctx)
306		be := backend.FromContext(ctx)
307		repoName := mux.Vars(r)["repo"]
308		user := proto.UserFromContext(ctx)
309
310		service := git.Service(mux.Vars(r)["service"])
311		if service == "" {
312			// Get service from request params
313			service = getServiceType(r)
314		}
315
316		accessLevel := be.AccessLevelForUser(ctx, repoName, user)
317		ctx = access.WithContext(ctx, accessLevel)
318		r = r.WithContext(ctx)
319
320		file := mux.Vars(r)["file"]
321
322		// The LFS case must stay ahead of the service cases. "file" is derived
323		// from the request path, but "service" can come from a query parameter
324		// the caller controls, so an LFS request is authorized as LFS no
325		// matter what service it claims to be.
326		switch {
327		case strings.HasPrefix(file, "info/lfs"):
328			if !cfg.LFS.Enabled {
329				logger.Debug("LFS is not enabled, skipping")
330				renderNotFound(w, r)
331				return
332			}
333
334			if repo == nil || accessLevel < access.ReadOnlyAccess {
335				denyRead(w, r, err, true)
336				return
337			}
338
339			if lfsNeedsWrite(file, r.Method) && accessLevel < access.ReadWriteAccess {
340				renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
341					Message: "write access required",
342				})
343				return
344			}
345
346		case service == git.ReceivePackService:
347			if badCredentials(err) {
348				renderForbidden(w, r)
349				return
350			}
351
352			if accessLevel < access.ReadWriteAccess {
353				askCredentials(w, r)
354				renderUnauthorized(w, r)
355				return
356			}
357
358			// Create the repo if it doesn't exist, but only from the push
359			// endpoints, not from any route that carries the service param.
360			if repo == nil && (file == "info/refs" || file == git.ReceivePackService.String()) {
361				var cerr error
362				repo, cerr = be.CreateRepository(ctx, repoName, user, proto.RepositoryOptions{})
363				if cerr != nil {
364					logger.Error("failed to create repository", "repo", repoName, "err", cerr)
365					renderInternalServerError(w, r)
366					return
367				}
368
369				ctx = proto.WithRepositoryContext(ctx, repo)
370				r = r.WithContext(ctx)
371			}
372		}
373
374		if badCredentials(err) {
375			renderForbidden(w, r)
376			return
377		}
378
379		if repo == nil || accessLevel < access.ReadOnlyAccess {
380			denyRead(w, r, err, false)
381			return
382		}
383
384		next.ServeHTTP(w, r)
385	}
386}
387
388//nolint:revive
389func serviceRpc(w http.ResponseWriter, r *http.Request) {
390	ctx := r.Context()
391	cfg := config.FromContext(ctx)
392	logger := log.FromContext(ctx)
393	service, dir, repoName := git.Service(mux.Vars(r)["service"]), mux.Vars(r)["dir"], mux.Vars(r)["repo"]
394
395	if !isSmart(r, service) {
396		renderForbidden(w, r)
397		return
398	}
399
400	if service == git.ReceivePackService {
401		gitHttpReceiveCounter.WithLabelValues(repoName)
402	}
403
404	w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-result", service))
405	w.Header().Set("Connection", "Keep-Alive")
406	w.Header().Set("Transfer-Encoding", "chunked")
407	w.Header().Set("X-Content-Type-Options", "nosniff")
408	w.WriteHeader(http.StatusOK)
409
410	version := r.Header.Get("Git-Protocol")
411
412	cmd := git.ServiceCommand{
413		Dir: dir,
414	}
415
416	switch service {
417	case git.UploadPackService, git.ReceivePackService:
418		cmd.Args = append(cmd.Args, "--stateless-rpc")
419	}
420
421	user := proto.UserFromContext(ctx)
422	cmd.Env = cfg.Environ()
423	cmd.Env = append(cmd.Env, []string{
424		"SOFT_SERVE_REPO_NAME=" + repoName,
425		"SOFT_SERVE_REPO_PATH=" + dir,
426		"SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),
427	}...)
428	if user != nil {
429		cmd.Env = append(cmd.Env, []string{
430			"SOFT_SERVE_USERNAME=" + user.Username(),
431		}...)
432	}
433	if len(version) != 0 {
434		cmd.Env = append(cmd.Env, []string{
435			fmt.Sprintf("GIT_PROTOCOL=%s", version),
436		}...)
437	}
438
439	var (
440		err    error
441		reader io.ReadCloser
442	)
443
444	// Handle gzip encoding
445	reader = r.Body
446	switch r.Header.Get("Content-Encoding") {
447	case "gzip":
448		reader, err = gzip.NewReader(reader)
449		if err != nil {
450			logger.Errorf("failed to create gzip reader: %v", err)
451			renderInternalServerError(w, r)
452			return
453		}
454		defer reader.Close() //nolint: errcheck
455	}
456
457	cmd.Stdin = reader
458	cmd.Stdout = w
459
460	if err := service.Handler(ctx, cmd); err != nil {
461		logger.Errorf("failed to handle service: %v", err)
462		return
463	}
464
465	if service == git.ReceivePackService {
466		if err := git.EnsureDefaultBranch(ctx, cmd.Dir); err != nil {
467			logger.Errorf("failed to ensure default branch: %s", err)
468		}
469	}
470}
471
472func getInfoRefs(w http.ResponseWriter, r *http.Request) {
473	ctx := r.Context()
474	cfg := config.FromContext(ctx)
475	dir, repoName, file := mux.Vars(r)["dir"], mux.Vars(r)["repo"], mux.Vars(r)["file"]
476	service := getServiceType(r)
477	protocol := r.Header.Get("Git-Protocol")
478
479	gitHttpUploadCounter.WithLabelValues(repoName, file).Inc()
480
481	if service != "" && (service == git.UploadPackService || service == git.ReceivePackService) {
482		// Smart HTTP
483		var refs bytes.Buffer
484		cmd := git.ServiceCommand{
485			Stdout: &refs,
486			Dir:    dir,
487			Args:   []string{"--stateless-rpc", "--advertise-refs"},
488		}
489
490		user := proto.UserFromContext(ctx)
491		cmd.Env = cfg.Environ()
492		cmd.Env = append(cmd.Env, []string{
493			"SOFT_SERVE_REPO_NAME=" + repoName,
494			"SOFT_SERVE_REPO_PATH=" + dir,
495			"SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),
496		}...)
497		if user != nil {
498			cmd.Env = append(cmd.Env, []string{
499				"SOFT_SERVE_USERNAME=" + user.Username(),
500			}...)
501		}
502		if len(protocol) != 0 {
503			cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", protocol))
504		}
505
506		var version int
507		for _, p := range strings.Split(protocol, ":") {
508			if strings.HasPrefix(p, "version=") {
509				if v, _ := strconv.Atoi(p[8:]); v > version {
510					version = v
511				}
512			}
513		}
514
515		if err := service.Handler(ctx, cmd); err != nil {
516			renderNotFound(w, r)
517			return
518		}
519
520		hdrNocache(w)
521		w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-advertisement", service))
522		w.WriteHeader(http.StatusOK)
523		if version < 2 {
524			git.WritePktline(w, "# service="+service.String()) //nolint: errcheck
525		}
526		w.Write(refs.Bytes()) //nolint: errcheck
527	} else {
528		// Dumb HTTP
529		updateServerInfo(ctx, dir) //nolint: errcheck
530		hdrNocache(w)
531		sendFile("text/plain; charset=utf-8", w, r)
532	}
533}
534
535func getInfoPacks(w http.ResponseWriter, r *http.Request) {
536	hdrCacheForever(w)
537	sendFile("text/plain; charset=utf-8", w, r)
538}
539
540func getLooseObject(w http.ResponseWriter, r *http.Request) {
541	hdrCacheForever(w)
542	sendFile("application/x-git-loose-object", w, r)
543}
544
545func getPackFile(w http.ResponseWriter, r *http.Request) {
546	hdrCacheForever(w)
547	sendFile("application/x-git-packed-objects", w, r)
548}
549
550func getIdxFile(w http.ResponseWriter, r *http.Request) {
551	hdrCacheForever(w)
552	sendFile("application/x-git-packed-objects-toc", w, r)
553}
554
555func getTextFile(w http.ResponseWriter, r *http.Request) {
556	hdrNocache(w)
557	sendFile("text/plain", w, r)
558}
559
560func sendFile(contentType string, w http.ResponseWriter, r *http.Request) {
561	dir, file := mux.Vars(r)["dir"], mux.Vars(r)["file"]
562	reqFile := filepath.Join(dir, file)
563
564	f, err := os.Stat(reqFile)
565	if os.IsNotExist(err) {
566		renderNotFound(w, r)
567		return
568	}
569
570	w.Header().Set("Content-Type", contentType)
571	w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size()))
572	w.Header().Set("Last-Modified", f.ModTime().Format(http.TimeFormat))
573	http.ServeFile(w, r, reqFile)
574}
575
576func getServiceType(r *http.Request) git.Service {
577	service := r.FormValue("service")
578	if !strings.HasPrefix(service, "git-") {
579		return ""
580	}
581
582	return git.Service(service)
583}
584
585func isSmart(r *http.Request, service git.Service) bool {
586	contentType := r.Header.Get("Content-Type")
587	return strings.HasPrefix(contentType, fmt.Sprintf("application/x-%s-request", service))
588}
589
590func updateServerInfo(ctx context.Context, dir string) error {
591	return gitb.UpdateServerInfo(ctx, dir)
592}
593
594// HTTP error response handling functions
595
596func renderBadRequest(w http.ResponseWriter, r *http.Request) {
597	renderStatus(http.StatusBadRequest)(w, r)
598}
599
600func renderMethodNotAllowed(w http.ResponseWriter, r *http.Request) {
601	if r.Proto == "HTTP/1.1" {
602		renderStatus(http.StatusMethodNotAllowed)(w, r)
603	} else {
604		renderBadRequest(w, r)
605	}
606}
607
608func renderNotFound(w http.ResponseWriter, r *http.Request) {
609	renderStatus(http.StatusNotFound)(w, r)
610}
611
612func renderUnauthorized(w http.ResponseWriter, r *http.Request) {
613	renderStatus(http.StatusUnauthorized)(w, r)
614}
615
616func renderForbidden(w http.ResponseWriter, r *http.Request) {
617	renderStatus(http.StatusForbidden)(w, r)
618}
619
620func renderInternalServerError(w http.ResponseWriter, r *http.Request) {
621	renderStatus(http.StatusInternalServerError)(w, r)
622}
623
624// Header writing functions
625
626func hdrNocache(w http.ResponseWriter) {
627	w.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT")
628	w.Header().Set("Pragma", "no-cache")
629	w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
630}
631
632func hdrCacheForever(w http.ResponseWriter) {
633	now := time.Now().Unix()
634	expires := now + 31536000
635	w.Header().Set("Date", fmt.Sprintf("%d", now))
636	w.Header().Set("Expires", fmt.Sprintf("%d", expires))
637	w.Header().Set("Cache-Control", "public, max-age=31536000")
638}