package cabana import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "reflect" "slices" "strconv" "strings" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/boardwalk" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" "git.golem15.com/golem15/summercms/modules/phrasebook" "gorm.io/gorm" ) // Routes is the raw admin API and SPA mounted by surf.BuildRouter. Prefix is // the normalized backend.uri every admin route lives under. type Routes struct { Middleware pact.Middleware Mount func(r pact.Router) Prefix string } type service struct { app *backpack.App reg *Registry secret string ttl time.Duration refreshTTL time.Duration grace time.Duration bcryptCost int loginMax int loginDecay int issuer string bl bouncer.BlacklistStore users bouncer.UserProvider // the backend guard's provider, reused by refresh prefix string spa http.Handler // insecureCookie drops Secure from the admin cookie (backend.cookie_secure // false, development only); the zero value keeps the cookie Secure. insecureCookie bool } // adminPrefix returns the mount path; a zero service uses the default. func (s *service) adminPrefix() string { if s == nil || s.prefix == "" { return DefaultAdminPrefix } return s.prefix } // apiBase is the admin API root: the prefix plus /api/v1 (D-03). func (s *service) apiBase() string { return s.adminPrefix() + adminAPIVersion } // Activate compiles admin controllers and, when any exist, requires // admin.jwt.secret. No controllers means no admin routes and no secret check. func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) { items, err := collectControllers(plugins) if err != nil { return nil, err } if len(items) == 0 { return nil, nil } if err := checkReservedSegments(items); err != nil { return nil, err } secret, err := adminSecret(app) if err != nil { return nil, err } prefix, err := AdminPrefix(app) if err != nil { return nil, err } secureCookie, err := adminCookieSecure(app) if err != nil { return nil, err } reg, err := compileRegistry(items) if err != nil { return nil, err } if err := compileContributions(reg, plugins); err != nil { return nil, err } if app == nil { return nil, errors.New("cabana: app is nil") } if tr, ok := app.Lookup[*phrasebook.Translator](); ok && tr != nil { if err := validateMessageKeys(reg, tr); err != nil { return nil, err } } guards, ok := app.Lookup[*bouncer.Registry]() if !ok || guards == nil { guards = bouncer.NewRegistry() if err := app.Publish(guards); err != nil { return nil, err } } bl := adminBlacklist(app) users := lazyBackendUsers{app: app, reg: reg} guard := bouncer.NewBackendJWTGuard(secret, users, bl, writeUnauthenticated, AdminCookieName) // The admin API is only as strong as this guard (audience, secret, user // provider), so cabana always registers its own and never mounts the API // behind a guard another plugin already put under the name "backend": a // taken name fails boot instead of silently replacing admin authentication. // Activating twice on one application (a test assembling two handlers) // finds cabana's own guard and keeps it. if owner, taken := guards.Owner("backend"); !taken { if err := guards.Register("summercms.cabana", "backend", guard); err != nil { return nil, fmt.Errorf("cabana: backend guard: %w", err) } } else if owner != "summercms.cabana" { return nil, fmt.Errorf("cabana: backend guard: guard %q is already registered by %s; the admin API needs its own audience-checking guard under that name", "backend", owner) } mw, err := guards.Middleware("backend") if err != nil { return nil, err } loginMax, loginDecay := adminLoginWindow(app) svc := &service{ app: app, reg: reg, secret: secret, ttl: adminTTL(app), refreshTTL: adminRefreshTTL(app), grace: adminGrace(app), bcryptCost: adminBcryptCost(app), loginMax: loginMax, loginDecay: loginDecay, issuer: adminIssuer(app, prefix), bl: bl, users: users, prefix: prefix, insecureCookie: !secureCookie, } spa, err := boardwalk.Handler(prefix, http.HandlerFunc(writeNotFound)) if err != nil { return nil, fmt.Errorf("cabana: admin SPA: %w", err) } svc.spa = spa return &Routes{Middleware: mw, Mount: svc.mount, Prefix: prefix}, nil } func writeNotFound(w http.ResponseWriter, _ *http.Request) { WriteError(w, http.StatusNotFound, "not_found", msgNotFound) } // serveSPA answers GET {prefix} and GET {prefix}/{path...} from the embedded // build. API paths that no route matched fall through to it and receive the // D-10 not_found envelope, never index.html. func (s *service) serveSPA(w http.ResponseWriter, r *http.Request) { if s == nil || s.spa == nil { writeNotFound(w, r) return } s.spa.ServeHTTP(w, r) } func writeUnauthenticated(w http.ResponseWriter, _ error) { WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated) } type lazyBackendUsers struct { app *backpack.App reg *Registry } func (p lazyBackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) { if p.app == nil { return nil, errors.New("cabana: database is not configured") } db, ok := p.app.Lookup[*gorm.DB]() if !ok || db == nil { return nil, errors.New("cabana: database is not configured") } return (BackendUsers{DB: db, Registry: p.reg}).FindByID(ctx, id) } func (s *service) mount(r pact.Router) { throttle := fmt.Sprintf("throttle:%d,%d", s.loginMax, s.loginDecay) api := s.apiBase() r.GroupRaw(api+"/auth", nil, func(g pact.Router) { // Login is exempt from the CSRF header: without it the response is a // Bearer body and no cookie is set, so a cross-site post gains nothing. g.Post("/login", s.login, throttle) g.Post("/refresh", requireAjax(s.refresh)) // Logout reads and verifies the token itself (see service.logout), so // it is mounted outside the backend guard, which rejects an expired // access token that is still refreshable. g.Post("/logout", requireAjax(s.logout)) }) // The string bundle is public: the login screen needs it before auth. r.GroupRaw(api, nil, func(g pact.Router) { g.Get("/lang", s.langBundle) }) r.GroupRaw(api, []string{"backend"}, func(g pact.Router) { g.Get("/auth/me", s.me) g.Get("/navigation", s.navigation) g.Get("/settings", s.settingsList) g.Get("/settings/{code}/schema", s.settingsSchema) constrainSetting(g) g.Get("/settings/{code}", s.settingsGet) constrainSetting(g) g.Put("/settings/{code}", requireAjax(s.settingsPut)) constrainSetting(g) g.Get("/{vendor}/{plugin}/{controller}/schema/list", s.listSchema) constrainController(g) g.Get("/{vendor}/{plugin}/{controller}/schema/form", s.formSchema) constrainController(g) g.Get("/{vendor}/{plugin}/{controller}/schema/relation/{name}", s.relationSchema) constrainRelation(g) g.Get("/{vendor}/{plugin}/{controller}", s.list) constrainController(g) g.Post("/{vendor}/{plugin}/{controller}", requireAjax(s.create)) constrainController(g) g.Post("/{vendor}/{plugin}/{controller}/bulk-delete", requireAjax(s.bulkDelete)) constrainController(g) // Runtime extension actions (Phase 10.1): cabana owns these routes, so // CSRF, auth and record scoping never depend on plugin code. g.Post("/{vendor}/{plugin}/{controller}/widgets/{field}", requireAjax(s.widgetAction)) constrainController(g) g.Where("field", "[A-Za-z_][A-Za-z0-9_]*") g.Post("/{vendor}/{plugin}/{controller}/toolbar/{action}", requireAjax(s.toolbarAction)) constrainController(g) g.Where("action", "[A-Za-z_][A-Za-z0-9_]*") g.Get("/{vendor}/{plugin}/{controller}/partials/{name}", s.partial) constrainRelation(g) g.Get("/{vendor}/{plugin}/{controller}/{id}", s.show) constrainController(g) g.Put("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.update)) constrainController(g) g.Delete("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.deleteRecord)) constrainController(g) // Six-segment GET routes share one pattern: ServeMux rejects the // relation list next to the field options route (neither is more // specific), so nestedGet dispatches on the literal segments. g.Get("/{vendor}/{plugin}/{controller}/{id}/{segment}/{name}", s.nestedGet) constrainNested(g) g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", s.relationCandidates) constrainRelation(g) g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", requireAjax(s.relationLink)) constrainRelation(g) g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", requireAjax(s.relationUnlink)) constrainRelation(g) }) // The SPA shell: public, no guard. ServeMux prefers every API pattern // above over the {path...} wildcard. r.GroupRaw(s.adminPrefix(), nil, func(g pact.Router) { // Declared plugin JS and CSS (D-16); a miss falls through to the SPA, // which serves its own dist assets under the same /assets/ path. g.Get("/assets/{vendor}/{plugin}/{file...}", s.pluginAsset) g.Where("vendor", "[A-Za-z0-9_-]+") g.Where("plugin", "[A-Za-z0-9_-]+") g.Get("", s.serveSPA) g.Get("/{path...}", s.serveSPA) }) } func constrainController(g pact.Router) { g.Where("vendor", "[A-Za-z0-9_-]+") g.Where("plugin", "[A-Za-z0-9_-]+") g.Where("controller", "[A-Za-z0-9_-]+") } func constrainRelation(g pact.Router) { constrainController(g) g.Where("name", "[A-Za-z_][A-Za-z0-9_]*") } func constrainNested(g pact.Router) { constrainController(g) g.Where("segment", "[A-Za-z_][A-Za-z0-9_]*") g.Where("name", "[A-Za-z_][A-Za-z0-9_]*") } // nestedGet serves the logical routes // // GET /{vendor}/{plugin}/{controller}/{id}/relations/{name} // GET /{vendor}/{plugin}/{controller}/fields/{field}/options // GET /{vendor}/{plugin}/{controller}/filters/{scope}/options // // A numeric id never equals a literal segment, so the dispatch is unambiguous. // Anything else is the D-10 not_found envelope, as an unmatched API path. func (s *service) nestedGet(w http.ResponseWriter, r *http.Request) { id, segment, name := r.PathValue("id"), r.PathValue("segment"), r.PathValue("name") switch { case id == "fields" && name == "options": r.SetPathValue("field", segment) s.fieldOptions(w, r) case id == "filters" && name == "options": r.SetPathValue("scope", segment) s.filterOptions(w, r) case segment == "relations": s.relationLinked(w, r) default: writeNotFound(w, r) } } func constrainSetting(g pact.Router) { g.Where("code", "[A-Za-z_][A-Za-z0-9_-]*") } func (s *service) navigation(w http.ResponseWriter, r *http.Request) { principal, ok := bouncer.User(r.Context()) if !ok || principal == nil || !principal.Backend { WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated) return } navigation, _ := s.reg.Metadata(r.Context(), principal, s.translator()) WriteData(w, http.StatusOK, navigation, map[string]any{"locale": schemaLocale(r.Context(), s.translator())}) } func (s *service) settingsList(w http.ResponseWriter, r *http.Request) { principal, ok := bouncer.User(r.Context()) if !ok || principal == nil || !principal.Backend { WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated) return } _, settings := s.reg.Metadata(r.Context(), principal, s.translator()) WriteData(w, http.StatusOK, settings, map[string]any{"locale": schemaLocale(r.Context(), s.translator())}) } func (s *service) settingsSchema(w http.ResponseWriter, r *http.Request) { s.protectSetting(w, r, func(setting *CompiledSetting) { view, err := setting.Form.Localize(r.Context(), s.translator(), nil) if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } WriteData(w, http.StatusOK, view, map[string]any{"locale": view.Meta.Locale}) }) } func (s *service) settingsGet(w http.ResponseWriter, r *http.Request) { s.protectSetting(w, r, func(setting *CompiledSetting) { db, err := s.db() if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } result, err := (SettingsService{DB: db}).Get(r.Context(), setting) if err != nil { writeCRUDError(w, err) return } WriteData(w, http.StatusOK, result, nil) }) } func (s *service) settingsPut(w http.ResponseWriter, r *http.Request) { s.protectSetting(w, r, func(setting *CompiledSetting) { body, err := decodeObject(r) if err != nil { writeCRUDError(w, err) return } db, err := s.db() if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } result, err := (SettingsService{DB: db}).Put(r.Context(), setting, body) if err != nil { writeCRUDError(w, err) return } WriteData(w, http.StatusOK, result, nil) }) } func (s *service) protectSetting(w http.ResponseWriter, r *http.Request, fn func(*CompiledSetting)) { principal, ok := bouncer.User(r.Context()) if !ok || principal == nil || !principal.Backend { WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated) return } setting, exists := s.reg.Setting(r.PathValue("code")) if !exists { WriteError(w, http.StatusNotFound, "not_found", msgNotFound) return } if !Allows(principal, setting.Item.Permissions) { WriteError(w, http.StatusForbidden, "forbidden", msgForbidden) return } fn(setting) } func (s *service) relationSchema(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { cr, err := relationOf(cc, r.PathValue("name")) if err != nil { writeCRUDError(w, err) return } tr := s.translator() view := cr.Schema.Localize(r.Context(), tr) meta := map[string]any{} if locale := schemaLocale(r.Context(), tr); locale != "" { meta["locale"] = locale } WriteData(w, http.StatusOK, view, meta) }) } func relationQueryFromRequest(r *http.Request) RelationQuery { q := r.URL.Query() return RelationQuery{Search: q.Get("search"), Sort: q.Get("sort"), Dir: q.Get("dir"), Page: q.Get("page"), PerPage: q.Get("per_page")} } func (s *service) relationLinked(w http.ResponseWriter, r *http.Request) { s.relationList(w, r, false) } func (s *service) relationCandidates(w http.ResponseWriter, r *http.Request) { s.relationList(w, r, true) } func (s *service) relationList(w http.ResponseWriter, r *http.Request, candidates bool) { s.protect(w, r, func(cc *CompiledController) { id, err := pathID(r) if err != nil { writeCRUDError(w, err) return } svc, err := s.relations() if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } var result *RelationResult if candidates { result, err = svc.Candidates(r.Context(), cc, r.PathValue("name"), id, relationQueryFromRequest(r)) } else { result, err = svc.Linked(r.Context(), cc, r.PathValue("name"), id, relationQueryFromRequest(r)) } if err != nil { writeCRUDError(w, err) return } WriteData(w, http.StatusOK, result.Data, map[string]any{"page": result.Meta.Page, "per_page": result.Meta.PerPage, "total": result.Meta.Total, "last_page": result.Meta.LastPage}) }) } func (s *service) relationLink(w http.ResponseWriter, r *http.Request) { s.relationMutation(w, r, true) } func (s *service) relationUnlink(w http.ResponseWriter, r *http.Request) { s.relationMutation(w, r, false) } func (s *service) relationMutation(w http.ResponseWriter, r *http.Request, link bool) { s.protect(w, r, func(cc *CompiledController) { // The panel's toolbarButtons are the capability: a relation declared // without `link` (or `unlink`) refuses that route, whatever the // controller permission. An unknown relation is the service's 404. action := "unlink" if link { action = "link" } if cr, ok := cc.Relations[r.PathValue("name")]; ok && cr != nil && cr.Schema != nil && !slices.Contains(cr.Schema.View.ToolbarButtons, action) { if principal, _ := bouncer.User(r.Context()); principal != nil { s.logAuth(r, "denied", principal.ID) } WriteError(w, http.StatusForbidden, "forbidden", msgForbidden) return } id, err := pathID(r) if err != nil { writeCRUDError(w, err) return } in, err := decodeRelationMutation(r) if err != nil { writeCRUDError(w, err) return } svc, err := s.relations() if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } var result RelationMutationResult if link { result, err = svc.Link(r.Context(), cc, r.PathValue("name"), id, in) } else { result, err = svc.Unlink(r.Context(), cc, r.PathValue("name"), id, in) } if err != nil { writeCRUDError(w, err) return } WriteData(w, http.StatusOK, result, nil) }) } func decodeRelationMutation(r *http.Request) (RelationMutationInput, error) { dec := json.NewDecoder(r.Body) dec.UseNumber() dec.DisallowUnknownFields() var in RelationMutationInput if err := dec.Decode(&in); err != nil { return RelationMutationInput{}, relationInvalid("body", "The request body is invalid.") } var trailing any if err := dec.Decode(&trailing); err != io.EOF { return RelationMutationInput{}, relationInvalid("body", "The request body is invalid.") } return in, nil } func (s *service) relations() (RelationService, error) { db, err := s.db() if err != nil { return RelationService{}, err } return RelationService{DB: db}, nil } func (s *service) formSchema(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { if cc.Form == nil { WriteError(w, http.StatusNotFound, "not_found", msgNotFound) return } view, err := cc.Form.Localize(r.Context(), s.translator(), dropdownProvider(cc.Controller)) if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } // Like toolbarActions in the list schema (D-12), a widget whose // action this admin may not run is not offered. A new slice: the // localized view must never share its backing array with the cache. principal, _ := bouncer.User(r.Context()) kept := make([]FormField, 0, len(view.Fields)) for _, field := range view.Fields { if field.Type == "widget" { action, ok := cc.Actions[field.Action] if !ok || !Allows(principal, action.Permissions) { continue } } kept = append(kept, field) } view.Fields = kept view.Assets = s.controllerAssets(cc) meta := map[string]any{} if view.Meta.Locale != "" { meta["locale"] = view.Meta.Locale } WriteData(w, http.StatusOK, view, meta) }) } func (s *service) listSchema(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { schema := cc.List if schema == nil { schema = &ListSchema{} } view, err := schema.Localize(r.Context(), s.translator()) if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } // Only the registered toolbar actions this admin may run are offered. principal, _ := bouncer.User(r.Context()) allowed := make([]ToolbarAction, 0, len(view.ToolbarActions)) for _, action := range view.ToolbarActions { if registered, ok := cc.Actions[action.Name]; ok && Allows(principal, registered.Permissions) { allowed = append(allowed, action) } } view.ToolbarActions = allowed view.Assets = s.controllerAssets(cc) meta := map[string]any{} if view.Meta != nil { meta["locale"] = view.Meta.Locale } WriteData(w, http.StatusOK, view, meta) }) } func (s *service) translator() *phrasebook.Translator { if s == nil || s.app == nil { return nil } tr, ok := s.app.Lookup[*phrasebook.Translator]() if !ok { return nil } return tr } func (s *service) show(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { id, err := pathID(r) if err != nil { writeCRUDError(w, err) return } svc, err := s.crud() if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } rec, err := svc.ShowRecord(r.Context(), cc, id) if err != nil { writeCRUDError(w, err) return } WriteData(w, http.StatusOK, rec.Data, rec.Meta) }) } func (s *service) create(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { if !s.operationDeclared(w, r, cc, "create") { return } body, err := decodeObject(r) if err != nil { writeCRUDError(w, err) return } svc, err := s.crud() if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } rec, err := svc.CreateRecord(r.Context(), cc, RecordInput{Body: body}) if err != nil { writeCRUDError(w, err) return } WriteData(w, http.StatusCreated, rec.Data, rec.Meta) }) } func (s *service) update(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { if !s.operationDeclared(w, r, cc, "update") { return } id, err := pathID(r) if err != nil { writeCRUDError(w, err) return } body, err := decodeObject(r) if err != nil { writeCRUDError(w, err) return } svc, err := s.crud() if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } rec, err := svc.UpdateRecord(r.Context(), cc, id, RecordInput{Body: body}) if err != nil { writeCRUDError(w, err) return } WriteData(w, http.StatusOK, rec.Data, rec.Meta) }) } func (s *service) bulkDelete(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { if !s.operationDeclared(w, r, cc, "bulk-delete") { return } in, err := decodeBulk(r) if err != nil { writeCRUDError(w, err) return } svc, err := s.crud() if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } result, err := svc.BulkDelete(r.Context(), cc, in) if err != nil { writeCRUDError(w, err) return } WriteData(w, http.StatusOK, result, nil) }) } func decodeBulk(r *http.Request) (BulkDeleteInput, error) { dec := json.NewDecoder(r.Body) dec.UseNumber() var in BulkDeleteInput if err := dec.Decode(&in); err != nil { return BulkDeleteInput{}, &ValidationError{Details: map[string]any{"body": []string{"The request body is invalid."}}} } return in, nil } func (s *service) deleteRecord(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { if !s.operationDeclared(w, r, cc, "delete") { return } id, err := pathID(r) if err != nil { writeCRUDError(w, err) return } svc, err := s.crud() if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } result, err := svc.Delete(r.Context(), cc, id) if err != nil { writeCRUDError(w, err) return } WriteData(w, http.StatusOK, result, nil) }) } func (s *service) crud() (CRUDService, error) { db, err := s.db() if err != nil { return CRUDService{}, err } return CRUDService{DB: db}, nil } func (s *service) list(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { db, err := s.db() if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } result, err := ExecuteList(r.Context(), db, cc, listQueryFromRequest(r)) var invalid *ListValidationError if errors.As(err, &invalid) { WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed", invalid.Details) return } if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } WriteData(w, http.StatusOK, result.Data, map[string]any{ "page": result.Meta.Page, "per_page": result.Meta.PerPage, "total": result.Meta.Total, "last_page": result.Meta.LastPage, }) }) } // protect runs after the backend guard. Controller lookup precedes permission // evaluation, and schema/SQL run only inside fn. func (s *service) protect(w http.ResponseWriter, r *http.Request, fn func(*CompiledController)) { id := r.PathValue("vendor") + "." + r.PathValue("plugin") + "." + r.PathValue("controller") cc, ok := s.reg.Get(id) if !ok { WriteError(w, http.StatusNotFound, "not_found", msgNotFound) return } principal, _ := bouncer.User(r.Context()) if principal == nil || !principal.Backend { WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated) return } if !Allows(principal, requiredOf(cc.Controller)) { s.logAuth(r, "denied", principal.ID) WriteError(w, http.StatusForbidden, "forbidden", msgForbidden) return } fn(cc) } // operationDeclared refuses a write the controller's YAML does not declare, so // the compiled list and form are the capability, not a hint for the SPA. Create // needs a form and a toolbar `create` button; update and single-record delete // (the form screen's delete button, as in Winter) need a form; bulk delete needs // the toolbar `delete` button, which in turn needs showCheckboxes. The answer is // 403 with the same envelope as a permission failure. func (s *service) operationDeclared(w http.ResponseWriter, r *http.Request, cc *CompiledController, op string) bool { if cc.operationDeclared(op) { return true } if principal, _ := bouncer.User(r.Context()); principal != nil { s.logAuth(r, "denied", principal.ID) } WriteError(w, http.StatusForbidden, "forbidden", msgForbidden) return false } func projectRow(row any, controller pact.AdminController, cols []ListColumn) map[string]any { v := reflect.ValueOf(row) for v.Kind() == reflect.Pointer { if v.IsNil() { return map[string]any{} } v = v.Elem() } out := make(map[string]any, len(cols)+1) if id := fieldByColumn(v, "id"); id.IsValid() && id.CanInterface() { out["id"] = id.Interface() } for _, col := range cols { if col.Relation != "" { if value, ok := relatedSelect(v, controller, col); ok { out[col.Key] = value } continue } field := fieldByColumn(v, col.Key) if !field.IsValid() || !field.CanInterface() { continue } out[col.Key] = field.Interface() } return out } // fieldByColumn returns the model field stored in column, looking through // embedded structs such as gorm.Model. A field with an explicit `column:` tag is // matched by that tag alone; only an untagged field falls back to its Go name // (case-insensitive) or GORM's default column name for it, and a shallower // field shadows an embedded one, as in Go. func fieldByColumn(v reflect.Value, column string) reflect.Value { if v.Kind() != reflect.Struct { return reflect.Value{} } fields := modelFields(v.Type()) best := -1 for i := range fields { if gormColumn(fields[i].Field) == column && (best < 0 || len(fields[i].Path) < len(fields[best].Path)) { best = i } } if best < 0 { for i := range fields { untagged := gormColumn(fields[i].Field) == "" if untagged && (strings.EqualFold(fields[i].Field.Name, column) || defaultColumnName(fields[i].Field) == column) && (best < 0 || len(fields[i].Path) < len(fields[best].Path)) { best = i } } } if best < 0 { return reflect.Value{} } field, err := v.FieldByIndexErr(fields[best].Path) if err != nil { return reflect.Value{} } return field } func gormColumn(field reflect.StructField) string { for _, part := range strings.Split(field.Tag.Get("gorm"), ";") { part = strings.TrimSpace(part) if name, ok := strings.CutPrefix(part, "column:"); ok { return name } } return "" } func uitoa(id uint) string { return strconv.FormatUint(uint64(id), 10) }