From eeb078d52805e7c4110760f6d2b9f5a5d756f101 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 12 Jun 2026 11:38:26 +0200 Subject: [PATCH] fix(admin): keep the filter query intact in console pager and export links The paginated users and messages lists interpolate the pre-encoded filter query (url.Values.Encode) after the "?" in their pager and CSV-export links. There html/template treats it as a single query value and percent-encodes the structural "=" and "&" again, so "kind=robots" rendered as "kind%3drobots" and the multi-pair message filter collapsed -- every page step dropped the active filter. Type FilterQuery as template.URL so the already-escaped fragment is emitted verbatim (the attribute-level "&" -> "&" stays correct, the browser decodes it back). It is safe because url.Values.Encode output is strictly percent-encoded. games/complaints use status={{.Status}} -- a single value in proper query-value context -- and were never affected. --- backend/internal/adminconsole/render_test.go | 51 +++++++++++++++++++ backend/internal/adminconsole/views.go | 13 +++-- .../internal/server/handlers_admin_console.go | 5 +- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/backend/internal/adminconsole/render_test.go b/backend/internal/adminconsole/render_test.go index eb6ab9c..b266e47 100644 --- a/backend/internal/adminconsole/render_test.go +++ b/backend/internal/adminconsole/render_test.go @@ -2,6 +2,7 @@ package adminconsole import ( "bytes" + "html/template" "io/fs" "strings" "testing" @@ -55,6 +56,56 @@ func TestRendererRendersEveryPage(t *testing.T) { } } +// TestPagerLinksPreserveFilterQuery guards the paginated lists whose links carry a +// pre-encoded filter query (url.Values.Encode) past the page number. The query fragment +// must reach the link verbatim: the contextual escaper would otherwise re-encode its +// structural "=" and "&" (turning "kind=robots" into the broken "kind%3drobots"), dropping +// the active filter on every page step. FilterQuery is typed template.URL to prevent that. +func TestPagerLinksPreserveFilterQuery(t *testing.T) { + r := MustNewRenderer() + + t.Run("users", func(t *testing.T) { + var buf bytes.Buffer + view := UsersView{Robots: true, FilterQuery: template.URL("kind=robots"), Pager: NewPager(2, 50, 200)} + if err := r.Render(&buf, "users", PageData{Title: "Users", Data: view}); err != nil { + t.Fatalf("render users: %v", err) + } + out := buf.String() + for _, want := range []string{ + `href="/_gm/users?kind=robots&page=1"`, + `href="/_gm/users?kind=robots&page=3"`, + } { + if !strings.Contains(out, want) { + t.Errorf("users pager: missing %q in:\n%s", want, out) + } + } + if strings.Contains(out, "kind%3drobots") { + t.Error("users pager: filter query was double-encoded (kind%3drobots)") + } + }) + + t.Run("messages", func(t *testing.T) { + var buf bytes.Buffer + view := MessagesView{FilterQuery: template.URL("game=abc&user=def"), Pager: NewPager(2, 50, 200)} + if err := r.Render(&buf, "messages", PageData{Title: "Messages", Data: view}); err != nil { + t.Fatalf("render messages: %v", err) + } + out := buf.String() + for _, want := range []string{ + `href="/_gm/messages.csv?game=abc&user=def"`, + `href="/_gm/messages?game=abc&user=def&page=1"`, + `href="/_gm/messages?game=abc&user=def&page=3"`, + } { + if !strings.Contains(out, want) { + t.Errorf("messages pager: missing %q in:\n%s", want, out) + } + } + if strings.Contains(out, "%3d") || strings.Contains(out, "%26") { + t.Error("messages pager: filter query was double-encoded (%3d / %26)") + } + }) +} + // TestRendererUnknownPage reports an error for a page that does not exist. func TestRendererUnknownPage(t *testing.T) { r := MustNewRenderer() diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index d63ca1e..1018557 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -51,11 +51,14 @@ type UsersView struct { Items []UserRow Pager Pager // Robots is the active people/robots toggle; NameMask/ExternalIDMask are the current - // glob filters; FilterQuery is those encoded for pager/toggle links. + // glob filters; FilterQuery is those URL-encoded for the pager links. It is an + // already-escaped query fragment (url.Values.Encode), so it is typed template.URL to + // be emitted verbatim — interpolated as a plain string it would have its "=" and "&" + // percent-encoded again by the contextual escaper. Robots bool NameMask string ExternalIDMask string - FilterQuery string + FilterQuery template.URL } // UserRow is one account row in the list. MoveMin/Avg/Max are the account's @@ -77,7 +80,9 @@ type UserRow struct { // MessagesView is the paginated chat-message moderation list. NameMask/ExtMask are the // current sender glob filters; GameID/UserID pin the list to one game / sender (set from a -// game or user card); FilterQuery is the active filters encoded for the pager links. +// game or user card); FilterQuery is the active filters URL-encoded for the pager and CSV +// links — an already-escaped query fragment, hence template.URL so it is not re-encoded +// inside the link (see UsersView.FilterQuery). type MessagesView struct { Items []MessageRow Pager Pager @@ -85,7 +90,7 @@ type MessagesView struct { ExtMask string GameID string UserID string - FilterQuery string + FilterQuery template.URL } // MessageRow is one chat message in the moderation list: its sender (linked to the user diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index a75e4c7..e98c3f1 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/csv" "fmt" + "html/template" "net/http" "net/url" "path/filepath" @@ -108,7 +109,7 @@ func (s *Server) consoleUsers(c *gin.Context) { view := adminconsole.UsersView{ Pager: adminconsole.NewPager(page, adminPageSize, total), Robots: filter.Robots, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask, - FilterQuery: q.Encode(), + FilterQuery: template.URL(q.Encode()), } ids := make([]uuid.UUID, 0, len(items)) for _, it := range items { @@ -174,7 +175,7 @@ func (s *Server) consoleMessages(c *gin.Context) { Pager: adminconsole.NewPager(page, adminPageSize, total), NameMask: filter.NameMask, ExtMask: filter.ExtMask, - FilterQuery: q.Encode(), + FilterQuery: template.URL(q.Encode()), } if filter.GameID != uuid.Nil { view.GameID = filter.GameID.String() -- 2.52.0