package cabana import ( "context" "encoding/json" "errors" "io" "net/http" "strconv" "strings" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "gorm.io/gorm" "gorm.io/gorm/clause" ) // relationParent is the parent record of a relation route: a saved record // loaded through FormExtendQuery, or (id 0) the record being created in the // admin's form session, whose relation work is held against key. type relationParent struct { model any id uint // key is the unsaved parent's deferred-binding key (D-03), nil for a // saved parent; morph is the related model's morph type, the slave type // of the relation's bindings. key *lagoon.DeferredKey morph string } // unsaved reports whether the parent is the record being created. func (p *relationParent) unsaved() bool { return p != nil && p.key != nil } // loadParent loads the parent record of a relation route through // loadRecord (FormExtendQuery, FOR UPDATE). Id 0 is the record being // created in the s.SessionKey session: it needs a deferrable relation, the // controller's create operation, the relation-manager field in the create // context and a backend admin; the parent is then a fresh zero-key record. // A missing or hidden parent, and id 0 without all of that, are // recordNotFound. func (s RelationService) loadParent(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, ownerID uint) (*relationParent, error) { parent, err := newWritableModel(cc) if err != nil { return nil, err } morph, err := lagoon.MorphType(tx, cr.Contract.NewRelated()) if err != nil { return nil, lifecycleFailure(cc, err) } if ownerID > 0 { if err := loadRecord(ctx, tx, cc, parent, castPK(parent, ownerID)); err != nil { return nil, err } return &relationParent{model: parent, id: ownerID, morph: morph}, nil } if s.SessionKey == "" || !cr.deferrable || !cc.operationDeclared("create") || !contextAllows(cc, cr.fieldName, "create") { return nil, recordNotFound{} } principal, _ := bouncer.User(ctx) if principal == nil || !principal.Backend || principal.ID == 0 { return nil, recordNotFound{} } master, err := lagoon.MorphType(tx, parent) if err != nil { return nil, lifecycleFailure(cc, err) } key := lagoon.DeferredKey{SessionKey: s.SessionKey, AdminID: principal.ID, MasterType: master} return &relationParent{model: parent, key: &key, morph: morph}, nil } // boundSlaves is the subquery of the unsaved parent's pending binds for the // relation (WinterCMS withDeferred): CAST(pk AS TEXT) IN (?). func (p *relationParent) boundSlaves(tx *gorm.DB, cr *CompiledRelation) *gorm.DB { return lagoon.DeferredSlaves(tx, *p.key, cr.Contract.Name, p.morph, true) } // relationQuery is relationBaseQuery for a resolved parent. On an unsaved // parent the linked rows are the session's pending binds, and candidates // leave those out. func relationQuery(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent *relationParent, candidates bool) (*gorm.DB, any, error) { if !parent.unsaved() { return relationBaseQuery(ctx, tx, cc, cr, parent.model, candidates) } target := cr.Contract.NewRelated() column := quotedIdent(tx, tableName(target)) + "." + quotedIdent(tx, primaryColumn(target)) if !candidates { return tx.WithContext(ctx).Model(target).Where("CAST("+column+" AS TEXT) IN (?)", parent.boundSlaves(tx, cr)), target, nil } q, target, err := relationBaseQuery(ctx, tx, cc, cr, parent.model, true) if err != nil { return nil, nil, err } return q.Where("CAST("+column+" AS TEXT) NOT IN (?)", parent.boundSlaves(tx, cr)), target, nil } // fillChild fills and validates a child of a relation form like the // controller save does: the body is projected through the form's writable // fields for op, filled with lagoon.Fill, checked by BeforeValidate, the // model rules merged with the form's required flags, and the datepicker // bounds. A failure is a 422 ValidationError. func (s RelationService) fillChild(ctx context.Context, tx *gorm.DB, form *CompiledController, model any, body map[string]any, op string) error { projected := projectOperation(form, body, op) if err := lagoon.Fill(model, fillAllowed(form, model, op), projected, false); err != nil { var typed *lagoon.FillTypeError if errors.As(err, &typed) { return &ValidationError{Details: fillTypeDetails(typed.Key)} } return &CapabilityError{ControllerID: controllerID(form)} } if hook, ok := model.(lagoon.HasBeforeValidate); ok && hook != nil { if err := hook.BeforeValidate(tx); err != nil { return &CapabilityError{ControllerID: controllerID(form)} } } rules := mergedRules(form, model, op) msgs, err := lagoon.Validate(ctx, tx, model, rules, valuesForRules(model, rules), nil) if err != nil { return &CapabilityError{ControllerID: controllerID(form)} } for field, extra := range dateBoundDetails(ctx, s.tr, form, model, op) { if msgs == nil { msgs = map[string][]string{} } msgs[field] = append(msgs[field], extra...) } if len(msgs) > 0 { return &ValidationError{Details: validationDetails(msgs)} } return nil } // CreateChild creates a related record through the relation's manage form // (D-11, D-16) and attaches it to the parent: a hasMany child gets the // parent's key in its ForeignKey (set by the server, never from the body), // a belongsToMany record gets a pivot row (RelationBeforeLink stamps its // hook columns). The parent is loaded through FormExtendQuery. The child is // filled and validated like a controller save, and the controller's // optional pact.RelationBeforeCreate and pact.RelationAfterCreate hooks run // around the insert; any failure rolls the whole create back. func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in RecordInput) (RecordResult, error) { if s.DB == nil { return RecordResult{}, errors.New("cabana: database is not configured") } cr, err := relationOf(cc, relation) if err != nil { return RecordResult{}, err } if cr.child == nil { return RecordResult{}, &CapabilityError{ControllerID: controllerID(cc)} } var result RecordResult err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { ctx = withTx(ctx, tx) parent, err := s.loadParent(ctx, tx, cc, cr, ownerID) if err != nil { return err } child, err := newWritableModel(cr.child) if err != nil { return err } if cr.hasMany() && !parent.unsaved() { if err := setModelColumn(child, cr.Contract.ForeignKey, parent.id); err != nil { return lifecycleFailure(cc, err) } } if err := s.fillChild(ctx, tx, cr.child, child, in.Body, "create"); err != nil { return err } if hook, ok := cc.Controller.(pact.RelationBeforeCreate); ok && hook != nil { if err := hook.RelationBeforeCreate(ctx, cr.Contract.Name, parent.model, child); err != nil { return lifecycleFailure(cc, err) } } if err := tx.WithContext(ctx).Create(child).Error; err != nil { return lifecycleFailure(cc, err) } switch { case parent.unsaved(): // The child exists now, unattached; the parent's first save // attaches it, the purge deletes it if that never happens. env := &lagoon.DeferredEnvelope{Created: true} if err := lagoon.DeferredBind(ctx, tx, *parent.key, cr.Contract.Name, parent.morph, uitoa(pkUint(child)), env); err != nil { return lifecycleFailure(cc, err) } case !cr.hasMany(): if err := insertPivot(ctx, tx, cc, cr, parent.model, child, nil); err != nil { return lifecycleFailure(cc, err) } } if err := s.commitChildFiles(ctx, tx, cr, child, "create", in); err != nil { return err } if hook, ok := cc.Controller.(pact.RelationAfterCreate); ok && hook != nil { if err := hook.RelationAfterCreate(ctx, cr.Contract.Name, parent.model, child); err != nil { return lifecycleFailure(cc, err) } } result, err = projectFullRecord(ctx, tx, cr.child, child) return err }) if err != nil { return RecordResult{}, err } return result, nil } // loadChild finds one child of the parent with a single query that carries // the parent predicate (D-15): on a hasMany the child's ForeignKey must be // the parent's key, on a belongsToMany a pivot row must link the child to // the parent, and on an unsaved parent the child must be bound in the // admin's own session. A child of another parent, or another admin's // pending child, is recordNotFound (404, never 403). lock adds FOR UPDATE. func loadChild(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, parent *relationParent, childID uint, lock bool) (any, error) { if parent == nil || childID == 0 { return nil, recordNotFound{} } child := cr.Contract.NewRelated() table := tableName(child) pk := clause.Column{Table: table, Name: primaryColumn(child)} q := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(child). Where(clause.Eq{Column: pk, Value: castPK(child, childID)}) switch { case parent.unsaved(): column := quotedIdent(tx, table) + "." + quotedIdent(tx, primaryColumn(child)) q = q.Where("CAST("+column+" AS TEXT) IN (?)", parent.boundSlaves(tx, cr)) case cr.hasMany(): q = q.Where(clause.Eq{Column: clause.Column{Table: table, Name: cr.Contract.ForeignKey}, Value: parent.id}) default: linked := "EXISTS (SELECT 1 FROM " + quotedIdent(tx, tableName(cr.Contract.NewPivot())) + " p WHERE p." + quotedIdent(tx, cr.Contract.ParentForeignKey) + " = ? AND p." + quotedIdent(tx, cr.Contract.RelatedForeignKey) + " = " + quotedIdent(tx, table) + "." + quotedIdent(tx, primaryColumn(child)) + ")" q = q.Where(linked, parent.id) } if lock { q = q.Clauses(clause.Locking{Strength: "UPDATE"}) } if err := q.Take(child).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, recordNotFound{} } return nil, err } return child, nil } // childForm is the form a child is shown with: the manage form when the // relation declares update, else the view form. func (cr *CompiledRelation) childForm() *CompiledController { if cr.allows("update") && cr.child != nil { return cr.child } return cr.view } // ShowChild loads one child of the parent (D-15) and projects it through // the manage form when the relation declares update, else the view form. func (s RelationService) ShowChild(ctx context.Context, cc *CompiledController, relation string, ownerID, childID uint) (RecordResult, error) { if s.DB == nil { return RecordResult{}, errors.New("cabana: database is not configured") } cr, err := relationOf(cc, relation) if err != nil { return RecordResult{}, err } form := cr.childForm() if form == nil { return RecordResult{}, recordNotFound{} } var result RecordResult err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { ctx = withTx(ctx, tx) parent, err := s.loadParent(ctx, tx, cc, cr, ownerID) if err != nil { return err } child, err := loadChild(ctx, tx, cr, parent, childID, false) if err != nil { return err } result, err = projectFullRecord(ctx, tx, form, child) return err }) if err != nil { return RecordResult{}, err } return result, nil } // UpdateChild saves one child of the parent through the manage form (D-16): // the child is loaded under the parent scope and locked, filled and // validated like a controller update, and saved through its model between // the optional pact.RelationBeforeUpdate and pact.RelationAfterUpdate hooks. func (s RelationService) UpdateChild(ctx context.Context, cc *CompiledController, relation string, ownerID, childID uint, in RecordInput) (RecordResult, error) { if s.DB == nil { return RecordResult{}, errors.New("cabana: database is not configured") } cr, err := relationOf(cc, relation) if err != nil { return RecordResult{}, err } if cr.child == nil { return RecordResult{}, &CapabilityError{ControllerID: controllerID(cc)} } var result RecordResult err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { ctx = withTx(ctx, tx) parent, err := s.loadParent(ctx, tx, cc, cr, ownerID) if err != nil { return err } child, err := loadChild(ctx, tx, cr, parent, childID, true) if err != nil { return err } if err := s.fillChild(ctx, tx, cr.child, child, in.Body, "update"); err != nil { return err } if hook, ok := cc.Controller.(pact.RelationBeforeUpdate); ok && hook != nil { if err := hook.RelationBeforeUpdate(ctx, cr.Contract.Name, parent.model, child); err != nil { return lifecycleFailure(cc, err) } } if err := tx.WithContext(ctx).Save(child).Error; err != nil { return lifecycleFailure(cc, err) } if err := s.commitChildFiles(ctx, tx, cr, child, "update", in); err != nil { return err } if hook, ok := cc.Controller.(pact.RelationAfterUpdate); ok && hook != nil { if err := hook.RelationAfterUpdate(ctx, cr.Contract.Name, parent.model, child); err != nil { return lifecycleFailure(cc, err) } } result, err = projectFullRecord(ctx, tx, cr.child, child) return err }) if err != nil { return RecordResult{}, err } return result, nil } // DeleteChildren deletes children of the parent (D-12). Every id must be a // child of this parent, or the whole request is recordNotFound and nothing // is deleted. Per child the optional pact.RelationBeforeDelete runs, then a // hasMany child is deleted through its model (hooks and soft delete run), // a belongsToMany record first loses this parent's pivot row and is then // deleted through its model, then pact.RelationAfterDelete runs. func (s RelationService) DeleteChildren(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in BulkDeleteInput) (BulkResult, error) { if s.DB == nil { return BulkResult{}, errors.New("cabana: database is not configured") } ids, err := normalizeIDs(in.IDs) if err != nil { return BulkResult{}, err } cr, err := relationOf(cc, relation) if 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) parent, err := s.loadParent(ctx, tx, cc, cr, ownerID) if err != nil { return err } children := make([]any, 0, len(ids)) for _, id := range ids { child, err := loadChild(ctx, tx, cr, parent, id, true) if err != nil { return err } children = append(children, child) } for _, child := range children { if parent.unsaved() { if _, err := lagoon.DeferredUnbind(ctx, tx, *parent.key, cr.Contract.Name, parent.morph, uitoa(pkUint(child))); err != nil { return lifecycleFailure(cc, err) } } if err := s.deleteChild(ctx, tx, cc, cr, parent, child); err != nil { return err } } result.Deleted = len(children) return nil }) if err != nil { return BulkResult{}, err } return result, nil } // deleteChild deletes one loaded child of the parent through its model. func (s RelationService) deleteChild(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent *relationParent, child any) error { if hook, ok := cc.Controller.(pact.RelationBeforeDelete); ok && hook != nil { if err := hook.RelationBeforeDelete(ctx, cr.Contract.Name, parent.model, child); err != nil { return lifecycleFailure(cc, err) } } if !cr.hasMany() && parent.id > 0 { rows, err := lockPivotRows(ctx, tx, cr, parent.id, []uint{pkUint(child)}) if err != nil { return lifecycleFailure(cc, err) } for _, row := range rows { if err := tx.WithContext(ctx).Delete(row).Error; err != nil { return lifecycleFailure(cc, err) } } } if err := tx.WithContext(ctx).Delete(child).Error; err != nil { return lifecycleFailure(cc, err) } if hook, ok := cc.Controller.(pact.RelationAfterDelete); ok && hook != nil { if err := hook.RelationAfterDelete(ctx, cr.Contract.Name, parent.model, child); err != nil { return lifecycleFailure(cc, err) } } return nil } // loadPivotRow loads this parent's pivot row for the related id, locked; a // missing row is recordNotFound. func loadPivotRow(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, parent *relationParent, childID uint) (any, error) { if childID == 0 { return nil, recordNotFound{} } rows, err := lockPivotRows(ctx, tx, cr, parent.id, []uint{childID}) if err != nil { return nil, err } if len(rows) == 0 { return nil, recordNotFound{} } return rows[0], nil } // ShowPivot returns the pivot form values of the link between the parent // and one related record (D-14). func (s RelationService) ShowPivot(ctx context.Context, cc *CompiledController, relation string, ownerID, childID uint) (map[string]any, error) { if s.DB == nil { return nil, errors.New("cabana: database is not configured") } cr, err := relationOf(cc, relation) if err != nil { return nil, err } if cr.pivot == nil { return nil, recordNotFound{} } var data map[string]any err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { ctx = withTx(ctx, tx) parent, err := s.loadParent(ctx, tx, cc, cr, ownerID) if err != nil { return err } if parent.unsaved() { bind, err := findPendingBind(ctx, tx, cr, parent, childID) if err != nil { return err } env, err := bind.Envelope() if err != nil { return lifecycleFailure(cc, err) } row := cr.Contract.NewPivot() _ = lagoon.Fill(row, pivotFillKeys(cr), ProjectWritableFields(cr.pivot, env.Pivot), false) data = pivotRecord(cr, row, childID) return nil } row, err := loadPivotRow(ctx, tx, cr, parent, childID) if err != nil { return err } data = pivotRecord(cr, row, childID) return nil }) return data, err } // UpdatePivot saves pivot form values on the link between the parent and // one related record (D-14): only pivot form fields are accepted (an // unknown key is a 422), and the pivot model is saved through GORM. func (s RelationService) UpdatePivot(ctx context.Context, cc *CompiledController, relation string, ownerID, childID uint, values map[string]any) (map[string]any, error) { if s.DB == nil { return nil, errors.New("cabana: database is not configured") } cr, err := relationOf(cc, relation) if err != nil { return nil, err } if cr.pivot == nil { return nil, recordNotFound{} } var data map[string]any err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { ctx = withTx(ctx, tx) parent, err := s.loadParent(ctx, tx, cc, cr, ownerID) if err != nil { return err } if parent.unsaved() { data, err = s.updatePendingPivot(ctx, tx, cc, cr, parent, childID, values) return err } row, err := loadPivotRow(ctx, tx, cr, parent, childID) if err != nil { return err } if err := s.fillPivot(ctx, tx, cr, row, values); err != nil { return err } if err := tx.WithContext(ctx).Save(row).Error; err != nil { return lifecycleFailure(cc, err) } data = pivotRecord(cr, row, childID) return nil }) return data, err } // findPendingBind loads, locked, the unsaved parent's pending bind of one // related record; a missing bind is recordNotFound. func findPendingBind(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, parent *relationParent, childID uint) (*lagoon.DeferredBinding, error) { var row lagoon.DeferredBinding err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Clauses(clause.Locking{Strength: "UPDATE"}). Where("session_key = ? AND backend_user_id = ? AND master_type = ? AND master_field = ? AND slave_type = ? AND slave_id = ? AND is_bind", parent.key.SessionKey, parent.key.AdminID, parent.key.MasterType, cr.Contract.Name, parent.morph, uitoa(childID)). Order("id").Take(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, recordNotFound{} } if err != nil { return nil, err } return &row, nil } // pivotFillKeys are the pivot form's writable columns. func pivotFillKeys(cr *CompiledRelation) []string { out := make([]string, 0, len(cr.pivot.Writable)) for _, field := range cr.pivot.Writable { out = append(out, field.FillKey) } return out } // updatePendingPivot saves pivot form values on a pending link of an // unsaved parent: they are whitelisted and validated exactly as on a saved // parent and stored in the bind's envelope, which the parent's first save // writes to the pivot row. func (s RelationService) updatePendingPivot(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent *relationParent, childID uint, values map[string]any) (map[string]any, error) { bind, err := findPendingBind(ctx, tx, cr, parent, childID) if err != nil { return nil, err } env, err := bind.Envelope() if err != nil { return nil, lifecycleFailure(cc, err) } row := cr.Contract.NewPivot() _ = lagoon.Fill(row, pivotFillKeys(cr), ProjectWritableFields(cr.pivot, env.Pivot), false) if err := s.fillPivot(ctx, tx, cr, row, values); err != nil { return nil, err } merged := map[string]any{} for key, value := range ProjectWritableFields(cr.pivot, env.Pivot) { merged[key] = value } for key, value := range ProjectWritableFields(cr.pivot, values) { merged[key] = value } env.Pivot = merged raw, err := json.Marshal(env) if err != nil { return nil, lifecycleFailure(cc, err) } if err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(&lagoon.DeferredBinding{}). Where("id = ?", bind.ID).Update("pivot_data", string(raw)).Error; err != nil { return nil, lifecycleFailure(cc, err) } return pivotRecord(cr, row, childID), nil } // commitChildFiles applies the child form's own file bindings (the // X-Child-Session-Key session, in.SessionKey) to the saved child inside the // child's transaction, then rechecks the child's file limits (D-17). func (s RelationService) commitChildFiles(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, child any, op string, in RecordInput) error { crud := CRUDService{DB: s.DB, bucket: s.bucket, tr: s.tr} return crud.commitDeferred(ctx, tx, cr.child, child, op, RecordInput{SessionKey: in.SessionKey}) } // pivotRecord projects a pivot row through the pivot form, keyed by the // related record's id. func pivotRecord(cr *CompiledRelation, row any, childID uint) map[string]any { data := projectRecord(cr.pivot, row) data["id"] = childID return data } // childFileScope resolves a relation child file route (D-17) inside tx: // the relation's manage form field, the parent (a saved record through // FormExtendQuery, or id 0 in the X-Session-Key session) and the child. // Child 0 is the child not created yet and needs X-Child-Session-Key; a // saved child must pass loadChild under the parent. The file key is the // child form's key, the admin and the related model's morph type, so the // child's create or update save commits the files. Anything else is // recordNotFound. func childFileScope(ctx context.Context, tx *gorm.DB, r *http.Request, cc *CompiledController, cr *CompiledRelation, childID uint) (*fileScope, error) { cf := cr.child.files[r.PathValue("field")] if cf == nil { return nil, recordNotFound{} } id, err := pathID(r) if err != nil { return nil, err } parentKey, _, err := sessionKeyFrom(r) if err != nil { return nil, err } key, hasKey, err := childSessionKeyFrom(r) if err != nil { return nil, err } op := "update" if childID == 0 { op = "create" if !hasKey { return nil, recordNotFound{} } } if !contextAllows(cr.child, cf.name, op) { return nil, recordNotFound{} } parent, err := (RelationService{SessionKey: parentKey}).loadParent(ctx, tx, cc, cr, id) if err != nil { return nil, err } sc := &fileScope{cc: cr.child, file: cf, ownerID: childID, morph: parent.morph} if hasKey { principal, _ := bouncer.User(ctx) if principal == nil || principal.ID == 0 { return nil, recordNotFound{} } sc.key = lagoon.DeferredKey{SessionKey: key, AdminID: principal.ID, MasterType: parent.morph} sc.hasKey = true } if childID > 0 { child, err := loadChild(ctx, tx, cr, parent, childID, true) if err != nil { return nil, err } sc.owner = child } return sc, nil } // relationButton resolves the route's relation and refuses (403) a route // whose toolbar button the view panel does not declare. An unknown relation // is 404. It writes the response and returns nil on refusal. func (s *service) relationButton(w http.ResponseWriter, r *http.Request, cc *CompiledController, button string) *CompiledRelation { return s.relationAllowed(w, r, cc, func(cr *CompiledRelation) bool { return cr.allows(button) }) } // decodeCappedObject decodes a JSON object body capped at // http.body_limits.default_bytes; trailing data is refused. func (s *service) decodeCappedObject(w http.ResponseWriter, r *http.Request) (map[string]any, error) { dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, s.jsonCap())) dec.UseNumber() var body map[string]any if err := dec.Decode(&body); err != nil { var tooBig *http.MaxBytesError if errors.As(err, &tooBig) { return nil, err } return nil, invalidBody() } var trailing any if err := dec.Decode(&trailing); err != io.EOF { return nil, invalidBody() } if body == nil { body = map[string]any{} } return body, nil } // writeRelationError maps a relation child route failure: a body past the // cap is 413 payload_too_large, everything else as writeCRUDError. func writeRelationError(w http.ResponseWriter, err error) { var tooBig *http.MaxBytesError if errors.As(err, &tooBig) { WriteError(w, http.StatusRequestEntityTooLarge, "payload_too_large", msgPayloadTooLarge) return } writeCRUDError(w, err) } // relationChildCreate serves POST .../{id}/relations/{name}/records. func (s *service) relationChildCreate(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { cr := s.relationButton(w, r, cc, "create") if cr == nil { return } id, err := pathID(r) if err != nil { writeCRUDError(w, err) return } body, err := s.decodeCappedObject(w, r) if err != nil { writeRelationError(w, err) return } svc, ok := s.relationsFor(w, r) if !ok { return } childKey, _, err := childSessionKeyFrom(r) if err != nil { writeCRUDError(w, err) return } rec, err := svc.CreateChild(r.Context(), cc, cr.Contract.Name, id, RecordInput{Body: body, SessionKey: childKey}) if err != nil { writeRelationError(w, err) return } WriteData(w, http.StatusCreated, rec.Data, rec.Meta) }) } // pathChildID parses {child}; anything but a positive integer is not found. func pathChildID(r *http.Request) (uint, error) { n, err := strconv.ParseUint(strings.TrimSpace(r.PathValue("child")), 10, 64) if err != nil || n == 0 { return 0, recordNotFound{} } return uint(n), nil } // relationAllowed resolves the route's relation and refuses (403) unless // allowed reports the relation declares what the route needs. An unknown // relation is 404. It writes the response and returns nil on refusal. func (s *service) relationAllowed(w http.ResponseWriter, r *http.Request, cc *CompiledController, allowed func(*CompiledRelation) bool) *CompiledRelation { cr, err := relationOf(cc, r.PathValue("name")) if err != nil { writeCRUDError(w, err) return nil } if !allowed(cr) { if principal, _ := bouncer.User(r.Context()); principal != nil { s.logAuth(r, "denied", principal.ID) } WriteError(w, http.StatusForbidden, "forbidden", msgForbidden) return nil } return cr } // relationIDs parses {id} and {child}. func relationIDs(r *http.Request) (uint, uint, error) { id, err := pathID(r) if err != nil { return 0, 0, err } child, err := pathChildID(r) if err != nil { return 0, 0, err } return id, child, nil } // relationChildShow serves GET .../{id}/relations/{name}/records/{child}: // allowed when the relation declares update (with a manage form) or has a // view form. func (s *service) relationChildShow(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { cr := s.relationAllowed(w, r, cc, func(cr *CompiledRelation) bool { return cr.childForm() != nil }) if cr == nil { return } id, child, err := relationIDs(r) if err != nil { writeCRUDError(w, err) return } svc, ok := s.relationsFor(w, r) if !ok { return } rec, err := svc.ShowChild(r.Context(), cc, cr.Contract.Name, id, child) if err != nil { writeRelationError(w, err) return } WriteData(w, http.StatusOK, rec.Data, rec.Meta) }) } // relationChildUpdate serves PUT .../{id}/relations/{name}/records/{child}. func (s *service) relationChildUpdate(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { cr := s.relationButton(w, r, cc, "update") if cr == nil { return } id, child, err := relationIDs(r) if err != nil { writeCRUDError(w, err) return } body, err := s.decodeCappedObject(w, r) if err != nil { writeRelationError(w, err) return } svc, ok := s.relationsFor(w, r) if !ok { return } childKey, _, err := childSessionKeyFrom(r) if err != nil { writeCRUDError(w, err) return } rec, err := svc.UpdateChild(r.Context(), cc, cr.Contract.Name, id, child, RecordInput{Body: body, SessionKey: childKey}) if err != nil { writeRelationError(w, err) return } WriteData(w, http.StatusOK, rec.Data, rec.Meta) }) } // relationChildDelete serves POST .../{id}/relations/{name}/delete with // {ids}. func (s *service) relationChildDelete(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { cr := s.relationButton(w, r, cc, "delete") if cr == nil { return } id, err := pathID(r) if err != nil { writeCRUDError(w, err) return } var in BulkDeleteInput if err := s.decodeStrictNumbers(w, r, &in); err != nil { writeRelationError(w, err) return } svc, ok := s.relationsFor(w, r) if !ok { return } result, err := svc.DeleteChildren(r.Context(), cc, cr.Contract.Name, id, in) if err != nil { writeRelationError(w, err) return } WriteData(w, http.StatusOK, result, nil) }) } // decodeStrictNumbers decodes a capped JSON body into dest with numbers // kept as json.Number, unknown keys and trailing data refused. func (s *service) decodeStrictNumbers(w http.ResponseWriter, r *http.Request, dest any) error { dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, s.jsonCap())) dec.UseNumber() dec.DisallowUnknownFields() if err := dec.Decode(dest); err != nil { var tooBig *http.MaxBytesError if errors.As(err, &tooBig) { return err } return invalidBody() } var trailing any if err := dec.Decode(&trailing); err != io.EOF { return invalidBody() } return nil } // pivotAllowed: the pivot routes need a pivot form and the link or update // button. func pivotAllowed(cr *CompiledRelation) bool { return cr.pivot != nil && (cr.allows("link") || cr.allows("update")) } // relationPivotShow serves GET .../{id}/relations/{name}/pivot/{child}. func (s *service) relationPivotShow(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { cr := s.relationAllowed(w, r, cc, pivotAllowed) if cr == nil { return } id, child, err := relationIDs(r) if err != nil { writeCRUDError(w, err) return } svc, ok := s.relationsFor(w, r) if !ok { return } data, err := svc.ShowPivot(r.Context(), cc, cr.Contract.Name, id, child) if err != nil { writeRelationError(w, err) return } WriteData(w, http.StatusOK, data, nil) }) } // relationPivotUpdate serves PUT .../{id}/relations/{name}/pivot/{child}. func (s *service) relationPivotUpdate(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { cr := s.relationAllowed(w, r, cc, pivotAllowed) if cr == nil { return } id, child, err := relationIDs(r) if err != nil { writeCRUDError(w, err) return } body, err := s.decodeCappedObject(w, r) if err != nil { writeRelationError(w, err) return } svc, ok := s.relationsFor(w, r) if !ok { return } data, err := svc.UpdatePivot(r.Context(), cc, cr.Contract.Name, id, child, body) if err != nil { writeRelationError(w, err) return } WriteData(w, http.StatusOK, data, nil) }) }