package cabana import ( "context" "encoding/json" "errors" "fmt" "log/slog" "net/http" "reflect" "slices" "strconv" "strings" "time" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "gorm.io/gorm" "gorm.io/gorm/clause" gormschema "gorm.io/gorm/schema" ) // ListInput is the raw admin list query. Identifiers are matched later // against the compiled schema; nothing here is SQL. type ListInput struct { Search string Sort string Dir string Page string PerPage string Filters map[string]string } // ListMeta is the D-11 pagination block. RowStates are the states of the // page's rows keyed by row id (a decimal string), each a subset of deleted, // negative and disabled in that order; rows without a state are left out, and // the key is absent when the controller reports no state at all. type ListMeta struct { Page int `json:"page"` PerPage int `json:"per_page"` Total int64 `json:"total"` LastPage int `json:"last_page"` RowStates map[string][]string `json:"row_states,omitempty"` } // ListResult is one page of projected rows. type ListResult struct { Data []map[string]any Meta ListMeta } // ListValidationError is a D-10 validation_failed failure. type ListValidationError struct { Details map[string]any } func (e *ListValidationError) Error() string { return "validation_failed" } func listInvalid(field, message string) *ListValidationError { return &ListValidationError{Details: map[string]any{field: []string{message}}} } func listQueryFromRequest(r *http.Request) ListInput { q := r.URL.Query() filters := map[string]string{} for key, vals := range q { if !strings.HasPrefix(key, "filter[") || !strings.HasSuffix(key, "]") || len(key) <= len("filter[]") { continue } name := key[len("filter[") : len(key)-1] if len(vals) == 0 { filters[name] = "" continue } filters[name] = vals[len(vals)-1] } return ListInput{ Search: q.Get(listSearchTerm), Sort: q.Get("sort"), Dir: q.Get("dir"), Page: q.Get("page"), PerPage: q.Get("per_page"), Filters: filters, } } // ExecuteList runs an allowlisted search, sort, filter, and page against db. func ExecuteList(ctx context.Context, db *gorm.DB, cc *CompiledController, in ListInput) (*ListResult, error) { if cc == nil || cc.List == nil { return nil, errors.New("cabana: list schema is missing") } if db == nil { return nil, errors.New("cabana: database is not configured") } src, ok := cc.Controller.(pact.AdminRecordSource) if !ok || src == nil || src.NewRecord() == nil { return nil, errors.New("cabana: admin controller has no record source") } model := src.NewRecord() mt := reflect.TypeOf(model) if mt == nil || mt.Kind() != reflect.Pointer || mt.Elem().Kind() != reflect.Struct { return nil, errors.New("cabana: admin model must be a pointer") } if ctx == nil { ctx = context.Background() } page, per, err := normalizePage(cc.List, in) if err != nil { return nil, err } sortCol, sortDesc, err := normalizeSort(cc.List, in) if err != nil { return nil, err } q := db.WithContext(ctx).Model(model) if ext, ok := cc.Controller.(pact.ListExtendQuery); ok && ext != nil { if next := ext.ListExtendQuery(ctx, q); next != nil { q = next } } q, err = applyListSearch(q, cc.Controller, cc.List, model, in.Search) if err != nil { return nil, err } q, err = applyListFilters(q, cc.List, model, in.Filters) if err != nil { return nil, err } var total int64 if err := q.Session(&gorm.Session{}).Count(&total).Error; err != nil { return nil, err } q, err = applyListOrder(q, cc.Controller, cc.List, model, sortCol, sortDesc) if err != nil { return nil, err } for _, col := range cc.List.Columns { if col.Relation == "" { continue } if field, ok := relationFieldName(model, col.Relation); ok { q = q.Preload(field) } } offset := (page - 1) * per slice := reflect.New(reflect.SliceOf(mt.Elem())) if err := q.Offset(offset).Limit(per).Find(slice.Interface()).Error; err != nil { return nil, err } values := slice.Elem() data := make([]map[string]any, 0, values.Len()) records := make([]any, 0, values.Len()) for i := 0; i < values.Len(); i++ { record := values.Index(i).Addr().Interface() records = append(records, record) data = append(data, projectRowCtx(ctx, record, cc.Controller, cc.List.Columns)) } states, err := listRowStates(ctx, db, cc, records) if err != nil { return nil, err } paged := lagoon.Paginate(data, page, per, total) return &ListResult{ Data: paged.Data, Meta: ListMeta{ Page: paged.Meta.CurrentPage, PerPage: paged.Meta.PerPage, Total: paged.Meta.Total, LastPage: paged.Meta.LastPage, RowStates: states, }, }, nil } // rowStateOrder is the fixed set of row states and the order they are sent in. var rowStateOrder = []pact.RowState{pact.RowStateDeleted, pact.RowStateNegative, pact.RowStateDisabled} // listRowStates asks a controller implementing pact.ListRowStates for the // states of one page of records, in one call (D-12). Each row keeps only the // known states, once each, in the fixed order; an unknown value is dropped // and logged, never sent. The result is keyed by primary key and nil when no // row has a state. A hook error or a result that is not index-aligned with // records fails the list. func listRowStates(ctx context.Context, db *gorm.DB, cc *CompiledController, records []any) (map[string][]string, error) { hook, ok := cc.Controller.(pact.ListRowStates) if !ok || hook == nil || len(records) == 0 { return nil, nil } result, err := hook.ListRowStates(ctx, db.WithContext(ctx), records) if err != nil { slog.Error("cabana: list row states failed", "controller", controllerID(cc), "error", err) return nil, err } if len(result) != len(records) { slog.Error("cabana: list row states are not aligned with the page", "controller", controllerID(cc), "records", len(records), "states", len(result)) return nil, fmt.Errorf("cabana: controller %s returned %d row states for %d records", controllerID(cc), len(result), len(records)) } var out map[string][]string for i, states := range result { if len(states) == 0 { continue } has := map[pact.RowState]bool{} for _, state := range states { if !slices.Contains(rowStateOrder, state) { slog.Warn("cabana: unknown list row state dropped", "controller", controllerID(cc), "state", string(state)) continue } has[state] = true } kept := make([]string, 0, len(has)) for _, state := range rowStateOrder { if has[state] { kept = append(kept, string(state)) } } if len(kept) == 0 { continue } if out == nil { out = map[string][]string{} } out[uitoa(pkUint(records[i]))] = kept } return out, nil } func normalizePage(schema *ListSchema, in ListInput) (int, int, error) { page := 1 if in.Page != "" { n, err := strconv.Atoi(in.Page) if err != nil || n < 1 { return 0, 0, listInvalid("page", "must be a positive integer") } page = n } per := schema.RecordsPerPage if per < 1 { per = 20 } if in.PerPage != "" { n, err := strconv.Atoi(in.PerPage) if err != nil || !pageSizeAllowed(schema, n) { return 0, 0, listInvalid("per_page", "is not an allowed page size") } per = n } return page, per, nil } func pageSizeAllowed(schema *ListSchema, n int) bool { if n < 1 { return false } options := schema.PerPageOptions if len(options) == 0 { return n == schema.RecordsPerPage || (schema.RecordsPerPage < 1 && n == 20) } for _, option := range options { if option == n { return true } } return false } func normalizeSort(schema *ListSchema, in ListInput) (string, bool, error) { if in.Sort == "" { if schema.DefaultSort == nil { return "", false, nil } return schema.DefaultSort.Column, schema.DefaultSort.Direction == "desc", nil } if !schema.ShowSorting { return "", false, listInvalid("sort", "is not sortable") } var match *ListColumn for i := range schema.Columns { if schema.Columns[i].Key == in.Sort { match = &schema.Columns[i] break } } if match == nil || !match.Sortable { return "", false, listInvalid("sort", "is not a sortable column") } dir := in.Dir if dir == "" { dir = "asc" } if dir != "asc" && dir != "desc" { return "", false, listInvalid("dir", "must be asc or desc") } return match.Key, dir == "desc", nil } func applyListOrder(db *gorm.DB, controller pact.AdminController, schema *ListSchema, model any, column string, desc bool) (*gorm.DB, error) { table := tableName(model) pk := primaryColumn(model) if column != "" { order := clause.OrderByColumn{Column: clause.Column{Table: table, Name: column}, Desc: desc} for _, col := range schema.Columns { if col.Key != column || col.Relation == "" { continue } field, ok := relationFieldName(model, col.Relation) if !ok { continue } sub, err := relationSubquery(db, model, field, listRelationColumn(controller, col.Relation, col.Select), 0, "sort") if err != nil { return nil, err } if sub.toMany { // Winter sorts a to-many column on its group_concat sub-select; // string_agg in the same order is the comma-joined value the // list displays (relatedSelectMany). A raw column merges with // the primary-key tiebreak below into one ORDER BY. value := "CAST(" + sub.column + " AS TEXT)" order.Column = clause.Column{ Name: fmt.Sprintf("(SELECT string_agg(%s, ', ' ORDER BY %s) FROM %s WHERE %s)", value, value, sub.from, sub.where), Raw: true, } break } // A to-one relation is joined; GORM aliases the joined // association with the Go field name. db = db.Joins(field) order.Column = clause.Column{Table: field, Name: sub.name} break } db = db.Order(order) } if column != pk { db = db.Order(clause.OrderByColumn{Column: clause.Column{Table: table, Name: pk}}) } return db, nil } // relationSQL is the FROM and WHERE of a subquery over the related records // of one relation, correlated to the outer list query's main table. It holds // no bound values: every name in it comes from GORM's parsed relationship and // is quoted by the dialector. type relationSQL struct { // from is the related table aliased cabana_r; a belongsToMany // relation adds its pivot aliased cabana_p, joined to the related // table. from string // where correlates the related records to the main table's row and // skips soft-deleted related records. where string // column is the selected related column, qualified with the alias. column string // name is the selected column's physical name. name string // toMany is true for hasMany and belongsToMany relations. toMany bool } // relationSubquery builds the subquery parts for the relation behind the Go // field of model, selecting column from the related table. Table, pivot and // key names come from GORM's parsed relationship, never from a naming // convention, so custom foreignKey, joinForeignKey and many2many tags and the // naming strategy are honored. The aliases carry n, which keeps a // self-referencing relation apart from the main table and two subqueries of // one statement apart from each other. A missing relation, a column the // related model lacks and polymorphic relations are refused as validation // failures on param. func relationSubquery(db *gorm.DB, model any, field, column string, n int, param string) (relationSQL, error) { invalid := listInvalid(param, "is not a searchable relation column") if param == "sort" { invalid = listInvalid(param, "is not a sortable column") } stmt := &gorm.Statement{DB: db} if err := stmt.Parse(model); err != nil { return relationSQL{}, err } rel, ok := stmt.Schema.Relationships.Relations[field] if !ok || rel == nil || rel.FieldSchema == nil || rel.Polymorphic != nil || !identifier(column) { return relationSQL{}, invalid } selected := rel.FieldSchema.LookUpField(column) if selected == nil || selected.DBName == "" { return relationSQL{}, invalid } for _, ref := range rel.References { if ref == nil || ref.PrimaryValue != "" || ref.PrimaryKey == nil || ref.ForeignKey == nil { return relationSQL{}, invalid } } main := stmt.Schema.Table related := fmt.Sprintf("cabana_r%d", n) out := relationSQL{ column: qualifiedColumn(db, related, selected.DBName), name: selected.DBName, } var on, where []string switch rel.Type { case gormschema.Many2Many: if rel.JoinTable == nil { return relationSQL{}, invalid } pivot := fmt.Sprintf("cabana_p%d", n) for _, ref := range rel.References { if ref.OwnPrimaryKey { // pivot owner key = main primary key where = append(where, qualifiedColumn(db, pivot, ref.ForeignKey.DBName)+" = "+qualifiedColumn(db, main, ref.PrimaryKey.DBName)) } else { // pivot related key = related primary key on = append(on, qualifiedColumn(db, pivot, ref.ForeignKey.DBName)+" = "+qualifiedColumn(db, related, ref.PrimaryKey.DBName)) } } if len(on) == 0 || len(where) == 0 { return relationSQL{}, invalid } out.from = fmt.Sprintf("%s AS %s JOIN %s AS %s ON %s", quotedIdent(db, rel.JoinTable.Table), quotedIdent(db, pivot), quotedIdent(db, rel.FieldSchema.Table), quotedIdent(db, related), strings.Join(on, " AND ")) out.toMany = true case gormschema.HasMany, gormschema.HasOne: for _, ref := range rel.References { // related foreign key = main primary key where = append(where, qualifiedColumn(db, related, ref.ForeignKey.DBName)+" = "+qualifiedColumn(db, main, ref.PrimaryKey.DBName)) } out.from = quotedIdent(db, rel.FieldSchema.Table) + " AS " + quotedIdent(db, related) out.toMany = rel.Type == gormschema.HasMany case gormschema.BelongsTo: for _, ref := range rel.References { // related primary key = main foreign key where = append(where, qualifiedColumn(db, related, ref.PrimaryKey.DBName)+" = "+qualifiedColumn(db, main, ref.ForeignKey.DBName)) } out.from = quotedIdent(db, rel.FieldSchema.Table) + " AS " + quotedIdent(db, related) default: return relationSQL{}, invalid } if len(where) == 0 { return relationSQL{}, invalid } // Winter's whereHas applies the related model's soft-delete scope. for _, f := range rel.FieldSchema.Fields { if f.DBName != "" && f.FieldType == deletedAtType { where = append(where, qualifiedColumn(db, related, f.DBName)+" IS NULL") break } } out.where = strings.Join(where, " AND ") return out, nil } var deletedAtType = reflect.TypeOf(gorm.DeletedAt{}) func applyListSearch(db *gorm.DB, controller pact.AdminController, schema *ListSchema, model any, term string) (*gorm.DB, error) { term = strings.TrimSpace(term) if term == "" { return db, nil } var cols []ListColumn for _, col := range schema.Columns { if col.Searchable { cols = append(cols, col) } } if len(cols) == 0 { return nil, listInvalid("search", "is not searchable") } pattern := "%" + escapeLike(strings.ToLower(term)) + "%" parts := make([]string, 0, len(cols)) args := make([]any, 0, len(cols)) main := tableName(model) n := 0 for _, col := range cols { if col.Relation != "" { field, ok := relationFieldName(model, col.Relation) if !ok { return nil, listInvalid("search", "is not searchable") } // Every relation type is searched through a correlated EXISTS // (Winter's orWhereHas), so the outer query gains no JOIN: a // record matching through several related rows is counted and // listed once, and unqualified ListExtendQuery or scope // predicates stay unambiguous. sub, err := relationSubquery(db, model, field, listRelationColumn(controller, col.Relation, col.Select), n, "search") if err != nil { return nil, err } n++ parts = append(parts, fmt.Sprintf("EXISTS (SELECT 1 FROM %s WHERE %s AND LOWER(CAST(%s AS TEXT)) LIKE ? ESCAPE '\\')", sub.from, sub.where, sub.column)) args = append(args, pattern) continue } if !identifier(col.Key) { return nil, listInvalid("search", "is not searchable") } // CAST makes a numeric, date or boolean column searchable as its text // form (Winter casts to TEXT too); PostgreSQL has no lower(integer). parts = append(parts, fmt.Sprintf("LOWER(CAST(%s AS TEXT)) LIKE ? ESCAPE '\\'", qualifiedColumn(db, main, col.Key))) args = append(args, pattern) } return db.Where(strings.Join(parts, " OR "), args...), nil } func applyListFilters(db *gorm.DB, schema *ListSchema, model any, filters map[string]string) (*gorm.DB, error) { if len(filters) == 0 { return db, nil } byName := map[string]ListFilter{} for _, filter := range schema.Filters { byName[filter.Name] = filter } table := tableName(model) var provider pact.FilterScope if scope, ok := model.(pact.FilterScope); ok { provider = scope } for name, raw := range filters { filter, ok := byName[name] if !ok || !identifier(name) { return nil, listInvalid("filter", "is not a declared filter") } switch filter.Type { case "switch": value, ok := switchArgument(filter, raw) if !ok { return nil, listInvalid("filter", "is not a declared value") } db = db.Where(clause.Eq{Column: clause.Column{Table: table, Name: filter.Column}, Value: value}) case "daterange": start, end, err := parseDateRange(raw) if err != nil { return nil, listInvalid("filter", "is not a valid date range") } db = db.Where(clause.Gte{Column: clause.Column{Table: table, Name: filter.Column}, Value: start}) db = db.Where(clause.Lt{Column: clause.Column{Table: table, Name: filter.Column}, Value: end}) case "scope": arg, err := scopeArgument(raw) if err != nil { return nil, listInvalid("filter", "is not a declared value") } if provider == nil { return nil, errors.New("cabana: filter scope provider is missing") } next := provider.FilterScope(filter.Scope, db, arg) if next == nil { return nil, errors.New("cabana: filter scope returned nil") } db = next default: return nil, listInvalid("filter", "is not a declared filter") } } return db, nil } func switchArgument(filter ListFilter, raw string) (any, bool) { if len(filter.Options) > 0 { for _, opt := range filter.Options { if string(opt.Value.raw) == raw { return scalarValue(opt.Value), true } } return nil, false } if filter.TrueValue != nil && string(filter.TrueValue.raw) == raw { return scalarValue(*filter.TrueValue), true } if filter.FalseValue != nil && string(filter.FalseValue.raw) == raw { return scalarValue(*filter.FalseValue), true } return nil, false } func scalarValue(s jsonScalar) any { var v any if err := json.Unmarshal(s.raw, &v); err != nil { return string(s.raw) } return v } func parseDateRange(raw string) (time.Time, time.Time, error) { startText, endText, ok := strings.Cut(raw, "..") if !ok || startText == "" || endText == "" { return time.Time{}, time.Time{}, errors.New("range") } start, err := time.ParseInLocation("2006-01-02", startText, time.UTC) if err != nil { return time.Time{}, time.Time{}, err } end, err := time.ParseInLocation("2006-01-02", endText, time.UTC) if err != nil { return time.Time{}, time.Time{}, err } if end.Before(start) { return time.Time{}, time.Time{}, errors.New("range order") } return start, end.AddDate(0, 0, 1), nil } func scopeArgument(raw string) (any, error) { if raw == "" || strings.ContainsAny(raw, " \t;'\"\\") || strings.Contains(raw, "--") { return nil, errors.New("value") } if n, err := strconv.ParseInt(raw, 10, 64); err == nil { return n, nil } if !identifier(raw) { return nil, errors.New("value") } return raw, nil } func escapeLike(s string) string { s = strings.ReplaceAll(s, `\`, `\\`) s = strings.ReplaceAll(s, `%`, `\%`) s = strings.ReplaceAll(s, `_`, `\_`) return s } func qualifiedColumn(db *gorm.DB, table, column string) string { return quotedIdent(db, table) + "." + quotedIdent(db, column) } func quotedIdent(db *gorm.DB, name string) string { if db == nil || db.Dialector == nil || !identifier(name) && !strings.Contains(name, "_") { return `"` + strings.ReplaceAll(name, `"`, `""`) + `"` } var b strings.Builder db.Dialector.QuoteTo(&b, name) return b.String() } func tableName(model any) string { if model == nil { return "" } if namer, ok := model.(interface{ TableName() string }); ok { return namer.TableName() } t := reflect.TypeOf(model) for t != nil && t.Kind() == reflect.Pointer { t = t.Elem() } if t == nil { return "" } ptr := reflect.New(t) if namer, ok := ptr.Interface().(interface{ TableName() string }); ok { return namer.TableName() } return "" } // relationFieldName resolves a Winter relation key onto the exported Go field. // Exact names win; otherwise the match is case-insensitive, so relation: genre // selects field Genre. func relationFieldName(model any, relation string) (string, bool) { t := reflect.TypeOf(model) for t != nil && t.Kind() == reflect.Pointer { t = t.Elem() } if t == nil || t.Kind() != reflect.Struct || relation == "" { return "", false } if field, ok := t.FieldByName(relation); ok && isListRelation(field.Type) { return field.Name, true } for i := 0; i < t.NumField(); i++ { field := t.Field(i) if field.PkgPath != "" || !isListRelation(field.Type) { continue } if strings.EqualFold(field.Name, relation) { return field.Name, true } } return "", false } func primaryColumn(model any) string { for _, mf := range modelFields(reflect.TypeOf(model)) { if !hasPrimaryKeyTag(mf.Field) { continue } if name := gormColumn(mf.Field); name != "" { return name } return defaultColumnName(mf.Field) } return "id" } func relatedSelect(row reflect.Value, controller pact.AdminController, col ListColumn) (any, bool) { for row.Kind() == reflect.Pointer { if row.IsNil() { return nil, false } row = row.Elem() } if row.Kind() != reflect.Struct || col.Relation == "" { return nil, false } name, ok := relationFieldName(row.Interface(), col.Relation) if !ok { return nil, false } field := row.FieldByName(name) if !field.IsValid() { return nil, false } for field.Kind() == reflect.Pointer { if field.IsNil() { return nil, false } field = field.Elem() } column := listRelationColumn(controller, col.Relation, col.Select) if field.Kind() == reflect.Slice { return relatedSelectMany(field, column) } if field.Kind() != reflect.Struct { return nil, false } selected := fieldByColumn(field, column) if !selected.IsValid() || !selected.CanInterface() { return nil, false } return selected.Interface(), true } // relatedSelectMany joins the selected column of every related record with // ", ", as Winter's list does for a to-many relation column. No related // records leave the key unset, so the list shows its empty placeholder. func relatedSelectMany(records reflect.Value, column string) (any, bool) { values := make([]string, 0, records.Len()) for i := 0; i < records.Len(); i++ { record := records.Index(i) for record.Kind() == reflect.Pointer && !record.IsNil() { record = record.Elem() } if record.Kind() != reflect.Struct { continue } selected := fieldByColumn(record, column) for selected.IsValid() && selected.Kind() == reflect.Pointer && !selected.IsNil() { selected = selected.Elem() } if !selected.IsValid() || !selected.CanInterface() || selected.Kind() == reflect.Pointer { continue } values = append(values, fmt.Sprint(selected.Interface())) } if len(values) == 0 { return nil, false } return strings.Join(values, ", "), true } func listRelationColumn(controller pact.AdminController, relation, column string) string { mapper, ok := controller.(pact.ListRelationColumnMapper) if !ok || mapper == nil { return column } mapped, ok := mapper.ListRelationColumn(relation, column) if !ok || mapped == "" { return column } return mapped }