feat: use gorilla/mux

Ayman Bagabas created

Change summary

go.mod                         |   4 
go.sum                         |   8 
server/web/git.go              | 240 +++++++++++++++++------------------
server/web/git_lfs.go          |  20 +-
server/web/goget.go            |  20 +-
server/web/server.go           |  30 +---
server/web/util.go             |   8 
testscript/testdata/http.txtar |  44 +++++-
8 files changed, 198 insertions(+), 176 deletions(-)

Detailed changes

go.mod 🔗

@@ -29,6 +29,8 @@ require (
 	github.com/gobwas/glob v0.2.3
 	github.com/gogs/git-module v1.8.2
 	github.com/golang-jwt/jwt/v5 v5.0.0
+	github.com/gorilla/handlers v1.5.1
+	github.com/gorilla/mux v1.8.0
 	github.com/hashicorp/golang-lru/v2 v2.0.4
 	github.com/jmoiron/sqlx v1.3.5
 	github.com/lib/pq v1.10.9
@@ -41,7 +43,6 @@ require (
 	github.com/rubyist/tracerx v0.0.0-20170927163412-787959303086
 	github.com/spf13/cobra v1.7.0
 	go.uber.org/automaxprocs v1.5.3
-	goji.io v2.0.2+incompatible
 	golang.org/x/crypto v0.11.0
 	golang.org/x/sync v0.3.0
 	gopkg.in/yaml.v3 v3.0.1
@@ -58,6 +59,7 @@ require (
 	github.com/cespare/xxhash/v2 v2.2.0 // indirect
 	github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
 	github.com/dlclark/regexp2 v1.4.0 // indirect
+	github.com/felixge/httpsnoop v1.0.1 // indirect
 	github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 // indirect
 	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
 	github.com/go-logfmt/logfmt v0.6.0 // indirect

go.sum 🔗

@@ -49,6 +49,8 @@ github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E
 github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ=
+github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 h1:mtDjlmloH7ytdblogrMz1/8Hqua1y8B4ID+bh3rvod0=
 github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1/go.mod h1:fenKRzpXDjNpsIBhuhUzvjCKlDjKam0boRAenTE0Q6A=
 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
@@ -80,6 +82,10 @@ github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
 github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
+github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
+github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
+github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
+github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
 github.com/hashicorp/golang-lru/v2 v2.0.4 h1:7GHuZcgid37q8o5i3QI9KMT4nCWQQ3Kx3Ov6bb9MfK0=
 github.com/hashicorp/golang-lru/v2 v2.0.4/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -192,8 +198,6 @@ github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18W
 github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ=
 go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8=
 go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0=
-goji.io v2.0.2+incompatible h1:uIssv/elbKRLznFUy3Xj4+2Mz/qKhek/9aZQDUMae7c=
-goji.io v2.0.2+incompatible/go.mod h1:sbqFwrtqZACxLBTQcdgVjFh54yGVCvwq8+w49MVMMIk=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=

server/web/git.go 🔗

@@ -10,7 +10,6 @@ import (
 	"net/http"
 	"os"
 	"path/filepath"
-	"regexp"
 	"strings"
 	"time"
 
@@ -23,64 +22,19 @@ import (
 	"github.com/charmbracelet/soft-serve/server/lfs"
 	"github.com/charmbracelet/soft-serve/server/proto"
 	"github.com/charmbracelet/soft-serve/server/utils"
+	"github.com/gorilla/mux"
 	"github.com/prometheus/client_golang/prometheus"
 	"github.com/prometheus/client_golang/prometheus/promauto"
-	"goji.io/pat"
-	"goji.io/pattern"
 )
 
 // GitRoute is a route for git services.
 type GitRoute struct {
 	method  []string
-	pattern *regexp.Regexp
 	handler http.HandlerFunc
+	path    string
 }
 
-var _ Route = GitRoute{}
-
-// Match implements goji.Pattern.
-func (g GitRoute) Match(r *http.Request) *http.Request {
-	re := g.pattern
-	ctx := r.Context()
-	cfg := config.FromContext(ctx)
-	if m := re.FindStringSubmatch(r.URL.Path); m != nil {
-		// This finds the Git objects & packs filenames in the URL.
-		file := strings.Replace(r.URL.Path, m[1]+"/", "", 1)
-		repo := utils.SanitizeRepo(m[1])
-		// Add repo suffix (.git)
-		r.URL.Path = fmt.Sprintf("%s.git/%s", repo, file)
-
-		var service git.Service
-		var oid string    // LFS object ID
-		var lockID string // LFS lock ID
-		switch {
-		case strings.HasSuffix(r.URL.Path, git.UploadPackService.String()):
-			service = git.UploadPackService
-		case strings.HasSuffix(r.URL.Path, git.ReceivePackService.String()):
-			service = git.ReceivePackService
-		case len(m) > 2:
-			if strings.HasPrefix(file, "info/lfs/objects/basic/") {
-				oid = m[2]
-			} else if strings.HasPrefix(file, "info/lfs/locks/") && strings.HasSuffix(file, "/unlock") {
-				lockID = m[2]
-			}
-			fallthrough
-		case strings.HasPrefix(file, "info/lfs"):
-			service = gitLfsService
-		}
-
-		ctx = context.WithValue(ctx, pattern.Variable("lock_id"), lockID)
-		ctx = context.WithValue(ctx, pattern.Variable("oid"), oid)
-		ctx = context.WithValue(ctx, pattern.Variable("service"), service.String())
-		ctx = context.WithValue(ctx, pattern.Variable("dir"), filepath.Join(cfg.DataPath, "repos", repo+".git"))
-		ctx = context.WithValue(ctx, pattern.Variable("repo"), repo)
-		ctx = context.WithValue(ctx, pattern.Variable("file"), file)
-
-		return r.WithContext(ctx)
-	}
-
-	return nil
-}
+var _ http.Handler = GitRoute{}
 
 // ServeHTTP implements http.Handler.
 func (g GitRoute) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -118,18 +72,49 @@ var (
 	}, []string{"repo", "file"})
 )
 
-var (
-	serviceRpcMatcher            = regexp.MustCompile("(.*?)/(?:git-upload-pack|git-receive-pack)$") // nolint: revive
-	getInfoRefsMatcher           = regexp.MustCompile("(.*?)/info/refs$")
-	getTextFileMatcher           = regexp.MustCompile("(.*?)/(?:HEAD|objects/info/alternates|objects/info/http-alternates|objects/info/[^/]*)$")
-	getInfoPacksMatcher          = regexp.MustCompile("(.*?)/objects/info/packs$")
-	getLooseObjectMatcher        = regexp.MustCompile("(.*?)/objects/[0-9a-f]{2}/[0-9a-f]{38}$")
-	getPackFileMatcher           = regexp.MustCompile(`(.*?)/objects/pack/pack-[0-9a-f]{40}\.pack$`)
-	getIdxFileMatcher            = regexp.MustCompile(`(.*?)/objects/pack/pack-[0-9a-f]{40}\.idx$`)
-	serviceLfsBatchMatcher       = regexp.MustCompile("(.*?)/info/lfs/objects/batch$")
-	serviceLfsBasicMatcher       = regexp.MustCompile("(.*?)/info/lfs/objects/basic/([0-9a-f]{64})$")
-	serviceLfsBasicVerifyMatcher = regexp.MustCompile("(.*?)/info/lfs/objects/basic/verify$")
-)
+func withParams(h http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		ctx := r.Context()
+		logger := log.FromContext(ctx)
+		cfg := config.FromContext(ctx)
+		vars := mux.Vars(r)
+		repo := vars["repo"]
+
+		// Construct "file" param from path
+		vars["file"] = strings.TrimPrefix(r.URL.Path, "/"+repo+"/")
+
+		// Set service type
+		switch {
+		case strings.HasSuffix(r.URL.Path, git.UploadPackService.String()):
+			vars["service"] = git.UploadPackService.String()
+		case strings.HasSuffix(r.URL.Path, git.ReceivePackService.String()):
+			vars["service"] = git.ReceivePackService.String()
+		}
+
+		repo = utils.SanitizeRepo(repo)
+		vars["repo"] = repo
+		vars["dir"] = filepath.Join(cfg.DataPath, "repos", repo+".git")
+		logger.Info("request vars", "vars", vars)
+
+		// Add repo suffix (.git)
+		r.URL.Path = fmt.Sprintf("%s.git/%s", repo, vars["file"])
+		r = mux.SetURLVars(r, vars)
+		h.ServeHTTP(w, r)
+	})
+}
+
+// GitController is a router for git services.
+func GitController(_ context.Context, r *mux.Router) {
+	basePrefix := "/{repo:.*}"
+	for _, route := range gitRoutes {
+		// NOTE: withParam must always be the outermost wrapper, otherwise the
+		// request vars will not be set.
+		r.Handle(basePrefix+route.path, withParams(withAccess(route)))
+	}
+
+	// Handle go-get
+	r.Handle(basePrefix, withParams(withAccess(GoGetHandler{}))).Methods(http.MethodGet)
+}
 
 var gitRoutes = []GitRoute{
 	// Git services
@@ -137,77 +122,72 @@ var gitRoutes = []GitRoute{
 	// This is handled through wrapping the handlers for each route.
 	// See below (withAccess).
 	{
-		pattern: serviceRpcMatcher,
 		method:  []string{http.MethodPost},
 		handler: serviceRpc,
+		path:    "/{service:(?:git-upload-pack|git-receive-pack)$}",
 	},
 	{
-		pattern: getInfoRefsMatcher,
 		method:  []string{http.MethodGet},
 		handler: getInfoRefs,
+		path:    "/info/refs",
 	},
 	{
-		pattern: getTextFileMatcher,
 		method:  []string{http.MethodGet},
 		handler: getTextFile,
+		path:    "/{_:(?:HEAD|objects/info/alternates|objects/info/http-alternates|objects/info/[^/]*)$}",
 	},
 	{
-		pattern: getTextFileMatcher,
-		method:  []string{http.MethodGet},
-		handler: getTextFile,
-	},
-	{
-		pattern: getInfoPacksMatcher,
 		method:  []string{http.MethodGet},
 		handler: getInfoPacks,
+		path:    "/objects/info/packs",
 	},
 	{
-		pattern: getLooseObjectMatcher,
 		method:  []string{http.MethodGet},
 		handler: getLooseObject,
+		path:    "/objects/{_:[0-9a-f]{2}/[0-9a-f]{38}$}",
 	},
 	{
-		pattern: getPackFileMatcher,
 		method:  []string{http.MethodGet},
 		handler: getPackFile,
+		path:    "/objects/pack/{_:pack-[0-9a-f]{40}\\.pack$}",
 	},
 	{
-		pattern: getIdxFileMatcher,
 		method:  []string{http.MethodGet},
 		handler: getIdxFile,
+		path:    "/objects/pack/{_:pack-[0-9a-f]{40}\\.idx$}",
 	},
 	// Git LFS
 	{
-		pattern: serviceLfsBatchMatcher,
 		method:  []string{http.MethodPost},
 		handler: serviceLfsBatch,
+		path:    "/info/lfs/objects/batch",
 	},
 	{
 		// Git LFS basic object handler
-		pattern: serviceLfsBasicMatcher,
 		method:  []string{http.MethodGet, http.MethodPut},
 		handler: serviceLfsBasic,
+		path:    "/info/lfs/objects/basic/{oid:[0-9a-f]{64}$}",
 	},
 	{
-		pattern: serviceLfsBasicVerifyMatcher,
 		method:  []string{http.MethodPost},
 		handler: serviceLfsBasicVerify,
+		path:    "/info/lfs/objects/basic/verify",
 	},
 	// Git LFS locks
 	{
-		pattern: regexp.MustCompile(`(.*?)/info/lfs/locks$`),
 		method:  []string{http.MethodPost, http.MethodGet},
 		handler: serviceLfsLocks,
+		path:    "/info/lfs/locks",
 	},
 	{
-		pattern: regexp.MustCompile(`(.*?)/info/lfs/locks/verify$`),
 		method:  []string{http.MethodPost},
 		handler: serviceLfsLocksVerify,
+		path:    "/info/lfs/locks/verify",
 	},
 	{
-		pattern: regexp.MustCompile(`(.*?)/info/lfs/locks/([0-9]+)/unlock$`),
 		method:  []string{http.MethodPost},
 		handler: serviceLfsLocksDelete,
+		path:    "/info/lfs/locks/{lock_id:[0-9]+}/unlock",
 	},
 }
 
@@ -227,7 +207,7 @@ func withAccess(next http.Handler) http.HandlerFunc {
 		// Store repository in context
 		// We're not checking for errors here because we want to allow
 		// repo creation on the fly.
-		repoName := pat.Param(r, "repo")
+		repoName := mux.Vars(r)["repo"]
 		repo, _ := be.Repository(ctx, repoName)
 		ctx = proto.WithRepositoryContext(ctx, repo)
 		r = r.WithContext(ctx)
@@ -244,7 +224,7 @@ func withAccess(next http.Handler) http.HandlerFunc {
 
 		if user == nil && !be.AllowKeyless(ctx) {
 			askCredentials(w, r)
-			renderUnauthorized(w)
+			renderUnauthorized(w, r)
 			return
 		}
 
@@ -256,7 +236,7 @@ func withAccess(next http.Handler) http.HandlerFunc {
 			logger.Info("found user", "username", user.Username())
 		}
 
-		service := git.Service(pat.Param(r, "service"))
+		service := git.Service(mux.Vars(r)["service"])
 		if service == "" {
 			// Get service from request params
 			service = getServiceType(r)
@@ -266,20 +246,17 @@ func withAccess(next http.Handler) http.HandlerFunc {
 		ctx = access.WithContext(ctx, accessLevel)
 		r = r.WithContext(ctx)
 
-		logger.Info("access level", "repo", repoName, "level", accessLevel)
-
-		file := pat.Param(r, "file")
+		file := mux.Vars(r)["file"]
 
 		// We only allow these services to proceed any other services should return 403
 		// - git-upload-pack
 		// - git-receive-pack
 		// - git-lfs
-		switch service {
-		case git.UploadPackService:
-		case git.ReceivePackService:
+		switch {
+		case service == git.ReceivePackService:
 			if accessLevel < access.ReadWriteAccess {
 				askCredentials(w, r)
-				renderUnauthorized(w)
+				renderUnauthorized(w, r)
 				return
 			}
 
@@ -288,17 +265,34 @@ func withAccess(next http.Handler) http.HandlerFunc {
 				repo, err = be.CreateRepository(ctx, repoName, proto.RepositoryOptions{})
 				if err != nil {
 					logger.Error("failed to create repository", "repo", repoName, "err", err)
-					renderInternalServerError(w)
+					renderInternalServerError(w, r)
 					return
 				}
 
 				ctx = proto.WithRepositoryContext(ctx, repo)
 				r = r.WithContext(ctx)
 			}
-		case gitLfsService:
+
+			fallthrough
+		case service == git.UploadPackService:
+			if repo == nil {
+				// If the repo doesn't exist, return 404
+				renderNotFound(w, r)
+				return
+			} else if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) {
+				// return 403 when bad credentials are provided
+				renderForbidden(w, r)
+				return
+			} else if accessLevel < access.ReadOnlyAccess {
+				askCredentials(w, r)
+				renderUnauthorized(w, r)
+				return
+			}
+
+		case strings.HasPrefix(file, "info/lfs"):
 			if !cfg.LFS.Enabled {
 				logger.Debug("LFS is not enabled, skipping")
-				renderNotFound(w)
+				renderNotFound(w, r)
 				return
 			}
 
@@ -334,6 +328,7 @@ func withAccess(next http.Handler) http.HandlerFunc {
 					// Basic verify
 				}
 			}
+
 			if accessLevel < access.ReadOnlyAccess {
 				if repo == nil {
 					renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
@@ -351,22 +346,19 @@ func withAccess(next http.Handler) http.HandlerFunc {
 				}
 				return
 			}
-		default:
-			renderForbidden(w)
-			return
 		}
 
-		if repo == nil {
-			// If the repo doesn't exist, return 404
-			renderNotFound(w)
-			return
-		} else if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) {
+		switch {
+		case r.URL.Query().Get("go-get") == "1" && accessLevel >= access.ReadOnlyAccess:
+			// Allow go-get requests to passthrough.
+			break
+		case errors.Is(err, ErrInvalidToken), errors.Is(err, ErrInvalidPassword):
 			// return 403 when bad credentials are provided
-			renderForbidden(w)
+			renderForbidden(w, r)
 			return
-		} else if accessLevel < access.ReadOnlyAccess {
-			askCredentials(w, r)
-			renderUnauthorized(w)
+		case repo == nil, accessLevel < access.ReadOnlyAccess:
+			// Don't hint that the repo exists if the user doesn't have access
+			renderNotFound(w, r)
 			return
 		}
 
@@ -379,10 +371,10 @@ func serviceRpc(w http.ResponseWriter, r *http.Request) {
 	ctx := r.Context()
 	cfg := config.FromContext(ctx)
 	logger := log.FromContext(ctx)
-	service, dir, repoName := git.Service(pat.Param(r, "service")), pat.Param(r, "dir"), pat.Param(r, "repo")
+	service, dir, repoName := git.Service(mux.Vars(r)["service"]), mux.Vars(r)["dir"], mux.Vars(r)["repo"]
 
 	if !isSmart(r, service) {
-		renderForbidden(w)
+		renderForbidden(w, r)
 		return
 	}
 
@@ -431,7 +423,7 @@ func serviceRpc(w http.ResponseWriter, r *http.Request) {
 		reader, err := gzip.NewReader(reader)
 		if err != nil {
 			logger.Errorf("failed to create gzip reader: %v", err)
-			renderInternalServerError(w)
+			renderInternalServerError(w, r)
 			return
 		}
 		defer reader.Close() // nolint: errcheck
@@ -441,10 +433,10 @@ func serviceRpc(w http.ResponseWriter, r *http.Request) {
 
 	if err := service.Handler(ctx, cmd); err != nil {
 		if errors.Is(err, git.ErrInvalidRepo) {
-			renderNotFound(w)
+			renderNotFound(w, r)
 			return
 		}
-		renderInternalServerError(w)
+		renderInternalServerError(w, r)
 		return
 	}
 
@@ -486,7 +478,7 @@ func serviceRpc(w http.ResponseWriter, r *http.Request) {
 func getInfoRefs(w http.ResponseWriter, r *http.Request) {
 	ctx := r.Context()
 	cfg := config.FromContext(ctx)
-	dir, repoName, file := pat.Param(r, "dir"), pat.Param(r, "repo"), pat.Param(r, "file")
+	dir, repoName, file := mux.Vars(r)["dir"], mux.Vars(r)["repo"], mux.Vars(r)["file"]
 	service := getServiceType(r)
 	version := r.Header.Get("Git-Protocol")
 
@@ -518,7 +510,7 @@ func getInfoRefs(w http.ResponseWriter, r *http.Request) {
 		}
 
 		if err := service.Handler(ctx, cmd); err != nil {
-			renderNotFound(w)
+			renderNotFound(w, r)
 			return
 		}
 
@@ -564,12 +556,12 @@ func getTextFile(w http.ResponseWriter, r *http.Request) {
 }
 
 func sendFile(contentType string, w http.ResponseWriter, r *http.Request) {
-	dir, file := pat.Param(r, "dir"), pat.Param(r, "file")
+	dir, file := mux.Vars(r)["dir"], mux.Vars(r)["file"]
 	reqFile := filepath.Join(dir, file)
 
 	f, err := os.Stat(reqFile)
 	if os.IsNotExist(err) {
-		renderNotFound(w)
+		renderNotFound(w, r)
 		return
 	}
 
@@ -599,32 +591,32 @@ func updateServerInfo(ctx context.Context, dir string) error {
 
 // HTTP error response handling functions
 
-func renderBadRequest(w http.ResponseWriter) {
-	renderStatus(http.StatusBadRequest)(w, nil)
+func renderBadRequest(w http.ResponseWriter, r *http.Request) {
+	renderStatus(http.StatusBadRequest)(w, r)
 }
 
 func renderMethodNotAllowed(w http.ResponseWriter, r *http.Request) {
 	if r.Proto == "HTTP/1.1" {
 		renderStatus(http.StatusMethodNotAllowed)(w, r)
 	} else {
-		renderBadRequest(w)
+		renderBadRequest(w, r)
 	}
 }
 
-func renderNotFound(w http.ResponseWriter) {
-	renderStatus(http.StatusNotFound)(w, nil)
+func renderNotFound(w http.ResponseWriter, r *http.Request) {
+	renderStatus(http.StatusNotFound)(w, r)
 }
 
-func renderUnauthorized(w http.ResponseWriter) {
-	renderStatus(http.StatusUnauthorized)(w, nil)
+func renderUnauthorized(w http.ResponseWriter, r *http.Request) {
+	renderStatus(http.StatusUnauthorized)(w, r)
 }
 
-func renderForbidden(w http.ResponseWriter) {
-	renderStatus(http.StatusForbidden)(w, nil)
+func renderForbidden(w http.ResponseWriter, r *http.Request) {
+	renderStatus(http.StatusForbidden)(w, r)
 }
 
-func renderInternalServerError(w http.ResponseWriter) {
-	renderStatus(http.StatusInternalServerError)(w, nil)
+func renderInternalServerError(w http.ResponseWriter, r *http.Request) {
+	renderStatus(http.StatusInternalServerError)(w, r)
 }
 
 // Header writing functions

server/web/git_lfs.go 🔗

@@ -19,17 +19,13 @@ import (
 	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/charmbracelet/soft-serve/server/db"
 	"github.com/charmbracelet/soft-serve/server/db/models"
-	"github.com/charmbracelet/soft-serve/server/git"
 	"github.com/charmbracelet/soft-serve/server/lfs"
 	"github.com/charmbracelet/soft-serve/server/proto"
 	"github.com/charmbracelet/soft-serve/server/storage"
 	"github.com/charmbracelet/soft-serve/server/store"
-	"goji.io/pat"
+	"github.com/gorilla/mux"
 )
 
-// Place holder service to handle Git LFS requests.
-const gitLfsService git.Service = "git-lfs-service"
-
 // serviceLfsBatch handles a Git LFS batch requests.
 // https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md
 // TODO: support refname
@@ -80,7 +76,7 @@ func serviceLfsBatch(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	name := pat.Param(r, "repo")
+	name := mux.Vars(r)["repo"]
 	repo := proto.RepositoryFromContext(ctx)
 	if repo == nil {
 		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
@@ -184,8 +180,8 @@ func serviceLfsBatch(w http.ResponseWriter, r *http.Request) {
 		accessLevel := access.FromContext(ctx)
 		if accessLevel < access.ReadWriteAccess {
 			askCredentials(w, r)
-			renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{
-				Message: "credentials needed",
+			renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
+				Message: "write access required",
 			})
 			return
 		}
@@ -255,7 +251,7 @@ func serviceLfsBasic(w http.ResponseWriter, r *http.Request) {
 // GET: /<repo>.git/info/lfs/objects/basic/<oid>
 func serviceLfsBasicDownload(w http.ResponseWriter, r *http.Request) {
 	ctx := r.Context()
-	oid := pat.Param(r, "oid")
+	oid := mux.Vars(r)["oid"]
 	repo := proto.RepositoryFromContext(ctx)
 	cfg := config.FromContext(ctx)
 	logger := log.FromContext(ctx).WithPrefix("http.lfs-basic")
@@ -304,14 +300,14 @@ func serviceLfsBasicUpload(w http.ResponseWriter, r *http.Request) {
 	}
 
 	ctx := r.Context()
-	oid := pat.Param(r, "oid")
+	oid := mux.Vars(r)["oid"]
 	cfg := config.FromContext(ctx)
 	be := backend.FromContext(ctx)
 	dbx := db.FromContext(ctx)
 	datastore := store.FromContext(ctx)
 	logger := log.FromContext(ctx).WithPrefix("http.lfs-basic")
 	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
-	name := pat.Param(r, "repo")
+	name := mux.Vars(r)["repo"]
 
 	defer r.Body.Close() // nolint: errcheck
 	repo, err := be.Repository(ctx, name)
@@ -836,7 +832,7 @@ func serviceLfsLocksDelete(w http.ResponseWriter, r *http.Request) {
 
 	ctx := r.Context()
 	logger := log.FromContext(ctx).WithPrefix("http.lfs-locks")
-	lockIDStr := pat.Param(r, "lock_id")
+	lockIDStr := mux.Vars(r)["lock_id"]
 	if lockIDStr == "" {
 		logger.Error("error getting lock id")
 		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{

server/web/goget.go 🔗

@@ -6,12 +6,13 @@ import (
 	"path"
 	"text/template"
 
+	"github.com/charmbracelet/log"
 	"github.com/charmbracelet/soft-serve/server/backend"
 	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/charmbracelet/soft-serve/server/utils"
+	"github.com/gorilla/mux"
 	"github.com/prometheus/client_golang/prometheus"
 	"github.com/prometheus/client_golang/prometheus/promauto"
-	"goji.io/pattern"
 )
 
 var goGetCounter = promauto.NewCounterVec(prometheus.CounterOpts{
@@ -26,7 +27,7 @@ var repoIndexHTMLTpl = template.Must(template.New("index").Parse(`<!DOCTYPE html
 <head>
     <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
     <meta http-equiv="refresh" content="0; url=https://godoc.org/{{ .ImportRoot }}/{{.Repo}}">
-    <meta name="go-import" content="{{ .ImportRoot }}/{{ .Repo }} git {{ .Config.HTTP.PublicURL }}/{{ .Repo }}">
+    <meta name="go-import" content="{{ .ImportRoot }}/{{ .Repo }} git {{ .Config.HTTP.PublicURL }}/{{ .Repo }}.git">
 </head>
 <body>
 Redirecting to docs at <a href="https://godoc.org/{{ .ImportRoot }}/{{ .Repo }}">godoc.org/{{ .ImportRoot }}/{{ .Repo }}</a>...
@@ -40,15 +41,17 @@ type GoGetHandler struct{}
 var _ http.Handler = (*GoGetHandler)(nil)
 
 func (g GoGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	repo := pattern.Path(r.Context())
-	repo = utils.SanitizeRepo(repo)
 	ctx := r.Context()
 	cfg := config.FromContext(ctx)
 	be := backend.FromContext(ctx)
+	logger := log.FromContext(ctx)
+	repo := mux.Vars(r)["repo"]
 
 	// Handle go get requests.
 	//
-	// Always return a 200 status code, even if the repo doesn't exist.
+	// Always return a 200 status code, even if the repo path doesn't exist.
+	// It will try to find the repo by walking up the path until it finds one.
+	// If it can't find one, it will return a 404.
 	//
 	// https://golang.org/cmd/go/#hdr-Remote_import_paths
 	// https://go.dev/ref/mod#vcs-branch
@@ -78,11 +81,12 @@ func (g GoGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			Config     *config.Config
 			ImportRoot string
 		}{
-			Repo:       url.PathEscape(repo),
+			Repo:       utils.SanitizeRepo(repo),
 			Config:     cfg,
 			ImportRoot: importRoot.Host,
 		}); err != nil {
-			http.Error(w, err.Error(), http.StatusInternalServerError)
+			logger.Error("failed to render go get template", "err", err)
+			renderInternalServerError(w, r)
 			return
 		}
 
@@ -90,5 +94,5 @@ func (g GoGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	http.NotFound(w, r)
+	renderNotFound(w, r)
 }

server/web/server.go 🔗

@@ -4,35 +4,25 @@ import (
 	"context"
 	"net/http"
 
-	"goji.io"
-	"goji.io/pat"
+	"github.com/gorilla/handlers"
+	"github.com/gorilla/mux"
 )
 
-// Route is an interface for a route.
-type Route interface {
-	http.Handler
-	goji.Pattern
-}
-
 // NewRouter returns a new HTTP router.
-// TODO: use gorilla/mux and friends
 func NewRouter(ctx context.Context) http.Handler {
-	mux := goji.NewMux()
+	router := mux.NewRouter()
 
 	// Git routes
-	for _, service := range gitRoutes {
-		mux.Handle(service, withAccess(service))
-	}
-
-	// go-get handler
-	mux.Handle(pat.Get("/*"), GoGetHandler{})
+	GitController(ctx, router)
 
-	// Middlewares
-	mux.Use(NewLoggingMiddleware)
+	router.PathPrefix("/").HandlerFunc(renderNotFound)
 
 	// Context handler
 	// Adds context to the request
-	ctxHandler := NewContextHandler(ctx)
+	h := NewContextHandler(ctx)(router)
+	h = handlers.CompressHandler(h)
+	h = handlers.RecoveryHandler()(h)
+	h = NewLoggingMiddleware(h)
 
-	return ctxHandler(mux)
+	return h
 }

server/web/util.go 🔗

@@ -1,10 +1,14 @@
 package web
 
-import "net/http"
+import (
+	"fmt"
+	"io"
+	"net/http"
+)
 
 func renderStatus(code int) http.HandlerFunc {
 	return func(w http.ResponseWriter, _ *http.Request) {
 		w.WriteHeader(code)
-		w.Write([]byte(http.StatusText(code))) // nolint: errcheck
+		io.WriteString(w, fmt.Sprintf("%d %s", code, http.StatusText(code))) // nolint: errcheck
 	}
 }

testscript/testdata/http.txtar 🔗

@@ -35,6 +35,11 @@ git -C repo2 tag v0.1.0
 git -C repo2 push origin HEAD
 git -C repo2 push origin HEAD --tags
 
+# dumb http git
+exec curl -s -XGET http://localhost:$HTTP_PORT/repo2.git/info/refs
+stdout '[0-9a-z]{40}	refs/heads/master\n[0-9a-z]{40}	refs/tags/v0.1.0'
+
+
 # http errors
 exec curl -s -XGET http://localhost:$HTTP_PORT/repo2111foobar.git/foo/bar
 stdout '404.*'
@@ -59,10 +64,23 @@ stdout '.*unsupported operation.*'
 exec curl -s -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{"operation":"download","objects":[{}]}' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
 cmp stdout http1.txt
 exec curl -s -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{"operation":"upload","objects":[{}]}' http://$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
-stdout '.*credentials needed.*'
+stdout '.*write access required.*'
 exec curl -s -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{"operation":"upload","objects":[{}]}' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
 cmp stdout http1.txt
 
+
+# go-get allow (public repo)
+exec curl -s http://localhost:$HTTP_PORT/repo2.git?go-get=1
+cmpenv stdout goget.txt
+exec curl -s http://localhost:$HTTP_PORT/repo2.git/subpackage?go-get=1
+cmpenv stdout goget.txt
+exec curl -s http://localhost:$HTTP_PORT/repo2/subpackage?go-get=1
+cmpenv stdout goget.txt
+
+# go-get not found (invalid method)
+exec curl -s -XPOST http://localhost:$HTTP_PORT/repo2/subpackage?go-get=1
+stdout '404.*'
+
 # set private
 soft repo private repo2 true
 
@@ -80,20 +98,32 @@ stdout '.*credentials needed.*'
 exec curl -s http://0$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
 cmp stdout http3.txt
 
+# deny dumb http git
+exec curl -s -XGET http://localhost:$HTTP_PORT/repo2.git/info/refs
+stdout '404.*'
+
 # deny access ask for credentials
 # this means the server responded with a 401 and prompted for credentials
 # but we disable git terminal prompting to we get a fatal instead of a 401 "Unauthorized"
 ! git clone http://localhost:$HTTP_PORT/repo2 repo2_clone
 cmpenv stderr gitclone.txt
 ! git clone http://someuser:somepassword@localhost:$HTTP_PORT/repo2 repo2_clone
-stderr '.*Forbidden.*'
+stderr '.*403.*'
 
-# go-get endpoints not found
-exec curl -s http://localhost:$HTTP_PORT/repo2.git
+# go-get not found (private repo)
+exec curl -s http://localhost:$HTTP_PORT/repo2.git?go-get=1
 stdout '404.*'
 
-# go-get endpoints
-exec curl -s http://localhost:$HTTP_PORT/repo2.git?go-get=1
+# go-get forbidden (private repo & expired token)
+exec curl -s http://$ETOKEN@localhost:$HTTP_PORT/repo2.git?go-get=1
+stdout '403.*'
+
+# go-get not found (private repo & different user)
+exec curl -s http://$UTOKEN@localhost:$HTTP_PORT/repo2.git?go-get=1
+stdout '404.*'
+
+# go-get with creds
+exec curl -s http://$TOKEN@localhost:$HTTP_PORT/repo2.git?go-get=1
 cmpenv stdout goget.txt
 
 -- http1.txt --
@@ -108,7 +138,7 @@ cmpenv stdout goget.txt
 <head>
     <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
     <meta http-equiv="refresh" content="0; url=https://godoc.org/localhost:$HTTP_PORT/repo2">
-    <meta name="go-import" content="localhost:$HTTP_PORT/repo2 git http://localhost:$HTTP_PORT/repo2">
+    <meta name="go-import" content="localhost:$HTTP_PORT/repo2 git http://localhost:$HTTP_PORT/repo2.git">
 </head>
 <body>
 Redirecting to docs at <a href="https://godoc.org/localhost:$HTTP_PORT/repo2">godoc.org/localhost:$HTTP_PORT/repo2</a>...