Files
summercms/modules/cabana/crud.go
Jakub Zych 71073bc8a2 feat(12.1-01): cabana.ForbiddenError answers a refused write with 403
- hooks and bulk, record, toolbar and widget actions may return it
- 403 forbidden with the localized message and field details; the write's
  transaction is rolled back; other errors stay the opaque 500
- form shows a refused save as a persistent banner and keeps the values;
  a refused delete is a toast
- smoke tests, OpenAPI notes, dist, README, docs
2026-10-04 23:53:34 +02:00

1235 lines
34 KiB
Go

package cabana
import (
"context"
"encoding/json"
"errors"
"log/slog"
"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
}
// 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.
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" }
// localizeForbidden returns err with a *ForbiddenError's Message and Details
// strings translated in the request locale; any other error, and a nil one,
// is returned unchanged. The plugin's error value is never modified: it may
// be a shared variable.
func localizeForbidden(ctx context.Context, tr *phrasebook.Translator, err error) error {
var refused *ForbiddenError
if err == nil || !errors.As(err, &refused) || refused == nil {
return err
}
if ctx == nil {
ctx = context.Background()
}
ctx = towel.WithLocale(ctx, schemaLocale(ctx, tr))
out := &ForbiddenError{Message: translateKey(ctx, tr, refused.Message)}
if len(refused.Details) > 0 {
out.Details = make(map[string]any, len(refused.Details))
for field, value := range refused.Details {
switch messages := value.(type) {
case string:
out.Details[field] = []string{translateKey(ctx, tr, messages)}
case []string:
list := make([]string, len(messages))
for i, text := range messages {
list[i] = translateKey(ctx, tr, text)
}
out.Details[field] = list
case []any:
list := make([]any, len(messages))
for i, item := range messages {
if text, ok := item.(string); ok {
list[i] = translateKey(ctx, tr, text)
} else {
list[i] = item
}
}
out.Details[field] = list
default:
out.Details[field] = value
}
}
}
return out
}
// transaction runs fn in lagoon.Transaction on the service's database and
// localizes a *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 localizeForbidden(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
}
for key, val := range body {
fillKey, ok := allowed[key]
if !ok || nestedValue(val) {
continue
}
out[fillKey] = val
}
return out
}
// BindWritableFields records schema field names onto model column fill keys.
// Protected columns are omitted. A 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())
bindings := make([]WritableField, 0, len(cc.Form.Fields))
for _, field := range cc.Form.Fields {
if !scalarFormField(field.Type) || protectedFillKey(field.Name) {
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.
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))
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
}
// 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) {
slog.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).
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
}
result.Meta.Actions, err = s.offeredRecordActions(withTx(ctx, tx), cc, target)
return err
})
if err != nil {
return RecordResult{}, err
}
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
}
}
out = append(out, RecordAction{
Name: name,
Label: translateKey(ctx, s.tr, action.Label),
Confirm: translateKey(ctx, s.tr, action.Confirm),
})
}
return out, nil
}
// 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.
func (s CRUDService) RecordAction(ctx context.Context, cc *CompiledController, id any, name 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})
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" }
// projectFullRecord is the D-18 record shape: scalar writable fields, relation
// values keyed by field name, and their labels.
func projectFullRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any) (RecordResult, error) {
data := projectRecord(cc, model)
meta, err := projectRelationFields(ctx, tx, cc, model, data)
if err != nil {
return RecordResult{}, err
}
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"
}
// 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
}
var result RecordResult
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
}
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)
if err := lagoon.Fill(target, fillAllowed(cc, target, op), 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(cc, target, op)
msgs, err := lagoon.Validate(ctx, tx, target, rules, valuesForRules(target, rules), 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-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
}
if err := assignBelongsTo(cc, target, relations); 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)
}
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
}
result, err = projectFullRecord(ctx, tx, cc, target)
return err
})
if err != nil {
return RecordResult{}, err
}
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
}
func lockScoped(ctx context.Context, tx *gorm.DB, cc *CompiledController, proto any, ids []uint) ([]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 := tx.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)
}
err := q.Clauses(clause.Locking{Strength: "UPDATE"}).
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 model's rules with the form's `required` flags. 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(cc *CompiledController, model any, op string) map[string]string {
out := map[string]string{}
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.
if field.Required && scalarFormField(field.Type) && contextAllows(cc, field.Name, op) {
out[field.Name] = mergeRequired(out[field.Name])
}
}
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":
return true
default:
return false
}
}
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")