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