This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / models / label.go
18 kB 729 lines
1package models 2 3import ( 4 "context" 5 "crypto/sha1" 6 "encoding/hex" 7 "encoding/json" 8 "errors" 9 "fmt" 10 "regexp" 11 "slices" 12 "strings" 13 "time" 14 15 "github.com/bluesky-social/indigo/api/atproto" 16 "github.com/bluesky-social/indigo/atproto/syntax" 17 "github.com/bluesky-social/indigo/xrpc" 18 "tangled.org/core/api/tangled" 19 "tangled.org/core/idresolver" 20) 21 22type ConcreteType string 23 24const ( 25 ConcreteTypeNull ConcreteType = "null" 26 ConcreteTypeString ConcreteType = "string" 27 ConcreteTypeInt ConcreteType = "integer" 28 ConcreteTypeBool ConcreteType = "boolean" 29) 30 31type ValueTypeFormat string 32 33const ( 34 ValueTypeFormatAny ValueTypeFormat = "any" 35 ValueTypeFormatDid ValueTypeFormat = "did" 36) 37 38// ValueType represents an atproto lexicon type definition with constraints 39type ValueType struct { 40 Type ConcreteType `json:"type"` 41 Format ValueTypeFormat `json:"format,omitempty"` 42 Enum []string `json:"enum,omitempty"` 43} 44 45func (vt *ValueType) AsRecord() tangled.LabelDefinition_ValueType { 46 return tangled.LabelDefinition_ValueType{ 47 Type: string(vt.Type), 48 Format: string(vt.Format), 49 Enum: vt.Enum, 50 } 51} 52 53func ValueTypeFromRecord(record tangled.LabelDefinition_ValueType) ValueType { 54 return ValueType{ 55 Type: ConcreteType(record.Type), 56 Format: ValueTypeFormat(record.Format), 57 Enum: record.Enum, 58 } 59} 60 61func (vt ValueType) IsConcreteType() bool { 62 return vt.Type == ConcreteTypeNull || 63 vt.Type == ConcreteTypeString || 64 vt.Type == ConcreteTypeInt || 65 vt.Type == ConcreteTypeBool 66} 67 68func (vt ValueType) IsNull() bool { 69 return vt.Type == ConcreteTypeNull 70} 71 72func (vt ValueType) IsString() bool { 73 return vt.Type == ConcreteTypeString 74} 75 76func (vt ValueType) IsInt() bool { 77 return vt.Type == ConcreteTypeInt 78} 79 80func (vt ValueType) IsBool() bool { 81 return vt.Type == ConcreteTypeBool 82} 83 84func (vt ValueType) IsEnum() bool { 85 return len(vt.Enum) > 0 86} 87 88func (vt ValueType) IsDidFormat() bool { 89 return vt.Format == ValueTypeFormatDid 90} 91 92func (vt ValueType) IsAnyFormat() bool { 93 return vt.Format == ValueTypeFormatAny 94} 95 96type LabelDefinition struct { 97 Id int64 98 Did string 99 Rkey string 100 101 Name string 102 ValueType ValueType 103 Scope []string 104 Color *string 105 Multiple bool 106 Created time.Time 107} 108 109func (l *LabelDefinition) AtUri() syntax.ATURI { 110 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", l.Did, tangled.LabelDefinitionNSID, l.Rkey)) 111} 112 113func (l *LabelDefinition) AsRecord() tangled.LabelDefinition { 114 vt := l.ValueType.AsRecord() 115 return tangled.LabelDefinition{ 116 Name: l.Name, 117 Color: l.Color, 118 CreatedAt: l.Created.Format(time.RFC3339), 119 Multiple: &l.Multiple, 120 Scope: l.Scope, 121 ValueType: &vt, 122 } 123} 124 125var ( 126 // Label name should be alphanumeric with hyphens/underscores, but not start/end with them 127 labelNameRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$`) 128 // Color should be a valid hex color 129 colorRegex = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`) 130 // You can only label issues and pulls presently 131 validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID} 132) 133 134var _ Validator = new(LabelDefinition) 135 136func (l *LabelDefinition) Validate() error { 137 if l.Name == "" { 138 return fmt.Errorf("label name is empty") 139 } 140 if len(l.Name) > 40 { 141 return fmt.Errorf("label name too long (max 40 graphemes)") 142 } 143 if len(l.Name) < 1 { 144 return fmt.Errorf("label name too short (min 1 grapheme)") 145 } 146 if !labelNameRegex.MatchString(l.Name) { 147 return fmt.Errorf("label name contains invalid characters (use only letters, numbers, hyphens, and underscores)") 148 } 149 150 if !l.ValueType.IsConcreteType() { 151 return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", l.ValueType.Type) 152 } 153 154 // null type checks: cannot be enums, multiple or explicit format 155 if l.ValueType.IsNull() && l.ValueType.IsEnum() { 156 return fmt.Errorf("null type cannot be used in conjunction with enum type") 157 } 158 if l.ValueType.IsNull() && l.Multiple { 159 return fmt.Errorf("null type labels cannot be multiple") 160 } 161 if l.ValueType.IsNull() && !l.ValueType.IsAnyFormat() { 162 return fmt.Errorf("format cannot be used in conjunction with null type") 163 } 164 165 // format checks: cannot be used with enum, or integers 166 if !l.ValueType.IsAnyFormat() && l.ValueType.IsEnum() { 167 return fmt.Errorf("enum types cannot be used in conjunction with format specification") 168 } 169 170 if !l.ValueType.IsAnyFormat() && !l.ValueType.IsString() { 171 return fmt.Errorf("format specifications are only permitted on string types") 172 } 173 174 // validate scope (nsid format) 175 if l.Scope == nil { 176 return fmt.Errorf("scope is required") 177 } 178 for _, s := range l.Scope { 179 if _, err := syntax.ParseNSID(s); err != nil { 180 return fmt.Errorf("failed to parse scope: %w", err) 181 } 182 if !slices.Contains(validScopes, s) { 183 return fmt.Errorf("invalid scope: scope must be present in %q", validScopes) 184 } 185 } 186 187 // validate color if provided 188 if l.Color != nil { 189 color := strings.TrimSpace(*l.Color) 190 if color == "" { 191 // empty color is fine, set to nil 192 l.Color = nil 193 } else { 194 if !colorRegex.MatchString(color) { 195 return fmt.Errorf("color must be a valid hex color (e.g. #79FFE1 or #000)") 196 } 197 // expand 3-digit hex to 6-digit hex 198 if len(color) == 4 { // #ABC 199 color = fmt.Sprintf("#%c%c%c%c%c%c", color[1], color[1], color[2], color[2], color[3], color[3]) 200 } 201 // convert to uppercase for consistency 202 color = strings.ToUpper(color) 203 l.Color = &color 204 } 205 } 206 207 return nil 208} 209 210// ValidateOperandValue validates the label operation operand value based on 211// label definition. 212// 213// NOTE: This can modify the [LabelOp] 214func (def *LabelDefinition) ValidateOperandValue(op *LabelOp) error { 215 expectedKey := def.AtUri().String() 216 if op.OperandKey != def.AtUri().String() { 217 return fmt.Errorf("operand key %q does not match label definition URI %q", op.OperandKey, expectedKey) 218 } 219 220 valueType := def.ValueType 221 222 // this is permitted, it "unsets" a label 223 if op.OperandValue == "" { 224 op.Operation = LabelOperationDel 225 return nil 226 } 227 228 switch valueType.Type { 229 case ConcreteTypeNull: 230 // For null type, value should be empty 231 if op.OperandValue != "null" { 232 return fmt.Errorf("null type requires empty value, got %q", op.OperandValue) 233 } 234 235 case ConcreteTypeString: 236 // For string type, validate enum constraints if present 237 if valueType.IsEnum() { 238 if !slices.Contains(valueType.Enum, op.OperandValue) { 239 return fmt.Errorf("value %q is not in allowed enum values %v", op.OperandValue, valueType.Enum) 240 } 241 } 242 243 switch valueType.Format { 244 case ValueTypeFormatDid: 245 if _, err := syntax.ParseDID(op.OperandValue); err != nil { 246 return fmt.Errorf("failed to resolve did/handle: %w", err) 247 } 248 case ValueTypeFormatAny, "": 249 default: 250 return fmt.Errorf("unsupported format constraint: %q", valueType.Format) 251 } 252 253 case ConcreteTypeInt: 254 if op.OperandValue == "" { 255 return fmt.Errorf("integer type requires non-empty value") 256 } 257 if _, err := fmt.Sscanf(op.OperandValue, "%d", new(int)); err != nil { 258 return fmt.Errorf("value %q is not a valid integer", op.OperandValue) 259 } 260 261 if valueType.IsEnum() { 262 if !slices.Contains(valueType.Enum, op.OperandValue) { 263 return fmt.Errorf("value %q is not in allowed enum values %v", op.OperandValue, valueType.Enum) 264 } 265 } 266 267 case ConcreteTypeBool: 268 if op.OperandValue != "true" && op.OperandValue != "false" { 269 return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", op.OperandValue) 270 } 271 272 // validate enum constraints if present (though uncommon for booleans) 273 if valueType.IsEnum() { 274 if !slices.Contains(valueType.Enum, op.OperandValue) { 275 return fmt.Errorf("value %q is not in allowed enum values %v", op.OperandValue, valueType.Enum) 276 } 277 } 278 279 default: 280 return fmt.Errorf("unsupported value type: %q", valueType.Type) 281 } 282 283 return nil 284} 285 286// random color for a given seed 287func randomColor(seed string) string { 288 hash := sha1.Sum([]byte(seed)) 289 hexStr := hex.EncodeToString(hash[:]) 290 r := hexStr[0:2] 291 g := hexStr[2:4] 292 b := hexStr[4:6] 293 294 return fmt.Sprintf("#%s%s%s", r, g, b) 295} 296 297func (l LabelDefinition) GetColor() string { 298 if l.Color == nil { 299 seed := fmt.Sprintf("%d:%s:%s", l.Id, l.Did, l.Rkey) 300 color := randomColor(seed) 301 return color 302 } 303 304 return *l.Color 305} 306 307func LabelDefinitionFromRecord(did, rkey string, record tangled.LabelDefinition) (*LabelDefinition, error) { 308 created, err := time.Parse(time.RFC3339, record.CreatedAt) 309 if err != nil { 310 created = time.Time{} 311 } 312 313 multiple := false 314 if record.Multiple != nil { 315 multiple = *record.Multiple 316 } 317 318 var vt ValueType 319 if record.ValueType != nil { 320 vt = ValueTypeFromRecord(*record.ValueType) 321 } 322 323 return &LabelDefinition{ 324 Did: did, 325 Rkey: rkey, 326 327 Name: record.Name, 328 ValueType: vt, 329 Scope: record.Scope, 330 Color: record.Color, 331 Multiple: multiple, 332 Created: created, 333 }, nil 334} 335 336type LabelOp struct { 337 Id int64 338 Did string 339 Rkey string 340 Subject syntax.ATURI 341 Operation LabelOperation 342 OperandKey string 343 OperandValue string 344 PerformedAt time.Time 345} 346 347func (l LabelOp) SortAt() time.Time { 348 // if createdat is invalid (before epoch), treat as null -> return zero time 349 if l.PerformedAt.Before(time.UnixMicro(0)) { 350 return time.Time{} 351 } 352 return l.PerformedAt 353} 354 355var _ Validator = new(LabelOp) 356 357func (l *LabelOp) Validate() error { 358 if _, err := syntax.ParseATURI(string(l.Subject)); err != nil { 359 return fmt.Errorf("invalid subject URI: %w", err) 360 } 361 if l.Operation != LabelOperationAdd && l.Operation != LabelOperationDel { 362 return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", l.Operation) 363 } 364 // Validate performed time is not zero/invalid 365 if l.PerformedAt.IsZero() { 366 return fmt.Errorf("performed_at timestamp is required") 367 } 368 return nil 369} 370 371type LabelOperation string 372 373const ( 374 LabelOperationAdd LabelOperation = "add" 375 LabelOperationDel LabelOperation = "del" 376) 377 378// a record can create multiple label ops 379func LabelOpsFromRecord(did, rkey string, record tangled.LabelOp) []LabelOp { 380 performed, err := time.Parse(time.RFC3339, record.PerformedAt) 381 if err != nil { 382 performed = time.Time{} 383 } 384 385 mkOp := func(operand *tangled.LabelOp_Operand) LabelOp { 386 return LabelOp{ 387 Did: did, 388 Rkey: rkey, 389 Subject: syntax.ATURI(record.Subject), 390 OperandKey: operand.Key, 391 OperandValue: operand.Value, 392 PerformedAt: performed, 393 } 394 } 395 396 var ops []LabelOp 397 // deletes first, then additions 398 for _, o := range record.Delete { 399 if o != nil { 400 op := mkOp(o) 401 op.Operation = LabelOperationDel 402 ops = append(ops, op) 403 } 404 } 405 for _, o := range record.Add { 406 if o != nil { 407 op := mkOp(o) 408 op.Operation = LabelOperationAdd 409 ops = append(ops, op) 410 } 411 } 412 413 return ops 414} 415 416func LabelOpsAsRecord(ops []LabelOp) tangled.LabelOp { 417 if len(ops) == 0 { 418 return tangled.LabelOp{} 419 } 420 421 // use the first operation to establish common fields 422 first := ops[0] 423 record := tangled.LabelOp{ 424 Subject: string(first.Subject), 425 PerformedAt: first.PerformedAt.Format(time.RFC3339), 426 } 427 428 var addOperands []*tangled.LabelOp_Operand 429 var deleteOperands []*tangled.LabelOp_Operand 430 431 for _, op := range ops { 432 operand := &tangled.LabelOp_Operand{ 433 Key: op.OperandKey, 434 Value: op.OperandValue, 435 } 436 437 switch op.Operation { 438 case LabelOperationAdd: 439 addOperands = append(addOperands, operand) 440 case LabelOperationDel: 441 deleteOperands = append(deleteOperands, operand) 442 default: 443 return tangled.LabelOp{} 444 } 445 } 446 447 record.Add = addOperands 448 record.Delete = deleteOperands 449 450 return record 451} 452 453type set = map[string]struct{} 454 455type LabelState struct { 456 inner map[string]set 457 names map[string]string 458} 459 460func NewLabelState() LabelState { 461 return LabelState{ 462 inner: make(map[string]set), 463 names: make(map[string]string), 464 } 465} 466 467func (s LabelState) LabelNames() []string { 468 var result []string 469 for key, valset := range s.inner { 470 if valset == nil { 471 continue 472 } 473 if name, ok := s.names[key]; ok { 474 result = append(result, name) 475 } 476 } 477 return result 478} 479 480// LabelNameValues returns composite "name:value" strings for all labels 481// that have non-empty values. 482func (s LabelState) LabelNameValues() []string { 483 var result []string 484 for key, valset := range s.inner { 485 if valset == nil { 486 continue 487 } 488 name, ok := s.names[key] 489 if !ok { 490 continue 491 } 492 for val := range valset { 493 if val != "" { 494 result = append(result, name+":"+val) 495 } 496 } 497 } 498 return result 499} 500 501func (s LabelState) Inner() map[string]set { 502 return s.inner 503} 504 505func (s LabelState) SetName(key, name string) { 506 s.names[key] = name 507} 508 509func (s LabelState) ContainsLabel(l string) bool { 510 if valset, exists := s.inner[l]; exists { 511 if valset != nil { 512 return true 513 } 514 } 515 516 return false 517} 518 519// go maps behavior in templates make this necessary, 520// indexing a map and getting `set` in return is apparently truthy 521func (s LabelState) ContainsLabelAndVal(l, v string) bool { 522 if valset, exists := s.inner[l]; exists { 523 if _, exists := valset[v]; exists { 524 return true 525 } 526 } 527 528 return false 529} 530 531func (s LabelState) GetValSet(l string) set { 532 if valset, exists := s.inner[l]; exists { 533 return valset 534 } else { 535 return make(set) 536 } 537} 538 539type LabelApplicationCtx struct { 540 Defs map[string]*LabelDefinition // labelAt -> labelDef 541} 542 543var ( 544 LabelNoOpError = errors.New("no-op") 545) 546 547func (c *LabelApplicationCtx) ApplyLabelOp(state LabelState, op LabelOp) error { 548 def, ok := c.Defs[op.OperandKey] 549 if !ok { 550 // this def was deleted, but an op exists, so we just skip over the op 551 return nil 552 } 553 554 state.names[op.OperandKey] = def.Name 555 556 switch op.Operation { 557 case LabelOperationAdd: 558 // if valueset is empty, init it 559 if state.inner[op.OperandKey] == nil { 560 state.inner[op.OperandKey] = make(set) 561 } 562 563 // if valueset is populated & this val alr exists, this labelop is a noop 564 if valueSet, exists := state.inner[op.OperandKey]; exists { 565 if _, exists = valueSet[op.OperandValue]; exists { 566 return LabelNoOpError 567 } 568 } 569 570 if def.Multiple { 571 // append to set 572 state.inner[op.OperandKey][op.OperandValue] = struct{}{} 573 } else { 574 // reset to just this value 575 state.inner[op.OperandKey] = set{op.OperandValue: struct{}{}} 576 } 577 578 case LabelOperationDel: 579 // if label DNE, then deletion is a no-op 580 if valueSet, exists := state.inner[op.OperandKey]; !exists { 581 return LabelNoOpError 582 } else if _, exists = valueSet[op.OperandValue]; !exists { // if value DNE, then deletion is no-op 583 return LabelNoOpError 584 } 585 586 if def.Multiple { 587 // remove from set 588 delete(state.inner[op.OperandKey], op.OperandValue) 589 } else { 590 // reset the entire label 591 delete(state.inner, op.OperandKey) 592 } 593 594 // if the map becomes empty, then set it to nil, this is just the inverse of add 595 if len(state.inner[op.OperandKey]) == 0 { 596 state.inner[op.OperandKey] = nil 597 } 598 599 } 600 601 return nil 602} 603 604func (c *LabelApplicationCtx) ApplyLabelOps(state LabelState, ops []LabelOp) { 605 // sort label ops in sort order first 606 slices.SortFunc(ops, func(a, b LabelOp) int { 607 return a.SortAt().Compare(b.SortAt()) 608 }) 609 610 // apply ops in sequence 611 for _, o := range ops { 612 _ = c.ApplyLabelOp(state, o) 613 } 614} 615 616// IsInverse checks if one label operation is the inverse of another 617// returns true if one is an add and the other is a delete with the same key and value 618func (op1 LabelOp) IsInverse(op2 LabelOp) bool { 619 if op1.OperandKey != op2.OperandKey || op1.OperandValue != op2.OperandValue { 620 return false 621 } 622 623 return (op1.Operation == LabelOperationAdd && op2.Operation == LabelOperationDel) || 624 (op1.Operation == LabelOperationDel && op2.Operation == LabelOperationAdd) 625} 626 627// removes pairs of label operations that are inverses of each other 628// from the given slice. the function preserves the order of remaining operations. 629func ReduceLabelOps(ops []LabelOp) []LabelOp { 630 if len(ops) <= 1 { 631 return ops 632 } 633 634 keep := make([]bool, len(ops)) 635 for i := range keep { 636 keep[i] = true 637 } 638 639 for i := range ops { 640 if !keep[i] { 641 continue 642 } 643 644 for j := i + 1; j < len(ops); j++ { 645 if !keep[j] { 646 continue 647 } 648 649 if ops[i].IsInverse(ops[j]) { 650 keep[i] = false 651 keep[j] = false 652 break // move to next i since this one is now eliminated 653 } 654 } 655 } 656 657 // build result slice with only kept operations 658 var result []LabelOp 659 for i, op := range ops { 660 if keep[i] { 661 result = append(result, op) 662 } 663 } 664 665 return result 666} 667 668func FetchLabelDefs(r *idresolver.Resolver, aturis []string) ([]LabelDefinition, error) { 669 var labelDefs []LabelDefinition 670 ctx := context.Background() 671 672 for _, dl := range aturis { 673 atUri, err := syntax.ParseATURI(dl) 674 if err != nil { 675 return nil, fmt.Errorf("failed to parse AT-URI %s: %v", dl, err) 676 } 677 if atUri.Collection() != tangled.LabelDefinitionNSID { 678 return nil, fmt.Errorf("expected AT-URI pointing %s collection: %s", tangled.LabelDefinitionNSID, atUri) 679 } 680 681 owner, err := r.ResolveIdent(ctx, atUri.Authority().String()) 682 if err != nil { 683 return nil, fmt.Errorf("failed to resolve default label owner DID %s: %v", atUri.Authority(), err) 684 } 685 686 xrpcc := xrpc.Client{ 687 Host: owner.PDSEndpoint(), 688 } 689 690 record, err := atproto.RepoGetRecord( 691 ctx, 692 &xrpcc, 693 "", 694 atUri.Collection().String(), 695 atUri.Authority().String(), 696 atUri.RecordKey().String(), 697 ) 698 if err != nil { 699 return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err) 700 } 701 702 if record != nil { 703 bytes, err := record.Value.MarshalJSON() 704 if err != nil { 705 return nil, fmt.Errorf("failed to marshal record value for %s: %v", atUri, err) 706 } 707 708 raw := json.RawMessage(bytes) 709 labelRecord := tangled.LabelDefinition{} 710 err = json.Unmarshal(raw, &labelRecord) 711 if err != nil { 712 return nil, fmt.Errorf("invalid record for %s: %w", atUri, err) 713 } 714 715 labelDef, err := LabelDefinitionFromRecord( 716 atUri.Authority().String(), 717 atUri.RecordKey().String(), 718 labelRecord, 719 ) 720 if err != nil { 721 return nil, fmt.Errorf("failed to create label definition from record %s: %v", atUri, err) 722 } 723 724 labelDefs = append(labelDefs, *labelDef) 725 } 726 } 727 728 return labelDefs, nil 729}