-416
api/tangled/cbor_gen.go
-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
+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
-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
-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
-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
+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
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
+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
+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
+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
+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
-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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+1
-1
appview/state/state.go
+27
appview/validator/issue.go
+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
-1
cmd/cborgen/cborgen.go
+9
-9
flake.lock
+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
+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
+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
+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
-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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
-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
+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
+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
+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
-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
+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
+1
-1
spindle/models/pipeline_env.go
+51
spindle/models/secret_mask.go
+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
+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
+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
+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
+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
+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
+1
-1
spindle/xrpc/owner.go
+72
spindle/xrpc/pipeline_cancelPipeline.go
+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
+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
+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
+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
+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
+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
+
)