forked from tangled.org/core
Monorepo for Tangled

Compare changes

Choose any two refs to compare.

-416
api/tangled/cbor_gen.go
··· 561 561 562 562 return nil 563 563 } 564 - func (t *Comment) MarshalCBOR(w io.Writer) error { 565 - if t == nil { 566 - _, err := w.Write(cbg.CborNull) 567 - return err 568 - } 569 - 570 - cw := cbg.NewCborWriter(w) 571 - fieldCount := 7 572 - 573 - if t.Mentions == nil { 574 - fieldCount-- 575 - } 576 - 577 - if t.References == nil { 578 - fieldCount-- 579 - } 580 - 581 - if t.ReplyTo == nil { 582 - fieldCount-- 583 - } 584 - 585 - if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 586 - return err 587 - } 588 - 589 - // t.Body (string) (string) 590 - if len("body") > 1000000 { 591 - return xerrors.Errorf("Value in field \"body\" was too long") 592 - } 593 - 594 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("body"))); err != nil { 595 - return err 596 - } 597 - if _, err := cw.WriteString(string("body")); err != nil { 598 - return err 599 - } 600 - 601 - if len(t.Body) > 1000000 { 602 - return xerrors.Errorf("Value in field t.Body was too long") 603 - } 604 - 605 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Body))); err != nil { 606 - return err 607 - } 608 - if _, err := cw.WriteString(string(t.Body)); err != nil { 609 - return err 610 - } 611 - 612 - // t.LexiconTypeID (string) (string) 613 - if len("$type") > 1000000 { 614 - return xerrors.Errorf("Value in field \"$type\" was too long") 615 - } 616 - 617 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 618 - return err 619 - } 620 - if _, err := cw.WriteString(string("$type")); err != nil { 621 - return err 622 - } 623 - 624 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.comment"))); err != nil { 625 - return err 626 - } 627 - if _, err := cw.WriteString(string("sh.tangled.comment")); err != nil { 628 - return err 629 - } 630 - 631 - // t.ReplyTo (string) (string) 632 - if t.ReplyTo != nil { 633 - 634 - if len("replyTo") > 1000000 { 635 - return xerrors.Errorf("Value in field \"replyTo\" was too long") 636 - } 637 - 638 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("replyTo"))); err != nil { 639 - return err 640 - } 641 - if _, err := cw.WriteString(string("replyTo")); err != nil { 642 - return err 643 - } 644 - 645 - if t.ReplyTo == nil { 646 - if _, err := cw.Write(cbg.CborNull); err != nil { 647 - return err 648 - } 649 - } else { 650 - if len(*t.ReplyTo) > 1000000 { 651 - return xerrors.Errorf("Value in field t.ReplyTo was too long") 652 - } 653 - 654 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ReplyTo))); err != nil { 655 - return err 656 - } 657 - if _, err := cw.WriteString(string(*t.ReplyTo)); err != nil { 658 - return err 659 - } 660 - } 661 - } 662 - 663 - // t.Subject (string) (string) 664 - if len("subject") > 1000000 { 665 - return xerrors.Errorf("Value in field \"subject\" was too long") 666 - } 667 - 668 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil { 669 - return err 670 - } 671 - if _, err := cw.WriteString(string("subject")); err != nil { 672 - return err 673 - } 674 - 675 - if len(t.Subject) > 1000000 { 676 - return xerrors.Errorf("Value in field t.Subject was too long") 677 - } 678 - 679 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil { 680 - return err 681 - } 682 - if _, err := cw.WriteString(string(t.Subject)); err != nil { 683 - return err 684 - } 685 - 686 - // t.Mentions ([]string) (slice) 687 - if t.Mentions != nil { 688 - 689 - if len("mentions") > 1000000 { 690 - return xerrors.Errorf("Value in field \"mentions\" was too long") 691 - } 692 - 693 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil { 694 - return err 695 - } 696 - if _, err := cw.WriteString(string("mentions")); err != nil { 697 - return err 698 - } 699 - 700 - if len(t.Mentions) > 8192 { 701 - return xerrors.Errorf("Slice value in field t.Mentions was too long") 702 - } 703 - 704 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil { 705 - return err 706 - } 707 - for _, v := range t.Mentions { 708 - if len(v) > 1000000 { 709 - return xerrors.Errorf("Value in field v was too long") 710 - } 711 - 712 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 713 - return err 714 - } 715 - if _, err := cw.WriteString(string(v)); err != nil { 716 - return err 717 - } 718 - 719 - } 720 - } 721 - 722 - // t.CreatedAt (string) (string) 723 - if len("createdAt") > 1000000 { 724 - return xerrors.Errorf("Value in field \"createdAt\" was too long") 725 - } 726 - 727 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 728 - return err 729 - } 730 - if _, err := cw.WriteString(string("createdAt")); err != nil { 731 - return err 732 - } 733 - 734 - if len(t.CreatedAt) > 1000000 { 735 - return xerrors.Errorf("Value in field t.CreatedAt was too long") 736 - } 737 - 738 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 739 - return err 740 - } 741 - if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 742 - return err 743 - } 744 - 745 - // t.References ([]string) (slice) 746 - if t.References != nil { 747 - 748 - if len("references") > 1000000 { 749 - return xerrors.Errorf("Value in field \"references\" was too long") 750 - } 751 - 752 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { 753 - return err 754 - } 755 - if _, err := cw.WriteString(string("references")); err != nil { 756 - return err 757 - } 758 - 759 - if len(t.References) > 8192 { 760 - return xerrors.Errorf("Slice value in field t.References was too long") 761 - } 762 - 763 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { 764 - return err 765 - } 766 - for _, v := range t.References { 767 - if len(v) > 1000000 { 768 - return xerrors.Errorf("Value in field v was too long") 769 - } 770 - 771 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 772 - return err 773 - } 774 - if _, err := cw.WriteString(string(v)); err != nil { 775 - return err 776 - } 777 - 778 - } 779 - } 780 - return nil 781 - } 782 - 783 - func (t *Comment) UnmarshalCBOR(r io.Reader) (err error) { 784 - *t = Comment{} 785 - 786 - cr := cbg.NewCborReader(r) 787 - 788 - maj, extra, err := cr.ReadHeader() 789 - if err != nil { 790 - return err 791 - } 792 - defer func() { 793 - if err == io.EOF { 794 - err = io.ErrUnexpectedEOF 795 - } 796 - }() 797 - 798 - if maj != cbg.MajMap { 799 - return fmt.Errorf("cbor input should be of type map") 800 - } 801 - 802 - if extra > cbg.MaxLength { 803 - return fmt.Errorf("Comment: map struct too large (%d)", extra) 804 - } 805 - 806 - n := extra 807 - 808 - nameBuf := make([]byte, 10) 809 - for i := uint64(0); i < n; i++ { 810 - nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 811 - if err != nil { 812 - return err 813 - } 814 - 815 - if !ok { 816 - // Field doesn't exist on this type, so ignore it 817 - if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 818 - return err 819 - } 820 - continue 821 - } 822 - 823 - switch string(nameBuf[:nameLen]) { 824 - // t.Body (string) (string) 825 - case "body": 826 - 827 - { 828 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 829 - if err != nil { 830 - return err 831 - } 832 - 833 - t.Body = string(sval) 834 - } 835 - // t.LexiconTypeID (string) (string) 836 - case "$type": 837 - 838 - { 839 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 840 - if err != nil { 841 - return err 842 - } 843 - 844 - t.LexiconTypeID = string(sval) 845 - } 846 - // t.ReplyTo (string) (string) 847 - case "replyTo": 848 - 849 - { 850 - b, err := cr.ReadByte() 851 - if err != nil { 852 - return err 853 - } 854 - if b != cbg.CborNull[0] { 855 - if err := cr.UnreadByte(); err != nil { 856 - return err 857 - } 858 - 859 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 860 - if err != nil { 861 - return err 862 - } 863 - 864 - t.ReplyTo = (*string)(&sval) 865 - } 866 - } 867 - // t.Subject (string) (string) 868 - case "subject": 869 - 870 - { 871 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 872 - if err != nil { 873 - return err 874 - } 875 - 876 - t.Subject = string(sval) 877 - } 878 - // t.Mentions ([]string) (slice) 879 - case "mentions": 880 - 881 - maj, extra, err = cr.ReadHeader() 882 - if err != nil { 883 - return err 884 - } 885 - 886 - if extra > 8192 { 887 - return fmt.Errorf("t.Mentions: array too large (%d)", extra) 888 - } 889 - 890 - if maj != cbg.MajArray { 891 - return fmt.Errorf("expected cbor array") 892 - } 893 - 894 - if extra > 0 { 895 - t.Mentions = make([]string, extra) 896 - } 897 - 898 - for i := 0; i < int(extra); i++ { 899 - { 900 - var maj byte 901 - var extra uint64 902 - var err error 903 - _ = maj 904 - _ = extra 905 - _ = err 906 - 907 - { 908 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 909 - if err != nil { 910 - return err 911 - } 912 - 913 - t.Mentions[i] = string(sval) 914 - } 915 - 916 - } 917 - } 918 - // t.CreatedAt (string) (string) 919 - case "createdAt": 920 - 921 - { 922 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 923 - if err != nil { 924 - return err 925 - } 926 - 927 - t.CreatedAt = string(sval) 928 - } 929 - // t.References ([]string) (slice) 930 - case "references": 931 - 932 - maj, extra, err = cr.ReadHeader() 933 - if err != nil { 934 - return err 935 - } 936 - 937 - if extra > 8192 { 938 - return fmt.Errorf("t.References: array too large (%d)", extra) 939 - } 940 - 941 - if maj != cbg.MajArray { 942 - return fmt.Errorf("expected cbor array") 943 - } 944 - 945 - if extra > 0 { 946 - t.References = make([]string, extra) 947 - } 948 - 949 - for i := 0; i < int(extra); i++ { 950 - { 951 - var maj byte 952 - var extra uint64 953 - var err error 954 - _ = maj 955 - _ = extra 956 - _ = err 957 - 958 - { 959 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 960 - if err != nil { 961 - return err 962 - } 963 - 964 - t.References[i] = string(sval) 965 - } 966 - 967 - } 968 - } 969 - 970 - default: 971 - // Field doesn't exist on this type, so ignore it 972 - if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 973 - return err 974 - } 975 - } 976 - } 977 - 978 - return nil 979 - } 980 564 func (t *FeedReaction) MarshalCBOR(w io.Writer) error { 981 565 if t == nil { 982 566 _, err := w.Write(cbg.CborNull)
+34
api/tangled/pipelinecancelPipeline.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.pipeline.cancelPipeline 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + PipelineCancelPipelineNSID = "sh.tangled.pipeline.cancelPipeline" 15 + ) 16 + 17 + // PipelineCancelPipeline_Input is the input argument to a sh.tangled.pipeline.cancelPipeline call. 18 + type PipelineCancelPipeline_Input struct { 19 + // pipeline: pipeline at-uri 20 + Pipeline string `json:"pipeline" cborgen:"pipeline"` 21 + // repo: repo at-uri, spindle can't resolve repo from pipeline at-uri yet 22 + Repo string `json:"repo" cborgen:"repo"` 23 + // workflow: workflow name 24 + Workflow string `json:"workflow" cborgen:"workflow"` 25 + } 26 + 27 + // PipelineCancelPipeline calls the XRPC method "sh.tangled.pipeline.cancelPipeline". 28 + func PipelineCancelPipeline(ctx context.Context, c util.LexClient, input *PipelineCancelPipeline_Input) error { 29 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.pipeline.cancelPipeline", nil, input, nil); err != nil { 30 + return err 31 + } 32 + 33 + return nil 34 + }
-27
api/tangled/tangledcomment.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.comment 6 - 7 - import ( 8 - "github.com/bluesky-social/indigo/lex/util" 9 - ) 10 - 11 - const ( 12 - CommentNSID = "sh.tangled.comment" 13 - ) 14 - 15 - func init() { 16 - util.RegisterType("sh.tangled.comment", &Comment{}) 17 - } // 18 - // RECORDTYPE: Comment 19 - type Comment struct { 20 - LexiconTypeID string `json:"$type,const=sh.tangled.comment" cborgen:"$type,const=sh.tangled.comment"` 21 - Body string `json:"body" cborgen:"body"` 22 - CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 24 - References []string `json:"references,omitempty" cborgen:"references,omitempty"` 25 - ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` 26 - Subject string `json:"subject" cborgen:"subject"` 27 - }
-199
appview/db/comments.go
··· 1 - package db 2 - 3 - import ( 4 - "database/sql" 5 - "fmt" 6 - "maps" 7 - "slices" 8 - "sort" 9 - "strings" 10 - "time" 11 - 12 - "github.com/bluesky-social/indigo/atproto/syntax" 13 - "tangled.org/core/appview/models" 14 - "tangled.org/core/orm" 15 - ) 16 - 17 - func PutComment(tx *sql.Tx, c *models.Comment) error { 18 - result, err := tx.Exec( 19 - `insert into comments ( 20 - did, 21 - rkey, 22 - subject_at, 23 - reply_to, 24 - body, 25 - pull_submission_id, 26 - created 27 - ) 28 - values (?, ?, ?, ?, ?, ?, ?) 29 - on conflict(did, rkey) do update set 30 - subject_at = excluded.subject_at, 31 - reply_to = excluded.reply_to, 32 - body = excluded.body, 33 - edited = case 34 - when 35 - comments.subject_at != excluded.subject_at 36 - or comments.body != excluded.body 37 - or comments.reply_to != excluded.reply_to 38 - then ? 39 - else comments.edited 40 - end`, 41 - c.Did, 42 - c.Rkey, 43 - c.Subject, 44 - c.ReplyTo, 45 - c.Body, 46 - c.PullSubmissionId, 47 - c.Created.Format(time.RFC3339), 48 - time.Now().Format(time.RFC3339), 49 - ) 50 - if err != nil { 51 - return err 52 - } 53 - 54 - c.Id, err = result.LastInsertId() 55 - if err != nil { 56 - return err 57 - } 58 - 59 - if err := putReferences(tx, c.AtUri(), c.References); err != nil { 60 - return fmt.Errorf("put reference_links: %w", err) 61 - } 62 - 63 - return nil 64 - } 65 - 66 - func DeleteComments(e Execer, filters ...orm.Filter) error { 67 - var conditions []string 68 - var args []any 69 - for _, filter := range filters { 70 - conditions = append(conditions, filter.Condition()) 71 - args = append(args, filter.Arg()...) 72 - } 73 - 74 - whereClause := "" 75 - if conditions != nil { 76 - whereClause = " where " + strings.Join(conditions, " and ") 77 - } 78 - 79 - query := fmt.Sprintf(`update comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 80 - 81 - _, err := e.Exec(query, args...) 82 - return err 83 - } 84 - 85 - func GetComments(e Execer, filters ...orm.Filter) ([]models.Comment, error) { 86 - commentMap := make(map[string]*models.Comment) 87 - 88 - var conditions []string 89 - var args []any 90 - for _, filter := range filters { 91 - conditions = append(conditions, filter.Condition()) 92 - args = append(args, filter.Arg()...) 93 - } 94 - 95 - whereClause := "" 96 - if conditions != nil { 97 - whereClause = " where " + strings.Join(conditions, " and ") 98 - } 99 - 100 - query := fmt.Sprintf(` 101 - select 102 - id, 103 - did, 104 - rkey, 105 - subject_at, 106 - reply_to, 107 - body, 108 - pull_submission_id, 109 - created, 110 - edited, 111 - deleted 112 - from 113 - comments 114 - %s 115 - `, whereClause) 116 - 117 - rows, err := e.Query(query, args...) 118 - if err != nil { 119 - return nil, err 120 - } 121 - 122 - for rows.Next() { 123 - var comment models.Comment 124 - var created string 125 - var rkey, edited, deleted, replyTo sql.Null[string] 126 - err := rows.Scan( 127 - &comment.Id, 128 - &comment.Did, 129 - &rkey, 130 - &comment.Subject, 131 - &replyTo, 132 - &comment.Body, 133 - &comment.PullSubmissionId, 134 - &created, 135 - &edited, 136 - &deleted, 137 - ) 138 - if err != nil { 139 - return nil, err 140 - } 141 - 142 - // this is a remnant from old times, newer comments always have rkey 143 - if rkey.Valid { 144 - comment.Rkey = rkey.V 145 - } 146 - 147 - if t, err := time.Parse(time.RFC3339, created); err == nil { 148 - comment.Created = t 149 - } 150 - 151 - if edited.Valid { 152 - if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 153 - comment.Edited = &t 154 - } 155 - } 156 - 157 - if deleted.Valid { 158 - if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 159 - comment.Deleted = &t 160 - } 161 - } 162 - 163 - if replyTo.Valid { 164 - rt := syntax.ATURI(replyTo.V) 165 - comment.ReplyTo = &rt 166 - } 167 - 168 - atUri := comment.AtUri().String() 169 - commentMap[atUri] = &comment 170 - } 171 - 172 - if err := rows.Err(); err != nil { 173 - return nil, err 174 - } 175 - defer rows.Close() 176 - 177 - // collect references from each comments 178 - commentAts := slices.Collect(maps.Keys(commentMap)) 179 - allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) 180 - if err != nil { 181 - return nil, fmt.Errorf("failed to query reference_links: %w", err) 182 - } 183 - for commentAt, references := range allReferencs { 184 - if comment, ok := commentMap[commentAt.String()]; ok { 185 - comment.References = references 186 - } 187 - } 188 - 189 - var comments []models.Comment 190 - for _, c := range commentMap { 191 - comments = append(comments, *c) 192 - } 193 - 194 - sort.Slice(comments, func(i, j int) bool { 195 - return comments[i].Created.After(comments[j].Created) 196 - }) 197 - 198 - return comments, nil 199 - }
-32
appview/db/db.go
··· 1173 1173 return err 1174 1174 }) 1175 1175 1176 - // not migrating existing comments here 1177 - // all legacy comments will be dropped 1178 - orm.RunMigration(conn, logger, "add-comments-table", func(tx *sql.Tx) error { 1179 - _, err := tx.Exec(` 1180 - drop table if exists comments; 1181 - 1182 - create table comments ( 1183 - -- identifiers 1184 - id integer primary key autoincrement, 1185 - did text not null, 1186 - rkey text not null, 1187 - at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.comment' || '/' || rkey) stored, 1188 - 1189 - -- at identifiers 1190 - subject_at text not null, 1191 - reply_to text, -- at_uri of parent comment 1192 - 1193 - pull_submission_id integer, -- dirty fix until we atprotate the pull-rounds 1194 - 1195 - -- content 1196 - body text not null, 1197 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1198 - edited text, 1199 - deleted text, 1200 - 1201 - -- constraints 1202 - unique(did, rkey) 1203 - ); 1204 - `) 1205 - return err 1206 - }) 1207 - 1208 1176 return &DB{ 1209 1177 db, 1210 1178 logger,
+186 -6
appview/db/issues.go
··· 100 100 } 101 101 102 102 func GetIssuesPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]models.Issue, error) { 103 - issueMap := make(map[syntax.ATURI]*models.Issue) // at-uri -> issue 103 + issueMap := make(map[string]*models.Issue) // at-uri -> issue 104 104 105 105 var conditions []string 106 106 var args []any ··· 196 196 } 197 197 } 198 198 199 - issueMap[issue.AtUri()] = &issue 199 + atUri := issue.AtUri().String() 200 + issueMap[atUri] = &issue 200 201 } 201 202 202 203 // collect reverse repos ··· 228 229 // collect comments 229 230 issueAts := slices.Collect(maps.Keys(issueMap)) 230 231 231 - comments, err := GetComments(e, orm.FilterIn("subject_at", issueAts)) 232 + comments, err := GetIssueComments(e, orm.FilterIn("issue_at", issueAts)) 232 233 if err != nil { 233 234 return nil, fmt.Errorf("failed to query comments: %w", err) 234 235 } 235 236 for i := range comments { 236 - issueAt := comments[i].Subject 237 + issueAt := comments[i].IssueAt 237 238 if issue, ok := issueMap[issueAt]; ok { 238 239 issue.Comments = append(issue.Comments, comments[i]) 239 240 } ··· 245 246 return nil, fmt.Errorf("failed to query labels: %w", err) 246 247 } 247 248 for issueAt, labels := range allLabels { 248 - if issue, ok := issueMap[issueAt]; ok { 249 + if issue, ok := issueMap[issueAt.String()]; ok { 249 250 issue.Labels = labels 250 251 } 251 252 } ··· 256 257 return nil, fmt.Errorf("failed to query reference_links: %w", err) 257 258 } 258 259 for issueAt, references := range allReferencs { 259 - if issue, ok := issueMap[issueAt]; ok { 260 + if issue, ok := issueMap[issueAt.String()]; ok { 260 261 issue.References = references 261 262 } 262 263 } ··· 348 349 } 349 350 350 351 return ids, nil 352 + } 353 + 354 + func AddIssueComment(tx *sql.Tx, c models.IssueComment) (int64, error) { 355 + result, err := tx.Exec( 356 + `insert into issue_comments ( 357 + did, 358 + rkey, 359 + issue_at, 360 + body, 361 + reply_to, 362 + created, 363 + edited 364 + ) 365 + values (?, ?, ?, ?, ?, ?, null) 366 + on conflict(did, rkey) do update set 367 + issue_at = excluded.issue_at, 368 + body = excluded.body, 369 + edited = case 370 + when 371 + issue_comments.issue_at != excluded.issue_at 372 + or issue_comments.body != excluded.body 373 + or issue_comments.reply_to != excluded.reply_to 374 + then ? 375 + else issue_comments.edited 376 + end`, 377 + c.Did, 378 + c.Rkey, 379 + c.IssueAt, 380 + c.Body, 381 + c.ReplyTo, 382 + c.Created.Format(time.RFC3339), 383 + time.Now().Format(time.RFC3339), 384 + ) 385 + if err != nil { 386 + return 0, err 387 + } 388 + 389 + id, err := result.LastInsertId() 390 + if err != nil { 391 + return 0, err 392 + } 393 + 394 + if err := putReferences(tx, c.AtUri(), c.References); err != nil { 395 + return 0, fmt.Errorf("put reference_links: %w", err) 396 + } 397 + 398 + return id, nil 399 + } 400 + 401 + func DeleteIssueComments(e Execer, filters ...orm.Filter) error { 402 + var conditions []string 403 + var args []any 404 + for _, filter := range filters { 405 + conditions = append(conditions, filter.Condition()) 406 + args = append(args, filter.Arg()...) 407 + } 408 + 409 + whereClause := "" 410 + if conditions != nil { 411 + whereClause = " where " + strings.Join(conditions, " and ") 412 + } 413 + 414 + query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 415 + 416 + _, err := e.Exec(query, args...) 417 + return err 418 + } 419 + 420 + func GetIssueComments(e Execer, filters ...orm.Filter) ([]models.IssueComment, error) { 421 + commentMap := make(map[string]*models.IssueComment) 422 + 423 + var conditions []string 424 + var args []any 425 + for _, filter := range filters { 426 + conditions = append(conditions, filter.Condition()) 427 + args = append(args, filter.Arg()...) 428 + } 429 + 430 + whereClause := "" 431 + if conditions != nil { 432 + whereClause = " where " + strings.Join(conditions, " and ") 433 + } 434 + 435 + query := fmt.Sprintf(` 436 + select 437 + id, 438 + did, 439 + rkey, 440 + issue_at, 441 + reply_to, 442 + body, 443 + created, 444 + edited, 445 + deleted 446 + from 447 + issue_comments 448 + %s 449 + `, whereClause) 450 + 451 + rows, err := e.Query(query, args...) 452 + if err != nil { 453 + return nil, err 454 + } 455 + defer rows.Close() 456 + 457 + for rows.Next() { 458 + var comment models.IssueComment 459 + var created string 460 + var rkey, edited, deleted, replyTo sql.Null[string] 461 + err := rows.Scan( 462 + &comment.Id, 463 + &comment.Did, 464 + &rkey, 465 + &comment.IssueAt, 466 + &replyTo, 467 + &comment.Body, 468 + &created, 469 + &edited, 470 + &deleted, 471 + ) 472 + if err != nil { 473 + return nil, err 474 + } 475 + 476 + // this is a remnant from old times, newer comments always have rkey 477 + if rkey.Valid { 478 + comment.Rkey = rkey.V 479 + } 480 + 481 + if t, err := time.Parse(time.RFC3339, created); err == nil { 482 + comment.Created = t 483 + } 484 + 485 + if edited.Valid { 486 + if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 487 + comment.Edited = &t 488 + } 489 + } 490 + 491 + if deleted.Valid { 492 + if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 493 + comment.Deleted = &t 494 + } 495 + } 496 + 497 + if replyTo.Valid { 498 + comment.ReplyTo = &replyTo.V 499 + } 500 + 501 + atUri := comment.AtUri().String() 502 + commentMap[atUri] = &comment 503 + } 504 + 505 + if err = rows.Err(); err != nil { 506 + return nil, err 507 + } 508 + 509 + // collect references for each comments 510 + commentAts := slices.Collect(maps.Keys(commentMap)) 511 + allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) 512 + if err != nil { 513 + return nil, fmt.Errorf("failed to query reference_links: %w", err) 514 + } 515 + for commentAt, references := range allReferencs { 516 + if comment, ok := commentMap[commentAt.String()]; ok { 517 + comment.References = references 518 + } 519 + } 520 + 521 + var comments []models.IssueComment 522 + for _, c := range commentMap { 523 + comments = append(comments, *c) 524 + } 525 + 526 + sort.Slice(comments, func(i, j int) bool { 527 + return comments[i].Created.After(comments[j].Created) 528 + }) 529 + 530 + return comments, nil 351 531 } 352 532 353 533 func DeleteIssues(tx *sql.Tx, did, rkey string) error {
+6 -6
appview/db/pipeline.go
··· 6 6 "strings" 7 7 "time" 8 8 9 + "github.com/bluesky-social/indigo/atproto/syntax" 9 10 "tangled.org/core/appview/models" 10 11 "tangled.org/core/orm" 11 12 ) ··· 216 217 } 217 218 defer rows.Close() 218 219 219 - pipelines := make(map[string]models.Pipeline) 220 + pipelines := make(map[syntax.ATURI]models.Pipeline) 220 221 for rows.Next() { 221 222 var p models.Pipeline 222 223 var t models.Trigger ··· 253 254 p.Trigger = &t 254 255 p.Statuses = make(map[string]models.WorkflowStatus) 255 256 256 - k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey) 257 - pipelines[k] = p 257 + pipelines[p.AtUri()] = p 258 258 } 259 259 260 260 // get all statuses ··· 314 314 return nil, fmt.Errorf("invalid status created timestamp %q: %w", created, err) 315 315 } 316 316 317 - key := fmt.Sprintf("%s/%s", ps.PipelineKnot, ps.PipelineRkey) 317 + pipelineAt := ps.PipelineAt() 318 318 319 319 // extract 320 - pipeline, ok := pipelines[key] 320 + pipeline, ok := pipelines[pipelineAt] 321 321 if !ok { 322 322 continue 323 323 } ··· 331 331 332 332 // reassign 333 333 pipeline.Statuses[ps.Workflow] = statuses 334 - pipelines[key] = pipeline 334 + pipelines[pipelineAt] = pipeline 335 335 } 336 336 337 337 var all []models.Pipeline
+121 -6
appview/db/pulls.go
··· 447 447 return nil, err 448 448 } 449 449 450 - // Get comments for all submissions using GetComments 450 + // Get comments for all submissions using GetPullComments 451 451 submissionIds := slices.Collect(maps.Keys(submissionMap)) 452 - comments, err := GetComments(e, orm.FilterIn("pull_submission_id", submissionIds)) 452 + comments, err := GetPullComments(e, orm.FilterIn("submission_id", submissionIds)) 453 453 if err != nil { 454 454 return nil, fmt.Errorf("failed to get pull comments: %w", err) 455 455 } 456 456 for _, comment := range comments { 457 - if comment.PullSubmissionId != nil { 458 - if submission, ok := submissionMap[*comment.PullSubmissionId]; ok { 459 - submission.Comments = append(submission.Comments, comment) 460 - } 457 + if submission, ok := submissionMap[comment.SubmissionId]; ok { 458 + submission.Comments = append(submission.Comments, comment) 461 459 } 462 460 } 463 461 ··· 477 475 return m, nil 478 476 } 479 477 478 + func GetPullComments(e Execer, filters ...orm.Filter) ([]models.PullComment, error) { 479 + var conditions []string 480 + var args []any 481 + for _, filter := range filters { 482 + conditions = append(conditions, filter.Condition()) 483 + args = append(args, filter.Arg()...) 484 + } 485 + 486 + whereClause := "" 487 + if conditions != nil { 488 + whereClause = " where " + strings.Join(conditions, " and ") 489 + } 490 + 491 + query := fmt.Sprintf(` 492 + select 493 + id, 494 + pull_id, 495 + submission_id, 496 + repo_at, 497 + owner_did, 498 + comment_at, 499 + body, 500 + created 501 + from 502 + pull_comments 503 + %s 504 + order by 505 + created asc 506 + `, whereClause) 507 + 508 + rows, err := e.Query(query, args...) 509 + if err != nil { 510 + return nil, err 511 + } 512 + defer rows.Close() 513 + 514 + commentMap := make(map[string]*models.PullComment) 515 + for rows.Next() { 516 + var comment models.PullComment 517 + var createdAt string 518 + err := rows.Scan( 519 + &comment.ID, 520 + &comment.PullId, 521 + &comment.SubmissionId, 522 + &comment.RepoAt, 523 + &comment.OwnerDid, 524 + &comment.CommentAt, 525 + &comment.Body, 526 + &createdAt, 527 + ) 528 + if err != nil { 529 + return nil, err 530 + } 531 + 532 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 533 + comment.Created = t 534 + } 535 + 536 + atUri := comment.AtUri().String() 537 + commentMap[atUri] = &comment 538 + } 539 + 540 + if err := rows.Err(); err != nil { 541 + return nil, err 542 + } 543 + 544 + // collect references for each comments 545 + commentAts := slices.Collect(maps.Keys(commentMap)) 546 + allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) 547 + if err != nil { 548 + return nil, fmt.Errorf("failed to query reference_links: %w", err) 549 + } 550 + for commentAt, references := range allReferencs { 551 + if comment, ok := commentMap[commentAt.String()]; ok { 552 + comment.References = references 553 + } 554 + } 555 + 556 + var comments []models.PullComment 557 + for _, c := range commentMap { 558 + comments = append(comments, *c) 559 + } 560 + 561 + sort.Slice(comments, func(i, j int) bool { 562 + return comments[i].Created.Before(comments[j].Created) 563 + }) 564 + 565 + return comments, nil 566 + } 567 + 480 568 // timeframe here is directly passed into the sql query filter, and any 481 569 // timeframe in the past should be negative; e.g.: "-3 months" 482 570 func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]models.Pull, error) { ··· 551 639 } 552 640 553 641 return pulls, nil 642 + } 643 + 644 + func NewPullComment(tx *sql.Tx, comment *models.PullComment) (int64, error) { 645 + query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` 646 + res, err := tx.Exec( 647 + query, 648 + comment.OwnerDid, 649 + comment.RepoAt, 650 + comment.SubmissionId, 651 + comment.CommentAt, 652 + comment.PullId, 653 + comment.Body, 654 + ) 655 + if err != nil { 656 + return 0, err 657 + } 658 + 659 + i, err := res.LastInsertId() 660 + if err != nil { 661 + return 0, err 662 + } 663 + 664 + if err := putReferences(tx, comment.AtUri(), comment.References); err != nil { 665 + return 0, fmt.Errorf("put reference_links: %w", err) 666 + } 667 + 668 + return i, nil 554 669 } 555 670 556 671 func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState models.PullState) error {
+32 -20
appview/db/reference.go
··· 11 11 "tangled.org/core/orm" 12 12 ) 13 13 14 - // ValidateReferenceLinks resolves refLinks to Issue/PR/Comment ATURIs. 14 + // ValidateReferenceLinks resolves refLinks to Issue/PR/IssueComment/PullComment ATURIs. 15 15 // It will ignore missing refLinks. 16 16 func ValidateReferenceLinks(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { 17 17 var ( ··· 53 53 values %s 54 54 ) 55 55 select 56 - i.at_uri, c.at_uri 56 + i.did, i.rkey, 57 + c.did, c.rkey 57 58 from input inp 58 59 join repos r 59 60 on r.did = inp.owner_did ··· 61 62 join issues i 62 63 on i.repo_at = r.at_uri 63 64 and i.issue_id = inp.issue_id 64 - left join comments c 65 + left join issue_comments c 65 66 on inp.comment_id is not null 66 - and c.subject_at = i.at_uri 67 + and c.issue_at = i.at_uri 67 68 and c.id = inp.comment_id 68 69 `, 69 70 strings.Join(vals, ","), ··· 78 79 79 80 for rows.Next() { 80 81 // Scan rows 81 - var issueUri string 82 - var commentUri sql.NullString 82 + var issueOwner, issueRkey string 83 + var commentOwner, commentRkey sql.NullString 83 84 var uri syntax.ATURI 84 - if err := rows.Scan(&issueUri, &commentUri); err != nil { 85 + if err := rows.Scan(&issueOwner, &issueRkey, &commentOwner, &commentRkey); err != nil { 85 86 return nil, err 86 87 } 87 - if commentUri.Valid { 88 - uri = syntax.ATURI(commentUri.String) 88 + if commentOwner.Valid && commentRkey.Valid { 89 + uri = syntax.ATURI(fmt.Sprintf( 90 + "at://%s/%s/%s", 91 + commentOwner.String, 92 + tangled.RepoIssueCommentNSID, 93 + commentRkey.String, 94 + )) 89 95 } else { 90 - uri = syntax.ATURI(issueUri) 96 + uri = syntax.ATURI(fmt.Sprintf( 97 + "at://%s/%s/%s", 98 + issueOwner, 99 + tangled.RepoIssueNSID, 100 + issueRkey, 101 + )) 91 102 } 92 103 uris = append(uris, uri) 93 104 } ··· 113 124 values %s 114 125 ) 115 126 select 116 - p.owner_did, p.rkey, c.at_uri 127 + p.owner_did, p.rkey, 128 + c.comment_at 117 129 from input inp 118 130 join repos r 119 131 on r.did = inp.owner_did ··· 121 133 join pulls p 122 134 on p.repo_at = r.at_uri 123 135 and p.pull_id = inp.pull_id 124 - left join comments c 136 + left join pull_comments c 125 137 on inp.comment_id is not null 126 - and c.subject_at = ('at://' || p.owner_did || '/' || 'sh.tangled.repo.pull' || '/' || p.rkey) 138 + and c.repo_at = r.at_uri and c.pull_id = p.pull_id 127 139 and c.id = inp.comment_id 128 140 `, 129 141 strings.Join(vals, ","), ··· 271 283 return nil, fmt.Errorf("get issue backlinks: %w", err) 272 284 } 273 285 backlinks = append(backlinks, ls...) 274 - ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.CommentNSID]) 286 + ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.RepoIssueCommentNSID]) 275 287 if err != nil { 276 288 return nil, fmt.Errorf("get issue_comment backlinks: %w", err) 277 289 } ··· 281 293 return nil, fmt.Errorf("get pull backlinks: %w", err) 282 294 } 283 295 backlinks = append(backlinks, ls...) 284 - ls, err = getPullCommentBacklinks(e, backlinksMap[tangled.CommentNSID]) 296 + ls, err = getPullCommentBacklinks(e, backlinksMap[tangled.RepoPullCommentNSID]) 285 297 if err != nil { 286 298 return nil, fmt.Errorf("get pull_comment backlinks: %w", err) 287 299 } ··· 340 352 rows, err := e.Query( 341 353 fmt.Sprintf( 342 354 `select r.did, r.name, i.issue_id, c.id, i.title, i.open 343 - from comments c 355 + from issue_comments c 344 356 join issues i 345 - on i.at_uri = c.subject_at 357 + on i.at_uri = c.issue_at 346 358 join repos r 347 359 on r.at_uri = i.repo_at 348 360 where %s`, ··· 416 428 if len(aturis) == 0 { 417 429 return nil, nil 418 430 } 419 - filter := orm.FilterIn("c.at_uri", aturis) 431 + filter := orm.FilterIn("c.comment_at", aturis) 420 432 rows, err := e.Query( 421 433 fmt.Sprintf( 422 434 `select r.did, r.name, p.pull_id, c.id, p.title, p.state 423 435 from repos r 424 436 join pulls p 425 437 on r.at_uri = p.repo_at 426 - join comments c 427 - on ('at://' || p.owner_did || '/' || 'sh.tangled.repo.pull' || '/' || p.rkey) = c.subject_at 438 + join pull_comments c 439 + on r.at_uri = c.repo_at and p.pull_id = c.pull_id 428 440 where %s`, 429 441 filter.Condition(), 430 442 ),
+11 -19
appview/ingester.go
··· 79 79 err = i.ingestString(e) 80 80 case tangled.RepoIssueNSID: 81 81 err = i.ingestIssue(ctx, e) 82 - case tangled.CommentNSID: 83 - err = i.ingestComment(e) 82 + case tangled.RepoIssueCommentNSID: 83 + err = i.ingestIssueComment(e) 84 84 case tangled.LabelDefinitionNSID: 85 85 err = i.ingestLabelDefinition(e) 86 86 case tangled.LabelOpNSID: ··· 868 868 return nil 869 869 } 870 870 871 - func (i *Ingester) ingestComment(e *jmodels.Event) error { 871 + func (i *Ingester) ingestIssueComment(e *jmodels.Event) error { 872 872 did := e.Did 873 873 rkey := e.Commit.RKey 874 874 875 875 var err error 876 876 877 - l := i.Logger.With("handler", "ingestComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 877 + l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 878 878 l.Info("ingesting record") 879 879 880 880 ddb, ok := i.Db.Execer.(*db.DB) ··· 885 885 switch e.Commit.Operation { 886 886 case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 887 887 raw := json.RawMessage(e.Commit.Record) 888 - record := tangled.Comment{} 888 + record := tangled.RepoIssueComment{} 889 889 err = json.Unmarshal(raw, &record) 890 890 if err != nil { 891 891 return fmt.Errorf("invalid record: %w", err) 892 892 } 893 893 894 - comment, err := models.CommentFromRecord(did, rkey, record) 894 + comment, err := models.IssueCommentFromRecord(did, rkey, record) 895 895 if err != nil { 896 896 return fmt.Errorf("failed to parse comment from record: %w", err) 897 897 } 898 898 899 - // TODO: ingest pull comments 900 - // we aren't ingesting pull comments yet because pull itself isn't fully atprotated. 901 - // so we cannot know which round this comment is pointing to 902 - if comment.Subject.Collection().String() == tangled.RepoPullNSID { 903 - l.Info("skip ingesting pull comments") 904 - return nil 905 - } 906 - 907 - if err := comment.Validate(); err != nil { 899 + if err := i.Validator.ValidateIssueComment(comment); err != nil { 908 900 return fmt.Errorf("failed to validate comment: %w", err) 909 901 } 910 902 ··· 914 906 } 915 907 defer tx.Rollback() 916 908 917 - err = db.PutComment(tx, comment) 909 + _, err = db.AddIssueComment(tx, *comment) 918 910 if err != nil { 919 - return fmt.Errorf("failed to create comment: %w", err) 911 + return fmt.Errorf("failed to create issue comment: %w", err) 920 912 } 921 913 922 914 return tx.Commit() 923 915 924 916 case jmodels.CommitOperationDelete: 925 - if err := db.DeleteComments( 917 + if err := db.DeleteIssueComments( 926 918 ddb, 927 919 orm.FilterEq("did", did), 928 920 orm.FilterEq("rkey", rkey), 929 921 ); err != nil { 930 - return fmt.Errorf("failed to delete comment record: %w", err) 922 + return fmt.Errorf("failed to delete issue comment record: %w", err) 931 923 } 932 924 933 925 return nil
+29 -31
appview/issues/issues.go
··· 403 403 404 404 body := r.FormValue("body") 405 405 if body == "" { 406 - rp.pages.Notice(w, "issue-comment", "Body is required") 406 + rp.pages.Notice(w, "issue", "Body is required") 407 407 return 408 408 } 409 409 410 - var replyTo *syntax.ATURI 411 - replyToRaw := r.FormValue("reply-to") 412 - if replyToRaw != "" { 413 - aturi, err := syntax.ParseATURI(r.FormValue("reply-to")) 414 - if err != nil { 415 - rp.pages.Notice(w, "issue-comment", "reply-to should be valid AT-URI") 416 - return 417 - } 418 - replyTo = &aturi 410 + replyToUri := r.FormValue("reply-to") 411 + var replyTo *string 412 + if replyToUri != "" { 413 + replyTo = &replyToUri 419 414 } 420 415 421 416 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 422 417 423 - comment := models.Comment{ 424 - Did: syntax.DID(user.Did), 418 + comment := models.IssueComment{ 419 + Did: user.Did, 425 420 Rkey: tid.TID(), 426 - Subject: issue.AtUri(), 421 + IssueAt: issue.AtUri().String(), 427 422 ReplyTo: replyTo, 428 423 Body: body, 429 424 Created: time.Now(), 430 425 Mentions: mentions, 431 426 References: references, 432 427 } 433 - if err = comment.Validate(); err != nil { 428 + if err = rp.validator.ValidateIssueComment(&comment); err != nil { 434 429 l.Error("failed to validate comment", "err", err) 435 430 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 436 431 return ··· 446 441 447 442 // create a record first 448 443 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 449 - Collection: tangled.CommentNSID, 450 - Repo: user.Did, 444 + Collection: tangled.RepoIssueCommentNSID, 445 + Repo: comment.Did, 451 446 Rkey: comment.Rkey, 452 447 Record: &lexutil.LexiconTypeDecoder{ 453 448 Val: &record, ··· 473 468 } 474 469 defer tx.Rollback() 475 470 476 - err = db.PutComment(tx, &comment) 471 + commentId, err := db.AddIssueComment(tx, comment) 477 472 if err != nil { 478 473 l.Error("failed to create comment", "err", err) 479 474 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") ··· 489 484 // reset atUri to make rollback a no-op 490 485 atUri = "" 491 486 492 - rp.notifier.NewComment(r.Context(), &comment) 487 + // notify about the new comment 488 + comment.Id = commentId 489 + 490 + rp.notifier.NewIssueComment(r.Context(), &comment, mentions) 493 491 494 492 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 495 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, comment.Id)) 493 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId)) 496 494 } 497 495 498 496 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { ··· 507 505 } 508 506 509 507 commentId := chi.URLParam(r, "commentId") 510 - comments, err := db.GetComments( 508 + comments, err := db.GetIssueComments( 511 509 rp.db, 512 510 orm.FilterEq("id", commentId), 513 511 ) ··· 543 541 } 544 542 545 543 commentId := chi.URLParam(r, "commentId") 546 - comments, err := db.GetComments( 544 + comments, err := db.GetIssueComments( 547 545 rp.db, 548 546 orm.FilterEq("id", commentId), 549 547 ) ··· 559 557 } 560 558 comment := comments[0] 561 559 562 - if comment.Did.String() != user.Did { 560 + if comment.Did != user.Did { 563 561 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 564 562 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 565 563 return ··· 599 597 } 600 598 defer tx.Rollback() 601 599 602 - err = db.PutComment(tx, &newComment) 600 + _, err = db.AddIssueComment(tx, newComment) 603 601 if err != nil { 604 602 l.Error("failed to perferom update-description query", "err", err) 605 603 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") ··· 610 608 // rkey is optional, it was introduced later 611 609 if newComment.Rkey != "" { 612 610 // update the record on pds 613 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.CommentNSID, user.Did, comment.Rkey) 611 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 614 612 if err != nil { 615 613 l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 616 614 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") ··· 618 616 } 619 617 620 618 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 621 - Collection: tangled.CommentNSID, 619 + Collection: tangled.RepoIssueCommentNSID, 622 620 Repo: user.Did, 623 621 Rkey: newComment.Rkey, 624 622 SwapRecord: ex.Cid, ··· 653 651 } 654 652 655 653 commentId := chi.URLParam(r, "commentId") 656 - comments, err := db.GetComments( 654 + comments, err := db.GetIssueComments( 657 655 rp.db, 658 656 orm.FilterEq("id", commentId), 659 657 ) ··· 689 687 } 690 688 691 689 commentId := chi.URLParam(r, "commentId") 692 - comments, err := db.GetComments( 690 + comments, err := db.GetIssueComments( 693 691 rp.db, 694 692 orm.FilterEq("id", commentId), 695 693 ) ··· 725 723 } 726 724 727 725 commentId := chi.URLParam(r, "commentId") 728 - comments, err := db.GetComments( 726 + comments, err := db.GetIssueComments( 729 727 rp.db, 730 728 orm.FilterEq("id", commentId), 731 729 ) ··· 741 739 } 742 740 comment := comments[0] 743 741 744 - if comment.Did.String() != user.Did { 742 + if comment.Did != user.Did { 745 743 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 746 744 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 747 745 return ··· 754 752 755 753 // optimistic deletion 756 754 deleted := time.Now() 757 - err = db.DeleteComments(rp.db, orm.FilterEq("id", comment.Id)) 755 + err = db.DeleteIssueComments(rp.db, orm.FilterEq("id", comment.Id)) 758 756 if err != nil { 759 757 l.Error("failed to delete comment", "err", err) 760 758 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") ··· 770 768 return 771 769 } 772 770 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 773 - Collection: tangled.CommentNSID, 771 + Collection: tangled.RepoIssueCommentNSID, 774 772 Repo: user.Did, 775 773 Rkey: comment.Rkey, 776 774 })
-117
appview/models/comment.go
··· 1 - package models 2 - 3 - import ( 4 - "fmt" 5 - "strings" 6 - "time" 7 - 8 - "github.com/bluesky-social/indigo/atproto/syntax" 9 - "tangled.org/core/api/tangled" 10 - ) 11 - 12 - type Comment struct { 13 - Id int64 14 - Did syntax.DID 15 - Rkey string 16 - Subject syntax.ATURI 17 - ReplyTo *syntax.ATURI 18 - Body string 19 - Created time.Time 20 - Edited *time.Time 21 - Deleted *time.Time 22 - Mentions []syntax.DID 23 - References []syntax.ATURI 24 - PullSubmissionId *int 25 - } 26 - 27 - func (c *Comment) AtUri() syntax.ATURI { 28 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", c.Did, tangled.CommentNSID, c.Rkey)) 29 - } 30 - 31 - func (c *Comment) AsRecord() tangled.Comment { 32 - mentions := make([]string, len(c.Mentions)) 33 - for i, did := range c.Mentions { 34 - mentions[i] = string(did) 35 - } 36 - references := make([]string, len(c.References)) 37 - for i, uri := range c.References { 38 - references[i] = string(uri) 39 - } 40 - var replyTo *string 41 - if c.ReplyTo != nil { 42 - replyToStr := c.ReplyTo.String() 43 - replyTo = &replyToStr 44 - } 45 - return tangled.Comment{ 46 - Subject: c.Subject.String(), 47 - Body: c.Body, 48 - CreatedAt: c.Created.Format(time.RFC3339), 49 - ReplyTo: replyTo, 50 - Mentions: mentions, 51 - References: references, 52 - } 53 - } 54 - 55 - func (c *Comment) IsTopLevel() bool { 56 - return c.ReplyTo == nil 57 - } 58 - 59 - func (c *Comment) IsReply() bool { 60 - return c.ReplyTo != nil 61 - } 62 - 63 - func (c *Comment) Validate() error { 64 - // TODO: sanitize the body and then trim space 65 - if sb := strings.TrimSpace(c.Body); sb == "" { 66 - return fmt.Errorf("body is empty after HTML sanitization") 67 - } 68 - 69 - // if it's for PR, PullSubmissionId should not be nil 70 - if c.Subject.Collection().String() == tangled.RepoPullNSID { 71 - if c.PullSubmissionId == nil { 72 - return fmt.Errorf("PullSubmissionId should not be nil") 73 - } 74 - } 75 - return nil 76 - } 77 - 78 - func CommentFromRecord(did, rkey string, record tangled.Comment) (*Comment, error) { 79 - created, err := time.Parse(time.RFC3339, record.CreatedAt) 80 - if err != nil { 81 - created = time.Now() 82 - } 83 - 84 - ownerDid := did 85 - 86 - if _, err = syntax.ParseATURI(record.Subject); err != nil { 87 - return nil, err 88 - } 89 - 90 - i := record 91 - mentions := make([]syntax.DID, len(record.Mentions)) 92 - for i, did := range record.Mentions { 93 - mentions[i] = syntax.DID(did) 94 - } 95 - references := make([]syntax.ATURI, len(record.References)) 96 - for i, uri := range i.References { 97 - references[i] = syntax.ATURI(uri) 98 - } 99 - var replyTo *syntax.ATURI 100 - if record.ReplyTo != nil { 101 - replyToAtUri := syntax.ATURI(*record.ReplyTo) 102 - replyTo = &replyToAtUri 103 - } 104 - 105 - comment := Comment{ 106 - Did: syntax.DID(ownerDid), 107 - Rkey: rkey, 108 - Body: record.Body, 109 - Subject: syntax.ATURI(record.Subject), 110 - ReplyTo: replyTo, 111 - Created: created, 112 - Mentions: mentions, 113 - References: references, 114 - } 115 - 116 - return &comment, nil 117 - }
+89 -8
appview/models/issue.go
··· 26 26 27 27 // optionally, populate this when querying for reverse mappings 28 28 // like comment counts, parent repo etc. 29 - Comments []Comment 29 + Comments []IssueComment 30 30 Labels LabelState 31 31 Repo *Repo 32 32 } ··· 62 62 } 63 63 64 64 type CommentListItem struct { 65 - Self *Comment 66 - Replies []*Comment 65 + Self *IssueComment 66 + Replies []*IssueComment 67 67 } 68 68 69 69 func (it *CommentListItem) Participants() []syntax.DID { ··· 88 88 89 89 func (i *Issue) CommentList() []CommentListItem { 90 90 // Create a map to quickly find comments by their aturi 91 - toplevel := make(map[syntax.ATURI]*CommentListItem) 92 - var replies []*Comment 91 + toplevel := make(map[string]*CommentListItem) 92 + var replies []*IssueComment 93 93 94 94 // collect top level comments into the map 95 95 for _, comment := range i.Comments { 96 96 if comment.IsTopLevel() { 97 - toplevel[comment.AtUri()] = &CommentListItem{ 97 + toplevel[comment.AtUri().String()] = &CommentListItem{ 98 98 Self: &comment, 99 99 } 100 100 } else { ··· 115 115 } 116 116 117 117 // sort everything 118 - sortFunc := func(a, b *Comment) bool { 118 + sortFunc := func(a, b *IssueComment) bool { 119 119 return a.Created.Before(b.Created) 120 120 } 121 121 sort.Slice(listing, func(i, j int) bool { ··· 144 144 addParticipant(i.Did) 145 145 146 146 for _, c := range i.Comments { 147 - addParticipant(c.Did.String()) 147 + addParticipant(c.Did) 148 148 } 149 149 150 150 return participants ··· 171 171 Open: true, // new issues are open by default 172 172 } 173 173 } 174 + 175 + type IssueComment struct { 176 + Id int64 177 + Did string 178 + Rkey string 179 + IssueAt string 180 + ReplyTo *string 181 + Body string 182 + Created time.Time 183 + Edited *time.Time 184 + Deleted *time.Time 185 + Mentions []syntax.DID 186 + References []syntax.ATURI 187 + } 188 + 189 + func (i *IssueComment) AtUri() syntax.ATURI { 190 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) 191 + } 192 + 193 + func (i *IssueComment) AsRecord() tangled.RepoIssueComment { 194 + mentions := make([]string, len(i.Mentions)) 195 + for i, did := range i.Mentions { 196 + mentions[i] = string(did) 197 + } 198 + references := make([]string, len(i.References)) 199 + for i, uri := range i.References { 200 + references[i] = string(uri) 201 + } 202 + return tangled.RepoIssueComment{ 203 + Body: i.Body, 204 + Issue: i.IssueAt, 205 + CreatedAt: i.Created.Format(time.RFC3339), 206 + ReplyTo: i.ReplyTo, 207 + Mentions: mentions, 208 + References: references, 209 + } 210 + } 211 + 212 + func (i *IssueComment) IsTopLevel() bool { 213 + return i.ReplyTo == nil 214 + } 215 + 216 + func (i *IssueComment) IsReply() bool { 217 + return i.ReplyTo != nil 218 + } 219 + 220 + func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { 221 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 222 + if err != nil { 223 + created = time.Now() 224 + } 225 + 226 + ownerDid := did 227 + 228 + if _, err = syntax.ParseATURI(record.Issue); err != nil { 229 + return nil, err 230 + } 231 + 232 + i := record 233 + mentions := make([]syntax.DID, len(record.Mentions)) 234 + for i, did := range record.Mentions { 235 + mentions[i] = syntax.DID(did) 236 + } 237 + references := make([]syntax.ATURI, len(record.References)) 238 + for i, uri := range i.References { 239 + references[i] = syntax.ATURI(uri) 240 + } 241 + 242 + comment := IssueComment{ 243 + Did: ownerDid, 244 + Rkey: rkey, 245 + Body: record.Body, 246 + IssueAt: record.Issue, 247 + ReplyTo: record.ReplyTo, 248 + Created: created, 249 + Mentions: mentions, 250 + References: references, 251 + } 252 + 253 + return &comment, nil 254 + }
+10
appview/models/pipeline.go
··· 1 1 package models 2 2 3 3 import ( 4 + "fmt" 4 5 "slices" 5 6 "time" 6 7 7 8 "github.com/bluesky-social/indigo/atproto/syntax" 8 9 "github.com/go-git/go-git/v5/plumbing" 10 + "tangled.org/core/api/tangled" 9 11 spindle "tangled.org/core/spindle/models" 10 12 "tangled.org/core/workflow" 11 13 ) ··· 23 25 // populate when querying for reverse mappings 24 26 Trigger *Trigger 25 27 Statuses map[string]WorkflowStatus 28 + } 29 + 30 + func (p *Pipeline) AtUri() syntax.ATURI { 31 + return syntax.ATURI(fmt.Sprintf("at://did:web:%s/%s/%s", p.Knot, tangled.PipelineNSID, p.Rkey)) 26 32 } 27 33 28 34 type WorkflowStatus struct { ··· 128 134 Error *string 129 135 ExitCode int 130 136 } 137 + 138 + func (ps *PipelineStatus) PipelineAt() syntax.ATURI { 139 + return syntax.ATURI(fmt.Sprintf("at://did:web:%s/%s/%s", ps.PipelineKnot, tangled.PipelineNSID, ps.PipelineRkey)) 140 + }
+46 -2
appview/models/pull.go
··· 138 138 RoundNumber int 139 139 Patch string 140 140 Combined string 141 - Comments []Comment 141 + Comments []PullComment 142 142 SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 143 143 144 144 // meta 145 145 Created time.Time 146 146 } 147 + 148 + type PullComment struct { 149 + // ids 150 + ID int 151 + PullId int 152 + SubmissionId int 153 + 154 + // at ids 155 + RepoAt string 156 + OwnerDid string 157 + CommentAt string 158 + 159 + // content 160 + Body string 161 + 162 + // meta 163 + Mentions []syntax.DID 164 + References []syntax.ATURI 165 + 166 + // meta 167 + Created time.Time 168 + } 169 + 170 + func (p *PullComment) AtUri() syntax.ATURI { 171 + return syntax.ATURI(p.CommentAt) 172 + } 173 + 174 + // func (p *PullComment) AsRecord() tangled.RepoPullComment { 175 + // mentions := make([]string, len(p.Mentions)) 176 + // for i, did := range p.Mentions { 177 + // mentions[i] = string(did) 178 + // } 179 + // references := make([]string, len(p.References)) 180 + // for i, uri := range p.References { 181 + // references[i] = string(uri) 182 + // } 183 + // return tangled.RepoPullComment{ 184 + // Pull: p.PullAt, 185 + // Body: p.Body, 186 + // Mentions: mentions, 187 + // References: references, 188 + // CreatedAt: p.Created.Format(time.RFC3339), 189 + // } 190 + // } 147 191 148 192 func (p *Pull) LastRoundNumber() int { 149 193 return len(p.Submissions) - 1 ··· 245 289 addParticipant(s.PullAt.Authority().String()) 246 290 247 291 for _, c := range s.Comments { 248 - addParticipant(c.Did.String()) 292 + addParticipant(c.OwnerDid) 249 293 } 250 294 251 295 return participants
+113 -111
appview/notify/db/db.go
··· 74 74 // no-op 75 75 } 76 76 77 - func (n *databaseNotifier) NewComment(ctx context.Context, comment *models.Comment) { 78 - var ( 79 - // built the recipients list: 80 - // - the owner of the repo 81 - // - | if the comment is a reply -> everybody on that thread 82 - // | if the comment is a top level -> just the issue owner 83 - // - remove mentioned users from the recipients list 84 - recipients = sets.New[syntax.DID]() 85 - entityType string 86 - entityId string 87 - repoId *int64 88 - issueId *int64 89 - pullId *int64 90 - ) 91 - 92 - subjectDid, err := comment.Subject.Authority().AsDID() 77 + func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 78 + collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 93 79 if err != nil { 94 - log.Printf("NewComment: expected did based at-uri for comment.subject") 80 + log.Printf("failed to fetch collaborators: %v", err) 95 81 return 96 82 } 97 - switch comment.Subject.Collection() { 98 - case tangled.RepoIssueNSID: 99 - issues, err := db.GetIssues( 100 - n.db, 101 - orm.FilterEq("did", subjectDid), 102 - orm.FilterEq("rkey", comment.Subject.RecordKey()), 103 - ) 104 - if err != nil { 105 - log.Printf("NewComment: failed to get issues: %v", err) 106 - return 107 - } 108 - if len(issues) == 0 { 109 - log.Printf("NewComment: no issue found for %s", comment.Subject) 110 - return 111 - } 112 - issue := issues[0] 113 83 114 - recipients.Insert(syntax.DID(issue.Repo.Did)) 115 - if comment.IsReply() { 116 - // if this comment is a reply, then notify everybody in that thread 117 - parentAtUri := *comment.ReplyTo 118 - 119 - // find the parent thread, and add all DIDs from here to the recipient list 120 - for _, t := range issue.CommentList() { 121 - if t.Self.AtUri() == parentAtUri { 122 - for _, p := range t.Participants() { 123 - recipients.Insert(p) 124 - } 125 - } 126 - } 127 - } else { 128 - // not a reply, notify just the issue author 129 - recipients.Insert(syntax.DID(issue.Did)) 130 - } 131 - 132 - entityType = "issue" 133 - entityId = issue.AtUri().String() 134 - repoId = &issue.Repo.Id 135 - issueId = &issue.Id 136 - case tangled.RepoPullNSID: 137 - pulls, err := db.GetPullsWithLimit( 138 - n.db, 139 - 1, 140 - orm.FilterEq("owner_did", subjectDid), 141 - orm.FilterEq("rkey", comment.Subject.RecordKey()), 142 - ) 143 - if err != nil { 144 - log.Printf("NewComment: failed to get pulls: %v", err) 145 - return 146 - } 147 - if len(pulls) == 0 { 148 - log.Printf("NewComment: no pull found for %s", comment.Subject) 149 - return 150 - } 151 - pull := pulls[0] 152 - 153 - pull.Repo, err = db.GetRepo(n.db, orm.FilterEq("at_uri", pull.RepoAt)) 154 - if err != nil { 155 - log.Printf("NewComment: failed to get repos: %v", err) 156 - return 157 - } 158 - 159 - recipients.Insert(syntax.DID(pull.Repo.Did)) 160 - for _, p := range pull.Participants() { 161 - recipients.Insert(syntax.DID(p)) 162 - } 163 - 164 - entityType = "pull" 165 - entityId = pull.AtUri().String() 166 - repoId = &pull.Repo.Id 167 - p := int64(pull.ID) 168 - pullId = &p 169 - default: 170 - return // no-op 84 + // build the recipients list 85 + // - owner of the repo 86 + // - collaborators in the repo 87 + // - remove users already mentioned 88 + recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) 89 + for _, c := range collaborators { 90 + recipients.Insert(c.SubjectDid) 171 91 } 172 - 173 - for _, m := range comment.Mentions { 92 + for _, m := range mentions { 174 93 recipients.Remove(m) 175 94 } 176 95 96 + actorDid := syntax.DID(issue.Did) 97 + entityType := "issue" 98 + entityId := issue.AtUri().String() 99 + repoId := &issue.Repo.Id 100 + issueId := &issue.Id 101 + var pullId *int64 102 + 177 103 n.notifyEvent( 178 - comment.Did, 104 + actorDid, 179 105 recipients, 180 - models.NotificationTypeIssueCommented, 106 + models.NotificationTypeIssueCreated, 181 107 entityType, 182 108 entityId, 183 109 repoId, ··· 185 111 pullId, 186 112 ) 187 113 n.notifyEvent( 188 - comment.Did, 189 - sets.Collect(slices.Values(comment.Mentions)), 114 + actorDid, 115 + sets.Collect(slices.Values(mentions)), 190 116 models.NotificationTypeUserMentioned, 191 117 entityType, 192 118 entityId, ··· 196 122 ) 197 123 } 198 124 199 - func (n *databaseNotifier) DeleteComment(ctx context.Context, comment *models.Comment) { 200 - // no-op 201 - } 202 - 203 - func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 204 - collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 125 + func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 126 + issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.IssueAt)) 205 127 if err != nil { 206 - log.Printf("failed to fetch collaborators: %v", err) 128 + log.Printf("NewIssueComment: failed to get issues: %v", err) 129 + return 130 + } 131 + if len(issues) == 0 { 132 + log.Printf("NewIssueComment: no issue found for %s", comment.IssueAt) 207 133 return 208 134 } 135 + issue := issues[0] 209 136 210 - // build the recipients list 211 - // - owner of the repo 212 - // - collaborators in the repo 213 - // - remove users already mentioned 137 + // built the recipients list: 138 + // - the owner of the repo 139 + // - | if the comment is a reply -> everybody on that thread 140 + // | if the comment is a top level -> just the issue owner 141 + // - remove mentioned users from the recipients list 214 142 recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) 215 - for _, c := range collaborators { 216 - recipients.Insert(c.SubjectDid) 143 + 144 + if comment.IsReply() { 145 + // if this comment is a reply, then notify everybody in that thread 146 + parentAtUri := *comment.ReplyTo 147 + 148 + // find the parent thread, and add all DIDs from here to the recipient list 149 + for _, t := range issue.CommentList() { 150 + if t.Self.AtUri().String() == parentAtUri { 151 + for _, p := range t.Participants() { 152 + recipients.Insert(p) 153 + } 154 + } 155 + } 156 + } else { 157 + // not a reply, notify just the issue author 158 + recipients.Insert(syntax.DID(issue.Did)) 217 159 } 160 + 218 161 for _, m := range mentions { 219 162 recipients.Remove(m) 220 163 } 221 164 222 - actorDid := syntax.DID(issue.Did) 165 + actorDid := syntax.DID(comment.Did) 223 166 entityType := "issue" 224 167 entityId := issue.AtUri().String() 225 168 repoId := &issue.Repo.Id ··· 229 172 n.notifyEvent( 230 173 actorDid, 231 174 recipients, 232 - models.NotificationTypeIssueCreated, 175 + models.NotificationTypeIssueCommented, 233 176 entityType, 234 177 entityId, 235 178 repoId, ··· 309 252 actorDid, 310 253 recipients, 311 254 eventType, 255 + entityType, 256 + entityId, 257 + repoId, 258 + issueId, 259 + pullId, 260 + ) 261 + } 262 + 263 + func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 264 + pull, err := db.GetPull(n.db, 265 + syntax.ATURI(comment.RepoAt), 266 + comment.PullId, 267 + ) 268 + if err != nil { 269 + log.Printf("NewPullComment: failed to get pulls: %v", err) 270 + return 271 + } 272 + 273 + repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", comment.RepoAt)) 274 + if err != nil { 275 + log.Printf("NewPullComment: failed to get repos: %v", err) 276 + return 277 + } 278 + 279 + // build up the recipients list: 280 + // - repo owner 281 + // - all pull participants 282 + // - remove those already mentioned 283 + recipients := sets.Singleton(syntax.DID(repo.Did)) 284 + for _, p := range pull.Participants() { 285 + recipients.Insert(syntax.DID(p)) 286 + } 287 + for _, m := range mentions { 288 + recipients.Remove(m) 289 + } 290 + 291 + actorDid := syntax.DID(comment.OwnerDid) 292 + eventType := models.NotificationTypePullCommented 293 + entityType := "pull" 294 + entityId := pull.AtUri().String() 295 + repoId := &repo.Id 296 + var issueId *int64 297 + p := int64(pull.ID) 298 + pullId := &p 299 + 300 + n.notifyEvent( 301 + actorDid, 302 + recipients, 303 + eventType, 304 + entityType, 305 + entityId, 306 + repoId, 307 + issueId, 308 + pullId, 309 + ) 310 + n.notifyEvent( 311 + actorDid, 312 + sets.Collect(slices.Values(mentions)), 313 + models.NotificationTypeUserMentioned, 312 314 entityType, 313 315 entityId, 314 316 repoId,
+8 -8
appview/notify/merged_notifier.go
··· 53 53 m.fanout("DeleteStar", ctx, star) 54 54 } 55 55 56 - func (m *mergedNotifier) NewComment(ctx context.Context, comment *models.Comment) { 57 - m.fanout("NewComment", ctx, comment) 58 - } 59 - 60 - func (m *mergedNotifier) DeleteComment(ctx context.Context, comment *models.Comment) { 61 - m.fanout("DeleteComment", ctx, comment) 62 - } 63 - 64 56 func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 65 57 m.fanout("NewIssue", ctx, issue, mentions) 58 + } 59 + 60 + func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 61 + m.fanout("NewIssueComment", ctx, comment, mentions) 66 62 } 67 63 68 64 func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { ··· 83 79 84 80 func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { 85 81 m.fanout("NewPull", ctx, pull) 82 + } 83 + 84 + func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 85 + m.fanout("NewPullComment", ctx, comment, mentions) 86 86 } 87 87 88 88 func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
+7 -7
appview/notify/notifier.go
··· 13 13 NewStar(ctx context.Context, star *models.Star) 14 14 DeleteStar(ctx context.Context, star *models.Star) 15 15 16 - NewComment(ctx context.Context, comment *models.Comment) 17 - DeleteComment(ctx context.Context, comment *models.Comment) 18 - 19 16 NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) 17 + NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) 20 18 NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) 21 19 DeleteIssue(ctx context.Context, issue *models.Issue) 22 20 ··· 24 22 DeleteFollow(ctx context.Context, follow *models.Follow) 25 23 26 24 NewPull(ctx context.Context, pull *models.Pull) 25 + NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) 27 26 NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) 28 27 29 28 UpdateProfile(ctx context.Context, profile *models.Profile) ··· 43 42 func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} 44 43 func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 45 44 46 - func (m *BaseNotifier) NewComment(ctx context.Context, comment *models.Comment) {} 47 - func (m *BaseNotifier) DeleteComment(ctx context.Context, comment *models.Comment) {} 48 - 49 45 func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {} 46 + func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 47 + } 50 48 func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {} 51 49 func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {} 52 50 53 51 func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} 54 52 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 55 53 56 - func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 54 + func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 55 + func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment, mentions []syntax.DID) { 56 + } 57 57 func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {} 58 58 59 59 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
+20 -5
appview/notify/posthog/notifier.go
··· 86 86 } 87 87 } 88 88 89 + func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 90 + err := n.client.Enqueue(posthog.Capture{ 91 + DistinctId: comment.OwnerDid, 92 + Event: "new_pull_comment", 93 + Properties: posthog.Properties{ 94 + "repo_at": comment.RepoAt, 95 + "pull_id": comment.PullId, 96 + "mentions": mentions, 97 + }, 98 + }) 99 + if err != nil { 100 + log.Println("failed to enqueue posthog event:", err) 101 + } 102 + } 103 + 89 104 func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 90 105 err := n.client.Enqueue(posthog.Capture{ 91 106 DistinctId: pull.OwnerDid, ··· 165 180 } 166 181 } 167 182 168 - func (n *posthogNotifier) NewComment(ctx context.Context, comment *models.Comment) { 183 + func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 169 184 err := n.client.Enqueue(posthog.Capture{ 170 - DistinctId: comment.Did.String(), 171 - Event: "new_comment", 185 + DistinctId: comment.Did, 186 + Event: "new_issue_comment", 172 187 Properties: posthog.Properties{ 173 - "subject_at": comment.Subject, 174 - "mentions": comment.Mentions, 188 + "issue_at": comment.IssueAt, 189 + "mentions": mentions, 175 190 }, 176 191 }) 177 192 if err != nil {
+4 -4
appview/pages/pages.go
··· 988 988 LoggedInUser *oauth.User 989 989 RepoInfo repoinfo.RepoInfo 990 990 Issue *models.Issue 991 - Comment *models.Comment 991 + Comment *models.IssueComment 992 992 } 993 993 994 994 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { ··· 999 999 LoggedInUser *oauth.User 1000 1000 RepoInfo repoinfo.RepoInfo 1001 1001 Issue *models.Issue 1002 - Comment *models.Comment 1002 + Comment *models.IssueComment 1003 1003 } 1004 1004 1005 1005 func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { ··· 1010 1010 LoggedInUser *oauth.User 1011 1011 RepoInfo repoinfo.RepoInfo 1012 1012 Issue *models.Issue 1013 - Comment *models.Comment 1013 + Comment *models.IssueComment 1014 1014 } 1015 1015 1016 1016 func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { ··· 1021 1021 LoggedInUser *oauth.User 1022 1022 RepoInfo repoinfo.RepoInfo 1023 1023 Issue *models.Issue 1024 - Comment *models.Comment 1024 + Comment *models.IssueComment 1025 1025 } 1026 1026 1027 1027 func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
+1 -1
appview/pages/templates/repo/empty.html
··· 26 26 {{ else if (and .LoggedInUser (eq .LoggedInUser.Did .RepoInfo.OwnerDid)) }} 27 27 {{ $knot := .RepoInfo.Knot }} 28 28 {{ if eq $knot "knot1.tangled.sh" }} 29 - {{ $knot = "tangled.sh" }} 29 + {{ $knot = "tangled.org" }} 30 30 {{ end }} 31 31 <div class="w-full flex place-content-center"> 32 32 <div class="py-6 w-fit flex flex-col gap-4">
+2 -2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
··· 1 1 {{ define "repo/issues/fragments/issueCommentHeader" }} 2 2 <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 "> 3 - {{ template "user/fragments/picHandleLink" .Comment.Did.String }} 3 + {{ template "user/fragments/picHandleLink" .Comment.Did }} 4 4 {{ template "hats" $ }} 5 5 {{ template "timestamp" . }} 6 - {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did.String) }} 6 + {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} 7 7 {{ if and $isCommentOwner (not .Comment.Deleted) }} 8 8 {{ template "editIssueComment" . }} 9 9 {{ template "deleteIssueComment" . }}
+10
appview/pages/templates/repo/pipelines/workflow.html
··· 12 12 {{ block "sidebar" . }} {{ end }} 13 13 </div> 14 14 <div class="col-span-1 md:col-span-3"> 15 + <div class="flex justify-end mb-2"> 16 + <button 17 + class="btn" 18 + hx-post="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/workflow/{{ .Workflow }}/cancel" 19 + hx-swap="none" 20 + {{ if (index .Pipeline.Statuses .Workflow).Latest.Status.IsFinish -}} 21 + disabled 22 + {{- end }} 23 + >Cancel</button> 24 + </div> 15 25 {{ block "logs" . }} {{ end }} 16 26 </div> 17 27 </section>
+3 -3
appview/pages/templates/repo/pulls/pull.html
··· 165 165 166 166 <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 167 167 {{ range $cidx, $c := .Comments }} 168 - <div id="comment-{{$c.Id}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full"> 168 + <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full"> 169 169 {{ if gt $cidx 0 }} 170 170 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 171 171 {{ end }} 172 172 <div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1"> 173 - {{ template "user/fragments/picHandleLink" $c.Did.String }} 173 + {{ template "user/fragments/picHandleLink" $c.OwnerDid }} 174 174 <span class="before:content-['ยท']"></span> 175 - <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.Id}}">{{ template "repo/fragments/time" $c.Created }}</a> 175 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}">{{ template "repo/fragments/time" $c.Created }}</a> 176 176 </div> 177 177 <div class="prose dark:prose-invert"> 178 178 {{ $c.Body | markdown }}
+2 -2
appview/pages/templates/user/fragments/followCard.html
··· 6 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" /> 7 7 </div> 8 8 9 - <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full"> 9 + <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full min-w-0"> 10 10 <div class="flex-1 min-h-0 justify-around flex flex-col"> 11 11 <a href="/{{ $userIdent }}"> 12 12 <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 13 13 </a> 14 14 {{ with .Profile }} 15 - <p class="text-sm pb-2 md:pb-2">{{.Description}}</p> 15 + <p class="text-sm pb-2 md:pb-2 break-words">{{.Description}}</p> 16 16 {{ end }} 17 17 <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 18 18 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
+82
appview/pipelines/pipelines.go
··· 4 4 "bytes" 5 5 "context" 6 6 "encoding/json" 7 + "fmt" 7 8 "log/slog" 8 9 "net/http" 9 10 "strings" 10 11 "time" 11 12 13 + "tangled.org/core/api/tangled" 12 14 "tangled.org/core/appview/config" 13 15 "tangled.org/core/appview/db" 16 + "tangled.org/core/appview/models" 14 17 "tangled.org/core/appview/oauth" 15 18 "tangled.org/core/appview/pages" 16 19 "tangled.org/core/appview/reporesolver" ··· 41 44 r.Get("/", p.Index) 42 45 r.Get("/{pipeline}/workflow/{workflow}", p.Workflow) 43 46 r.Get("/{pipeline}/workflow/{workflow}/logs", p.Logs) 47 + r.Post("/{pipeline}/workflow/{workflow}/cancel", p.Cancel) 44 48 45 49 return r 46 50 } ··· 314 318 } 315 319 } 316 320 } 321 + } 322 + 323 + func (p *Pipelines) Cancel(w http.ResponseWriter, r *http.Request) { 324 + l := p.logger.With("handler", "Cancel") 325 + 326 + var ( 327 + pipelineId = chi.URLParam(r, "pipeline") 328 + workflow = chi.URLParam(r, "workflow") 329 + ) 330 + if pipelineId == "" || workflow == "" { 331 + http.Error(w, "missing pipeline ID or workflow", http.StatusBadRequest) 332 + return 333 + } 334 + 335 + f, err := p.repoResolver.Resolve(r) 336 + if err != nil { 337 + l.Error("failed to get repo and knot", "err", err) 338 + http.Error(w, "bad repo/knot", http.StatusBadRequest) 339 + return 340 + } 341 + 342 + pipeline, err := func() (models.Pipeline, error) { 343 + ps, err := db.GetPipelineStatuses( 344 + p.db, 345 + 1, 346 + orm.FilterEq("repo_owner", f.Did), 347 + orm.FilterEq("repo_name", f.Name), 348 + orm.FilterEq("knot", f.Knot), 349 + orm.FilterEq("id", pipelineId), 350 + ) 351 + if err != nil { 352 + return models.Pipeline{}, err 353 + } 354 + if len(ps) != 1 { 355 + return models.Pipeline{}, fmt.Errorf("wrong pipeline count %d", len(ps)) 356 + } 357 + return ps[0], nil 358 + }() 359 + if err != nil { 360 + l.Error("pipeline query failed", "err", err) 361 + http.Error(w, "pipeline not found", http.StatusNotFound) 362 + } 363 + var ( 364 + spindle = f.Spindle 365 + knot = f.Knot 366 + rkey = pipeline.Rkey 367 + ) 368 + 369 + if spindle == "" || knot == "" || rkey == "" { 370 + http.Error(w, "invalid repo info", http.StatusBadRequest) 371 + return 372 + } 373 + 374 + spindleClient, err := p.oauth.ServiceClient( 375 + r, 376 + oauth.WithService(f.Spindle), 377 + oauth.WithLxm(tangled.PipelineCancelPipelineNSID), 378 + oauth.WithExp(60), 379 + oauth.WithDev(p.config.Core.Dev), 380 + oauth.WithTimeout(time.Second*30), // workflow cleanup usually takes time 381 + ) 382 + 383 + err = tangled.PipelineCancelPipeline( 384 + r.Context(), 385 + spindleClient, 386 + &tangled.PipelineCancelPipeline_Input{ 387 + Repo: string(f.RepoAt()), 388 + Pipeline: pipeline.AtUri().String(), 389 + Workflow: workflow, 390 + }, 391 + ) 392 + errorId := "pipeline-action" 393 + if err != nil { 394 + l.Error("failed to cancel pipeline", "err", err) 395 + p.pages.Notice(w, errorId, "Failed to add secret.") 396 + return 397 + } 398 + l.Debug("canceled pipeline", "uri", pipeline.AtUri()) 317 399 } 318 400 319 401 // either a message or an error
+1 -1
appview/pulls/opengraph.go
··· 277 277 } 278 278 279 279 // Get comment count from database 280 - comments, err := db.GetComments(s.db, orm.FilterEq("subject_at", pull.AtUri())) 280 + comments, err := db.GetPullComments(s.db, orm.FilterEq("pull_id", pull.ID)) 281 281 if err != nil { 282 282 log.Printf("failed to get pull comments: %v", err) 283 283 }
+23 -24
appview/pulls/pulls.go
··· 741 741 } 742 742 defer tx.Rollback() 743 743 744 - comment := models.Comment{ 745 - Did: syntax.DID(user.Did), 746 - Rkey: tid.TID(), 747 - Subject: pull.AtUri(), 748 - ReplyTo: nil, 749 - Body: body, 750 - Created: time.Now(), 751 - Mentions: mentions, 752 - References: references, 753 - PullSubmissionId: &pull.Submissions[roundNumber].ID, 754 - } 755 - if err = comment.Validate(); err != nil { 756 - log.Println("failed to validate comment", err) 757 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 758 - return 759 - } 760 - record := comment.AsRecord() 744 + createdAt := time.Now().Format(time.RFC3339) 761 745 762 746 client, err := s.oauth.AuthorizedClient(r) 763 747 if err != nil { ··· 765 749 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 766 750 return 767 751 } 768 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 769 - Collection: tangled.CommentNSID, 752 + atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 753 + Collection: tangled.RepoPullCommentNSID, 770 754 Repo: user.Did, 771 - Rkey: comment.Rkey, 755 + Rkey: tid.TID(), 772 756 Record: &lexutil.LexiconTypeDecoder{ 773 - Val: &record, 757 + Val: &tangled.RepoPullComment{ 758 + Pull: pull.AtUri().String(), 759 + Body: body, 760 + CreatedAt: createdAt, 761 + }, 774 762 }, 775 763 }) 776 764 if err != nil { ··· 779 767 return 780 768 } 781 769 770 + comment := &models.PullComment{ 771 + OwnerDid: user.Did, 772 + RepoAt: f.RepoAt().String(), 773 + PullId: pull.PullId, 774 + Body: body, 775 + CommentAt: atResp.Uri, 776 + SubmissionId: pull.Submissions[roundNumber].ID, 777 + Mentions: mentions, 778 + References: references, 779 + } 780 + 782 781 // Create the pull comment in the database with the commentAt field 783 - err = db.PutComment(tx, &comment) 782 + commentId, err := db.NewPullComment(tx, comment) 784 783 if err != nil { 785 784 log.Println("failed to create pull comment", err) 786 785 s.pages.Notice(w, "pull-comment", "Failed to create comment.") ··· 794 793 return 795 794 } 796 795 797 - s.notifier.NewComment(r.Context(), &comment) 796 + s.notifier.NewPullComment(r.Context(), comment, mentions) 798 797 799 798 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 800 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, comment.Id)) 799 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId)) 801 800 return 802 801 } 803 802 }
+1 -1
appview/state/state.go
··· 117 117 tangled.SpindleNSID, 118 118 tangled.StringNSID, 119 119 tangled.RepoIssueNSID, 120 - tangled.CommentNSID, 120 + tangled.RepoIssueCommentNSID, 121 121 tangled.LabelDefinitionNSID, 122 122 tangled.LabelOpNSID, 123 123 },
+27
appview/validator/issue.go
··· 4 4 "fmt" 5 5 "strings" 6 6 7 + "tangled.org/core/appview/db" 7 8 "tangled.org/core/appview/models" 9 + "tangled.org/core/orm" 8 10 ) 11 + 12 + func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error { 13 + // if comments have parents, only ingest ones that are 1 level deep 14 + if comment.ReplyTo != nil { 15 + parents, err := db.GetIssueComments(v.db, orm.FilterEq("at_uri", *comment.ReplyTo)) 16 + if err != nil { 17 + return fmt.Errorf("failed to fetch parent comment: %w", err) 18 + } 19 + if len(parents) != 1 { 20 + return fmt.Errorf("incorrect number of parent comments returned: %d", len(parents)) 21 + } 22 + 23 + // depth check 24 + parent := parents[0] 25 + if parent.ReplyTo != nil { 26 + return fmt.Errorf("incorrect depth, this comment is replying at depth >1") 27 + } 28 + } 29 + 30 + if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" { 31 + return fmt.Errorf("body is empty after HTML sanitization") 32 + } 33 + 34 + return nil 35 + } 9 36 10 37 func (v *Validator) ValidateIssue(issue *models.Issue) error { 11 38 if issue.Title == "" {
-1
cmd/cborgen/cborgen.go
··· 15 15 "api/tangled/cbor_gen.go", 16 16 "tangled", 17 17 tangled.ActorProfile{}, 18 - tangled.Comment{}, 19 18 tangled.FeedReaction{}, 20 19 tangled.FeedStar{}, 21 20 tangled.GitRefUpdate{},
+9 -9
flake.lock
··· 35 35 "systems": "systems" 36 36 }, 37 37 "locked": { 38 - "lastModified": 1694529238, 39 - "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 38 + "lastModified": 1731533236, 39 + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 40 40 "owner": "numtide", 41 41 "repo": "flake-utils", 42 - "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 42 + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 43 43 "type": "github" 44 44 }, 45 45 "original": { ··· 56 56 ] 57 57 }, 58 58 "locked": { 59 - "lastModified": 1754078208, 60 - "narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=", 59 + "lastModified": 1763982521, 60 + "narHash": "sha256-ur4QIAHwgFc0vXiaxn5No/FuZicxBr2p0gmT54xZkUQ=", 61 61 "owner": "nix-community", 62 62 "repo": "gomod2nix", 63 - "rev": "7f963246a71626c7fc70b431a315c4388a0c95cf", 63 + "rev": "02e63a239d6eabd595db56852535992c898eba72", 64 64 "type": "github" 65 65 }, 66 66 "original": { ··· 150 150 }, 151 151 "nixpkgs": { 152 152 "locked": { 153 - "lastModified": 1765186076, 154 - "narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=", 153 + "lastModified": 1766070988, 154 + "narHash": "sha256-G/WVghka6c4bAzMhTwT2vjLccg/awmHkdKSd2JrycLc=", 155 155 "owner": "nixos", 156 156 "repo": "nixpkgs", 157 - "rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8", 157 + "rev": "c6245e83d836d0433170a16eb185cefe0572f8b8", 158 158 "type": "github" 159 159 }, 160 160 "original": {
+31 -2
flake.nix
··· 91 91 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 92 92 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 93 93 knot = self.callPackage ./nix/pkgs/knot.nix {}; 94 + did-method-plc = self.callPackage ./nix/pkgs/did-method-plc.nix {}; 95 + bluesky-jetstream = self.callPackage ./nix/pkgs/bluesky-jetstream.nix {}; 96 + bluesky-relay = self.callPackage ./nix/pkgs/bluesky-relay.nix {}; 97 + tap = self.callPackage ./nix/pkgs/tap.nix {}; 94 98 }); 95 99 in { 96 100 overlays.default = final: prev: { 97 - inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview; 101 + inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview did-method-plc bluesky-jetstream bluesky-relay tap; 98 102 }; 99 103 100 104 packages = forAllSystems (system: let ··· 103 107 staticPackages = mkPackageSet pkgs.pkgsStatic; 104 108 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 105 109 in { 106 - inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib; 110 + inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib did-method-plc bluesky-jetstream bluesky-relay tap; 107 111 108 112 pkgsStatic-appview = staticPackages.appview; 109 113 pkgsStatic-knot = staticPackages.knot; ··· 302 306 imports = [./nix/modules/spindle.nix]; 303 307 304 308 services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.spindle; 309 + services.tangled.spindle.tap-package = lib.mkDefault self.packages.${pkgs.system}.tap; 310 + }; 311 + nixosModules.did-method-plc = { 312 + lib, 313 + pkgs, 314 + ... 315 + }: { 316 + imports = [./nix/modules/did-method-plc.nix]; 317 + services.did-method-plc.package = lib.mkDefault self.packages.${pkgs.system}.did-method-plc; 318 + }; 319 + nixosModules.bluesky-relay = { 320 + lib, 321 + pkgs, 322 + ... 323 + }: { 324 + imports = [./nix/modules/bluesky-relay.nix]; 325 + services.bluesky-relay.package = lib.mkDefault self.packages.${pkgs.system}.bluesky-relay; 326 + }; 327 + nixosModules.bluesky-jetstream = { 328 + lib, 329 + pkgs, 330 + ... 331 + }: { 332 + imports = [./nix/modules/bluesky-jetstream.nix]; 333 + services.bluesky-jetstream.package = lib.mkDefault self.packages.${pkgs.system}.bluesky-jetstream; 305 334 }; 306 335 }; 307 336 }
+3 -2
go.mod
··· 45 45 github.com/urfave/cli/v3 v3.3.3 46 46 github.com/whyrusleeping/cbor-gen v0.3.1 47 47 github.com/yuin/goldmark v1.7.13 48 + github.com/yuin/goldmark-emoji v1.0.6 48 49 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 49 50 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab 50 51 golang.org/x/crypto v0.40.0 51 52 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 52 53 golang.org/x/image v0.31.0 53 54 golang.org/x/net v0.42.0 54 - golang.org/x/sync v0.17.0 55 55 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 56 56 gopkg.in/yaml.v3 v3.0.1 57 57 ) ··· 131 131 github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect 132 132 github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect 133 133 github.com/hashicorp/go-sockaddr v1.0.7 // indirect 134 + github.com/hashicorp/go-version v1.8.0 // indirect 134 135 github.com/hashicorp/golang-lru v1.0.2 // indirect 135 136 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 136 137 github.com/hashicorp/hcl v1.0.1-vault-7 // indirect ··· 190 191 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 191 192 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 192 193 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 193 - github.com/yuin/goldmark-emoji v1.0.6 // indirect 194 194 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 195 195 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 196 196 go.etcd.io/bbolt v1.4.0 // indirect ··· 204 204 go.uber.org/atomic v1.11.0 // indirect 205 205 go.uber.org/multierr v1.11.0 // indirect 206 206 go.uber.org/zap v1.27.0 // indirect 207 + golang.org/x/sync v0.17.0 // indirect 207 208 golang.org/x/sys v0.34.0 // indirect 208 209 golang.org/x/text v0.29.0 // indirect 209 210 golang.org/x/time v0.12.0 // indirect
+2
go.sum
··· 264 264 github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= 265 265 github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= 266 266 github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= 267 + github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= 268 + github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 267 269 github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 268 270 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 269 271 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
-51
lexicons/comment/comment.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.comment", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "subject", 14 - "body", 15 - "createdAt" 16 - ], 17 - "properties": { 18 - "subject": { 19 - "type": "string", 20 - "format": "at-uri" 21 - }, 22 - "body": { 23 - "type": "string" 24 - }, 25 - "createdAt": { 26 - "type": "string", 27 - "format": "datetime" 28 - }, 29 - "replyTo": { 30 - "type": "string", 31 - "format": "at-uri" 32 - }, 33 - "mentions": { 34 - "type": "array", 35 - "items": { 36 - "type": "string", 37 - "format": "did" 38 - } 39 - }, 40 - "references": { 41 - "type": "array", 42 - "items": { 43 - "type": "string", 44 - "format": "at-uri" 45 - } 46 - } 47 - } 48 - } 49 - } 50 - } 51 - }
+33
lexicons/pipeline/cancelPipeline.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.pipeline.cancelPipeline", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Cancel a running pipeline", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["repo", "pipeline", "workflow"], 13 + "properties": { 14 + "repo": { 15 + "type": "string", 16 + "format": "at-uri", 17 + "description": "repo at-uri, spindle can't resolve repo from pipeline at-uri yet" 18 + }, 19 + "pipeline": { 20 + "type": "string", 21 + "format": "at-uri", 22 + "description": "pipeline at-uri" 23 + }, 24 + "workflow": { 25 + "type": "string", 26 + "description": "workflow name" 27 + } 28 + } 29 + } 30 + } 31 + } 32 + } 33 + }
+6
nix/gomod2nix.toml
··· 304 304 [mod."github.com/hashicorp/go-sockaddr"] 305 305 version = "v1.0.7" 306 306 hash = "sha256-p6eDOrGzN1jMmT/F/f/VJMq0cKNFhUcEuVVwTE6vSrs=" 307 + [mod."github.com/hashicorp/go-version"] 308 + version = "v1.8.0" 309 + hash = "sha256-KXtqERmYrWdpqPCViWcHbe6jnuH7k16bvBIcuJuevj8=" 307 310 [mod."github.com/hashicorp/golang-lru"] 308 311 version = "v1.0.2" 309 312 hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48=" ··· 530 533 [mod."github.com/yuin/goldmark"] 531 534 version = "v1.7.13" 532 535 hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE=" 536 + [mod."github.com/yuin/goldmark-emoji"] 537 + version = "v1.0.6" 538 + hash = "sha256-+d6bZzOPE+JSFsZbQNZMCWE+n3jgcQnkPETVk47mxSY=" 533 539 [mod."github.com/yuin/goldmark-highlighting/v2"] 534 540 version = "v2.0.0-20230729083705-37449abec8cc" 535 541 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
+64
nix/modules/bluesky-jetstream.nix
··· 1 + { 2 + config, 3 + pkgs, 4 + lib, 5 + ... 6 + }: let 7 + cfg = config.services.bluesky-jetstream; 8 + in 9 + with lib; { 10 + options.services.bluesky-jetstream = { 11 + enable = mkEnableOption "jetstream server"; 12 + package = mkPackageOption pkgs "bluesky-jetstream" {}; 13 + 14 + # dataDir = mkOption { 15 + # type = types.str; 16 + # default = "/var/lib/jetstream"; 17 + # description = "directory to store data (pebbleDB)"; 18 + # }; 19 + livenessTtl = mkOption { 20 + type = types.int; 21 + default = 15; 22 + description = "time to restart when no event detected (seconds)"; 23 + }; 24 + websocketUrl = mkOption { 25 + type = types.str; 26 + default = "wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos"; 27 + description = "full websocket path to the ATProto SubscribeRepos XRPC endpoint"; 28 + }; 29 + }; 30 + config = mkIf cfg.enable { 31 + systemd.services.bluesky-jetstream = { 32 + description = "bluesky jetstream"; 33 + after = ["network.target" "pds.service"]; 34 + wantedBy = ["multi-user.target"]; 35 + 36 + serviceConfig = { 37 + User = "jetstream"; 38 + Group = "jetstream"; 39 + StateDirectory = "jetstream"; 40 + StateDirectoryMode = "0755"; 41 + # preStart = '' 42 + # mkdir -p "${cfg.dataDir}" 43 + # chown -R jetstream:jetstream "${cfg.dataDir}" 44 + # ''; 45 + # WorkingDirectory = cfg.dataDir; 46 + Environment = [ 47 + "JETSTREAM_DATA_DIR=/var/lib/jetstream/data" 48 + "JETSTREAM_LIVENESS_TTL=${toString cfg.livenessTtl}s" 49 + "JETSTREAM_WS_URL=${cfg.websocketUrl}" 50 + ]; 51 + ExecStart = getExe cfg.package; 52 + Restart = "always"; 53 + RestartSec = 5; 54 + }; 55 + }; 56 + users = { 57 + users.jetstream = { 58 + group = "jetstream"; 59 + isSystemUser = true; 60 + }; 61 + groups.jetstream = {}; 62 + }; 63 + }; 64 + }
+48
nix/modules/bluesky-relay.nix
··· 1 + { 2 + config, 3 + pkgs, 4 + lib, 5 + ... 6 + }: let 7 + cfg = config.services.bluesky-relay; 8 + in 9 + with lib; { 10 + options.services.bluesky-relay = { 11 + enable = mkEnableOption "relay server"; 12 + package = mkPackageOption pkgs "bluesky-relay" {}; 13 + }; 14 + config = mkIf cfg.enable { 15 + systemd.services.bluesky-relay = { 16 + description = "bluesky relay"; 17 + after = ["network.target" "pds.service"]; 18 + wantedBy = ["multi-user.target"]; 19 + 20 + serviceConfig = { 21 + User = "relay"; 22 + Group = "relay"; 23 + StateDirectory = "relay"; 24 + StateDirectoryMode = "0755"; 25 + Environment = [ 26 + "RELAY_ADMIN_PASSWORD=password" 27 + "RELAY_PLC_HOST=https://plc.tngl.boltless.dev" 28 + "DATABASE_URL=sqlite:///var/lib/relay/relay.sqlite" 29 + "RELAY_IP_BIND=:2470" 30 + "RELAY_PERSIST_DIR=/var/lib/relay" 31 + "RELAY_DISABLE_REQUEST_CRAWL=0" 32 + "RELAY_INITIAL_SEQ_NUMBER=1" 33 + "RELAY_ALLOW_INSECURE_HOSTS=1" 34 + ]; 35 + ExecStart = "${getExe cfg.package} serve"; 36 + Restart = "always"; 37 + RestartSec = 5; 38 + }; 39 + }; 40 + users = { 41 + users.relay = { 42 + group = "relay"; 43 + isSystemUser = true; 44 + }; 45 + groups.relay = {}; 46 + }; 47 + }; 48 + }
+76
nix/modules/did-method-plc.nix
··· 1 + { 2 + config, 3 + pkgs, 4 + lib, 5 + ... 6 + }: let 7 + cfg = config.services.did-method-plc; 8 + in 9 + with lib; { 10 + options.services.did-method-plc = { 11 + enable = mkEnableOption "did-method-plc server"; 12 + package = mkPackageOption pkgs "did-method-plc" {}; 13 + }; 14 + config = mkIf cfg.enable { 15 + services.postgresql = { 16 + enable = true; 17 + package = pkgs.postgresql_14; 18 + ensureDatabases = ["plc"]; 19 + ensureUsers = [ 20 + { 21 + name = "pg"; 22 + # ensurePermissions."DATABASE plc" = "ALL PRIVILEGES"; 23 + } 24 + ]; 25 + authentication = '' 26 + local all all trust 27 + host all all 127.0.0.1/32 trust 28 + ''; 29 + }; 30 + systemd.services.did-method-plc = { 31 + description = "did-method-plc"; 32 + 33 + after = ["postgresql.service"]; 34 + wants = ["postgresql.service"]; 35 + wantedBy = ["multi-user.target"]; 36 + 37 + environment = let 38 + db_creds_json = builtins.toJSON { 39 + username = "pg"; 40 + password = ""; 41 + host = "127.0.0.1"; 42 + port = 5432; 43 + }; 44 + in { 45 + # TODO: inherit from config 46 + DEBUG_MODE = "1"; 47 + LOG_ENABLED = "true"; 48 + LOG_LEVEL = "debug"; 49 + LOG_DESTINATION = "1"; 50 + ENABLE_MIGRATIONS = "true"; 51 + DB_CREDS_JSON = db_creds_json; 52 + DB_MIGRATE_CREDS_JSON = db_creds_json; 53 + PLC_VERSION = "0.0.1"; 54 + PORT = "8080"; 55 + }; 56 + 57 + serviceConfig = { 58 + ExecStart = getExe cfg.package; 59 + User = "plc"; 60 + Group = "plc"; 61 + StateDirectory = "plc"; 62 + StateDirectoryMode = "0755"; 63 + Restart = "always"; 64 + 65 + # Hardening 66 + }; 67 + }; 68 + users = { 69 + users.plc = { 70 + group = "plc"; 71 + isSystemUser = true; 72 + }; 73 + groups.plc = {}; 74 + }; 75 + }; 76 + }
+35
nix/modules/spindle.nix
··· 1 1 { 2 2 config, 3 + pkgs, 3 4 lib, 4 5 ... 5 6 }: let ··· 16 17 package = mkOption { 17 18 type = types.package; 18 19 description = "Package to use for the spindle"; 20 + }; 21 + tap-package = mkOption { 22 + type = types.package; 23 + description = "Package to use for the spindle"; 24 + }; 25 + 26 + atpRelayUrl = mkOption { 27 + type = types.str; 28 + default = "https://relay1.us-east.bsky.network"; 29 + description = "atproto relay"; 19 30 }; 20 31 21 32 server = { ··· 114 125 config = mkIf cfg.enable { 115 126 virtualisation.docker.enable = true; 116 127 128 + systemd.services.spindle-tap = { 129 + description = "spindle tap service"; 130 + after = ["network.target" "docker.service"]; 131 + wantedBy = ["multi-user.target"]; 132 + serviceConfig = { 133 + LogsDirectory = "spindle-tap"; 134 + StateDirectory = "spindle-tap"; 135 + Environment = [ 136 + "TAP_BIND=:2480" 137 + "TAP_PLC_URL=${cfg.server.plcUrl}" 138 + "TAP_RELAY_URL=${cfg.atpRelayUrl}" 139 + "TAP_COLLECTION_FILTERS=${concatStringsSep "," [ 140 + "sh.tangled.repo" 141 + "sh.tangled.repo.collaborator" 142 + "sh.tangled.spindle.member" 143 + ]}" 144 + ]; 145 + ExecStart = "${getExe cfg.tap-package} run"; 146 + }; 147 + }; 148 + 117 149 systemd.services.spindle = { 118 150 description = "spindle service"; 119 151 after = ["network.target" "docker.service"]; 120 152 wantedBy = ["multi-user.target"]; 153 + path = [ 154 + pkgs.git 155 + ]; 121 156 serviceConfig = { 122 157 LogsDirectory = "spindle"; 123 158 StateDirectory = "spindle";
+20
nix/pkgs/bluesky-jetstream.nix
··· 1 + { 2 + buildGoModule, 3 + fetchFromGitHub, 4 + }: 5 + buildGoModule { 6 + pname = "bluesky-jetstream"; 7 + version = "0.1.0"; 8 + src = fetchFromGitHub { 9 + owner = "bluesky-social"; 10 + repo = "jetstream"; 11 + rev = "7d7efa58d7f14101a80ccc4f1085953948b7d5de"; 12 + sha256 = "sha256-1e9SL/8gaDPMA4YZed51ffzgpkptbMd0VTbTTDbPTFw="; 13 + }; 14 + subPackages = ["cmd/jetstream"]; 15 + vendorHash = "sha256-/21XJQH6fo9uPzlABUAbdBwt1O90odmppH6gXu2wkiQ="; 16 + doCheck = false; 17 + meta = { 18 + mainProgram = "jetstream"; 19 + }; 20 + }
+20
nix/pkgs/bluesky-relay.nix
··· 1 + { 2 + buildGoModule, 3 + fetchFromGitHub, 4 + }: 5 + buildGoModule { 6 + pname = "bluesky-relay"; 7 + version = "0.1.0"; 8 + src = fetchFromGitHub { 9 + owner = "boltlessengineer"; 10 + repo = "indigo"; 11 + rev = "b769ea60b7dde5e2bd0b8ee3ce8462a0c0e596fe"; 12 + sha256 = "sha256-jHRY825TBYaH1WkKFUoNbo4UlMSyuHvCGjYPiBnKo44="; 13 + }; 14 + subPackages = ["cmd/relay"]; 15 + vendorHash = "sha256-UOedwNYnM8Jx6B7Y9tFcZX8IeUBESAFAPTRYk7n0yo8="; 16 + doCheck = false; 17 + meta = { 18 + mainProgram = "relay"; 19 + }; 20 + }
+65
nix/pkgs/did-method-plc.nix
··· 1 + # inspired by https://github.com/NixOS/nixpkgs/blob/333bfb7c258fab089a834555ea1c435674c459b4/pkgs/by-name/ga/gatsby-cli/package.nix 2 + { 3 + lib, 4 + stdenv, 5 + fetchFromGitHub, 6 + fetchYarnDeps, 7 + yarnConfigHook, 8 + yarnBuildHook, 9 + nodejs, 10 + makeBinaryWrapper, 11 + }: 12 + stdenv.mkDerivation (finalAttrs: { 13 + pname = "did-method-plc"; 14 + version = "0.0.1"; 15 + 16 + src = fetchFromGitHub { 17 + owner = "did-method-plc"; 18 + repo = "did-method-plc"; 19 + rev = "158ba5535ac3da4fd4309954bde41deab0b45972"; 20 + sha256 = "sha256-O5smubbrnTDMCvL6iRyMXkddr5G7YHxkQRVMRULHanQ="; 21 + }; 22 + postPatch = '' 23 + # remove dd-trace dependency 24 + sed -i '3d' packages/server/service/index.js 25 + ''; 26 + 27 + yarnOfflineCache = fetchYarnDeps { 28 + yarnLock = finalAttrs.src + "/yarn.lock"; 29 + hash = "sha256-g8GzaAbWSnWwbQjJMV2DL5/ZlWCCX0sRkjjvX3tqU4Y="; 30 + }; 31 + 32 + nativeBuildInputs = [ 33 + yarnConfigHook 34 + yarnBuildHook 35 + nodejs 36 + makeBinaryWrapper 37 + ]; 38 + yarnBuildScript = "lerna"; 39 + yarnBuildFlags = [ 40 + "run" 41 + "build" 42 + "--scope" 43 + "@did-plc/server" 44 + "--include-dependencies" 45 + ]; 46 + 47 + installPhase = '' 48 + runHook preInstall 49 + 50 + mkdir -p $out/lib/node_modules/ 51 + mv packages/ $out/lib/packages/ 52 + mv node_modules/* $out/lib/node_modules/ 53 + 54 + makeWrapper ${lib.getExe nodejs} $out/bin/plc \ 55 + --add-flags $out/lib/packages/server/service/index.js \ 56 + --add-flags --enable-source-maps \ 57 + --set NODE_PATH $out/lib/node_modules 58 + 59 + runHook postInstall 60 + ''; 61 + 62 + meta = { 63 + mainProgram = "plc"; 64 + }; 65 + })
+20
nix/pkgs/tap.nix
··· 1 + { 2 + buildGoModule, 3 + fetchFromGitHub, 4 + }: 5 + buildGoModule { 6 + pname = "tap"; 7 + version = "0.1.0"; 8 + src = fetchFromGitHub { 9 + owner = "bluesky-social"; 10 + repo = "indigo"; 11 + rev = "f92cb29224fcc60f666b20ee3514e431a58ff811"; 12 + sha256 = "sha256-35ltXnq0SJeo3j33D7Nndbcnw5XWBJLRrmZ+nCmZVQw="; 13 + }; 14 + subPackages = ["cmd/tap"]; 15 + vendorHash = "sha256-UOedwNYnM8Jx6B7Y9tFcZX8IeUBESAFAPTRYk7n0yo8="; 16 + doCheck = false; 17 + meta = { 18 + mainProgram = "tap"; 19 + }; 20 + }
+2
nix/vm.nix
··· 19 19 20 20 plcUrl = envVarOr "TANGLED_VM_PLC_URL" "https://plc.directory"; 21 21 jetstream = envVarOr "TANGLED_VM_JETSTREAM_ENDPOINT" "wss://jetstream1.us-west.bsky.network/subscribe"; 22 + relayUrl = envVarOr "TANGLED_VM_RELAY_URL" "https://relay1.us-east.bsky.network"; 22 23 in 23 24 nixpkgs.lib.nixosSystem { 24 25 inherit system; ··· 95 96 }; 96 97 services.tangled.spindle = { 97 98 enable = true; 99 + atpRelayUrl = relayUrl; 98 100 server = { 99 101 owner = envVar "TANGLED_VM_SPINDLE_OWNER"; 100 102 hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555";
+10
orm/orm.go
··· 20 20 } 21 21 defer tx.Rollback() 22 22 23 + _, err = tx.Exec(` 24 + create table if not exists migrations ( 25 + id integer primary key autoincrement, 26 + name text unique 27 + ); 28 + `) 29 + if err != nil { 30 + return fmt.Errorf("creating migrations table: %w", err) 31 + } 32 + 23 33 var exists bool 24 34 err = tx.QueryRow("select exists (select 1 from migrations where name = ?)", name).Scan(&exists) 25 35 if err != nil {
+144
rbac2/rbac2.go
··· 1 + package rbac2 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + 7 + adapter "github.com/Blank-Xu/sql-adapter" 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + "github.com/casbin/casbin/v2" 10 + "github.com/casbin/casbin/v2/model" 11 + "github.com/casbin/casbin/v2/util" 12 + "tangled.org/core/api/tangled" 13 + ) 14 + 15 + const ( 16 + Model = ` 17 + [request_definition] 18 + r = sub, dom, obj, act 19 + 20 + [policy_definition] 21 + p = sub, dom, obj, act 22 + 23 + [role_definition] 24 + g = _, _, _ 25 + 26 + [policy_effect] 27 + e = some(where (p.eft == allow)) 28 + 29 + [matchers] 30 + m = g(r.sub, p.sub, r.dom) && keyMatch4(r.dom, p.dom) && r.obj == p.obj && r.act == p.act 31 + ` 32 + ) 33 + 34 + type Enforcer struct { 35 + e *casbin.Enforcer 36 + } 37 + 38 + func NewEnforcer(path string) (*Enforcer, error) { 39 + m, err := model.NewModelFromString(Model) 40 + if err != nil { 41 + return nil, err 42 + } 43 + 44 + db, err := sql.Open("sqlite3", path+"?_foreign_keys=1") 45 + if err != nil { 46 + return nil, err 47 + } 48 + 49 + a, err := adapter.NewAdapter(db, "sqlite3", "acl") 50 + if err != nil { 51 + return nil, err 52 + } 53 + 54 + e, err := casbin.NewEnforcer(m, a) 55 + if err != nil { 56 + return nil, err 57 + } 58 + 59 + if err := seedTangledPolicies(e); err != nil { 60 + return nil, err 61 + } 62 + 63 + return &Enforcer{e}, nil 64 + } 65 + 66 + func seedTangledPolicies(e *casbin.Enforcer) error { 67 + // policies 68 + aturi := func(nsid string) string { 69 + return fmt.Sprintf("at://{did}/%s/{rkey}", nsid) 70 + } 71 + 72 + _, err := e.AddPoliciesEx([][]string{ 73 + // sub | dom | obj | act 74 + {"repo:owner", aturi(tangled.RepoNSID), "/", "write"}, 75 + {"repo:owner", aturi(tangled.RepoNSID), "/collaborator", "write"}, // invite 76 + {"repo:collaborator", aturi(tangled.RepoNSID), "/settings", "write"}, 77 + {"repo:collaborator", aturi(tangled.RepoNSID), "/git", "write"}, // git push 78 + 79 + {"server:owner", "/knot/{did}", "/member", "write"}, // invite 80 + {"server:member", "/knot/{did}", "/git", "write"}, 81 + 82 + {"server:owner", "/spindle/{did}", "/member", "write"}, // invite 83 + }) 84 + if err != nil { 85 + return err 86 + } 87 + 88 + // grouping policies 89 + // TODO(boltless): define our own matcher to replace keyMatch4 90 + e.AddNamedDomainMatchingFunc("g", "keyMatch4", util.KeyMatch4) 91 + _, err = e.AddGroupingPoliciesEx([][]string{ 92 + // sub | role | dom 93 + {"repo:owner", "repo:collaborator", aturi(tangled.RepoNSID)}, 94 + 95 + // using '/knot/' prefix here because knot/spindle identifiers don't 96 + // include the collection type 97 + {"server:owner", "server:member", "/knot/{did}"}, 98 + {"server:owner", "server:member", "/spindle/{did}"}, 99 + }) 100 + return err 101 + } 102 + 103 + func (e *Enforcer) hasImplicitRoleForUser(name string, role string, domain ...string) (bool, error) { 104 + roles, err := e.e.GetImplicitRolesForUser(name, domain...) 105 + if err != nil { 106 + return false, err 107 + } 108 + for _, r := range roles { 109 + if r == role { 110 + return true, nil 111 + } 112 + } 113 + return false, nil 114 + } 115 + 116 + // setRoleForUser sets single user role for specified domain. 117 + // All existing users with that role will be removed. 118 + func (e *Enforcer) setRoleForUser(name string, role string, domain ...string) error { 119 + currentUsers, err := e.e.GetUsersForRole(role, domain...) 120 + if err != nil { 121 + return err 122 + } 123 + 124 + for _, oldUser := range currentUsers { 125 + _, err = e.e.DeleteRoleForUser(oldUser, role, domain...) 126 + if err != nil { 127 + return err 128 + } 129 + } 130 + 131 + _, err = e.e.AddRoleForUser(name, role, domain...) 132 + return err 133 + } 134 + 135 + // validateAtUri enforeces AT-URI to have valid did as authority and match collection NSID. 136 + func validateAtUri(uri syntax.ATURI, expected string) error { 137 + if !uri.Authority().IsDID() { 138 + return fmt.Errorf("expected at-uri with did") 139 + } 140 + if expected != "" && uri.Collection().String() != expected { 141 + return fmt.Errorf("incorrect repo at-uri collection nsid '%s' (expected '%s')", uri.Collection(), expected) 142 + } 143 + return nil 144 + }
+115
rbac2/rbac2_test.go
··· 1 + package rbac2_test 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + _ "github.com/mattn/go-sqlite3" 8 + "github.com/stretchr/testify/assert" 9 + "tangled.org/core/rbac2" 10 + ) 11 + 12 + func setup(t *testing.T) *rbac2.Enforcer { 13 + enforcer, err := rbac2.NewEnforcer(":memory:") 14 + assert.NoError(t, err) 15 + 16 + return enforcer 17 + } 18 + 19 + func TestRepoOwnerPermissions(t *testing.T) { 20 + var ( 21 + e = setup(t) 22 + ok bool 23 + err error 24 + fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") 25 + fooUser = syntax.DID("did:plc:foo") 26 + ) 27 + 28 + assert.NoError(t, e.AddRepo(fooRepo)) 29 + 30 + ok, err = e.IsRepoOwner(fooUser, fooRepo) 31 + assert.NoError(t, err) 32 + assert.True(t, ok, "repo author should be repo owner") 33 + 34 + ok, err = e.IsRepoWriteAllowed(fooUser, fooRepo) 35 + assert.NoError(t, err) 36 + assert.True(t, ok, "repo owner should be able to modify the repo itself") 37 + 38 + ok, err = e.IsRepoCollaborator(fooUser, fooRepo) 39 + assert.NoError(t, err) 40 + assert.True(t, ok, "repo owner should inherit role role:collaborator") 41 + 42 + ok, err = e.IsRepoSettingsWriteAllowed(fooUser, fooRepo) 43 + assert.NoError(t, err) 44 + assert.True(t, ok, "repo owner should inherit collaborator permissions") 45 + } 46 + 47 + func TestRepoCollaboratorPermissions(t *testing.T) { 48 + var ( 49 + e = setup(t) 50 + ok bool 51 + err error 52 + fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") 53 + barUser = syntax.DID("did:plc:bar") 54 + ) 55 + 56 + assert.NoError(t, e.AddRepo(fooRepo)) 57 + assert.NoError(t, e.AddRepoCollaborator(barUser, fooRepo)) 58 + 59 + ok, err = e.IsRepoCollaborator(barUser, fooRepo) 60 + assert.NoError(t, err) 61 + assert.True(t, ok, "should set repo collaborator") 62 + 63 + ok, err = e.IsRepoSettingsWriteAllowed(barUser, fooRepo) 64 + assert.NoError(t, err) 65 + assert.True(t, ok, "repo collaborator should be able to edit repo settings") 66 + 67 + ok, err = e.IsRepoWriteAllowed(barUser, fooRepo) 68 + assert.NoError(t, err) 69 + assert.False(t, ok, "repo collaborator shouldn't be able to modify the repo itself") 70 + } 71 + 72 + func TestGetByRole(t *testing.T) { 73 + var ( 74 + e = setup(t) 75 + err error 76 + fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") 77 + owner = syntax.DID("did:plc:foo") 78 + collaborator1 = syntax.DID("did:plc:bar") 79 + collaborator2 = syntax.DID("did:plc:baz") 80 + ) 81 + 82 + assert.NoError(t, e.AddRepo(fooRepo)) 83 + assert.NoError(t, e.AddRepoCollaborator(collaborator1, fooRepo)) 84 + assert.NoError(t, e.AddRepoCollaborator(collaborator2, fooRepo)) 85 + 86 + collaborators, err := e.GetRepoCollaborators(fooRepo) 87 + assert.NoError(t, err) 88 + assert.ElementsMatch(t, []syntax.DID{ 89 + owner, 90 + collaborator1, 91 + collaborator2, 92 + }, collaborators) 93 + } 94 + 95 + func TestSpindleOwnerPermissions(t *testing.T) { 96 + var ( 97 + e = setup(t) 98 + ok bool 99 + err error 100 + spindle = syntax.DID("did:web:spindle.example.com") 101 + owner = syntax.DID("did:plc:foo") 102 + member = syntax.DID("did:plc:bar") 103 + ) 104 + 105 + assert.NoError(t, e.SetSpindleOwner(owner, spindle)) 106 + assert.NoError(t, e.AddSpindleMember(member, spindle)) 107 + 108 + ok, err = e.IsSpindleMemberInviteAllowed(owner, spindle) 109 + assert.NoError(t, err) 110 + assert.True(t, ok, "spindle owner can invite members") 111 + 112 + ok, err = e.IsSpindleMemberInviteAllowed(member, spindle) 113 + assert.NoError(t, err) 114 + assert.False(t, ok, "spindle member cannot invite members") 115 + }
+91
rbac2/repo.go
··· 1 + package rbac2 2 + 3 + import ( 4 + "slices" 5 + "strings" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "tangled.org/core/api/tangled" 9 + ) 10 + 11 + // AddRepo adds new repo with its owner to rbac enforcer 12 + func (e *Enforcer) AddRepo(repo syntax.ATURI) error { 13 + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 14 + return err 15 + } 16 + user := repo.Authority() 17 + 18 + return e.setRoleForUser(user.String(), "repo:owner", repo.String()) 19 + } 20 + 21 + // DeleteRepo deletes all policies related to the repo 22 + func (e *Enforcer) DeleteRepo(repo syntax.ATURI) error { 23 + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 24 + return err 25 + } 26 + 27 + _, err := e.e.DeleteDomains(repo.String()) 28 + return err 29 + } 30 + 31 + // AddRepoCollaborator adds new collaborator to the repo 32 + func (e *Enforcer) AddRepoCollaborator(user syntax.DID, repo syntax.ATURI) error { 33 + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 34 + return err 35 + } 36 + 37 + _, err := e.e.AddRoleForUser(user.String(), "repo:collaborator", repo.String()) 38 + return err 39 + } 40 + 41 + // RemoveRepoCollaborator removes the collaborator from the repo. 42 + // This won't remove inherited roles like repository owner. 43 + func (e *Enforcer) RemoveRepoCollaborator(user syntax.DID, repo syntax.ATURI) error { 44 + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 45 + return err 46 + } 47 + 48 + _, err := e.e.DeleteRoleForUser(user.String(), "repo:collaborator", repo.String()) 49 + return err 50 + } 51 + 52 + func (e *Enforcer) GetRepoCollaborators(repo syntax.ATURI) ([]syntax.DID, error) { 53 + var collaborators []syntax.DID 54 + members, err := e.e.GetImplicitUsersForRole("repo:collaborator", repo.String()) 55 + if err != nil { 56 + return nil, err 57 + } 58 + for _, m := range members { 59 + if !strings.HasPrefix(m, "did:") { // skip non-user subjects like 'repo:owner' 60 + continue 61 + } 62 + collaborators = append(collaborators, syntax.DID(m)) 63 + } 64 + 65 + slices.Sort(collaborators) 66 + return slices.Compact(collaborators), nil 67 + } 68 + 69 + func (e *Enforcer) IsRepoOwner(user syntax.DID, repo syntax.ATURI) (bool, error) { 70 + return e.e.HasRoleForUser(user.String(), "repo:owner", repo.String()) 71 + } 72 + 73 + func (e *Enforcer) IsRepoCollaborator(user syntax.DID, repo syntax.ATURI) (bool, error) { 74 + return e.hasImplicitRoleForUser(user.String(), "repo:collaborator", repo.String()) 75 + } 76 + 77 + func (e *Enforcer) IsRepoWriteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 78 + return e.e.Enforce(user.String(), repo.String(), "#/", "write") 79 + } 80 + 81 + func (e *Enforcer) IsRepoSettingsWriteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 82 + return e.e.Enforce(user.String(), repo.String(), "#/settings", "write") 83 + } 84 + 85 + func (e *Enforcer) IsRepoCollaboratorInviteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 86 + return e.e.Enforce(user.String(), repo.String(), "#/collaborator", "write") 87 + } 88 + 89 + func (e *Enforcer) IsRepoGitPushAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 90 + return e.e.Enforce(user.String(), repo.String(), "#/git", "write") 91 + }
+29
rbac2/spindle.go
··· 1 + package rbac2 2 + 3 + import "github.com/bluesky-social/indigo/atproto/syntax" 4 + 5 + func (e *Enforcer) SetSpindleOwner(user syntax.DID, spindle syntax.DID) error { 6 + return e.setRoleForUser(user.String(), "server:owner", intoSpindle(spindle)) 7 + } 8 + 9 + func (e *Enforcer) IsSpindleMember(user syntax.DID, spindle syntax.DID) (bool, error) { 10 + return e.e.HasRoleForUser(user.String(), "server:member", spindle.String()) 11 + } 12 + 13 + func (e *Enforcer) AddSpindleMember(user syntax.DID, spindle syntax.DID) error { 14 + _, err := e.e.AddRoleForUser(user.String(), "server:member", intoSpindle(spindle)) 15 + return err 16 + } 17 + 18 + func (e *Enforcer) RemoveSpindleMember(user syntax.DID, spindle syntax.DID) error { 19 + _, err := e.e.DeleteRoleForUser(user.String(), "server:member", intoSpindle(spindle)) 20 + return err 21 + } 22 + 23 + func (e *Enforcer) IsSpindleMemberInviteAllowed(user syntax.DID, spindle syntax.DID) (bool, error) { 24 + return e.e.Enforce(user.String(), intoSpindle(spindle), "#/member", "write") 25 + } 26 + 27 + func intoSpindle(did syntax.DID) string { 28 + return "/spindle/" + did.String() 29 + }
+18 -11
spindle/config/config.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 + "path" 6 7 7 8 "github.com/bluesky-social/indigo/atproto/syntax" 8 9 "github.com/sethvargo/go-envconfig" 9 10 ) 10 11 11 12 type Server struct { 12 - ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"` 13 - DBPath string `env:"DB_PATH, default=spindle.db"` 14 - Hostname string `env:"HOSTNAME, required"` 15 - JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 16 - PlcUrl string `env:"PLC_URL, default=https://plc.directory"` 17 - Dev bool `env:"DEV, default=false"` 18 - Owner string `env:"OWNER, required"` 19 - Secrets Secrets `env:",prefix=SECRETS_"` 20 - LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 21 - QueueSize int `env:"QUEUE_SIZE, default=100"` 22 - MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of jobs that run at a time 13 + ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"` 14 + DBPath string `env:"DB_PATH, default=spindle.db"` 15 + Hostname string `env:"HOSTNAME, required"` 16 + JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 17 + TapUrl string `env:"TAP_URL, required"` 18 + PlcUrl string `env:"PLC_URL, default=https://plc.directory"` 19 + Dev bool `env:"DEV, default=false"` 20 + Owner syntax.DID `env:"OWNER, required"` 21 + Secrets Secrets `env:",prefix=SECRETS_"` 22 + LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 23 + DataDir string `env:"DATA_DIR, default=/var/lib/spindle"` 24 + QueueSize int `env:"QUEUE_SIZE, default=100"` 25 + MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of jobs that run at a time 23 26 } 24 27 25 28 func (s Server) Did() syntax.DID { 26 29 return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) 30 + } 31 + 32 + func (s Server) RepoDir() string { 33 + return path.Join(s.DataDir, "repos") 27 34 } 28 35 29 36 type Secrets struct {
+59 -18
spindle/db/db.go
··· 1 1 package db 2 2 3 3 import ( 4 + "context" 4 5 "database/sql" 5 6 "strings" 6 7 8 + "github.com/bluesky-social/indigo/atproto/syntax" 7 9 _ "github.com/mattn/go-sqlite3" 10 + "tangled.org/core/log" 11 + "tangled.org/core/orm" 8 12 ) 9 13 10 14 type DB struct { 11 15 *sql.DB 12 16 } 13 17 14 - func Make(dbPath string) (*DB, error) { 18 + func Make(ctx context.Context, dbPath string) (*DB, error) { 15 19 // https://github.com/mattn/go-sqlite3#connection-string 16 20 opts := []string{ 17 21 "_foreign_keys=1", ··· 19 23 "_synchronous=NORMAL", 20 24 "_auto_vacuum=incremental", 21 25 } 26 + 27 + logger := log.FromContext(ctx) 28 + logger = log.SubLogger(logger, "db") 22 29 23 30 db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 24 31 if err != nil { 25 32 return nil, err 26 33 } 27 34 28 - // NOTE: If any other migration is added here, you MUST 29 - // copy the pattern in appview: use a single sql.Conn 30 - // for every migration. 35 + conn, err := db.Conn(ctx) 36 + if err != nil { 37 + return nil, err 38 + } 39 + defer conn.Close() 31 40 32 41 _, err = db.Exec(` 33 42 create table if not exists _jetstream ( ··· 76 85 return nil, err 77 86 } 78 87 79 - return &DB{db}, nil 80 - } 88 + // run migrations 89 + 90 + // NOTE: this won't migrate existing records 91 + // they will be fetched again with tap instead 92 + orm.RunMigration(conn, logger, "add-rkey-to-repos", func(tx *sql.Tx) error { 93 + // archive legacy repos (just in case) 94 + _, err = tx.Exec(`alter table repos rename to repos_old`) 95 + if err != nil { 96 + return err 97 + } 98 + 99 + _, err := tx.Exec(` 100 + create table repos_new ( 101 + -- identifiers 102 + id integer primary key autoincrement, 103 + did text not null, 104 + rkey text not null, 105 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo' || '/' || rkey) stored, 106 + 107 + name text not null, 108 + knot text not null, 109 + 110 + addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 111 + unique(did, rkey) 112 + ); 113 + `) 114 + if err != nil { 115 + return err 116 + } 117 + 118 + return nil 119 + }) 81 120 82 - func (d *DB) SaveLastTimeUs(lastTimeUs int64) error { 83 - _, err := d.Exec(` 84 - insert into _jetstream (id, last_time_us) 85 - values (1, ?) 86 - on conflict(id) do update set last_time_us = excluded.last_time_us 87 - `, lastTimeUs) 88 - return err 121 + return &DB{db}, nil 89 122 } 90 123 91 - func (d *DB) GetLastTimeUs() (int64, error) { 92 - var lastTimeUs int64 93 - row := d.QueryRow(`select last_time_us from _jetstream where id = 1;`) 94 - err := row.Scan(&lastTimeUs) 95 - return lastTimeUs, err 124 + func (d *DB) IsKnownDid(did syntax.DID) (bool, error) { 125 + // is spindle member / repo collaborator 126 + var exists bool 127 + err := d.QueryRow( 128 + `select exists ( 129 + select 1 from repo_collaborators where did = ? 130 + union all 131 + select 1 from spindle_members where did = ? 132 + )`, 133 + did, 134 + did, 135 + ).Scan(&exists) 136 + return exists, err 96 137 }
+6 -18
spindle/db/events.go
··· 18 18 EventJson string `json:"event"` 19 19 } 20 20 21 - func (d *DB) InsertEvent(event Event, notifier *notifier.Notifier) error { 21 + func (d *DB) insertEvent(event Event, notifier *notifier.Notifier) error { 22 22 _, err := d.Exec( 23 23 `insert into events (rkey, nsid, event, created) values (?, ?, ?, ?)`, 24 24 event.Rkey, ··· 70 70 return evts, nil 71 71 } 72 72 73 - func (d *DB) CreateStatusEvent(rkey string, s tangled.PipelineStatus, n *notifier.Notifier) error { 74 - eventJson, err := json.Marshal(s) 75 - if err != nil { 76 - return err 77 - } 78 - 79 - event := Event{ 80 - Rkey: rkey, 81 - Nsid: tangled.PipelineStatusNSID, 82 - Created: time.Now().UnixNano(), 83 - EventJson: string(eventJson), 84 - } 85 - 86 - return d.InsertEvent(event, n) 87 - } 88 - 89 73 func (d *DB) createStatusEvent( 90 74 workflowId models.WorkflowId, 91 75 statusKind models.StatusKind, ··· 116 100 EventJson: string(eventJson), 117 101 } 118 102 119 - return d.InsertEvent(event, n) 103 + return d.insertEvent(event, n) 120 104 121 105 } 122 106 ··· 164 148 165 149 func (d *DB) StatusFailed(workflowId models.WorkflowId, workflowError string, exitCode int64, n *notifier.Notifier) error { 166 150 return d.createStatusEvent(workflowId, models.StatusKindFailed, &workflowError, &exitCode, n) 151 + } 152 + 153 + func (d *DB) StatusCancelled(workflowId models.WorkflowId, workflowError string, exitCode int64, n *notifier.Notifier) error { 154 + return d.createStatusEvent(workflowId, models.StatusKindCancelled, &workflowError, &exitCode, n) 167 155 } 168 156 169 157 func (d *DB) StatusSuccess(workflowId models.WorkflowId, n *notifier.Notifier) error {
-44
spindle/db/known_dids.go
··· 1 - package db 2 - 3 - func (d *DB) AddDid(did string) error { 4 - _, err := d.Exec(`insert or ignore into known_dids (did) values (?)`, did) 5 - return err 6 - } 7 - 8 - func (d *DB) RemoveDid(did string) error { 9 - _, err := d.Exec(`delete from known_dids where did = ?`, did) 10 - return err 11 - } 12 - 13 - func (d *DB) GetAllDids() ([]string, error) { 14 - var dids []string 15 - 16 - rows, err := d.Query(`select did from known_dids`) 17 - if err != nil { 18 - return nil, err 19 - } 20 - defer rows.Close() 21 - 22 - for rows.Next() { 23 - var did string 24 - if err := rows.Scan(&did); err != nil { 25 - return nil, err 26 - } 27 - dids = append(dids, did) 28 - } 29 - 30 - if err := rows.Err(); err != nil { 31 - return nil, err 32 - } 33 - 34 - return dids, nil 35 - } 36 - 37 - func (d *DB) HasKnownDids() bool { 38 - var count int 39 - err := d.QueryRow(`select count(*) from known_dids`).Scan(&count) 40 - if err != nil { 41 - return false 42 - } 43 - return count > 0 44 - }
+120 -11
spindle/db/repos.go
··· 1 1 package db 2 2 3 + import "github.com/bluesky-social/indigo/atproto/syntax" 4 + 3 5 type Repo struct { 4 - Knot string 5 - Owner string 6 - Name string 6 + Did syntax.DID 7 + Rkey syntax.RecordKey 8 + Name string 9 + Knot string 10 + } 11 + 12 + type RepoCollaborator struct { 13 + Did syntax.DID 14 + Rkey syntax.RecordKey 15 + Repo syntax.ATURI 16 + Subject syntax.DID 7 17 } 8 18 9 - func (d *DB) AddRepo(knot, owner, name string) error { 10 - _, err := d.Exec(`insert or ignore into repos (knot, owner, name) values (?, ?, ?)`, knot, owner, name) 19 + func (d *DB) PutRepo(repo *Repo) error { 20 + _, err := d.Exec( 21 + `insert or ignore into repos (did, rkey, name, knot) 22 + values (?, ?, ?, ?) 23 + on conflict(did, rkey) do update set 24 + name = excluded.name 25 + knot = excluded.knot`, 26 + repo.Did, 27 + repo.Rkey, 28 + repo.Name, 29 + repo.Knot, 30 + ) 31 + return err 32 + } 33 + 34 + func (d *DB) DeleteRepo(did syntax.DID, rkey syntax.RecordKey) error { 35 + _, err := d.Exec( 36 + `delete from repos where did = ? and rkey = ?`, 37 + did, 38 + rkey, 39 + ) 11 40 return err 12 41 } 13 42 ··· 34 63 return knots, nil 35 64 } 36 65 37 - func (d *DB) GetRepo(knot, owner, name string) (*Repo, error) { 66 + func (d *DB) GetRepo(did syntax.DID, rkey syntax.RecordKey) (*Repo, error) { 38 67 var repo Repo 68 + err := d.DB.QueryRow( 69 + `select 70 + did, 71 + rkey, 72 + name, 73 + knot 74 + from repos where did = ? and rkey = ?`, 75 + did, 76 + rkey, 77 + ).Scan( 78 + &repo.Did, 79 + &repo.Rkey, 80 + &repo.Name, 81 + &repo.Knot, 82 + ) 83 + if err != nil { 84 + return nil, err 85 + } 86 + return &repo, nil 87 + } 39 88 40 - query := "select knot, owner, name from repos where knot = ? and owner = ? and name = ?" 41 - err := d.DB.QueryRow(query, knot, owner, name). 42 - Scan(&repo.Knot, &repo.Owner, &repo.Name) 89 + func (d *DB) GetRepoWithName(did syntax.DID, name string) (*Repo, error) { 90 + var repo Repo 91 + err := d.DB.QueryRow( 92 + `select 93 + did, 94 + rkey, 95 + name, 96 + knot 97 + from repos where did = ? and name = ?`, 98 + did, 99 + name, 100 + ).Scan( 101 + &repo.Did, 102 + &repo.Rkey, 103 + &repo.Name, 104 + &repo.Knot, 105 + ) 106 + if err != nil { 107 + return nil, err 108 + } 109 + return &repo, nil 110 + } 111 + 112 + func (d *DB) PutRepoCollaborator(collaborator *RepoCollaborator) error { 113 + _, err := d.Exec( 114 + `insert into repo_collaborators (did, rkey, repo, subject) 115 + values (?, ?, ?, ?) 116 + on conflict(did, rkey) do update set 117 + repo = excluded.repo 118 + subject = excluded.subject`, 119 + collaborator.Did, 120 + collaborator.Rkey, 121 + collaborator.Repo, 122 + collaborator.Subject, 123 + ) 124 + return err 125 + } 126 + 127 + func (d *DB) RemoveRepoCollaborator(did syntax.DID, rkey syntax.RecordKey) error { 128 + _, err := d.Exec( 129 + `delete from repo_collaborators where did = ? and rkey = ?`, 130 + did, 131 + rkey, 132 + ) 133 + return err 134 + } 43 135 136 + func (d *DB) GetRepoCollaborator(did syntax.DID, rkey syntax.RecordKey) (*RepoCollaborator, error) { 137 + var collaborator RepoCollaborator 138 + err := d.DB.QueryRow( 139 + `select 140 + did, 141 + rkey, 142 + repo, 143 + subject 144 + from repo_collaborators 145 + where did = ? and rkey = ?`, 146 + did, 147 + rkey, 148 + ).Scan( 149 + &collaborator.Did, 150 + &collaborator.Rkey, 151 + &collaborator.Repo, 152 + &collaborator.Subject, 153 + ) 44 154 if err != nil { 45 155 return nil, err 46 156 } 47 - 48 - return &repo, nil 157 + return &collaborator, nil 49 158 }
+5 -1
spindle/engine/engine.go
··· 70 70 } 71 71 defer eng.DestroyWorkflow(ctx, wid) 72 72 73 - wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid) 73 + secretValues := make([]string, len(allSecrets)) 74 + for i, s := range allSecrets { 75 + secretValues[i] = s.Value 76 + } 77 + wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid, secretValues) 74 78 if err != nil { 75 79 l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 76 80 wfLogger = nil
+24 -10
spindle/engines/nixery/engine.go
··· 179 179 return err 180 180 } 181 181 e.registerCleanup(wid, func(ctx context.Context) error { 182 - return e.docker.NetworkRemove(ctx, networkName(wid)) 182 + err := e.docker.NetworkRemove(ctx, networkName(wid)) 183 + if err != nil { 184 + return fmt.Errorf("removing network: %w", err) 185 + } 186 + return nil 183 187 }) 184 188 185 189 addl := wf.Data.(addlFields) ··· 229 233 return fmt.Errorf("creating container: %w", err) 230 234 } 231 235 e.registerCleanup(wid, func(ctx context.Context) error { 232 - err = e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{}) 236 + err := e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{}) 233 237 if err != nil { 234 - return err 238 + return fmt.Errorf("stopping container: %w", err) 235 239 } 236 240 237 - return e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{ 241 + err = e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{ 238 242 RemoveVolumes: true, 239 243 RemoveLinks: false, 240 244 Force: false, 241 245 }) 246 + if err != nil { 247 + return fmt.Errorf("removing container: %w", err) 248 + } 249 + return nil 242 250 }) 243 251 244 252 err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}) ··· 394 402 } 395 403 396 404 func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error { 397 - e.cleanupMu.Lock() 398 - key := wid.String() 399 - 400 - fns := e.cleanup[key] 401 - delete(e.cleanup, key) 402 - e.cleanupMu.Unlock() 405 + fns := e.drainCleanups(wid) 403 406 404 407 for _, fn := range fns { 405 408 if err := fn(ctx); err != nil { ··· 415 418 416 419 key := wid.String() 417 420 e.cleanup[key] = append(e.cleanup[key], fn) 421 + } 422 + 423 + func (e *Engine) drainCleanups(wid models.WorkflowId) []cleanupFunc { 424 + e.cleanupMu.Lock() 425 + key := wid.String() 426 + 427 + fns := e.cleanup[key] 428 + delete(e.cleanup, key) 429 + e.cleanupMu.Unlock() 430 + 431 + return fns 418 432 } 419 433 420 434 func networkName(wid models.WorkflowId) string {
-300
spindle/ingester.go
··· 1 - package spindle 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "errors" 7 - "fmt" 8 - "time" 9 - 10 - "tangled.org/core/api/tangled" 11 - "tangled.org/core/eventconsumer" 12 - "tangled.org/core/rbac" 13 - "tangled.org/core/spindle/db" 14 - 15 - comatproto "github.com/bluesky-social/indigo/api/atproto" 16 - "github.com/bluesky-social/indigo/atproto/identity" 17 - "github.com/bluesky-social/indigo/atproto/syntax" 18 - "github.com/bluesky-social/indigo/xrpc" 19 - "github.com/bluesky-social/jetstream/pkg/models" 20 - securejoin "github.com/cyphar/filepath-securejoin" 21 - ) 22 - 23 - type Ingester func(ctx context.Context, e *models.Event) error 24 - 25 - func (s *Spindle) ingest() Ingester { 26 - return func(ctx context.Context, e *models.Event) error { 27 - var err error 28 - defer func() { 29 - eventTime := e.TimeUS 30 - lastTimeUs := eventTime + 1 31 - if err := s.db.SaveLastTimeUs(lastTimeUs); err != nil { 32 - err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 33 - } 34 - }() 35 - 36 - if e.Kind != models.EventKindCommit { 37 - return nil 38 - } 39 - 40 - switch e.Commit.Collection { 41 - case tangled.SpindleMemberNSID: 42 - err = s.ingestMember(ctx, e) 43 - case tangled.RepoNSID: 44 - err = s.ingestRepo(ctx, e) 45 - case tangled.RepoCollaboratorNSID: 46 - err = s.ingestCollaborator(ctx, e) 47 - } 48 - 49 - if err != nil { 50 - s.l.Debug("failed to process message", "nsid", e.Commit.Collection, "err", err) 51 - } 52 - 53 - return nil 54 - } 55 - } 56 - 57 - func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error { 58 - var err error 59 - did := e.Did 60 - rkey := e.Commit.RKey 61 - 62 - l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID) 63 - 64 - switch e.Commit.Operation { 65 - case models.CommitOperationCreate, models.CommitOperationUpdate: 66 - raw := e.Commit.Record 67 - record := tangled.SpindleMember{} 68 - err = json.Unmarshal(raw, &record) 69 - if err != nil { 70 - l.Error("invalid record", "error", err) 71 - return err 72 - } 73 - 74 - domain := s.cfg.Server.Hostname 75 - recordInstance := record.Instance 76 - 77 - if recordInstance != domain { 78 - l.Error("domain mismatch", "domain", recordInstance, "expected", domain) 79 - return fmt.Errorf("domain mismatch: %s != %s", record.Instance, domain) 80 - } 81 - 82 - ok, err := s.e.IsSpindleInviteAllowed(did, rbacDomain) 83 - if err != nil || !ok { 84 - l.Error("failed to add member", "did", did, "error", err) 85 - return fmt.Errorf("failed to enforce permissions: %w", err) 86 - } 87 - 88 - if err := db.AddSpindleMember(s.db, db.SpindleMember{ 89 - Did: syntax.DID(did), 90 - Rkey: rkey, 91 - Instance: recordInstance, 92 - Subject: syntax.DID(record.Subject), 93 - Created: time.Now(), 94 - }); err != nil { 95 - l.Error("failed to add member", "error", err) 96 - return fmt.Errorf("failed to add member: %w", err) 97 - } 98 - 99 - if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil { 100 - l.Error("failed to add member", "error", err) 101 - return fmt.Errorf("failed to add member: %w", err) 102 - } 103 - l.Info("added member from firehose", "member", record.Subject) 104 - 105 - if err := s.db.AddDid(record.Subject); err != nil { 106 - l.Error("failed to add did", "error", err) 107 - return fmt.Errorf("failed to add did: %w", err) 108 - } 109 - s.jc.AddDid(record.Subject) 110 - 111 - return nil 112 - 113 - case models.CommitOperationDelete: 114 - record, err := db.GetSpindleMember(s.db, did, rkey) 115 - if err != nil { 116 - l.Error("failed to find member", "error", err) 117 - return fmt.Errorf("failed to find member: %w", err) 118 - } 119 - 120 - if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil { 121 - l.Error("failed to remove member", "error", err) 122 - return fmt.Errorf("failed to remove member: %w", err) 123 - } 124 - 125 - if err := s.e.RemoveSpindleMember(rbacDomain, record.Subject.String()); err != nil { 126 - l.Error("failed to add member", "error", err) 127 - return fmt.Errorf("failed to add member: %w", err) 128 - } 129 - l.Info("added member from firehose", "member", record.Subject) 130 - 131 - if err := s.db.RemoveDid(record.Subject.String()); err != nil { 132 - l.Error("failed to add did", "error", err) 133 - return fmt.Errorf("failed to add did: %w", err) 134 - } 135 - s.jc.RemoveDid(record.Subject.String()) 136 - 137 - } 138 - return nil 139 - } 140 - 141 - func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 142 - var err error 143 - did := e.Did 144 - 145 - l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 146 - 147 - l.Info("ingesting repo record", "did", did) 148 - 149 - switch e.Commit.Operation { 150 - case models.CommitOperationCreate, models.CommitOperationUpdate: 151 - raw := e.Commit.Record 152 - record := tangled.Repo{} 153 - err = json.Unmarshal(raw, &record) 154 - if err != nil { 155 - l.Error("invalid record", "error", err) 156 - return err 157 - } 158 - 159 - domain := s.cfg.Server.Hostname 160 - 161 - // no spindle configured for this repo 162 - if record.Spindle == nil { 163 - l.Info("no spindle configured", "name", record.Name) 164 - return nil 165 - } 166 - 167 - // this repo did not want this spindle 168 - if *record.Spindle != domain { 169 - l.Info("different spindle configured", "name", record.Name, "spindle", *record.Spindle, "domain", domain) 170 - return nil 171 - } 172 - 173 - // add this repo to the watch list 174 - if err := s.db.AddRepo(record.Knot, did, record.Name); err != nil { 175 - l.Error("failed to add repo", "error", err) 176 - return fmt.Errorf("failed to add repo: %w", err) 177 - } 178 - 179 - didSlashRepo, err := securejoin.SecureJoin(did, record.Name) 180 - if err != nil { 181 - return err 182 - } 183 - 184 - // add repo to rbac 185 - if err := s.e.AddRepo(did, rbac.ThisServer, didSlashRepo); err != nil { 186 - l.Error("failed to add repo to enforcer", "error", err) 187 - return fmt.Errorf("failed to add repo: %w", err) 188 - } 189 - 190 - // add collaborators to rbac 191 - owner, err := s.res.ResolveIdent(ctx, did) 192 - if err != nil || owner.Handle.IsInvalidHandle() { 193 - return err 194 - } 195 - if err := s.fetchAndAddCollaborators(ctx, owner, didSlashRepo); err != nil { 196 - return err 197 - } 198 - 199 - // add this knot to the event consumer 200 - src := eventconsumer.NewKnotSource(record.Knot) 201 - s.ks.AddSource(context.Background(), src) 202 - 203 - return nil 204 - 205 - } 206 - return nil 207 - } 208 - 209 - func (s *Spindle) ingestCollaborator(ctx context.Context, e *models.Event) error { 210 - var err error 211 - 212 - l := s.l.With("component", "ingester", "record", tangled.RepoCollaboratorNSID, "did", e.Did) 213 - 214 - l.Info("ingesting collaborator record") 215 - 216 - switch e.Commit.Operation { 217 - case models.CommitOperationCreate, models.CommitOperationUpdate: 218 - raw := e.Commit.Record 219 - record := tangled.RepoCollaborator{} 220 - err = json.Unmarshal(raw, &record) 221 - if err != nil { 222 - l.Error("invalid record", "error", err) 223 - return err 224 - } 225 - 226 - subjectId, err := s.res.ResolveIdent(ctx, record.Subject) 227 - if err != nil || subjectId.Handle.IsInvalidHandle() { 228 - return err 229 - } 230 - 231 - repoAt, err := syntax.ParseATURI(record.Repo) 232 - if err != nil { 233 - l.Info("rejecting record, invalid repoAt", "repoAt", record.Repo) 234 - return nil 235 - } 236 - 237 - // TODO: get rid of this entirely 238 - // resolve this aturi to extract the repo record 239 - owner, err := s.res.ResolveIdent(ctx, repoAt.Authority().String()) 240 - if err != nil || owner.Handle.IsInvalidHandle() { 241 - return fmt.Errorf("failed to resolve handle: %w", err) 242 - } 243 - 244 - xrpcc := xrpc.Client{ 245 - Host: owner.PDSEndpoint(), 246 - } 247 - 248 - resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 249 - if err != nil { 250 - return err 251 - } 252 - 253 - repo := resp.Value.Val.(*tangled.Repo) 254 - didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 255 - 256 - // check perms for this user 257 - if ok, err := s.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil { 258 - return fmt.Errorf("insufficient permissions: %w", err) 259 - } 260 - 261 - // add collaborator to rbac 262 - if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 263 - l.Error("failed to add repo to enforcer", "error", err) 264 - return fmt.Errorf("failed to add repo: %w", err) 265 - } 266 - 267 - return nil 268 - } 269 - return nil 270 - } 271 - 272 - func (s *Spindle) fetchAndAddCollaborators(ctx context.Context, owner *identity.Identity, didSlashRepo string) error { 273 - l := s.l.With("component", "ingester", "handler", "fetchAndAddCollaborators") 274 - 275 - l.Info("fetching and adding existing collaborators") 276 - 277 - xrpcc := xrpc.Client{ 278 - Host: owner.PDSEndpoint(), 279 - } 280 - 281 - resp, err := comatproto.RepoListRecords(ctx, &xrpcc, tangled.RepoCollaboratorNSID, "", 50, owner.DID.String(), false) 282 - if err != nil { 283 - return err 284 - } 285 - 286 - var errs error 287 - for _, r := range resp.Records { 288 - if r == nil { 289 - continue 290 - } 291 - record := r.Value.Val.(*tangled.RepoCollaborator) 292 - 293 - if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 294 - l.Error("failed to add repo to enforcer", "error", err) 295 - errors.Join(errs, fmt.Errorf("failed to add repo: %w", err)) 296 - } 297 - } 298 - 299 - return errs 300 - }
+6 -1
spindle/models/logger.go
··· 12 12 type WorkflowLogger struct { 13 13 file *os.File 14 14 encoder *json.Encoder 15 + mask *SecretMask 15 16 } 16 17 17 - func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) { 18 + func NewWorkflowLogger(baseDir string, wid WorkflowId, secretValues []string) (*WorkflowLogger, error) { 18 19 path := LogFilePath(baseDir, wid) 19 20 20 21 file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) ··· 25 26 return &WorkflowLogger{ 26 27 file: file, 27 28 encoder: json.NewEncoder(file), 29 + mask: NewSecretMask(secretValues), 28 30 }, nil 29 31 } 30 32 ··· 62 64 63 65 func (w *dataWriter) Write(p []byte) (int, error) { 64 66 line := strings.TrimRight(string(p), "\r\n") 67 + if w.logger.mask != nil { 68 + line = w.logger.mask.Mask(line) 69 + } 65 70 entry := NewDataLogLine(w.idx, line, w.stream) 66 71 if err := w.logger.encoder.Encode(entry); err != nil { 67 72 return 0, err
+1 -1
spindle/models/pipeline_env.go
··· 20 20 // Standard CI environment variable 21 21 env["CI"] = "true" 22 22 23 - env["TANGLED_PIPELINE_ID"] = pipelineId.Rkey 23 + env["TANGLED_PIPELINE_ID"] = pipelineId.AtUri().String() 24 24 25 25 // Repo info 26 26 if tr.Repo != nil {
+51
spindle/models/secret_mask.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/base64" 5 + "strings" 6 + ) 7 + 8 + // SecretMask replaces secret values in strings with "***". 9 + type SecretMask struct { 10 + replacer *strings.Replacer 11 + } 12 + 13 + // NewSecretMask creates a mask for the given secret values. 14 + // Also registers base64-encoded variants of each secret. 15 + func NewSecretMask(values []string) *SecretMask { 16 + var pairs []string 17 + 18 + for _, value := range values { 19 + if value == "" { 20 + continue 21 + } 22 + 23 + pairs = append(pairs, value, "***") 24 + 25 + b64 := base64.StdEncoding.EncodeToString([]byte(value)) 26 + if b64 != value { 27 + pairs = append(pairs, b64, "***") 28 + } 29 + 30 + b64NoPad := strings.TrimRight(b64, "=") 31 + if b64NoPad != b64 && b64NoPad != value { 32 + pairs = append(pairs, b64NoPad, "***") 33 + } 34 + } 35 + 36 + if len(pairs) == 0 { 37 + return nil 38 + } 39 + 40 + return &SecretMask{ 41 + replacer: strings.NewReplacer(pairs...), 42 + } 43 + } 44 + 45 + // Mask replaces all registered secret values with "***". 46 + func (m *SecretMask) Mask(input string) string { 47 + if m == nil || m.replacer == nil { 48 + return input 49 + } 50 + return m.replacer.Replace(input) 51 + }
+135
spindle/models/secret_mask_test.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/base64" 5 + "testing" 6 + ) 7 + 8 + func TestSecretMask_BasicMasking(t *testing.T) { 9 + mask := NewSecretMask([]string{"mysecret123"}) 10 + 11 + input := "The password is mysecret123 in this log" 12 + expected := "The password is *** in this log" 13 + 14 + result := mask.Mask(input) 15 + if result != expected { 16 + t.Errorf("expected %q, got %q", expected, result) 17 + } 18 + } 19 + 20 + func TestSecretMask_Base64Encoded(t *testing.T) { 21 + secret := "mysecret123" 22 + mask := NewSecretMask([]string{secret}) 23 + 24 + b64 := base64.StdEncoding.EncodeToString([]byte(secret)) 25 + input := "Encoded: " + b64 26 + expected := "Encoded: ***" 27 + 28 + result := mask.Mask(input) 29 + if result != expected { 30 + t.Errorf("expected %q, got %q", expected, result) 31 + } 32 + } 33 + 34 + func TestSecretMask_Base64NoPadding(t *testing.T) { 35 + // "test" encodes to "dGVzdA==" with padding 36 + secret := "test" 37 + mask := NewSecretMask([]string{secret}) 38 + 39 + b64NoPad := "dGVzdA" // base64 without padding 40 + input := "Token: " + b64NoPad 41 + expected := "Token: ***" 42 + 43 + result := mask.Mask(input) 44 + if result != expected { 45 + t.Errorf("expected %q, got %q", expected, result) 46 + } 47 + } 48 + 49 + func TestSecretMask_MultipleSecrets(t *testing.T) { 50 + mask := NewSecretMask([]string{"password1", "apikey123"}) 51 + 52 + input := "Using password1 and apikey123 for auth" 53 + expected := "Using *** and *** for auth" 54 + 55 + result := mask.Mask(input) 56 + if result != expected { 57 + t.Errorf("expected %q, got %q", expected, result) 58 + } 59 + } 60 + 61 + func TestSecretMask_MultipleOccurrences(t *testing.T) { 62 + mask := NewSecretMask([]string{"secret"}) 63 + 64 + input := "secret appears twice: secret" 65 + expected := "*** appears twice: ***" 66 + 67 + result := mask.Mask(input) 68 + if result != expected { 69 + t.Errorf("expected %q, got %q", expected, result) 70 + } 71 + } 72 + 73 + func TestSecretMask_ShortValues(t *testing.T) { 74 + mask := NewSecretMask([]string{"abc", "xy", ""}) 75 + 76 + if mask == nil { 77 + t.Fatal("expected non-nil mask") 78 + } 79 + 80 + input := "abc xy test" 81 + expected := "*** *** test" 82 + result := mask.Mask(input) 83 + if result != expected { 84 + t.Errorf("expected %q, got %q", expected, result) 85 + } 86 + } 87 + 88 + func TestSecretMask_NilMask(t *testing.T) { 89 + var mask *SecretMask 90 + 91 + input := "some input text" 92 + result := mask.Mask(input) 93 + if result != input { 94 + t.Errorf("expected %q, got %q", input, result) 95 + } 96 + } 97 + 98 + func TestSecretMask_EmptyInput(t *testing.T) { 99 + mask := NewSecretMask([]string{"secret"}) 100 + 101 + result := mask.Mask("") 102 + if result != "" { 103 + t.Errorf("expected empty string, got %q", result) 104 + } 105 + } 106 + 107 + func TestSecretMask_NoMatch(t *testing.T) { 108 + mask := NewSecretMask([]string{"secretvalue"}) 109 + 110 + input := "nothing to mask here" 111 + result := mask.Mask(input) 112 + if result != input { 113 + t.Errorf("expected %q, got %q", input, result) 114 + } 115 + } 116 + 117 + func TestSecretMask_EmptySecretsList(t *testing.T) { 118 + mask := NewSecretMask([]string{}) 119 + 120 + if mask != nil { 121 + t.Error("expected nil mask for empty secrets list") 122 + } 123 + } 124 + 125 + func TestSecretMask_EmptySecretsFiltered(t *testing.T) { 126 + mask := NewSecretMask([]string{"ab", "validpassword", "", "xyz"}) 127 + 128 + input := "Using validpassword here" 129 + expected := "Using *** here" 130 + 131 + result := mask.Mask(input) 132 + if result != expected { 133 + t.Errorf("expected %q, got %q", expected, result) 134 + } 135 + }
+133 -70
spindle/server.go
··· 1 1 package spindle 2 2 3 3 import ( 4 + "bytes" 4 5 "context" 5 6 _ "embed" 6 7 "encoding/json" ··· 8 9 "log/slog" 9 10 "maps" 10 11 "net/http" 12 + "os" 13 + "os/exec" 14 + "path" 15 + "strings" 11 16 17 + "github.com/bluesky-social/indigo/atproto/syntax" 12 18 "github.com/go-chi/chi/v5" 19 + "github.com/hashicorp/go-version" 13 20 "tangled.org/core/api/tangled" 14 21 "tangled.org/core/eventconsumer" 15 22 "tangled.org/core/eventconsumer/cursor" 16 23 "tangled.org/core/idresolver" 17 - "tangled.org/core/jetstream" 18 24 "tangled.org/core/log" 19 25 "tangled.org/core/notifier" 20 - "tangled.org/core/rbac" 26 + "tangled.org/core/rbac2" 21 27 "tangled.org/core/spindle/config" 22 28 "tangled.org/core/spindle/db" 23 29 "tangled.org/core/spindle/engine" ··· 26 32 "tangled.org/core/spindle/queue" 27 33 "tangled.org/core/spindle/secrets" 28 34 "tangled.org/core/spindle/xrpc" 35 + "tangled.org/core/tap" 29 36 "tangled.org/core/xrpc/serviceauth" 30 37 ) 31 38 32 39 //go:embed motd 33 40 var motd []byte 34 41 35 - const ( 36 - rbacDomain = "thisserver" 37 - ) 38 - 39 42 type Spindle struct { 40 - jc *jetstream.JetstreamClient 43 + tap *tap.Client 41 44 db *db.DB 42 - e *rbac.Enforcer 45 + e *rbac2.Enforcer 43 46 l *slog.Logger 44 47 n *notifier.Notifier 45 48 engs map[string]models.Engine ··· 54 57 func New(ctx context.Context, cfg *config.Config, engines map[string]models.Engine) (*Spindle, error) { 55 58 logger := log.FromContext(ctx) 56 59 57 - d, err := db.Make(cfg.Server.DBPath) 60 + if err := ensureGitVersion(); err != nil { 61 + return nil, fmt.Errorf("ensuring git version: %w", err) 62 + } 63 + 64 + d, err := db.Make(ctx, cfg.Server.DBPath) 58 65 if err != nil { 59 66 return nil, fmt.Errorf("failed to setup db: %w", err) 60 67 } 61 68 62 - e, err := rbac.NewEnforcer(cfg.Server.DBPath) 69 + e, err := rbac2.NewEnforcer(cfg.Server.DBPath) 63 70 if err != nil { 64 71 return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err) 65 72 } 66 - e.E.EnableAutoSave(true) 67 73 68 74 n := notifier.New() 69 75 ··· 95 101 jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount) 96 102 logger.Info("initialized queue", "queueSize", cfg.Server.QueueSize, "numWorkers", cfg.Server.MaxJobCount) 97 103 98 - collections := []string{ 99 - tangled.SpindleMemberNSID, 100 - tangled.RepoNSID, 101 - tangled.RepoCollaboratorNSID, 102 - } 103 - jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true) 104 - if err != nil { 105 - return nil, fmt.Errorf("failed to setup jetstream client: %w", err) 106 - } 107 - jc.AddDid(cfg.Server.Owner) 108 - 109 - // Check if the spindle knows about any Dids; 110 - dids, err := d.GetAllDids() 111 - if err != nil { 112 - return nil, fmt.Errorf("failed to get all dids: %w", err) 113 - } 114 - for _, d := range dids { 115 - jc.AddDid(d) 116 - } 104 + tap := tap.NewClient(cfg.Server.TapUrl, "") 117 105 118 106 resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl) 119 107 120 108 spindle := &Spindle{ 121 - jc: jc, 109 + tap: &tap, 122 110 e: e, 123 111 db: d, 124 112 l: logger, ··· 130 118 vault: vault, 131 119 } 132 120 133 - err = e.AddSpindle(rbacDomain) 134 - if err != nil { 135 - return nil, fmt.Errorf("failed to set rbac domain: %w", err) 136 - } 137 - err = spindle.configureOwner() 121 + err = e.SetSpindleOwner(spindle.cfg.Server.Owner, spindle.cfg.Server.Did()) 138 122 if err != nil { 139 123 return nil, err 140 124 } ··· 143 127 cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath) 144 128 if err != nil { 145 129 return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err) 146 - } 147 - 148 - err = jc.StartJetstream(ctx, spindle.ingest()) 149 - if err != nil { 150 - return nil, fmt.Errorf("failed to start jetstream consumer: %w", err) 151 130 } 152 131 153 132 // for each incoming sh.tangled.pipeline, we execute ··· 197 176 } 198 177 199 178 // Enforcer returns the RBAC enforcer instance. 200 - func (s *Spindle) Enforcer() *rbac.Enforcer { 179 + func (s *Spindle) Enforcer() *rbac2.Enforcer { 201 180 return s.e 202 181 } 203 182 ··· 217 196 s.ks.Start(ctx) 218 197 }() 219 198 199 + go func() { 200 + s.l.Info("starting tap stream consumer") 201 + s.tap.Connect(ctx, &tap.SimpleIndexer{ 202 + EventHandler: s.processEvent, 203 + }) 204 + }() 205 + 220 206 s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr) 221 207 return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router()) 222 208 } ··· 268 254 Config: s.cfg, 269 255 Resolver: s.res, 270 256 Vault: s.vault, 257 + Notifier: s.Notifier(), 271 258 ServiceAuth: serviceAuth, 272 259 } 273 260 ··· 275 262 } 276 263 277 264 func (s *Spindle) processPipeline(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error { 265 + l := log.FromContext(ctx).With("handler", "processKnotStream") 266 + l = l.With("src", src.Key(), "msg.Nsid", msg.Nsid, "msg.Rkey", msg.Rkey) 278 267 if msg.Nsid == tangled.PipelineNSID { 268 + return nil 279 269 tpl := tangled.Pipeline{} 280 270 err := json.Unmarshal(msg.EventJson, &tpl) 281 271 if err != nil { ··· 296 286 } 297 287 298 288 // filter by repos 299 - _, err = s.db.GetRepo( 300 - tpl.TriggerMetadata.Repo.Knot, 301 - tpl.TriggerMetadata.Repo.Did, 289 + _, err = s.db.GetRepoWithName( 290 + syntax.DID(tpl.TriggerMetadata.Repo.Did), 302 291 tpl.TriggerMetadata.Repo.Repo, 303 292 ) 304 293 if err != nil { 305 - return err 294 + return fmt.Errorf("failed to get repo: %w", err) 306 295 } 307 296 308 297 pipelineId := models.PipelineId{ ··· 323 312 Name: w.Name, 324 313 }, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n) 325 314 if err != nil { 326 - return err 315 + return fmt.Errorf("db.StatusFailed: %w", err) 327 316 } 328 317 329 318 continue ··· 337 326 338 327 ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl) 339 328 if err != nil { 340 - return err 329 + return fmt.Errorf("init workflow: %w", err) 341 330 } 342 331 343 332 // inject TANGLED_* env vars after InitWorkflow ··· 354 343 Name: w.Name, 355 344 }, s.n) 356 345 if err != nil { 357 - return err 346 + return fmt.Errorf("db.StatusPending: %w", err) 358 347 } 359 348 } 360 349 } ··· 377 366 } else { 378 367 s.l.Error("failed to enqueue pipeline: queue is full") 379 368 } 369 + } else if msg.Nsid == tangled.GitRefUpdateNSID { 370 + event := tangled.GitRefUpdate{} 371 + if err := json.Unmarshal(msg.EventJson, &event); err != nil { 372 + l.Error("error unmarshalling", "err", err) 373 + return err 374 + } 375 + l = l.With("repoDid", event.RepoDid, "repoName", event.RepoName) 376 + 377 + // use event.RepoAt 378 + // sync git repos in {data}/repos/{did}/sh.tangled.repo/{rkey} 379 + // if it's nil, don't run pipeline. knot needs upgrade 380 + // we will leave sh.tangled.pipeline.trigger for backward compatibility 381 + 382 + // NOTE: we are blindly trusting the knot that it will return only repos it own 383 + repoCloneUri := s.newRepoCloneUrl(src.Key(), event.RepoDid, event.RepoName) 384 + repoPath := s.newRepoPath(event.RepoDid, event.RepoName) 385 + err := sparseSyncGitRepo(ctx, repoCloneUri, repoPath, event.NewSha) 386 + if err != nil { 387 + l.Error("failed to sync git repo", "err", err) 388 + return fmt.Errorf("sync git repo: %w", err) 389 + } 390 + l.Info("synced git repo") 391 + 392 + // TODO: plan the pipeline 380 393 } 381 394 382 395 return nil 383 396 } 384 397 385 - func (s *Spindle) configureOwner() error { 386 - cfgOwner := s.cfg.Server.Owner 398 + func (s *Spindle) newRepoPath(did, name string) string { 399 + return path.Join(s.cfg.Server.RepoDir(), did, name) 400 + } 401 + 402 + func (s *Spindle) newRepoCloneUrl(knot, did, name string) string { 403 + scheme := "https://" 404 + if s.cfg.Server.Dev { 405 + scheme = "http://" 406 + } 407 + return fmt.Sprintf("%s%s/%s/%s", scheme, knot, did, name) 408 + } 409 + 410 + const RequiredVersion = "2.49.0" 411 + 412 + func ensureGitVersion() error { 413 + v, err := gitVersion() 414 + if err != nil { 415 + return fmt.Errorf("fetching git version: %w", err) 416 + } 417 + if v.LessThan(version.Must(version.NewVersion(RequiredVersion))) { 418 + return fmt.Errorf("installed git version %q is not supported, Spindle requires git version >= %q", v, RequiredVersion) 419 + } 420 + return nil 421 + } 387 422 388 - existing, err := s.e.GetSpindleUsersByRole("server:owner", rbacDomain) 423 + // TODO: move to "git" module shared between knot, appview & spindle 424 + func gitVersion() (*version.Version, error) { 425 + var buf bytes.Buffer 426 + cmd := exec.Command("git", "version") 427 + cmd.Stdout = &buf 428 + cmd.Stderr = os.Stderr 429 + err := cmd.Run() 389 430 if err != nil { 390 - return err 431 + return nil, err 432 + } 433 + fields := strings.Fields(buf.String()) 434 + if len(fields) < 3 { 435 + return nil, fmt.Errorf("invalid git version: %s", buf) 391 436 } 392 437 393 - switch len(existing) { 394 - case 0: 395 - // no owner configured, continue 396 - case 1: 397 - // find existing owner 398 - existingOwner := existing[0] 438 + // version string is like: "git version 2.29.3" or "git version 2.29.3.windows.1" 439 + versionString := fields[2] 440 + if pos := strings.Index(versionString, "windows"); pos >= 1 { 441 + versionString = versionString[:pos-1] 442 + } 443 + return version.NewVersion(versionString) 444 + } 399 445 400 - // no ownership change, this is okay 401 - if existingOwner == s.cfg.Server.Owner { 402 - break 446 + func sparseSyncGitRepo(ctx context.Context, cloneUri, path, rev string) error { 447 + exist, err := isDir(path) 448 + if err != nil { 449 + return err 450 + } 451 + if !exist { 452 + if err := exec.Command("git", "clone", "--no-checkout", "--depth=1", "--filter=tree:0", "--revision="+rev, cloneUri, path).Run(); err != nil { 453 + return fmt.Errorf("git clone: %w", err) 403 454 } 404 - 405 - // remove existing owner 406 - err = s.e.RemoveSpindleOwner(rbacDomain, existingOwner) 407 - if err != nil { 408 - return nil 455 + if err := exec.Command("git", "-C", path, "sparse-checkout", "set", "--no-cone", `'/.tangled/workflows'`).Run(); err != nil { 456 + return fmt.Errorf("git sparse-checkout set: %w", err) 457 + } 458 + if err := exec.Command("git", "-C", path, "checkout", rev).Run(); err != nil { 459 + return fmt.Errorf("git checkout: %w", err) 460 + } 461 + } else { 462 + if err := exec.Command("git", "-C", path, "pull", "origin", rev).Run(); err != nil { 463 + return fmt.Errorf("git pull: %w", err) 409 464 } 410 - default: 411 - return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", s.cfg.Server.DBPath) 412 465 } 466 + return nil 467 + } 413 468 414 - return s.e.AddSpindleOwner(rbacDomain, cfgOwner) 469 + func isDir(path string) (bool, error) { 470 + info, err := os.Stat(path) 471 + if err == nil && info.IsDir() { 472 + return true, nil 473 + } 474 + if os.IsNotExist(err) { 475 + return false, nil 476 + } 477 + return false, err 415 478 }
+281
spindle/tap.go
··· 1 + package spindle 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/eventconsumer" 12 + "tangled.org/core/spindle/db" 13 + "tangled.org/core/tap" 14 + ) 15 + 16 + func (s *Spindle) processEvent(ctx context.Context, evt tap.Event) error { 17 + l := s.l.With("component", "tapIndexer") 18 + 19 + var err error 20 + switch evt.Type { 21 + case tap.EvtRecord: 22 + switch evt.Record.Collection.String() { 23 + case tangled.SpindleMemberNSID: 24 + err = s.processMember(ctx, evt) 25 + case tangled.RepoNSID: 26 + err = s.processRepo(ctx, evt) 27 + case tangled.RepoCollaboratorNSID: 28 + err = s.processCollaborator(ctx, evt) 29 + case tangled.RepoPullNSID: 30 + err = s.processPull(ctx, evt) 31 + } 32 + case tap.EvtIdentity: 33 + // no-op 34 + } 35 + 36 + if err != nil { 37 + l.Error("failed to process message. will retry later", "event.ID", evt.ID, "err", err) 38 + return err 39 + } 40 + return nil 41 + } 42 + 43 + // NOTE: make sure to return nil if we don't need to retry (e.g. forbidden, unrelated) 44 + 45 + func (s *Spindle) processMember(ctx context.Context, evt tap.Event) error { 46 + l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri()) 47 + 48 + l.Info("processing spindle.member record") 49 + 50 + // check perms for this user 51 + if ok, err := s.e.IsSpindleMemberInviteAllowed(evt.Record.Did, s.cfg.Server.Did()); !ok || err != nil { 52 + l.Warn("forbidden request", "did", evt.Record.Did, "error", err) 53 + return nil 54 + } 55 + 56 + switch evt.Record.Action { 57 + case tap.RecordCreateAction, tap.RecordUpdateAction: 58 + record := tangled.SpindleMember{} 59 + if err := json.Unmarshal(evt.Record.Record, &record); err != nil { 60 + return fmt.Errorf("parsing record: %w", err) 61 + } 62 + 63 + domain := s.cfg.Server.Hostname 64 + if record.Instance != domain { 65 + l.Info("domain mismatch", "domain", record.Instance, "expected", domain) 66 + return nil 67 + } 68 + 69 + created, err := time.Parse(record.CreatedAt, time.RFC3339) 70 + if err != nil { 71 + created = time.Now() 72 + } 73 + if err := db.AddSpindleMember(s.db, db.SpindleMember{ 74 + Did: evt.Record.Did, 75 + Rkey: evt.Record.Rkey.String(), 76 + Instance: record.Instance, 77 + Subject: syntax.DID(record.Subject), 78 + Created: created, 79 + }); err != nil { 80 + l.Error("failed to add member", "error", err) 81 + return fmt.Errorf("adding member to db: %w", err) 82 + } 83 + if err := s.e.AddSpindleMember(syntax.DID(record.Subject), s.cfg.Server.Did()); err != nil { 84 + return fmt.Errorf("adding member to rbac: %w", err) 85 + } 86 + if err := s.tap.AddRepos(ctx, []syntax.DID{syntax.DID(record.Subject)}); err != nil { 87 + return fmt.Errorf("adding did to tap", err) 88 + } 89 + 90 + l.Info("added member", "member", record.Subject) 91 + return nil 92 + 93 + case tap.RecordDeleteAction: 94 + var ( 95 + did = evt.Record.Did.String() 96 + rkey = evt.Record.Rkey.String() 97 + ) 98 + member, err := db.GetSpindleMember(s.db, did, rkey) 99 + if err != nil { 100 + return fmt.Errorf("finding member: %w", err) 101 + } 102 + 103 + if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil { 104 + return fmt.Errorf("removing member from db: %w", err) 105 + } 106 + if err := s.e.RemoveSpindleMember(member.Subject, s.cfg.Server.Did()); err != nil { 107 + return fmt.Errorf("removing member from rbac: %w", err) 108 + } 109 + if err := s.tapSafeRemoveDid(ctx, member.Subject); err != nil { 110 + return fmt.Errorf("removing did from tap: %w", err) 111 + } 112 + 113 + l.Info("removed member", "member", member.Subject) 114 + return nil 115 + } 116 + return nil 117 + } 118 + 119 + func (s *Spindle) processCollaborator(ctx context.Context, evt tap.Event) error { 120 + l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri()) 121 + 122 + l.Info("processing collaborator record") 123 + switch evt.Record.Action { 124 + case tap.RecordCreateAction, tap.RecordUpdateAction: 125 + record := tangled.RepoCollaborator{} 126 + if err := json.Unmarshal(evt.Record.Record, &record); err != nil { 127 + l.Error("invalid record", "err", err) 128 + return fmt.Errorf("parsing record: %w", err) 129 + } 130 + 131 + // check perms for this user 132 + if ok, err := s.e.IsRepoCollaboratorInviteAllowed(evt.Record.Did, syntax.ATURI(record.Repo)); !ok || err != nil { 133 + l.Warn("forbidden request", "did", evt.Record.Did, "err", err) 134 + return nil 135 + } 136 + 137 + if err := s.db.PutRepoCollaborator(&db.RepoCollaborator{ 138 + Did: evt.Record.Did, 139 + Rkey: evt.Record.Rkey, 140 + Repo: syntax.ATURI(record.Repo), 141 + Subject: syntax.DID(record.Subject), 142 + }); err != nil { 143 + return fmt.Errorf("adding collaborator to db: %w", err) 144 + } 145 + if err := s.e.AddRepoCollaborator(syntax.DID(record.Subject), syntax.ATURI(record.Repo)); err != nil { 146 + return fmt.Errorf("adding collaborator to rbac: %w", err) 147 + } 148 + if err := s.tap.AddRepos(ctx, []syntax.DID{syntax.DID(record.Subject)}); err != nil { 149 + return fmt.Errorf("adding did to tap: %w", err) 150 + } 151 + 152 + l.Info("add repo collaborator", "subejct", record.Subject, "repo", record.Repo) 153 + return nil 154 + 155 + case tap.RecordDeleteAction: 156 + // get existing collaborator 157 + collaborator, err := s.db.GetRepoCollaborator(evt.Record.Did, evt.Record.Rkey) 158 + if err != nil { 159 + return fmt.Errorf("failed to get existing collaborator info: %w", err) 160 + } 161 + 162 + // check perms for this user 163 + if ok, err := s.e.IsRepoCollaboratorInviteAllowed(evt.Record.Did, collaborator.Repo); !ok || err != nil { 164 + l.Warn("forbidden request", "did", evt.Record.Did, "err", err) 165 + return nil 166 + } 167 + 168 + if err := s.db.RemoveRepoCollaborator(collaborator.Subject, collaborator.Rkey); err != nil { 169 + return fmt.Errorf("removing collaborator from db: %w", err) 170 + } 171 + if err := s.e.RemoveRepoCollaborator(collaborator.Subject, collaborator.Repo); err != nil { 172 + return fmt.Errorf("removing collaborator from rbac: %w", err) 173 + } 174 + if err := s.tapSafeRemoveDid(ctx, collaborator.Subject); err != nil { 175 + return fmt.Errorf("removing did from tap: %w", err) 176 + } 177 + 178 + l.Info("removed repo collaborator", "subejct", collaborator.Subject, "repo", collaborator.Repo) 179 + return nil 180 + } 181 + return nil 182 + } 183 + 184 + func (s *Spindle) processRepo(ctx context.Context, evt tap.Event) error { 185 + l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri()) 186 + 187 + l.Info("processing repo record") 188 + 189 + // check perms for this user 190 + if ok, err := s.e.IsSpindleMember(evt.Record.Did, s.cfg.Server.Did()); !ok || err != nil { 191 + l.Warn("forbidden request", "did", evt.Record.Did, "err", err) 192 + return nil 193 + } 194 + 195 + switch evt.Record.Action { 196 + case tap.RecordCreateAction, tap.RecordUpdateAction: 197 + record := tangled.Repo{} 198 + if err := json.Unmarshal(evt.Record.Record, &record); err != nil { 199 + return fmt.Errorf("parsing record: %w", err) 200 + } 201 + 202 + domain := s.cfg.Server.Hostname 203 + if record.Spindle == nil || *record.Spindle != domain { 204 + if record.Spindle == nil { 205 + l.Info("spindle isn't configured", "name", record.Name) 206 + } else { 207 + l.Info("different spindle configured", "name", record.Name, "spindle", *record.Spindle, "domain", domain) 208 + } 209 + if err := s.db.DeleteRepo(evt.Record.Did, evt.Record.Rkey); err != nil { 210 + return fmt.Errorf("deleting repo from db: %w", err) 211 + } 212 + return nil 213 + } 214 + 215 + if err := s.db.PutRepo(&db.Repo{ 216 + Did: evt.Record.Did, 217 + Rkey: evt.Record.Rkey, 218 + Name: record.Name, 219 + Knot: record.Knot, 220 + }); err != nil { 221 + return fmt.Errorf("adding repo to db: %w", err) 222 + } 223 + 224 + if err := s.e.AddRepo(evt.Record.AtUri()); err != nil { 225 + return fmt.Errorf("adding repo to rbac") 226 + } 227 + 228 + // add this knot to the event consumer 229 + src := eventconsumer.NewKnotSource(record.Knot) 230 + s.ks.AddSource(context.Background(), src) 231 + 232 + l.Info("added repo", "repo", evt.Record.AtUri()) 233 + return nil 234 + 235 + case tap.RecordDeleteAction: 236 + // check perms for this user 237 + if ok, err := s.e.IsRepoOwner(evt.Record.Did, evt.Record.AtUri()); !ok || err != nil { 238 + l.Warn("forbidden request", "did", evt.Record.Did, "err", err) 239 + return nil 240 + } 241 + 242 + if err := s.db.DeleteRepo(evt.Record.Did, evt.Record.Rkey); err != nil { 243 + return fmt.Errorf("deleting repo from db: %w", err) 244 + } 245 + 246 + if err := s.e.DeleteRepo(evt.Record.AtUri()); err != nil { 247 + return fmt.Errorf("deleting repo from rbac: %w", err) 248 + } 249 + 250 + l.Info("deleted repo", "repo", evt.Record.AtUri()) 251 + return nil 252 + } 253 + return nil 254 + } 255 + 256 + func (s *Spindle) processPull(ctx context.Context, evt tap.Event) error { 257 + l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri()) 258 + 259 + l.Info("processing pull record") 260 + 261 + switch evt.Record.Action { 262 + case tap.RecordCreateAction, tap.RecordUpdateAction: 263 + // TODO 264 + case tap.RecordDeleteAction: 265 + // TODO 266 + } 267 + return nil 268 + } 269 + 270 + func (s *Spindle) tapSafeRemoveDid(ctx context.Context, did syntax.DID) error { 271 + known, err := s.db.IsKnownDid(syntax.DID(did)) 272 + if err != nil { 273 + return fmt.Errorf("ensuring did known state: %w", err) 274 + } 275 + if !known { 276 + if err := s.tap.RemoveRepos(ctx, []syntax.DID{did}); err != nil { 277 + return fmt.Errorf("removing did from tap: %w", err) 278 + } 279 + } 280 + return nil 281 + }
+1 -2
spindle/xrpc/add_secret.go
··· 11 11 "github.com/bluesky-social/indigo/xrpc" 12 12 securejoin "github.com/cyphar/filepath-securejoin" 13 13 "tangled.org/core/api/tangled" 14 - "tangled.org/core/rbac" 15 14 "tangled.org/core/spindle/secrets" 16 15 xrpcerr "tangled.org/core/xrpc/errors" 17 16 ) ··· 68 67 return 69 68 } 70 69 71 - if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 70 + if ok, err := x.Enforcer.IsRepoSettingsWriteAllowed(actorDid, repoAt); !ok || err != nil { 72 71 l.Error("insufficent permissions", "did", actorDid.String()) 73 72 writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 74 73 return
+1 -2
spindle/xrpc/list_secrets.go
··· 11 11 "github.com/bluesky-social/indigo/xrpc" 12 12 securejoin "github.com/cyphar/filepath-securejoin" 13 13 "tangled.org/core/api/tangled" 14 - "tangled.org/core/rbac" 15 14 "tangled.org/core/spindle/secrets" 16 15 xrpcerr "tangled.org/core/xrpc/errors" 17 16 ) ··· 63 62 return 64 63 } 65 64 66 - if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 65 + if ok, err := x.Enforcer.IsRepoSettingsWriteAllowed(actorDid, repoAt); !ok || err != nil { 67 66 l.Error("insufficent permissions", "did", actorDid.String()) 68 67 writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 69 68 return
+1 -1
spindle/xrpc/owner.go
··· 9 9 ) 10 10 11 11 func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) { 12 - owner := x.Config.Server.Owner 12 + owner := x.Config.Server.Owner.String() 13 13 if owner == "" { 14 14 writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError) 15 15 return
+72
spindle/xrpc/pipeline_cancelPipeline.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "strings" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/spindle/models" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 + ) 14 + 15 + func (x *Xrpc) CancelPipeline(w http.ResponseWriter, r *http.Request) { 16 + l := x.Logger 17 + fail := func(e xrpcerr.XrpcError) { 18 + l.Error("failed", "kind", e.Tag, "error", e.Message) 19 + writeError(w, e, http.StatusBadRequest) 20 + } 21 + l.Debug("cancel pipeline") 22 + 23 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 24 + if !ok { 25 + fail(xrpcerr.MissingActorDidError) 26 + return 27 + } 28 + 29 + var input tangled.PipelineCancelPipeline_Input 30 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 31 + fail(xrpcerr.GenericError(err)) 32 + return 33 + } 34 + 35 + aturi := syntax.ATURI(input.Pipeline) 36 + wid := models.WorkflowId{ 37 + PipelineId: models.PipelineId{ 38 + Knot: strings.TrimPrefix(aturi.Authority().String(), "did:web:"), 39 + Rkey: aturi.RecordKey().String(), 40 + }, 41 + Name: input.Workflow, 42 + } 43 + l.Debug("cancel pipeline", "wid", wid) 44 + 45 + // unfortunately we have to resolve repo-at here 46 + repoAt, err := syntax.ParseATURI(input.Repo) 47 + if err != nil { 48 + fail(xrpcerr.InvalidRepoError(input.Repo)) 49 + return 50 + } 51 + 52 + isRepoOwner, err := x.Enforcer.IsRepoOwner(actorDid, repoAt) 53 + if err != nil || !isRepoOwner { 54 + fail(xrpcerr.AccessControlError(actorDid.String())) 55 + return 56 + } 57 + for _, engine := range x.Engines { 58 + l.Debug("destorying workflow", "wid", wid) 59 + err = engine.DestroyWorkflow(r.Context(), wid) 60 + if err != nil { 61 + fail(xrpcerr.GenericError(fmt.Errorf("dailed to destroy workflow: %w", err))) 62 + return 63 + } 64 + err = x.Db.StatusCancelled(wid, "User canceled the workflow", -1, x.Notifier) 65 + if err != nil { 66 + fail(xrpcerr.GenericError(fmt.Errorf("dailed to emit status failed: %w", err))) 67 + return 68 + } 69 + } 70 + 71 + w.WriteHeader(http.StatusOK) 72 + }
+1 -2
spindle/xrpc/remove_secret.go
··· 10 10 "github.com/bluesky-social/indigo/xrpc" 11 11 securejoin "github.com/cyphar/filepath-securejoin" 12 12 "tangled.org/core/api/tangled" 13 - "tangled.org/core/rbac" 14 13 "tangled.org/core/spindle/secrets" 15 14 xrpcerr "tangled.org/core/xrpc/errors" 16 15 ) ··· 62 61 return 63 62 } 64 63 65 - if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 64 + if ok, err := x.Enforcer.IsRepoSettingsWriteAllowed(actorDid, repoAt); !ok || err != nil { 66 65 l.Error("insufficent permissions", "did", actorDid.String()) 67 66 writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 68 67 return
+5 -2
spindle/xrpc/xrpc.go
··· 10 10 11 11 "tangled.org/core/api/tangled" 12 12 "tangled.org/core/idresolver" 13 - "tangled.org/core/rbac" 13 + "tangled.org/core/notifier" 14 + "tangled.org/core/rbac2" 14 15 "tangled.org/core/spindle/config" 15 16 "tangled.org/core/spindle/db" 16 17 "tangled.org/core/spindle/models" ··· 24 25 type Xrpc struct { 25 26 Logger *slog.Logger 26 27 Db *db.DB 27 - Enforcer *rbac.Enforcer 28 + Enforcer *rbac2.Enforcer 28 29 Engines map[string]models.Engine 29 30 Config *config.Config 30 31 Resolver *idresolver.Resolver 31 32 Vault secrets.Manager 33 + Notifier *notifier.Notifier 32 34 ServiceAuth *serviceauth.ServiceAuth 33 35 } 34 36 ··· 41 43 r.Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 42 44 r.Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 43 45 r.Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 46 + r.Post("/"+tangled.PipelineCancelPipelineNSID, x.CancelPipeline) 44 47 }) 45 48 46 49 // service query endpoints (no auth required)
+18
tap/simpleIndexer.go
··· 1 + package tap 2 + 3 + import "context" 4 + 5 + type SimpleIndexer struct { 6 + EventHandler func(ctx context.Context, evt Event) error 7 + ErrorHandler func(ctx context.Context, err error) 8 + } 9 + 10 + var _ Handler = (*SimpleIndexer)(nil) 11 + 12 + func (i *SimpleIndexer) OnEvent(ctx context.Context, evt Event) error { 13 + return i.EventHandler(ctx, evt) 14 + } 15 + 16 + func (i *SimpleIndexer) OnError(ctx context.Context, err error) { 17 + i.ErrorHandler(ctx, err) 18 + }
+169
tap/tap.go
··· 1 + /// heavily inspired by <https://github.com/bluesky-social/atproto/blob/c7f5a868837d3e9b3289f988fee2267789327b06/packages/tap/README.md> 2 + 3 + package tap 4 + 5 + import ( 6 + "bytes" 7 + "context" 8 + "encoding/json" 9 + "fmt" 10 + "net/http" 11 + "net/url" 12 + 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + "github.com/gorilla/websocket" 15 + "tangled.org/core/log" 16 + ) 17 + 18 + // type WebsocketOptions struct { 19 + // maxReconnectSeconds int 20 + // heartbeatIntervalMs int 21 + // // onReconnectError 22 + // } 23 + 24 + type Handler interface { 25 + OnEvent(ctx context.Context, evt Event) error 26 + OnError(ctx context.Context, err error) 27 + } 28 + 29 + type Client struct { 30 + Url string 31 + AdminPassword string 32 + HTTPClient *http.Client 33 + } 34 + 35 + func NewClient(url, adminPassword string) Client { 36 + return Client{ 37 + Url: url, 38 + AdminPassword: adminPassword, 39 + HTTPClient: &http.Client{}, 40 + } 41 + } 42 + 43 + func (c *Client) AddRepos(ctx context.Context, dids []syntax.DID) error { 44 + body, err := json.Marshal(map[string][]syntax.DID{"dids": dids}) 45 + if err != nil { 46 + return err 47 + } 48 + req, err := http.NewRequestWithContext(ctx, "POST", c.Url+"/repos/add", bytes.NewReader(body)) 49 + if err != nil { 50 + return err 51 + } 52 + req.SetBasicAuth("admin", c.AdminPassword) 53 + req.Header.Set("Content-Type", "application/json") 54 + 55 + resp, err := c.HTTPClient.Do(req) 56 + if err != nil { 57 + return err 58 + } 59 + defer resp.Body.Close() 60 + if resp.StatusCode != http.StatusOK { 61 + return fmt.Errorf("tap: /repos/add failed with status %d", resp.StatusCode) 62 + } 63 + return nil 64 + } 65 + 66 + func (c *Client) RemoveRepos(ctx context.Context, dids []syntax.DID) error { 67 + body, err := json.Marshal(map[string][]syntax.DID{"dids": dids}) 68 + if err != nil { 69 + return err 70 + } 71 + req, err := http.NewRequestWithContext(ctx, "POST", c.Url+"/repos/remove", bytes.NewReader(body)) 72 + if err != nil { 73 + return err 74 + } 75 + req.SetBasicAuth("admin", c.AdminPassword) 76 + req.Header.Set("Content-Type", "application/json") 77 + 78 + resp, err := c.HTTPClient.Do(req) 79 + if err != nil { 80 + return err 81 + } 82 + defer resp.Body.Close() 83 + if resp.StatusCode != http.StatusOK { 84 + return fmt.Errorf("tap: /repos/remove failed with status %d", resp.StatusCode) 85 + } 86 + return nil 87 + } 88 + 89 + func (c *Client) Connect(ctx context.Context, handler Handler) error { 90 + l := log.FromContext(ctx) 91 + 92 + u, err := url.Parse(c.Url) 93 + if err != nil { 94 + return err 95 + } 96 + if u.Scheme == "https" { 97 + u.Scheme = "wss" 98 + } else { 99 + u.Scheme = "ws" 100 + } 101 + u.Path = "/channel" 102 + 103 + // TODO: set auth on dial 104 + 105 + url := u.String() 106 + 107 + // var backoff int 108 + // for { 109 + // select { 110 + // case <-ctx.Done(): 111 + // return ctx.Err() 112 + // default: 113 + // } 114 + // 115 + // header := http.Header{ 116 + // "Authorization": []string{""}, 117 + // } 118 + // conn, res, err := websocket.DefaultDialer.DialContext(ctx, url, header) 119 + // if err != nil { 120 + // l.Warn("dialing failed", "url", url, "err", err, "backoff", backoff) 121 + // time.Sleep(time.Duration(5+backoff) * time.Second) 122 + // backoff++ 123 + // 124 + // continue 125 + // } else { 126 + // backoff = 0 127 + // } 128 + // 129 + // l.Info("event subscription response", "code", res.StatusCode) 130 + // } 131 + 132 + // TODO: keep websocket connection alive 133 + conn, _, err := websocket.DefaultDialer.DialContext(ctx, url, nil) 134 + if err != nil { 135 + return err 136 + } 137 + defer conn.Close() 138 + 139 + for { 140 + select { 141 + case <-ctx.Done(): 142 + return ctx.Err() 143 + default: 144 + } 145 + _, message, err := conn.ReadMessage() 146 + if err != nil { 147 + return err 148 + } 149 + 150 + var ev Event 151 + if err := json.Unmarshal(message, &ev); err != nil { 152 + handler.OnError(ctx, fmt.Errorf("failed to parse message: %w", err)) 153 + continue 154 + } 155 + if err := handler.OnEvent(ctx, ev); err != nil { 156 + handler.OnError(ctx, fmt.Errorf("failed to process event %d: %w", ev.ID, err)) 157 + continue 158 + } 159 + 160 + ack := map[string]any{ 161 + "type": "ack", 162 + "id": ev.ID, 163 + } 164 + if err := conn.WriteJSON(ack); err != nil { 165 + l.Warn("failed to send ack", "err", err) 166 + continue 167 + } 168 + } 169 + }
+62
tap/types.go
··· 1 + package tap 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + ) 9 + 10 + type EventType string 11 + 12 + const ( 13 + EvtRecord EventType = "record" 14 + EvtIdentity EventType = "identity" 15 + ) 16 + 17 + type Event struct { 18 + ID int64 `json:"id"` 19 + Type EventType `json:"type"` 20 + Record *RecordEventData `json:"record,omitempty"` 21 + Identity *IdentityEventData `json:"identity,omitempty"` 22 + } 23 + 24 + type RecordEventData struct { 25 + Live bool `json:"live"` 26 + Did syntax.DID `json:"did"` 27 + Rev string `json:"rev"` 28 + Collection syntax.NSID `json:"collection"` 29 + Rkey syntax.RecordKey `json:"rkey"` 30 + Action RecordAction `json:"action"` 31 + Record json.RawMessage `json:"record,omitempty"` 32 + CID *syntax.CID `json:"cid,omitempty"` 33 + } 34 + 35 + func (r *RecordEventData) AtUri() syntax.ATURI { 36 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, r.Collection, r.Rkey)) 37 + } 38 + 39 + type RecordAction string 40 + 41 + const ( 42 + RecordCreateAction RecordAction = "create" 43 + RecordUpdateAction RecordAction = "update" 44 + RecordDeleteAction RecordAction = "delete" 45 + ) 46 + 47 + type IdentityEventData struct { 48 + DID syntax.DID `json:"did"` 49 + Handle string `json:"handle"` 50 + IsActive bool `json:"is_active"` 51 + Status RepoStatus `json:"status"` 52 + } 53 + 54 + type RepoStatus string 55 + 56 + const ( 57 + RepoStatusActive RepoStatus = "active" 58 + RepoStatusTakendown RepoStatus = "takendown" 59 + RepoStatusSuspended RepoStatus = "suspended" 60 + RepoStatusDeactivated RepoStatus = "deactivated" 61 + RepoStatusDeleted RepoStatus = "deleted" 62 + )