package cabana import ( "context" "encoding/json" "errors" "math" "net/http" "reflect" "sort" "strconv" "strings" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "gorm.io/gorm" "gorm.io/gorm/clause" ) // CRUDService runs schema-projected record and bulk operations. type CRUDService struct { DB *gorm.DB } // RecordInput is a decoded JSON object. Keys are untrusted. type RecordInput struct { Body map[string]any } // 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"` } // ValidationError is a D-10 validation_failed failure. type ValidationError struct { Details map[string]any } func (e *ValidationError) Error() string { return "validation_failed" } // 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 := lagoon.Transaction(ctx, s.DB, 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 = lagoon.Transaction(ctx, s.DB, 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 } // 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() } 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) return err }) if err != nil { return RecordResult{}, err } return result, nil } // 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 = lagoon.Transaction(ctx, s.DB, 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)} } 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 { err = tx.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 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 ve *ValidationError if errors.As(err, &ve) { WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed", ve.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 } 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 } var closed *CapabilityError if errors.As(err, &closed) { return err } var partial partialSelection if errors.As(err, &partial) { 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": 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")