- DELETE, PUT and POST reorder under .../{id}/files/{field}, each scoped by one parent query (404 for a foreign file)
- protected download and thumb routes: is_public=false only, nosniff, private no-store, sandbox CSP, inline only for jpeg/png/gif/webp
- the save applies deferred removals, replaces attachOne files and rechecks maxFiles and required
- blobs of deleted files are removed after commit
- swagger2openapi emits binary content for file responses
- admin OpenAPI, TS types, conformance, README and attachments docs
949 lines
25 KiB
Go
949 lines
25 KiB
Go
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"
|
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
|
"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"`
|
|
}
|
|
|
|
// 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 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 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")
|