diff --git a/go.mod b/go.mod index 67d13c4c6..c0a03a5ca 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 92f81ebc3..6843898e7 100644 --- a/go.sum +++ b/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= diff --git a/server/web/git.go b/server/web/git.go index a2158f434..bf2fbd6e3 100644 --- a/server/web/git.go +++ b/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 diff --git a/server/web/git_lfs.go b/server/web/git_lfs.go index e49ca18ab..b00a2661b 100644 --- a/server/web/git_lfs.go +++ b/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: /.git/info/lfs/objects/basic/ 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{ diff --git a/server/web/goget.go b/server/web/goget.go index 7d9ec1466..3e56f9db8 100644 --- a/server/web/goget.go +++ b/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(` - + Redirecting to docs at godoc.org/{{ .ImportRoot }}/{{ .Repo }}... @@ -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) } diff --git a/server/web/server.go b/server/web/server.go index af344e7a1..73921e68b 100644 --- a/server/web/server.go +++ b/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 } diff --git a/server/web/util.go b/server/web/util.go index 2c68d6991..412d0e00e 100644 --- a/server/web/util.go +++ b/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 } } diff --git a/testscript/testdata/http.txtar b/testscript/testdata/http.txtar index e07105146..defc142d3 100644 --- a/testscript/testdata/http.txtar +++ b/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 - + Redirecting to docs at godoc.org/localhost:$HTTP_PORT/repo2...