From c0487660758ddee613540b23c5965ff88132eedb Mon Sep 17 00:00:00 2001 From: Alex Dunmow Date: Wed, 17 Jun 2026 22:37:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(checks):=20add=20check=2028=20=E2=80=94=20?= =?UTF-8?q?public=20page=20handlers=20inject=20admin=20toolbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New AST check scans handler render functions that build site settings (getSiteSettings + doc["site_settings"]) and fails if they don't also set siteSettings["toolbar"], so admin-toolbar injection can't silently regress when a new public page type is added. - toolbar.go: AST scanner; exempts renderListingPage (auto SEO pages) and renderVersionPreview (carries its own preview banner). - check_toolbar.go: registration at Seq 280 (ID "28"). - registry_test.go: +1 expected check; golden fixtures regenerated. Co-Authored-By: Claude Opus 4.8 (1M context) --- check_toolbar.go | 37 +++++++++++ main.go | 1 + registry_test.go | 1 + testdata/golden/clean/expected.stdout | 3 + testdata/golden/nomod/expected.stdout | 3 + toolbar.go | 96 +++++++++++++++++++++++++++ 6 files changed, 141 insertions(+) create mode 100644 check_toolbar.go create mode 100644 toolbar.go diff --git a/check_toolbar.go b/check_toolbar.go new file mode 100644 index 0000000..6a0e0ac --- /dev/null +++ b/check_toolbar.go @@ -0,0 +1,37 @@ +package main + +import "fmt" + +func init() { + register(Check{ + Seq: 280, + ID: "28", + Title: "Public page handlers inject admin toolbar data", + Run: func(ctx *ScanContext, rep *Reporter) { + fmt.Println("=== Check 28: Public page handlers inject admin toolbar data ===") + var violations []toolbarViolation + for _, target := range ctx.backendTargets { + for _, v := range checkToolbarInjection(target.root) { + v.file = prefixDisplayPath(target.display, v.file) + violations = append(violations, v) + } + } + if len(violations) > 0 { + fmt.Printf(" FAIL: %d public page handler(s) missing toolbar injection:\n", len(violations)) + for _, v := range violations { + fmt.Printf(" %s:%d func %s — missing siteSettings[\"toolbar\"] for admin toolbar\n", v.file, v.line, v.funcName) + } + fmt.Println("\n Fix: Add auth check + toolbar injection between getSiteSettings() and doc[\"site_settings\"]:") + fmt.Println(" if claims, ok := auth.GetUserFromContext(ctx); ok && claims != nil {") + fmt.Println(" toolbarData := h.buildToolbarData(ctx, r, page, systemTemplate, pageTemplateKey)") + fmt.Println(" siteSettings[\"toolbar\"] = toolbarData") + fmt.Println(" }") + rep.Fail() + } else { + fmt.Println(" OK: All public page handlers inject admin toolbar data") + } + + fmt.Println() + }, + }) +} diff --git a/main.go b/main.go index 8447b6c..5f89d86 100644 --- a/main.go +++ b/main.go @@ -33,6 +33,7 @@ // 25. sqlc compile and buf generate must succeed for scanned roots that define them // 26. Orchestrator backend tests must pass when checking the core BlockNinja repo // 27. No hand-rolled HTML sanitization — use bluemonday +// 28. Public page handlers inject admin toolbar data package main import ( diff --git a/registry_test.go b/registry_test.go index 202f468..01d0c5b 100644 --- a/registry_test.go +++ b/registry_test.go @@ -18,6 +18,7 @@ func TestRegistryOrder(t *testing.T) { "1", "2", "2b", "2c", "2d", "2e", "2f", "3", "3b", "4", "5", "6", "7", "8", "9", "10", "10b", "10disc", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", + "28", } ordered := make([]Check, len(registry)) diff --git a/testdata/golden/clean/expected.stdout b/testdata/golden/clean/expected.stdout index 76776f4..6fd2522 100644 --- a/testdata/golden/clean/expected.stdout +++ b/testdata/golden/clean/expected.stdout @@ -87,3 +87,6 @@ === Check 22: No hand-rolled HTML sanitization (use bluemonday) === OK: No hand-rolled HTML sanitization detected +=== Check 28: Public page handlers inject admin toolbar data === + OK: All public page handlers inject admin toolbar data + diff --git a/testdata/golden/nomod/expected.stdout b/testdata/golden/nomod/expected.stdout index 3d56217..5942f93 100644 --- a/testdata/golden/nomod/expected.stdout +++ b/testdata/golden/nomod/expected.stdout @@ -108,3 +108,6 @@ === Check 22: No hand-rolled HTML sanitization (use bluemonday) === OK: No hand-rolled HTML sanitization detected +=== Check 28: Public page handlers inject admin toolbar data === + OK: All public page handlers inject admin toolbar data + diff --git a/toolbar.go b/toolbar.go new file mode 100644 index 0000000..2a23177 --- /dev/null +++ b/toolbar.go @@ -0,0 +1,96 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" +) + +type toolbarViolation struct { + file string + line int + funcName string +} + +// Functions that render full public pages but legitimately skip toolbar injection. +var toolbarExemptFuncs = map[string]bool{ + // Auto-generated SEO listing pages — not block-editable. + "renderListingPage": true, + // Historical version preview — has its own preview banner; toolbar edit/publish + // buttons would be misleading in that context. + "renderVersionPreview": true, +} + +// checkToolbarInjection scans handler Go files for public page render functions +// (identified by containing both getSiteSettings and doc["site_settings"]) and +// verifies each one also injects siteSettings["toolbar"] for the admin toolbar. +func checkToolbarInjection(root string) []toolbarViolation { + var violations []toolbarViolation + + handlersDir := filepath.Join(root, "internal", "handlers") + if _, err := os.Stat(handlersDir); err != nil { + return nil + } + + _ = filepath.Walk(handlersDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") { + return nil + } + if strings.HasSuffix(path, "_test.go") { + return nil + } + + src, err := os.ReadFile(path) + if err != nil { + return nil + } + srcText := string(src) + + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, path, src, 0) + if err != nil { + return nil + } + + relPath, _ := filepath.Rel(root, path) + + for _, decl := range f.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil { + continue + } + + if toolbarExemptFuncs[fn.Name.Name] { + continue + } + + bodyStart := fset.Position(fn.Body.Pos()).Offset + bodyEnd := fset.Position(fn.Body.End()).Offset + if bodyEnd > len(srcText) { + bodyEnd = len(srcText) + } + body := srcText[bodyStart:bodyEnd] + + hasSiteSettings := strings.Contains(body, "getSiteSettings") + hasDocAssign := strings.Contains(body, `doc["site_settings"]`) + + if hasSiteSettings && hasDocAssign { + hasToolbar := strings.Contains(body, `siteSettings["toolbar"]`) + if !hasToolbar { + violations = append(violations, toolbarViolation{ + file: relPath, + line: fset.Position(fn.Pos()).Line, + funcName: fn.Name.Name, + }) + } + } + } + + return nil + }) + + return violations +}