this repo has no description
1package appview 2 3import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "log/slog" 8 "strings" 9 "time" 10 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 "github.com/bluesky-social/jetstream/pkg/models" 13 "github.com/go-git/go-git/v5/plumbing" 14 "github.com/ipfs/go-cid" 15 "tangled.sh/tangled.sh/core/api/tangled" 16 "tangled.sh/tangled.sh/core/appview/config" 17 "tangled.sh/tangled.sh/core/appview/db" 18 "tangled.sh/tangled.sh/core/appview/pages/markup" 19 "tangled.sh/tangled.sh/core/appview/serververify" 20 "tangled.sh/tangled.sh/core/idresolver" 21 "tangled.sh/tangled.sh/core/rbac" 22) 23 24type Ingester struct { 25 Db db.DbWrapper 26 Enforcer *rbac.Enforcer 27 IdResolver *idresolver.Resolver 28 Config *config.Config 29 Logger *slog.Logger 30} 31 32type processFunc func(ctx context.Context, e *models.Event) error 33 34func (i *Ingester) Ingest() processFunc { 35 return func(ctx context.Context, e *models.Event) error { 36 var err error 37 defer func() { 38 eventTime := e.TimeUS 39 lastTimeUs := eventTime + 1 40 if err := i.Db.SaveLastTimeUs(lastTimeUs); err != nil { 41 err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 42 } 43 }() 44 45 l := i.Logger.With("kind", e.Kind) 46 switch e.Kind { 47 case models.EventKindAccount: 48 if !e.Account.Active && *e.Account.Status == "deactivated" { 49 err = i.IdResolver.InvalidateIdent(ctx, e.Account.Did) 50 } 51 case models.EventKindIdentity: 52 err = i.IdResolver.InvalidateIdent(ctx, e.Identity.Did) 53 case models.EventKindCommit: 54 switch e.Commit.Collection { 55 case tangled.GraphFollowNSID: 56 err = i.ingestFollow(e) 57 case tangled.FeedStarNSID: 58 err = i.ingestStar(e) 59 case tangled.PublicKeyNSID: 60 err = i.ingestPublicKey(e) 61 case tangled.RepoArtifactNSID: 62 err = i.ingestArtifact(e) 63 case tangled.ActorProfileNSID: 64 err = i.ingestProfile(e) 65 case tangled.SpindleMemberNSID: 66 err = i.ingestSpindleMember(ctx, e) 67 case tangled.SpindleNSID: 68 err = i.ingestSpindle(ctx, e) 69 case tangled.KnotMemberNSID: 70 err = i.ingestKnotMember(e) 71 case tangled.KnotNSID: 72 err = i.ingestKnot(e) 73 case tangled.StringNSID: 74 err = i.ingestString(e) 75 case tangled.RepoIssueNSID: 76 err = i.ingestIssue(ctx, e) 77 } 78 l = i.Logger.With("nsid", e.Commit.Collection) 79 } 80 81 if err != nil { 82 l.Debug("error ingesting record", "err", err) 83 } 84 85 return nil 86 } 87} 88 89func (i *Ingester) ingestStar(e *models.Event) error { 90 var err error 91 did := e.Did 92 93 l := i.Logger.With("handler", "ingestStar") 94 l = l.With("nsid", e.Commit.Collection) 95 96 switch e.Commit.Operation { 97 case models.CommitOperationCreate, models.CommitOperationUpdate: 98 var subjectUri syntax.ATURI 99 100 raw := json.RawMessage(e.Commit.Record) 101 record := tangled.FeedStar{} 102 err := json.Unmarshal(raw, &record) 103 if err != nil { 104 l.Error("invalid record", "err", err) 105 return err 106 } 107 108 subjectUri, err = syntax.ParseATURI(record.Subject) 109 if err != nil { 110 l.Error("invalid record", "err", err) 111 return err 112 } 113 err = db.AddStar(i.Db, &db.Star{ 114 StarredByDid: did, 115 RepoAt: subjectUri, 116 Rkey: e.Commit.RKey, 117 }) 118 case models.CommitOperationDelete: 119 err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey) 120 } 121 122 if err != nil { 123 return fmt.Errorf("failed to %s star record: %w", e.Commit.Operation, err) 124 } 125 126 return nil 127} 128 129func (i *Ingester) ingestFollow(e *models.Event) error { 130 var err error 131 did := e.Did 132 133 l := i.Logger.With("handler", "ingestFollow") 134 l = l.With("nsid", e.Commit.Collection) 135 136 switch e.Commit.Operation { 137 case models.CommitOperationCreate, models.CommitOperationUpdate: 138 raw := json.RawMessage(e.Commit.Record) 139 record := tangled.GraphFollow{} 140 err = json.Unmarshal(raw, &record) 141 if err != nil { 142 l.Error("invalid record", "err", err) 143 return err 144 } 145 146 err = db.AddFollow(i.Db, &db.Follow{ 147 UserDid: did, 148 SubjectDid: record.Subject, 149 Rkey: e.Commit.RKey, 150 }) 151 case models.CommitOperationDelete: 152 err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey) 153 } 154 155 if err != nil { 156 return fmt.Errorf("failed to %s follow record: %w", e.Commit.Operation, err) 157 } 158 159 return nil 160} 161 162func (i *Ingester) ingestPublicKey(e *models.Event) error { 163 did := e.Did 164 var err error 165 166 l := i.Logger.With("handler", "ingestPublicKey") 167 l = l.With("nsid", e.Commit.Collection) 168 169 switch e.Commit.Operation { 170 case models.CommitOperationCreate, models.CommitOperationUpdate: 171 l.Debug("processing add of pubkey") 172 raw := json.RawMessage(e.Commit.Record) 173 record := tangled.PublicKey{} 174 err = json.Unmarshal(raw, &record) 175 if err != nil { 176 l.Error("invalid record", "err", err) 177 return err 178 } 179 180 name := record.Name 181 key := record.Key 182 err = db.AddPublicKey(i.Db, did, name, key, e.Commit.RKey) 183 case models.CommitOperationDelete: 184 l.Debug("processing delete of pubkey") 185 err = db.DeletePublicKeyByRkey(i.Db, did, e.Commit.RKey) 186 } 187 188 if err != nil { 189 return fmt.Errorf("failed to %s pubkey record: %w", e.Commit.Operation, err) 190 } 191 192 return nil 193} 194 195func (i *Ingester) ingestArtifact(e *models.Event) error { 196 did := e.Did 197 var err error 198 199 l := i.Logger.With("handler", "ingestArtifact") 200 l = l.With("nsid", e.Commit.Collection) 201 202 switch e.Commit.Operation { 203 case models.CommitOperationCreate, models.CommitOperationUpdate: 204 raw := json.RawMessage(e.Commit.Record) 205 record := tangled.RepoArtifact{} 206 err = json.Unmarshal(raw, &record) 207 if err != nil { 208 l.Error("invalid record", "err", err) 209 return err 210 } 211 212 repoAt, err := syntax.ParseATURI(record.Repo) 213 if err != nil { 214 return err 215 } 216 217 repo, err := db.GetRepoByAtUri(i.Db, repoAt.String()) 218 if err != nil { 219 return err 220 } 221 222 ok, err := i.Enforcer.E.Enforce(did, repo.Knot, repo.DidSlashRepo(), "repo:push") 223 if err != nil || !ok { 224 return err 225 } 226 227 createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) 228 if err != nil { 229 createdAt = time.Now() 230 } 231 232 artifact := db.Artifact{ 233 Did: did, 234 Rkey: e.Commit.RKey, 235 RepoAt: repoAt, 236 Tag: plumbing.Hash(record.Tag), 237 CreatedAt: createdAt, 238 BlobCid: cid.Cid(record.Artifact.Ref), 239 Name: record.Name, 240 Size: uint64(record.Artifact.Size), 241 MimeType: record.Artifact.MimeType, 242 } 243 244 err = db.AddArtifact(i.Db, artifact) 245 case models.CommitOperationDelete: 246 err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey)) 247 } 248 249 if err != nil { 250 return fmt.Errorf("failed to %s artifact record: %w", e.Commit.Operation, err) 251 } 252 253 return nil 254} 255 256func (i *Ingester) ingestProfile(e *models.Event) error { 257 did := e.Did 258 var err error 259 260 l := i.Logger.With("handler", "ingestProfile") 261 l = l.With("nsid", e.Commit.Collection) 262 263 if e.Commit.RKey != "self" { 264 return fmt.Errorf("ingestProfile only ingests `self` record") 265 } 266 267 switch e.Commit.Operation { 268 case models.CommitOperationCreate, models.CommitOperationUpdate: 269 raw := json.RawMessage(e.Commit.Record) 270 record := tangled.ActorProfile{} 271 err = json.Unmarshal(raw, &record) 272 if err != nil { 273 l.Error("invalid record", "err", err) 274 return err 275 } 276 277 description := "" 278 if record.Description != nil { 279 description = *record.Description 280 } 281 282 includeBluesky := record.Bluesky 283 284 location := "" 285 if record.Location != nil { 286 location = *record.Location 287 } 288 289 var links [5]string 290 for i, l := range record.Links { 291 if i < 5 { 292 links[i] = l 293 } 294 } 295 296 var stats [2]db.VanityStat 297 for i, s := range record.Stats { 298 if i < 2 { 299 stats[i].Kind = db.VanityStatKind(s) 300 } 301 } 302 303 var pinned [6]syntax.ATURI 304 for i, r := range record.PinnedRepositories { 305 if i < 6 { 306 pinned[i] = syntax.ATURI(r) 307 } 308 } 309 310 profile := db.Profile{ 311 Did: did, 312 Description: description, 313 IncludeBluesky: includeBluesky, 314 Location: location, 315 Links: links, 316 Stats: stats, 317 PinnedRepos: pinned, 318 } 319 320 ddb, ok := i.Db.Execer.(*db.DB) 321 if !ok { 322 return fmt.Errorf("failed to index profile record, invalid db cast") 323 } 324 325 tx, err := ddb.Begin() 326 if err != nil { 327 return fmt.Errorf("failed to start transaction") 328 } 329 330 err = db.ValidateProfile(tx, &profile) 331 if err != nil { 332 return fmt.Errorf("invalid profile record") 333 } 334 335 err = db.UpsertProfile(tx, &profile) 336 case models.CommitOperationDelete: 337 err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey)) 338 } 339 340 if err != nil { 341 return fmt.Errorf("failed to %s profile record: %w", e.Commit.Operation, err) 342 } 343 344 return nil 345} 346 347func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error { 348 did := e.Did 349 var err error 350 351 l := i.Logger.With("handler", "ingestSpindleMember") 352 l = l.With("nsid", e.Commit.Collection) 353 354 switch e.Commit.Operation { 355 case models.CommitOperationCreate: 356 raw := json.RawMessage(e.Commit.Record) 357 record := tangled.SpindleMember{} 358 err = json.Unmarshal(raw, &record) 359 if err != nil { 360 l.Error("invalid record", "err", err) 361 return err 362 } 363 364 // only spindle owner can invite to spindles 365 ok, err := i.Enforcer.IsSpindleInviteAllowed(did, record.Instance) 366 if err != nil || !ok { 367 return fmt.Errorf("failed to enforce permissions: %w", err) 368 } 369 370 memberId, err := i.IdResolver.ResolveIdent(ctx, record.Subject) 371 if err != nil { 372 return err 373 } 374 375 if memberId.Handle.IsInvalidHandle() { 376 return err 377 } 378 379 ddb, ok := i.Db.Execer.(*db.DB) 380 if !ok { 381 return fmt.Errorf("failed to index profile record, invalid db cast") 382 } 383 384 err = db.AddSpindleMember(ddb, db.SpindleMember{ 385 Did: syntax.DID(did), 386 Rkey: e.Commit.RKey, 387 Instance: record.Instance, 388 Subject: memberId.DID, 389 }) 390 if !ok { 391 return fmt.Errorf("failed to add to db: %w", err) 392 } 393 394 err = i.Enforcer.AddSpindleMember(record.Instance, memberId.DID.String()) 395 if err != nil { 396 return fmt.Errorf("failed to update ACLs: %w", err) 397 } 398 399 l.Info("added spindle member") 400 case models.CommitOperationDelete: 401 rkey := e.Commit.RKey 402 403 ddb, ok := i.Db.Execer.(*db.DB) 404 if !ok { 405 return fmt.Errorf("failed to index profile record, invalid db cast") 406 } 407 408 // get record from db first 409 members, err := db.GetSpindleMembers( 410 ddb, 411 db.FilterEq("did", did), 412 db.FilterEq("rkey", rkey), 413 ) 414 if err != nil || len(members) != 1 { 415 return fmt.Errorf("failed to get member: %w, len(members) = %d", err, len(members)) 416 } 417 member := members[0] 418 419 tx, err := ddb.Begin() 420 if err != nil { 421 return fmt.Errorf("failed to start txn: %w", err) 422 } 423 424 // remove record by rkey && update enforcer 425 if err = db.RemoveSpindleMember( 426 tx, 427 db.FilterEq("did", did), 428 db.FilterEq("rkey", rkey), 429 ); err != nil { 430 return fmt.Errorf("failed to remove from db: %w", err) 431 } 432 433 // update enforcer 434 err = i.Enforcer.RemoveSpindleMember(member.Instance, member.Subject.String()) 435 if err != nil { 436 return fmt.Errorf("failed to update ACLs: %w", err) 437 } 438 439 if err = tx.Commit(); err != nil { 440 return fmt.Errorf("failed to commit txn: %w", err) 441 } 442 443 if err = i.Enforcer.E.SavePolicy(); err != nil { 444 return fmt.Errorf("failed to save ACLs: %w", err) 445 } 446 447 l.Info("removed spindle member") 448 } 449 450 return nil 451} 452 453func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error { 454 did := e.Did 455 var err error 456 457 l := i.Logger.With("handler", "ingestSpindle") 458 l = l.With("nsid", e.Commit.Collection) 459 460 switch e.Commit.Operation { 461 case models.CommitOperationCreate: 462 raw := json.RawMessage(e.Commit.Record) 463 record := tangled.Spindle{} 464 err = json.Unmarshal(raw, &record) 465 if err != nil { 466 l.Error("invalid record", "err", err) 467 return err 468 } 469 470 instance := e.Commit.RKey 471 472 ddb, ok := i.Db.Execer.(*db.DB) 473 if !ok { 474 return fmt.Errorf("failed to index profile record, invalid db cast") 475 } 476 477 err := db.AddSpindle(ddb, db.Spindle{ 478 Owner: syntax.DID(did), 479 Instance: instance, 480 }) 481 if err != nil { 482 l.Error("failed to add spindle to db", "err", err, "instance", instance) 483 return err 484 } 485 486 err = serververify.RunVerification(ctx, instance, did, i.Config.Core.Dev) 487 if err != nil { 488 l.Error("failed to add spindle to db", "err", err, "instance", instance) 489 return err 490 } 491 492 _, err = serververify.MarkSpindleVerified(ddb, i.Enforcer, instance, did) 493 if err != nil { 494 return fmt.Errorf("failed to mark verified: %w", err) 495 } 496 497 return nil 498 499 case models.CommitOperationDelete: 500 instance := e.Commit.RKey 501 502 ddb, ok := i.Db.Execer.(*db.DB) 503 if !ok { 504 return fmt.Errorf("failed to index profile record, invalid db cast") 505 } 506 507 // get record from db first 508 spindles, err := db.GetSpindles( 509 ddb, 510 db.FilterEq("owner", did), 511 db.FilterEq("instance", instance), 512 ) 513 if err != nil || len(spindles) != 1 { 514 return fmt.Errorf("failed to get spindles: %w, len(spindles) = %d", err, len(spindles)) 515 } 516 spindle := spindles[0] 517 518 tx, err := ddb.Begin() 519 if err != nil { 520 return err 521 } 522 defer func() { 523 tx.Rollback() 524 i.Enforcer.E.LoadPolicy() 525 }() 526 527 // remove spindle members first 528 err = db.RemoveSpindleMember( 529 tx, 530 db.FilterEq("owner", did), 531 db.FilterEq("instance", instance), 532 ) 533 if err != nil { 534 return err 535 } 536 537 err = db.DeleteSpindle( 538 tx, 539 db.FilterEq("owner", did), 540 db.FilterEq("instance", instance), 541 ) 542 if err != nil { 543 return err 544 } 545 546 if spindle.Verified != nil { 547 err = i.Enforcer.RemoveSpindle(instance) 548 if err != nil { 549 return err 550 } 551 } 552 553 err = tx.Commit() 554 if err != nil { 555 return err 556 } 557 558 err = i.Enforcer.E.SavePolicy() 559 if err != nil { 560 return err 561 } 562 } 563 564 return nil 565} 566 567func (i *Ingester) ingestString(e *models.Event) error { 568 did := e.Did 569 rkey := e.Commit.RKey 570 571 var err error 572 573 l := i.Logger.With("handler", "ingestString", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 574 l.Info("ingesting record") 575 576 ddb, ok := i.Db.Execer.(*db.DB) 577 if !ok { 578 return fmt.Errorf("failed to index string record, invalid db cast") 579 } 580 581 switch e.Commit.Operation { 582 case models.CommitOperationCreate, models.CommitOperationUpdate: 583 raw := json.RawMessage(e.Commit.Record) 584 record := tangled.String{} 585 err = json.Unmarshal(raw, &record) 586 if err != nil { 587 l.Error("invalid record", "err", err) 588 return err 589 } 590 591 string := db.StringFromRecord(did, rkey, record) 592 593 if err = string.Validate(); err != nil { 594 l.Error("invalid record", "err", err) 595 return err 596 } 597 598 if err = db.AddString(ddb, string); err != nil { 599 l.Error("failed to add string", "err", err) 600 return err 601 } 602 603 return nil 604 605 case models.CommitOperationDelete: 606 if err := db.DeleteString( 607 ddb, 608 db.FilterEq("did", did), 609 db.FilterEq("rkey", rkey), 610 ); err != nil { 611 l.Error("failed to delete", "err", err) 612 return fmt.Errorf("failed to delete string record: %w", err) 613 } 614 615 return nil 616 } 617 618 return nil 619} 620 621func (i *Ingester) ingestKnotMember(e *models.Event) error { 622 did := e.Did 623 var err error 624 625 l := i.Logger.With("handler", "ingestKnotMember") 626 l = l.With("nsid", e.Commit.Collection) 627 628 switch e.Commit.Operation { 629 case models.CommitOperationCreate: 630 raw := json.RawMessage(e.Commit.Record) 631 record := tangled.KnotMember{} 632 err = json.Unmarshal(raw, &record) 633 if err != nil { 634 l.Error("invalid record", "err", err) 635 return err 636 } 637 638 // only knot owner can invite to knots 639 ok, err := i.Enforcer.IsKnotInviteAllowed(did, record.Domain) 640 if err != nil || !ok { 641 return fmt.Errorf("failed to enforce permissions: %w", err) 642 } 643 644 memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject) 645 if err != nil { 646 return err 647 } 648 649 if memberId.Handle.IsInvalidHandle() { 650 return err 651 } 652 653 err = i.Enforcer.AddKnotMember(record.Domain, memberId.DID.String()) 654 if err != nil { 655 return fmt.Errorf("failed to update ACLs: %w", err) 656 } 657 658 l.Info("added knot member") 659 case models.CommitOperationDelete: 660 // we don't store knot members in a table (like we do for spindle) 661 // and we can't remove this just yet. possibly fixed if we switch 662 // to either: 663 // 1. a knot_members table like with spindle and store the rkey 664 // 2. use the knot host as the rkey 665 // 666 // TODO: implement member deletion 667 l.Info("skipping knot member delete", "did", did, "rkey", e.Commit.RKey) 668 } 669 670 return nil 671} 672 673func (i *Ingester) ingestKnot(e *models.Event) error { 674 did := e.Did 675 var err error 676 677 l := i.Logger.With("handler", "ingestKnot") 678 l = l.With("nsid", e.Commit.Collection) 679 680 switch e.Commit.Operation { 681 case models.CommitOperationCreate: 682 raw := json.RawMessage(e.Commit.Record) 683 record := tangled.Knot{} 684 err = json.Unmarshal(raw, &record) 685 if err != nil { 686 l.Error("invalid record", "err", err) 687 return err 688 } 689 690 domain := e.Commit.RKey 691 692 ddb, ok := i.Db.Execer.(*db.DB) 693 if !ok { 694 return fmt.Errorf("failed to index profile record, invalid db cast") 695 } 696 697 err := db.AddKnot(ddb, domain, did) 698 if err != nil { 699 l.Error("failed to add knot to db", "err", err, "domain", domain) 700 return err 701 } 702 703 err = serververify.RunVerification(context.Background(), domain, did, i.Config.Core.Dev) 704 if err != nil { 705 l.Error("failed to verify knot", "err", err, "domain", domain) 706 return err 707 } 708 709 err = serververify.MarkKnotVerified(ddb, i.Enforcer, domain, did) 710 if err != nil { 711 return fmt.Errorf("failed to mark verified: %w", err) 712 } 713 714 return nil 715 716 case models.CommitOperationDelete: 717 domain := e.Commit.RKey 718 719 ddb, ok := i.Db.Execer.(*db.DB) 720 if !ok { 721 return fmt.Errorf("failed to index knot record, invalid db cast") 722 } 723 724 // get record from db first 725 registrations, err := db.GetRegistrations( 726 ddb, 727 db.FilterEq("domain", domain), 728 db.FilterEq("did", did), 729 ) 730 if err != nil { 731 return fmt.Errorf("failed to get registration: %w", err) 732 } 733 if len(registrations) != 1 { 734 return fmt.Errorf("got incorret number of registrations: %d, expected 1", len(registrations)) 735 } 736 registration := registrations[0] 737 738 tx, err := ddb.Begin() 739 if err != nil { 740 return err 741 } 742 defer func() { 743 tx.Rollback() 744 i.Enforcer.E.LoadPolicy() 745 }() 746 747 err = db.DeleteKnot( 748 tx, 749 db.FilterEq("did", did), 750 db.FilterEq("domain", domain), 751 ) 752 if err != nil { 753 return err 754 } 755 756 if registration.Registered != nil { 757 err = i.Enforcer.RemoveKnot(domain) 758 if err != nil { 759 return err 760 } 761 } 762 763 err = tx.Commit() 764 if err != nil { 765 return err 766 } 767 768 err = i.Enforcer.E.SavePolicy() 769 if err != nil { 770 return err 771 } 772 } 773 774 return nil 775} 776func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error { 777 did := e.Did 778 rkey := e.Commit.RKey 779 780 var err error 781 782 l := i.Logger.With("handler", "ingestIssue", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 783 l.Info("ingesting record") 784 785 ddb, ok := i.Db.Execer.(*db.DB) 786 if !ok { 787 return fmt.Errorf("failed to index issue record, invalid db cast") 788 } 789 790 switch e.Commit.Operation { 791 case models.CommitOperationCreate: 792 raw := json.RawMessage(e.Commit.Record) 793 record := tangled.RepoIssue{} 794 err = json.Unmarshal(raw, &record) 795 if err != nil { 796 l.Error("invalid record", "err", err) 797 return err 798 } 799 800 issue := db.IssueFromRecord(did, rkey, record) 801 802 sanitizer := markup.NewSanitizer() 803 if st := strings.TrimSpace(sanitizer.SanitizeDescription(issue.Title)); st == "" { 804 return fmt.Errorf("title is empty after HTML sanitization") 805 } 806 if sb := strings.TrimSpace(sanitizer.SanitizeDefault(issue.Body)); sb == "" { 807 return fmt.Errorf("body is empty after HTML sanitization") 808 } 809 810 tx, err := ddb.BeginTx(ctx, nil) 811 if err != nil { 812 l.Error("failed to begin transaction", "err", err) 813 return err 814 } 815 816 err = db.NewIssue(tx, &issue) 817 if err != nil { 818 l.Error("failed to create issue", "err", err) 819 return err 820 } 821 822 return nil 823 824 case models.CommitOperationUpdate: 825 raw := json.RawMessage(e.Commit.Record) 826 record := tangled.RepoIssue{} 827 err = json.Unmarshal(raw, &record) 828 if err != nil { 829 l.Error("invalid record", "err", err) 830 return err 831 } 832 833 body := "" 834 if record.Body != nil { 835 body = *record.Body 836 } 837 838 sanitizer := markup.NewSanitizer() 839 if st := strings.TrimSpace(sanitizer.SanitizeDescription(record.Title)); st == "" { 840 return fmt.Errorf("title is empty after HTML sanitization") 841 } 842 if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" { 843 return fmt.Errorf("body is empty after HTML sanitization") 844 } 845 846 err = db.UpdateIssueByRkey(ddb, did, rkey, record.Title, body) 847 if err != nil { 848 l.Error("failed to update issue", "err", err) 849 return err 850 } 851 852 return nil 853 854 case models.CommitOperationDelete: 855 if err := db.DeleteIssueByRkey(ddb, did, rkey); err != nil { 856 l.Error("failed to delete", "err", err) 857 return fmt.Errorf("failed to delete issue record: %w", err) 858 } 859 860 return nil 861 } 862 863 return fmt.Errorf("unknown operation: %s", e.Commit.Operation) 864}