package cabana import ( "context" "encoding/json" "errors" "io" "net/http" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "gorm.io/gorm" ) // relationParent is the parent record of a relation route, loaded through // FormExtendQuery. type relationParent struct { model any id uint } // loadParent loads the parent record of a relation route through // loadRecord (FormExtendQuery, FOR UPDATE). A missing or hidden parent, and // id 0, are recordNotFound. func (s RelationService) loadParent(ctx context.Context, tx *gorm.DB, cc *CompiledController, ownerID uint) (*relationParent, error) { parent, err := newWritableModel(cc) if err != nil { return nil, err } if ownerID == 0 { return nil, recordNotFound{} } if err := loadRecord(ctx, tx, cc, parent, castPK(parent, ownerID)); err != nil { return nil, err } return &relationParent{model: parent, id: ownerID}, 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, ownerID) if err != nil { return err } child, err := newWritableModel(cr.child) if err != nil { return err } if cr.hasMany() { 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) } if !cr.hasMany() { if err := insertPivot(ctx, tx, cc, cr, parent.model, child, nil); err != nil { return lifecycleFailure(cc, 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 } // 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 { cr, err := relationOf(cc, r.PathValue("name")) if err != nil { writeCRUDError(w, err) return nil } if !cr.allows(button) { if principal, _ := bouncer.User(r.Context()); principal != nil { s.logAuth(r, "denied", principal.ID) } WriteError(w, http.StatusForbidden, "forbidden", msgForbidden) return nil } return cr } // 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, err := s.relations() if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } rec, err := svc.CreateChild(r.Context(), cc, cr.Contract.Name, id, RecordInput{Body: body}) if err != nil { writeRelationError(w, err) return } WriteData(w, http.StatusCreated, rec.Data, rec.Meta) }) }