diff --git a/internal/core/audit.go b/internal/core/audit.go index 18ffaf0..dfce55e 100644 --- a/internal/core/audit.go +++ b/internal/core/audit.go @@ -1,8 +1,6 @@ package core import ( - "encoding/base64" - "encoding/json" "net/http" "time" @@ -42,22 +40,14 @@ type logCursor struct { // encodeLogCursor packs a session position into an opaque, URL-safe token. func encodeLogCursor(startedAt time.Time, id int64) string { - b, _ := json.Marshal(logCursor{StartedAt: startedAt, ID: id}) - return base64.RawURLEncoding.EncodeToString(b) + return web.EncodeCursor(logCursor{StartedAt: startedAt, ID: id}) } // decodeLogCursor reverses encodeLogCursor. A missing or malformed token decodes // to nil — the first page — so a tampered URL just resets paging. func decodeLogCursor(tok string) *store.CommandLogCursor { - if tok == "" { - return nil - } - b, err := base64.RawURLEncoding.DecodeString(tok) - if err != nil { - return nil - } - var c logCursor - if json.Unmarshal(b, &c) != nil { + c, ok := web.DecodeCursor[logCursor](tok) + if !ok { return nil } return &store.CommandLogCursor{StartedAt: c.StartedAt, ID: c.ID} diff --git a/internal/marketing/orgs.go b/internal/marketing/orgs.go index 8730bc5..d13d092 100644 --- a/internal/marketing/orgs.go +++ b/internal/marketing/orgs.go @@ -1,8 +1,6 @@ package marketing import ( - "encoding/base64" - "encoding/json" "net/http" "strings" "time" @@ -178,24 +176,13 @@ type repCursor struct { } func encodeRepCursor(name string, id int64) string { - b, _ := json.Marshal(repCursor{Name: name, ID: id}) - return base64.RawURLEncoding.EncodeToString(b) + return web.EncodeCursor(repCursor{Name: name, ID: id}) } // decodeRepCursor reverses encodeRepCursor; a missing or malformed cursor decodes // to the first page ("", 0). func decodeRepCursor(tok string) (name string, id int64) { - if tok == "" { - return "", 0 - } - b, err := base64.RawURLEncoding.DecodeString(tok) - if err != nil { - return "", 0 - } - var c repCursor - if json.Unmarshal(b, &c) != nil { - return "", 0 - } + c, _ := web.DecodeCursor[repCursor](tok) return c.Name, c.ID } @@ -229,23 +216,11 @@ func nextOrgCursor(sort store.OrgSort, query string, last store.OrgSummary) orgC // encodeOrgCursor packs a directory position into an opaque, URL-safe token. func encodeOrgCursor(c orgCursor) string { - b, _ := json.Marshal(c) - return base64.RawURLEncoding.EncodeToString(b) + return web.EncodeCursor(c) } // decodeOrgCursor reverses encodeOrgCursor. A missing or malformed cursor decodes // to ok=false, i.e. the first page. func decodeOrgCursor(tok string) (orgCursor, bool) { - if tok == "" { - return orgCursor{}, false - } - b, err := base64.RawURLEncoding.DecodeString(tok) - if err != nil { - return orgCursor{}, false - } - var c orgCursor - if json.Unmarshal(b, &c) != nil { - return orgCursor{}, false - } - return c, true + return web.DecodeCursor[orgCursor](tok) } diff --git a/internal/web/cursor.go b/internal/web/cursor.go new file mode 100644 index 0000000..fccee56 --- /dev/null +++ b/internal/web/cursor.go @@ -0,0 +1,32 @@ +package web + +import ( + "encoding/base64" + "encoding/json" +) + +// EncodeCursor packs a keyset position into an opaque, URL-safe token. Callers +// pass a small named struct with short JSON tags (the wire form is not stable +// API — it only round-trips through the client's ?cursor/?before parameter). +func EncodeCursor[T any](pos T) string { + b, _ := json.Marshal(pos) + return base64.RawURLEncoding.EncodeToString(b) +} + +// DecodeCursor reverses EncodeCursor. A missing or malformed token decodes to the +// zero value with ok=false, so a tampered or stale URL just resets paging to the +// first page rather than erroring. +func DecodeCursor[T any](tok string) (pos T, ok bool) { + if tok == "" { + return pos, false + } + b, err := base64.RawURLEncoding.DecodeString(tok) + if err != nil { + return pos, false + } + if json.Unmarshal(b, &pos) != nil { + var zero T + return zero, false + } + return pos, true +} diff --git a/internal/web/cursor_test.go b/internal/web/cursor_test.go new file mode 100644 index 0000000..0f6969c --- /dev/null +++ b/internal/web/cursor_test.go @@ -0,0 +1,56 @@ +package web + +import ( + "testing" + "time" +) + +type testCursor struct { + Name string `json:"n"` + ID int64 `json:"i"` + At time.Time `json:"t"` +} + +func TestCursorRoundTrip(t *testing.T) { + want := testCursor{Name: "repeater-alpha", ID: 42, At: time.Unix(1_700_000_000, 0).UTC()} + got, ok := DecodeCursor[testCursor](EncodeCursor(want)) + if !ok { + t.Fatal("DecodeCursor: ok=false for a token we just encoded") + } + if got.Name != want.Name || got.ID != want.ID || !got.At.Equal(want.At) { + t.Fatalf("round trip mismatch: got %+v want %+v", got, want) + } +} + +func TestCursorTokenIsURLSafe(t *testing.T) { + // RawURLEncoding must not emit '+', '/', or '=' — the token rides in a query + // parameter unescaped. + tok := EncodeCursor(testCursor{Name: "a/b+c==", ID: 1}) + for _, r := range tok { + switch r { + case '+', '/', '=': + t.Fatalf("token %q contains non-URL-safe rune %q", tok, r) + } + } +} + +func TestDecodeCursorRejectsBadInput(t *testing.T) { + // A missing, malformed-base64, or malformed-JSON token must reset to the zero + // value with ok=false so a tampered URL just pages from the start. + cases := map[string]string{ + "empty": "", + "not base64": "!!!not base64!!!", + "base64 non-json": EncodeCursor([]byte("plain string, not this struct's JSON")), + } + for name, tok := range cases { + t.Run(name, func(t *testing.T) { + got, ok := DecodeCursor[testCursor](tok) + if ok { + t.Fatalf("ok=true for %s input %q", name, tok) + } + if got != (testCursor{}) { + t.Fatalf("non-zero cursor %+v for %s input", got, name) + } + }) + } +}