diff --git a/appview/db/label.go b/appview/db/label.go --- a/appview/db/label.go +++ b/appview/db/label.go @@ -6,7 +6,6 @@ "encoding/hex" "errors" "fmt" - "log" "maps" "slices" "strings" @@ -80,6 +79,14 @@ func (vt ValueType) IsEnumType() bool { return len(vt.Enum) > 0 +} + +func (vt ValueType) IsDidFormat() bool { + return vt.Format == ValueTypeFormatDid +} + +func (vt ValueType) IsAnyFormat() bool { + return vt.Format == ValueTypeFormatAny } type LabelDefinition struct { @@ -595,8 +602,6 @@ results[subject] = state } - log.Println("results for get labels", "s", results) - return results, nil } @@ -631,7 +636,7 @@ } type LabelApplicationCtx struct { - defs map[string]*LabelDefinition // labelAt -> labelDef + Defs map[string]*LabelDefinition // labelAt -> labelDef } var ( @@ -653,7 +658,7 @@ } func (c *LabelApplicationCtx) ApplyLabelOp(state LabelState, op LabelOp) error { - def := c.defs[op.OperandKey] + def := c.Defs[op.OperandKey] switch op.Operation { case LabelOperationAdd: @@ -713,9 +718,4 @@ for _, o := range ops { _ = c.ApplyLabelOp(state, o) } -} - -type Label struct { - def *LabelDefinition - val set } diff --git a/appview/issues/issues.go b/appview/issues/issues.go --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -92,7 +92,11 @@ userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) } - labelDefs, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) + labelDefs, err := db.GetLabelDefinitions( + rp.db, + db.FilterIn("at_uri", f.Repo.Labels), + db.FilterEq("scope", tangled.RepoIssueNSID), + ) if err != nil { log.Println("failed to fetch labels", err) rp.pages.Error503(w) diff --git a/appview/repo/repo.go b/appview/repo/repo.go --- a/appview/repo/repo.go +++ b/appview/repo/repo.go @@ -987,6 +987,7 @@ // get form values for label definition name := r.FormValue("name") concreteType := r.FormValue("valueType") + valueFormat := r.FormValue("valueFormat") enumValues := r.FormValue("enumValues") scope := r.FormValue("scope") color := r.FormValue("color") @@ -999,9 +1000,14 @@ } } + format := db.ValueTypeFormatAny + if valueFormat == "did" { + format = db.ValueTypeFormatDid + } + valueType := db.ValueType{ Type: db.ConcreteType(concreteType), - Format: db.ValueTypeFormatAny, + Format: format, Enum: variants, } diff --git a/appview/state/state.go b/appview/state/state.go --- a/appview/state/state.go +++ b/appview/state/state.go @@ -78,7 +78,7 @@ cache := cache.New(config.Redis.Addr) sess := session.New(cache) oauth := oauth.NewOAuth(config, sess) - validator := validator.New(d) + validator := validator.New(d, res) posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) if err != nil { diff --git a/appview/validator/label.go b/appview/validator/label.go --- a/appview/validator/label.go +++ b/appview/validator/label.go @@ -1,6 +1,7 @@ package validator import ( + "context" "fmt" "regexp" "strings" @@ -71,6 +72,107 @@ color = strings.ToUpper(color) label.Color = &color } + } + + return nil +} + +func (v *Validator) ValidateLabelOp(labelDef *db.LabelDefinition, labelOp *db.LabelOp) error { + if labelDef == nil { + return fmt.Errorf("label definition is required") + } + if labelOp == nil { + return fmt.Errorf("label operation is required") + } + + expectedKey := labelDef.AtUri().String() + if labelOp.OperandKey != expectedKey { + return fmt.Errorf("operand key %q does not match label definition URI %q", labelOp.OperandKey, expectedKey) + } + + if labelOp.Operation != db.LabelOperationAdd && labelOp.Operation != db.LabelOperationDel { + return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", labelOp.Operation) + } + + if labelOp.Subject == "" { + return fmt.Errorf("subject URI is required") + } + if _, err := syntax.ParseATURI(string(labelOp.Subject)); err != nil { + return fmt.Errorf("invalid subject URI: %w", err) + } + + if err := v.validateOperandValue(labelDef, labelOp); err != nil { + return fmt.Errorf("invalid operand value: %w", err) + } + + // Validate performed time is not zero/invalid + if labelOp.PerformedAt.IsZero() { + return fmt.Errorf("performed_at timestamp is required") + } + + return nil +} + +func (v *Validator) validateOperandValue(labelDef *db.LabelDefinition, labelOp *db.LabelOp) error { + valueType := labelDef.ValueType + + switch valueType.Type { + case db.ConcreteTypeNull: + // For null type, value should be empty + if labelOp.OperandValue != "null" { + return fmt.Errorf("null type requires empty value, got %q", labelOp.OperandValue) + } + + case db.ConcreteTypeString: + // For string type, validate enum constraints if present + if valueType.IsEnumType() { + if !slices.Contains(valueType.Enum, labelOp.OperandValue) { + return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) + } + } + + switch valueType.Format { + case db.ValueTypeFormatDid: + id, err := v.resolver.ResolveIdent(context.Background(), labelOp.OperandValue) + if err != nil { + return fmt.Errorf("failed to resolve did/handle: %w", err) + } + + labelOp.OperandValue = id.DID.String() + + case db.ValueTypeFormatAny, "": + default: + return fmt.Errorf("unsupported format constraint: %q", valueType.Format) + } + + case db.ConcreteTypeInt: + if labelOp.OperandValue == "" { + return fmt.Errorf("integer type requires non-empty value") + } + if _, err := fmt.Sscanf(labelOp.OperandValue, "%d", new(int)); err != nil { + return fmt.Errorf("value %q is not a valid integer", labelOp.OperandValue) + } + + if valueType.IsEnumType() { + if !slices.Contains(valueType.Enum, labelOp.OperandValue) { + return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) + } + } + + case db.ConcreteTypeBool: + if labelOp.OperandValue != "true" && labelOp.OperandValue != "false" { + return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", labelOp.OperandValue) + } + + // validate enum constraints if present (though uncommon for booleans) + if valueType.IsEnumType() { + if !slices.Contains(valueType.Enum, labelOp.OperandValue) { + return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) + } + } + + default: + return fmt.Errorf("unsupported value type: %q", valueType.Type) } return nil diff --git a/appview/validator/validator.go b/appview/validator/validator.go --- a/appview/validator/validator.go +++ b/appview/validator/validator.go @@ -3,16 +3,19 @@ import ( "tangled.sh/tangled.sh/core/appview/db" "tangled.sh/tangled.sh/core/appview/pages/markup" + "tangled.sh/tangled.sh/core/idresolver" ) type Validator struct { db *db.DB sanitizer markup.Sanitizer + resolver *idresolver.Resolver } -func New(db *db.DB) *Validator { +func New(db *db.DB, res *idresolver.Resolver) *Validator { return &Validator{ db: db, sanitizer: markup.NewSanitizer(), + resolver: res, } } diff --git a/appview/pages/templates/labels/fragments/label.html b/appview/pages/templates/labels/fragments/label.html --- a/appview/pages/templates/labels/fragments/label.html +++ b/appview/pages/templates/labels/fragments/label.html @@ -1,8 +1,20 @@ {{ define "labels/fragments/label" }} {{ $d := .def }} {{ $v := .val }} - + {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} - {{ $d.Name }}{{ if not $d.ValueType.IsNull }}/{{ $v }}{{ end }} + {{ $d.Name }}{{ if not $d.ValueType.IsNull }}/{{ template "labelVal" (dict "def" $d "val" $v) }}{{ end }} +{{ end }} + + +{{ define "labelVal" }} + {{ $d := .def }} + {{ $v := .val }} + + {{ if $d.ValueType.IsDidFormat }} + {{ resolve $v }} + {{ else }} + {{ $v }} + {{ end }} {{ end }}