Files
summercms/modules/cabana/crud.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

1635 lines
51 KiB
Go

package cabana
import (
"context"
"encoding/json"
"errors"
"math"
"net/http"
"reflect"
"sort"
"strconv"
"strings"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
"gocloud.dev/blob"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// CRUDService runs schema-projected record and bulk operations.
type CRUDService struct {
DB *gorm.DB
// bucket deletes the blobs of files a save replaces or removes, after
// commit; tr localizes the file limit messages. Both may be nil.
bucket *blob.Bucket
tr *phrasebook.Translator
// writer persists non-default locale values for mltext/mltextarea/mlmarkdown
// fields. Nil when the translate plugin is not mounted.
writer TranslationWriter
}
// RecordInput is a decoded JSON object. Keys are untrusted. SessionKey is
// the form's X-Session-Key (SessionKeyHeader), empty when the request has
// none: the save applies the file bindings held against it.
type RecordInput struct {
Body map[string]any
SessionKey string
}
// BulkDeleteInput is the bulk-delete body.
type BulkDeleteInput struct {
IDs []any `json:"ids"`
}
// BulkResult is the deleted-count payload.
type BulkResult struct {
Deleted int `json:"deleted"`
}
// BulkActionResult is the answer of a declared bulk action: the localized
// message for the toast (empty when the action sets none) and the number of
// records the action reports as changed.
type BulkActionResult struct {
Message string `json:"message"`
Affected int `json:"affected"`
}
// ValidationError is a D-10 validation_failed failure, answered 422. Details
// maps a field name to its messages (a string or a list), each a phrase key
// or text. A key returned by controller code (a lifecycle hook, an action, a
// reorder hook, a singleton record) is translated in the request locale
// before the response is written, like a ForbiddenError; text is written as
// given.
type ValidationError struct {
Details map[string]any
}
func (e *ValidationError) Error() string { return "validation_failed" }
// ForbiddenError is a write that controller code refuses (D-27): a lifecycle
// hook, a bulk action, a record action, a toolbar action or a widget action
// returns it, and the admin API answers 403 with code forbidden. Message is a
// phrase key or text shown to the administrator; it may be empty, and the
// admin then shows its own text. Details maps a field name to a list of
// messages (phrase keys or text) shown on that field. Both are localized in
// the request locale before the response is written. The surrounding
// transaction is rolled back, so a refused write changes nothing.
type ForbiddenError struct {
Message string
Details map[string]any
}
func (e *ForbiddenError) Error() string { return "forbidden" }
// localizeControllerError returns err with the messages of a plugin-authored
// refusal translated in the request locale: the Details of a
// *ValidationError, or the Message and Details of a *ForbiddenError. The two
// types are tested in writeCRUDError's order, so the error localized is the
// one written. Any other error, a nil one, and a ValidationError without
// Details are returned unchanged. The plugin's error value is never modified:
// it may be a shared package variable read by concurrent requests in other
// locales, so a new error, map and slices are built.
func localizeControllerError(ctx context.Context, tr *phrasebook.Translator, err error) error {
if err == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
var invalid *ValidationError
if errors.As(err, &invalid) {
if invalid == nil || len(invalid.Details) == 0 {
return err
}
ctx = towel.WithLocale(ctx, schemaLocale(ctx, tr))
return &ValidationError{Details: localizeDetails(ctx, tr, invalid.Details)}
}
var refused *ForbiddenError
if !errors.As(err, &refused) || refused == nil {
return err
}
ctx = towel.WithLocale(ctx, schemaLocale(ctx, tr))
out := &ForbiddenError{Message: localizeDetail(ctx, tr, refused.Message)}
if len(refused.Details) > 0 {
out.Details = localizeDetails(ctx, tr, refused.Details)
}
return out
}
// localizeDetails is the copy-on-write loop over a refusal's Details: a
// string becomes a one-message list, a []string or []any becomes a new slice
// with its strings localized, and any other value is passed through. The
// input map and its slices are never written to.
func localizeDetails(ctx context.Context, tr *phrasebook.Translator, details map[string]any) map[string]any {
out := make(map[string]any, len(details))
for field, value := range details {
switch messages := value.(type) {
case string:
out[field] = []string{localizeDetail(ctx, tr, messages)}
case []string:
list := make([]string, len(messages))
for i, text := range messages {
list[i] = localizeDetail(ctx, tr, text)
}
out[field] = list
case []any:
list := make([]any, len(messages))
for i, item := range messages {
if text, ok := item.(string); ok {
list[i] = localizeDetail(ctx, tr, text)
} else {
list[i] = item
}
}
out[field] = list
default:
out[field] = value
}
}
return out
}
// localizeDetail translates one refusal message when it is a phrase key and
// returns any other text unchanged. Every phrasebook key is
// vendor.plugin::group.path and the lookup is exact, so text without "::"
// can never resolve; skipping it keeps already-translated messages (lagoon
// rule messages, framework text) byte-identical and keeps the development
// log free of a missing-key warning per message. An empty message stays
// empty.
func localizeDetail(ctx context.Context, tr *phrasebook.Translator, text string) string {
if !strings.Contains(text, "::") {
return text
}
return translateKey(ctx, tr, text)
}
// transaction runs fn in lagoon.Transaction on the service's database and
// localizes a *ValidationError or *ForbiddenError that comes out of it, so a
// refusal leaves the service ready to be written.
func (s CRUDService) transaction(ctx context.Context, fn func(ctx context.Context, tx *gorm.DB) error) error {
return localizeControllerError(ctx, s.tr, lagoon.Transaction(ctx, s.DB, fn))
}
// CapabilityError is a fail-closed Fill/Validate failure with controller context.
type CapabilityError struct {
ControllerID string
}
func (e *CapabilityError) Error() string {
if e == nil {
return "cabana: missing Fill/Validate capability"
}
return "cabana: controller " + e.ControllerID + ": missing Fill/Validate capability"
}
type recordNotFound struct{}
func (recordNotFound) Error() string { return "cabana: not found" }
type hasRules interface {
Rules() map[string]string
}
// ProjectWritableFields copies only activation-bound writable keys.
// Unknown keys, case variants, nested objects, and protected columns are dropped.
func ProjectWritableFields(cc *CompiledController, body map[string]any) map[string]any {
out := map[string]any{}
if cc == nil || body == nil {
return out
}
allowed := map[string]string{}
for _, field := range cc.Writable {
if field.FillKey == "" || protectedFillKey(field.Name) || protectedFillKey(field.FillKey) {
continue
}
allowed[field.Name] = field.FillKey
}
// A repeater's value is a list of rows (D-07): only those fields are
// exempt from the nested-value refusal, and only for a list.
repeaters := repeaterNames(cc)
for key, val := range body {
fillKey, ok := allowed[key]
if !ok {
continue
}
if nestedValue(val) && !(repeaters[key] && listValue(val)) {
continue
}
out[fillKey] = val
}
return out
}
// BindWritableFields records schema field names onto model column fill keys.
// Protected columns and the fields the controller lists through
// pact.FormVirtualFields are omitted. Any other scalar field with no column
// fails activation.
func BindWritableFields(cc *CompiledController) error {
if cc == nil || cc.Form == nil {
return nil
}
id := ""
if cc.Controller != nil {
id = cc.Controller.ID()
}
src, ok := cc.Controller.(pact.AdminRecordSource)
if !ok || src == nil || src.NewRecord() == nil {
return nil
}
cols := modelColumns(src.NewRecord())
// A virtual field (pact.FormVirtualFields) is not a column of the form:
// it gets no column check and no binding, so Fill never receives it and
// no response returns it.
cc.virtual = virtualFieldSet(cc.Controller)
bindings := make([]WritableField, 0, len(cc.Form.Fields))
for _, field := range cc.Form.Fields {
if !valueFormField(field) || protectedFillKey(field.Name) || cc.virtual[field.Name] {
continue
}
// A form extension's field is never a column of the owner's model
// (D-91): its extension loads and saves it.
if cc.extensionOf(field.Name) != nil {
continue
}
if _, known := cols[field.Name]; !known {
return errors.New("cabana: controller " + id + ": field " + field.Name + " is not a model column")
}
bindings = append(bindings, WritableField{Name: field.Name, FillKey: field.Name})
}
cc.Writable = bindings
return nil
}
// Create persists a projected record after Fill and Validate.
func (s CRUDService) Create(ctx context.Context, cc *CompiledController, in RecordInput) (map[string]any, error) {
res, err := s.save(ctx, cc, nil, in, false)
return res.Data, err
}
// Update persists a projected change after Fill and Validate.
func (s CRUDService) Update(ctx context.Context, cc *CompiledController, id any, in RecordInput) (map[string]any, error) {
res, err := s.save(ctx, cc, id, in, true)
return res.Data, err
}
// CreateRecord is Create plus the relation labels of the saved record (D-18).
func (s CRUDService) CreateRecord(ctx context.Context, cc *CompiledController, in RecordInput) (RecordResult, error) {
return s.save(ctx, cc, nil, in, false)
}
// UpdateRecord is Update plus the relation labels of the saved record (D-18).
func (s CRUDService) UpdateRecord(ctx context.Context, cc *CompiledController, id any, in RecordInput) (RecordResult, error) {
return s.save(ctx, cc, id, in, true)
}
// Delete removes one scoped record. A missing or out-of-scope id is a successful no-op.
func (s CRUDService) Delete(ctx context.Context, cc *CompiledController, id any) (BulkResult, error) {
if s.DB == nil {
return BulkResult{}, errors.New("cabana: database is not configured")
}
if ctx == nil {
ctx = context.Background()
}
if _, err := newWritableModel(cc); err != nil {
return BulkResult{}, err
}
var result BulkResult
err := s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
target, err := newWritableModel(cc)
if err != nil {
return err
}
pk, err := coercePK(target, id)
if err != nil {
return err
}
if err := loadRecord(ctx, tx, cc, target, pk); err != nil {
var missing recordNotFound
if errors.As(err, &missing) {
result.Deleted = 0
return nil
}
return err
}
if err := deleteRecord(ctx, tx, cc, target); err != nil {
return err
}
result.Deleted = 1
return nil
})
if err != nil {
return BulkResult{}, err
}
return result, nil
}
// BulkDelete removes a normalized id set in one transaction.
// An empty selection is validation_failed. A wholly absent selection is a
// successful no-op. A mixed present/absent selection conflicts and rolls back.
func (s CRUDService) BulkDelete(ctx context.Context, cc *CompiledController, in BulkDeleteInput) (BulkResult, error) {
if s.DB == nil {
return BulkResult{}, errors.New("cabana: database is not configured")
}
if ctx == nil {
ctx = context.Background()
}
ids, err := normalizeIDs(in.IDs)
if err != nil {
return BulkResult{}, err
}
if _, err := newWritableModel(cc); err != nil {
return BulkResult{}, err
}
var result BulkResult
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
if err := ctx.Err(); err != nil {
return lifecycleFailure(cc, err)
}
proto, err := newWritableModel(cc)
if err != nil {
return err
}
rows, err := lockScoped(ctx, tx, cc, proto, ids)
if err != nil {
return err
}
if len(rows) == 0 {
result.Deleted = 0
return nil
}
if len(rows) != len(ids) {
return partialSelection{}
}
for _, row := range rows {
if err := ctx.Err(); err != nil {
return lifecycleFailure(cc, err)
}
if err := deleteRecord(ctx, tx, cc, row); err != nil {
return err
}
}
result.Deleted = len(rows)
return nil
})
if err != nil {
return BulkResult{}, err
}
return result, nil
}
// BulkAction runs the declared bulk action name on a normalized id set in one
// transaction. The ids are resolved and row-locked through the controller's
// ListExtendQuery scope first, and the action receives the loaded records,
// never the ids. An empty selection is validation_failed. A selection that
// matches no scoped row is a successful no-op with Affected 0 and the action
// does not run. A mixed present/absent selection conflicts and rolls back. A
// name the list does not declare, or the controller does not register, is not
// found. A Detached action opens no transaction: its records are loaded
// through the same scope and rules without row locks, and Run gets a context
// without a write transaction.
func (s CRUDService) BulkAction(ctx context.Context, cc *CompiledController, name string, in BulkDeleteInput) (BulkActionResult, error) {
if s.DB == nil {
return BulkActionResult{}, errors.New("cabana: database is not configured")
}
if ctx == nil {
ctx = context.Background()
}
action, ok := bulkActionOf(cc, name)
if !ok {
return BulkActionResult{}, recordNotFound{}
}
ids, err := normalizeIDs(in.IDs)
if err != nil {
return BulkActionResult{}, err
}
if _, err := newWritableModel(cc); err != nil {
return BulkActionResult{}, err
}
ctx = towel.WithLocale(ctx, schemaLocale(ctx, s.tr))
if action.Detached {
result, err := s.detachedBulkAction(ctx, cc, name, action, ids)
return result, localizeControllerError(ctx, s.tr, err)
}
var result BulkActionResult
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
if err := ctx.Err(); err != nil {
return lifecycleFailure(cc, err)
}
proto, err := newWritableModel(cc)
if err != nil {
return err
}
rows, err := lockScoped(ctx, tx, cc, proto, ids)
if err != nil {
return err
}
if len(rows) == 0 {
return nil
}
if len(rows) != len(ids) {
return partialSelection{}
}
out, err := action.Run(ctx, pact.AdminBulkActionInput{Records: rows})
if err != nil {
return actionFailure(cc, "bulk action", name, err)
}
result = BulkActionResult{Message: translateKey(ctx, s.tr, out.Message), Affected: out.Affected}
return nil
})
if err != nil {
return BulkActionResult{}, err
}
return result, nil
}
// detachedBulkAction is the branch of BulkAction for a Detached action: no
// transaction is opened, the rows are loaded through the list scope without
// row locks, and Run gets the locale context without a write transaction.
// The empty, partial-selection, failure and message rules are those of the
// locked path.
func (s CRUDService) detachedBulkAction(ctx context.Context, cc *CompiledController, name string, action pact.AdminBulkAction, ids []uint) (BulkActionResult, error) {
if err := ctx.Err(); err != nil {
return BulkActionResult{}, lifecycleFailure(cc, err)
}
proto, err := newWritableModel(cc)
if err != nil {
return BulkActionResult{}, err
}
rows, err := loadScoped(ctx, s.DB, cc, proto, ids, false)
if err != nil {
return BulkActionResult{}, err
}
if len(rows) == 0 {
return BulkActionResult{}, nil
}
if len(rows) != len(ids) {
return BulkActionResult{}, partialSelection{}
}
out, err := action.Run(ctx, pact.AdminBulkActionInput{Records: rows})
if err != nil {
return BulkActionResult{}, actionFailure(cc, "bulk action", name, err)
}
return BulkActionResult{Message: translateKey(ctx, s.tr, out.Message), Affected: out.Affected}, nil
}
// actionFailure classifies the error of a bulk or record action's Run like a
// lifecycle hook's: the errors a plugin may answer with pass through, anything
// else is logged with the controller and action and becomes the opaque
// lifecycle error (500, no error text).
func actionFailure(cc *CompiledController, kind, name string, err error) error {
out := lifecycleFailure(cc, err)
var life *lifecycleError
if errors.As(out, &life) && !errors.As(err, &life) {
moduleLog.Error("cabana: admin "+kind+" failed", "controller", controllerID(cc), "action", name, "error", err)
}
return out
}
// Show loads one scoped record. Missing and out-of-scope ids are identical.
func (s CRUDService) Show(ctx context.Context, cc *CompiledController, id any) (map[string]any, error) {
res, err := s.ShowRecord(ctx, cc, id)
if err != nil {
return nil, err
}
return res.Data, nil
}
// ShowRecord is Show plus the relation labels of the record (D-18). A
// ValidationError or ForbiddenError from a controller hook it runs is
// returned with its messages localized in the request locale.
func (s CRUDService) ShowRecord(ctx context.Context, cc *CompiledController, id any) (RecordResult, error) {
if s.DB == nil {
return RecordResult{}, errors.New("cabana: database is not configured")
}
if ctx == nil {
ctx = context.Background()
}
ctx = towel.WithLocale(ctx, schemaLocale(ctx, s.tr))
var result RecordResult
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
target, err := newWritableModel(cc)
if err != nil {
return err
}
pk, err := coercePK(target, id)
if err != nil {
return err
}
if err := loadRecord(ctx, tx, cc, target, pk); err != nil {
return err
}
result, err = projectFullRecord(ctx, tx, cc, target)
if err != nil {
return err
}
if err := hydrateMLRecord(ctx, tx, cc, s.writer, target, result.Data, "update"); err != nil {
return err
}
if err := hydratePluginFields(ctx, tx, cc, s.writer, target, result.Data, "update"); err != nil {
return err
}
visible := visibleExtensions(ctx, cc, principalOf(ctx))
if err := hydrateFormExtensions(withTx(ctx, tx), extensionHydrate{Tx: tx, Tr: s.tr, Writer: s.writer, CC: cc, Target: target, Op: "update", Visible: visible}, result.Data); err != nil {
return err
}
result.Meta.Actions, err = s.offeredRecordActions(withTx(ctx, tx), cc, target)
return err
})
if err != nil {
// FormRecordTitle, a record action's Applies and the
// RelationLockProvider may refuse the read with phrase keys.
return RecordResult{}, localizeControllerError(ctx, s.tr, err)
}
localizePermissionLocks(ctx, s.tr, result.Meta.PermissionLocks)
return result, nil
}
// offeredRecordActions lists the declared record actions the principal on ctx
// may run and that apply to the loaded record, in declared order, with
// localized label and confirm text (D-10). It is nil when none is offered.
// Applies runs with the read transaction on ctx; its error fails the read.
func (s CRUDService) offeredRecordActions(ctx context.Context, cc *CompiledController, record any) ([]RecordAction, error) {
if cc == nil || cc.Form == nil || len(cc.Form.recordActions) == 0 {
return nil, nil
}
principal, _ := bouncer.User(ctx)
var out []RecordAction
for _, name := range cc.Form.recordActions {
action, ok := cc.RecordActions[name]
if !ok || !Allows(principal, action.Permissions) {
continue
}
if action.Applies != nil {
applies, err := action.Applies(ctx, record)
if err != nil {
return nil, actionFailure(cc, "record action", name, err)
}
if !applies {
continue
}
}
offered := RecordAction{
Name: name,
Label: translateKey(ctx, s.tr, action.Label),
Confirm: translateKey(ctx, s.tr, action.Confirm),
Danger: action.Danger,
}
if action.Prompt != nil {
offered.Prompt = &RecordActionPrompt{
Label: translateKey(ctx, s.tr, action.Prompt.Label),
Placeholder: translateKey(ctx, s.tr, action.Prompt.Placeholder),
Comment: translateKey(ctx, s.tr, action.Prompt.Comment),
MaxLength: promptMaxLength(action.Prompt),
}
}
out = append(out, offered)
}
return out, nil
}
// defaultPromptMaxLength is the longest prompt value in runes when a
// pact.AdminActionPrompt leaves MaxLength at 0.
const defaultPromptMaxLength = 255
// promptMaxLength is the prompt's cap in runes, defaultPromptMaxLength for 0
// or less.
func promptMaxLength(prompt *pact.AdminActionPrompt) int {
if prompt == nil || prompt.MaxLength <= 0 {
return defaultPromptMaxLength
}
return prompt.MaxLength
}
// RecordAction runs the declared record action name on one record in a
// transaction. The record is loaded through the controller's FormExtendQuery
// scope with a row lock: a missing or out-of-scope id is not found. The
// action's Applies is checked again inside the transaction, and an action
// that does not apply to the record's current state conflicts. A name the
// form does not declare, or the controller does not register, is not found.
// The action receives no prompt value; RecordActionWithValue carries one.
func (s CRUDService) RecordAction(ctx context.Context, cc *CompiledController, id any, name string) (AdminActionResult, error) {
return s.RecordActionWithValue(ctx, cc, id, name, "")
}
// RecordActionWithValue is RecordAction with the trimmed answer of a
// prompted action's text input, handed to Run as
// pact.AdminRecordActionInput.Value (empty for an action without a Prompt;
// the record action route validates it before calling this).
func (s CRUDService) RecordActionWithValue(ctx context.Context, cc *CompiledController, id any, name, value string) (AdminActionResult, error) {
if s.DB == nil {
return AdminActionResult{}, errors.New("cabana: database is not configured")
}
if ctx == nil {
ctx = context.Background()
}
action, ok := recordActionOf(cc, name)
if !ok {
return AdminActionResult{}, recordNotFound{}
}
if _, err := newWritableModel(cc); err != nil {
return AdminActionResult{}, err
}
ctx = towel.WithLocale(ctx, schemaLocale(ctx, s.tr))
result := AdminActionResult{Fill: map[string]any{}}
err := s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
target, err := newWritableModel(cc)
if err != nil {
return err
}
pk, err := coercePK(target, id)
if err != nil {
return err
}
if err := loadRecord(ctx, tx, cc, target, pk); err != nil {
return err
}
if action.Applies != nil {
applies, err := action.Applies(ctx, target)
if err != nil {
return actionFailure(cc, "record action", name, err)
}
if !applies {
return actionConflict{}
}
}
out, err := action.Run(ctx, pact.AdminRecordActionInput{RecordID: uint64(pkUint(target)), Record: target, Value: value})
if err != nil {
return actionFailure(cc, "record action", name, err)
}
result.Message = translateKey(ctx, s.tr, out.Message)
return nil
})
if err != nil {
return AdminActionResult{}, err
}
return result, nil
}
// actionConflict is a record action that does not apply to the record's
// current state (409).
type actionConflict struct{}
func (actionConflict) Error() string { return "cabana: action does not apply" }
// recordDataExtender is implemented by cabana's own admin controllers that
// send a value the generic projection leaves out, such as a protected column
// a virtual field edits (the Administrators screen's is_superuser switch).
// extendRecordData adds keys to data for the principal on ctx. Unexported:
// only controllers in this package can implement it, so no other
// controller's record payload changes.
type recordDataExtender interface {
extendRecordData(ctx context.Context, record any, data map[string]any)
}
// projectFullRecord is the D-18 record shape: scalar writable fields, relation
// values keyed by field name with their labels, and the stored permissions of
// every permissioneditor field as an object (D-16).
func projectFullRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any) (RecordResult, error) {
data := projectRecord(cc, model)
hydrateRepeaterFields(cc, data)
if cc != nil && cc.Form != nil {
hydrateTaglistFields(cc.Form.Fields, data)
}
meta, err := projectRelationFields(ctx, tx, cc, model, data)
if err != nil {
return RecordResult{}, err
}
if err := projectPermissionFields(withTx(ctx, tx), cc, model, data); err != nil {
return RecordResult{}, err
}
// D-21: the permissions the record fixes, per permissioneditor field.
// Notes are phrase keys here; the show and save paths translate them.
for _, name := range permissionFieldNames(cc) {
provider, ok := cc.Controller.(PermissionRecordLockProvider)
if !ok || provider == nil {
break
}
locks, err := provider.AdminPermissionRecordLocks(withTx(ctx, tx), name, model)
if err != nil {
return RecordResult{}, lifecycleFailure(cc, err)
}
if len(locks) == 0 {
continue
}
if meta.PermissionLocks == nil {
meta.PermissionLocks = map[string][]PermissionRecordLock{}
}
meta.PermissionLocks[name] = append([]PermissionRecordLock(nil), locks...)
}
if cc != nil {
if ext, ok := cc.Controller.(recordDataExtender); ok && ext != nil {
ext.extendRecordData(ctx, model, data)
}
if hook, ok := cc.Controller.(pact.FormRecordTitle); ok && hook != nil {
title, err := hook.FormRecordTitle(ctx, tx, model)
if err != nil {
return RecordResult{}, lifecycleFailure(cc, err)
}
meta.Title = title
}
}
return RecordResult{Data: data, Meta: meta}, nil
}
func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, in RecordInput, update bool) (RecordResult, error) {
if s.DB == nil {
return RecordResult{}, errors.New("cabana: database is not configured")
}
if ctx == nil {
ctx = context.Background()
}
if _, err := newWritableModel(cc); err != nil {
return RecordResult{}, err
}
op := "create"
if update {
op = "update"
}
// D-91: form extension values leave the body first, so no owner path
// (Fill, bindings, translations, attachments) ever reads them; the keys
// of an extension this administrator does not see are dropped.
visible := visibleExtensions(ctx, cc, principalOf(ctx))
extensions, err := liftExtensionValues(ctx, extensionLift{CC: cc, Op: op, Visible: visible, Writer: s.writer, Tr: s.tr, DB: s.DB}, in.Body)
if err != nil {
return RecordResult{}, err
}
// Writable relation keys are lifted before scalar projection (which drops
// every nested value); only keys present in the body are applied.
relations, err := liftRelationValues(cc, in.Body, op)
if err != nil {
return RecordResult{}, err
}
// Virtual field values never reach Fill: they are handed to the Form
// hooks on the context and to the rules.
virtual, err := liftVirtualValues(cc, in.Body, op)
if err != nil {
return RecordResult{}, err
}
permissions, err := liftPermissionValues(cc, in.Body, op)
if err != nil {
return RecordResult{}, err
}
// ML locale maps are lifted before scalar projection, which drops every
// nested value. The default-locale string is written back onto the body
// so Fill sees a host scalar.
translations, err := liftMLValues(ctx, cc, in.Body, op, s.writer, s.DB)
if err != nil {
return RecordResult{}, err
}
// Plugin field values are lifted the same way and decoded by their type
// inside the transaction, once the record is loaded.
pluginValues := liftPluginValues(cc, in.Body, op)
// A richeditor value is a string or null; anything else is refused on
// the field instead of being dropped by the projection.
if err := checkRichEditorValues(cc, in.Body, op); err != nil {
return RecordResult{}, err
}
// D-07: a money value is an exact decimal string within the field's
// scale and max, or null; anything else is a 422 on the field.
if err := checkMoneyValues(ctx, s.tr, cc, in.Body, op); err != nil {
return RecordResult{}, err
}
// D-17: a taglist value is normalised (trimmed, no empties, no
// duplicates) and checked before the projection; an array-mode list
// reaches its JSON column as JSON text.
if cc != nil && cc.Form != nil {
allows := func(name string) bool { return contextAllows(cc, name, op) }
if err := checkTaglistValues(ctx, s.tr, cc.Form.Fields, in.Body, allows, !update); err != nil {
return RecordResult{}, err
}
}
// D-07: repeater rows are decoded in place (only nested keys, coerced
// scalars, per-row required, row bounds) before the projection, which
// lets a repeater's list through.
if err := liftRepeaterValues(ctx, cc, in.Body, op, s.tr); err != nil {
return RecordResult{}, err
}
var (
result RecordResult
notices []pluginNotice
)
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withVirtualFields(withTx(ctx, tx), virtual)
target, err := newWritableModel(cc)
if err != nil {
return err
}
if update {
pk, err := coercePK(target, id)
if err != nil {
return err
}
if err := loadRecord(ctx, tx, cc, target, pk); err != nil {
return err
}
}
projected := projectOperation(cc, in.Body, op)
decoded, err := decodePluginFields(ctx, pluginDecodeInput{Tx: tx, Tr: s.tr, Writer: s.writer, CC: cc, Target: target, Values: pluginValues, Projected: projected, Translations: translations, Update: update})
if err != nil {
return err
}
// D-10: every richeditor value is sanitized before Fill sees it.
notices = append(decoded.notices, sanitizeRichEditors(cc, projected)...)
notices = append(notices, sanitizeRepeaterRichEditors(cc, projected)...)
// D-08: a dependent dropdown's value must be among the options for
// its sibling's value, top level and in every repeater row.
if err := checkDependentValues(ctx, cc, s.tr, target, projected, op); err != nil {
return err
}
// The Form hooks read what this save writes for plugin field types.
ctx = withFieldWrites(ctx, decoded.writes)
allowed := fillAllowed(cc, target, op)
if err := lagoon.Fill(target, allowed, projected, false); err != nil {
// A value that does not fit its column is the admin's input,
// not a missing capability: answer it on the field.
var typed *lagoon.FillTypeError
if errors.As(err, &typed) {
return &ValidationError{Details: fillTypeDetails(typed.Key)}
}
return &CapabilityError{ControllerID: controllerID(cc)}
}
if hook, ok := target.(lagoon.HasBeforeValidate); ok && hook != nil {
if err := hook.BeforeValidate(tx); err != nil {
return &CapabilityError{ControllerID: controllerID(cc)}
}
}
rules := mergedRules(ctx, cc, target, op)
msgs, err := lagoon.Validate(ctx, tx, target, rules, virtualValuesForRules(cc, target, rules, virtual), nil)
if err != nil {
return &CapabilityError{ControllerID: controllerID(cc)}
}
for field, extra := range dateBoundDetails(ctx, s.tr, cc, target, op) {
if msgs == nil {
msgs = map[string][]string{}
}
msgs[field] = append(msgs[field], extra...)
}
if len(msgs) > 0 {
return &ValidationError{Details: validationDetails(msgs)}
}
if update {
err = formBeforeUpdate(ctx, cc, target)
} else {
err = formBeforeCreate(ctx, cc, target)
}
if err != nil {
return err
}
// D-16: permission values are checked against the controller's
// options and stored by the controller before the row write.
if err := applyPermissionValues(ctx, cc, target, permissions, update); err != nil {
return err
}
// D-18: submitted ids pass the same scoped query as the options
// endpoint; belongsTo keys land before the row write, pivot rows after.
if err := checkRelationScope(ctx, tx, cc, relations); err != nil {
return err
}
// D-07: a change to related ids the controller locks for this
// administrator is refused before any row is written.
if err := checkRelationLocks(ctx, tx, cc, target, relations); err != nil {
return err
}
// A belongsTo whose key changes clears its ClearOnChange columns,
// except those this save writes from its own form fields.
written := map[string]bool{}
for _, key := range allowed {
if _, ok := projected[key]; ok {
written[key] = true
}
}
if err := assignBelongsTo(cc, target, relations, written); err != nil {
return err
}
if update {
// The row write uses the scope the load used: a soft-deleted
// record the controller's FormExtendQuery includes (Winter's
// withTrashed) is updated in place and stays soft-deleted.
err = tx.Unscoped().Save(target).Error
} else {
err = tx.Create(target).Error
}
if err != nil {
return lifecycleFailure(cc, err)
}
// Non-default locales are written only after the host row has an id
// and after permission, query scope, and relation locks have passed.
if err := applyMLTranslations(ctx, tx, s.writer, target, translations); err != nil {
return err
}
if err := syncBelongsToMany(ctx, tx, cc, target, relations); err != nil {
return err
}
if err := s.commitDeferred(ctx, tx, cc, target, op, in); err != nil {
return err
}
if update {
err = formAfterUpdate(ctx, cc, target)
} else {
err = formAfterCreate(ctx, cc, target)
}
if err != nil {
return err
}
// D-91: form extensions save their own values in this transaction,
// after the owner row is written; an error rolls everything back.
extNotices, err := saveFormExtensions(ctx, extensionSave{Tx: tx, Tr: s.tr, Writer: s.writer, CC: cc, Target: target, Values: extensions, Visible: visible, Created: !update})
if err != nil {
return err
}
notices = append(notices, extNotices...)
result, err = projectFullRecord(ctx, tx, cc, target)
if err != nil {
return err
}
if err := hydrateMLRecord(ctx, tx, cc, s.writer, target, result.Data, op); err != nil {
return err
}
if err := hydratePluginFields(ctx, tx, cc, s.writer, target, result.Data, op); err != nil {
return err
}
return hydrateFormExtensions(ctx, extensionHydrate{Tx: tx, Tr: s.tr, Writer: s.writer, CC: cc, Target: target, Op: op, Visible: visible}, result.Data)
})
if err != nil {
return RecordResult{}, err
}
result.Meta.Notices = localizeNotices(ctx, s.tr, notices)
localizePermissionLocks(ctx, s.tr, result.Meta.PermissionLocks)
return result, nil
}
func writeCRUDError(w http.ResponseWriter, err error) {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
WriteError(w, http.StatusRequestEntityTooLarge, "payload_too_large", msgPayloadTooLarge)
return
}
var ve *ValidationError
if errors.As(err, &ve) {
WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed", ve.Details)
return
}
// Controller code refused the write (D-27): only the plugin-authored
// message and details are written, never another error's text.
var refused *ForbiddenError
if errors.As(err, &refused) && refused != nil {
WriteErrorDetails(w, http.StatusForbidden, "forbidden", refused.Message, refused.Details)
return
}
var missing recordNotFound
if errors.As(err, &missing) {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
var partial partialSelection
if errors.As(err, &partial) {
WriteError(w, http.StatusConflict, "conflict", "Conflict")
return
}
var stale actionConflict
if errors.As(err, &stale) {
WriteError(w, http.StatusConflict, "conflict", "Conflict")
return
}
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
}
func newWritableModel(cc *CompiledController) (any, error) {
id := controllerID(cc)
if cc == nil || cc.Controller == nil {
return nil, &CapabilityError{ControllerID: id}
}
src, ok := cc.Controller.(pact.AdminRecordSource)
if !ok || src == nil {
return nil, &CapabilityError{ControllerID: id}
}
model := src.NewRecord()
if model == nil {
return nil, &CapabilityError{ControllerID: id}
}
if _, ok := model.(lagoon.HasFillable); !ok {
return nil, &CapabilityError{ControllerID: id}
}
if _, ok := model.(hasRules); !ok {
return nil, &CapabilityError{ControllerID: id}
}
return model, nil
}
func controllerID(cc *CompiledController) string {
if cc == nil || cc.Controller == nil {
return ""
}
return cc.Controller.ID()
}
func loadRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, dest any, pk any) error {
col := primaryColumn(dest)
q := tx.WithContext(ctx)
if cc != nil {
if ext, ok := cc.Controller.(pact.FormExtendQuery); ok && ext != nil {
if next := ext.FormExtendQuery(ctx, q); next != nil {
q = next
}
}
}
err := q.Clauses(clause.Locking{Strength: "UPDATE"}).
Where(clause.Eq{Column: clause.Column{Name: col}, Value: pk}).
Take(dest).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return recordNotFound{}
}
if err != nil {
return lifecycleFailure(cc, err)
}
return nil
}
type lifecycleError struct {
ControllerID string
}
func (e *lifecycleError) Error() string {
if e == nil {
return "cabana: lifecycle failed"
}
return "cabana: controller " + e.ControllerID + " failed"
}
func lifecycleFailure(cc *CompiledController, err error) error {
if err == nil {
return nil
}
var missing recordNotFound
if errors.As(err, &missing) {
return err
}
var invalid *ValidationError
if errors.As(err, &invalid) {
return err
}
// Without this a hook's refusal would become the opaque lifecycle error.
var refused *ForbiddenError
if errors.As(err, &refused) {
return err
}
var closed *CapabilityError
if errors.As(err, &closed) {
return err
}
var partial partialSelection
if errors.As(err, &partial) {
return err
}
var stale actionConflict
if errors.As(err, &stale) {
return err
}
var life *lifecycleError
if errors.As(err, &life) {
return err
}
return &lifecycleError{ControllerID: controllerID(cc)}
}
type partialSelection struct{}
func (partialSelection) Error() string { return "cabana: partial selection" }
func normalizeIDs(raw []any) ([]uint, error) {
if len(raw) == 0 {
return nil, &ValidationError{Details: map[string]any{"ids": []string{"The ids field is required."}}}
}
seen := map[uint]struct{}{}
ids := make([]uint, 0, len(raw))
for _, item := range raw {
n, err := asUint(item)
if err != nil {
return nil, &ValidationError{Details: map[string]any{"ids": []string{"The ids field must be a list of integers."}}}
}
if _, ok := seen[n]; ok {
continue
}
seen[n] = struct{}{}
ids = append(ids, n)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids, nil
}
// lockScoped loads and row-locks the rows of ids through the list scope on
// the write transaction tx (loadScoped with lock).
func lockScoped(ctx context.Context, tx *gorm.DB, cc *CompiledController, proto any, ids []uint) ([]any, error) {
return loadScoped(ctx, tx, cc, proto, ids, true)
}
// loadScoped loads the rows of ids through the controller's ListExtendQuery
// scope on db, ordered by primary key, with FOR UPDATE row locks when lock
// is set (lockScoped) and without them for a detached bulk action.
func loadScoped(ctx context.Context, db *gorm.DB, cc *CompiledController, proto any, ids []uint, lock bool) ([]any, error) {
mt := reflect.TypeOf(proto)
if mt == nil || mt.Kind() != reflect.Pointer || mt.Elem().Kind() != reflect.Struct {
return nil, &CapabilityError{ControllerID: controllerID(cc)}
}
holder := reflect.New(reflect.SliceOf(mt.Elem()))
q := db.WithContext(ctx).Model(proto)
if cc != nil {
if ext, ok := cc.Controller.(pact.ListExtendQuery); ok && ext != nil {
if next := ext.ListExtendQuery(ctx, q); next != nil {
q = next
}
}
}
col := primaryColumn(proto)
vals := make([]any, len(ids))
for i, id := range ids {
vals[i] = castPK(proto, id)
}
if lock {
q = q.Clauses(clause.Locking{Strength: "UPDATE"})
}
err := q.Where(clause.IN{Column: clause.Column{Name: col}, Values: vals}).
Order(clause.OrderByColumn{Column: clause.Column{Name: col}, Desc: false}).
Find(holder.Interface()).Error
if err != nil {
return nil, lifecycleFailure(cc, err)
}
values := holder.Elem()
out := make([]any, values.Len())
for i := 0; i < values.Len(); i++ {
out[i] = values.Index(i).Addr().Interface()
}
sort.SliceStable(out, func(i, j int) bool { return pkUint(out[i]) < pkUint(out[j]) })
return out, nil
}
func pkUint(model any) uint {
v := reflect.ValueOf(model)
for v.Kind() == reflect.Pointer {
if v.IsNil() {
return 0
}
v = v.Elem()
}
field := fieldByColumn(v, primaryColumn(model))
if !field.IsValid() || !field.CanInterface() {
return 0
}
n, err := asUint(field.Interface())
if err != nil {
return 0
}
return n
}
func formBeforeCreate(ctx context.Context, cc *CompiledController, model any) error {
hook, ok := cc.Controller.(pact.FormBeforeCreate)
if !ok || hook == nil {
return nil
}
return lifecycleFailure(cc, hook.FormBeforeCreate(ctx, model))
}
func formAfterCreate(ctx context.Context, cc *CompiledController, model any) error {
hook, ok := cc.Controller.(pact.FormAfterCreate)
if !ok || hook == nil {
return nil
}
return lifecycleFailure(cc, hook.FormAfterCreate(ctx, model))
}
func formBeforeUpdate(ctx context.Context, cc *CompiledController, model any) error {
hook, ok := cc.Controller.(pact.FormBeforeUpdate)
if !ok || hook == nil {
return nil
}
return lifecycleFailure(cc, hook.FormBeforeUpdate(ctx, model))
}
func formAfterUpdate(ctx context.Context, cc *CompiledController, model any) error {
hook, ok := cc.Controller.(pact.FormAfterUpdate)
if !ok || hook == nil {
return nil
}
return lifecycleFailure(cc, hook.FormAfterUpdate(ctx, model))
}
func deleteRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any) error {
if hook, ok := cc.Controller.(pact.FormBeforeDelete); ok && hook != nil {
if err := lifecycleFailure(cc, hook.FormBeforeDelete(ctx, model)); err != nil {
return err
}
}
if err := tx.WithContext(ctx).Delete(model).Error; err != nil {
return lifecycleFailure(cc, err)
}
if hook, ok := cc.Controller.(pact.FormAfterDelete); ok && hook != nil {
if err := lifecycleFailure(cc, hook.FormAfterDelete(ctx, model)); err != nil {
return err
}
}
return nil
}
func decodeObject(r *http.Request) (map[string]any, error) {
dec := json.NewDecoder(r.Body)
dec.UseNumber()
var body map[string]any
if err := dec.Decode(&body); err != nil {
return nil, &ValidationError{Details: map[string]any{"body": []string{"The request body is invalid."}}}
}
if body == nil {
body = map[string]any{}
}
return body, nil
}
func pathID(r *http.Request) (uint, error) {
raw := strings.TrimSpace(r.PathValue("id"))
n, err := strconv.ParseUint(raw, 10, 64)
if raw == "" || err != nil {
return 0, &ValidationError{Details: map[string]any{"id": []string{"The id field must be an integer."}}}
}
return uint(n), nil
}
func projectOperation(cc *CompiledController, body map[string]any, op string) map[string]any {
projected := ProjectWritableFields(cc, body)
if op == "" || cc == nil {
return projected
}
filtered := map[string]any{}
for _, field := range cc.Writable {
if !contextAllows(cc, field.Name, op) {
continue
}
if val, ok := projected[field.FillKey]; ok {
filtered[field.FillKey] = val
}
}
return filtered
}
func fillAllowed(cc *CompiledController, model any, op string) []string {
fillable, ok := model.(lagoon.HasFillable)
if !ok || fillable == nil {
return nil
}
allowed := map[string]struct{}{}
for _, key := range fillable.Fillable() {
if !protectedFillKey(key) {
allowed[key] = struct{}{}
}
}
out := make([]string, 0, len(allowed))
if cc == nil {
return out
}
for _, field := range cc.Writable {
if protectedFillKey(field.FillKey) {
continue
}
if _, ok := allowed[field.FillKey]; !ok || !contextAllows(cc, field.Name, op) {
continue
}
out = append(out, field.FillKey)
}
return out
}
// mergedRules combines the base rules with the form's `required` flags. The
// base is the model's Rules(), or the set a controller implementing
// pact.FormRules returns for op (create or update), which replaces the model's
// rules for admin saves. A field whose `context` hides it on op cannot be
// supplied there, so its form level `required` does not apply to that
// operation.
func mergedRules(ctx context.Context, cc *CompiledController, model any, op string) map[string]string {
out := map[string]string{}
var own pact.FormRules
if cc != nil && cc.Controller != nil && (op == "create" || op == "update") {
own, _ = cc.Controller.(pact.FormRules)
}
if own != nil {
for key, rule := range own.FormRules(ctx, op) {
out[key] = rule
}
} else if rules, ok := model.(hasRules); ok && rules != nil {
for key, rule := range rules.Rules() {
out[key] = rule
}
}
if cc == nil || cc.Form == nil {
return out
}
for _, field := range cc.Form.Fields {
// Relation fields are not writable columns. required stays on the
// schema for the client, but it cannot be checked by Fill. A virtual
// field is checked against its submitted value.
// A repeater's required is checked on its rows (liftRepeaterValues),
// a taglist's on its tags (checkTaglistValues).
if field.Required && field.Type != repeaterType && field.Type != taglistType && (valueFormField(field) || cc.virtual[field.Name]) && contextAllows(cc, field.Name, op) {
out[field.Name] = mergeRequired(out[field.Name])
}
}
return out
}
// virtualFieldSet is the set of names a controller lists through
// pact.FormVirtualFields; nil when it lists none.
func virtualFieldSet(ctl pact.AdminController) map[string]bool {
src, ok := ctl.(pact.FormVirtualFields)
if !ok || src == nil {
return nil
}
names := src.FormVirtualFields()
if len(names) == 0 {
return nil
}
out := make(map[string]bool, len(names))
for _, name := range names {
out[name] = true
}
return out
}
// liftVirtualValues collects the submitted values of the form's virtual
// fields (pact.FormVirtualFields) for op: only fields present in the body
// whose context allows the operation. A nested value is validation_failed on
// the field. The result is never nil.
func liftVirtualValues(cc *CompiledController, body map[string]any, op string) (map[string]any, error) {
out := map[string]any{}
if cc == nil || cc.Form == nil || len(cc.virtual) == 0 {
return out, nil
}
for _, field := range cc.Form.Fields {
if !cc.virtual[field.Name] || !contextAllows(cc, field.Name, op) {
continue
}
value, present := body[field.Name]
if !present {
continue
}
if nestedValue(value) {
return nil, &ValidationError{Details: fillTypeDetails(field.Name)}
}
out[field.Name] = value
}
return out, nil
}
// virtualValuesForRules is valuesForRules for a controller save: a rule on a
// virtual field sees the submitted value, or nothing when the field was not
// submitted, and never the model column of the same name (a stored password
// hash, for example). Every submitted virtual value is present, so `confirmed`
// and `different` can read a field no rule names.
func virtualValuesForRules(cc *CompiledController, model any, rules map[string]string, virtual map[string]any) map[string]any {
out := valuesForRules(model, rules)
if cc == nil || len(cc.virtual) == 0 {
return out
}
for name := range cc.virtual {
delete(out, name)
}
for name, value := range virtual {
out[name] = value
}
return out
}
func mergeRequired(rule string) string {
if strings.TrimSpace(rule) == "" {
return "required"
}
for _, tok := range strings.Split(rule, "|") {
name, _, _ := strings.Cut(strings.TrimSpace(tok), ":")
if name == "required" {
return rule
}
}
return rule + "|required"
}
func valuesForRules(model any, rules map[string]string) map[string]any {
v := reflect.ValueOf(model)
for v.Kind() == reflect.Pointer {
if v.IsNil() {
return map[string]any{}
}
v = v.Elem()
}
out := map[string]any{}
for field := range rules {
f := fieldByColumn(v, field)
if f.IsValid() && f.CanInterface() {
out[field] = f.Interface()
}
}
return out
}
// fillTypeDetails is the 422 detail for a value lagoon.Fill could not store
// in its column. Writable fill keys equal their form field names.
func fillTypeDetails(key string) map[string]any {
return map[string]any{key: []string{"The " + key + " field has an invalid value."}}
}
func validationDetails(msgs map[string][]string) map[string]any {
out := make(map[string]any, len(msgs))
for key, messages := range msgs {
out[key] = messages
}
return out
}
func projectRecord(cc *CompiledController, model any) map[string]any {
v := reflect.ValueOf(model)
for v.Kind() == reflect.Pointer {
if v.IsNil() {
return map[string]any{}
}
v = v.Elem()
}
out := map[string]any{}
if id := fieldByColumn(v, primaryColumn(model)); id.IsValid() && id.CanInterface() {
out["id"] = id.Interface()
}
if cc == nil {
return out
}
for _, field := range cc.Writable {
if protectedFillKey(field.FillKey) {
continue
}
f := fieldByColumn(v, field.FillKey)
if f.IsValid() && f.CanInterface() {
out[field.Name] = f.Interface()
}
}
return out
}
func modelColumns(model any) map[string]struct{} {
cols := map[string]struct{}{}
for _, mf := range modelFields(reflect.TypeOf(model)) {
if name := gormColumn(mf.Field); name != "" {
cols[name] = struct{}{}
}
}
return cols
}
func scalarFormField(typ string) bool {
switch typ {
case "text", "textarea", "number", "checkbox", "switch", "dropdown", "datepicker",
"markdown", "mltext", "mltextarea", "mlmarkdown", "richeditor", moneyType:
return true
default:
return false
}
}
// valueFormField reports whether a controller form field binds to its model
// column: a scalar built-in type or a plugin field type.
func valueFormField(field FormField) bool {
return scalarFormField(field.Type) || field.fieldType != nil || field.Type == repeaterType || field.Type == taglistType
}
func protectedFillKey(key string) bool {
switch strings.ToLower(key) {
case "id", "created_at", "updated_at", "deleted_at",
"owner_id", "user_id", "collection_id", "organisation_id", "organization_id",
"scope_id", "role_id", "permissions",
"is_superuser", "is_system", "is_activated", "password":
return true
default:
return false
}
}
func nestedValue(val any) bool {
switch val.(type) {
case map[string]any, []any:
return true
default:
return false
}
}
func contextAllows(cc *CompiledController, name, op string) bool {
if op == "" || cc == nil || cc.Form == nil {
return true
}
for _, field := range cc.Form.Fields {
if field.Name != name {
continue
}
if field.Context == nil || len(field.Context.values) == 0 {
return true
}
for _, value := range field.Context.values {
if value == op {
return true
}
}
return false
}
return true
}
func coercePK(model any, id any) (any, error) {
n, err := asUint(id)
if err != nil {
return nil, &ValidationError{Details: map[string]any{"id": []string{"The id field must be an integer."}}}
}
return castPK(model, n), nil
}
func castPK(model any, n uint) any {
t := reflect.TypeOf(model)
for t != nil && t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t == nil || t.Kind() != reflect.Struct {
return n
}
for _, mf := range modelFields(t) {
field := mf.Field
if !hasPrimaryKeyTag(field) {
continue
}
switch field.Type.Kind() {
case reflect.Uint:
return uint(n)
case reflect.Uint32:
return uint32(n)
case reflect.Uint64:
return uint64(n)
case reflect.Int:
return int(n)
case reflect.Int64:
return int64(n)
case reflect.String:
return strconv.FormatUint(uint64(n), 10)
default:
return n
}
}
return n
}
func asUint(id any) (uint, error) {
switch n := id.(type) {
case uint:
return n, nil
case uint32:
return uint(n), nil
case uint64:
if uint64(uint(n)) != n {
return 0, errBadID
}
return uint(n), nil
case int:
if n < 0 {
return 0, errBadID
}
return uint(n), nil
case int64:
if n < 0 {
return 0, errBadID
}
return uint(n), nil
case float64:
if n < 0 || n != math.Trunc(n) || n > math.MaxUint32 && strconv.IntSize == 32 {
return 0, errBadID
}
return uint(n), nil
case json.Number:
i, err := n.Int64()
if err != nil || i < 0 {
return 0, errBadID
}
return uint(i), nil
case string:
i, err := strconv.ParseUint(n, 10, 64)
if err != nil {
return 0, errBadID
}
return uint(i), nil
default:
return 0, errBadID
}
}
var errBadID = errors.New("cabana: bad id")