Files
summercms/modules/cabana/http.go
Jakub Zych 48a5b8045a feat(12.2-03): add hasMany relation contracts, relation forms and child create
- RelationContract gains Kind (empty is belongsToMany) and ForeignKey, with kind-aware boot checks
- manage.form, view.form and pivot.form compile against the related or pivot model; $/ paths resolve inside the plugin
- view toolbarButtons accept create|update|delete|link|unlink, each the capability of its routes
- POST .../relations/{name}/records creates a child through the manage form; the server sets the hasMany key
- relation schema carries kind, deferrable and the localized forms; 17 new relation message keys in en and pl
2026-10-02 18:37:13 +02:00

952 lines
29 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/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.
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
}
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
}
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,
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)
}
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)
// Relation child routes (D-11, D-12): create through the relation's
// manage form.
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records", requireAjax(s.relationChildCreate))
constrainRelation(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)
})
// 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 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)
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}/{id}/files/{field}
// 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)
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(), 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, bucket: s.bucket(), tr: s.translator()}, 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
}
key, _, err := sessionKeyFrom(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.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 := 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, 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 := 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, bucket: s.bucket(), tr: s.translator()}, 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)
}