package cabana import ( "context" "errors" "fmt" "io/fs" "log/slog" "net/http" "path" "reflect" "sort" "strconv" "strings" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/towel" "gorm.io/gorm" "gorm.io/gorm/clause" ) // msgReorderStale is the phrase key of the 409 a reorder answers when the // records changed since the tree was loaded (backend::lang). const msgReorderStale = "backend::lang.reorder.stale" // maxReorderPartitions caps the partition choices a reorder page offers. const maxReorderPartitions = 500 // reorderDocument is config_reorder.yaml (D-09): WinterCMS's // reorder_config.yaml keys title, nameFrom and modelClass, plus the parent // and sort columns, the depth limit and an optional partition. type reorderDocument struct { Title string `yaml:"title"` NameFrom string `yaml:"nameFrom"` ModelClass string `yaml:"modelClass"` ParentColumn string `yaml:"parentColumn"` SortColumn string `yaml:"sortColumn"` MaxDepth int `yaml:"maxDepth"` Partition *reorderPartitionY `yaml:"partition"` } type reorderPartitionY struct { Column string `yaml:"column"` Relation string `yaml:"relation"` NameFrom string `yaml:"nameFrom"` Label string `yaml:"label"` } // compiledReorder is a controller's compiled config_reorder.yaml. type compiledReorder struct { title string nameFrom string parentColumn string sortColumn string maxDepth int partition *compiledPartition } type compiledPartition struct { column string relation string label string labelCol string fieldRel *CompiledFieldRelation } // ListReorder is the list schema's `reorder` object: the controller has a // tree reorder page (config_reorder.yaml). Partition is set when the tree is // reordered within one value of a partition column, such as one menu. type ListReorder struct { Title string `json:"title"` Partition *ListReorderPartition `json:"partition,omitempty"` } // ListReorderPartition names the partition column and its label. type ListReorderPartition struct { Column string `json:"column"` Label string `json:"label,omitempty"` } // compileReorder reads the controller's optional config_reorder.yaml. Every // unknown key, a column that is not a column of the controller's record, a // partition relation that is not a belongsTo field relation of the form over // the partition column, and a negative maxDepth fail boot. It also enforces // that toolbar.buttons lists reorder only on a controller that has the file. func compileReorder(pluginID string, cc *CompiledController, fsys fs.FS) error { ctl := cc.Controller dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/") cfgPath := path.Join(dir, "config_reorder.yaml") listHasReorder := cc.List != nil && hasToolbarButton(cc.List, "reorder") if _, err := fs.Stat(fsys, cfgPath); err != nil { if !errors.Is(err, fs.ErrNotExist) { return bootErr(pluginID, ctl.ID(), cfgPath, err) } if listHasReorder { return bootErr(pluginID, ctl.ID(), "config_list.yaml", fmt.Errorf("toolbar.buttons: reorder needs config_reorder.yaml")) } return nil } raw, err := readAsset(fsys, cfgPath) if err != nil { return bootErr(pluginID, ctl.ID(), cfgPath, err) } var doc reorderDocument if err := decodeStrict(raw, &doc); err != nil { return bootErr(pluginID, ctl.ID(), cfgPath, err) } fail := func(err error) error { return bootErr(pluginID, ctl.ID(), cfgPath, err) } if doc.ModelClass != "" && doc.ModelClass != ctl.ModelName() { return fail(fmt.Errorf("modelClass %q does not match %q", doc.ModelClass, ctl.ModelName())) } src, ok := ctl.(pact.AdminRecordSource) if !ok || src == nil || src.NewRecord() == nil { return fail(fmt.Errorf("a reorder page needs an AdminRecordSource")) } if cc.List == nil { return fail(fmt.Errorf("a reorder page needs config_list.yaml")) } cols := modelColumns(src.NewRecord()) out := &compiledReorder{ title: doc.Title, nameFrom: doc.NameFrom, parentColumn: doc.ParentColumn, sortColumn: doc.SortColumn, maxDepth: doc.MaxDepth, } if out.parentColumn == "" { out.parentColumn = "parent_id" } if out.sortColumn == "" { out.sortColumn = "sort_order" } if doc.MaxDepth < 0 { return fail(fmt.Errorf("maxDepth must be 0 (unlimited) or more")) } if !identifier(out.nameFrom) { return fail(fmt.Errorf("nameFrom %q is not a column name", out.nameFrom)) } for key, column := range map[string]string{"nameFrom": out.nameFrom, "parentColumn": out.parentColumn, "sortColumn": out.sortColumn} { if _, ok := cols[column]; !ok || !identifier(column) { return fail(fmt.Errorf("%s %s is not a column of the record", key, column)) } } if p := doc.Partition; p != nil { if _, ok := cols[p.Column]; !ok || !identifier(p.Column) { return fail(fmt.Errorf("partition.column %s is not a column of the record", p.Column)) } fr, ok := cc.FieldRelations[p.Relation] if !ok || fr.Contract.Kind != relationKindBelongsTo || fr.Contract.ForeignKey != p.Column { return fail(fmt.Errorf("partition.relation %s must be a belongsTo relation field of the form over %s", p.Relation, p.Column)) } labelCol := fr.LabelColumn if p.NameFrom != "" { if !identifier(p.NameFrom) { return fail(fmt.Errorf("partition.nameFrom %q is not a column name", p.NameFrom)) } if _, ok := modelColumns(fr.Contract.NewRelated())[p.NameFrom]; !ok { return fail(fmt.Errorf("partition.nameFrom %s is not a column of the related record", p.NameFrom)) } labelCol = p.NameFrom } out.partition = &compiledPartition{column: p.Column, relation: p.Relation, label: p.Label, labelCol: labelCol, fieldRel: fr} } cc.reorder = out reorder := &ListReorder{Title: out.title} if out.partition != nil { reorder.Partition = &ListReorderPartition{Column: out.partition.column, Label: out.partition.label} } cc.List.Reorder = reorder return nil } // AdminReorderPartitionOption is one partition a reorder page offers. type AdminReorderPartitionOption struct { Value uint `json:"value"` Label string `json:"label"` } // AdminReorderPartition is the partition of a reorder tree: its label, the // partitions the administrator may choose and the one shown (null when there // is none to choose). type AdminReorderPartition struct { Label string `json:"label"` Options []AdminReorderPartitionOption `json:"options"` Value *uint `json:"value"` } // AdminReorderNode is one record of a reorder tree. type AdminReorderNode struct { ID uint `json:"id"` ParentID *uint `json:"parent_id"` SortOrder int64 `json:"sort_order"` Name string `json:"name"` } // AdminReorderTree is the data of the reorder routes: the page title, the // partition (null without one) and every record of the scoped set, ordered by // the sort column and then the id. type AdminReorderTree struct { Title string `json:"title"` Partition *AdminReorderPartition `json:"partition"` Nodes []AdminReorderNode `json:"nodes"` } // AdminReorderNodeInput is one node of a reorder save: the record id, its new // parent (null at the top) and its new sort value. type AdminReorderNodeInput struct { ID uint64 `json:"id"` ParentID *uint64 `json:"parent_id"` SortOrder int64 `json:"sort_order"` } // AdminReorderRequest is the body of POST .../reorder: the partition the tree // belongs to (required when the page has one) and every node of the scoped // set, exactly once. type AdminReorderRequest struct { Partition *uint64 `json:"partition"` Nodes []AdminReorderNodeInput `json:"nodes"` } // reorderStale is the 409 of a reorder save whose node set no longer equals // the locked scoped set. type reorderStale struct{} func (reorderStale) Error() string { return "cabana: reorder set changed" } // reorderGet serves GET {vendor}/{plugin}/{controller}/reorder?partition=. func (s *service) reorderGet(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { if cc.reorder == nil || cc.singleton { writeNotFound(w, r) return } db, err := s.db() if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } var partition *uint64 if raw := strings.TrimSpace(r.URL.Query().Get("partition")); raw != "" { n, err := strconv.ParseUint(raw, 10, 64) if err != nil || n == 0 { WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed", map[string]any{"partition": []string{"The partition field must be an id."}}) return } partition = &n } ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), s.translator())) var tree AdminReorderTree err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var err error tree, err = s.reorderTree(withTx(ctx, tx), tx, cc, partition, false) return err }) if err != nil { s.writeReorderError(w, r, cc, err) return } WriteData(w, http.StatusOK, tree, map[string]any{"locale": schemaLocale(r.Context(), s.translator())}) }) } // reorderPost serves POST {vendor}/{plugin}/{controller}/reorder: one // transaction locks the scoped set (ListExtendQuery plus the partition), // requires the submitted ids to equal it exactly (409 reorder_stale // otherwise), refuses a duplicate id, a parent outside the set, a cycle and a // depth above maxDepth (422), then writes the parent and sort columns of // every node and answers the new tree. func (s *service) reorderPost(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { if cc.reorder == nil || cc.singleton { writeNotFound(w, r) return } var in AdminReorderRequest if err := s.decodeStrictBody(w, r, &in); err != nil { writeCRUDError(w, err) return } db, err := s.db() if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), s.translator())) var tree AdminReorderTree err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { txCtx := withTx(ctx, tx) if cc.reorder.partition != nil && in.Partition == nil { return &ValidationError{Details: map[string]any{"partition": []string{"The partition field is required."}}} } locked, err := s.reorderTree(txCtx, tx, cc, in.Partition, true) if err != nil { return err } if err := checkReorderNodes(cc.reorder, locked.Nodes, in.Nodes); err != nil { return err } proto := cc.Controller.(pact.AdminRecordSource).NewRecord() pk := primaryColumn(proto) for _, node := range in.Nodes { var parent any if node.ParentID != nil { parent = castPK(proto, uint(*node.ParentID)) } // UpdateColumns writes exactly the two columns and runs no // model hooks, so a save hook that renumbers sort values // cannot undo the new order. err := tx.WithContext(txCtx).Model(cc.Controller.(pact.AdminRecordSource).NewRecord()). Where(clause.Eq{Column: clause.Column{Name: pk}, Value: castPK(proto, uint(node.ID))}). UpdateColumns(map[string]any{cc.reorder.parentColumn: parent, cc.reorder.sortColumn: node.SortOrder}).Error if err != nil { return lifecycleFailure(cc, err) } } tree, err = s.reorderTree(txCtx, tx, cc, in.Partition, false) return err }) if err != nil { s.writeReorderError(w, r, cc, err) return } WriteData(w, http.StatusOK, tree, map[string]any{"locale": schemaLocale(r.Context(), s.translator())}) }) } func (s *service) writeReorderError(w http.ResponseWriter, r *http.Request, cc *CompiledController, err error) { var stale reorderStale if errors.As(err, &stale) { ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), s.translator())) WriteError(w, http.StatusConflict, "reorder_stale", translateKey(ctx, s.translator(), msgReorderStale)) return } var ve *ValidationError var missing recordNotFound if !errors.As(err, &ve) && !errors.As(err, &missing) { slog.Error("cabana: reorder failed", "controller", controllerID(cc), "error", err) } writeCRUDError(w, err) } // reorderTree loads the scoped set of a reorder page: the records the list // scope (ListExtendQuery) returns within the partition, ordered by the sort // column and then the primary key; lock adds FOR UPDATE. A partition that is // not among the offered ones is not found; without one requested, the first // offered partition is shown. func (s *service) reorderTree(ctx context.Context, tx *gorm.DB, cc *CompiledController, requested *uint64, lock bool) (AdminReorderTree, error) { ro := cc.reorder tr := s.translator() tree := AdminReorderTree{Title: translateKey(ctx, tr, ro.title), Nodes: []AdminReorderNode{}} proto := cc.Controller.(pact.AdminRecordSource).NewRecord() q := tx.WithContext(ctx).Model(proto) if ext, ok := cc.Controller.(pact.ListExtendQuery); ok && ext != nil { if next := ext.ListExtendQuery(ctx, q); next != nil { q = next } } table := tableName(proto) if p := ro.partition; p != nil { options, err := reorderPartitions(ctx, tx, cc, p) if err != nil { return AdminReorderTree{}, err } part := &AdminReorderPartition{Label: translateKey(ctx, tr, p.label), Options: options} tree.Partition = part switch { case requested != nil: found := false for _, opt := range options { found = found || uint64(opt.Value) == *requested } if !found { return AdminReorderTree{}, recordNotFound{} } v := uint(*requested) part.Value = &v case len(options) > 0: v := options[0].Value part.Value = &v default: return tree, nil } q = q.Where(clause.Eq{Column: clause.Column{Table: table, Name: p.column}, Value: *part.Value}) } if lock { q = q.Clauses(clause.Locking{Strength: "UPDATE"}) } pk := primaryColumn(proto) holder := reflect.New(reflect.SliceOf(reflect.TypeOf(proto).Elem())) err := q.Order(clause.OrderByColumn{Column: clause.Column{Table: table, Name: ro.sortColumn}}). Order(clause.OrderByColumn{Column: clause.Column{Table: table, Name: pk}}). Find(holder.Interface()).Error if err != nil { return AdminReorderTree{}, lifecycleFailure(cc, err) } rows := holder.Elem() for i := 0; i < rows.Len(); i++ { row := rows.Index(i) node := AdminReorderNode{ID: uintOf(fieldByColumn(row, pk))} if parent := uintOf(fieldByColumn(row, ro.parentColumn)); parent != 0 { node.ParentID = &parent } node.SortOrder = intOf(fieldByColumn(row, ro.sortColumn)) if f := fieldByColumn(row, ro.nameFrom); f.IsValid() && f.CanInterface() { if v := derefValue(f.Interface()); v != nil { node.Name = fmt.Sprint(v) } } tree.Nodes = append(tree.Nodes, node) } return tree, nil } // reorderPartitions lists the partitions the administrator may choose: the // related records the partition relation field offers (its // RelationExtendOptionsQuery scope), ordered by label then id. func reorderPartitions(ctx context.Context, tx *gorm.DB, cc *CompiledController, p *compiledPartition) ([]AdminReorderPartitionOption, error) { related := p.fieldRel.Contract.NewRelated() table := tableName(related) pk := primaryColumn(related) q := scopedRelationQuery(ctx, tx, cc, p.relation, p.fieldRel) label := labelExpression(q, table, p.labelCol) rows := []AdminReorderPartitionOption{} err := q.Select(quotedIdent(q, table) + "." + quotedIdent(q, pk) + " AS value, " + label + " AS label"). Order(label). Order(clause.OrderByColumn{Column: clause.Column{Table: table, Name: pk}}). Limit(maxReorderPartitions). Scan(&rows).Error if err != nil { return nil, lifecycleFailure(cc, err) } return rows, nil } // checkReorderNodes compares a submitted tree with the locked set: a // duplicate id is a 422, any difference between the two id sets a 409, and a // parent outside the set, a cycle or a depth above maxDepth a 422. func checkReorderNodes(ro *compiledReorder, locked []AdminReorderNode, nodes []AdminReorderNodeInput) error { invalid := func(msg string) error { return &ValidationError{Details: map[string]any{"nodes": []string{msg}}} } parents := make(map[uint64]uint64, len(nodes)) for _, node := range nodes { if node.ID == 0 { return invalid("The nodes field must list record ids.") } if _, dup := parents[node.ID]; dup { return invalid("The nodes field lists a record twice.") } parent := uint64(0) if node.ParentID != nil { parent = *node.ParentID } parents[node.ID] = parent } if len(parents) != len(locked) { return reorderStale{} } for _, node := range locked { if _, ok := parents[uint64(node.ID)]; !ok { return reorderStale{} } } ids := make([]uint64, 0, len(parents)) for id, parent := range parents { ids = append(ids, id) if parent == 0 { continue } if parent == id { return invalid("A record cannot be its own parent.") } if _, ok := parents[parent]; !ok { return invalid("A parent is not one of the reordered records.") } } sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) for _, id := range ids { depth := 1 for cur := parents[id]; cur != 0; cur = parents[cur] { depth++ if depth > len(parents) { return invalid("The tree has a cycle.") } } if ro.maxDepth > 0 && depth > ro.maxDepth { return invalid(fmt.Sprintf("The tree may be at most %d levels deep.", ro.maxDepth)) } } return nil } // uintOf reads an unsigned id from a struct field (a pointer is // dereferenced; nil and non-numbers are 0). func uintOf(f reflect.Value) uint { if !f.IsValid() || !f.CanInterface() { return 0 } v := reflect.ValueOf(derefValue(f.Interface())) switch v.Kind() { case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: return uint(v.Uint()) case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: if v.Int() > 0 { return uint(v.Int()) } } return 0 } // intOf reads an integer from a struct field (a pointer is dereferenced; nil // and non-numbers are 0). func intOf(f reflect.Value) int64 { if !f.IsValid() || !f.CanInterface() { return 0 } v := reflect.ValueOf(derefValue(f.Interface())) switch v.Kind() { case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: return v.Int() case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: return int64(v.Uint()) } return 0 }