From 6b3abd9def453b4fd9ffcabba4fc8699ce1a6025 Mon Sep 17 00:00:00 2001 From: TowyTowy Date: Thu, 9 Jul 2026 12:43:12 +0200 Subject: [PATCH] fix(api): keep reverse-proxy path prefix in paging next url When gotify runs behind a reverse proxy that strips a subpath before forwarding (e.g. Caddy `uri strip_prefix /gotify`), the incoming request path no longer contains the prefix, so the `next` paging url emitted by GetMessages dropped it (e.g. `/message` instead of `/gotify/message`). Honor the X-Forwarded-Prefix header, which such proxies can set to announce the stripped prefix, when constructing the `next` url so it keeps pointing at the externally reachable path. Behavior is unchanged when the header is absent. Fixes #664 Co-Authored-By: Claude Fable 5 --- api/message.go | 16 +++++++++++++++- api/message_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/api/message.go b/api/message.go index ca818109..ee3c1ef0 100644 --- a/api/message.go +++ b/api/message.go @@ -105,7 +105,7 @@ func buildWithPaging(ctx *gin.Context, paging *pagingParams, messages []*model.M useMessages = messages[:len(messages)-1] since = useMessages[len(useMessages)-1].ID url := location.Get(ctx) - url.Path = ctx.Request.URL.Path + url.Path = withForwardedPrefix(ctx, ctx.Request.URL.Path) query := url.Query() query.Add("limit", strconv.Itoa(paging.Limit)) query.Add("since", strconv.FormatUint(uint64(since), 10)) @@ -118,6 +118,20 @@ func buildWithPaging(ctx *gin.Context, paging *pagingParams, messages []*model.M } } +// withForwardedPrefix prepends the reverse-proxy path prefix to the given +// request path. Reverse proxies that strip a subpath before forwarding the +// request (e.g. Caddy's `uri strip_prefix /gotify`) can announce the stripped +// prefix via the X-Forwarded-Prefix header, so that URLs emitted by gotify — +// like the paging `next` link — keep pointing at the externally reachable path +// instead of dropping the prefix. See #664. +func withForwardedPrefix(ctx *gin.Context, path string) string { + prefix := strings.Trim(ctx.GetHeader("X-Forwarded-Prefix"), "/") + if prefix == "" { + return path + } + return "/" + prefix + "/" + strings.TrimPrefix(path, "/") +} + func withPaging(ctx *gin.Context, f func(pagingParams *pagingParams)) { params := &pagingParams{Limit: 100} if err := ctx.MustBindWith(params, binding.Query); err == nil { diff --git a/api/message_test.go b/api/message_test.go index 2a1d4a2b..968318aa 100644 --- a/api/message_test.go +++ b/api/message_test.go @@ -106,6 +106,33 @@ func (s *MessageSuite) Test_GetMessages_WithLimit_ReturnsNext() { test.BodyEquals(s.T(), expected, s.recorder) } +func (s *MessageSuite) Test_GetMessages_WithLimit_BehindProxyPrefix_ReturnsNextWithPrefix() { + user := s.db.User(5) + app1 := user.App(1) + app2 := user.App(2) + var messages []*model.Message + for i := 100; i >= 1; i -= 2 { + one := app2.NewMessage(uint(i)) + two := app1.NewMessage(uint(i - 1)) + messages = append(messages, &one, &two) + } + + // The reverse proxy strips the /gotify prefix, so gotify receives /message, + // but announces the stripped prefix via X-Forwarded-Prefix. The emitted + // next url must keep the prefix. See #664. + s.withURL("http", "example.com", "/message", "limit=5") + s.ctx.Request.Header.Set("X-Forwarded-Prefix", "/gotify") + test.WithUser(s.ctx, 5) + s.a.GetMessages(s.ctx) + + expected := &model.PagedMessages{ + Paging: model.Paging{Limit: 5, Size: 5, Since: 96, Next: "http://example.com/gotify/message?limit=5&since=96"}, + Messages: toExternalMessages(messages[:5]), + } + + test.BodyEquals(s.T(), expected, s.recorder) +} + func (s *MessageSuite) Test_GetMessages_WithLimit_WithSince_ReturnsNext() { user := s.db.User(5) app1 := user.App(1)