fix(admin): keep the filter query intact in console pager and export links
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
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.
This commit is contained in:
@@ -2,6 +2,7 @@ package adminconsole
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"html/template"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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.
|
// TestRendererUnknownPage reports an error for a page that does not exist.
|
||||||
func TestRendererUnknownPage(t *testing.T) {
|
func TestRendererUnknownPage(t *testing.T) {
|
||||||
r := MustNewRenderer()
|
r := MustNewRenderer()
|
||||||
|
|||||||
@@ -51,11 +51,14 @@ type UsersView struct {
|
|||||||
Items []UserRow
|
Items []UserRow
|
||||||
Pager Pager
|
Pager Pager
|
||||||
// Robots is the active people/robots toggle; NameMask/ExternalIDMask are the current
|
// 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
|
Robots bool
|
||||||
NameMask string
|
NameMask string
|
||||||
ExternalIDMask string
|
ExternalIDMask string
|
||||||
FilterQuery string
|
FilterQuery template.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserRow is one account row in the list. MoveMin/Avg/Max are the account's
|
// 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
|
// 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
|
// 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 {
|
type MessagesView struct {
|
||||||
Items []MessageRow
|
Items []MessageRow
|
||||||
Pager Pager
|
Pager Pager
|
||||||
@@ -85,7 +90,7 @@ type MessagesView struct {
|
|||||||
ExtMask string
|
ExtMask string
|
||||||
GameID string
|
GameID string
|
||||||
UserID string
|
UserID string
|
||||||
FilterQuery string
|
FilterQuery template.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// MessageRow is one chat message in the moderation list: its sender (linked to the user
|
// MessageRow is one chat message in the moderation list: its sender (linked to the user
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -108,7 +109,7 @@ func (s *Server) consoleUsers(c *gin.Context) {
|
|||||||
view := adminconsole.UsersView{
|
view := adminconsole.UsersView{
|
||||||
Pager: adminconsole.NewPager(page, adminPageSize, total),
|
Pager: adminconsole.NewPager(page, adminPageSize, total),
|
||||||
Robots: filter.Robots, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask,
|
Robots: filter.Robots, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask,
|
||||||
FilterQuery: q.Encode(),
|
FilterQuery: template.URL(q.Encode()),
|
||||||
}
|
}
|
||||||
ids := make([]uuid.UUID, 0, len(items))
|
ids := make([]uuid.UUID, 0, len(items))
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
@@ -174,7 +175,7 @@ func (s *Server) consoleMessages(c *gin.Context) {
|
|||||||
Pager: adminconsole.NewPager(page, adminPageSize, total),
|
Pager: adminconsole.NewPager(page, adminPageSize, total),
|
||||||
NameMask: filter.NameMask,
|
NameMask: filter.NameMask,
|
||||||
ExtMask: filter.ExtMask,
|
ExtMask: filter.ExtMask,
|
||||||
FilterQuery: q.Encode(),
|
FilterQuery: template.URL(q.Encode()),
|
||||||
}
|
}
|
||||||
if filter.GameID != uuid.Nil {
|
if filter.GameID != uuid.Nil {
|
||||||
view.GameID = filter.GameID.String()
|
view.GameID = filter.GameID.String()
|
||||||
|
|||||||
Reference in New Issue
Block a user