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")