Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit fea3640

Browse files
committedApr 10, 2025
fix
1 parent 02e49a0 commit fea3640

File tree

4 files changed

+93
-4
lines changed

4 files changed

+93
-4
lines changed
 

‎custom/conf/app.example.ini

+1
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,7 @@ LEVEL = Info
780780
;; for example: block anonymous AI crawlers from accessing repo code pages.
781781
;; The "expensive" mode is experimental and subject to change.
782782
;REQUIRE_SIGNIN_VIEW = false
783+
;OVERLOAD_INFLIGHT_ANONYMOUS_REQUESTS =
783784
;;
784785
;; Mail notification
785786
;ENABLE_NOTIFY_MAIL = false

‎modules/setting/service.go

+6-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package setting
55

66
import (
77
"regexp"
8+
"runtime"
89
"strings"
910
"time"
1011

@@ -45,6 +46,8 @@ var Service = struct {
4546
ShowMilestonesDashboardPage bool
4647
RequireSignInViewStrict bool
4748
BlockAnonymousAccessExpensive bool
49+
BlockAnonymousAccessOverload bool
50+
OverloadInflightAnonymousRequests int
4851
EnableNotifyMail bool
4952
EnableBasicAuth bool
5053
EnablePasskeyAuth bool
@@ -164,10 +167,12 @@ func loadServiceFrom(rootCfg ConfigProvider) {
164167
// boolean values are considered as "strict"
165168
var err error
166169
Service.RequireSignInViewStrict, err = sec.Key("REQUIRE_SIGNIN_VIEW").Bool()
170+
Service.OverloadInflightAnonymousRequests = sec.Key("OVERLOAD_INFLIGHT_ANONYMOUS_REQUESTS").MustInt(4 * runtime.NumCPU())
167171
if s := sec.Key("REQUIRE_SIGNIN_VIEW").String(); err != nil && s != "" {
168172
// non-boolean value only supports "expensive" at the moment
169173
Service.BlockAnonymousAccessExpensive = s == "expensive"
170-
if !Service.BlockAnonymousAccessExpensive {
174+
Service.BlockAnonymousAccessOverload = s == "overload"
175+
if !Service.BlockAnonymousAccessExpensive && !Service.BlockAnonymousAccessOverload {
171176
log.Fatal("Invalid config option: REQUIRE_SIGNIN_VIEW = %s", s)
172177
}
173178
}

‎routers/common/blockexpensive.go

+71-3
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,94 @@ package common
66
import (
77
"net/http"
88
"strings"
9+
"sync/atomic"
10+
"time"
911

1012
user_model "code.gitea.io/gitea/models/user"
1113
"code.gitea.io/gitea/modules/reqctx"
1214
"code.gitea.io/gitea/modules/setting"
15+
"code.gitea.io/gitea/modules/templates"
16+
"code.gitea.io/gitea/modules/util"
1317
"code.gitea.io/gitea/modules/web/middleware"
18+
"code.gitea.io/gitea/services/context"
1419

1520
"github.com/go-chi/chi/v5"
21+
lru "github.com/hashicorp/golang-lru/v2"
1622
)
1723

24+
const tplStatus503RateLimit templates.TplName = "status/503_ratelimit"
25+
26+
type RateLimitToken struct {
27+
RetryAfter time.Time
28+
}
29+
1830
func BlockExpensive() func(next http.Handler) http.Handler {
19-
if !setting.Service.BlockAnonymousAccessExpensive {
31+
if !setting.Service.BlockAnonymousAccessExpensive && !setting.Service.BlockAnonymousAccessOverload {
2032
return nil
2133
}
34+
35+
tokenCache, _ := lru.New[string, RateLimitToken](10000)
36+
37+
deferAnonymousRateLimitAccess := func(w http.ResponseWriter, req *http.Request) bool {
38+
// * For a crawler: if it sees 503 error, it would retry later (they have their own queue), there is still a chance for them to read all pages
39+
// * For a real anonymous user: allocate a token, and let them wait for a while by browser JS (queue the request by browser)
40+
41+
const tokenCookieName = "gitea_arlt" // gitea anonymous rate limit token
42+
cookieToken, _ := req.Cookie(tokenCookieName)
43+
if cookieToken != nil && cookieToken.Value != "" {
44+
token, exist := tokenCache.Get(cookieToken.Value)
45+
if exist {
46+
if time.Now().After(token.RetryAfter) {
47+
// still valid
48+
tokenCache.Remove(cookieToken.Value)
49+
return false
50+
}
51+
// not reach RetryAfter time, so either remove the old one and allocate a new one, or keep using the old one
52+
// TODO: in the future, we could do better to allow more accesses for the same token, or extend the expiration time if the access seems well-behaved
53+
tokenCache.Remove(cookieToken.Value)
54+
}
55+
}
56+
57+
// TODO: merge the code with RenderPanicErrorPage
58+
tmplCtx := context.TemplateContext{}
59+
tmplCtx["Locale"] = middleware.Locale(w, req)
60+
ctxData := middleware.GetContextData(req.Context())
61+
62+
tokenKey, _ := util.CryptoRandomString(32)
63+
retryAfterDuration := 1 * time.Second
64+
token := RateLimitToken{RetryAfter: time.Now().Add(retryAfterDuration)}
65+
tokenCache.Add(tokenKey, token)
66+
ctxData["RateLimitTokenKey"] = tokenKey
67+
ctxData["RateLimitCookieName"] = tokenCookieName
68+
ctxData["RateLimitRetryAfterMs"] = retryAfterDuration.Milliseconds() + 100
69+
_ = templates.HTMLRenderer().HTML(w, http.StatusServiceUnavailable, tplStatus503RateLimit, ctxData, tmplCtx)
70+
return true
71+
}
72+
2273
return func(next http.Handler) http.Handler {
74+
inflightRequestNum := atomic.Int32{}
2375
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
2476
ret := determineRequestPriority(reqctx.FromContext(req.Context()))
2577
if !ret.SignedIn {
26-
if ret.Expensive || ret.LongPolling {
27-
http.Redirect(w, req, setting.AppSubURL+"/user/login", http.StatusSeeOther)
78+
if ret.LongPolling {
79+
http.Error(w, "Long polling is not allowed for anonymous users", http.StatusForbidden)
2880
return
2981
}
82+
if ret.Expensive {
83+
inflightNum := inflightRequestNum.Add(1)
84+
defer inflightRequestNum.Add(-1)
85+
86+
if setting.Service.BlockAnonymousAccessExpensive {
87+
// strictly block the anonymous accesses to expensive pages, to save CPU
88+
http.Redirect(w, req, setting.AppSubURL+"/user/login", http.StatusSeeOther)
89+
return
90+
} else if int(inflightNum) > setting.Service.OverloadInflightAnonymousRequests {
91+
// be friendly to anonymous access (crawler, real anonymous user) to expensive pages, but limit the inflight requests
92+
if deferAnonymousRateLimitAccess(w, req) {
93+
return
94+
}
95+
}
96+
}
3097
}
3198
next.ServeHTTP(w, req)
3299
})
@@ -44,6 +111,7 @@ func isRoutePathExpensive(routePattern string) bool {
44111
"/{username}/{reponame}/blame/",
45112
"/{username}/{reponame}/commit/",
46113
"/{username}/{reponame}/commits/",
114+
"/{username}/{reponame}/compare/",
47115
"/{username}/{reponame}/graph",
48116
"/{username}/{reponame}/media/",
49117
"/{username}/{reponame}/raw/",

‎templates/status/503_ratelimit.tmpl

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{{template "base/head" .}}
2+
<div role="main" aria-label="{{.Title}}" class="page-content">
3+
{{if .IsRepo}}{{template "repo/header" .}}{{end}}
4+
<div class="ui container">
5+
{{/*TODO: this page could be improved*/}}
6+
Server is busy, loading .... or <a href="{{AppSubUrl}}/user/login">click here to sign in</a>.
7+
<script>
8+
setTimeout(() => {
9+
document.cookie = "{{.RateLimitCookieName}}={{.RateLimitTokenKey}}; path=/";
10+
window.location.reload();
11+
}, {{.RateLimitRetryAfterMs}});
12+
</script>
13+
</div>
14+
</div>
15+
{{template "base/footer" .}}

0 commit comments

Comments
 (0)
Please sign in to comment.