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