diff --git a/assets/go-licenses.json b/assets/go-licenses.json index 0b6a7fd99a6..f1a35ce1e91 100644 --- a/assets/go-licenses.json +++ b/assets/go-licenses.json @@ -294,6 +294,11 @@ "path": "github.com/bmatcuk/doublestar/v4/LICENSE", "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2014 Bob Matcuk\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n" }, + { + "name": "github.com/bohde/codel", + "path": "github.com/bohde/codel/LICENSE", + "licenseText": "BSD 3-Clause License\n\nCopyright (c) 2018, Rowan Bohde\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are met:\n\n* Redistributions of source code must retain the above copyright notice, this\n list of conditions and the following disclaimer.\n\n* Redistributions in binary form must reproduce the above copyright notice,\n this list of conditions and the following disclaimer in the documentation\n and/or other materials provided with the distribution.\n\n* Neither the name of the copyright holder nor the names of its\n contributors may be used to endorse or promote products derived from\n this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\nAND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\nIMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE\nFOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\nDAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\nSERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER\nCAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,\nOR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" + }, { "name": "github.com/boombuler/barcode", "path": "github.com/boombuler/barcode/LICENSE", diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 8d395511687..c10de959539 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -940,7 +940,29 @@ LEVEL = Info ;; ;; Disable the code explore page. ;DISABLE_CODE_PAGE = false + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;[qos] +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; +;; Enable request quality of service and overload protection. +; ENABLED = false +;; +;; The maximum number of concurrent requests that the server will +;; process before enqueueing new requests. Default is "CpuNum * 4". +; MAX_INFLIGHT = +;; +;; The maximum number of requests that can be enqueued before new +;; requests will be dropped. +; MAX_WAITING = 100 +;; +;; Target maximum wait time a request may be enqueued for. Requests +;; that are enqueued for less than this amount of time will not be +;; dropped. When wait times exceed this amount, a portion of requests +;; will be dropped until wait times have decreased below this amount. +; TARGET_WAIT_TIME = 250ms ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -1423,7 +1445,6 @@ LEVEL = Info ;; or use comma separated list: inline-dollar, inline-parentheses, block-dollar, block-square-brackets ;; Defaults to "inline-dollar,block-dollar" to follow GitHub's behavior. ;MATH_CODE_BLOCK_DETECTION = -;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/go.mod b/go.mod index 094aeeb6bc8..bbe0d5a4c98 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/codecommit v1.28.1 github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb github.com/blevesearch/bleve/v2 v2.4.2 + github.com/bohde/codel v0.2.0 github.com/buildkite/terminal-to-html/v3 v3.16.8 github.com/caddyserver/certmagic v0.22.0 github.com/charmbracelet/git-lfs-transfer v0.2.0 diff --git a/go.sum b/go.sum index cde24edce8a..9dbb7df0603 100644 --- a/go.sum +++ b/go.sum @@ -179,6 +179,9 @@ github.com/blevesearch/zapx/v16 v16.1.5 h1:b0sMcarqNFxuXvjoXsF8WtwVahnxyhEvBSRJi github.com/blevesearch/zapx/v16 v16.1.5/go.mod h1:J4mSF39w1QELc11EWRSBFkPeZuO7r/NPKkHzDCoiaI8= github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmizerany/perks v0.0.0-20141205001514-d9a9656a3a4b/go.mod h1:ac9efd0D1fsDb3EJvhqgXRbFx7bs2wqZ10HQPeU8U/Q= +github.com/bohde/codel v0.2.0 h1:fzF7ibgKmCfQbOzQCblmQcwzDRmV7WO7VMLm/hDvD3E= +github.com/bohde/codel v0.2.0/go.mod h1:Idb1IRvTdwkRjIjguLIo+FXhIBhcpGl94o7xra6ggWk= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4= github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= @@ -881,6 +884,7 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= @@ -1025,6 +1029,8 @@ modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg= modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI= mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk= +pgregory.net/rapid v0.4.2 h1:lsi9jhvZTYvzVpeG93WWgimPRmiJQfGFRNTEZh1dtY0= +pgregory.net/rapid v0.4.2/go.mod h1:UYpPVyjFHzYBGHIxLFoupi8vwk6rXNzRY9OMvVxFIOU= strk.kbt.io/projects/go/libravatar v0.0.0-20191008002943-06d1c002b251 h1:mUcz5b3FJbP5Cvdq7Khzn6J9OCUQJaBwgBkCR+MOwSs= strk.kbt.io/projects/go/libravatar v0.0.0-20191008002943-06d1c002b251/go.mod h1:FJGmPh3vz9jSos1L/F91iAgnC/aejc0wIIrF2ZwJxdY= xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo= diff --git a/models/packages/descriptor.go b/models/packages/descriptor.go index c97bd46c9e3..1ea181c7232 100644 --- a/models/packages/descriptor.go +++ b/models/packages/descriptor.go @@ -11,6 +11,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/packages/alpine" "code.gitea.io/gitea/modules/packages/arch" @@ -102,22 +103,26 @@ func (pd *PackageDescriptor) CalculateBlobSize() int64 { // GetPackageDescriptor gets the package description for a version func GetPackageDescriptor(ctx context.Context, pv *PackageVersion) (*PackageDescriptor, error) { - p, err := GetPackageByID(ctx, pv.PackageID) + return getPackageDescriptor(ctx, pv, cache.NewEphemeralCache()) +} + +func getPackageDescriptor(ctx context.Context, pv *PackageVersion, c *cache.EphemeralCache) (*PackageDescriptor, error) { + p, err := cache.GetWithEphemeralCache(ctx, c, "package", pv.PackageID, GetPackageByID) if err != nil { return nil, err } - o, err := user_model.GetUserByID(ctx, p.OwnerID) + o, err := cache.GetWithEphemeralCache(ctx, c, "user", p.OwnerID, user_model.GetUserByID) if err != nil { return nil, err } var repository *repo_model.Repository if p.RepoID > 0 { - repository, err = repo_model.GetRepositoryByID(ctx, p.RepoID) + repository, err = cache.GetWithEphemeralCache(ctx, c, "repo", p.RepoID, repo_model.GetRepositoryByID) if err != nil && !repo_model.IsErrRepoNotExist(err) { return nil, err } } - creator, err := user_model.GetUserByID(ctx, pv.CreatorID) + creator, err := cache.GetWithEphemeralCache(ctx, c, "user", pv.CreatorID, user_model.GetUserByID) if err != nil { if errors.Is(err, util.ErrNotExist) { creator = user_model.NewGhostUser() @@ -145,9 +150,13 @@ func GetPackageDescriptor(ctx context.Context, pv *PackageVersion) (*PackageDesc return nil, err } - pfds, err := GetPackageFileDescriptors(ctx, pfs) - if err != nil { - return nil, err + pfds := make([]*PackageFileDescriptor, 0, len(pfs)) + for _, pf := range pfs { + pfd, err := getPackageFileDescriptor(ctx, pf, c) + if err != nil { + return nil, err + } + pfds = append(pfds, pfd) } var metadata any @@ -221,7 +230,11 @@ func GetPackageDescriptor(ctx context.Context, pv *PackageVersion) (*PackageDesc // GetPackageFileDescriptor gets a package file descriptor for a package file func GetPackageFileDescriptor(ctx context.Context, pf *PackageFile) (*PackageFileDescriptor, error) { - pb, err := GetBlobByID(ctx, pf.BlobID) + return getPackageFileDescriptor(ctx, pf, cache.NewEphemeralCache()) +} + +func getPackageFileDescriptor(ctx context.Context, pf *PackageFile, c *cache.EphemeralCache) (*PackageFileDescriptor, error) { + pb, err := cache.GetWithEphemeralCache(ctx, c, "package_file_blob", pf.BlobID, GetBlobByID) if err != nil { return nil, err } @@ -251,9 +264,13 @@ func GetPackageFileDescriptors(ctx context.Context, pfs []*PackageFile) ([]*Pack // GetPackageDescriptors gets the package descriptions for the versions func GetPackageDescriptors(ctx context.Context, pvs []*PackageVersion) ([]*PackageDescriptor, error) { + return getPackageDescriptors(ctx, pvs, cache.NewEphemeralCache()) +} + +func getPackageDescriptors(ctx context.Context, pvs []*PackageVersion, c *cache.EphemeralCache) ([]*PackageDescriptor, error) { pds := make([]*PackageDescriptor, 0, len(pvs)) for _, pv := range pvs { - pd, err := GetPackageDescriptor(ctx, pv) + pd, err := getPackageDescriptor(ctx, pv, c) if err != nil { return nil, err } diff --git a/modules/cache/context.go b/modules/cache/context.go index 85eb9e6790e..23f7c23a522 100644 --- a/modules/cache/context.go +++ b/modules/cache/context.go @@ -5,176 +5,39 @@ package cache import ( "context" - "sync" "time" - - "code.gitea.io/gitea/modules/log" ) -// cacheContext is a context that can be used to cache data in a request level context -// This is useful for caching data that is expensive to calculate and is likely to be -// used multiple times in a request. -type cacheContext struct { - data map[any]map[any]any - lock sync.RWMutex - created time.Time - discard bool -} +type cacheContextKeyType struct{} -func (cc *cacheContext) Get(tp, key any) any { - cc.lock.RLock() - defer cc.lock.RUnlock() - return cc.data[tp][key] -} +var cacheContextKey = cacheContextKeyType{} -func (cc *cacheContext) Put(tp, key, value any) { - cc.lock.Lock() - defer cc.lock.Unlock() - - if cc.discard { - return - } - - d := cc.data[tp] - if d == nil { - d = make(map[any]any) - cc.data[tp] = d - } - d[key] = value -} - -func (cc *cacheContext) Delete(tp, key any) { - cc.lock.Lock() - defer cc.lock.Unlock() - delete(cc.data[tp], key) -} - -func (cc *cacheContext) Discard() { - cc.lock.Lock() - defer cc.lock.Unlock() - cc.data = nil - cc.discard = true -} - -func (cc *cacheContext) isDiscard() bool { - cc.lock.RLock() - defer cc.lock.RUnlock() - return cc.discard -} - -// cacheContextLifetime is the max lifetime of cacheContext. -// Since cacheContext is used to cache data in a request level context, 5 minutes is enough. -// If a cacheContext is used more than 5 minutes, it's probably misuse. -const cacheContextLifetime = 5 * time.Minute - -var timeNow = time.Now - -func (cc *cacheContext) Expired() bool { - return timeNow().Sub(cc.created) > cacheContextLifetime -} - -var cacheContextKey = struct{}{} - -/* -Since there are both WithCacheContext and WithNoCacheContext, -it may be confusing when there is nesting. - -Some cases to explain the design: - -When: -- A, B or C means a cache context. -- A', B' or C' means a discard cache context. -- ctx means context.Backgrand(). -- A(ctx) means a cache context with ctx as the parent context. -- B(A(ctx)) means a cache context with A(ctx) as the parent context. -- With is alias of WithCacheContext. -- WithNo is alias of WithNoCacheContext. - -So: -- With(ctx) -> A(ctx) -- With(With(ctx)) -> A(ctx), not B(A(ctx)), always reuse parent cache context if possible. -- With(With(With(ctx))) -> A(ctx), not C(B(A(ctx))), ditto. -- WithNo(ctx) -> ctx, not A'(ctx), don't create new cache context if we don't have to. -- WithNo(With(ctx)) -> A'(ctx) -- WithNo(WithNo(With(ctx))) -> A'(ctx), not B'(A'(ctx)), don't create new cache context if we don't have to. -- With(WithNo(With(ctx))) -> B(A'(ctx)), not A(ctx), never reuse a discard cache context. -- WithNo(With(WithNo(With(ctx)))) -> B'(A'(ctx)) -- With(WithNo(With(WithNo(With(ctx))))) -> C(B'(A'(ctx))), so there's always only one not-discard cache context. -*/ +// contextCacheLifetime is the max lifetime of context cache. +// Since context cache is used to cache data in a request level context, 5 minutes is enough. +// If a context cache is used more than 5 minutes, it's probably abused. +const contextCacheLifetime = 5 * time.Minute func WithCacheContext(ctx context.Context) context.Context { - if c, ok := ctx.Value(cacheContextKey).(*cacheContext); ok { - if !c.isDiscard() { - // reuse parent context - return ctx - } - } - // FIXME: review the use of this nolint directive - return context.WithValue(ctx, cacheContextKey, &cacheContext{ //nolint:staticcheck - data: make(map[any]map[any]any), - created: timeNow(), - }) -} - -func WithNoCacheContext(ctx context.Context) context.Context { - if c, ok := ctx.Value(cacheContextKey).(*cacheContext); ok { - // The caller want to run long-life tasks, but the parent context is a cache context. - // So we should disable and clean the cache data, or it will be kept in memory for a long time. - c.Discard() + if c := GetContextCache(ctx); c != nil { return ctx } - - return ctx + return context.WithValue(ctx, cacheContextKey, NewEphemeralCache(contextCacheLifetime)) } -func GetContextData(ctx context.Context, tp, key any) any { - if c, ok := ctx.Value(cacheContextKey).(*cacheContext); ok { - if c.Expired() { - // The warning means that the cache context is misused for long-life task, - // it can be resolved with WithNoCacheContext(ctx). - log.Warn("cache context is expired, is highly likely to be misused for long-life tasks: %v", c) - return nil - } - return c.Get(tp, key) - } - return nil -} - -func SetContextData(ctx context.Context, tp, key, value any) { - if c, ok := ctx.Value(cacheContextKey).(*cacheContext); ok { - if c.Expired() { - // The warning means that the cache context is misused for long-life task, - // it can be resolved with WithNoCacheContext(ctx). - log.Warn("cache context is expired, is highly likely to be misused for long-life tasks: %v", c) - return - } - c.Put(tp, key, value) - return - } -} - -func RemoveContextData(ctx context.Context, tp, key any) { - if c, ok := ctx.Value(cacheContextKey).(*cacheContext); ok { - if c.Expired() { - // The warning means that the cache context is misused for long-life task, - // it can be resolved with WithNoCacheContext(ctx). - log.Warn("cache context is expired, is highly likely to be misused for long-life tasks: %v", c) - return - } - c.Delete(tp, key) - } +func GetContextCache(ctx context.Context) *EphemeralCache { + c, _ := ctx.Value(cacheContextKey).(*EphemeralCache) + return c } // GetWithContextCache returns the cache value of the given key in the given context. +// FIXME: in some cases, the "context cache" should not be used, because it has uncontrollable behaviors +// For example, these calls: +// * GetWithContextCache(TargetID) -> OtherCodeCreateModel(TargetID) -> GetWithContextCache(TargetID) +// Will cause the second call is not able to get the correct created target. +// UNLESS it is certain that the target won't be changed during the request, DO NOT use it. func GetWithContextCache[T, K any](ctx context.Context, groupKey string, targetKey K, f func(context.Context, K) (T, error)) (T, error) { - v := GetContextData(ctx, groupKey, targetKey) - if vv, ok := v.(T); ok { - return vv, nil + if c := GetContextCache(ctx); c != nil { + return GetWithEphemeralCache(ctx, c, groupKey, targetKey, f) } - t, err := f(ctx, targetKey) - if err != nil { - return t, err - } - SetContextData(ctx, groupKey, targetKey, t) - return t, nil + return f(ctx, targetKey) } diff --git a/modules/cache/context_test.go b/modules/cache/context_test.go index 23dd789dbc2..8371c2b9080 100644 --- a/modules/cache/context_test.go +++ b/modules/cache/context_test.go @@ -8,27 +8,29 @@ import ( "testing" "time" + "code.gitea.io/gitea/modules/test" + "github.com/stretchr/testify/assert" ) func TestWithCacheContext(t *testing.T) { ctx := WithCacheContext(t.Context()) - - v := GetContextData(ctx, "empty_field", "my_config1") + c := GetContextCache(ctx) + v, _ := c.Get("empty_field", "my_config1") assert.Nil(t, v) const field = "system_setting" - v = GetContextData(ctx, field, "my_config1") + v, _ = c.Get(field, "my_config1") assert.Nil(t, v) - SetContextData(ctx, field, "my_config1", 1) - v = GetContextData(ctx, field, "my_config1") + c.Put(field, "my_config1", 1) + v, _ = c.Get(field, "my_config1") assert.NotNil(t, v) assert.Equal(t, 1, v.(int)) - RemoveContextData(ctx, field, "my_config1") - RemoveContextData(ctx, field, "my_config2") // remove a non-exist key + c.Delete(field, "my_config1") + c.Delete(field, "my_config2") // remove a non-exist key - v = GetContextData(ctx, field, "my_config1") + v, _ = c.Get(field, "my_config1") assert.Nil(t, v) vInt, err := GetWithContextCache(ctx, field, "my_config1", func(context.Context, string) (int, error) { @@ -37,42 +39,12 @@ func TestWithCacheContext(t *testing.T) { assert.NoError(t, err) assert.Equal(t, 1, vInt) - v = GetContextData(ctx, field, "my_config1") + v, _ = c.Get(field, "my_config1") assert.EqualValues(t, 1, v) - now := timeNow - defer func() { - timeNow = now - }() - timeNow = func() time.Time { - return now().Add(5 * time.Minute) - } - v = GetContextData(ctx, field, "my_config1") + defer test.MockVariableValue(&timeNow, func() time.Time { + return time.Now().Add(5 * time.Minute) + })() + v, _ = c.Get(field, "my_config1") assert.Nil(t, v) } - -func TestWithNoCacheContext(t *testing.T) { - ctx := t.Context() - - const field = "system_setting" - - v := GetContextData(ctx, field, "my_config1") - assert.Nil(t, v) - SetContextData(ctx, field, "my_config1", 1) - v = GetContextData(ctx, field, "my_config1") - assert.Nil(t, v) // still no cache - - ctx = WithCacheContext(ctx) - v = GetContextData(ctx, field, "my_config1") - assert.Nil(t, v) - SetContextData(ctx, field, "my_config1", 1) - v = GetContextData(ctx, field, "my_config1") - assert.NotNil(t, v) - - ctx = WithNoCacheContext(ctx) - v = GetContextData(ctx, field, "my_config1") - assert.Nil(t, v) - SetContextData(ctx, field, "my_config1", 1) - v = GetContextData(ctx, field, "my_config1") - assert.Nil(t, v) // still no cache -} diff --git a/modules/cache/ephemeral.go b/modules/cache/ephemeral.go new file mode 100644 index 00000000000..6996010ac4b --- /dev/null +++ b/modules/cache/ephemeral.go @@ -0,0 +1,90 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cache + +import ( + "context" + "sync" + "time" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" +) + +// EphemeralCache is a cache that can be used to store data in a request level context +// This is useful for caching data that is expensive to calculate and is likely to be +// used multiple times in a request. +type EphemeralCache struct { + data map[any]map[any]any + lock sync.RWMutex + created time.Time + checkLifeTime time.Duration +} + +var timeNow = time.Now + +func NewEphemeralCache(checkLifeTime ...time.Duration) *EphemeralCache { + return &EphemeralCache{ + data: make(map[any]map[any]any), + created: timeNow(), + checkLifeTime: util.OptionalArg(checkLifeTime, 0), + } +} + +func (cc *EphemeralCache) checkExceededLifeTime(tp, key any) bool { + if cc.checkLifeTime > 0 && timeNow().Sub(cc.created) > cc.checkLifeTime { + log.Warn("EphemeralCache is expired, is highly likely to be abused for long-life tasks: %v, %v", tp, key) + return true + } + return false +} + +func (cc *EphemeralCache) Get(tp, key any) (any, bool) { + if cc.checkExceededLifeTime(tp, key) { + return nil, false + } + cc.lock.RLock() + defer cc.lock.RUnlock() + ret, ok := cc.data[tp][key] + return ret, ok +} + +func (cc *EphemeralCache) Put(tp, key, value any) { + if cc.checkExceededLifeTime(tp, key) { + return + } + + cc.lock.Lock() + defer cc.lock.Unlock() + + d := cc.data[tp] + if d == nil { + d = make(map[any]any) + cc.data[tp] = d + } + d[key] = value +} + +func (cc *EphemeralCache) Delete(tp, key any) { + if cc.checkExceededLifeTime(tp, key) { + return + } + + cc.lock.Lock() + defer cc.lock.Unlock() + delete(cc.data[tp], key) +} + +func GetWithEphemeralCache[T, K any](ctx context.Context, c *EphemeralCache, groupKey string, targetKey K, f func(context.Context, K) (T, error)) (T, error) { + v, has := c.Get(groupKey, targetKey) + if vv, ok := v.(T); has && ok { + return vv, nil + } + t, err := f(ctx, targetKey) + if err != nil { + return t, err + } + c.Put(groupKey, targetKey, t) + return t, nil +} diff --git a/modules/setting/packages.go b/modules/setting/packages.go index 845f6d4e12b..b5984240648 100644 --- a/modules/setting/packages.go +++ b/modules/setting/packages.go @@ -13,9 +13,8 @@ import ( // Package registry settings var ( Packages = struct { - Storage *Storage - Enabled bool - ChunkedUploadPath string + Storage *Storage + Enabled bool LimitTotalOwnerCount int64 LimitTotalOwnerSize int64 @@ -65,13 +64,6 @@ func loadPackagesFrom(rootCfg ConfigProvider) (err error) { return err } - if HasInstallLock(rootCfg) { - Packages.ChunkedUploadPath, err = AppDataTempDir("package-upload").MkdirAllSub("") - if err != nil { - return fmt.Errorf("unable to create chunked upload directory: %w", err) - } - } - Packages.LimitTotalOwnerSize = mustBytes(sec, "LIMIT_TOTAL_OWNER_SIZE") Packages.LimitSizeAlpine = mustBytes(sec, "LIMIT_SIZE_ALPINE") Packages.LimitSizeArch = mustBytes(sec, "LIMIT_SIZE_ARCH") diff --git a/modules/setting/service.go b/modules/setting/service.go index d9535efec6e..b1b9fedd62a 100644 --- a/modules/setting/service.go +++ b/modules/setting/service.go @@ -5,6 +5,7 @@ package setting import ( "regexp" + "runtime" "strings" "time" @@ -98,6 +99,13 @@ var Service = struct { DisableOrganizationsPage bool `ini:"DISABLE_ORGANIZATIONS_PAGE"` DisableCodePage bool `ini:"DISABLE_CODE_PAGE"` } `ini:"service.explore"` + + QoS struct { + Enabled bool + MaxInFlightRequests int + MaxWaitingRequests int + TargetWaitTime time.Duration + } }{ AllowedUserVisibilityModesSlice: []bool{true, true, true}, } @@ -255,6 +263,7 @@ func loadServiceFrom(rootCfg ConfigProvider) { mustMapSetting(rootCfg, "service.explore", &Service.Explore) loadOpenIDSetting(rootCfg) + loadQosSetting(rootCfg) } func loadOpenIDSetting(rootCfg ConfigProvider) { @@ -276,3 +285,11 @@ func loadOpenIDSetting(rootCfg ConfigProvider) { } } } + +func loadQosSetting(rootCfg ConfigProvider) { + sec := rootCfg.Section("qos") + Service.QoS.Enabled = sec.Key("ENABLED").MustBool(false) + Service.QoS.MaxInFlightRequests = sec.Key("MAX_INFLIGHT").MustInt(4 * runtime.NumCPU()) + Service.QoS.MaxWaitingRequests = sec.Key("MAX_WAITING").MustInt(100) + Service.QoS.TargetWaitTime = sec.Key("TARGET_WAIT_TIME").MustDuration(250 * time.Millisecond) +} diff --git a/modules/structs/package.go b/modules/structs/package.go index a9a9429de2d..1973f925a58 100644 --- a/modules/structs/package.go +++ b/modules/structs/package.go @@ -23,8 +23,8 @@ type Package struct { // PackageFile represents a package file type PackageFile struct { - ID int64 `json:"id"` - Size int64 + ID int64 `json:"id"` + Size int64 `json:"size"` Name string `json:"name"` HashMD5 string `json:"md5"` HashSHA1 string `json:"sha1"` diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 28598fa566e..b342e66c4b5 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -117,6 +117,7 @@ files = Files error = Error error404 = The page you are trying to reach either does not exist or you are not authorized to view it. +error503 = The server was unable to complete your request. Please try again later. go_back = Go Back invalid_data = Invalid data: %v diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 5cd08a36181..b9b590725b4 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1544,14 +1544,19 @@ func Routes() *web.Router { // NOTE: these are Gitea package management API - see packages.CommonRoutes and packages.DockerContainerRoutes for endpoints that implement package manager APIs m.Group("/packages/{username}", func() { m.Group("/{type}/{name}", func() { + m.Get("/", packages.ListPackageVersions) + m.Group("/{version}", func() { m.Get("", packages.GetPackage) m.Delete("", reqPackageAccess(perm.AccessModeWrite), packages.DeletePackage) m.Get("/files", packages.ListPackageFiles) }) - m.Post("/-/link/{repo_name}", reqPackageAccess(perm.AccessModeWrite), packages.LinkPackage) - m.Post("/-/unlink", reqPackageAccess(perm.AccessModeWrite), packages.UnlinkPackage) + m.Group("/-", func() { + m.Get("/latest", packages.GetLatestPackageVersion) + m.Post("/link/{repo_name}", reqPackageAccess(perm.AccessModeWrite), packages.LinkPackage) + m.Post("/unlink", reqPackageAccess(perm.AccessModeWrite), packages.UnlinkPackage) + }) }) m.Get("/", packages.ListPackages) diff --git a/routers/api/v1/packages/package.go b/routers/api/v1/packages/package.go index f8695193445..41b7f2a43f6 100644 --- a/routers/api/v1/packages/package.go +++ b/routers/api/v1/packages/package.go @@ -56,13 +56,10 @@ func ListPackages(ctx *context.APIContext) { listOptions := utils.GetListOptions(ctx) - packageType := ctx.FormTrim("type") - query := ctx.FormTrim("q") - - pvs, count, err := packages.SearchVersions(ctx, &packages.PackageSearchOptions{ + apiPackages, count, err := searchPackages(ctx, &packages.PackageSearchOptions{ OwnerID: ctx.Package.Owner.ID, - Type: packages.Type(packageType), - Name: packages.SearchValue{Value: query}, + Type: packages.Type(ctx.FormTrim("type")), + Name: packages.SearchValue{Value: ctx.FormTrim("q")}, IsInternal: optional.Some(false), Paginator: &listOptions, }) @@ -71,22 +68,6 @@ func ListPackages(ctx *context.APIContext) { return } - pds, err := packages.GetPackageDescriptors(ctx, pvs) - if err != nil { - ctx.APIErrorInternal(err) - return - } - - apiPackages := make([]*api.Package, 0, len(pds)) - for _, pd := range pds { - apiPackage, err := convert.ToPackage(ctx, pd, ctx.Doer) - if err != nil { - ctx.APIErrorInternal(err) - return - } - apiPackages = append(apiPackages, apiPackage) - } - ctx.SetLinkHeader(int(count), listOptions.PageSize) ctx.SetTotalCountHeader(count) ctx.JSON(http.StatusOK, apiPackages) @@ -217,6 +198,121 @@ func ListPackageFiles(ctx *context.APIContext) { ctx.JSON(http.StatusOK, apiPackageFiles) } +// ListPackageVersions gets all versions of a package +func ListPackageVersions(ctx *context.APIContext) { + // swagger:operation GET /packages/{owner}/{type}/{name} package listPackageVersions + // --- + // summary: Gets all versions of a package + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the package + // type: string + // required: true + // - name: type + // in: path + // description: type of the package + // type: string + // required: true + // - name: name + // in: path + // description: name of the package + // type: string + // required: true + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/PackageList" + // "404": + // "$ref": "#/responses/notFound" + + listOptions := utils.GetListOptions(ctx) + + apiPackages, count, err := searchPackages(ctx, &packages.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages.Type(ctx.PathParam("type")), + Name: packages.SearchValue{Value: ctx.PathParam("name"), ExactMatch: true}, + IsInternal: optional.Some(false), + Paginator: &listOptions, + }) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.SetLinkHeader(int(count), listOptions.PageSize) + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, apiPackages) +} + +// GetLatestPackageVersion gets the latest version of a package +func GetLatestPackageVersion(ctx *context.APIContext) { + // swagger:operation GET /packages/{owner}/{type}/{name}/-/latest package getLatestPackageVersion + // --- + // summary: Gets the latest version of a package + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the package + // type: string + // required: true + // - name: type + // in: path + // description: type of the package + // type: string + // required: true + // - name: name + // in: path + // description: name of the package + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/Package" + // "404": + // "$ref": "#/responses/notFound" + + pvs, _, err := packages.SearchLatestVersions(ctx, &packages.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages.Type(ctx.PathParam("type")), + Name: packages.SearchValue{Value: ctx.PathParam("name"), ExactMatch: true}, + IsInternal: optional.Some(false), + }) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if len(pvs) == 0 { + ctx.APIError(http.StatusNotFound, err) + return + } + + pd, err := packages.GetPackageDescriptor(ctx, pvs[0]) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + apiPackage, err := convert.ToPackage(ctx, pd, ctx.Doer) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusOK, apiPackage) +} + // LinkPackage sets a repository link for a package func LinkPackage(ctx *context.APIContext) { // swagger:operation POST /packages/{owner}/{type}/{name}/-/link/{repo_name} package linkPackage @@ -335,3 +431,26 @@ func UnlinkPackage(ctx *context.APIContext) { } ctx.Status(http.StatusNoContent) } + +func searchPackages(ctx *context.APIContext, opts *packages.PackageSearchOptions) ([]*api.Package, int64, error) { + pvs, count, err := packages.SearchVersions(ctx, opts) + if err != nil { + return nil, 0, err + } + + pds, err := packages.GetPackageDescriptors(ctx, pvs) + if err != nil { + return nil, 0, err + } + + apiPackages := make([]*api.Package, 0, len(pds)) + for _, pd := range pds { + apiPackage, err := convert.ToPackage(ctx, pd, ctx.Doer) + if err != nil { + return nil, 0, err + } + apiPackages = append(apiPackages, apiPackage) + } + + return apiPackages, count, nil +} diff --git a/routers/api/v1/repo/migrate.go b/routers/api/v1/repo/migrate.go index 87bb2690317..f2e0cad86ca 100644 --- a/routers/api/v1/repo/migrate.go +++ b/routers/api/v1/repo/migrate.go @@ -181,7 +181,7 @@ func Migrate(ctx *context.APIContext) { IsPrivate: opts.Private || setting.Repository.ForcePrivate, IsMirror: opts.Mirror, Status: repo_model.RepositoryBeingMigrated, - }) + }, false) if err != nil { handleMigrateError(ctx, repoOwner, err) return diff --git a/routers/common/qos.go b/routers/common/qos.go new file mode 100644 index 00000000000..e50fbe4f699 --- /dev/null +++ b/routers/common/qos.go @@ -0,0 +1,145 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "fmt" + "net/http" + "strings" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/web/middleware" + giteacontext "code.gitea.io/gitea/services/context" + + "github.com/bohde/codel" + "github.com/go-chi/chi/v5" +) + +const tplStatus503 templates.TplName = "status/503" + +type Priority int + +func (p Priority) String() string { + switch p { + case HighPriority: + return "high" + case DefaultPriority: + return "default" + case LowPriority: + return "low" + default: + return fmt.Sprintf("%d", p) + } +} + +const ( + LowPriority = Priority(-10) + DefaultPriority = Priority(0) + HighPriority = Priority(10) +) + +// QoS implements quality of service for requests, based upon whether +// or not the user is logged in. All traffic may get dropped, and +// anonymous users are deprioritized. +func QoS() func(next http.Handler) http.Handler { + if !setting.Service.QoS.Enabled { + return nil + } + + maxOutstanding := setting.Service.QoS.MaxInFlightRequests + if maxOutstanding <= 0 { + maxOutstanding = 10 + } + + c := codel.NewPriority(codel.Options{ + // The maximum number of waiting requests. + MaxPending: setting.Service.QoS.MaxWaitingRequests, + // The maximum number of in-flight requests. + MaxOutstanding: maxOutstanding, + // The target latency that a blocked request should wait + // for. After this, it might be dropped. + TargetLatency: setting.Service.QoS.TargetWaitTime, + }) + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + + priority := requestPriority(ctx) + + // Check if the request can begin processing. + err := c.Acquire(ctx, int(priority)) + if err != nil { + log.Error("QoS error, dropping request of priority %s: %v", priority, err) + renderServiceUnavailable(w, req) + return + } + + // Release long-polling immediately, so they don't always + // take up an in-flight request + if strings.Contains(req.URL.Path, "/user/events") { + c.Release() + } else { + defer c.Release() + } + + next.ServeHTTP(w, req) + }) + } +} + +// requestPriority assigns a priority value for a request based upon +// whether the user is logged in and how expensive the endpoint is +func requestPriority(ctx context.Context) Priority { + // If the user is logged in, assign high priority. + data := middleware.GetContextData(ctx) + if _, ok := data[middleware.ContextDataKeySignedUser].(*user_model.User); ok { + return HighPriority + } + + rctx := chi.RouteContext(ctx) + if rctx == nil { + return DefaultPriority + } + + // If we're operating in the context of a repo, assign low priority + routePattern := rctx.RoutePattern() + if strings.HasPrefix(routePattern, "/{username}/{reponame}/") { + return LowPriority + } + + return DefaultPriority +} + +// renderServiceUnavailable will render an HTTP 503 Service +// Unavailable page, providing HTML if the client accepts it. +func renderServiceUnavailable(w http.ResponseWriter, req *http.Request) { + acceptsHTML := false + for _, part := range req.Header["Accept"] { + if strings.Contains(part, "text/html") { + acceptsHTML = true + break + } + } + + // If the client doesn't accept HTML, then render a plain text response + if !acceptsHTML { + http.Error(w, "503 Service Unavailable", http.StatusServiceUnavailable) + return + } + + tmplCtx := giteacontext.TemplateContext{} + tmplCtx["Locale"] = middleware.Locale(w, req) + ctxData := middleware.GetContextData(req.Context()) + err := templates.HTMLRenderer().HTML(w, http.StatusServiceUnavailable, tplStatus503, ctxData, tmplCtx) + if err != nil { + log.Error("Error occurs again when rendering service unavailable page: %v", err) + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("Internal server error, please collect error logs and report to Gitea issue tracker")) + } +} diff --git a/routers/common/qos_test.go b/routers/common/qos_test.go new file mode 100644 index 00000000000..850a5f51dbe --- /dev/null +++ b/routers/common/qos_test.go @@ -0,0 +1,91 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package common + +import ( + "net/http" + "testing" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/contexttest" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" +) + +func TestRequestPriority(t *testing.T) { + type test struct { + Name string + User *user_model.User + RoutePattern string + Expected Priority + } + + cases := []test{ + { + Name: "Logged In", + User: &user_model.User{}, + Expected: HighPriority, + }, + { + Name: "Sign In", + RoutePattern: "/user/login", + Expected: DefaultPriority, + }, + { + Name: "Repo Home", + RoutePattern: "/{username}/{reponame}", + Expected: DefaultPriority, + }, + { + Name: "User Repo", + RoutePattern: "/{username}/{reponame}/src/branch/main", + Expected: LowPriority, + }, + } + + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + ctx, _ := contexttest.MockContext(t, "") + + if tc.User != nil { + data := middleware.GetContextData(ctx) + data[middleware.ContextDataKeySignedUser] = tc.User + } + + rctx := chi.RouteContext(ctx) + rctx.RoutePatterns = []string{tc.RoutePattern} + + assert.Exactly(t, tc.Expected, requestPriority(ctx)) + }) + } +} + +func TestRenderServiceUnavailable(t *testing.T) { + t.Run("HTML", func(t *testing.T) { + ctx, resp := contexttest.MockContext(t, "") + ctx.Req.Header.Set("Accept", "text/html") + + renderServiceUnavailable(resp, ctx.Req) + assert.Equal(t, http.StatusServiceUnavailable, resp.Code) + assert.Contains(t, resp.Header().Get("Content-Type"), "text/html") + + body := resp.Body.String() + assert.Contains(t, body, `lang="en-US"`) + assert.Contains(t, body, "503 Service Unavailable") + }) + + t.Run("plain", func(t *testing.T) { + ctx, resp := contexttest.MockContext(t, "") + ctx.Req.Header.Set("Accept", "text/plain") + + renderServiceUnavailable(resp, ctx.Req) + assert.Equal(t, http.StatusServiceUnavailable, resp.Code) + assert.Contains(t, resp.Header().Get("Content-Type"), "text/plain") + + body := resp.Body.String() + assert.Contains(t, body, "503 Service Unavailable") + }) +} diff --git a/routers/web/repo/blame.go b/routers/web/repo/blame.go index e125267524c..e304633f952 100644 --- a/routers/web/repo/blame.go +++ b/routers/web/repo/blame.go @@ -8,6 +8,7 @@ import ( gotemplate "html/template" "net/http" "net/url" + "path" "strconv" "strings" @@ -69,7 +70,7 @@ func RefBlame(ctx *context.Context) { blob := entry.Blob() fileSize := blob.Size() ctx.Data["FileSize"] = fileSize - ctx.Data["FileName"] = blob.Name() + ctx.Data["FileTreePath"] = ctx.Repo.TreePath tplName := tplRepoViewContent if !ctx.FormBool("only_content") { @@ -285,8 +286,7 @@ func renderBlame(ctx *context.Context, blameParts []*git.BlamePart, commitNames if i != len(lines)-1 { line += "\n" } - fileName := fmt.Sprintf("%v", ctx.Data["FileName"]) - line, lexerNameForLine := highlight.Code(fileName, language, line) + line, lexerNameForLine := highlight.Code(path.Base(ctx.Repo.TreePath), language, line) // set lexer name to the first detected lexer. this is certainly suboptimal and // we should instead highlight the whole file at once diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 3fd1eacb581..3569a356d15 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -215,13 +215,12 @@ func SearchCommits(ctx *context.Context) { // FileHistory show a file's reversions func FileHistory(ctx *context.Context) { - fileName := ctx.Repo.TreePath - if len(fileName) == 0 { + if ctx.Repo.TreePath == "" { Commits(ctx) return } - commitsCount, err := ctx.Repo.GitRepo.FileCommitsCount(ctx.Repo.RefFullName.ShortName(), fileName) // FIXME: legacy code used ShortName + commitsCount, err := ctx.Repo.GitRepo.FileCommitsCount(ctx.Repo.RefFullName.ShortName(), ctx.Repo.TreePath) if err != nil { ctx.ServerError("FileCommitsCount", err) return @@ -238,7 +237,7 @@ func FileHistory(ctx *context.Context) { commits, err := ctx.Repo.GitRepo.CommitsByFileAndRange( git.CommitsByFileAndRangeOptions{ Revision: ctx.Repo.RefFullName.ShortName(), // FIXME: legacy code used ShortName - File: fileName, + File: ctx.Repo.TreePath, Page: page, }) if err != nil { @@ -253,7 +252,7 @@ func FileHistory(ctx *context.Context) { ctx.Data["Username"] = ctx.Repo.Owner.Name ctx.Data["Reponame"] = ctx.Repo.Repository.Name - ctx.Data["FileName"] = fileName + ctx.Data["FileTreePath"] = ctx.Repo.TreePath ctx.Data["CommitCount"] = commitsCount pager := context.NewPagination(int(commitsCount), setting.Git.CommitsRangeSize, page, 5) diff --git a/routers/web/repo/editor.go b/routers/web/repo/editor.go index c181aad050f..c925b611514 100644 --- a/routers/web/repo/editor.go +++ b/routers/web/repo/editor.go @@ -160,7 +160,6 @@ func editFile(ctx *context.Context, isNewFile bool) { defer dataRc.Close() ctx.Data["FileSize"] = blob.Size() - ctx.Data["FileName"] = blob.Name() buf := make([]byte, 1024) n, _ := util.ReadAtMost(dataRc, buf) diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go index ff0e1b4d54c..3df6051975b 100644 --- a/routers/web/repo/view_file.go +++ b/routers/web/repo/view_file.go @@ -52,7 +52,7 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) { ctx.Data["Title"] = ctx.Tr("repo.file.title", ctx.Repo.Repository.Name+"/"+path.Base(ctx.Repo.TreePath), ctx.Repo.RefFullName.ShortName()) ctx.Data["FileIsSymlink"] = entry.IsLink() - ctx.Data["FileName"] = blob.Name() + ctx.Data["FileTreePath"] = ctx.Repo.TreePath ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/raw/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) if ctx.Repo.TreePath == ".editorconfig" { diff --git a/routers/web/repo/view_readme.go b/routers/web/repo/view_readme.go index 606ee7ff797..459cf0a6162 100644 --- a/routers/web/repo/view_readme.go +++ b/routers/web/repo/view_readme.go @@ -162,7 +162,7 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil defer dataRc.Close() ctx.Data["FileIsText"] = fInfo.isTextFile - ctx.Data["FileName"] = path.Join(subfolder, readmeFile.Name()) + ctx.Data["FileTreePath"] = path.Join(subfolder, readmeFile.Name()) ctx.Data["FileSize"] = fInfo.fileSize ctx.Data["IsLFSFile"] = fInfo.isLFSFile diff --git a/routers/web/web.go b/routers/web/web.go index dbfa489cd09..dce8114ba25 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -285,7 +285,7 @@ func Routes() *web.Router { webRoutes := web.NewRouter() webRoutes.Use(mid...) - webRoutes.Group("", func() { registerWebRoutes(webRoutes) }, common.BlockExpensive()) + webRoutes.Group("", func() { registerWebRoutes(webRoutes) }, common.BlockExpensive(), common.QoS()) routes.Mount("", webRoutes) return routes } diff --git a/services/migrations/gitea_uploader.go b/services/migrations/gitea_uploader.go index cd5af399104..82d756dc56f 100644 --- a/services/migrations/gitea_uploader.go +++ b/services/migrations/gitea_uploader.go @@ -107,7 +107,7 @@ func (g *GiteaLocalUploader) CreateRepo(ctx context.Context, repo *base.Reposito IsPrivate: opts.Private || setting.Repository.ForcePrivate, IsMirror: opts.Mirror, Status: repo_model.RepositoryBeingMigrated, - }) + }, false) } else { r, err = repo_model.GetRepositoryByID(ctx, opts.MigrateToRepoID) } diff --git a/services/migrations/github.go b/services/migrations/github.go index e512ebc24ac..ce3e5ced9e7 100644 --- a/services/migrations/github.go +++ b/services/migrations/github.go @@ -133,7 +133,7 @@ func (g *GithubDownloaderV3) LogString() string { func (g *GithubDownloaderV3) addClient(client *http.Client, baseURL string) { githubClient := github.NewClient(client) if baseURL != "https://github.com" { - githubClient, _ = github.NewClient(client).WithEnterpriseURLs(baseURL, baseURL) + githubClient, _ = githubClient.WithEnterpriseURLs(baseURL, baseURL) } g.clients = append(g.clients, githubClient) g.rates = append(g.rates, nil) @@ -872,3 +872,18 @@ func (g *GithubDownloaderV3) GetReviews(ctx context.Context, reviewable base.Rev } return allReviews, nil } + +// FormatCloneURL add authentication into remote URLs +func (g *GithubDownloaderV3) FormatCloneURL(opts MigrateOptions, remoteAddr string) (string, error) { + u, err := url.Parse(remoteAddr) + if err != nil { + return "", err + } + if len(opts.AuthToken) > 0 { + // "multiple tokens" are used to benefit more "API rate limit quota" + // git clone doesn't count for rate limits, so only use the first token. + // source: https://github.com/orgs/community/discussions/44515 + u.User = url.UserPassword("oauth2", strings.Split(opts.AuthToken, ",")[0]) + } + return u.String(), nil +} diff --git a/services/migrations/github_test.go b/services/migrations/github_test.go index 2625fb62ec7..6d1a5378b96 100644 --- a/services/migrations/github_test.go +++ b/services/migrations/github_test.go @@ -12,6 +12,7 @@ import ( base "code.gitea.io/gitea/modules/migration" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestGitHubDownloadRepo(t *testing.T) { @@ -429,3 +430,36 @@ func TestGitHubDownloadRepo(t *testing.T) { }, }, reviews) } + +func TestGithubMultiToken(t *testing.T) { + testCases := []struct { + desc string + token string + expectedCloneURL string + }{ + { + desc: "Single Token", + token: "single_token", + expectedCloneURL: "https://oauth2:single_token@github.com", + }, + { + desc: "Multi Token", + token: "token1,token2", + expectedCloneURL: "https://oauth2:token1@github.com", + }, + } + factory := GithubDownloaderV3Factory{} + + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + opts := base.MigrateOptions{CloneAddr: "https://github.com/go-gitea/gitea", AuthToken: tC.token} + client, err := factory.New(t.Context(), opts) + require.NoError(t, err) + + cloneURL, err := client.FormatCloneURL(opts, "https://github.com") + require.NoError(t, err) + + assert.Equal(t, tC.expectedCloneURL, cloneURL) + }) + } +} diff --git a/services/org/team_test.go b/services/org/team_test.go index 831fe1669f7..aa39771cd23 100644 --- a/services/org/team_test.go +++ b/services/org/team_test.go @@ -205,7 +205,8 @@ func TestIncludesAllRepositoriesTeams(t *testing.T) { // Create repos. repoIDs := make([]int64, 0) for i := 0; i < 3; i++ { - r, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, user, org.AsUser(), repo_service.CreateRepoOptions{Name: fmt.Sprintf("repo-%d", i)}) + r, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, user, org.AsUser(), + repo_service.CreateRepoOptions{Name: fmt.Sprintf("repo-%d", i)}, true) assert.NoError(t, err, "CreateRepository %d", i) if r != nil { repoIDs = append(repoIDs, r.ID) @@ -267,7 +268,7 @@ func TestIncludesAllRepositoriesTeams(t *testing.T) { } // Create repo and check teams repositories. - r, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, user, org.AsUser(), repo_service.CreateRepoOptions{Name: "repo-last"}) + r, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, user, org.AsUser(), repo_service.CreateRepoOptions{Name: "repo-last"}, true) assert.NoError(t, err, "CreateRepository last") if r != nil { repoIDs = append(repoIDs, r.ID) diff --git a/services/packages/cargo/index.go b/services/packages/cargo/index.go index decb224b85d..e8a2f189c8b 100644 --- a/services/packages/cargo/index.go +++ b/services/packages/cargo/index.go @@ -213,7 +213,7 @@ func getOrCreateIndexRepository(ctx context.Context, doer, owner *user_model.Use if errors.Is(err, util.ErrNotExist) { repo, err = repo_service.CreateRepositoryDirectly(ctx, doer, owner, repo_service.CreateRepoOptions{ Name: IndexRepositoryName, - }) + }, true) if err != nil { return nil, fmt.Errorf("CreateRepository: %w", err) } diff --git a/services/packages/container/blob_uploader.go b/services/packages/container/blob_uploader.go index bae2e2d6af6..27bc4a5421b 100644 --- a/services/packages/container/blob_uploader.go +++ b/services/packages/container/blob_uploader.go @@ -12,7 +12,7 @@ import ( packages_model "code.gitea.io/gitea/models/packages" packages_module "code.gitea.io/gitea/modules/packages" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/tempdir" ) var ( @@ -30,8 +30,12 @@ type BlobUploader struct { reading bool } -func buildFilePath(id string) string { - return util.FilePathJoinAbs(setting.Packages.ChunkedUploadPath, id) +func uploadPathTempDir() *tempdir.TempDir { + return setting.AppDataTempDir("package-upload") +} + +func buildFilePath(uploadPath *tempdir.TempDir, id string) string { + return uploadPath.JoinPath(id) } // NewBlobUploader creates a new blob uploader for the given id @@ -48,7 +52,12 @@ func NewBlobUploader(ctx context.Context, id string) (*BlobUploader, error) { } } - f, err := os.OpenFile(buildFilePath(model.ID), os.O_RDWR|os.O_CREATE, 0o666) + uploadPath := uploadPathTempDir() + _, err = uploadPath.MkdirAllSub("") + if err != nil { + return nil, err + } + f, err := os.OpenFile(buildFilePath(uploadPath, model.ID), os.O_RDWR|os.O_CREATE, 0o666) if err != nil { return nil, err } @@ -118,13 +127,13 @@ func (u *BlobUploader) Read(p []byte) (int, error) { return u.file.Read(p) } -// Remove deletes the data and the model of a blob upload +// RemoveBlobUploadByID Remove deletes the data and the model of a blob upload func RemoveBlobUploadByID(ctx context.Context, id string) error { if err := packages_model.DeleteBlobUploadByID(ctx, id); err != nil { return err } - err := os.Remove(buildFilePath(id)) + err := os.Remove(buildFilePath(uploadPathTempDir(), id)) if err != nil && !os.IsNotExist(err) { return err } diff --git a/services/repository/create.go b/services/repository/create.go index 897f2a1d141..c4a9dbb1b69 100644 --- a/services/repository/create.go +++ b/services/repository/create.go @@ -199,7 +199,10 @@ func initRepository(ctx context.Context, u *user_model.User, repo *repo_model.Re } // CreateRepositoryDirectly creates a repository for the user/organization. -func CreateRepositoryDirectly(ctx context.Context, doer, owner *user_model.User, opts CreateRepoOptions) (*repo_model.Repository, error) { +// if needsUpdateToReady is true, it will update the repository status to ready when success +func CreateRepositoryDirectly(ctx context.Context, doer, owner *user_model.User, + opts CreateRepoOptions, needsUpdateToReady bool, +) (*repo_model.Repository, error) { if !doer.CanCreateRepoIn(owner) { return nil, repo_model.ErrReachLimitOfRepo{ Limit: owner.MaxRepoCreation, @@ -243,8 +246,6 @@ func CreateRepositoryDirectly(ctx context.Context, doer, owner *user_model.User, ObjectFormatName: opts.ObjectFormatName, } - needsUpdateStatus := opts.Status != repo_model.RepositoryReady - // 1 - create the repository database operations first err := db.WithTx(ctx, func(ctx context.Context) error { return createRepositoryInDB(ctx, doer, owner, repo, false) @@ -318,7 +319,7 @@ func CreateRepositoryDirectly(ctx context.Context, doer, owner *user_model.User, } // 7 - update repository status to be ready - if needsUpdateStatus { + if needsUpdateToReady { repo.Status = repo_model.RepositoryReady if err = repo_model.UpdateRepositoryCols(ctx, repo, "status"); err != nil { return nil, fmt.Errorf("UpdateRepositoryCols: %w", err) diff --git a/services/repository/create_test.go b/services/repository/create_test.go index 9ecf404e8ba..8e3fdf88a55 100644 --- a/services/repository/create_test.go +++ b/services/repository/create_test.go @@ -25,7 +25,7 @@ func TestCreateRepositoryDirectly(t *testing.T) { createdRepo, err := CreateRepositoryDirectly(git.DefaultContext, user2, user2, CreateRepoOptions{ Name: "created-repo", - }) + }, true) assert.NoError(t, err) assert.NotNil(t, createdRepo) @@ -44,7 +44,7 @@ func TestCreateRepositoryDirectly(t *testing.T) { createdRepo2, err := CreateRepositoryDirectly(db.DefaultContext, user2, user2, CreateRepoOptions{ Name: "created-repo", - }) + }, true) assert.Nil(t, createdRepo2) assert.Error(t, err) diff --git a/services/repository/repository.go b/services/repository/repository.go index 90aad4f9d8b..e078a8fc3c3 100644 --- a/services/repository/repository.go +++ b/services/repository/repository.go @@ -47,7 +47,7 @@ type WebSearchResults struct { // CreateRepository creates a repository for the user/organization. func CreateRepository(ctx context.Context, doer, owner *user_model.User, opts CreateRepoOptions) (*repo_model.Repository, error) { - repo, err := CreateRepositoryDirectly(ctx, doer, owner, opts) + repo, err := CreateRepositoryDirectly(ctx, doer, owner, opts, true) if err != nil { // No need to rollback here we should do this in CreateRepository... return nil, err diff --git a/services/task/task.go b/services/task/task.go index 105aee2a25b..ee5fa1f348f 100644 --- a/services/task/task.go +++ b/services/task/task.go @@ -111,7 +111,7 @@ func CreateMigrateTask(ctx context.Context, doer, u *user_model.User, opts base. IsPrivate: opts.Private || setting.Repository.ForcePrivate, IsMirror: opts.Mirror, Status: repo_model.RepositoryBeingMigrated, - }) + }, false) if err != nil { task.EndTime = timeutil.TimeStampNow() task.Status = structs.TaskStatusFailed diff --git a/templates/repo/blame.tmpl b/templates/repo/blame.tmpl index 05f79612bdd..9596fe837ae 100644 --- a/templates/repo/blame.tmpl +++ b/templates/repo/blame.tmpl @@ -10,7 +10,7 @@ {{end}} {{end}} -