From e435b1900a00dd98d65aa1f7668fe41e4df83044 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sat, 28 Dec 2024 11:31:46 +0800 Subject: Refactor arch route handlers (#32993) --- modules/web/route.go | 373 -------------------------------------------- modules/web/route_test.go | 205 ------------------------ modules/web/router.go | 258 ++++++++++++++++++++++++++++++ modules/web/router_combo.go | 41 +++++ modules/web/router_path.go | 135 ++++++++++++++++ modules/web/router_test.go | 245 +++++++++++++++++++++++++++++ routers/api/packages/api.go | 39 +---- 7 files changed, 683 insertions(+), 613 deletions(-) delete mode 100644 modules/web/route.go delete mode 100644 modules/web/route_test.go create mode 100644 modules/web/router.go create mode 100644 modules/web/router_combo.go create mode 100644 modules/web/router_path.go create mode 100644 modules/web/router_test.go diff --git a/modules/web/route.go b/modules/web/route.go deleted file mode 100644 index 729ac46cdc..0000000000 --- a/modules/web/route.go +++ /dev/null @@ -1,373 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package web - -import ( - "fmt" - "net/http" - "net/url" - "reflect" - "regexp" - "strings" - - "code.gitea.io/gitea/modules/container" - "code.gitea.io/gitea/modules/htmlutil" - "code.gitea.io/gitea/modules/reqctx" - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" - "code.gitea.io/gitea/modules/web/middleware" - - "gitea.com/go-chi/binding" - "github.com/go-chi/chi/v5" -) - -// Bind binding an obj to a handler's context data -func Bind[T any](_ T) http.HandlerFunc { - return func(resp http.ResponseWriter, req *http.Request) { - theObj := new(T) // create a new form obj for every request but not use obj directly - data := middleware.GetContextData(req.Context()) - binding.Bind(req, theObj) - SetForm(data, theObj) - middleware.AssignForm(theObj, data) - } -} - -// SetForm set the form object -func SetForm(dataStore reqctx.ContextDataProvider, obj any) { - dataStore.GetData()["__form"] = obj -} - -// GetForm returns the validate form information -func GetForm(dataStore reqctx.RequestDataStore) any { - return dataStore.GetData()["__form"] -} - -// Router defines a route based on chi's router -type Router struct { - chiRouter chi.Router - curGroupPrefix string - curMiddlewares []any -} - -// NewRouter creates a new route -func NewRouter() *Router { - r := chi.NewRouter() - return &Router{chiRouter: r} -} - -// Use supports two middlewares -func (r *Router) Use(middlewares ...any) { - for _, m := range middlewares { - if m != nil { - r.chiRouter.Use(toHandlerProvider(m)) - } - } -} - -// Group mounts a sub-Router along a `pattern` string. -func (r *Router) Group(pattern string, fn func(), middlewares ...any) { - previousGroupPrefix := r.curGroupPrefix - previousMiddlewares := r.curMiddlewares - r.curGroupPrefix += pattern - r.curMiddlewares = append(r.curMiddlewares, middlewares...) - - fn() - - r.curGroupPrefix = previousGroupPrefix - r.curMiddlewares = previousMiddlewares -} - -func (r *Router) getPattern(pattern string) string { - newPattern := r.curGroupPrefix + pattern - if !strings.HasPrefix(newPattern, "/") { - newPattern = "/" + newPattern - } - if newPattern == "/" { - return newPattern - } - return strings.TrimSuffix(newPattern, "/") -} - -func isNilOrFuncNil(v any) bool { - if v == nil { - return true - } - r := reflect.ValueOf(v) - return r.Kind() == reflect.Func && r.IsNil() -} - -func (r *Router) wrapMiddlewareAndHandler(h []any) ([]func(http.Handler) http.Handler, http.HandlerFunc) { - handlerProviders := make([]func(http.Handler) http.Handler, 0, len(r.curMiddlewares)+len(h)+1) - for _, m := range r.curMiddlewares { - if !isNilOrFuncNil(m) { - handlerProviders = append(handlerProviders, toHandlerProvider(m)) - } - } - for _, m := range h { - if !isNilOrFuncNil(m) { - handlerProviders = append(handlerProviders, toHandlerProvider(m)) - } - } - middlewares := handlerProviders[:len(handlerProviders)-1] - handlerFunc := handlerProviders[len(handlerProviders)-1](nil).ServeHTTP - mockPoint := RouterMockPoint(MockAfterMiddlewares) - if mockPoint != nil { - middlewares = append(middlewares, mockPoint) - } - return middlewares, handlerFunc -} - -// Methods adds the same handlers for multiple http "methods" (separated by ","). -// If any method is invalid, the lower level router will panic. -func (r *Router) Methods(methods, pattern string, h ...any) { - middlewares, handlerFunc := r.wrapMiddlewareAndHandler(h) - fullPattern := r.getPattern(pattern) - if strings.Contains(methods, ",") { - methods := strings.Split(methods, ",") - for _, method := range methods { - r.chiRouter.With(middlewares...).Method(strings.TrimSpace(method), fullPattern, handlerFunc) - } - } else { - r.chiRouter.With(middlewares...).Method(methods, fullPattern, handlerFunc) - } -} - -// Mount attaches another Router along ./pattern/* -func (r *Router) Mount(pattern string, subRouter *Router) { - subRouter.Use(r.curMiddlewares...) - r.chiRouter.Mount(r.getPattern(pattern), subRouter.chiRouter) -} - -// Any delegate requests for all methods -func (r *Router) Any(pattern string, h ...any) { - middlewares, handlerFunc := r.wrapMiddlewareAndHandler(h) - r.chiRouter.With(middlewares...).HandleFunc(r.getPattern(pattern), handlerFunc) -} - -// Delete delegate delete method -func (r *Router) Delete(pattern string, h ...any) { - r.Methods("DELETE", pattern, h...) -} - -// Get delegate get method -func (r *Router) Get(pattern string, h ...any) { - r.Methods("GET", pattern, h...) -} - -// Head delegate head method -func (r *Router) Head(pattern string, h ...any) { - r.Methods("HEAD", pattern, h...) -} - -// Post delegate post method -func (r *Router) Post(pattern string, h ...any) { - r.Methods("POST", pattern, h...) -} - -// Put delegate put method -func (r *Router) Put(pattern string, h ...any) { - r.Methods("PUT", pattern, h...) -} - -// Patch delegate patch method -func (r *Router) Patch(pattern string, h ...any) { - r.Methods("PATCH", pattern, h...) -} - -// ServeHTTP implements http.Handler -func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { - r.normalizeRequestPath(w, req, r.chiRouter) -} - -// NotFound defines a handler to respond whenever a route could not be found. -func (r *Router) NotFound(h http.HandlerFunc) { - r.chiRouter.NotFound(h) -} - -type pathProcessorParam struct { - name string - captureGroup int -} - -type PathProcessor struct { - methods container.Set[string] - re *regexp.Regexp - params []pathProcessorParam -} - -func (r *Router) normalizeRequestPath(resp http.ResponseWriter, req *http.Request, next http.Handler) { - normalized := false - normalizedPath := req.URL.EscapedPath() - if normalizedPath == "" { - normalizedPath, normalized = "/", true - } else if normalizedPath != "/" { - normalized = strings.HasSuffix(normalizedPath, "/") - normalizedPath = strings.TrimRight(normalizedPath, "/") - } - removeRepeatedSlashes := strings.Contains(normalizedPath, "//") - normalized = normalized || removeRepeatedSlashes - - // the following code block is a slow-path for replacing all repeated slashes "//" to one single "/" - // if the path doesn't have repeated slashes, then no need to execute it - if removeRepeatedSlashes { - buf := &strings.Builder{} - for i := 0; i < len(normalizedPath); i++ { - if i == 0 || normalizedPath[i-1] != '/' || normalizedPath[i] != '/' { - buf.WriteByte(normalizedPath[i]) - } - } - normalizedPath = buf.String() - } - - // If the config tells Gitea to use a sub-url path directly without reverse proxy, - // then we need to remove the sub-url path from the request URL path. - // But "/v2" is special for OCI container registry, it should always be in the root of the site. - if setting.UseSubURLPath { - remainingPath, ok := strings.CutPrefix(normalizedPath, setting.AppSubURL+"/") - if ok { - normalizedPath = "/" + remainingPath - } else if normalizedPath == setting.AppSubURL { - normalizedPath = "/" - } else if !strings.HasPrefix(normalizedPath+"/", "/v2/") { - // do not respond to other requests, to simulate a real sub-path environment - resp.Header().Add("Content-Type", "text/html; charset=utf-8") - resp.WriteHeader(http.StatusNotFound) - _, _ = resp.Write([]byte(htmlutil.HTMLFormat(`404 page not found, sub-path is: %s`, setting.AppSubURL, setting.AppSubURL))) - return - } - normalized = true - } - - // if the path is normalized, then fill it back to the request - if normalized { - decodedPath, err := url.PathUnescape(normalizedPath) - if err != nil { - http.Error(resp, "400 Bad Request: unable to unescape path "+normalizedPath, http.StatusBadRequest) - return - } - req.URL.RawPath = normalizedPath - req.URL.Path = decodedPath - } - - next.ServeHTTP(resp, req) -} - -func (p *PathProcessor) ProcessRequestPath(chiCtx *chi.Context, path string) bool { - if !p.methods.Contains(chiCtx.RouteMethod) { - return false - } - if !strings.HasPrefix(path, "/") { - path = "/" + path - } - pathMatches := p.re.FindStringSubmatchIndex(path) // Golang regexp match pairs [start, end, start, end, ...] - if pathMatches == nil { - return false - } - var paramMatches [][]int - for i := 2; i < len(pathMatches); { - paramMatches = append(paramMatches, []int{pathMatches[i], pathMatches[i+1]}) - pmIdx := len(paramMatches) - 1 - end := pathMatches[i+1] - i += 2 - for ; i < len(pathMatches); i += 2 { - if pathMatches[i] >= end { - break - } - paramMatches[pmIdx] = append(paramMatches[pmIdx], pathMatches[i], pathMatches[i+1]) - } - } - for i, pm := range paramMatches { - groupIdx := p.params[i].captureGroup * 2 - chiCtx.URLParams.Add(p.params[i].name, path[pm[groupIdx]:pm[groupIdx+1]]) - } - return true -} - -func NewPathProcessor(methods, pattern string) *PathProcessor { - p := &PathProcessor{methods: make(container.Set[string])} - for _, method := range strings.Split(methods, ",") { - p.methods.Add(strings.TrimSpace(method)) - } - re := []byte{'^'} - lastEnd := 0 - for lastEnd < len(pattern) { - start := strings.IndexByte(pattern[lastEnd:], '<') - if start == -1 { - re = append(re, pattern[lastEnd:]...) - break - } - end := strings.IndexByte(pattern[lastEnd+start:], '>') - if end == -1 { - panic(fmt.Sprintf("invalid pattern: %s", pattern)) - } - re = append(re, pattern[lastEnd:lastEnd+start]...) - partName, partExp, _ := strings.Cut(pattern[lastEnd+start+1:lastEnd+start+end], ":") - lastEnd += start + end + 1 - - // TODO: it could support to specify a "capture group" for the name, for example: "/" - // it is not used so no need to implement it now - param := pathProcessorParam{} - if partExp == "*" { - re = append(re, "(.*?)/?"...) - if lastEnd < len(pattern) { - if pattern[lastEnd] == '/' { - lastEnd++ - } - } - } else { - partExp = util.IfZero(partExp, "[^/]+") - re = append(re, '(') - re = append(re, partExp...) - re = append(re, ')') - } - param.name = partName - p.params = append(p.params, param) - } - re = append(re, '$') - reStr := string(re) - p.re = regexp.MustCompile(reStr) - return p -} - -// Combo delegates requests to Combo -func (r *Router) Combo(pattern string, h ...any) *Combo { - return &Combo{r, pattern, h} -} - -// Combo represents a tiny group routes with same pattern -type Combo struct { - r *Router - pattern string - h []any -} - -// Get delegates Get method -func (c *Combo) Get(h ...any) *Combo { - c.r.Get(c.pattern, append(c.h, h...)...) - return c -} - -// Post delegates Post method -func (c *Combo) Post(h ...any) *Combo { - c.r.Post(c.pattern, append(c.h, h...)...) - return c -} - -// Delete delegates Delete method -func (c *Combo) Delete(h ...any) *Combo { - c.r.Delete(c.pattern, append(c.h, h...)...) - return c -} - -// Put delegates Put method -func (c *Combo) Put(h ...any) *Combo { - c.r.Put(c.pattern, append(c.h, h...)...) - return c -} - -// Patch delegates Patch method -func (c *Combo) Patch(h ...any) *Combo { - c.r.Patch(c.pattern, append(c.h, h...)...) - return c -} diff --git a/modules/web/route_test.go b/modules/web/route_test.go deleted file mode 100644 index ca3134b546..0000000000 --- a/modules/web/route_test.go +++ /dev/null @@ -1,205 +0,0 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package web - -import ( - "bytes" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/test" - "code.gitea.io/gitea/modules/util" - - "github.com/go-chi/chi/v5" - "github.com/stretchr/testify/assert" -) - -func chiURLParamsToMap(chiCtx *chi.Context) map[string]string { - pathParams := chiCtx.URLParams - m := make(map[string]string, len(pathParams.Keys)) - for i, key := range pathParams.Keys { - if key == "*" && pathParams.Values[i] == "" { - continue // chi router will add an empty "*" key if there is a "Mount" - } - m[key] = pathParams.Values[i] - } - return m -} - -func TestPathProcessor(t *testing.T) { - testProcess := func(pattern, uri string, expectedPathParams map[string]string) { - chiCtx := chi.NewRouteContext() - chiCtx.RouteMethod = "GET" - p := NewPathProcessor("GET", pattern) - assert.True(t, p.ProcessRequestPath(chiCtx, uri), "use pattern %s to process uri %s", pattern, uri) - assert.Equal(t, expectedPathParams, chiURLParamsToMap(chiCtx), "use pattern %s to process uri %s", pattern, uri) - } - testProcess("//", "/a/b", map[string]string{"p1": "a", "p2": "b"}) - testProcess("/", "", map[string]string{"p1": ""}) // this is a special case, because chi router could use empty path - testProcess("/", "/", map[string]string{"p1": ""}) - testProcess("//", "/a", map[string]string{"p1": "", "p2": "a"}) - testProcess("//", "/a/b", map[string]string{"p1": "a", "p2": "b"}) - testProcess("//", "/a/b/c", map[string]string{"p1": "a/b", "p2": "c"}) -} - -func TestRouter(t *testing.T) { - buff := bytes.NewBufferString("") - recorder := httptest.NewRecorder() - recorder.Body = buff - - type resultStruct struct { - method string - pathParams map[string]string - handlerMark string - } - var res resultStruct - - h := func(optMark ...string) func(resp http.ResponseWriter, req *http.Request) { - mark := util.OptionalArg(optMark, "") - return func(resp http.ResponseWriter, req *http.Request) { - res.method = req.Method - res.pathParams = chiURLParamsToMap(chi.RouteContext(req.Context())) - res.handlerMark = mark - } - } - - r := NewRouter() - r.Get("/{username}/{reponame}/{type:issues|pulls}", h("list-issues-a")) // this one will never be called - r.Group("/{username}/{reponame}", func() { - r.Get("/{type:issues|pulls}", h("list-issues-b")) - r.Group("", func() { - r.Get("/{type:issues|pulls}/{index}", h("view-issue")) - }, func(resp http.ResponseWriter, req *http.Request) { - if stop := req.FormValue("stop"); stop != "" { - h(stop)(resp, req) - resp.WriteHeader(http.StatusOK) - } - }) - r.Group("/issues/{index}", func() { - r.Post("/update", h("update-issue")) - }) - }) - - m := NewRouter() - r.Mount("/api/v1", m) - m.Group("/repos", func() { - m.Group("/{username}/{reponame}", func() { - m.Group("/branches", func() { - m.Get("", h()) - m.Post("", h()) - m.Group("/{name}", func() { - m.Get("", h()) - m.Patch("", h()) - m.Delete("", h()) - }) - }) - }) - }) - - testRoute := func(methodPath string, expected resultStruct) { - t.Run(methodPath, func(t *testing.T) { - res = resultStruct{} - methodPathFields := strings.Fields(methodPath) - req, err := http.NewRequest(methodPathFields[0], methodPathFields[1], nil) - assert.NoError(t, err) - r.ServeHTTP(recorder, req) - assert.EqualValues(t, expected, res) - }) - } - - t.Run("Root Router", func(t *testing.T) { - testRoute("GET /the-user/the-repo/other", resultStruct{}) - testRoute("GET /the-user/the-repo/pulls", resultStruct{ - method: "GET", - pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "pulls"}, - handlerMark: "list-issues-b", - }) - testRoute("GET /the-user/the-repo/issues/123", resultStruct{ - method: "GET", - pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "issues", "index": "123"}, - handlerMark: "view-issue", - }) - testRoute("GET /the-user/the-repo/issues/123?stop=hijack", resultStruct{ - method: "GET", - pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "issues", "index": "123"}, - handlerMark: "hijack", - }) - testRoute("POST /the-user/the-repo/issues/123/update", resultStruct{ - method: "POST", - pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "index": "123"}, - handlerMark: "update-issue", - }) - }) - - t.Run("Sub Router", func(t *testing.T) { - testRoute("GET /api/v1/repos/the-user/the-repo/branches", resultStruct{ - method: "GET", - pathParams: map[string]string{"username": "the-user", "reponame": "the-repo"}, - }) - - testRoute("POST /api/v1/repos/the-user/the-repo/branches", resultStruct{ - method: "POST", - pathParams: map[string]string{"username": "the-user", "reponame": "the-repo"}, - }) - - testRoute("GET /api/v1/repos/the-user/the-repo/branches/master", resultStruct{ - method: "GET", - pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "name": "master"}, - }) - - testRoute("PATCH /api/v1/repos/the-user/the-repo/branches/master", resultStruct{ - method: "PATCH", - pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "name": "master"}, - }) - - testRoute("DELETE /api/v1/repos/the-user/the-repo/branches/master", resultStruct{ - method: "DELETE", - pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "name": "master"}, - }) - }) -} - -func TestRouteNormalizePath(t *testing.T) { - type paths struct { - EscapedPath, RawPath, Path string - } - testPath := func(reqPath string, expectedPaths paths) { - recorder := httptest.NewRecorder() - recorder.Body = bytes.NewBuffer(nil) - - actualPaths := paths{EscapedPath: "(none)", RawPath: "(none)", Path: "(none)"} - r := NewRouter() - r.Get("/*", func(resp http.ResponseWriter, req *http.Request) { - actualPaths.EscapedPath = req.URL.EscapedPath() - actualPaths.RawPath = req.URL.RawPath - actualPaths.Path = req.URL.Path - }) - - req, err := http.NewRequest("GET", reqPath, nil) - assert.NoError(t, err) - r.ServeHTTP(recorder, req) - assert.Equal(t, expectedPaths, actualPaths, "req path = %q", reqPath) - } - - // RawPath could be empty if the EscapedPath is the same as escape(Path) and it is already normalized - testPath("/", paths{EscapedPath: "/", RawPath: "", Path: "/"}) - testPath("//", paths{EscapedPath: "/", RawPath: "/", Path: "/"}) - testPath("/%2f", paths{EscapedPath: "/%2f", RawPath: "/%2f", Path: "//"}) - testPath("///a//b/", paths{EscapedPath: "/a/b", RawPath: "/a/b", Path: "/a/b"}) - - defer test.MockVariableValue(&setting.UseSubURLPath, true)() - defer test.MockVariableValue(&setting.AppSubURL, "/sub-path")() - testPath("/", paths{EscapedPath: "(none)", RawPath: "(none)", Path: "(none)"}) // 404 - testPath("/sub-path", paths{EscapedPath: "/", RawPath: "/", Path: "/"}) - testPath("/sub-path/", paths{EscapedPath: "/", RawPath: "/", Path: "/"}) - testPath("/sub-path//a/b///", paths{EscapedPath: "/a/b", RawPath: "/a/b", Path: "/a/b"}) - testPath("/sub-path/%2f/", paths{EscapedPath: "/%2f", RawPath: "/%2f", Path: "//"}) - // "/v2" is special for OCI container registry, it should always be in the root of the site - testPath("/v2", paths{EscapedPath: "/v2", RawPath: "/v2", Path: "/v2"}) - testPath("/v2/", paths{EscapedPath: "/v2", RawPath: "/v2", Path: "/v2"}) - testPath("/v2/%2f", paths{EscapedPath: "/v2/%2f", RawPath: "/v2/%2f", Path: "/v2//"}) -} diff --git a/modules/web/router.go b/modules/web/router.go new file mode 100644 index 0000000000..da06b955b1 --- /dev/null +++ b/modules/web/router.go @@ -0,0 +1,258 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package web + +import ( + "net/http" + "net/url" + "reflect" + "strings" + + "code.gitea.io/gitea/modules/htmlutil" + "code.gitea.io/gitea/modules/reqctx" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web/middleware" + + "gitea.com/go-chi/binding" + "github.com/go-chi/chi/v5" +) + +// Bind binding an obj to a handler's context data +func Bind[T any](_ T) http.HandlerFunc { + return func(resp http.ResponseWriter, req *http.Request) { + theObj := new(T) // create a new form obj for every request but not use obj directly + data := middleware.GetContextData(req.Context()) + binding.Bind(req, theObj) + SetForm(data, theObj) + middleware.AssignForm(theObj, data) + } +} + +// SetForm set the form object +func SetForm(dataStore reqctx.ContextDataProvider, obj any) { + dataStore.GetData()["__form"] = obj +} + +// GetForm returns the validate form information +func GetForm(dataStore reqctx.RequestDataStore) any { + return dataStore.GetData()["__form"] +} + +// Router defines a route based on chi's router +type Router struct { + chiRouter *chi.Mux + curGroupPrefix string + curMiddlewares []any +} + +// NewRouter creates a new route +func NewRouter() *Router { + r := chi.NewRouter() + return &Router{chiRouter: r} +} + +// Use supports two middlewares +func (r *Router) Use(middlewares ...any) { + for _, m := range middlewares { + if m != nil { + r.chiRouter.Use(toHandlerProvider(m)) + } + } +} + +// Group mounts a sub-Router along a `pattern` string. +func (r *Router) Group(pattern string, fn func(), middlewares ...any) { + previousGroupPrefix := r.curGroupPrefix + previousMiddlewares := r.curMiddlewares + r.curGroupPrefix += pattern + r.curMiddlewares = append(r.curMiddlewares, middlewares...) + + fn() + + r.curGroupPrefix = previousGroupPrefix + r.curMiddlewares = previousMiddlewares +} + +func (r *Router) getPattern(pattern string) string { + newPattern := r.curGroupPrefix + pattern + if !strings.HasPrefix(newPattern, "/") { + newPattern = "/" + newPattern + } + if newPattern == "/" { + return newPattern + } + return strings.TrimSuffix(newPattern, "/") +} + +func isNilOrFuncNil(v any) bool { + if v == nil { + return true + } + r := reflect.ValueOf(v) + return r.Kind() == reflect.Func && r.IsNil() +} + +func wrapMiddlewareAndHandler(curMiddlewares, h []any) ([]func(http.Handler) http.Handler, http.HandlerFunc) { + handlerProviders := make([]func(http.Handler) http.Handler, 0, len(curMiddlewares)+len(h)+1) + for _, m := range curMiddlewares { + if !isNilOrFuncNil(m) { + handlerProviders = append(handlerProviders, toHandlerProvider(m)) + } + } + if len(h) == 0 { + panic("no endpoint handler provided") + } + for i, m := range h { + if !isNilOrFuncNil(m) { + handlerProviders = append(handlerProviders, toHandlerProvider(m)) + } else if i == len(h)-1 { + panic("endpoint handler can't be nil") + } + } + middlewares := handlerProviders[:len(handlerProviders)-1] + handlerFunc := handlerProviders[len(handlerProviders)-1](nil).ServeHTTP + mockPoint := RouterMockPoint(MockAfterMiddlewares) + if mockPoint != nil { + middlewares = append(middlewares, mockPoint) + } + return middlewares, handlerFunc +} + +// Methods adds the same handlers for multiple http "methods" (separated by ","). +// If any method is invalid, the lower level router will panic. +func (r *Router) Methods(methods, pattern string, h ...any) { + middlewares, handlerFunc := wrapMiddlewareAndHandler(r.curMiddlewares, h) + fullPattern := r.getPattern(pattern) + if strings.Contains(methods, ",") { + methods := strings.Split(methods, ",") + for _, method := range methods { + r.chiRouter.With(middlewares...).Method(strings.TrimSpace(method), fullPattern, handlerFunc) + } + } else { + r.chiRouter.With(middlewares...).Method(methods, fullPattern, handlerFunc) + } +} + +// Mount attaches another Router along ./pattern/* +func (r *Router) Mount(pattern string, subRouter *Router) { + subRouter.Use(r.curMiddlewares...) + r.chiRouter.Mount(r.getPattern(pattern), subRouter.chiRouter) +} + +// Any delegate requests for all methods +func (r *Router) Any(pattern string, h ...any) { + middlewares, handlerFunc := wrapMiddlewareAndHandler(r.curMiddlewares, h) + r.chiRouter.With(middlewares...).HandleFunc(r.getPattern(pattern), handlerFunc) +} + +// Delete delegate delete method +func (r *Router) Delete(pattern string, h ...any) { + r.Methods("DELETE", pattern, h...) +} + +// Get delegate get method +func (r *Router) Get(pattern string, h ...any) { + r.Methods("GET", pattern, h...) +} + +// Head delegate head method +func (r *Router) Head(pattern string, h ...any) { + r.Methods("HEAD", pattern, h...) +} + +// Post delegate post method +func (r *Router) Post(pattern string, h ...any) { + r.Methods("POST", pattern, h...) +} + +// Put delegate put method +func (r *Router) Put(pattern string, h ...any) { + r.Methods("PUT", pattern, h...) +} + +// Patch delegate patch method +func (r *Router) Patch(pattern string, h ...any) { + r.Methods("PATCH", pattern, h...) +} + +// ServeHTTP implements http.Handler +func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { + r.normalizeRequestPath(w, req, r.chiRouter) +} + +// NotFound defines a handler to respond whenever a route could not be found. +func (r *Router) NotFound(h http.HandlerFunc) { + r.chiRouter.NotFound(h) +} + +func (r *Router) normalizeRequestPath(resp http.ResponseWriter, req *http.Request, next http.Handler) { + normalized := false + normalizedPath := req.URL.EscapedPath() + if normalizedPath == "" { + normalizedPath, normalized = "/", true + } else if normalizedPath != "/" { + normalized = strings.HasSuffix(normalizedPath, "/") + normalizedPath = strings.TrimRight(normalizedPath, "/") + } + removeRepeatedSlashes := strings.Contains(normalizedPath, "//") + normalized = normalized || removeRepeatedSlashes + + // the following code block is a slow-path for replacing all repeated slashes "//" to one single "/" + // if the path doesn't have repeated slashes, then no need to execute it + if removeRepeatedSlashes { + buf := &strings.Builder{} + for i := 0; i < len(normalizedPath); i++ { + if i == 0 || normalizedPath[i-1] != '/' || normalizedPath[i] != '/' { + buf.WriteByte(normalizedPath[i]) + } + } + normalizedPath = buf.String() + } + + // If the config tells Gitea to use a sub-url path directly without reverse proxy, + // then we need to remove the sub-url path from the request URL path. + // But "/v2" is special for OCI container registry, it should always be in the root of the site. + if setting.UseSubURLPath { + remainingPath, ok := strings.CutPrefix(normalizedPath, setting.AppSubURL+"/") + if ok { + normalizedPath = "/" + remainingPath + } else if normalizedPath == setting.AppSubURL { + normalizedPath = "/" + } else if !strings.HasPrefix(normalizedPath+"/", "/v2/") { + // do not respond to other requests, to simulate a real sub-path environment + resp.Header().Add("Content-Type", "text/html; charset=utf-8") + resp.WriteHeader(http.StatusNotFound) + _, _ = resp.Write([]byte(htmlutil.HTMLFormat(`404 page not found, sub-path is: %s`, setting.AppSubURL, setting.AppSubURL))) + return + } + normalized = true + } + + // if the path is normalized, then fill it back to the request + if normalized { + decodedPath, err := url.PathUnescape(normalizedPath) + if err != nil { + http.Error(resp, "400 Bad Request: unable to unescape path "+normalizedPath, http.StatusBadRequest) + return + } + req.URL.RawPath = normalizedPath + req.URL.Path = decodedPath + } + + next.ServeHTTP(resp, req) +} + +// Combo delegates requests to Combo +func (r *Router) Combo(pattern string, h ...any) *Combo { + return &Combo{r, pattern, h} +} + +// PathGroup creates a group of paths which could be matched by regexp. +// It is only designed to resolve some special cases which chi router can't handle. +// For most cases, it shouldn't be used because it needs to iterate all rules to find the matched one (inefficient). +func (r *Router) PathGroup(pattern string, fn func(g *RouterPathGroup), h ...any) { + g := &RouterPathGroup{r: r, pathParam: "*"} + fn(g) + r.Any(pattern, append(h, g.ServeHTTP)...) +} diff --git a/modules/web/router_combo.go b/modules/web/router_combo.go new file mode 100644 index 0000000000..4478689027 --- /dev/null +++ b/modules/web/router_combo.go @@ -0,0 +1,41 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package web + +// Combo represents a tiny group routes with same pattern +type Combo struct { + r *Router + pattern string + h []any +} + +// Get delegates Get method +func (c *Combo) Get(h ...any) *Combo { + c.r.Get(c.pattern, append(c.h, h...)...) + return c +} + +// Post delegates Post method +func (c *Combo) Post(h ...any) *Combo { + c.r.Post(c.pattern, append(c.h, h...)...) + return c +} + +// Delete delegates Delete method +func (c *Combo) Delete(h ...any) *Combo { + c.r.Delete(c.pattern, append(c.h, h...)...) + return c +} + +// Put delegates Put method +func (c *Combo) Put(h ...any) *Combo { + c.r.Put(c.pattern, append(c.h, h...)...) + return c +} + +// Patch delegates Patch method +func (c *Combo) Patch(h ...any) *Combo { + c.r.Patch(c.pattern, append(c.h, h...)...) + return c +} diff --git a/modules/web/router_path.go b/modules/web/router_path.go new file mode 100644 index 0000000000..39082c0724 --- /dev/null +++ b/modules/web/router_path.go @@ -0,0 +1,135 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package web + +import ( + "fmt" + "net/http" + "regexp" + "strings" + + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/util" + + "github.com/go-chi/chi/v5" +) + +type RouterPathGroup struct { + r *Router + pathParam string + matchers []*routerPathMatcher +} + +func (g *RouterPathGroup) ServeHTTP(resp http.ResponseWriter, req *http.Request) { + chiCtx := chi.RouteContext(req.Context()) + path := chiCtx.URLParam(g.pathParam) + for _, m := range g.matchers { + if m.matchPath(chiCtx, path) { + handler := m.handlerFunc + for i := len(m.middlewares) - 1; i >= 0; i-- { + handler = m.middlewares[i](handler).ServeHTTP + } + handler(resp, req) + return + } + } + g.r.chiRouter.NotFoundHandler().ServeHTTP(resp, req) +} + +// MatchPath matches the request method, and uses regexp to match the path. +// The pattern uses "<...>" to define path parameters, for example: "/" (different from chi router) +// It is only designed to resolve some special cases which chi router can't handle. +// For most cases, it shouldn't be used because it needs to iterate all rules to find the matched one (inefficient). +func (g *RouterPathGroup) MatchPath(methods, pattern string, h ...any) { + g.matchers = append(g.matchers, newRouterPathMatcher(methods, pattern, h...)) +} + +type routerPathParam struct { + name string + captureGroup int +} + +type routerPathMatcher struct { + methods container.Set[string] + re *regexp.Regexp + params []routerPathParam + middlewares []func(http.Handler) http.Handler + handlerFunc http.HandlerFunc +} + +func (p *routerPathMatcher) matchPath(chiCtx *chi.Context, path string) bool { + if !p.methods.Contains(chiCtx.RouteMethod) { + return false + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + pathMatches := p.re.FindStringSubmatchIndex(path) // Golang regexp match pairs [start, end, start, end, ...] + if pathMatches == nil { + return false + } + var paramMatches [][]int + for i := 2; i < len(pathMatches); { + paramMatches = append(paramMatches, []int{pathMatches[i], pathMatches[i+1]}) + pmIdx := len(paramMatches) - 1 + end := pathMatches[i+1] + i += 2 + for ; i < len(pathMatches); i += 2 { + if pathMatches[i] >= end { + break + } + paramMatches[pmIdx] = append(paramMatches[pmIdx], pathMatches[i], pathMatches[i+1]) + } + } + for i, pm := range paramMatches { + groupIdx := p.params[i].captureGroup * 2 + chiCtx.URLParams.Add(p.params[i].name, path[pm[groupIdx]:pm[groupIdx+1]]) + } + return true +} + +func newRouterPathMatcher(methods, pattern string, h ...any) *routerPathMatcher { + middlewares, handlerFunc := wrapMiddlewareAndHandler(nil, h) + p := &routerPathMatcher{methods: make(container.Set[string]), middlewares: middlewares, handlerFunc: handlerFunc} + for _, method := range strings.Split(methods, ",") { + p.methods.Add(strings.TrimSpace(method)) + } + re := []byte{'^'} + lastEnd := 0 + for lastEnd < len(pattern) { + start := strings.IndexByte(pattern[lastEnd:], '<') + if start == -1 { + re = append(re, pattern[lastEnd:]...) + break + } + end := strings.IndexByte(pattern[lastEnd+start:], '>') + if end == -1 { + panic(fmt.Sprintf("invalid pattern: %s", pattern)) + } + re = append(re, pattern[lastEnd:lastEnd+start]...) + partName, partExp, _ := strings.Cut(pattern[lastEnd+start+1:lastEnd+start+end], ":") + lastEnd += start + end + 1 + + // TODO: it could support to specify a "capture group" for the name, for example: "/" + // it is not used so no need to implement it now + param := routerPathParam{} + if partExp == "*" { + re = append(re, "(.*?)/?"...) + if lastEnd < len(pattern) && pattern[lastEnd] == '/' { + lastEnd++ // the "*" pattern is able to handle the last slash, so skip it + } + } else { + partExp = util.IfZero(partExp, "[^/]+") + re = append(re, '(') + re = append(re, partExp...) + re = append(re, ')') + } + param.name = partName + p.params = append(p.params, param) + } + re = append(re, '$') + reStr := string(re) + p.re = regexp.MustCompile(reStr) + return p +} diff --git a/modules/web/router_test.go b/modules/web/router_test.go new file mode 100644 index 0000000000..bdcf623b95 --- /dev/null +++ b/modules/web/router_test.go @@ -0,0 +1,245 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package web + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/util" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" +) + +func chiURLParamsToMap(chiCtx *chi.Context) map[string]string { + pathParams := chiCtx.URLParams + m := make(map[string]string, len(pathParams.Keys)) + for i, key := range pathParams.Keys { + if key == "*" && pathParams.Values[i] == "" { + continue // chi router will add an empty "*" key if there is a "Mount" + } + m[key] = pathParams.Values[i] + } + return util.Iif(len(m) == 0, nil, m) +} + +func TestPathProcessor(t *testing.T) { + testProcess := func(pattern, uri string, expectedPathParams map[string]string) { + chiCtx := chi.NewRouteContext() + chiCtx.RouteMethod = "GET" + p := newRouterPathMatcher("GET", pattern, http.NotFound) + assert.True(t, p.matchPath(chiCtx, uri), "use pattern %s to process uri %s", pattern, uri) + assert.Equal(t, expectedPathParams, chiURLParamsToMap(chiCtx), "use pattern %s to process uri %s", pattern, uri) + } + + // the "<...>" is intentionally designed to distinguish from chi's path parameters, because: + // 1. their behaviors are totally different, we do not want to mislead developers + // 2. we can write regexp in "" easily and parse it easily + testProcess("//", "/a/b", map[string]string{"p1": "a", "p2": "b"}) + testProcess("/", "", map[string]string{"p1": ""}) // this is a special case, because chi router could use empty path + testProcess("/", "/", map[string]string{"p1": ""}) + testProcess("//", "/a", map[string]string{"p1": "", "p2": "a"}) + testProcess("//", "/a/b", map[string]string{"p1": "a", "p2": "b"}) + testProcess("//", "/a/b/c", map[string]string{"p1": "a/b", "p2": "c"}) +} + +func TestRouter(t *testing.T) { + buff := bytes.NewBufferString("") + recorder := httptest.NewRecorder() + recorder.Body = buff + + type resultStruct struct { + method string + pathParams map[string]string + handlerMark string + } + var res resultStruct + + h := func(optMark ...string) func(resp http.ResponseWriter, req *http.Request) { + mark := util.OptionalArg(optMark, "") + return func(resp http.ResponseWriter, req *http.Request) { + res.method = req.Method + res.pathParams = chiURLParamsToMap(chi.RouteContext(req.Context())) + res.handlerMark = mark + } + } + + stopMark := func(optMark ...string) func(resp http.ResponseWriter, req *http.Request) { + mark := util.OptionalArg(optMark, "") + return func(resp http.ResponseWriter, req *http.Request) { + if stop := req.FormValue("stop"); stop != "" && (mark == "" || mark == stop) { + h(stop)(resp, req) + resp.WriteHeader(http.StatusOK) + } + } + } + + r := NewRouter() + r.NotFound(h("not-found:/")) + r.Get("/{username}/{reponame}/{type:issues|pulls}", h("list-issues-a")) // this one will never be called + r.Group("/{username}/{reponame}", func() { + r.Get("/{type:issues|pulls}", h("list-issues-b")) + r.Group("", func() { + r.Get("/{type:issues|pulls}/{index}", h("view-issue")) + }, stopMark()) + r.Group("/issues/{index}", func() { + r.Post("/update", h("update-issue")) + }) + }) + + m := NewRouter() + m.NotFound(h("not-found:/api/v1")) + r.Mount("/api/v1", m) + m.Group("/repos", func() { + m.Group("/{username}/{reponame}", func() { + m.Group("/branches", func() { + m.Get("", h()) + m.Post("", h()) + m.Group("/{name}", func() { + m.Get("", h()) + m.Patch("", h()) + m.Delete("", h()) + }) + m.PathGroup("/*", func(g *RouterPathGroup) { + g.MatchPath("GET", `//`, stopMark("s2"), h("match-path")) + }, stopMark("s1")) + }) + }) + }) + + testRoute := func(t *testing.T, methodPath string, expected resultStruct) { + t.Run(methodPath, func(t *testing.T) { + res = resultStruct{} + methodPathFields := strings.Fields(methodPath) + req, err := http.NewRequest(methodPathFields[0], methodPathFields[1], nil) + assert.NoError(t, err) + r.ServeHTTP(recorder, req) + assert.EqualValues(t, expected, res) + }) + } + + t.Run("RootRouter", func(t *testing.T) { + testRoute(t, "GET /the-user/the-repo/other", resultStruct{method: "GET", handlerMark: "not-found:/"}) + testRoute(t, "GET /the-user/the-repo/pulls", resultStruct{ + method: "GET", + pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "pulls"}, + handlerMark: "list-issues-b", + }) + testRoute(t, "GET /the-user/the-repo/issues/123", resultStruct{ + method: "GET", + pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "issues", "index": "123"}, + handlerMark: "view-issue", + }) + testRoute(t, "GET /the-user/the-repo/issues/123?stop=hijack", resultStruct{ + method: "GET", + pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "issues", "index": "123"}, + handlerMark: "hijack", + }) + testRoute(t, "POST /the-user/the-repo/issues/123/update", resultStruct{ + method: "POST", + pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "index": "123"}, + handlerMark: "update-issue", + }) + }) + + t.Run("Sub Router", func(t *testing.T) { + testRoute(t, "GET /api/v1/other", resultStruct{method: "GET", handlerMark: "not-found:/api/v1"}) + testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches", resultStruct{ + method: "GET", + pathParams: map[string]string{"username": "the-user", "reponame": "the-repo"}, + }) + + testRoute(t, "POST /api/v1/repos/the-user/the-repo/branches", resultStruct{ + method: "POST", + pathParams: map[string]string{"username": "the-user", "reponame": "the-repo"}, + }) + + testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/master", resultStruct{ + method: "GET", + pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "name": "master"}, + }) + + testRoute(t, "PATCH /api/v1/repos/the-user/the-repo/branches/master", resultStruct{ + method: "PATCH", + pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "name": "master"}, + }) + + testRoute(t, "DELETE /api/v1/repos/the-user/the-repo/branches/master", resultStruct{ + method: "DELETE", + pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "name": "master"}, + }) + }) + + t.Run("MatchPath", func(t *testing.T) { + testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/fn", resultStruct{ + method: "GET", + pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn", "dir": "d1/d2", "file": "fn"}, + handlerMark: "match-path", + }) + testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/000", resultStruct{ + method: "GET", + pathParams: map[string]string{"reponame": "the-repo", "username": "the-user", "*": "d1/d2/000"}, + handlerMark: "not-found:/api/v1", + }) + + testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/fn?stop=s1", resultStruct{ + method: "GET", + pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn"}, + handlerMark: "s1", + }) + + testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/fn?stop=s2", resultStruct{ + method: "GET", + pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn", "dir": "d1/d2", "file": "fn"}, + handlerMark: "s2", + }) + }) +} + +func TestRouteNormalizePath(t *testing.T) { + type paths struct { + EscapedPath, RawPath, Path string + } + testPath := func(reqPath string, expectedPaths paths) { + recorder := httptest.NewRecorder() + recorder.Body = bytes.NewBuffer(nil) + + actualPaths := paths{EscapedPath: "(none)", RawPath: "(none)", Path: "(none)"} + r := NewRouter() + r.Get("/*", func(resp http.ResponseWriter, req *http.Request) { + actualPaths.EscapedPath = req.URL.EscapedPath() + actualPaths.RawPath = req.URL.RawPath + actualPaths.Path = req.URL.Path + }) + + req, err := http.NewRequest("GET", reqPath, nil) + assert.NoError(t, err) + r.ServeHTTP(recorder, req) + assert.Equal(t, expectedPaths, actualPaths, "req path = %q", reqPath) + } + + // RawPath could be empty if the EscapedPath is the same as escape(Path) and it is already normalized + testPath("/", paths{EscapedPath: "/", RawPath: "", Path: "/"}) + testPath("//", paths{EscapedPath: "/", RawPath: "/", Path: "/"}) + testPath("/%2f", paths{EscapedPath: "/%2f", RawPath: "/%2f", Path: "//"}) + testPath("///a//b/", paths{EscapedPath: "/a/b", RawPath: "/a/b", Path: "/a/b"}) + + defer test.MockVariableValue(&setting.UseSubURLPath, true)() + defer test.MockVariableValue(&setting.AppSubURL, "/sub-path")() + testPath("/", paths{EscapedPath: "(none)", RawPath: "(none)", Path: "(none)"}) // 404 + testPath("/sub-path", paths{EscapedPath: "/", RawPath: "/", Path: "/"}) + testPath("/sub-path/", paths{EscapedPath: "/", RawPath: "/", Path: "/"}) + testPath("/sub-path//a/b///", paths{EscapedPath: "/a/b", RawPath: "/a/b", Path: "/a/b"}) + testPath("/sub-path/%2f/", paths{EscapedPath: "/%2f", RawPath: "/%2f", Path: "//"}) + // "/v2" is special for OCI container registry, it should always be in the root of the site + testPath("/v2", paths{EscapedPath: "/v2", RawPath: "/v2", Path: "/v2"}) + testPath("/v2/", paths{EscapedPath: "/v2", RawPath: "/v2", Path: "/v2"}) + testPath("/v2/%2f", paths{EscapedPath: "/v2/%2f", RawPath: "/v2/%2f", Path: "/v2//"}) +} diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go index b50fbd638e..8c06836ff8 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -37,8 +37,6 @@ import ( "code.gitea.io/gitea/routers/api/packages/vagrant" "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/context" - - "github.com/go-chi/chi/v5" ) func reqPackageAccess(accessMode perm.AccessMode) func(ctx *context.Context) { @@ -140,39 +138,10 @@ func CommonRoutes() *web.Router { }, reqPackageAccess(perm.AccessModeRead)) r.Group("/arch", func() { r.Methods("HEAD,GET", "/repository.key", arch.GetRepositoryKey) - - reqPutRepository := web.NewPathProcessor("PUT", "/") - reqGetRepoArchFile := web.NewPathProcessor("HEAD,GET", "///") - reqDeleteRepoNameVerArch := web.NewPathProcessor("DELETE", "////") - - r.Any("*", func(ctx *context.Context) { - chiCtx := chi.RouteContext(ctx.Req.Context()) - path := ctx.PathParam("*") - - if reqPutRepository.ProcessRequestPath(chiCtx, path) { - reqPackageAccess(perm.AccessModeWrite)(ctx) - if ctx.Written() { - return - } - arch.UploadPackageFile(ctx) - return - } - - if reqGetRepoArchFile.ProcessRequestPath(chiCtx, path) { - arch.GetPackageOrRepositoryFile(ctx) - return - } - - if reqDeleteRepoNameVerArch.ProcessRequestPath(chiCtx, path) { - reqPackageAccess(perm.AccessModeWrite)(ctx) - if ctx.Written() { - return - } - arch.DeletePackageVersion(ctx) - return - } - - ctx.Status(http.StatusNotFound) + r.PathGroup("*", func(g *web.RouterPathGroup) { + g.MatchPath("PUT", "/", reqPackageAccess(perm.AccessModeWrite), arch.UploadPackageFile) + g.MatchPath("HEAD,GET", "///", arch.GetPackageOrRepositoryFile) + g.MatchPath("DELETE", "////", reqPackageAccess(perm.AccessModeWrite), arch.DeletePackageVersion) }) }, reqPackageAccess(perm.AccessModeRead)) r.Group("/cargo", func() { -- cgit v1.2.3