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