Files
summercms/modules/cabana/http.go
Jakub Zych e7423bd21f feat(32-01): form extension placement rules, boot errors and owner-aware branches
- placement: tabs before/after tab anchors, anchor cursor for plugin
  order, own-field anchors sorted with cycle detection, Requires() rule,
  anchor tab inheritance, Hide with reference checks, zone refusal (D-101)
- boot errors name plugin, controller and offending name; fileupload is
  refused (D-100); localized tab label collisions checked in Activate
- extension widgets dispatch to AdminFormExtension.Actions, partials
  render from the contributor's Dir through PartialData, plugin field
  types decode for Save and load after Load, translatable values reach
  Save as locale maps and never the owner's translations
- Permissions and Visible apply to schema, show, save, widgets and
  partials; AdminFormErrors answer 422, Save notices join meta.notices
2026-10-26 22:00:51 +01:00

1354 lines
45 KiB
Go

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/compass"
"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
// uploadBytes and defaultBytes are http.body_limits.upload_bytes and
// default_bytes (0 when not configured): the file routes cap their own
// bodies, since surf applies no body limit to raw routes.
uploadBytes int64
defaultBytes int64
}
// 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.
// When some plugin registers admin controllers, the admin's own
// Administrators and Roles screens (an internal pseudo-plugin with ID
// summercms.cabana) are compiled with them, and the record actions plugins
// add to any controller through pact.HasAdminActionExtensions are merged in.
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
}
// The admin is active: its own screens join the plugins for every later
// step. plugins is the caller's slice and is never modified.
plugins, screens := withBackendScreens(app, plugins)
if items, err = collectControllers(plugins); err != nil {
return nil, err
}
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
}
// Plugin field types are compiled first and passed down: every form of
// this boot resolves its `type:` aliases through this registry.
fieldTypes, err := compileFieldTypes(plugins)
if err != nil {
return nil, err
}
reg, err := compileRegistryWithTypes(items, fieldTypes)
if err != nil {
return nil, err
}
// D-91: fields and tabs other plugins add to a controller's form are
// placed once every form is compiled, before any later pass reads the
// forms.
if err := compileFormExtensions(reg, plugins); err != nil {
return nil, err
}
uploadBytes, err := configBytes(app, "http.body_limits.upload_bytes")
if err != nil {
return nil, err
}
defaultBytes, err := configBytes(app, "http.body_limits.default_bytes")
if err != nil {
return nil, err
}
if err := checkFileLimits(reg, uploadBytes); err != nil {
return nil, err
}
var gdb *gorm.DB
if app != nil {
gdb, _ = app.Lookup[*gorm.DB]()
}
if err := checkDeferredModels(reg, plugins, gdb); err != nil {
return nil, err
}
// Contributed record actions are merged before compileContributions,
// which validates every record action's permissions.
if err := compileActionExtensions(reg, plugins); err != nil {
return nil, err
}
if err := compileAccountContributions(reg, plugins); err != nil {
return nil, err
}
if err := compileContributions(reg, plugins); err != nil {
return nil, err
}
if err := validateFormExtensionPermissions(reg); err != nil {
return nil, err
}
screens.reg = reg
if err := compileTokenScopes(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
}
if err := validateTokenScopeMessageKeys(reg, tr); err != nil {
return nil, err
}
if err := checkFormExtensionTabLabels(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}
jwtGuard := bouncer.NewBackendJWTGuard(secret, users, bl, writeUnauthenticated, AdminCookieName)
var store tokenStore
if gdb != nil {
store = gormTokenStore{db: gdb}
}
guard := newCompositeGuard(jwtGuard, store, users, reg)
// 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)
}
// Cookie-less resolver for plugin routes (Bearer JWT or g15_ token only).
if _, published := app.Lookup[*backendResolver](); !published {
cookieLessJWT := bouncer.NewBackendJWTGuard(secret, users, bl, writeUnauthenticated)
resolverGuard := newCompositeGuard(cookieLessJWT, store, users, reg)
// The admin middleware and ResolveBackend share one throttle per activation.
resolverGuard.stamps = guard.stamps
if err := app.Publish(&backendResolver{guard: resolverGuard}); err != nil {
return nil, fmt.Errorf("cabana: backend resolver: %w", err)
}
}
guardMW, err := guards.Middleware("backend")
if err != nil {
return nil, err
}
mw := refuseFrontendMarker(guardMW)
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,
uploadBytes: uploadBytes,
defaultBytes: defaultBytes,
}
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)
}
// refuseFrontendMarker wraps the backend guard middleware: an in-process
// dispatch marker that lists the "backend" guard but carries a frontend
// principal (Backend false) is answered 401 before the guard honours it, as
// ResolveBackend refuses it, so no frontend user id is ever read as an
// administrator's by the admin API or a backend-guarded plugin route.
func refuseFrontendMarker(next pact.Middleware) pact.Middleware {
return func(h http.Handler) http.Handler {
inner := next(h)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if d, ok := bouncer.Dispatched(r.Context(), "backend"); ok && !d.Principal.Backend {
writeUnauthenticated(w, ErrUnauthenticated)
return
}
inner.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", interactiveOnly(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", interactiveOnly(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)
})
// locale.from-principal runs after the guard: an interactive admin's
// stored admin language overrides whatever the LocaleResolver or
// Accept-Language chose for the request.
r.GroupRaw(api, []string{"backend", "locale.from-principal"}, func(g pact.Router) {
g.Get("/auth/me", s.me)
// My account: the signed-in admin edits their own profile.
// interactiveOnly refuses personal API tokens before requireAjax.
g.Put("/account", interactiveOnly(requireAjax(s.accountUpdate)))
g.Get("/account/preferences", interactiveOnly(s.accountPreferencesGet))
g.Put("/account/preferences", interactiveOnly(requireAjax(s.accountPreferencesUpdate)))
g.Post("/account/avatar", interactiveOnly(requireAjax(s.avatarUpload)))
g.Delete("/account/avatar", interactiveOnly(requireAjax(s.avatarRemove)))
g.Get("/account/tokens", interactiveOnly(s.accountTokensList))
g.Post("/account/tokens", interactiveOnly(requireAjax(s.accountTokenCreate)))
g.Get("/account/tokens/scopes", interactiveOnly(s.accountTokenScopes))
g.Delete("/account/tokens/{id}", interactiveOnly(requireAjax(s.accountTokenRevoke)))
// Sections plugins contribute to My account (pact.HasAccountContributions).
g.Get("/account/contributions", interactiveOnly(s.accountContributionsList))
g.Post("/account/contributions/{name}/actions/{action}", interactiveOnly(requireAjax(s.accountContributionAction)))
g.Get("/navigation", s.navigation)
// Plugin field types (pact.HasFormFieldTypes): the SPA loads their
// scripts and styles once after sign-in.
g.Get("/field-types", s.fieldTypesList)
// Form previews render through RenderMarkdown; nothing is stored.
g.Post("/markdown/preview", requireAjax(s.markdownPreview))
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)
// Tree reorder page (D-09): the scoped set and its locked save.
g.Get("/{vendor}/{plugin}/{controller}/reorder", s.reorderGet)
constrainController(g)
g.Post("/{vendor}/{plugin}/{controller}/reorder", requireAjax(s.reorderPost))
constrainController(g)
// The one record of a pact.AdminSingleton controller (D-11).
g.Get("/{vendor}/{plugin}/{controller}/singleton", s.singletonRoute)
constrainController(g)
// Declared bulk actions (D-09): ids are resolved through the list
// scope before plugin code runs.
g.Post("/{vendor}/{plugin}/{controller}/bulk/{action}", requireAjax(s.bulkAction))
constrainController(g)
g.Where("action", "[A-Za-z_][A-Za-z0-9_]*")
// 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)
// Declared record actions (D-10): the record is loaded through the
// form scope before plugin code runs.
g.Post("/{vendor}/{plugin}/{controller}/{id}/actions/{action}", requireAjax(s.recordAction))
constrainController(g)
g.Where("action", "[A-Za-z_][A-Za-z0-9_]*")
// 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)
// Relation child routes (D-11, D-12, D-15): create, show, update and
// delete children through the relation's forms, every child scoped
// to the parent record.
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records", requireAjax(s.relationChildCreate))
constrainRelation(g)
g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}", s.relationChildShow)
constrainChild(g)
g.Put("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}", requireAjax(s.relationChildUpdate))
constrainChild(g)
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete", requireAjax(s.relationChildDelete))
constrainRelation(g)
// Pivot form values of one belongsToMany link (D-14).
g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}", s.relationPivotShow)
constrainChild(g)
g.Put("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}", requireAjax(s.relationPivotUpdate))
constrainChild(g)
// File routes of a relation child form (D-17): {child} 0 is the
// child being created in the X-Child-Session-Key session.
const childFiles = "/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}"
g.Get(childFiles, s.relationChildFileList)
constrainChildFile(g)
g.Post(childFiles, requireAjax(s.relationChildFileUpload))
constrainChildFile(g)
g.Post(childFiles+"/reorder", requireAjax(s.relationChildFileReorder))
constrainChildFile(g)
g.Put(childFiles+"/{file}", requireAjax(s.relationChildFileUpdate))
constrainChildFileID(g)
g.Delete(childFiles+"/{file}", requireAjax(s.relationChildFileRemove))
constrainChildFileID(g)
g.Get(childFiles+"/{file}/download", s.relationChildFileDownload)
constrainChildFileID(g)
g.Get(childFiles+"/{file}/thumb", s.relationChildFileThumb)
constrainChildFileID(g)
// File routes of `type: fileupload` fields (D-09). {id} 0 is the
// record being created in the X-Session-Key session.
g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}", requireAjax(s.fileUpload))
constrainFile(g)
g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder", requireAjax(s.fileReorder))
constrainFile(g)
g.Put("/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", requireAjax(s.fileUpdate))
constrainFileID(g)
g.Delete("/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", requireAjax(s.fileRemove))
constrainFileID(g)
g.Get("/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download", s.fileDownload)
constrainFileID(g)
g.Get("/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb", s.fileThumb)
constrainFileID(g)
// Uploads of plugin field type elements (D-18): cabana owns the route,
// the type's Upload only stores the file. {id} 0 is the create form.
g.Post("/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload", requireAjax(s.fieldUpload))
constrainFile(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 constrainChild(g pact.Router) {
constrainRelation(g)
g.Where("child", "[0-9]+")
}
func constrainChildFile(g pact.Router) {
constrainChild(g)
g.Where("field", "[A-Za-z_][A-Za-z0-9_]*")
}
func constrainChildFileID(g pact.Router) {
constrainChildFile(g)
g.Where("file", "[0-9]+")
}
func constrainFile(g pact.Router) {
constrainController(g)
g.Where("field", "[A-Za-z_][A-Za-z0-9_]*")
}
func constrainFileID(g pact.Router) {
constrainFile(g)
g.Where("file", "[0-9]+")
}
func constrainNested(g pact.Router) {
constrainController(g)
// segment admits one dot for the repeater.nested path of a dependent
// dropdown inside a repeater row (fields/{field}/dependent-options).
g.Where("segment", `[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?`)
// name admits a hyphen for the literal dependent-options segment; a
// relation or file field name with one matches nothing and answers 404.
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}/{id}/files/{field}
// GET /{vendor}/{plugin}/{controller}/fields/{field}/options
// GET /{vendor}/{plugin}/{controller}/fields/{field}/dependent-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 == "fields" && name == "dependent-options":
r.SetPathValue("field", segment)
s.dependentOptionsRoute(w, r)
case id == "filters" && name == "options":
r.SetPathValue("scope", segment)
s.filterOptions(w, r)
case segment == "relations":
s.relationLinked(w, r)
case segment == "files":
r.SetPathValue("field", name)
s.fileList(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(), settingDropdownProvider(setting))
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, Tr: s.translator(), Config: s.config()}).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 := s.decodeCappedObject(w, 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, Tr: s.translator(), Config: s.config()}).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
}
if codes := s.enabledContentLocales(r.Context()); len(codes) > 0 {
meta["enabledLocales"] = append([]string(nil), codes...)
}
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, ok := s.relationsFor(w, r)
if !ok {
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 := s.decodeCappedRelationMutation(w, r)
if err != nil {
writeCRUDError(w, err)
return
}
svc, ok := s.relationsFor(w, r)
if !ok {
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) {
return decodeRelationMutationBody(r.Body)
}
func decodeRelationMutationBody(body io.Reader) (RelationMutationInput, error) {
dec := json.NewDecoder(body)
dec.UseNumber()
dec.DisallowUnknownFields()
var in RelationMutationInput
if err := dec.Decode(&in); err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
return RelationMutationInput{}, err
}
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) decodeCappedRelationMutation(w http.ResponseWriter, r *http.Request) (RelationMutationInput, error) {
return decodeRelationMutationBody(http.MaxBytesReader(w, r.Body, s.jsonCap()))
}
func (s *service) relations() (RelationService, error) {
db, err := s.db()
if err != nil {
return RelationService{}, err
}
svc := RelationService{DB: db, bucket: s.bucket(), tr: s.translator()}
if s.app != nil {
if w, ok := s.app.Lookup[TranslationWriter](); ok {
svc.writer = w
}
}
return svc, nil
}
// relationsFor is the relation service of one request, carrying its
// X-Session-Key: with it, record id 0 is the record being created in that
// session. A malformed key is a 422; it writes the response and returns
// false on failure.
func (s *service) relationsFor(w http.ResponseWriter, r *http.Request) (RelationService, bool) {
key, _, err := sessionKeyFrom(r)
if err != nil {
writeCRUDError(w, err)
return RelationService{}, false
}
svc, err := s.relations()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return RelationService{}, false
}
svc.SessionKey = key
return svc, true
}
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())
hidden := hiddenSchemaFields(r.Context(), cc)
// D-91: an extension this administrator does not see (its
// Permissions or Visible) contributes no field.
visible := visibleExtensions(r.Context(), cc, principal)
kept := make([]FormField, 0, len(view.Fields))
for _, field := range view.Fields {
if hidden[field.Name] {
continue
}
ext := cc.extensionOf(field.Name)
if ext != nil && !visible[ext] {
continue
}
if field.Type == "widget" {
actions := cc.Actions
if ext != nil {
actions = ext.actions
}
action, ok := actions[field.Action]
if !ok || !Allows(principal, action.Permissions) {
continue
}
}
kept = append(kept, field)
}
// A permission editor's options are the controller's answer for this
// administrator (D-16); kept is this request's own slice.
if err := localizePermissionOptions(r.Context(), s.translator(), cc, kept); err != nil {
moduleLog.Error("cabana: permission options failed", "controller", controllerID(cc), "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
view.Fields = kept
view.Assets = s.controllerAssets(cc)
if codes := s.enabledContentLocales(r.Context()); len(codes) > 0 {
view.Meta.EnabledLocales = append([]string(nil), codes...)
}
meta := map[string]any{}
if view.Meta.Locale != "" {
meta["locale"] = view.Meta.Locale
}
if len(view.Meta.EnabledLocales) > 0 {
meta["enabledLocales"] = view.Meta.EnabledLocales
}
WriteData(w, http.StatusOK, view, meta)
})
}
// schemaFieldHider is implemented by cabana's own admin controllers that
// hide some fields from some administrators (the Administrators screen's
// is_superuser for non-superusers). hiddenSchemaFields names the form
// fields, list columns and list filters to leave out of this request's
// schemas. Unexported, like recordDataExtender.
type schemaFieldHider interface {
hiddenSchemaFields(ctx context.Context) map[string]bool
}
// hiddenSchemaFields asks cc's controller which schema entries to hide for
// the request; nil when it hides none.
func hiddenSchemaFields(ctx context.Context, cc *CompiledController) map[string]bool {
if cc == nil {
return nil
}
hider, ok := cc.Controller.(schemaFieldHider)
if !ok || hider == nil {
return nil
}
return hider.hiddenSchemaFields(ctx)
}
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
// The built-in delete stays; a declared bulk action is offered only
// to an admin who may run it. A new slice: the cached schema is
// never mutated.
bulk := make([]BulkAction, 0, len(view.BulkActions))
for _, entry := range view.BulkActions {
if builtinToolbarActions[entry.Name] {
bulk = append(bulk, entry)
continue
}
if registered, ok := cc.BulkActions[entry.Name]; ok && Allows(principal, registered.Permissions) {
bulk = append(bulk, entry)
}
}
view.BulkActions = bulk
// Fields a controller hides from this administrator take their
// column and filter with them; new slices again.
if hidden := hiddenSchemaFields(r.Context(), cc); len(hidden) > 0 {
columns := make([]ListColumn, 0, len(view.Columns))
for _, column := range view.Columns {
if !hidden[column.Key] {
columns = append(columns, column)
}
}
view.Columns = columns
filters := make([]ListFilter, 0, len(view.Filters))
for _, filter := range view.Filters {
if !hidden[filter.Name] {
filters = append(filters, filter)
}
}
view.Filters = filters
}
view.Assets = s.controllerAssets(cc)
meta := map[string]any{}
if view.Meta != nil {
meta["locale"] = view.Meta.Locale
}
WriteData(w, http.StatusOK, view, meta)
})
}
// config is the app configuration, nil without an app.
func (s *service) config() *compass.Config {
if s == nil || s.app == nil {
return nil
}
return s.app.Config
}
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
}
key, _, err := sessionKeyFrom(r)
if err != nil {
writeCRUDError(w, err)
return
}
body, err := s.decodeCappedObject(w, 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, SessionKey: key})
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
}
key, _, err := sessionKeyFrom(r)
if err != nil {
writeCRUDError(w, err)
return
}
body, err := s.decodeCappedObject(w, 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, SessionKey: key})
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 := s.decodeCappedBulk(w, 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) {
return decodeBulkBody(r.Body)
}
func decodeBulkBody(body io.Reader) (BulkDeleteInput, error) {
dec := json.NewDecoder(body)
dec.UseNumber()
var in BulkDeleteInput
if err := dec.Decode(&in); err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
return BulkDeleteInput{}, err
}
return BulkDeleteInput{}, &ValidationError{Details: map[string]any{"body": []string{"The request body is invalid."}}}
}
return in, nil
}
func (s *service) decodeCappedBulk(w http.ResponseWriter, r *http.Request) (BulkDeleteInput, error) {
return decodeBulkBody(http.MaxBytesReader(w, r.Body, s.jsonCap()))
}
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
}
svc := CRUDService{DB: db, bucket: s.bucket(), tr: s.translator()}
if s.app != nil {
if w, ok := s.app.Lookup[TranslationWriter](); ok {
svc.writer = w
}
}
return svc, nil
}
// enabledContentLocales is TranslationWriter.EnabledLocales after the same
// Lookup crud() uses. Empty when the adapter is unpublished or the query
// fails; list schemas never call this.
func (s *service) enabledContentLocales(ctx context.Context) []string {
if s == nil || s.app == nil {
return nil
}
writer, ok := s.app.Lookup[TranslationWriter]()
if !ok || writer == nil {
return nil
}
db, err := s.db()
if err != nil {
return nil
}
codes, err := writer.EnabledLocales(ctx, db)
if err != nil {
moduleLog.Error("cabana: enabled locales", "error", err)
return nil
}
// The SPA opens every ML control on the first code, and the default
// locale's values live in the host columns, so the default goes first
// and the other codes keep the writer's order.
def, err := writer.DefaultLocale(ctx, db)
if err != nil {
moduleLog.Error("cabana: default locale", "error", err)
return codes
}
if i := slices.Index(codes, def); def != "" && i > 0 {
codes = append([]string{def}, slices.Delete(slices.Clone(codes), i, i+1)...)
}
return codes
}
func (s *service) list(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
if singletonRefuses(w, r, cc) {
return
}
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
}
meta := map[string]any{
"page": result.Meta.Page,
"per_page": result.Meta.PerPage,
"total": result.Meta.Total,
"last_page": result.Meta.LastPage,
}
// Row states (D-12) are sent only when a row of the page has one.
if len(result.Meta.RowStates) > 0 {
meta["row_states"] = result.Meta.RowStates
}
WriteData(w, http.StatusOK, result.Data, meta)
})
}
// 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 {
// A singleton controller (D-11) has no create, delete or bulk delete.
if op != "update" && singletonRefuses(w, r, cc) {
return false
}
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 {
return projectRowCtx(context.Background(), row, controller, cols)
}
// projectRowCtx is one list row: the id and every visible column. A column
// whose type is a plugin field type with ListValue shows the type's text for
// the stored value.
func projectRowCtx(ctx context.Context, 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 {
// An invisible column is searched, never sent.
if col.Invisible {
continue
}
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
}
if ft := col.fieldType; ft != nil && ft.def.ListValue != nil {
out[col.Key] = ft.def.ListValue(ctx, derefValue(field.Interface()))
continue
}
out[col.Key] = field.Interface()
}
// A money column's currencyFrom value rides along under its own key even
// when it is not a declared column, so the admin formats each row's
// amount with that row's code (D-07).
for _, col := range cols {
if col.CurrencyFrom == "" {
continue
}
if _, present := out[col.CurrencyFrom]; present {
continue
}
if code := fieldByColumn(v, col.CurrencyFrom); code.IsValid() && code.CanInterface() {
out[col.CurrencyFrom] = code.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)
}