From 9c0bbd13ddda26b08553c674718300533c5f148a Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 24 Aug 2026 14:02:46 +0000 Subject: [PATCH] feat: status page id validation and cache key --- server/internal/services/statuspages.go | 22 ++++++++++ server/internal/services/statuspages_test.go | 43 +++++++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/server/internal/services/statuspages.go b/server/internal/services/statuspages.go index 1a46d4b..64610e7 100644 --- a/server/internal/services/statuspages.go +++ b/server/internal/services/statuspages.go @@ -2,6 +2,8 @@ package services import ( "context" + "errors" + "regexp" "time" "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db" @@ -10,6 +12,26 @@ import ( "go.mongodb.org/mongo-driver/v2/mongo/options" ) +// ErrInvalidPageID is returned for any page id that would not be safe or +// pleasant in a URL handed to a customer. +var ErrInvalidPageID = errors.New("page id must be 3-40 characters of a-z, 0-9 and -, starting and ending alphanumeric") + +// The slug is operator-chosen rather than random because it is printed on +// support pages and typed by people. First and last characters are +// alphanumeric so a page id never reads as a flag or a trailing separator. +var pageIDRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$`) + +func ValidatePageID(id string) error { + if !pageIDRe.MatchString(id) { + return ErrInvalidPageID + } + return nil +} + +func statusCacheKey(instanceID, pageID string) string { + return "vantage:status:" + instanceID + ":" + pageID +} + func spCtx() (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), 5*time.Second) } diff --git a/server/internal/services/statuspages_test.go b/server/internal/services/statuspages_test.go index e72ddd8..7d7aead 100644 --- a/server/internal/services/statuspages_test.go +++ b/server/internal/services/statuspages_test.go @@ -1,6 +1,9 @@ package services -import "testing" +import ( + "strings" + "testing" +) // A new instance-scoped collection that is not in ScopedCollections leaves its // rows behind when the instance is deleted. This is the cheapest possible @@ -20,3 +23,41 @@ func TestStatusCollectionsAreScoped(t *testing.T) { } } } + +func TestValidatePageID(t *testing.T) { + valid := []string{"api", "prod-eu", "status2", "a1b", strings.Repeat("a", 40)} + for _, s := range valid { + if err := ValidatePageID(s); err != nil { + t.Errorf("ValidatePageID(%q) = %v, want nil", s, err) + } + } + + invalid := []string{ + "", // empty + "ab", // too short + strings.Repeat("a", 41), // too long + "-api", // leading hyphen + "api-", // trailing hyphen + "API", // uppercase + "my page", // space + "api_v2", // underscore + "api/v2", // path separator + "..", // dots + } + for _, s := range invalid { + if err := ValidatePageID(s); err == nil { + t.Errorf("ValidatePageID(%q) = nil, want error", s) + } + } +} + +func TestStatusCacheKeyIsScopedByInstance(t *testing.T) { + a := statusCacheKey("inst-a", "api") + b := statusCacheKey("inst-b", "api") + if a == b { + t.Fatalf("two instances share a cache key: %q", a) + } + if a != "vantage:status:inst-a:api" { + t.Fatalf("statusCacheKey = %q, want vantage:status:inst-a:api", a) + } +}