Skip to content

Commit 84cbb6c

Browse files
authored
Fix duplicate sub-path for avatars (#31365)
Fix #31361, and add tests And this PR introduces an undocumented & debug-purpose-only config option: `USE_SUB_URL_PATH`. It does nothing for end users, it only helps the development of sub-path related problems. And also fix #31366 Co-authored-by: @ExplodingDragon
1 parent 2314749 commit 84cbb6c

File tree

13 files changed

+150
-40
lines changed

13 files changed

+150
-40
lines changed

custom/conf/app.example.ini

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ RUN_USER = ; git
8181
;; Overwrite the automatically generated public URL. Necessary for proxies and docker.
8282
;ROOT_URL = %(PROTOCOL)s://%(DOMAIN)s:%(HTTP_PORT)s/
8383
;;
84+
;; For development purpose only. It makes Gitea handle sub-path ("/sub-path/owner/repo/...") directly when debugging without a reverse proxy.
85+
;; DO NOT USE IT IN PRODUCTION!!!
86+
;USE_SUB_URL_PATH = false
87+
;;
8488
;; when STATIC_URL_PREFIX is empty it will follow ROOT_URL
8589
;STATIC_URL_PREFIX =
8690
;;

models/repo/avatar_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Copyright 2024 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package repo
5+
6+
import (
7+
"testing"
8+
9+
"code.gitea.io/gitea/models/db"
10+
"code.gitea.io/gitea/modules/setting"
11+
"code.gitea.io/gitea/modules/test"
12+
13+
"github.com/stretchr/testify/assert"
14+
)
15+
16+
func TestRepoAvatarLink(t *testing.T) {
17+
defer test.MockVariableValue(&setting.AppURL, "https://localhost/")()
18+
defer test.MockVariableValue(&setting.AppSubURL, "")()
19+
20+
repo := &Repository{ID: 1, Avatar: "avatar.png"}
21+
link := repo.AvatarLink(db.DefaultContext)
22+
assert.Equal(t, "https://localhost/repo-avatars/avatar.png", link)
23+
24+
setting.AppURL = "https://localhost/sub-path/"
25+
setting.AppSubURL = "/sub-path"
26+
link = repo.AvatarLink(db.DefaultContext)
27+
assert.Equal(t, "https://localhost/sub-path/repo-avatars/avatar.png", link)
28+
}

models/user/avatar.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,11 @@ func (u *User) AvatarLinkWithSize(ctx context.Context, size int) string {
8989
return avatars.GenerateEmailAvatarFastLink(ctx, u.AvatarEmail, size)
9090
}
9191

92-
// AvatarLink returns the full avatar url with http host. TODO: refactor it to a relative URL, but it is still used in API response at the moment
92+
// AvatarLink returns the full avatar url with http host.
93+
// TODO: refactor it to a relative URL, but it is still used in API response at the moment
9394
func (u *User) AvatarLink(ctx context.Context) string {
94-
return httplib.MakeAbsoluteURL(ctx, u.AvatarLinkWithSize(ctx, 0))
95+
relLink := u.AvatarLinkWithSize(ctx, 0) // it can't be empty
96+
return httplib.MakeAbsoluteURL(ctx, relLink)
9597
}
9698

9799
// IsUploadAvatarChanged returns true if the current user's avatar would be changed with the provided data

models/user/avatar_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Copyright 2024 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package user
5+
6+
import (
7+
"testing"
8+
9+
"code.gitea.io/gitea/models/db"
10+
"code.gitea.io/gitea/modules/setting"
11+
"code.gitea.io/gitea/modules/test"
12+
13+
"github.com/stretchr/testify/assert"
14+
)
15+
16+
func TestUserAvatarLink(t *testing.T) {
17+
defer test.MockVariableValue(&setting.AppURL, "https://localhost/")()
18+
defer test.MockVariableValue(&setting.AppSubURL, "")()
19+
20+
u := &User{ID: 1, Avatar: "avatar.png"}
21+
link := u.AvatarLink(db.DefaultContext)
22+
assert.Equal(t, "https://localhost/avatars/avatar.png", link)
23+
24+
setting.AppURL = "https://localhost/sub-path/"
25+
setting.AppSubURL = "/sub-path"
26+
link = u.AvatarLink(db.DefaultContext)
27+
assert.Equal(t, "https://localhost/sub-path/avatars/avatar.png", link)
28+
}

modules/httplib/url.go

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,16 @@ func getForwardedHost(req *http.Request) string {
5757
return req.Header.Get("X-Forwarded-Host")
5858
}
5959

60-
// GuessCurrentAppURL tries to guess the current full URL by http headers. It always has a '/' suffix, exactly the same as setting.AppURL
60+
// GuessCurrentAppURL tries to guess the current full app URL (with sub-path) by http headers. It always has a '/' suffix, exactly the same as setting.AppURL
6161
func GuessCurrentAppURL(ctx context.Context) string {
62+
return GuessCurrentHostURL(ctx) + setting.AppSubURL + "/"
63+
}
64+
65+
// GuessCurrentHostURL tries to guess the current full host URL (no sub-path) by http headers, there is no trailing slash.
66+
func GuessCurrentHostURL(ctx context.Context) string {
6267
req, ok := ctx.Value(RequestContextKey).(*http.Request)
6368
if !ok {
64-
return setting.AppURL
69+
return strings.TrimSuffix(setting.AppURL, setting.AppSubURL+"/")
6570
}
6671
// If no scheme provided by reverse proxy, then do not guess the AppURL, use the configured one.
6772
// At the moment, if site admin doesn't configure the proxy headers correctly, then Gitea would guess wrong.
@@ -74,20 +79,27 @@ func GuessCurrentAppURL(ctx context.Context) string {
7479
// So in the future maybe it should introduce a new config option, to let site admin decide how to guess the AppURL.
7580
reqScheme := getRequestScheme(req)
7681
if reqScheme == "" {
77-
return setting.AppURL
82+
return strings.TrimSuffix(setting.AppURL, setting.AppSubURL+"/")
7883
}
7984
reqHost := getForwardedHost(req)
8085
if reqHost == "" {
8186
reqHost = req.Host
8287
}
83-
return reqScheme + "://" + reqHost + setting.AppSubURL + "/"
88+
return reqScheme + "://" + reqHost
8489
}
8590

86-
func MakeAbsoluteURL(ctx context.Context, s string) string {
87-
if IsRelativeURL(s) {
88-
return GuessCurrentAppURL(ctx) + strings.TrimPrefix(s, "/")
91+
// MakeAbsoluteURL tries to make a link to an absolute URL:
92+
// * If link is empty, it returns the current app URL.
93+
// * If link is absolute, it returns the link.
94+
// * Otherwise, it returns the current host URL + link, the link itself should have correct sub-path (AppSubURL) if needed.
95+
func MakeAbsoluteURL(ctx context.Context, link string) string {
96+
if link == "" {
97+
return GuessCurrentAppURL(ctx)
98+
}
99+
if !IsRelativeURL(link) {
100+
return link
89101
}
90-
return s
102+
return GuessCurrentHostURL(ctx) + "/" + strings.TrimPrefix(link, "/")
91103
}
92104

93105
func IsCurrentGiteaSiteURL(ctx context.Context, s string) bool {

modules/httplib/url_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,22 +46,22 @@ func TestMakeAbsoluteURL(t *testing.T) {
4646

4747
ctx := context.Background()
4848
assert.Equal(t, "http://cfg-host/sub/", MakeAbsoluteURL(ctx, ""))
49-
assert.Equal(t, "http://cfg-host/sub/foo", MakeAbsoluteURL(ctx, "foo"))
50-
assert.Equal(t, "http://cfg-host/sub/foo", MakeAbsoluteURL(ctx, "/foo"))
49+
assert.Equal(t, "http://cfg-host/foo", MakeAbsoluteURL(ctx, "foo"))
50+
assert.Equal(t, "http://cfg-host/foo", MakeAbsoluteURL(ctx, "/foo"))
5151
assert.Equal(t, "http://other/foo", MakeAbsoluteURL(ctx, "http://other/foo"))
5252

5353
ctx = context.WithValue(ctx, RequestContextKey, &http.Request{
5454
Host: "user-host",
5555
})
56-
assert.Equal(t, "http://cfg-host/sub/foo", MakeAbsoluteURL(ctx, "/foo"))
56+
assert.Equal(t, "http://cfg-host/foo", MakeAbsoluteURL(ctx, "/foo"))
5757

5858
ctx = context.WithValue(ctx, RequestContextKey, &http.Request{
5959
Host: "user-host",
6060
Header: map[string][]string{
6161
"X-Forwarded-Host": {"forwarded-host"},
6262
},
6363
})
64-
assert.Equal(t, "http://cfg-host/sub/foo", MakeAbsoluteURL(ctx, "/foo"))
64+
assert.Equal(t, "http://cfg-host/foo", MakeAbsoluteURL(ctx, "/foo"))
6565

6666
ctx = context.WithValue(ctx, RequestContextKey, &http.Request{
6767
Host: "user-host",
@@ -70,7 +70,7 @@ func TestMakeAbsoluteURL(t *testing.T) {
7070
"X-Forwarded-Proto": {"https"},
7171
},
7272
})
73-
assert.Equal(t, "https://forwarded-host/sub/foo", MakeAbsoluteURL(ctx, "/foo"))
73+
assert.Equal(t, "https://forwarded-host/foo", MakeAbsoluteURL(ctx, "/foo"))
7474
}
7575

7676
func TestIsCurrentGiteaSiteURL(t *testing.T) {

modules/setting/global.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Copyright 2024 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package setting
5+
6+
// Global settings
7+
var (
8+
// RunUser is the OS user that Gitea is running as. ini:"RUN_USER"
9+
RunUser string
10+
// RunMode is the running mode of Gitea, it only accepts two values: "dev" and "prod".
11+
// Non-dev values will be replaced by "prod". ini: "RUN_MODE"
12+
RunMode string
13+
// IsProd is true if RunMode is not "dev"
14+
IsProd bool
15+
16+
// AppName is the Application name, used in the page title. ini: "APP_NAME"
17+
AppName string
18+
)

modules/setting/server.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,16 @@ const (
4040
LandingPageLogin LandingPage = "/user/login"
4141
)
4242

43+
// Server settings
4344
var (
44-
// AppName is the Application name, used in the page title.
45-
// It maps to ini:"APP_NAME"
46-
AppName string
4745
// AppURL is the Application ROOT_URL. It always has a '/' suffix
4846
// It maps to ini:"ROOT_URL"
4947
AppURL string
5048
// AppSubURL represents the sub-url mounting point for gitea. It is either "" or starts with '/' and ends without '/', such as '/{subpath}'.
5149
// This value is empty if site does not have sub-url.
5250
AppSubURL string
51+
// UseSubURLPath makes Gitea handle requests with sub-path like "/sub-path/owner/repo/...", to make it easier to debug sub-path related problems without a reverse proxy.
52+
UseSubURLPath bool
5353
// AppDataPath is the default path for storing data.
5454
// It maps to ini:"APP_DATA_PATH" in [server] and defaults to AppWorkPath + "/data"
5555
AppDataPath string
@@ -59,8 +59,6 @@ var (
5959
// AssetVersion holds a opaque value that is used for cache-busting assets
6060
AssetVersion string
6161

62-
// Server settings
63-
6462
Protocol Scheme
6563
UseProxyProtocol bool // `ini:"USE_PROXY_PROTOCOL"`
6664
ProxyProtocolTLSBridging bool //`ini:"PROXY_PROTOCOL_TLS_BRIDGING"`
@@ -275,9 +273,10 @@ func loadServerFrom(rootCfg ConfigProvider) {
275273
// This should be TrimRight to ensure that there is only a single '/' at the end of AppURL.
276274
AppURL = strings.TrimRight(appURL.String(), "/") + "/"
277275

278-
// Suburl should start with '/' and end without '/', such as '/{subpath}'.
276+
// AppSubURL should start with '/' and end without '/', such as '/{subpath}'.
279277
// This value is empty if site does not have sub-url.
280278
AppSubURL = strings.TrimSuffix(appURL.Path, "/")
279+
UseSubURLPath = sec.Key("USE_SUB_URL_PATH").MustBool(false)
281280
StaticURLPrefix = strings.TrimSuffix(sec.Key("STATIC_URL_PREFIX").MustString(AppSubURL), "/")
282281

283282
// Check if Domain differs from AppURL domain than update it to AppURL's domain

modules/setting/setting.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,7 @@ var (
2525
// AppStartTime store time gitea has started
2626
AppStartTime time.Time
2727

28-
// Other global setting objects
29-
3028
CfgProvider ConfigProvider
31-
RunMode string
32-
RunUser string
33-
IsProd bool
3429
IsWindows bool
3530

3631
// IsInTesting indicates whether the testing is running. A lot of unreliable code causes a lot of nonsense error logs during testing

routers/api/packages/container/container.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ func apiErrorDefined(ctx *context.Context, err *namedError) {
117117

118118
func apiUnauthorizedError(ctx *context.Context) {
119119
// container registry requires that the "/v2" must be in the root, so the sub-path in AppURL should be removed
120-
realmURL := strings.TrimSuffix(httplib.GuessCurrentAppURL(ctx), setting.AppSubURL+"/") + "/v2/token"
120+
realmURL := httplib.GuessCurrentHostURL(ctx) + "/v2/token"
121121
ctx.Resp.Header().Add("WWW-Authenticate", `Bearer realm="`+realmURL+`",service="container_registry",scope="*"`)
122122
apiErrorDefined(ctx, errUnauthorized)
123123
}

routers/common/middleware.go

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import (
2525
// ProtocolMiddlewares returns HTTP protocol related middlewares, and it provides a global panic recovery
2626
func ProtocolMiddlewares() (handlers []any) {
2727
// first, normalize the URL path
28-
handlers = append(handlers, stripSlashesMiddleware)
28+
handlers = append(handlers, normalizeRequestPathMiddleware)
2929

3030
// prepare the ContextData and panic recovery
3131
handlers = append(handlers, func(next http.Handler) http.Handler {
@@ -75,9 +75,9 @@ func ProtocolMiddlewares() (handlers []any) {
7575
return handlers
7676
}
7777

78-
func stripSlashesMiddleware(next http.Handler) http.Handler {
78+
func normalizeRequestPathMiddleware(next http.Handler) http.Handler {
7979
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
80-
// First of all escape the URL RawPath to ensure that all routing is done using a correctly escaped URL
80+
// escape the URL RawPath to ensure that all routing is done using a correctly escaped URL
8181
req.URL.RawPath = req.URL.EscapedPath()
8282

8383
urlPath := req.URL.RawPath
@@ -86,19 +86,42 @@ func stripSlashesMiddleware(next http.Handler) http.Handler {
8686
urlPath = rctx.RoutePath
8787
}
8888

89-
sanitizedPath := &strings.Builder{}
90-
prevWasSlash := false
91-
for _, chr := range strings.TrimRight(urlPath, "/") {
92-
if chr != '/' || !prevWasSlash {
93-
sanitizedPath.WriteRune(chr)
89+
normalizedPath := strings.TrimRight(urlPath, "/")
90+
// the following code block is a slow-path for replacing all repeated slashes "//" to one single "/"
91+
// if the path doesn't have repeated slashes, then no need to execute it
92+
if strings.Contains(normalizedPath, "//") {
93+
buf := &strings.Builder{}
94+
prevWasSlash := false
95+
for _, chr := range normalizedPath {
96+
if chr != '/' || !prevWasSlash {
97+
buf.WriteRune(chr)
98+
}
99+
prevWasSlash = chr == '/'
100+
}
101+
normalizedPath = buf.String()
102+
}
103+
104+
if setting.UseSubURLPath {
105+
remainingPath, ok := strings.CutPrefix(normalizedPath, setting.AppSubURL+"/")
106+
if ok {
107+
normalizedPath = "/" + remainingPath
108+
} else if normalizedPath == setting.AppSubURL {
109+
normalizedPath = "/"
110+
} else if !strings.HasPrefix(normalizedPath+"/", "/v2/") {
111+
// do not respond to other requests, to simulate a real sub-path environment
112+
http.Error(resp, "404 page not found, sub-path is: "+setting.AppSubURL, http.StatusNotFound)
113+
return
94114
}
95-
prevWasSlash = chr == '/'
115+
// TODO: it's not quite clear about how req.URL and rctx.RoutePath work together.
116+
// Fortunately, it is only used for debug purpose, we have enough time to figure it out in the future.
117+
req.URL.RawPath = normalizedPath
118+
req.URL.Path = normalizedPath
96119
}
97120

98121
if rctx == nil {
99-
req.URL.Path = sanitizedPath.String()
122+
req.URL.Path = normalizedPath
100123
} else {
101-
rctx.RoutePath = sanitizedPath.String()
124+
rctx.RoutePath = normalizedPath
102125
}
103126
next.ServeHTTP(resp, req)
104127
})

routers/common/middleware_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ func TestStripSlashesMiddleware(t *testing.T) {
6161
})
6262

6363
// pass the test middleware to validate the changes
64-
handlerToTest := stripSlashesMiddleware(testMiddleware)
64+
handlerToTest := normalizeRequestPathMiddleware(testMiddleware)
6565
// create a mock request to use
6666
req := httptest.NewRequest("GET", tt.inputPath, nil)
6767
// call the handler using a mock response recorder

services/pull/merge.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
user_model "code.gitea.io/gitea/models/user"
2424
"code.gitea.io/gitea/modules/cache"
2525
"code.gitea.io/gitea/modules/git"
26+
"code.gitea.io/gitea/modules/httplib"
2627
"code.gitea.io/gitea/modules/log"
2728
"code.gitea.io/gitea/modules/references"
2829
repo_module "code.gitea.io/gitea/modules/repository"
@@ -56,7 +57,7 @@ func getMergeMessage(ctx context.Context, baseGitRepo *git.Repository, pr *issue
5657
issueReference = "!"
5758
}
5859

59-
reviewedOn := fmt.Sprintf("Reviewed-on: %s/%s", setting.AppURL, pr.Issue.Link())
60+
reviewedOn := fmt.Sprintf("Reviewed-on: %s", httplib.MakeAbsoluteURL(ctx, pr.Issue.Link()))
6061
reviewedBy := pr.GetApprovers(ctx)
6162

6263
if mergeStyle != "" {

0 commit comments

Comments
 (0)