Skip to content

Commit 311050a

Browse files
committed
fix
1 parent 02e49a0 commit 311050a

File tree

4 files changed

+86
-5
lines changed

4 files changed

+86
-5
lines changed

custom/conf/app.example.ini

Lines changed: 1 addition & 0 deletions
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

Lines changed: 6 additions & 1 deletion
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

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,92 @@ 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+
// expires, need to use a new token
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)
28-
return
78+
if ret.LongPolling {
79+
http.Error(w, "Long polling is not allowed for anonymous users", http.StatusForbidden)
80+
}
81+
if ret.Expensive {
82+
inflightNum := inflightRequestNum.Add(1)
83+
defer inflightRequestNum.Add(-1)
84+
85+
if setting.Service.BlockAnonymousAccessExpensive {
86+
// strictly block the anonymous accesses to expensive pages, to save CPU
87+
http.Redirect(w, req, setting.AppSubURL+"/user/login", http.StatusSeeOther)
88+
return
89+
} else if int(inflightNum) > setting.Service.OverloadInflightAnonymousRequests {
90+
// be friendly to anonymous access (crawler, real anonymous user) to expensive pages, but limit the inflight requests
91+
if deferAnonymousRateLimitAccess(w, req) {
92+
return
93+
}
94+
}
2995
}
3096
}
3197
next.ServeHTTP(w, req)
@@ -44,6 +110,7 @@ func isRoutePathExpensive(routePattern string) bool {
44110
"/{username}/{reponame}/blame/",
45111
"/{username}/{reponame}/commit/",
46112
"/{username}/{reponame}/commits/",
113+
"/{username}/{reponame}/compare/",
47114
"/{username}/{reponame}/graph",
48115
"/{username}/{reponame}/media/",
49116
"/{username}/{reponame}/raw/",
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Server is busy, please wait for a few seconds .... or <a href="{{AppSubUrl}}/user/login">click here to sign in</a>.
2+
3+
<script>
4+
document.cookie = "{{.RateLimitCookieName}}={{.RateLimitTokenKey}}; path=/; domain={{.Domain}}; SameSite=Lax; Secure";
5+
setTimeout(() => {
6+
window.location.reload();
7+
}, {{.RateLimitRetryAfterMs}});
8+
</script>

0 commit comments

Comments
 (0)