+1
.gitattributes
+1
.gitattributes
···
1
+
api/tangled/** linguist-generated -diff
+495
-20
api/tangled/cbor_gen.go
+495
-20
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
+
}
564
980
func (t *FeedReaction) MarshalCBOR(w io.Writer) error {
565
981
if t == nil {
566
982
_, err := w.Write(cbg.CborNull)
···
7934
8350
}
7935
8351
7936
8352
cw := cbg.NewCborWriter(w)
7937
-
fieldCount := 9
8353
+
fieldCount := 10
7938
8354
7939
8355
if t.Body == nil {
7940
8356
fieldCount--
7941
8357
}
7942
8358
7943
8359
if t.Mentions == nil {
8360
+
fieldCount--
8361
+
}
8362
+
8363
+
if t.Patch == nil {
7944
8364
fieldCount--
7945
8365
}
7946
8366
···
8008
8428
}
8009
8429
8010
8430
// t.Patch (string) (string)
8011
-
if len("patch") > 1000000 {
8012
-
return xerrors.Errorf("Value in field \"patch\" was too long")
8013
-
}
8431
+
if t.Patch != nil {
8014
8432
8015
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patch"))); err != nil {
8016
-
return err
8017
-
}
8018
-
if _, err := cw.WriteString(string("patch")); err != nil {
8019
-
return err
8020
-
}
8433
+
if len("patch") > 1000000 {
8434
+
return xerrors.Errorf("Value in field \"patch\" was too long")
8435
+
}
8021
8436
8022
-
if len(t.Patch) > 1000000 {
8023
-
return xerrors.Errorf("Value in field t.Patch was too long")
8024
-
}
8437
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patch"))); err != nil {
8438
+
return err
8439
+
}
8440
+
if _, err := cw.WriteString(string("patch")); err != nil {
8441
+
return err
8442
+
}
8443
+
8444
+
if t.Patch == nil {
8445
+
if _, err := cw.Write(cbg.CborNull); err != nil {
8446
+
return err
8447
+
}
8448
+
} else {
8449
+
if len(*t.Patch) > 1000000 {
8450
+
return xerrors.Errorf("Value in field t.Patch was too long")
8451
+
}
8025
8452
8026
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Patch))); err != nil {
8027
-
return err
8028
-
}
8029
-
if _, err := cw.WriteString(string(t.Patch)); err != nil {
8030
-
return err
8453
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Patch))); err != nil {
8454
+
return err
8455
+
}
8456
+
if _, err := cw.WriteString(string(*t.Patch)); err != nil {
8457
+
return err
8458
+
}
8459
+
}
8031
8460
}
8032
8461
8033
8462
// t.Title (string) (string)
···
8147
8576
return err
8148
8577
}
8149
8578
8579
+
// t.PatchBlob (util.LexBlob) (struct)
8580
+
if len("patchBlob") > 1000000 {
8581
+
return xerrors.Errorf("Value in field \"patchBlob\" was too long")
8582
+
}
8583
+
8584
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patchBlob"))); err != nil {
8585
+
return err
8586
+
}
8587
+
if _, err := cw.WriteString(string("patchBlob")); err != nil {
8588
+
return err
8589
+
}
8590
+
8591
+
if err := t.PatchBlob.MarshalCBOR(cw); err != nil {
8592
+
return err
8593
+
}
8594
+
8150
8595
// t.References ([]string) (slice)
8151
8596
if t.References != nil {
8152
8597
···
8262
8707
case "patch":
8263
8708
8264
8709
{
8265
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8710
+
b, err := cr.ReadByte()
8266
8711
if err != nil {
8267
8712
return err
8268
8713
}
8714
+
if b != cbg.CborNull[0] {
8715
+
if err := cr.UnreadByte(); err != nil {
8716
+
return err
8717
+
}
8269
8718
8270
-
t.Patch = string(sval)
8719
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8720
+
if err != nil {
8721
+
return err
8722
+
}
8723
+
8724
+
t.Patch = (*string)(&sval)
8725
+
}
8271
8726
}
8272
8727
// t.Title (string) (string)
8273
8728
case "title":
···
8370
8825
}
8371
8826
8372
8827
t.CreatedAt = string(sval)
8828
+
}
8829
+
// t.PatchBlob (util.LexBlob) (struct)
8830
+
case "patchBlob":
8831
+
8832
+
{
8833
+
8834
+
b, err := cr.ReadByte()
8835
+
if err != nil {
8836
+
return err
8837
+
}
8838
+
if b != cbg.CborNull[0] {
8839
+
if err := cr.UnreadByte(); err != nil {
8840
+
return err
8841
+
}
8842
+
t.PatchBlob = new(util.LexBlob)
8843
+
if err := t.PatchBlob.UnmarshalCBOR(cr); err != nil {
8844
+
return xerrors.Errorf("unmarshaling t.PatchBlob pointer: %w", err)
8845
+
}
8846
+
}
8847
+
8373
8848
}
8374
8849
// t.References ([]string) (slice)
8375
8850
case "references":
-39
api/tangled/gitkeepCommit.go
-39
api/tangled/gitkeepCommit.go
···
1
-
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
-
3
-
package tangled
4
-
5
-
// schema: sh.tangled.git.keepCommit
6
-
7
-
import (
8
-
"context"
9
-
10
-
"github.com/bluesky-social/indigo/lex/util"
11
-
)
12
-
13
-
const (
14
-
GitKeepCommitNSID = "sh.tangled.git.keepCommit"
15
-
)
16
-
17
-
// GitKeepCommit_Input is the input argument to a sh.tangled.git.keepCommit call.
18
-
type GitKeepCommit_Input struct {
19
-
// ref: ref to keep
20
-
Ref string `json:"ref" cborgen:"ref"`
21
-
// repo: AT-URI of the repository
22
-
Repo string `json:"repo" cborgen:"repo"`
23
-
}
24
-
25
-
// GitKeepCommit_Output is the output of a sh.tangled.git.keepCommit call.
26
-
type GitKeepCommit_Output struct {
27
-
// commitId: Keeped commit hash
28
-
CommitId string `json:"commitId" cborgen:"commitId"`
29
-
}
30
-
31
-
// GitKeepCommit calls the XRPC method "sh.tangled.git.keepCommit".
32
-
func GitKeepCommit(ctx context.Context, c util.LexClient, input *GitKeepCommit_Input) (*GitKeepCommit_Output, error) {
33
-
var out GitKeepCommit_Output
34
-
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.git.keepCommit", nil, input, &out); err != nil {
35
-
return nil, err
36
-
}
37
-
38
-
return &out, nil
39
-
}
+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
+
}
+12
-9
api/tangled/repopull.go
+12
-9
api/tangled/repopull.go
···
17
17
} //
18
18
// RECORDTYPE: RepoPull
19
19
type RepoPull struct {
20
-
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"`
21
-
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
-
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
-
Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"`
24
-
Patch string `json:"patch" cborgen:"patch"`
25
-
References []string `json:"references,omitempty" cborgen:"references,omitempty"`
26
-
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
27
-
Target *RepoPull_Target `json:"target" cborgen:"target"`
28
-
Title string `json:"title" cborgen:"title"`
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"`
21
+
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
+
Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"`
24
+
// patch: (deprecated) use patchBlob instead
25
+
Patch *string `json:"patch,omitempty" cborgen:"patch,omitempty"`
26
+
// patchBlob: patch content
27
+
PatchBlob *util.LexBlob `json:"patchBlob" cborgen:"patchBlob"`
28
+
References []string `json:"references,omitempty" cborgen:"references,omitempty"`
29
+
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
30
+
Target *RepoPull_Target `json:"target" cborgen:"target"`
31
+
Title string `json:"title" cborgen:"title"`
29
32
}
30
33
31
34
// RepoPull_Source is a "source" in the sh.tangled.repo.pull schema.
+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
+
}
+3
-2
appview/db/artifact.go
+3
-2
appview/db/artifact.go
···
8
8
"github.com/go-git/go-git/v5/plumbing"
9
9
"github.com/ipfs/go-cid"
10
10
"tangled.org/core/appview/models"
11
+
"tangled.org/core/orm"
11
12
)
12
13
13
14
func AddArtifact(e Execer, artifact models.Artifact) error {
···
37
38
return err
38
39
}
39
40
40
-
func GetArtifact(e Execer, filters ...filter) ([]models.Artifact, error) {
41
+
func GetArtifact(e Execer, filters ...orm.Filter) ([]models.Artifact, error) {
41
42
var artifacts []models.Artifact
42
43
43
44
var conditions []string
···
109
110
return artifacts, nil
110
111
}
111
112
112
-
func DeleteArtifact(e Execer, filters ...filter) error {
113
+
func DeleteArtifact(e Execer, filters ...orm.Filter) error {
113
114
var conditions []string
114
115
var args []any
115
116
for _, filter := range filters {
+4
-3
appview/db/collaborators.go
+4
-3
appview/db/collaborators.go
···
6
6
"time"
7
7
8
8
"tangled.org/core/appview/models"
9
+
"tangled.org/core/orm"
9
10
)
10
11
11
12
func AddCollaborator(e Execer, c models.Collaborator) error {
···
16
17
return err
17
18
}
18
19
19
-
func DeleteCollaborator(e Execer, filters ...filter) error {
20
+
func DeleteCollaborator(e Execer, filters ...orm.Filter) error {
20
21
var conditions []string
21
22
var args []any
22
23
for _, filter := range filters {
···
58
59
return nil, nil
59
60
}
60
61
61
-
return GetRepos(e, 0, FilterIn("at_uri", repoAts))
62
+
return GetRepos(e, 0, orm.FilterIn("at_uri", repoAts))
62
63
}
63
64
64
-
func GetCollaborators(e Execer, filters ...filter) ([]models.Collaborator, error) {
65
+
func GetCollaborators(e Execer, filters ...orm.Filter) ([]models.Collaborator, error) {
65
66
var collaborators []models.Collaborator
66
67
var conditions []string
67
68
var args []any
+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
+
}
+54
-135
appview/db/db.go
+54
-135
appview/db/db.go
···
3
3
import (
4
4
"context"
5
5
"database/sql"
6
-
"fmt"
7
6
"log/slog"
8
-
"reflect"
9
7
"strings"
10
8
11
9
_ "github.com/mattn/go-sqlite3"
12
10
"tangled.org/core/log"
11
+
"tangled.org/core/orm"
13
12
)
14
13
15
14
type DB struct {
···
584
583
}
585
584
586
585
// run migrations
587
-
runMigration(conn, logger, "add-description-to-repos", func(tx *sql.Tx) error {
586
+
orm.RunMigration(conn, logger, "add-description-to-repos", func(tx *sql.Tx) error {
588
587
tx.Exec(`
589
588
alter table repos add column description text check (length(description) <= 200);
590
589
`)
591
590
return nil
592
591
})
593
592
594
-
runMigration(conn, logger, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
593
+
orm.RunMigration(conn, logger, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
595
594
// add unconstrained column
596
595
_, err := tx.Exec(`
597
596
alter table public_keys
···
614
613
return nil
615
614
})
616
615
617
-
runMigration(conn, logger, "add-rkey-to-comments", func(tx *sql.Tx) error {
616
+
orm.RunMigration(conn, logger, "add-rkey-to-comments", func(tx *sql.Tx) error {
618
617
_, err := tx.Exec(`
619
618
alter table comments drop column comment_at;
620
619
alter table comments add column rkey text;
···
622
621
return err
623
622
})
624
623
625
-
runMigration(conn, logger, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
624
+
orm.RunMigration(conn, logger, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
626
625
_, err := tx.Exec(`
627
626
alter table comments add column deleted text; -- timestamp
628
627
alter table comments add column edited text; -- timestamp
···
630
629
return err
631
630
})
632
631
633
-
runMigration(conn, logger, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
632
+
orm.RunMigration(conn, logger, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
634
633
_, err := tx.Exec(`
635
634
alter table pulls add column source_branch text;
636
635
alter table pulls add column source_repo_at text;
···
639
638
return err
640
639
})
641
640
642
-
runMigration(conn, logger, "add-source-to-repos", func(tx *sql.Tx) error {
641
+
orm.RunMigration(conn, logger, "add-source-to-repos", func(tx *sql.Tx) error {
643
642
_, err := tx.Exec(`
644
643
alter table repos add column source text;
645
644
`)
···
651
650
//
652
651
// [0]: https://sqlite.org/pragma.html#pragma_foreign_keys
653
652
conn.ExecContext(ctx, "pragma foreign_keys = off;")
654
-
runMigration(conn, logger, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error {
653
+
orm.RunMigration(conn, logger, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error {
655
654
_, err := tx.Exec(`
656
655
create table pulls_new (
657
656
-- identifiers
···
708
707
})
709
708
conn.ExecContext(ctx, "pragma foreign_keys = on;")
710
709
711
-
runMigration(conn, logger, "add-spindle-to-repos", func(tx *sql.Tx) error {
710
+
orm.RunMigration(conn, logger, "add-spindle-to-repos", func(tx *sql.Tx) error {
712
711
tx.Exec(`
713
712
alter table repos add column spindle text;
714
713
`)
···
718
717
// drop all knot secrets, add unique constraint to knots
719
718
//
720
719
// knots will henceforth use service auth for signed requests
721
-
runMigration(conn, logger, "no-more-secrets", func(tx *sql.Tx) error {
720
+
orm.RunMigration(conn, logger, "no-more-secrets", func(tx *sql.Tx) error {
722
721
_, err := tx.Exec(`
723
722
create table registrations_new (
724
723
id integer primary key autoincrement,
···
741
740
})
742
741
743
742
// recreate and add rkey + created columns with default constraint
744
-
runMigration(conn, logger, "rework-collaborators-table", func(tx *sql.Tx) error {
743
+
orm.RunMigration(conn, logger, "rework-collaborators-table", func(tx *sql.Tx) error {
745
744
// create new table
746
745
// - repo_at instead of repo integer
747
746
// - rkey field
···
795
794
return err
796
795
})
797
796
798
-
runMigration(conn, logger, "add-rkey-to-issues", func(tx *sql.Tx) error {
797
+
orm.RunMigration(conn, logger, "add-rkey-to-issues", func(tx *sql.Tx) error {
799
798
_, err := tx.Exec(`
800
799
alter table issues add column rkey text not null default '';
801
800
···
807
806
})
808
807
809
808
// repurpose the read-only column to "needs-upgrade"
810
-
runMigration(conn, logger, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error {
809
+
orm.RunMigration(conn, logger, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error {
811
810
_, err := tx.Exec(`
812
811
alter table registrations rename column read_only to needs_upgrade;
813
812
`)
···
815
814
})
816
815
817
816
// require all knots to upgrade after the release of total xrpc
818
-
runMigration(conn, logger, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error {
817
+
orm.RunMigration(conn, logger, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error {
819
818
_, err := tx.Exec(`
820
819
update registrations set needs_upgrade = 1;
821
820
`)
···
823
822
})
824
823
825
824
// require all knots to upgrade after the release of total xrpc
826
-
runMigration(conn, logger, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error {
825
+
orm.RunMigration(conn, logger, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error {
827
826
_, err := tx.Exec(`
828
827
alter table spindles add column needs_upgrade integer not null default 0;
829
828
`)
···
841
840
//
842
841
// disable foreign-keys for the next migration
843
842
conn.ExecContext(ctx, "pragma foreign_keys = off;")
844
-
runMigration(conn, logger, "remove-issue-at-from-issues", func(tx *sql.Tx) error {
843
+
orm.RunMigration(conn, logger, "remove-issue-at-from-issues", func(tx *sql.Tx) error {
845
844
_, err := tx.Exec(`
846
845
create table if not exists issues_new (
847
846
-- identifiers
···
911
910
// - new columns
912
911
// * column "reply_to" which can be any other comment
913
912
// * column "at-uri" which is a generated column
914
-
runMigration(conn, logger, "rework-issue-comments", func(tx *sql.Tx) error {
913
+
orm.RunMigration(conn, logger, "rework-issue-comments", func(tx *sql.Tx) error {
915
914
_, err := tx.Exec(`
916
915
create table if not exists issue_comments (
917
916
-- identifiers
···
971
970
//
972
971
// disable foreign-keys for the next migration
973
972
conn.ExecContext(ctx, "pragma foreign_keys = off;")
974
-
runMigration(conn, logger, "add-at-uri-to-pulls", func(tx *sql.Tx) error {
973
+
orm.RunMigration(conn, logger, "add-at-uri-to-pulls", func(tx *sql.Tx) error {
975
974
_, err := tx.Exec(`
976
975
create table if not exists pulls_new (
977
976
-- identifiers
···
1052
1051
//
1053
1052
// disable foreign-keys for the next migration
1054
1053
conn.ExecContext(ctx, "pragma foreign_keys = off;")
1055
-
runMigration(conn, logger, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error {
1054
+
orm.RunMigration(conn, logger, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error {
1056
1055
_, err := tx.Exec(`
1057
1056
create table if not exists pull_submissions_new (
1058
1057
-- identifiers
···
1106
1105
1107
1106
// knots may report the combined patch for a comparison, we can store that on the appview side
1108
1107
// (but not on the pds record), because calculating the combined patch requires a git index
1109
-
runMigration(conn, logger, "add-combined-column-submissions", func(tx *sql.Tx) error {
1108
+
orm.RunMigration(conn, logger, "add-combined-column-submissions", func(tx *sql.Tx) error {
1110
1109
_, err := tx.Exec(`
1111
1110
alter table pull_submissions add column combined text;
1112
1111
`)
1113
1112
return err
1114
1113
})
1115
1114
1116
-
runMigration(conn, logger, "add-pronouns-profile", func(tx *sql.Tx) error {
1115
+
orm.RunMigration(conn, logger, "add-pronouns-profile", func(tx *sql.Tx) error {
1117
1116
_, err := tx.Exec(`
1118
1117
alter table profile add column pronouns text;
1119
1118
`)
1120
1119
return err
1121
1120
})
1122
1121
1123
-
runMigration(conn, logger, "add-meta-column-repos", func(tx *sql.Tx) error {
1122
+
orm.RunMigration(conn, logger, "add-meta-column-repos", func(tx *sql.Tx) error {
1124
1123
_, err := tx.Exec(`
1125
1124
alter table repos add column website text;
1126
1125
alter table repos add column topics text;
···
1128
1127
return err
1129
1128
})
1130
1129
1131
-
runMigration(conn, logger, "add-usermentioned-preference", func(tx *sql.Tx) error {
1130
+
orm.RunMigration(conn, logger, "add-usermentioned-preference", func(tx *sql.Tx) error {
1132
1131
_, err := tx.Exec(`
1133
1132
alter table notification_preferences add column user_mentioned integer not null default 1;
1134
1133
`)
···
1136
1135
})
1137
1136
1138
1137
// remove the foreign key constraints from stars.
1139
-
runMigration(conn, logger, "generalize-stars-subject", func(tx *sql.Tx) error {
1138
+
orm.RunMigration(conn, logger, "generalize-stars-subject", func(tx *sql.Tx) error {
1140
1139
_, err := tx.Exec(`
1141
1140
create table stars_new (
1142
1141
id integer primary key autoincrement,
···
1174
1173
return err
1175
1174
})
1176
1175
1177
-
return &DB{
1178
-
db,
1179
-
logger,
1180
-
}, nil
1181
-
}
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;
1182
1181
1183
-
type migrationFn = func(*sql.Tx) error
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,
1184
1188
1185
-
func runMigration(c *sql.Conn, logger *slog.Logger, name string, migrationFn migrationFn) error {
1186
-
logger = logger.With("migration", name)
1189
+
-- at identifiers
1190
+
subject_at text not null,
1191
+
reply_to text, -- at_uri of parent comment
1187
1192
1188
-
tx, err := c.BeginTx(context.Background(), nil)
1189
-
if err != nil {
1190
-
return err
1191
-
}
1192
-
defer tx.Rollback()
1193
+
pull_submission_id integer, -- dirty fix until we atprotate the pull-rounds
1193
1194
1194
-
var exists bool
1195
-
err = tx.QueryRow("select exists (select 1 from migrations where name = ?)", name).Scan(&exists)
1196
-
if err != nil {
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
+
`)
1197
1205
return err
1198
-
}
1206
+
})
1199
1207
1200
-
if !exists {
1201
-
// run migration
1202
-
err = migrationFn(tx)
1203
-
if err != nil {
1204
-
logger.Error("failed to run migration", "err", err)
1205
-
return err
1206
-
}
1207
-
1208
-
// mark migration as complete
1209
-
_, err = tx.Exec("insert into migrations (name) values (?)", name)
1210
-
if err != nil {
1211
-
logger.Error("failed to mark migration as complete", "err", err)
1212
-
return err
1213
-
}
1214
-
1215
-
// commit the transaction
1216
-
if err := tx.Commit(); err != nil {
1217
-
return err
1218
-
}
1219
-
1220
-
logger.Info("migration applied successfully")
1221
-
} else {
1222
-
logger.Warn("skipped migration, already applied")
1223
-
}
1224
-
1225
-
return nil
1208
+
return &DB{
1209
+
db,
1210
+
logger,
1211
+
}, nil
1226
1212
}
1227
1213
1228
1214
func (d *DB) Close() error {
1229
1215
return d.DB.Close()
1230
1216
}
1231
-
1232
-
type filter struct {
1233
-
key string
1234
-
arg any
1235
-
cmp string
1236
-
}
1237
-
1238
-
func newFilter(key, cmp string, arg any) filter {
1239
-
return filter{
1240
-
key: key,
1241
-
arg: arg,
1242
-
cmp: cmp,
1243
-
}
1244
-
}
1245
-
1246
-
func FilterEq(key string, arg any) filter { return newFilter(key, "=", arg) }
1247
-
func FilterNotEq(key string, arg any) filter { return newFilter(key, "<>", arg) }
1248
-
func FilterGte(key string, arg any) filter { return newFilter(key, ">=", arg) }
1249
-
func FilterLte(key string, arg any) filter { return newFilter(key, "<=", arg) }
1250
-
func FilterIs(key string, arg any) filter { return newFilter(key, "is", arg) }
1251
-
func FilterIsNot(key string, arg any) filter { return newFilter(key, "is not", arg) }
1252
-
func FilterIn(key string, arg any) filter { return newFilter(key, "in", arg) }
1253
-
func FilterLike(key string, arg any) filter { return newFilter(key, "like", arg) }
1254
-
func FilterNotLike(key string, arg any) filter { return newFilter(key, "not like", arg) }
1255
-
func FilterContains(key string, arg any) filter {
1256
-
return newFilter(key, "like", fmt.Sprintf("%%%v%%", arg))
1257
-
}
1258
-
1259
-
func (f filter) Condition() string {
1260
-
rv := reflect.ValueOf(f.arg)
1261
-
kind := rv.Kind()
1262
-
1263
-
// if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)`
1264
-
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
1265
-
if rv.Len() == 0 {
1266
-
// always false
1267
-
return "1 = 0"
1268
-
}
1269
-
1270
-
placeholders := make([]string, rv.Len())
1271
-
for i := range placeholders {
1272
-
placeholders[i] = "?"
1273
-
}
1274
-
1275
-
return fmt.Sprintf("%s %s (%s)", f.key, f.cmp, strings.Join(placeholders, ", "))
1276
-
}
1277
-
1278
-
return fmt.Sprintf("%s %s ?", f.key, f.cmp)
1279
-
}
1280
-
1281
-
func (f filter) Arg() []any {
1282
-
rv := reflect.ValueOf(f.arg)
1283
-
kind := rv.Kind()
1284
-
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
1285
-
if rv.Len() == 0 {
1286
-
return nil
1287
-
}
1288
-
1289
-
out := make([]any, rv.Len())
1290
-
for i := range rv.Len() {
1291
-
out[i] = rv.Index(i).Interface()
1292
-
}
1293
-
return out
1294
-
}
1295
-
1296
-
return []any{f.arg}
1297
-
}
+6
-3
appview/db/follow.go
+6
-3
appview/db/follow.go
···
7
7
"time"
8
8
9
9
"tangled.org/core/appview/models"
10
+
"tangled.org/core/orm"
10
11
)
11
12
12
13
func AddFollow(e Execer, follow *models.Follow) error {
···
134
135
return result, nil
135
136
}
136
137
137
-
func GetFollows(e Execer, limit int, filters ...filter) ([]models.Follow, error) {
138
+
func GetFollows(e Execer, limit int, filters ...orm.Filter) ([]models.Follow, error) {
138
139
var follows []models.Follow
139
140
140
141
var conditions []string
···
166
167
if err != nil {
167
168
return nil, err
168
169
}
170
+
defer rows.Close()
171
+
169
172
for rows.Next() {
170
173
var follow models.Follow
171
174
var followedAt string
···
191
194
}
192
195
193
196
func GetFollowers(e Execer, did string) ([]models.Follow, error) {
194
-
return GetFollows(e, 0, FilterEq("subject_did", did))
197
+
return GetFollows(e, 0, orm.FilterEq("subject_did", did))
195
198
}
196
199
197
200
func GetFollowing(e Execer, did string) ([]models.Follow, error) {
198
-
return GetFollows(e, 0, FilterEq("user_did", did))
201
+
return GetFollows(e, 0, orm.FilterEq("user_did", did))
199
202
}
200
203
201
204
func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) {
+23
-201
appview/db/issues.go
+23
-201
appview/db/issues.go
···
13
13
"tangled.org/core/api/tangled"
14
14
"tangled.org/core/appview/models"
15
15
"tangled.org/core/appview/pagination"
16
+
"tangled.org/core/orm"
16
17
)
17
18
18
19
func PutIssue(tx *sql.Tx, issue *models.Issue) error {
···
27
28
28
29
issues, err := GetIssues(
29
30
tx,
30
-
FilterEq("did", issue.Did),
31
-
FilterEq("rkey", issue.Rkey),
31
+
orm.FilterEq("did", issue.Did),
32
+
orm.FilterEq("rkey", issue.Rkey),
32
33
)
33
34
switch {
34
35
case err != nil:
···
98
99
return nil
99
100
}
100
101
101
-
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) {
102
-
issueMap := make(map[string]*models.Issue) // at-uri -> issue
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
104
104
105
var conditions []string
105
106
var args []any
···
114
115
whereClause = " where " + strings.Join(conditions, " and ")
115
116
}
116
117
117
-
pLower := FilterGte("row_num", page.Offset+1)
118
-
pUpper := FilterLte("row_num", page.Offset+page.Limit)
118
+
pLower := orm.FilterGte("row_num", page.Offset+1)
119
+
pUpper := orm.FilterLte("row_num", page.Offset+page.Limit)
119
120
120
121
pageClause := ""
121
122
if page.Limit > 0 {
···
195
196
}
196
197
}
197
198
198
-
atUri := issue.AtUri().String()
199
-
issueMap[atUri] = &issue
199
+
issueMap[issue.AtUri()] = &issue
200
200
}
201
201
202
202
// collect reverse repos
···
205
205
repoAts = append(repoAts, string(issue.RepoAt))
206
206
}
207
207
208
-
repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts))
208
+
repos, err := GetRepos(e, 0, orm.FilterIn("at_uri", repoAts))
209
209
if err != nil {
210
210
return nil, fmt.Errorf("failed to build repo mappings: %w", err)
211
211
}
···
228
228
// collect comments
229
229
issueAts := slices.Collect(maps.Keys(issueMap))
230
230
231
-
comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts))
231
+
comments, err := GetComments(e, orm.FilterIn("subject_at", issueAts))
232
232
if err != nil {
233
233
return nil, fmt.Errorf("failed to query comments: %w", err)
234
234
}
235
235
for i := range comments {
236
-
issueAt := comments[i].IssueAt
236
+
issueAt := comments[i].Subject
237
237
if issue, ok := issueMap[issueAt]; ok {
238
238
issue.Comments = append(issue.Comments, comments[i])
239
239
}
240
240
}
241
241
242
242
// collect allLabels for each issue
243
-
allLabels, err := GetLabels(e, FilterIn("subject", issueAts))
243
+
allLabels, err := GetLabels(e, orm.FilterIn("subject", issueAts))
244
244
if err != nil {
245
245
return nil, fmt.Errorf("failed to query labels: %w", err)
246
246
}
247
247
for issueAt, labels := range allLabels {
248
-
if issue, ok := issueMap[issueAt.String()]; ok {
248
+
if issue, ok := issueMap[issueAt]; ok {
249
249
issue.Labels = labels
250
250
}
251
251
}
252
252
253
253
// collect references for each issue
254
-
allReferencs, err := GetReferencesAll(e, FilterIn("from_at", issueAts))
254
+
allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", issueAts))
255
255
if err != nil {
256
256
return nil, fmt.Errorf("failed to query reference_links: %w", err)
257
257
}
258
258
for issueAt, references := range allReferencs {
259
-
if issue, ok := issueMap[issueAt.String()]; ok {
259
+
if issue, ok := issueMap[issueAt]; ok {
260
260
issue.References = references
261
261
}
262
262
}
···
277
277
issues, err := GetIssuesPaginated(
278
278
e,
279
279
pagination.Page{},
280
-
FilterEq("repo_at", repoAt),
281
-
FilterEq("issue_id", issueId),
280
+
orm.FilterEq("repo_at", repoAt),
281
+
orm.FilterEq("issue_id", issueId),
282
282
)
283
283
if err != nil {
284
284
return nil, err
···
290
290
return &issues[0], nil
291
291
}
292
292
293
-
func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) {
293
+
func GetIssues(e Execer, filters ...orm.Filter) ([]models.Issue, error) {
294
294
return GetIssuesPaginated(e, pagination.Page{}, filters...)
295
295
}
296
296
···
298
298
func GetIssueIDs(e Execer, opts models.IssueSearchOptions) ([]int64, error) {
299
299
var ids []int64
300
300
301
-
var filters []filter
301
+
var filters []orm.Filter
302
302
openValue := 0
303
303
if opts.IsOpen {
304
304
openValue = 1
305
305
}
306
-
filters = append(filters, FilterEq("open", openValue))
306
+
filters = append(filters, orm.FilterEq("open", openValue))
307
307
if opts.RepoAt != "" {
308
-
filters = append(filters, FilterEq("repo_at", opts.RepoAt))
308
+
filters = append(filters, orm.FilterEq("repo_at", opts.RepoAt))
309
309
}
310
310
311
311
var conditions []string
···
350
350
return ids, nil
351
351
}
352
352
353
-
func AddIssueComment(tx *sql.Tx, c models.IssueComment) (int64, error) {
354
-
result, err := tx.Exec(
355
-
`insert into issue_comments (
356
-
did,
357
-
rkey,
358
-
issue_at,
359
-
body,
360
-
reply_to,
361
-
created,
362
-
edited
363
-
)
364
-
values (?, ?, ?, ?, ?, ?, null)
365
-
on conflict(did, rkey) do update set
366
-
issue_at = excluded.issue_at,
367
-
body = excluded.body,
368
-
edited = case
369
-
when
370
-
issue_comments.issue_at != excluded.issue_at
371
-
or issue_comments.body != excluded.body
372
-
or issue_comments.reply_to != excluded.reply_to
373
-
then ?
374
-
else issue_comments.edited
375
-
end`,
376
-
c.Did,
377
-
c.Rkey,
378
-
c.IssueAt,
379
-
c.Body,
380
-
c.ReplyTo,
381
-
c.Created.Format(time.RFC3339),
382
-
time.Now().Format(time.RFC3339),
383
-
)
384
-
if err != nil {
385
-
return 0, err
386
-
}
387
-
388
-
id, err := result.LastInsertId()
389
-
if err != nil {
390
-
return 0, err
391
-
}
392
-
393
-
if err := putReferences(tx, c.AtUri(), c.References); err != nil {
394
-
return 0, fmt.Errorf("put reference_links: %w", err)
395
-
}
396
-
397
-
return id, nil
398
-
}
399
-
400
-
func DeleteIssueComments(e Execer, filters ...filter) error {
401
-
var conditions []string
402
-
var args []any
403
-
for _, filter := range filters {
404
-
conditions = append(conditions, filter.Condition())
405
-
args = append(args, filter.Arg()...)
406
-
}
407
-
408
-
whereClause := ""
409
-
if conditions != nil {
410
-
whereClause = " where " + strings.Join(conditions, " and ")
411
-
}
412
-
413
-
query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause)
414
-
415
-
_, err := e.Exec(query, args...)
416
-
return err
417
-
}
418
-
419
-
func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) {
420
-
commentMap := make(map[string]*models.IssueComment)
421
-
422
-
var conditions []string
423
-
var args []any
424
-
for _, filter := range filters {
425
-
conditions = append(conditions, filter.Condition())
426
-
args = append(args, filter.Arg()...)
427
-
}
428
-
429
-
whereClause := ""
430
-
if conditions != nil {
431
-
whereClause = " where " + strings.Join(conditions, " and ")
432
-
}
433
-
434
-
query := fmt.Sprintf(`
435
-
select
436
-
id,
437
-
did,
438
-
rkey,
439
-
issue_at,
440
-
reply_to,
441
-
body,
442
-
created,
443
-
edited,
444
-
deleted
445
-
from
446
-
issue_comments
447
-
%s
448
-
`, whereClause)
449
-
450
-
rows, err := e.Query(query, args...)
451
-
if err != nil {
452
-
return nil, err
453
-
}
454
-
455
-
for rows.Next() {
456
-
var comment models.IssueComment
457
-
var created string
458
-
var rkey, edited, deleted, replyTo sql.Null[string]
459
-
err := rows.Scan(
460
-
&comment.Id,
461
-
&comment.Did,
462
-
&rkey,
463
-
&comment.IssueAt,
464
-
&replyTo,
465
-
&comment.Body,
466
-
&created,
467
-
&edited,
468
-
&deleted,
469
-
)
470
-
if err != nil {
471
-
return nil, err
472
-
}
473
-
474
-
// this is a remnant from old times, newer comments always have rkey
475
-
if rkey.Valid {
476
-
comment.Rkey = rkey.V
477
-
}
478
-
479
-
if t, err := time.Parse(time.RFC3339, created); err == nil {
480
-
comment.Created = t
481
-
}
482
-
483
-
if edited.Valid {
484
-
if t, err := time.Parse(time.RFC3339, edited.V); err == nil {
485
-
comment.Edited = &t
486
-
}
487
-
}
488
-
489
-
if deleted.Valid {
490
-
if t, err := time.Parse(time.RFC3339, deleted.V); err == nil {
491
-
comment.Deleted = &t
492
-
}
493
-
}
494
-
495
-
if replyTo.Valid {
496
-
comment.ReplyTo = &replyTo.V
497
-
}
498
-
499
-
atUri := comment.AtUri().String()
500
-
commentMap[atUri] = &comment
501
-
}
502
-
503
-
if err = rows.Err(); err != nil {
504
-
return nil, err
505
-
}
506
-
507
-
// collect references for each comments
508
-
commentAts := slices.Collect(maps.Keys(commentMap))
509
-
allReferencs, err := GetReferencesAll(e, FilterIn("from_at", commentAts))
510
-
if err != nil {
511
-
return nil, fmt.Errorf("failed to query reference_links: %w", err)
512
-
}
513
-
for commentAt, references := range allReferencs {
514
-
if comment, ok := commentMap[commentAt.String()]; ok {
515
-
comment.References = references
516
-
}
517
-
}
518
-
519
-
var comments []models.IssueComment
520
-
for _, c := range commentMap {
521
-
comments = append(comments, *c)
522
-
}
523
-
524
-
sort.Slice(comments, func(i, j int) bool {
525
-
return comments[i].Created.After(comments[j].Created)
526
-
})
527
-
528
-
return comments, nil
529
-
}
530
-
531
353
func DeleteIssues(tx *sql.Tx, did, rkey string) error {
532
354
_, err := tx.Exec(
533
355
`delete from issues
···
548
370
return nil
549
371
}
550
372
551
-
func CloseIssues(e Execer, filters ...filter) error {
373
+
func CloseIssues(e Execer, filters ...orm.Filter) error {
552
374
var conditions []string
553
375
var args []any
554
376
for _, filter := range filters {
···
566
388
return err
567
389
}
568
390
569
-
func ReopenIssues(e Execer, filters ...filter) error {
391
+
func ReopenIssues(e Execer, filters ...orm.Filter) error {
570
392
var conditions []string
571
393
var args []any
572
394
for _, filter := range filters {
+8
-7
appview/db/label.go
+8
-7
appview/db/label.go
···
10
10
11
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
12
"tangled.org/core/appview/models"
13
+
"tangled.org/core/orm"
13
14
)
14
15
15
16
// no updating type for now
···
59
60
return id, nil
60
61
}
61
62
62
-
func DeleteLabelDefinition(e Execer, filters ...filter) error {
63
+
func DeleteLabelDefinition(e Execer, filters ...orm.Filter) error {
63
64
var conditions []string
64
65
var args []any
65
66
for _, filter := range filters {
···
75
76
return err
76
77
}
77
78
78
-
func GetLabelDefinitions(e Execer, filters ...filter) ([]models.LabelDefinition, error) {
79
+
func GetLabelDefinitions(e Execer, filters ...orm.Filter) ([]models.LabelDefinition, error) {
79
80
var labelDefinitions []models.LabelDefinition
80
81
var conditions []string
81
82
var args []any
···
167
168
}
168
169
169
170
// helper to get exactly one label def
170
-
func GetLabelDefinition(e Execer, filters ...filter) (*models.LabelDefinition, error) {
171
+
func GetLabelDefinition(e Execer, filters ...orm.Filter) (*models.LabelDefinition, error) {
171
172
labels, err := GetLabelDefinitions(e, filters...)
172
173
if err != nil {
173
174
return nil, err
···
227
228
return id, nil
228
229
}
229
230
230
-
func GetLabelOps(e Execer, filters ...filter) ([]models.LabelOp, error) {
231
+
func GetLabelOps(e Execer, filters ...orm.Filter) ([]models.LabelOp, error) {
231
232
var labelOps []models.LabelOp
232
233
var conditions []string
233
234
var args []any
···
302
303
}
303
304
304
305
// get labels for a given list of subject URIs
305
-
func GetLabels(e Execer, filters ...filter) (map[syntax.ATURI]models.LabelState, error) {
306
+
func GetLabels(e Execer, filters ...orm.Filter) (map[syntax.ATURI]models.LabelState, error) {
306
307
ops, err := GetLabelOps(e, filters...)
307
308
if err != nil {
308
309
return nil, err
···
322
323
}
323
324
labelAts := slices.Collect(maps.Keys(labelAtSet))
324
325
325
-
actx, err := NewLabelApplicationCtx(e, FilterIn("at_uri", labelAts))
326
+
actx, err := NewLabelApplicationCtx(e, orm.FilterIn("at_uri", labelAts))
326
327
if err != nil {
327
328
return nil, err
328
329
}
···
338
339
return results, nil
339
340
}
340
341
341
-
func NewLabelApplicationCtx(e Execer, filters ...filter) (*models.LabelApplicationCtx, error) {
342
+
func NewLabelApplicationCtx(e Execer, filters ...orm.Filter) (*models.LabelApplicationCtx, error) {
342
343
labels, err := GetLabelDefinitions(e, filters...)
343
344
if err != nil {
344
345
return nil, err
+6
-5
appview/db/language.go
+6
-5
appview/db/language.go
···
7
7
8
8
"github.com/bluesky-social/indigo/atproto/syntax"
9
9
"tangled.org/core/appview/models"
10
+
"tangled.org/core/orm"
10
11
)
11
12
12
-
func GetRepoLanguages(e Execer, filters ...filter) ([]models.RepoLanguage, error) {
13
+
func GetRepoLanguages(e Execer, filters ...orm.Filter) ([]models.RepoLanguage, error) {
13
14
var conditions []string
14
15
var args []any
15
16
for _, filter := range filters {
···
27
28
whereClause,
28
29
)
29
30
rows, err := e.Query(query, args...)
30
-
31
31
if err != nil {
32
32
return nil, fmt.Errorf("failed to execute query: %w ", err)
33
33
}
34
+
defer rows.Close()
34
35
35
36
var langs []models.RepoLanguage
36
37
for rows.Next() {
···
85
86
return nil
86
87
}
87
88
88
-
func DeleteRepoLanguages(e Execer, filters ...filter) error {
89
+
func DeleteRepoLanguages(e Execer, filters ...orm.Filter) error {
89
90
var conditions []string
90
91
var args []any
91
92
for _, filter := range filters {
···
107
108
func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error {
108
109
err := DeleteRepoLanguages(
109
110
tx,
110
-
FilterEq("repo_at", repoAt),
111
-
FilterEq("ref", ref),
111
+
orm.FilterEq("repo_at", repoAt),
112
+
orm.FilterEq("ref", ref),
112
113
)
113
114
if err != nil {
114
115
return fmt.Errorf("failed to delete existing languages: %w", err)
+14
-13
appview/db/notifications.go
+14
-13
appview/db/notifications.go
···
11
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
12
"tangled.org/core/appview/models"
13
13
"tangled.org/core/appview/pagination"
14
+
"tangled.org/core/orm"
14
15
)
15
16
16
17
func CreateNotification(e Execer, notification *models.Notification) error {
···
44
45
}
45
46
46
47
// GetNotificationsPaginated retrieves notifications with filters and pagination
47
-
func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...filter) ([]*models.Notification, error) {
48
+
func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]*models.Notification, error) {
48
49
var conditions []string
49
50
var args []any
50
51
···
113
114
}
114
115
115
116
// GetNotificationsWithEntities retrieves notifications with their related entities
116
-
func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...filter) ([]*models.NotificationWithEntity, error) {
117
+
func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...orm.Filter) ([]*models.NotificationWithEntity, error) {
117
118
var conditions []string
118
119
var args []any
119
120
···
256
257
}
257
258
258
259
// GetNotifications retrieves notifications with filters
259
-
func GetNotifications(e Execer, filters ...filter) ([]*models.Notification, error) {
260
+
func GetNotifications(e Execer, filters ...orm.Filter) ([]*models.Notification, error) {
260
261
return GetNotificationsPaginated(e, pagination.FirstPage(), filters...)
261
262
}
262
263
263
-
func CountNotifications(e Execer, filters ...filter) (int64, error) {
264
+
func CountNotifications(e Execer, filters ...orm.Filter) (int64, error) {
264
265
var conditions []string
265
266
var args []any
266
267
for _, filter := range filters {
···
285
286
}
286
287
287
288
func MarkNotificationRead(e Execer, notificationID int64, userDID string) error {
288
-
idFilter := FilterEq("id", notificationID)
289
-
recipientFilter := FilterEq("recipient_did", userDID)
289
+
idFilter := orm.FilterEq("id", notificationID)
290
+
recipientFilter := orm.FilterEq("recipient_did", userDID)
290
291
291
292
query := fmt.Sprintf(`
292
293
UPDATE notifications
···
314
315
}
315
316
316
317
func MarkAllNotificationsRead(e Execer, userDID string) error {
317
-
recipientFilter := FilterEq("recipient_did", userDID)
318
-
readFilter := FilterEq("read", 0)
318
+
recipientFilter := orm.FilterEq("recipient_did", userDID)
319
+
readFilter := orm.FilterEq("read", 0)
319
320
320
321
query := fmt.Sprintf(`
321
322
UPDATE notifications
···
334
335
}
335
336
336
337
func DeleteNotification(e Execer, notificationID int64, userDID string) error {
337
-
idFilter := FilterEq("id", notificationID)
338
-
recipientFilter := FilterEq("recipient_did", userDID)
338
+
idFilter := orm.FilterEq("id", notificationID)
339
+
recipientFilter := orm.FilterEq("recipient_did", userDID)
339
340
340
341
query := fmt.Sprintf(`
341
342
DELETE FROM notifications
···
362
363
}
363
364
364
365
func GetNotificationPreference(e Execer, userDid string) (*models.NotificationPreferences, error) {
365
-
prefs, err := GetNotificationPreferences(e, FilterEq("user_did", userDid))
366
+
prefs, err := GetNotificationPreferences(e, orm.FilterEq("user_did", userDid))
366
367
if err != nil {
367
368
return nil, err
368
369
}
···
375
376
return p, nil
376
377
}
377
378
378
-
func GetNotificationPreferences(e Execer, filters ...filter) (map[syntax.DID]*models.NotificationPreferences, error) {
379
+
func GetNotificationPreferences(e Execer, filters ...orm.Filter) (map[syntax.DID]*models.NotificationPreferences, error) {
379
380
prefsMap := make(map[syntax.DID]*models.NotificationPreferences)
380
381
381
382
var conditions []string
···
483
484
484
485
func (d *DB) ClearOldNotifications(ctx context.Context, olderThan time.Duration) error {
485
486
cutoff := time.Now().Add(-olderThan)
486
-
createdFilter := FilterLte("created", cutoff)
487
+
createdFilter := orm.FilterLte("created", cutoff)
487
488
488
489
query := fmt.Sprintf(`
489
490
DELETE FROM notifications
+12
-11
appview/db/pipeline.go
+12
-11
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"
11
+
"tangled.org/core/orm"
10
12
)
11
13
12
-
func GetPipelines(e Execer, filters ...filter) ([]models.Pipeline, error) {
14
+
func GetPipelines(e Execer, filters ...orm.Filter) ([]models.Pipeline, error) {
13
15
var pipelines []models.Pipeline
14
16
15
17
var conditions []string
···
168
170
169
171
// this is a mega query, but the most useful one:
170
172
// get N pipelines, for each one get the latest status of its N workflows
171
-
func GetPipelineStatuses(e Execer, limit int, filters ...filter) ([]models.Pipeline, error) {
173
+
func GetPipelineStatuses(e Execer, limit int, filters ...orm.Filter) ([]models.Pipeline, error) {
172
174
var conditions []string
173
175
var args []any
174
176
for _, filter := range filters {
175
-
filter.key = "p." + filter.key // the table is aliased in the query to `p`
177
+
filter.Key = "p." + filter.Key // the table is aliased in the query to `p`
176
178
conditions = append(conditions, filter.Condition())
177
179
args = append(args, filter.Arg()...)
178
180
}
···
215
217
}
216
218
defer rows.Close()
217
219
218
-
pipelines := make(map[string]models.Pipeline)
220
+
pipelines := make(map[syntax.ATURI]models.Pipeline)
219
221
for rows.Next() {
220
222
var p models.Pipeline
221
223
var t models.Trigger
···
252
254
p.Trigger = &t
253
255
p.Statuses = make(map[string]models.WorkflowStatus)
254
256
255
-
k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey)
256
-
pipelines[k] = p
257
+
pipelines[p.AtUri()] = p
257
258
}
258
259
259
260
// get all statuses
···
264
265
conditions = nil
265
266
args = nil
266
267
for _, p := range pipelines {
267
-
knotFilter := FilterEq("pipeline_knot", p.Knot)
268
-
rkeyFilter := FilterEq("pipeline_rkey", p.Rkey)
268
+
knotFilter := orm.FilterEq("pipeline_knot", p.Knot)
269
+
rkeyFilter := orm.FilterEq("pipeline_rkey", p.Rkey)
269
270
conditions = append(conditions, fmt.Sprintf("(%s and %s)", knotFilter.Condition(), rkeyFilter.Condition()))
270
271
args = append(args, p.Knot)
271
272
args = append(args, p.Rkey)
···
313
314
return nil, fmt.Errorf("invalid status created timestamp %q: %w", created, err)
314
315
}
315
316
316
-
key := fmt.Sprintf("%s/%s", ps.PipelineKnot, ps.PipelineRkey)
317
+
pipelineAt := ps.PipelineAt()
317
318
318
319
// extract
319
-
pipeline, ok := pipelines[key]
320
+
pipeline, ok := pipelines[pipelineAt]
320
321
if !ok {
321
322
continue
322
323
}
···
330
331
331
332
// reassign
332
333
pipeline.Statuses[ps.Workflow] = statuses
333
-
pipelines[key] = pipeline
334
+
pipelines[pipelineAt] = pipeline
334
335
}
335
336
336
337
var all []models.Pipeline
+11
-5
appview/db/profile.go
+11
-5
appview/db/profile.go
···
11
11
12
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
13
"tangled.org/core/appview/models"
14
+
"tangled.org/core/orm"
14
15
)
15
16
16
17
const TimeframeMonths = 7
···
44
45
45
46
issues, err := GetIssues(
46
47
e,
47
-
FilterEq("did", forDid),
48
-
FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)),
48
+
orm.FilterEq("did", forDid),
49
+
orm.FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)),
49
50
)
50
51
if err != nil {
51
52
return nil, fmt.Errorf("error getting issues by owner did: %w", err)
···
65
66
*items = append(*items, &issue)
66
67
}
67
68
68
-
repos, err := GetRepos(e, 0, FilterEq("did", forDid))
69
+
repos, err := GetRepos(e, 0, orm.FilterEq("did", forDid))
69
70
if err != nil {
70
71
return nil, fmt.Errorf("error getting all repos by did: %w", err)
71
72
}
···
199
200
return tx.Commit()
200
201
}
201
202
202
-
func GetProfiles(e Execer, filters ...filter) (map[string]*models.Profile, error) {
203
+
func GetProfiles(e Execer, filters ...orm.Filter) (map[string]*models.Profile, error) {
203
204
var conditions []string
204
205
var args []any
205
206
for _, filter := range filters {
···
229
230
if err != nil {
230
231
return nil, err
231
232
}
233
+
defer rows.Close()
232
234
233
235
profileMap := make(map[string]*models.Profile)
234
236
for rows.Next() {
···
269
271
if err != nil {
270
272
return nil, err
271
273
}
274
+
defer rows.Close()
275
+
272
276
idxs := make(map[string]int)
273
277
for did := range profileMap {
274
278
idxs[did] = 0
···
289
293
if err != nil {
290
294
return nil, err
291
295
}
296
+
defer rows.Close()
297
+
292
298
idxs = make(map[string]int)
293
299
for did := range profileMap {
294
300
idxs[did] = 0
···
441
447
}
442
448
443
449
// ensure all pinned repos are either own repos or collaborating repos
444
-
repos, err := GetRepos(e, 0, FilterEq("did", profile.Did))
450
+
repos, err := GetRepos(e, 0, orm.FilterEq("did", profile.Did))
445
451
if err != nil {
446
452
log.Printf("getting repos for %s: %s", profile.Did, err)
447
453
}
+24
-138
appview/db/pulls.go
+24
-138
appview/db/pulls.go
···
13
13
14
14
"github.com/bluesky-social/indigo/atproto/syntax"
15
15
"tangled.org/core/appview/models"
16
+
"tangled.org/core/orm"
16
17
)
17
18
18
19
func NewPull(tx *sql.Tx, pull *models.Pull) error {
···
118
119
return pullId - 1, err
119
120
}
120
121
121
-
func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) {
122
+
func GetPullsWithLimit(e Execer, limit int, filters ...orm.Filter) ([]*models.Pull, error) {
122
123
pulls := make(map[syntax.ATURI]*models.Pull)
123
124
124
125
var conditions []string
···
229
230
for _, p := range pulls {
230
231
pullAts = append(pullAts, p.AtUri())
231
232
}
232
-
submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts))
233
+
submissionsMap, err := GetPullSubmissions(e, orm.FilterIn("pull_at", pullAts))
233
234
if err != nil {
234
235
return nil, fmt.Errorf("failed to get submissions: %w", err)
235
236
}
···
241
242
}
242
243
243
244
// collect allLabels for each issue
244
-
allLabels, err := GetLabels(e, FilterIn("subject", pullAts))
245
+
allLabels, err := GetLabels(e, orm.FilterIn("subject", pullAts))
245
246
if err != nil {
246
247
return nil, fmt.Errorf("failed to query labels: %w", err)
247
248
}
···
258
259
sourceAts = append(sourceAts, *p.PullSource.RepoAt)
259
260
}
260
261
}
261
-
sourceRepos, err := GetRepos(e, 0, FilterIn("at_uri", sourceAts))
262
+
sourceRepos, err := GetRepos(e, 0, orm.FilterIn("at_uri", sourceAts))
262
263
if err != nil && !errors.Is(err, sql.ErrNoRows) {
263
264
return nil, fmt.Errorf("failed to get source repos: %w", err)
264
265
}
···
274
275
}
275
276
}
276
277
277
-
allReferences, err := GetReferencesAll(e, FilterIn("from_at", pullAts))
278
+
allReferences, err := GetReferencesAll(e, orm.FilterIn("from_at", pullAts))
278
279
if err != nil {
279
280
return nil, fmt.Errorf("failed to query reference_links: %w", err)
280
281
}
···
295
296
return orderedByPullId, nil
296
297
}
297
298
298
-
func GetPulls(e Execer, filters ...filter) ([]*models.Pull, error) {
299
+
func GetPulls(e Execer, filters ...orm.Filter) ([]*models.Pull, error) {
299
300
return GetPullsWithLimit(e, 0, filters...)
300
301
}
301
302
302
303
func GetPullIDs(e Execer, opts models.PullSearchOptions) ([]int64, error) {
303
304
var ids []int64
304
305
305
-
var filters []filter
306
-
filters = append(filters, FilterEq("state", opts.State))
306
+
var filters []orm.Filter
307
+
filters = append(filters, orm.FilterEq("state", opts.State))
307
308
if opts.RepoAt != "" {
308
-
filters = append(filters, FilterEq("repo_at", opts.RepoAt))
309
+
filters = append(filters, orm.FilterEq("repo_at", opts.RepoAt))
309
310
}
310
311
311
312
var conditions []string
···
361
362
}
362
363
363
364
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) {
364
-
pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId))
365
+
pulls, err := GetPullsWithLimit(e, 1, orm.FilterEq("repo_at", repoAt), orm.FilterEq("pull_id", pullId))
365
366
if err != nil {
366
367
return nil, err
367
368
}
···
373
374
}
374
375
375
376
// mapping from pull -> pull submissions
376
-
func GetPullSubmissions(e Execer, filters ...filter) (map[syntax.ATURI][]*models.PullSubmission, error) {
377
+
func GetPullSubmissions(e Execer, filters ...orm.Filter) (map[syntax.ATURI][]*models.PullSubmission, error) {
377
378
var conditions []string
378
379
var args []any
379
380
for _, filter := range filters {
···
446
447
return nil, err
447
448
}
448
449
449
-
// Get comments for all submissions using GetPullComments
450
+
// Get comments for all submissions using GetComments
450
451
submissionIds := slices.Collect(maps.Keys(submissionMap))
451
-
comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds))
452
+
comments, err := GetComments(e, orm.FilterIn("pull_submission_id", submissionIds))
452
453
if err != nil {
453
454
return nil, fmt.Errorf("failed to get pull comments: %w", err)
454
455
}
455
456
for _, comment := range comments {
456
-
if submission, ok := submissionMap[comment.SubmissionId]; ok {
457
-
submission.Comments = append(submission.Comments, comment)
457
+
if comment.PullSubmissionId != nil {
458
+
if submission, ok := submissionMap[*comment.PullSubmissionId]; ok {
459
+
submission.Comments = append(submission.Comments, comment)
460
+
}
458
461
}
459
462
}
460
463
···
474
477
return m, nil
475
478
}
476
479
477
-
func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) {
478
-
var conditions []string
479
-
var args []any
480
-
for _, filter := range filters {
481
-
conditions = append(conditions, filter.Condition())
482
-
args = append(args, filter.Arg()...)
483
-
}
484
-
485
-
whereClause := ""
486
-
if conditions != nil {
487
-
whereClause = " where " + strings.Join(conditions, " and ")
488
-
}
489
-
490
-
query := fmt.Sprintf(`
491
-
select
492
-
id,
493
-
pull_id,
494
-
submission_id,
495
-
repo_at,
496
-
owner_did,
497
-
comment_at,
498
-
body,
499
-
created
500
-
from
501
-
pull_comments
502
-
%s
503
-
order by
504
-
created asc
505
-
`, whereClause)
506
-
507
-
rows, err := e.Query(query, args...)
508
-
if err != nil {
509
-
return nil, err
510
-
}
511
-
defer rows.Close()
512
-
513
-
commentMap := make(map[string]*models.PullComment)
514
-
for rows.Next() {
515
-
var comment models.PullComment
516
-
var createdAt string
517
-
err := rows.Scan(
518
-
&comment.ID,
519
-
&comment.PullId,
520
-
&comment.SubmissionId,
521
-
&comment.RepoAt,
522
-
&comment.OwnerDid,
523
-
&comment.CommentAt,
524
-
&comment.Body,
525
-
&createdAt,
526
-
)
527
-
if err != nil {
528
-
return nil, err
529
-
}
530
-
531
-
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
532
-
comment.Created = t
533
-
}
534
-
535
-
atUri := comment.AtUri().String()
536
-
commentMap[atUri] = &comment
537
-
}
538
-
539
-
if err := rows.Err(); err != nil {
540
-
return nil, err
541
-
}
542
-
543
-
// collect references for each comments
544
-
commentAts := slices.Collect(maps.Keys(commentMap))
545
-
allReferencs, err := GetReferencesAll(e, FilterIn("from_at", commentAts))
546
-
if err != nil {
547
-
return nil, fmt.Errorf("failed to query reference_links: %w", err)
548
-
}
549
-
for commentAt, references := range allReferencs {
550
-
if comment, ok := commentMap[commentAt.String()]; ok {
551
-
comment.References = references
552
-
}
553
-
}
554
-
555
-
var comments []models.PullComment
556
-
for _, c := range commentMap {
557
-
comments = append(comments, *c)
558
-
}
559
-
560
-
sort.Slice(comments, func(i, j int) bool {
561
-
return comments[i].Created.Before(comments[j].Created)
562
-
})
563
-
564
-
return comments, nil
565
-
}
566
-
567
480
// timeframe here is directly passed into the sql query filter, and any
568
481
// timeframe in the past should be negative; e.g.: "-3 months"
569
482
func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]models.Pull, error) {
···
640
553
return pulls, nil
641
554
}
642
555
643
-
func NewPullComment(tx *sql.Tx, comment *models.PullComment) (int64, error) {
644
-
query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)`
645
-
res, err := tx.Exec(
646
-
query,
647
-
comment.OwnerDid,
648
-
comment.RepoAt,
649
-
comment.SubmissionId,
650
-
comment.CommentAt,
651
-
comment.PullId,
652
-
comment.Body,
653
-
)
654
-
if err != nil {
655
-
return 0, err
656
-
}
657
-
658
-
i, err := res.LastInsertId()
659
-
if err != nil {
660
-
return 0, err
661
-
}
662
-
663
-
if err := putReferences(tx, comment.AtUri(), comment.References); err != nil {
664
-
return 0, fmt.Errorf("put reference_links: %w", err)
665
-
}
666
-
667
-
return i, nil
668
-
}
669
-
670
556
func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState models.PullState) error {
671
557
_, err := e.Exec(
672
558
`update pulls set state = ? where repo_at = ? and pull_id = ? and (state <> ? or state <> ?)`,
···
708
594
return err
709
595
}
710
596
711
-
func SetPullParentChangeId(e Execer, parentChangeId string, filters ...filter) error {
597
+
func SetPullParentChangeId(e Execer, parentChangeId string, filters ...orm.Filter) error {
712
598
var conditions []string
713
599
var args []any
714
600
···
732
618
733
619
// Only used when stacking to update contents in the event of a rebase (the interdiff should be empty).
734
620
// otherwise submissions are immutable
735
-
func UpdatePull(e Execer, newPatch, sourceRev string, filters ...filter) error {
621
+
func UpdatePull(e Execer, newPatch, sourceRev string, filters ...orm.Filter) error {
736
622
var conditions []string
737
623
var args []any
738
624
···
790
676
func GetStack(e Execer, stackId string) (models.Stack, error) {
791
677
unorderedPulls, err := GetPulls(
792
678
e,
793
-
FilterEq("stack_id", stackId),
794
-
FilterNotEq("state", models.PullDeleted),
679
+
orm.FilterEq("stack_id", stackId),
680
+
orm.FilterNotEq("state", models.PullDeleted),
795
681
)
796
682
if err != nil {
797
683
return nil, err
···
835
721
func GetAbandonedPulls(e Execer, stackId string) ([]*models.Pull, error) {
836
722
pulls, err := GetPulls(
837
723
e,
838
-
FilterEq("stack_id", stackId),
839
-
FilterEq("state", models.PullDeleted),
724
+
orm.FilterEq("stack_id", stackId),
725
+
orm.FilterEq("state", models.PullDeleted),
840
726
)
841
727
if err != nil {
842
728
return nil, err
+2
-1
appview/db/punchcard.go
+2
-1
appview/db/punchcard.go
···
7
7
"time"
8
8
9
9
"tangled.org/core/appview/models"
10
+
"tangled.org/core/orm"
10
11
)
11
12
12
13
// this adds to the existing count
···
20
21
return err
21
22
}
22
23
23
-
func MakePunchcard(e Execer, filters ...filter) (*models.Punchcard, error) {
24
+
func MakePunchcard(e Execer, filters ...orm.Filter) (*models.Punchcard, error) {
24
25
punchcard := &models.Punchcard{}
25
26
now := time.Now()
26
27
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
+23
-34
appview/db/reference.go
+23
-34
appview/db/reference.go
···
8
8
"github.com/bluesky-social/indigo/atproto/syntax"
9
9
"tangled.org/core/api/tangled"
10
10
"tangled.org/core/appview/models"
11
+
"tangled.org/core/orm"
11
12
)
12
13
13
-
// ValidateReferenceLinks resolves refLinks to Issue/PR/IssueComment/PullComment ATURIs.
14
+
// ValidateReferenceLinks resolves refLinks to Issue/PR/Comment ATURIs.
14
15
// It will ignore missing refLinks.
15
16
func ValidateReferenceLinks(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) {
16
17
var (
···
52
53
values %s
53
54
)
54
55
select
55
-
i.did, i.rkey,
56
-
c.did, c.rkey
56
+
i.at_uri, c.at_uri
57
57
from input inp
58
58
join repos r
59
59
on r.did = inp.owner_did
···
61
61
join issues i
62
62
on i.repo_at = r.at_uri
63
63
and i.issue_id = inp.issue_id
64
-
left join issue_comments c
64
+
left join comments c
65
65
on inp.comment_id is not null
66
-
and c.issue_at = i.at_uri
66
+
and c.subject_at = i.at_uri
67
67
and c.id = inp.comment_id
68
68
`,
69
69
strings.Join(vals, ","),
···
78
78
79
79
for rows.Next() {
80
80
// Scan rows
81
-
var issueOwner, issueRkey string
82
-
var commentOwner, commentRkey sql.NullString
81
+
var issueUri string
82
+
var commentUri sql.NullString
83
83
var uri syntax.ATURI
84
-
if err := rows.Scan(&issueOwner, &issueRkey, &commentOwner, &commentRkey); err != nil {
84
+
if err := rows.Scan(&issueUri, &commentUri); err != nil {
85
85
return nil, err
86
86
}
87
-
if commentOwner.Valid && commentRkey.Valid {
88
-
uri = syntax.ATURI(fmt.Sprintf(
89
-
"at://%s/%s/%s",
90
-
commentOwner.String,
91
-
tangled.RepoIssueCommentNSID,
92
-
commentRkey.String,
93
-
))
87
+
if commentUri.Valid {
88
+
uri = syntax.ATURI(commentUri.String)
94
89
} else {
95
-
uri = syntax.ATURI(fmt.Sprintf(
96
-
"at://%s/%s/%s",
97
-
issueOwner,
98
-
tangled.RepoIssueNSID,
99
-
issueRkey,
100
-
))
90
+
uri = syntax.ATURI(issueUri)
101
91
}
102
92
uris = append(uris, uri)
103
93
}
···
123
113
values %s
124
114
)
125
115
select
126
-
p.owner_did, p.rkey,
127
-
c.comment_at
116
+
p.owner_did, p.rkey, c.at_uri
128
117
from input inp
129
118
join repos r
130
119
on r.did = inp.owner_did
···
132
121
join pulls p
133
122
on p.repo_at = r.at_uri
134
123
and p.pull_id = inp.pull_id
135
-
left join pull_comments c
124
+
left join comments c
136
125
on inp.comment_id is not null
137
-
and c.repo_at = r.at_uri and c.pull_id = p.pull_id
126
+
and c.subject_at = ('at://' || p.owner_did || '/' || 'sh.tangled.repo.pull' || '/' || p.rkey)
138
127
and c.id = inp.comment_id
139
128
`,
140
129
strings.Join(vals, ","),
···
205
194
return err
206
195
}
207
196
208
-
func GetReferencesAll(e Execer, filters ...filter) (map[syntax.ATURI][]syntax.ATURI, error) {
197
+
func GetReferencesAll(e Execer, filters ...orm.Filter) (map[syntax.ATURI][]syntax.ATURI, error) {
209
198
var (
210
199
conditions []string
211
200
args []any
···
282
271
return nil, fmt.Errorf("get issue backlinks: %w", err)
283
272
}
284
273
backlinks = append(backlinks, ls...)
285
-
ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.RepoIssueCommentNSID])
274
+
ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.CommentNSID])
286
275
if err != nil {
287
276
return nil, fmt.Errorf("get issue_comment backlinks: %w", err)
288
277
}
···
292
281
return nil, fmt.Errorf("get pull backlinks: %w", err)
293
282
}
294
283
backlinks = append(backlinks, ls...)
295
-
ls, err = getPullCommentBacklinks(e, backlinksMap[tangled.RepoPullCommentNSID])
284
+
ls, err = getPullCommentBacklinks(e, backlinksMap[tangled.CommentNSID])
296
285
if err != nil {
297
286
return nil, fmt.Errorf("get pull_comment backlinks: %w", err)
298
287
}
···
347
336
if len(aturis) == 0 {
348
337
return nil, nil
349
338
}
350
-
filter := FilterIn("c.at_uri", aturis)
339
+
filter := orm.FilterIn("c.at_uri", aturis)
351
340
rows, err := e.Query(
352
341
fmt.Sprintf(
353
342
`select r.did, r.name, i.issue_id, c.id, i.title, i.open
354
-
from issue_comments c
343
+
from comments c
355
344
join issues i
356
-
on i.at_uri = c.issue_at
345
+
on i.at_uri = c.subject_at
357
346
join repos r
358
347
on r.at_uri = i.repo_at
359
348
where %s`,
···
427
416
if len(aturis) == 0 {
428
417
return nil, nil
429
418
}
430
-
filter := FilterIn("c.comment_at", aturis)
419
+
filter := orm.FilterIn("c.at_uri", aturis)
431
420
rows, err := e.Query(
432
421
fmt.Sprintf(
433
422
`select r.did, r.name, p.pull_id, c.id, p.title, p.state
434
423
from repos r
435
424
join pulls p
436
425
on r.at_uri = p.repo_at
437
-
join pull_comments c
438
-
on r.at_uri = c.repo_at and p.pull_id = c.pull_id
426
+
join comments c
427
+
on ('at://' || p.owner_did || '/' || 'sh.tangled.repo.pull' || '/' || p.rkey) = c.subject_at
439
428
where %s`,
440
429
filter.Condition(),
441
430
),
+5
-3
appview/db/registration.go
+5
-3
appview/db/registration.go
···
7
7
"time"
8
8
9
9
"tangled.org/core/appview/models"
10
+
"tangled.org/core/orm"
10
11
)
11
12
12
-
func GetRegistrations(e Execer, filters ...filter) ([]models.Registration, error) {
13
+
func GetRegistrations(e Execer, filters ...orm.Filter) ([]models.Registration, error) {
13
14
var registrations []models.Registration
14
15
15
16
var conditions []string
···
37
38
if err != nil {
38
39
return nil, err
39
40
}
41
+
defer rows.Close()
40
42
41
43
for rows.Next() {
42
44
var createdAt string
···
69
71
return registrations, nil
70
72
}
71
73
72
-
func MarkRegistered(e Execer, filters ...filter) error {
74
+
func MarkRegistered(e Execer, filters ...orm.Filter) error {
73
75
var conditions []string
74
76
var args []any
75
77
for _, filter := range filters {
···
94
96
return err
95
97
}
96
98
97
-
func DeleteKnot(e Execer, filters ...filter) error {
99
+
func DeleteKnot(e Execer, filters ...orm.Filter) error {
98
100
var conditions []string
99
101
var args []any
100
102
for _, filter := range filters {
+18
-6
appview/db/repos.go
+18
-6
appview/db/repos.go
···
11
11
12
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
13
"tangled.org/core/appview/models"
14
+
"tangled.org/core/orm"
14
15
)
15
16
16
-
func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) {
17
+
func GetRepos(e Execer, limit int, filters ...orm.Filter) ([]models.Repo, error) {
17
18
repoMap := make(map[syntax.ATURI]*models.Repo)
18
19
19
20
var conditions []string
···
55
56
limitClause,
56
57
)
57
58
rows, err := e.Query(repoQuery, args...)
58
-
59
59
if err != nil {
60
60
return nil, fmt.Errorf("failed to execute repo query: %w ", err)
61
61
}
62
+
defer rows.Close()
62
63
63
64
for rows.Next() {
64
65
var repo models.Repo
···
127
128
if err != nil {
128
129
return nil, fmt.Errorf("failed to execute labels query: %w ", err)
129
130
}
131
+
defer rows.Close()
132
+
130
133
for rows.Next() {
131
134
var repoat, labelat string
132
135
if err := rows.Scan(&repoat, &labelat); err != nil {
···
155
158
from repo_languages
156
159
where repo_at in (%s)
157
160
and is_default_ref = 1
161
+
and language <> ''
158
162
)
159
163
where rn = 1
160
164
`,
···
164
168
if err != nil {
165
169
return nil, fmt.Errorf("failed to execute lang query: %w ", err)
166
170
}
171
+
defer rows.Close()
172
+
167
173
for rows.Next() {
168
174
var repoat, lang string
169
175
if err := rows.Scan(&repoat, &lang); err != nil {
···
190
196
if err != nil {
191
197
return nil, fmt.Errorf("failed to execute star-count query: %w ", err)
192
198
}
199
+
defer rows.Close()
200
+
193
201
for rows.Next() {
194
202
var repoat string
195
203
var count int
···
219
227
if err != nil {
220
228
return nil, fmt.Errorf("failed to execute issue-count query: %w ", err)
221
229
}
230
+
defer rows.Close()
231
+
222
232
for rows.Next() {
223
233
var repoat string
224
234
var open, closed int
···
260
270
if err != nil {
261
271
return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err)
262
272
}
273
+
defer rows.Close()
274
+
263
275
for rows.Next() {
264
276
var repoat string
265
277
var open, merged, closed, deleted int
···
294
306
}
295
307
296
308
// helper to get exactly one repo
297
-
func GetRepo(e Execer, filters ...filter) (*models.Repo, error) {
309
+
func GetRepo(e Execer, filters ...orm.Filter) (*models.Repo, error) {
298
310
repos, err := GetRepos(e, 0, filters...)
299
311
if err != nil {
300
312
return nil, err
···
311
323
return &repos[0], nil
312
324
}
313
325
314
-
func CountRepos(e Execer, filters ...filter) (int64, error) {
326
+
func CountRepos(e Execer, filters ...orm.Filter) (int64, error) {
315
327
var conditions []string
316
328
var args []any
317
329
for _, filter := range filters {
···
542
554
return err
543
555
}
544
556
545
-
func UnsubscribeLabel(e Execer, filters ...filter) error {
557
+
func UnsubscribeLabel(e Execer, filters ...orm.Filter) error {
546
558
var conditions []string
547
559
var args []any
548
560
for _, filter := range filters {
···
560
572
return err
561
573
}
562
574
563
-
func GetRepoLabels(e Execer, filters ...filter) ([]models.RepoLabel, error) {
575
+
func GetRepoLabels(e Execer, filters ...orm.Filter) ([]models.RepoLabel, error) {
564
576
var conditions []string
565
577
var args []any
566
578
for _, filter := range filters {
+6
-5
appview/db/spindle.go
+6
-5
appview/db/spindle.go
···
7
7
"time"
8
8
9
9
"tangled.org/core/appview/models"
10
+
"tangled.org/core/orm"
10
11
)
11
12
12
-
func GetSpindles(e Execer, filters ...filter) ([]models.Spindle, error) {
13
+
func GetSpindles(e Execer, filters ...orm.Filter) ([]models.Spindle, error) {
13
14
var spindles []models.Spindle
14
15
15
16
var conditions []string
···
91
92
return err
92
93
}
93
94
94
-
func VerifySpindle(e Execer, filters ...filter) (int64, error) {
95
+
func VerifySpindle(e Execer, filters ...orm.Filter) (int64, error) {
95
96
var conditions []string
96
97
var args []any
97
98
for _, filter := range filters {
···
114
115
return res.RowsAffected()
115
116
}
116
117
117
-
func DeleteSpindle(e Execer, filters ...filter) error {
118
+
func DeleteSpindle(e Execer, filters ...orm.Filter) error {
118
119
var conditions []string
119
120
var args []any
120
121
for _, filter := range filters {
···
144
145
return err
145
146
}
146
147
147
-
func RemoveSpindleMember(e Execer, filters ...filter) error {
148
+
func RemoveSpindleMember(e Execer, filters ...orm.Filter) error {
148
149
var conditions []string
149
150
var args []any
150
151
for _, filter := range filters {
···
163
164
return err
164
165
}
165
166
166
-
func GetSpindleMembers(e Execer, filters ...filter) ([]models.SpindleMember, error) {
167
+
func GetSpindleMembers(e Execer, filters ...orm.Filter) ([]models.SpindleMember, error) {
167
168
var members []models.SpindleMember
168
169
169
170
var conditions []string
+6
-4
appview/db/star.go
+6
-4
appview/db/star.go
···
11
11
12
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
13
"tangled.org/core/appview/models"
14
+
"tangled.org/core/orm"
14
15
)
15
16
16
17
func AddStar(e Execer, star *models.Star) error {
···
133
134
134
135
// GetRepoStars return a list of stars each holding target repository.
135
136
// If there isn't known repo with starred at-uri, those stars will be ignored.
136
-
func GetRepoStars(e Execer, limit int, filters ...filter) ([]models.RepoStar, error) {
137
+
func GetRepoStars(e Execer, limit int, filters ...orm.Filter) ([]models.RepoStar, error) {
137
138
var conditions []string
138
139
var args []any
139
140
for _, filter := range filters {
···
164
165
if err != nil {
165
166
return nil, err
166
167
}
168
+
defer rows.Close()
167
169
168
170
starMap := make(map[string][]models.Star)
169
171
for rows.Next() {
···
195
197
return nil, nil
196
198
}
197
199
198
-
repos, err := GetRepos(e, 0, FilterIn("at_uri", args))
200
+
repos, err := GetRepos(e, 0, orm.FilterIn("at_uri", args))
199
201
if err != nil {
200
202
return nil, err
201
203
}
···
225
227
return repoStars, nil
226
228
}
227
229
228
-
func CountStars(e Execer, filters ...filter) (int64, error) {
230
+
func CountStars(e Execer, filters ...orm.Filter) (int64, error) {
229
231
var conditions []string
230
232
var args []any
231
233
for _, filter := range filters {
···
298
300
}
299
301
300
302
// get full repo data
301
-
repos, err := GetRepos(e, 0, FilterIn("at_uri", repoUris))
303
+
repos, err := GetRepos(e, 0, orm.FilterIn("at_uri", repoUris))
302
304
if err != nil {
303
305
return nil, err
304
306
}
+4
-3
appview/db/strings.go
+4
-3
appview/db/strings.go
···
8
8
"time"
9
9
10
10
"tangled.org/core/appview/models"
11
+
"tangled.org/core/orm"
11
12
)
12
13
13
14
func AddString(e Execer, s models.String) error {
···
44
45
return err
45
46
}
46
47
47
-
func GetStrings(e Execer, limit int, filters ...filter) ([]models.String, error) {
48
+
func GetStrings(e Execer, limit int, filters ...orm.Filter) ([]models.String, error) {
48
49
var all []models.String
49
50
50
51
var conditions []string
···
127
128
return all, nil
128
129
}
129
130
130
-
func CountStrings(e Execer, filters ...filter) (int64, error) {
131
+
func CountStrings(e Execer, filters ...orm.Filter) (int64, error) {
131
132
var conditions []string
132
133
var args []any
133
134
for _, filter := range filters {
···
151
152
return count, nil
152
153
}
153
154
154
-
func DeleteString(e Execer, filters ...filter) error {
155
+
func DeleteString(e Execer, filters ...orm.Filter) error {
155
156
var conditions []string
156
157
var args []any
157
158
for _, filter := range filters {
+9
-8
appview/db/timeline.go
+9
-8
appview/db/timeline.go
···
5
5
6
6
"github.com/bluesky-social/indigo/atproto/syntax"
7
7
"tangled.org/core/appview/models"
8
+
"tangled.org/core/orm"
8
9
)
9
10
10
11
// TODO: this gathers heterogenous events from different sources and aggregates
···
84
85
}
85
86
86
87
func getTimelineRepos(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
87
-
filters := make([]filter, 0)
88
+
filters := make([]orm.Filter, 0)
88
89
if userIsFollowing != nil {
89
-
filters = append(filters, FilterIn("did", userIsFollowing))
90
+
filters = append(filters, orm.FilterIn("did", userIsFollowing))
90
91
}
91
92
92
93
repos, err := GetRepos(e, limit, filters...)
···
104
105
105
106
var origRepos []models.Repo
106
107
if args != nil {
107
-
origRepos, err = GetRepos(e, 0, FilterIn("at_uri", args))
108
+
origRepos, err = GetRepos(e, 0, orm.FilterIn("at_uri", args))
108
109
}
109
110
if err != nil {
110
111
return nil, err
···
144
145
}
145
146
146
147
func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
147
-
filters := make([]filter, 0)
148
+
filters := make([]orm.Filter, 0)
148
149
if userIsFollowing != nil {
149
-
filters = append(filters, FilterIn("did", userIsFollowing))
150
+
filters = append(filters, orm.FilterIn("did", userIsFollowing))
150
151
}
151
152
152
153
stars, err := GetRepoStars(e, limit, filters...)
···
180
181
}
181
182
182
183
func getTimelineFollows(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
183
-
filters := make([]filter, 0)
184
+
filters := make([]orm.Filter, 0)
184
185
if userIsFollowing != nil {
185
-
filters = append(filters, FilterIn("user_did", userIsFollowing))
186
+
filters = append(filters, orm.FilterIn("user_did", userIsFollowing))
186
187
}
187
188
188
189
follows, err := GetFollows(e, limit, filters...)
···
199
200
return nil, nil
200
201
}
201
202
202
-
profiles, err := GetProfiles(e, FilterIn("did", subjects))
203
+
profiles, err := GetProfiles(e, orm.FilterIn("did", subjects))
203
204
if err != nil {
204
205
return nil, err
205
206
}
+44
-35
appview/ingester.go
+44
-35
appview/ingester.go
···
21
21
"tangled.org/core/appview/serververify"
22
22
"tangled.org/core/appview/validator"
23
23
"tangled.org/core/idresolver"
24
+
"tangled.org/core/orm"
24
25
"tangled.org/core/rbac"
25
26
)
26
27
···
78
79
err = i.ingestString(e)
79
80
case tangled.RepoIssueNSID:
80
81
err = i.ingestIssue(ctx, e)
81
-
case tangled.RepoIssueCommentNSID:
82
-
err = i.ingestIssueComment(e)
82
+
case tangled.CommentNSID:
83
+
err = i.ingestComment(e)
83
84
case tangled.LabelDefinitionNSID:
84
85
err = i.ingestLabelDefinition(e)
85
86
case tangled.LabelOpNSID:
···
253
254
254
255
err = db.AddArtifact(i.Db, artifact)
255
256
case jmodels.CommitOperationDelete:
256
-
err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
257
+
err = db.DeleteArtifact(i.Db, orm.FilterEq("did", did), orm.FilterEq("rkey", e.Commit.RKey))
257
258
}
258
259
259
260
if err != nil {
···
350
351
351
352
err = db.UpsertProfile(tx, &profile)
352
353
case jmodels.CommitOperationDelete:
353
-
err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
354
+
err = db.DeleteArtifact(i.Db, orm.FilterEq("did", did), orm.FilterEq("rkey", e.Commit.RKey))
354
355
}
355
356
356
357
if err != nil {
···
424
425
// get record from db first
425
426
members, err := db.GetSpindleMembers(
426
427
ddb,
427
-
db.FilterEq("did", did),
428
-
db.FilterEq("rkey", rkey),
428
+
orm.FilterEq("did", did),
429
+
orm.FilterEq("rkey", rkey),
429
430
)
430
431
if err != nil || len(members) != 1 {
431
432
return fmt.Errorf("failed to get member: %w, len(members) = %d", err, len(members))
···
440
441
// remove record by rkey && update enforcer
441
442
if err = db.RemoveSpindleMember(
442
443
tx,
443
-
db.FilterEq("did", did),
444
-
db.FilterEq("rkey", rkey),
444
+
orm.FilterEq("did", did),
445
+
orm.FilterEq("rkey", rkey),
445
446
); err != nil {
446
447
return fmt.Errorf("failed to remove from db: %w", err)
447
448
}
···
523
524
// get record from db first
524
525
spindles, err := db.GetSpindles(
525
526
ddb,
526
-
db.FilterEq("owner", did),
527
-
db.FilterEq("instance", instance),
527
+
orm.FilterEq("owner", did),
528
+
orm.FilterEq("instance", instance),
528
529
)
529
530
if err != nil || len(spindles) != 1 {
530
531
return fmt.Errorf("failed to get spindles: %w, len(spindles) = %d", err, len(spindles))
···
543
544
// remove spindle members first
544
545
err = db.RemoveSpindleMember(
545
546
tx,
546
-
db.FilterEq("owner", did),
547
-
db.FilterEq("instance", instance),
547
+
orm.FilterEq("owner", did),
548
+
orm.FilterEq("instance", instance),
548
549
)
549
550
if err != nil {
550
551
return err
···
552
553
553
554
err = db.DeleteSpindle(
554
555
tx,
555
-
db.FilterEq("owner", did),
556
-
db.FilterEq("instance", instance),
556
+
orm.FilterEq("owner", did),
557
+
orm.FilterEq("instance", instance),
557
558
)
558
559
if err != nil {
559
560
return err
···
621
622
case jmodels.CommitOperationDelete:
622
623
if err := db.DeleteString(
623
624
ddb,
624
-
db.FilterEq("did", did),
625
-
db.FilterEq("rkey", rkey),
625
+
orm.FilterEq("did", did),
626
+
orm.FilterEq("rkey", rkey),
626
627
); err != nil {
627
628
l.Error("failed to delete", "err", err)
628
629
return fmt.Errorf("failed to delete string record: %w", err)
···
740
741
// get record from db first
741
742
registrations, err := db.GetRegistrations(
742
743
ddb,
743
-
db.FilterEq("domain", domain),
744
-
db.FilterEq("did", did),
744
+
orm.FilterEq("domain", domain),
745
+
orm.FilterEq("did", did),
745
746
)
746
747
if err != nil {
747
748
return fmt.Errorf("failed to get registration: %w", err)
···
762
763
763
764
err = db.DeleteKnot(
764
765
tx,
765
-
db.FilterEq("did", did),
766
-
db.FilterEq("domain", domain),
766
+
orm.FilterEq("did", did),
767
+
orm.FilterEq("domain", domain),
767
768
)
768
769
if err != nil {
769
770
return err
···
867
868
return nil
868
869
}
869
870
870
-
func (i *Ingester) ingestIssueComment(e *jmodels.Event) error {
871
+
func (i *Ingester) ingestComment(e *jmodels.Event) error {
871
872
did := e.Did
872
873
rkey := e.Commit.RKey
873
874
874
875
var err error
875
876
876
-
l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
877
+
l := i.Logger.With("handler", "ingestComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
877
878
l.Info("ingesting record")
878
879
879
880
ddb, ok := i.Db.Execer.(*db.DB)
···
884
885
switch e.Commit.Operation {
885
886
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
886
887
raw := json.RawMessage(e.Commit.Record)
887
-
record := tangled.RepoIssueComment{}
888
+
record := tangled.Comment{}
888
889
err = json.Unmarshal(raw, &record)
889
890
if err != nil {
890
891
return fmt.Errorf("invalid record: %w", err)
891
892
}
892
893
893
-
comment, err := models.IssueCommentFromRecord(did, rkey, record)
894
+
comment, err := models.CommentFromRecord(did, rkey, record)
894
895
if err != nil {
895
896
return fmt.Errorf("failed to parse comment from record: %w", err)
896
897
}
897
898
898
-
if err := i.Validator.ValidateIssueComment(comment); err != nil {
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
908
return fmt.Errorf("failed to validate comment: %w", err)
900
909
}
901
910
···
905
914
}
906
915
defer tx.Rollback()
907
916
908
-
_, err = db.AddIssueComment(tx, *comment)
917
+
err = db.PutComment(tx, comment)
909
918
if err != nil {
910
-
return fmt.Errorf("failed to create issue comment: %w", err)
919
+
return fmt.Errorf("failed to create comment: %w", err)
911
920
}
912
921
913
922
return tx.Commit()
914
923
915
924
case jmodels.CommitOperationDelete:
916
-
if err := db.DeleteIssueComments(
925
+
if err := db.DeleteComments(
917
926
ddb,
918
-
db.FilterEq("did", did),
919
-
db.FilterEq("rkey", rkey),
927
+
orm.FilterEq("did", did),
928
+
orm.FilterEq("rkey", rkey),
920
929
); err != nil {
921
-
return fmt.Errorf("failed to delete issue comment record: %w", err)
930
+
return fmt.Errorf("failed to delete comment record: %w", err)
922
931
}
923
932
924
933
return nil
···
969
978
case jmodels.CommitOperationDelete:
970
979
if err := db.DeleteLabelDefinition(
971
980
ddb,
972
-
db.FilterEq("did", did),
973
-
db.FilterEq("rkey", rkey),
981
+
orm.FilterEq("did", did),
982
+
orm.FilterEq("rkey", rkey),
974
983
); err != nil {
975
984
return fmt.Errorf("failed to delete labeldef record: %w", err)
976
985
}
···
1010
1019
var repo *models.Repo
1011
1020
switch collection {
1012
1021
case tangled.RepoIssueNSID:
1013
-
i, err := db.GetIssues(ddb, db.FilterEq("at_uri", subject))
1022
+
i, err := db.GetIssues(ddb, orm.FilterEq("at_uri", subject))
1014
1023
if err != nil || len(i) != 1 {
1015
1024
return fmt.Errorf("failed to find subject: %w || subject count %d", err, len(i))
1016
1025
}
···
1019
1028
return fmt.Errorf("unsupport label subject: %s", collection)
1020
1029
}
1021
1030
1022
-
actx, err := db.NewLabelApplicationCtx(ddb, db.FilterIn("at_uri", repo.Labels))
1031
+
actx, err := db.NewLabelApplicationCtx(ddb, orm.FilterIn("at_uri", repo.Labels))
1023
1032
if err != nil {
1024
1033
return fmt.Errorf("failed to build label application ctx: %w", err)
1025
1034
}
+76
-73
appview/issues/issues.go
+76
-73
appview/issues/issues.go
···
19
19
"tangled.org/core/appview/config"
20
20
"tangled.org/core/appview/db"
21
21
issues_indexer "tangled.org/core/appview/indexer/issues"
22
+
"tangled.org/core/appview/mentions"
22
23
"tangled.org/core/appview/models"
23
24
"tangled.org/core/appview/notify"
24
25
"tangled.org/core/appview/oauth"
25
26
"tangled.org/core/appview/pages"
26
27
"tangled.org/core/appview/pages/repoinfo"
27
28
"tangled.org/core/appview/pagination"
28
-
"tangled.org/core/appview/refresolver"
29
29
"tangled.org/core/appview/reporesolver"
30
30
"tangled.org/core/appview/validator"
31
31
"tangled.org/core/idresolver"
32
+
"tangled.org/core/orm"
32
33
"tangled.org/core/rbac"
33
34
"tangled.org/core/tid"
34
35
)
35
36
36
37
type Issues struct {
37
-
oauth *oauth.OAuth
38
-
repoResolver *reporesolver.RepoResolver
39
-
enforcer *rbac.Enforcer
40
-
pages *pages.Pages
41
-
idResolver *idresolver.Resolver
42
-
refResolver *refresolver.Resolver
43
-
db *db.DB
44
-
config *config.Config
45
-
notifier notify.Notifier
46
-
logger *slog.Logger
47
-
validator *validator.Validator
48
-
indexer *issues_indexer.Indexer
38
+
oauth *oauth.OAuth
39
+
repoResolver *reporesolver.RepoResolver
40
+
enforcer *rbac.Enforcer
41
+
pages *pages.Pages
42
+
idResolver *idresolver.Resolver
43
+
mentionsResolver *mentions.Resolver
44
+
db *db.DB
45
+
config *config.Config
46
+
notifier notify.Notifier
47
+
logger *slog.Logger
48
+
validator *validator.Validator
49
+
indexer *issues_indexer.Indexer
49
50
}
50
51
51
52
func New(
···
54
55
enforcer *rbac.Enforcer,
55
56
pages *pages.Pages,
56
57
idResolver *idresolver.Resolver,
57
-
refResolver *refresolver.Resolver,
58
+
mentionsResolver *mentions.Resolver,
58
59
db *db.DB,
59
60
config *config.Config,
60
61
notifier notify.Notifier,
···
63
64
logger *slog.Logger,
64
65
) *Issues {
65
66
return &Issues{
66
-
oauth: oauth,
67
-
repoResolver: repoResolver,
68
-
enforcer: enforcer,
69
-
pages: pages,
70
-
idResolver: idResolver,
71
-
refResolver: refResolver,
72
-
db: db,
73
-
config: config,
74
-
notifier: notifier,
75
-
logger: logger,
76
-
validator: validator,
77
-
indexer: indexer,
67
+
oauth: oauth,
68
+
repoResolver: repoResolver,
69
+
enforcer: enforcer,
70
+
pages: pages,
71
+
idResolver: idResolver,
72
+
mentionsResolver: mentionsResolver,
73
+
db: db,
74
+
config: config,
75
+
notifier: notifier,
76
+
logger: logger,
77
+
validator: validator,
78
+
indexer: indexer,
78
79
}
79
80
}
80
81
···
113
114
114
115
labelDefs, err := db.GetLabelDefinitions(
115
116
rp.db,
116
-
db.FilterIn("at_uri", f.Labels),
117
-
db.FilterContains("scope", tangled.RepoIssueNSID),
117
+
orm.FilterIn("at_uri", f.Labels),
118
+
orm.FilterContains("scope", tangled.RepoIssueNSID),
118
119
)
119
120
if err != nil {
120
121
l.Error("failed to fetch labels", "err", err)
···
163
164
newIssue := issue
164
165
newIssue.Title = r.FormValue("title")
165
166
newIssue.Body = r.FormValue("body")
166
-
newIssue.Mentions, newIssue.References = rp.refResolver.Resolve(r.Context(), newIssue.Body)
167
+
newIssue.Mentions, newIssue.References = rp.mentionsResolver.Resolve(r.Context(), newIssue.Body)
167
168
168
169
if err := rp.validator.ValidateIssue(newIssue); err != nil {
169
170
l.Error("validation error", "err", err)
···
314
315
if isIssueOwner || isRepoOwner || isCollaborator {
315
316
err = db.CloseIssues(
316
317
rp.db,
317
-
db.FilterEq("id", issue.Id),
318
+
orm.FilterEq("id", issue.Id),
318
319
)
319
320
if err != nil {
320
321
l.Error("failed to close issue", "err", err)
···
361
362
if isCollaborator || isRepoOwner || isIssueOwner {
362
363
err := db.ReopenIssues(
363
364
rp.db,
364
-
db.FilterEq("id", issue.Id),
365
+
orm.FilterEq("id", issue.Id),
365
366
)
366
367
if err != nil {
367
368
l.Error("failed to reopen issue", "err", err)
···
402
403
403
404
body := r.FormValue("body")
404
405
if body == "" {
405
-
rp.pages.Notice(w, "issue", "Body is required")
406
+
rp.pages.Notice(w, "issue-comment", "Body is required")
406
407
return
407
408
}
408
409
409
-
replyToUri := r.FormValue("reply-to")
410
-
var replyTo *string
411
-
if replyToUri != "" {
412
-
replyTo = &replyToUri
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
413
419
}
414
420
415
-
mentions, references := rp.refResolver.Resolve(r.Context(), body)
421
+
mentions, references := rp.mentionsResolver.Resolve(r.Context(), body)
416
422
417
-
comment := models.IssueComment{
418
-
Did: user.Did,
423
+
comment := models.Comment{
424
+
Did: syntax.DID(user.Did),
419
425
Rkey: tid.TID(),
420
-
IssueAt: issue.AtUri().String(),
426
+
Subject: issue.AtUri(),
421
427
ReplyTo: replyTo,
422
428
Body: body,
423
429
Created: time.Now(),
424
430
Mentions: mentions,
425
431
References: references,
426
432
}
427
-
if err = rp.validator.ValidateIssueComment(&comment); err != nil {
433
+
if err = comment.Validate(); err != nil {
428
434
l.Error("failed to validate comment", "err", err)
429
435
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
430
436
return
···
440
446
441
447
// create a record first
442
448
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
443
-
Collection: tangled.RepoIssueCommentNSID,
444
-
Repo: comment.Did,
449
+
Collection: tangled.CommentNSID,
450
+
Repo: user.Did,
445
451
Rkey: comment.Rkey,
446
452
Record: &lexutil.LexiconTypeDecoder{
447
453
Val: &record,
···
467
473
}
468
474
defer tx.Rollback()
469
475
470
-
commentId, err := db.AddIssueComment(tx, comment)
476
+
err = db.PutComment(tx, &comment)
471
477
if err != nil {
472
478
l.Error("failed to create comment", "err", err)
473
479
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
···
483
489
// reset atUri to make rollback a no-op
484
490
atUri = ""
485
491
486
-
// notify about the new comment
487
-
comment.Id = commentId
488
-
489
-
rp.notifier.NewIssueComment(r.Context(), &comment, mentions)
492
+
rp.notifier.NewComment(r.Context(), &comment)
490
493
491
494
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
492
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId))
495
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, comment.Id))
493
496
}
494
497
495
498
func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) {
···
504
507
}
505
508
506
509
commentId := chi.URLParam(r, "commentId")
507
-
comments, err := db.GetIssueComments(
510
+
comments, err := db.GetComments(
508
511
rp.db,
509
-
db.FilterEq("id", commentId),
512
+
orm.FilterEq("id", commentId),
510
513
)
511
514
if err != nil {
512
515
l.Error("failed to fetch comment", "id", commentId)
···
540
543
}
541
544
542
545
commentId := chi.URLParam(r, "commentId")
543
-
comments, err := db.GetIssueComments(
546
+
comments, err := db.GetComments(
544
547
rp.db,
545
-
db.FilterEq("id", commentId),
548
+
orm.FilterEq("id", commentId),
546
549
)
547
550
if err != nil {
548
551
l.Error("failed to fetch comment", "id", commentId)
···
556
559
}
557
560
comment := comments[0]
558
561
559
-
if comment.Did != user.Did {
562
+
if comment.Did.String() != user.Did {
560
563
l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did)
561
564
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
562
565
return
···
584
587
newComment := comment
585
588
newComment.Body = newBody
586
589
newComment.Edited = &now
587
-
newComment.Mentions, newComment.References = rp.refResolver.Resolve(r.Context(), newBody)
590
+
newComment.Mentions, newComment.References = rp.mentionsResolver.Resolve(r.Context(), newBody)
588
591
589
592
record := newComment.AsRecord()
590
593
···
596
599
}
597
600
defer tx.Rollback()
598
601
599
-
_, err = db.AddIssueComment(tx, newComment)
602
+
err = db.PutComment(tx, &newComment)
600
603
if err != nil {
601
604
l.Error("failed to perferom update-description query", "err", err)
602
605
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
···
607
610
// rkey is optional, it was introduced later
608
611
if newComment.Rkey != "" {
609
612
// update the record on pds
610
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey)
613
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.CommentNSID, user.Did, comment.Rkey)
611
614
if err != nil {
612
615
l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
613
616
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
···
615
618
}
616
619
617
620
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
618
-
Collection: tangled.RepoIssueCommentNSID,
621
+
Collection: tangled.CommentNSID,
619
622
Repo: user.Did,
620
623
Rkey: newComment.Rkey,
621
624
SwapRecord: ex.Cid,
···
650
653
}
651
654
652
655
commentId := chi.URLParam(r, "commentId")
653
-
comments, err := db.GetIssueComments(
656
+
comments, err := db.GetComments(
654
657
rp.db,
655
-
db.FilterEq("id", commentId),
658
+
orm.FilterEq("id", commentId),
656
659
)
657
660
if err != nil {
658
661
l.Error("failed to fetch comment", "id", commentId)
···
686
689
}
687
690
688
691
commentId := chi.URLParam(r, "commentId")
689
-
comments, err := db.GetIssueComments(
692
+
comments, err := db.GetComments(
690
693
rp.db,
691
-
db.FilterEq("id", commentId),
694
+
orm.FilterEq("id", commentId),
692
695
)
693
696
if err != nil {
694
697
l.Error("failed to fetch comment", "id", commentId)
···
722
725
}
723
726
724
727
commentId := chi.URLParam(r, "commentId")
725
-
comments, err := db.GetIssueComments(
728
+
comments, err := db.GetComments(
726
729
rp.db,
727
-
db.FilterEq("id", commentId),
730
+
orm.FilterEq("id", commentId),
728
731
)
729
732
if err != nil {
730
733
l.Error("failed to fetch comment", "id", commentId)
···
738
741
}
739
742
comment := comments[0]
740
743
741
-
if comment.Did != user.Did {
744
+
if comment.Did.String() != user.Did {
742
745
l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did)
743
746
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
744
747
return
···
751
754
752
755
// optimistic deletion
753
756
deleted := time.Now()
754
-
err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id))
757
+
err = db.DeleteComments(rp.db, orm.FilterEq("id", comment.Id))
755
758
if err != nil {
756
759
l.Error("failed to delete comment", "err", err)
757
760
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
···
767
770
return
768
771
}
769
772
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
770
-
Collection: tangled.RepoIssueCommentNSID,
773
+
Collection: tangled.CommentNSID,
771
774
Repo: user.Did,
772
775
Rkey: comment.Rkey,
773
776
})
···
840
843
841
844
issues, err = db.GetIssues(
842
845
rp.db,
843
-
db.FilterIn("id", res.Hits),
846
+
orm.FilterIn("id", res.Hits),
844
847
)
845
848
if err != nil {
846
849
l.Error("failed to get issues", "err", err)
···
856
859
issues, err = db.GetIssuesPaginated(
857
860
rp.db,
858
861
page,
859
-
db.FilterEq("repo_at", f.RepoAt()),
860
-
db.FilterEq("open", openInt),
862
+
orm.FilterEq("repo_at", f.RepoAt()),
863
+
orm.FilterEq("open", openInt),
861
864
)
862
865
if err != nil {
863
866
l.Error("failed to get issues", "err", err)
···
868
871
869
872
labelDefs, err := db.GetLabelDefinitions(
870
873
rp.db,
871
-
db.FilterIn("at_uri", f.Labels),
872
-
db.FilterContains("scope", tangled.RepoIssueNSID),
874
+
orm.FilterIn("at_uri", f.Labels),
875
+
orm.FilterContains("scope", tangled.RepoIssueNSID),
873
876
)
874
877
if err != nil {
875
878
l.Error("failed to fetch labels", "err", err)
···
912
915
})
913
916
case http.MethodPost:
914
917
body := r.FormValue("body")
915
-
mentions, references := rp.refResolver.Resolve(r.Context(), body)
918
+
mentions, references := rp.mentionsResolver.Resolve(r.Context(), body)
916
919
917
920
issue := &models.Issue{
918
921
RepoAt: f.RepoAt(),
+19
-18
appview/knots/knots.go
+19
-18
appview/knots/knots.go
···
21
21
"tangled.org/core/appview/xrpcclient"
22
22
"tangled.org/core/eventconsumer"
23
23
"tangled.org/core/idresolver"
24
+
"tangled.org/core/orm"
24
25
"tangled.org/core/rbac"
25
26
"tangled.org/core/tid"
26
27
···
72
73
user := k.OAuth.GetUser(r)
73
74
registrations, err := db.GetRegistrations(
74
75
k.Db,
75
-
db.FilterEq("did", user.Did),
76
+
orm.FilterEq("did", user.Did),
76
77
)
77
78
if err != nil {
78
79
k.Logger.Error("failed to fetch knot registrations", "err", err)
···
102
103
103
104
registrations, err := db.GetRegistrations(
104
105
k.Db,
105
-
db.FilterEq("did", user.Did),
106
-
db.FilterEq("domain", domain),
106
+
orm.FilterEq("did", user.Did),
107
+
orm.FilterEq("domain", domain),
107
108
)
108
109
if err != nil {
109
110
l.Error("failed to get registrations", "err", err)
···
127
128
repos, err := db.GetRepos(
128
129
k.Db,
129
130
0,
130
-
db.FilterEq("knot", domain),
131
+
orm.FilterEq("knot", domain),
131
132
)
132
133
if err != nil {
133
134
l.Error("failed to get knot repos", "err", err)
···
293
294
// get record from db first
294
295
registrations, err := db.GetRegistrations(
295
296
k.Db,
296
-
db.FilterEq("did", user.Did),
297
-
db.FilterEq("domain", domain),
297
+
orm.FilterEq("did", user.Did),
298
+
orm.FilterEq("domain", domain),
298
299
)
299
300
if err != nil {
300
301
l.Error("failed to get registration", "err", err)
···
321
322
322
323
err = db.DeleteKnot(
323
324
tx,
324
-
db.FilterEq("did", user.Did),
325
-
db.FilterEq("domain", domain),
325
+
orm.FilterEq("did", user.Did),
326
+
orm.FilterEq("domain", domain),
326
327
)
327
328
if err != nil {
328
329
l.Error("failed to delete registration", "err", err)
···
402
403
// get record from db first
403
404
registrations, err := db.GetRegistrations(
404
405
k.Db,
405
-
db.FilterEq("did", user.Did),
406
-
db.FilterEq("domain", domain),
406
+
orm.FilterEq("did", user.Did),
407
+
orm.FilterEq("domain", domain),
407
408
)
408
409
if err != nil {
409
410
l.Error("failed to get registration", "err", err)
···
493
494
// Get updated registration to show
494
495
registrations, err = db.GetRegistrations(
495
496
k.Db,
496
-
db.FilterEq("did", user.Did),
497
-
db.FilterEq("domain", domain),
497
+
orm.FilterEq("did", user.Did),
498
+
orm.FilterEq("domain", domain),
498
499
)
499
500
if err != nil {
500
501
l.Error("failed to get registration", "err", err)
···
529
530
530
531
registrations, err := db.GetRegistrations(
531
532
k.Db,
532
-
db.FilterEq("did", user.Did),
533
-
db.FilterEq("domain", domain),
534
-
db.FilterIsNot("registered", "null"),
533
+
orm.FilterEq("did", user.Did),
534
+
orm.FilterEq("domain", domain),
535
+
orm.FilterIsNot("registered", "null"),
535
536
)
536
537
if err != nil {
537
538
l.Error("failed to get registration", "err", err)
···
637
638
638
639
registrations, err := db.GetRegistrations(
639
640
k.Db,
640
-
db.FilterEq("did", user.Did),
641
-
db.FilterEq("domain", domain),
642
-
db.FilterIsNot("registered", "null"),
641
+
orm.FilterEq("did", user.Did),
642
+
orm.FilterEq("domain", domain),
643
+
orm.FilterIsNot("registered", "null"),
643
644
)
644
645
if err != nil {
645
646
l.Error("failed to get registration", "err", err)
+5
-4
appview/labels/labels.go
+5
-4
appview/labels/labels.go
···
16
16
"tangled.org/core/appview/oauth"
17
17
"tangled.org/core/appview/pages"
18
18
"tangled.org/core/appview/validator"
19
+
"tangled.org/core/orm"
19
20
"tangled.org/core/rbac"
20
21
"tangled.org/core/tid"
21
22
···
88
89
repoAt := r.Form.Get("repo")
89
90
subjectUri := r.Form.Get("subject")
90
91
91
-
repo, err := db.GetRepo(l.db, db.FilterEq("at_uri", repoAt))
92
+
repo, err := db.GetRepo(l.db, orm.FilterEq("at_uri", repoAt))
92
93
if err != nil {
93
94
fail("Failed to get repository.", err)
94
95
return
95
96
}
96
97
97
98
// find all the labels that this repo subscribes to
98
-
repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt))
99
+
repoLabels, err := db.GetRepoLabels(l.db, orm.FilterEq("repo_at", repoAt))
99
100
if err != nil {
100
101
fail("Failed to get labels for this repository.", err)
101
102
return
···
106
107
labelAts = append(labelAts, rl.LabelAt.String())
107
108
}
108
109
109
-
actx, err := db.NewLabelApplicationCtx(l.db, db.FilterIn("at_uri", labelAts))
110
+
actx, err := db.NewLabelApplicationCtx(l.db, orm.FilterIn("at_uri", labelAts))
110
111
if err != nil {
111
112
fail("Invalid form data.", err)
112
113
return
113
114
}
114
115
115
116
// calculate the start state by applying already known labels
116
-
existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri))
117
+
existingOps, err := db.GetLabelOps(l.db, orm.FilterEq("subject", subjectUri))
117
118
if err != nil {
118
119
fail("Invalid form data.", err)
119
120
return
+67
appview/mentions/resolver.go
+67
appview/mentions/resolver.go
···
1
+
package mentions
2
+
3
+
import (
4
+
"context"
5
+
"log/slog"
6
+
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
8
+
"tangled.org/core/appview/config"
9
+
"tangled.org/core/appview/db"
10
+
"tangled.org/core/appview/models"
11
+
"tangled.org/core/appview/pages/markup"
12
+
"tangled.org/core/idresolver"
13
+
)
14
+
15
+
type Resolver struct {
16
+
config *config.Config
17
+
idResolver *idresolver.Resolver
18
+
execer db.Execer
19
+
logger *slog.Logger
20
+
}
21
+
22
+
func New(
23
+
config *config.Config,
24
+
idResolver *idresolver.Resolver,
25
+
execer db.Execer,
26
+
logger *slog.Logger,
27
+
) *Resolver {
28
+
return &Resolver{
29
+
config,
30
+
idResolver,
31
+
execer,
32
+
logger,
33
+
}
34
+
}
35
+
36
+
func (r *Resolver) Resolve(ctx context.Context, source string) ([]syntax.DID, []syntax.ATURI) {
37
+
l := r.logger.With("method", "Resolve")
38
+
39
+
rawMentions, rawRefs := markup.FindReferences(r.config.Core.AppviewHost, source)
40
+
l.Debug("found possible references", "mentions", rawMentions, "refs", rawRefs)
41
+
42
+
idents := r.idResolver.ResolveIdents(ctx, rawMentions)
43
+
var mentions []syntax.DID
44
+
for _, ident := range idents {
45
+
if ident != nil && !ident.Handle.IsInvalidHandle() {
46
+
mentions = append(mentions, ident.DID)
47
+
}
48
+
}
49
+
l.Debug("found mentions", "mentions", mentions)
50
+
51
+
var resolvedRefs []models.ReferenceLink
52
+
for _, rawRef := range rawRefs {
53
+
ident, err := r.idResolver.ResolveIdent(ctx, rawRef.Handle)
54
+
if err != nil || ident == nil || ident.Handle.IsInvalidHandle() {
55
+
continue
56
+
}
57
+
rawRef.Handle = string(ident.DID)
58
+
resolvedRefs = append(resolvedRefs, rawRef)
59
+
}
60
+
aturiRefs, err := db.ValidateReferenceLinks(r.execer, resolvedRefs)
61
+
if err != nil {
62
+
l.Error("failed running query", "err", err)
63
+
}
64
+
l.Debug("found references", "refs", aturiRefs)
65
+
66
+
return mentions, aturiRefs
67
+
}
+4
-3
appview/middleware/middleware.go
+4
-3
appview/middleware/middleware.go
···
18
18
"tangled.org/core/appview/pagination"
19
19
"tangled.org/core/appview/reporesolver"
20
20
"tangled.org/core/idresolver"
21
+
"tangled.org/core/orm"
21
22
"tangled.org/core/rbac"
22
23
)
23
24
···
175
176
}
176
177
177
178
func (mw Middleware) ResolveIdent() middlewareFunc {
178
-
excluded := []string{"favicon.ico"}
179
+
excluded := []string{"favicon.ico", "favicon.svg"}
179
180
180
181
return func(next http.Handler) http.Handler {
181
182
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
···
217
218
218
219
repo, err := db.GetRepo(
219
220
mw.db,
220
-
db.FilterEq("did", id.DID.String()),
221
-
db.FilterEq("name", repoName),
221
+
orm.FilterEq("did", id.DID.String()),
222
+
orm.FilterEq("name", repoName),
222
223
)
223
224
if err != nil {
224
225
log.Println("failed to resolve repo", "err", err)
+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
+
}
+8
-89
appview/models/issue.go
+8
-89
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 []IssueComment
29
+
Comments []Comment
30
30
Labels LabelState
31
31
Repo *Repo
32
32
}
···
62
62
}
63
63
64
64
type CommentListItem struct {
65
-
Self *IssueComment
66
-
Replies []*IssueComment
65
+
Self *Comment
66
+
Replies []*Comment
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[string]*CommentListItem)
92
-
var replies []*IssueComment
91
+
toplevel := make(map[syntax.ATURI]*CommentListItem)
92
+
var replies []*Comment
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().String()] = &CommentListItem{
97
+
toplevel[comment.AtUri()] = &CommentListItem{
98
98
Self: &comment,
99
99
}
100
100
} else {
···
115
115
}
116
116
117
117
// sort everything
118
-
sortFunc := func(a, b *IssueComment) bool {
118
+
sortFunc := func(a, b *Comment) 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)
147
+
addParticipant(c.Did.String())
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
+
}
+3
-47
appview/models/pull.go
+3
-47
appview/models/pull.go
···
83
83
Repo *Repo
84
84
}
85
85
86
+
// NOTE: This method does not include patch blob in returned atproto record
86
87
func (p Pull) AsRecord() tangled.RepoPull {
87
88
var source *tangled.RepoPull_Source
88
89
if p.PullSource != nil {
···
113
114
Repo: p.RepoAt.String(),
114
115
Branch: p.TargetBranch,
115
116
},
116
-
Patch: p.LatestPatch(),
117
117
Source: source,
118
118
}
119
119
return record
···
138
138
RoundNumber int
139
139
Patch string
140
140
Combined string
141
-
Comments []PullComment
141
+
Comments []Comment
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
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
-
// }
191
-
192
148
func (p *Pull) LastRoundNumber() int {
193
149
return len(p.Submissions) - 1
194
150
}
···
289
245
addParticipant(s.PullAt.Authority().String())
290
246
291
247
for _, c := range s.Comments {
292
-
addParticipant(c.OwnerDid)
248
+
addParticipant(c.Did.String())
293
249
}
294
250
295
251
return participants
+5
-4
appview/notifications/notifications.go
+5
-4
appview/notifications/notifications.go
···
11
11
"tangled.org/core/appview/oauth"
12
12
"tangled.org/core/appview/pages"
13
13
"tangled.org/core/appview/pagination"
14
+
"tangled.org/core/orm"
14
15
)
15
16
16
17
type Notifications struct {
···
53
54
54
55
total, err := db.CountNotifications(
55
56
n.db,
56
-
db.FilterEq("recipient_did", user.Did),
57
+
orm.FilterEq("recipient_did", user.Did),
57
58
)
58
59
if err != nil {
59
60
l.Error("failed to get total notifications", "err", err)
···
64
65
notifications, err := db.GetNotificationsWithEntities(
65
66
n.db,
66
67
page,
67
-
db.FilterEq("recipient_did", user.Did),
68
+
orm.FilterEq("recipient_did", user.Did),
68
69
)
69
70
if err != nil {
70
71
l.Error("failed to get notifications", "err", err)
···
96
97
97
98
count, err := db.CountNotifications(
98
99
n.db,
99
-
db.FilterEq("recipient_did", user.Did),
100
-
db.FilterEq("read", 0),
100
+
orm.FilterEq("recipient_did", user.Did),
101
+
orm.FilterEq("read", 0),
101
102
)
102
103
if err != nil {
103
104
http.Error(w, "Failed to get unread count", http.StatusInternalServerError)
+157
-148
appview/notify/db/db.go
+157
-148
appview/notify/db/db.go
···
3
3
import (
4
4
"context"
5
5
"log"
6
-
"maps"
7
6
"slices"
8
7
9
8
"github.com/bluesky-social/indigo/atproto/syntax"
···
12
11
"tangled.org/core/appview/models"
13
12
"tangled.org/core/appview/notify"
14
13
"tangled.org/core/idresolver"
14
+
"tangled.org/core/orm"
15
+
"tangled.org/core/sets"
15
16
)
16
17
17
18
const (
18
-
maxMentions = 5
19
+
maxMentions = 8
19
20
)
20
21
21
22
type databaseNotifier struct {
···
42
43
return
43
44
}
44
45
var err error
45
-
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt)))
46
+
repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", string(star.RepoAt)))
46
47
if err != nil {
47
48
log.Printf("NewStar: failed to get repos: %v", err)
48
49
return
49
50
}
50
51
51
52
actorDid := syntax.DID(star.Did)
52
-
recipients := []syntax.DID{syntax.DID(repo.Did)}
53
+
recipients := sets.Singleton(syntax.DID(repo.Did))
53
54
eventType := models.NotificationTypeRepoStarred
54
55
entityType := "repo"
55
56
entityId := star.RepoAt.String()
···
73
74
// no-op
74
75
}
75
76
76
-
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
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
+
)
77
91
78
-
// build the recipients list
79
-
// - owner of the repo
80
-
// - collaborators in the repo
81
-
var recipients []syntax.DID
82
-
recipients = append(recipients, syntax.DID(issue.Repo.Did))
83
-
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt()))
92
+
subjectDid, err := comment.Subject.Authority().AsDID()
84
93
if err != nil {
85
-
log.Printf("failed to fetch collaborators: %v", err)
94
+
log.Printf("NewComment: expected did based at-uri for comment.subject")
86
95
return
87
96
}
88
-
for _, c := range collaborators {
89
-
recipients = append(recipients, c.SubjectDid)
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
+
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
90
171
}
91
172
92
-
actorDid := syntax.DID(issue.Did)
93
-
entityType := "issue"
94
-
entityId := issue.AtUri().String()
95
-
repoId := &issue.Repo.Id
96
-
issueId := &issue.Id
97
-
var pullId *int64
173
+
for _, m := range comment.Mentions {
174
+
recipients.Remove(m)
175
+
}
98
176
99
177
n.notifyEvent(
100
-
actorDid,
178
+
comment.Did,
101
179
recipients,
102
-
models.NotificationTypeIssueCreated,
180
+
models.NotificationTypeIssueCommented,
103
181
entityType,
104
182
entityId,
105
183
repoId,
···
107
185
pullId,
108
186
)
109
187
n.notifyEvent(
110
-
actorDid,
111
-
mentions,
188
+
comment.Did,
189
+
sets.Collect(slices.Values(comment.Mentions)),
112
190
models.NotificationTypeUserMentioned,
113
191
entityType,
114
192
entityId,
···
118
196
)
119
197
}
120
198
121
-
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
122
-
issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt))
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()))
123
205
if err != nil {
124
-
log.Printf("NewIssueComment: failed to get issues: %v", err)
206
+
log.Printf("failed to fetch collaborators: %v", err)
125
207
return
126
208
}
127
-
if len(issues) == 0 {
128
-
log.Printf("NewIssueComment: no issue found for %s", comment.IssueAt)
129
-
return
209
+
210
+
// build the recipients list
211
+
// - owner of the repo
212
+
// - collaborators in the repo
213
+
// - remove users already mentioned
214
+
recipients := sets.Singleton(syntax.DID(issue.Repo.Did))
215
+
for _, c := range collaborators {
216
+
recipients.Insert(c.SubjectDid)
130
217
}
131
-
issue := issues[0]
132
-
133
-
var recipients []syntax.DID
134
-
recipients = append(recipients, syntax.DID(issue.Repo.Did))
135
-
136
-
if comment.IsReply() {
137
-
// if this comment is a reply, then notify everybody in that thread
138
-
parentAtUri := *comment.ReplyTo
139
-
allThreads := issue.CommentList()
140
-
141
-
// find the parent thread, and add all DIDs from here to the recipient list
142
-
for _, t := range allThreads {
143
-
if t.Self.AtUri().String() == parentAtUri {
144
-
recipients = append(recipients, t.Participants()...)
145
-
}
146
-
}
147
-
} else {
148
-
// not a reply, notify just the issue author
149
-
recipients = append(recipients, syntax.DID(issue.Did))
218
+
for _, m := range mentions {
219
+
recipients.Remove(m)
150
220
}
151
221
152
-
actorDid := syntax.DID(comment.Did)
222
+
actorDid := syntax.DID(issue.Did)
153
223
entityType := "issue"
154
224
entityId := issue.AtUri().String()
155
225
repoId := &issue.Repo.Id
···
159
229
n.notifyEvent(
160
230
actorDid,
161
231
recipients,
162
-
models.NotificationTypeIssueCommented,
232
+
models.NotificationTypeIssueCreated,
163
233
entityType,
164
234
entityId,
165
235
repoId,
···
168
238
)
169
239
n.notifyEvent(
170
240
actorDid,
171
-
mentions,
241
+
sets.Collect(slices.Values(mentions)),
172
242
models.NotificationTypeUserMentioned,
173
243
entityType,
174
244
entityId,
···
184
254
185
255
func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
186
256
actorDid := syntax.DID(follow.UserDid)
187
-
recipients := []syntax.DID{syntax.DID(follow.SubjectDid)}
257
+
recipients := sets.Singleton(syntax.DID(follow.SubjectDid))
188
258
eventType := models.NotificationTypeFollowed
189
259
entityType := "follow"
190
260
entityId := follow.UserDid
···
207
277
}
208
278
209
279
func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {
210
-
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
280
+
repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", string(pull.RepoAt)))
211
281
if err != nil {
212
282
log.Printf("NewPull: failed to get repos: %v", err)
213
283
return
214
284
}
285
+
collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", repo.RepoAt()))
286
+
if err != nil {
287
+
log.Printf("failed to fetch collaborators: %v", err)
288
+
return
289
+
}
215
290
216
291
// build the recipients list
217
292
// - owner of the repo
218
293
// - collaborators in the repo
219
-
var recipients []syntax.DID
220
-
recipients = append(recipients, syntax.DID(repo.Did))
221
-
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt()))
222
-
if err != nil {
223
-
log.Printf("failed to fetch collaborators: %v", err)
224
-
return
225
-
}
294
+
recipients := sets.Singleton(syntax.DID(repo.Did))
226
295
for _, c := range collaborators {
227
-
recipients = append(recipients, c.SubjectDid)
296
+
recipients.Insert(c.SubjectDid)
228
297
}
229
298
230
299
actorDid := syntax.DID(pull.OwnerDid)
···
248
317
)
249
318
}
250
319
251
-
func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
252
-
pull, err := db.GetPull(n.db,
253
-
syntax.ATURI(comment.RepoAt),
254
-
comment.PullId,
255
-
)
256
-
if err != nil {
257
-
log.Printf("NewPullComment: failed to get pulls: %v", err)
258
-
return
259
-
}
260
-
261
-
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt))
262
-
if err != nil {
263
-
log.Printf("NewPullComment: failed to get repos: %v", err)
264
-
return
265
-
}
266
-
267
-
// build up the recipients list:
268
-
// - repo owner
269
-
// - all pull participants
270
-
var recipients []syntax.DID
271
-
recipients = append(recipients, syntax.DID(repo.Did))
272
-
for _, p := range pull.Participants() {
273
-
recipients = append(recipients, syntax.DID(p))
274
-
}
275
-
276
-
actorDid := syntax.DID(comment.OwnerDid)
277
-
eventType := models.NotificationTypePullCommented
278
-
entityType := "pull"
279
-
entityId := pull.AtUri().String()
280
-
repoId := &repo.Id
281
-
var issueId *int64
282
-
p := int64(pull.ID)
283
-
pullId := &p
284
-
285
-
n.notifyEvent(
286
-
actorDid,
287
-
recipients,
288
-
eventType,
289
-
entityType,
290
-
entityId,
291
-
repoId,
292
-
issueId,
293
-
pullId,
294
-
)
295
-
n.notifyEvent(
296
-
actorDid,
297
-
mentions,
298
-
models.NotificationTypeUserMentioned,
299
-
entityType,
300
-
entityId,
301
-
repoId,
302
-
issueId,
303
-
pullId,
304
-
)
305
-
}
306
-
307
320
func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
308
321
// no-op
309
322
}
···
321
334
}
322
335
323
336
func (n *databaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
324
-
// build up the recipients list:
325
-
// - repo owner
326
-
// - repo collaborators
327
-
// - all issue participants
328
-
var recipients []syntax.DID
329
-
recipients = append(recipients, syntax.DID(issue.Repo.Did))
330
-
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt()))
337
+
collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt()))
331
338
if err != nil {
332
339
log.Printf("failed to fetch collaborators: %v", err)
333
340
return
334
341
}
342
+
343
+
// build up the recipients list:
344
+
// - repo owner
345
+
// - repo collaborators
346
+
// - all issue participants
347
+
recipients := sets.Singleton(syntax.DID(issue.Repo.Did))
335
348
for _, c := range collaborators {
336
-
recipients = append(recipients, c.SubjectDid)
349
+
recipients.Insert(c.SubjectDid)
337
350
}
338
351
for _, p := range issue.Participants() {
339
-
recipients = append(recipients, syntax.DID(p))
352
+
recipients.Insert(syntax.DID(p))
340
353
}
341
354
342
355
entityType := "pull"
···
366
379
367
380
func (n *databaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
368
381
// Get repo details
369
-
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
382
+
repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", string(pull.RepoAt)))
370
383
if err != nil {
371
384
log.Printf("NewPullState: failed to get repos: %v", err)
372
385
return
373
386
}
374
387
375
-
// build up the recipients list:
376
-
// - repo owner
377
-
// - all pull participants
378
-
var recipients []syntax.DID
379
-
recipients = append(recipients, syntax.DID(repo.Did))
380
-
collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt()))
388
+
collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", repo.RepoAt()))
381
389
if err != nil {
382
390
log.Printf("failed to fetch collaborators: %v", err)
383
391
return
384
392
}
393
+
394
+
// build up the recipients list:
395
+
// - repo owner
396
+
// - all pull participants
397
+
recipients := sets.Singleton(syntax.DID(repo.Did))
385
398
for _, c := range collaborators {
386
-
recipients = append(recipients, c.SubjectDid)
399
+
recipients.Insert(c.SubjectDid)
387
400
}
388
401
for _, p := range pull.Participants() {
389
-
recipients = append(recipients, syntax.DID(p))
402
+
recipients.Insert(syntax.DID(p))
390
403
}
391
404
392
405
entityType := "pull"
···
422
435
423
436
func (n *databaseNotifier) notifyEvent(
424
437
actorDid syntax.DID,
425
-
recipients []syntax.DID,
438
+
recipients sets.Set[syntax.DID],
426
439
eventType models.NotificationType,
427
440
entityType string,
428
441
entityId string,
···
430
443
issueId *int64,
431
444
pullId *int64,
432
445
) {
433
-
if eventType == models.NotificationTypeUserMentioned && len(recipients) > maxMentions {
434
-
recipients = recipients[:maxMentions]
446
+
// if the user is attempting to mention >maxMentions users, this is probably spam, do not mention anybody
447
+
if eventType == models.NotificationTypeUserMentioned && recipients.Len() > maxMentions {
448
+
return
435
449
}
436
-
recipientSet := make(map[syntax.DID]struct{})
437
-
for _, did := range recipients {
438
-
// everybody except actor themselves
439
-
if did != actorDid {
440
-
recipientSet[did] = struct{}{}
441
-
}
442
-
}
450
+
451
+
recipients.Remove(actorDid)
443
452
444
453
prefMap, err := db.GetNotificationPreferences(
445
454
n.db,
446
-
db.FilterIn("user_did", slices.Collect(maps.Keys(recipientSet))),
455
+
orm.FilterIn("user_did", slices.Collect(recipients.All())),
447
456
)
448
457
if err != nil {
449
458
// failed to get prefs for users
···
459
468
defer tx.Rollback()
460
469
461
470
// filter based on preferences
462
-
for recipientDid := range recipientSet {
471
+
for recipientDid := range recipients.All() {
463
472
prefs, ok := prefMap[recipientDid]
464
473
if !ok {
465
474
prefs = models.DefaultNotificationPreferences(recipientDid)
+8
-9
appview/notify/merged_notifier.go
+8
-9
appview/notify/merged_notifier.go
···
39
39
v.Call(in)
40
40
}(n)
41
41
}
42
-
wg.Wait()
43
42
}
44
43
45
44
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
···
52
51
53
52
func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) {
54
53
m.fanout("DeleteStar", ctx, star)
54
+
}
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)
55
62
}
56
63
57
64
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
58
65
m.fanout("NewIssue", ctx, issue, mentions)
59
66
}
60
67
61
-
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
62
-
m.fanout("NewIssueComment", ctx, comment, mentions)
63
-
}
64
-
65
68
func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
66
69
m.fanout("NewIssueState", ctx, actor, issue)
67
70
}
···
80
83
81
84
func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) {
82
85
m.fanout("NewPull", ctx, pull)
83
-
}
84
-
85
-
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
86
-
m.fanout("NewPullComment", ctx, comment, mentions)
87
86
}
88
87
89
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
+
16
19
NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID)
17
-
NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID)
18
20
NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue)
19
21
DeleteIssue(ctx context.Context, issue *models.Issue)
20
22
···
22
24
DeleteFollow(ctx context.Context, follow *models.Follow)
23
25
24
26
NewPull(ctx context.Context, pull *models.Pull)
25
-
NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID)
26
27
NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull)
27
28
28
29
UpdateProfile(ctx context.Context, profile *models.Profile)
···
42
43
func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {}
43
44
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {}
44
45
46
+
func (m *BaseNotifier) NewComment(ctx context.Context, comment *models.Comment) {}
47
+
func (m *BaseNotifier) DeleteComment(ctx context.Context, comment *models.Comment) {}
48
+
45
49
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
-
}
48
50
func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {}
49
51
func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {}
50
52
51
53
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {}
52
54
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {}
53
55
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
-
}
56
+
func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {}
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) {}
+5
-20
appview/notify/posthog/notifier.go
+5
-20
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
-
104
89
func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
105
90
err := n.client.Enqueue(posthog.Capture{
106
91
DistinctId: pull.OwnerDid,
···
180
165
}
181
166
}
182
167
183
-
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
168
+
func (n *posthogNotifier) NewComment(ctx context.Context, comment *models.Comment) {
184
169
err := n.client.Enqueue(posthog.Capture{
185
-
DistinctId: comment.Did,
186
-
Event: "new_issue_comment",
170
+
DistinctId: comment.Did.String(),
171
+
Event: "new_comment",
187
172
Properties: posthog.Properties{
188
-
"issue_at": comment.IssueAt,
189
-
"mentions": mentions,
173
+
"subject_at": comment.Subject,
174
+
"mentions": comment.Mentions,
190
175
},
191
176
})
192
177
if err != nil {
+3
-2
appview/oauth/handler.go
+3
-2
appview/oauth/handler.go
···
16
16
"tangled.org/core/api/tangled"
17
17
"tangled.org/core/appview/db"
18
18
"tangled.org/core/consts"
19
+
"tangled.org/core/orm"
19
20
"tangled.org/core/tid"
20
21
)
21
22
···
97
98
// and create an sh.tangled.spindle.member record with that
98
99
spindleMembers, err := db.GetSpindleMembers(
99
100
o.Db,
100
-
db.FilterEq("instance", "spindle.tangled.sh"),
101
-
db.FilterEq("subject", did),
101
+
orm.FilterEq("instance", "spindle.tangled.sh"),
102
+
orm.FilterEq("subject", did),
102
103
)
103
104
if err != nil {
104
105
l.Error("failed to get spindle members", "err", err)
appview/pages/assets/apple-touch-icon.png
appview/pages/assets/apple-touch-icon.png
This is a binary file and will not be displayed.
appview/pages/assets/favicon.ico
appview/pages/assets/favicon.ico
This is a binary file and will not be displayed.
+83
appview/pages/assets/favicon.svg
+83
appview/pages/assets/favicon.svg
···
1
+
<svg
2
+
version="1.1"
3
+
id="svg1"
4
+
width="25"
5
+
height="25"
6
+
color="#ffffff"
7
+
viewBox="0 0 25 25"
8
+
sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg"
9
+
inkscape:export-filename="tangled_dolly_silhouette_black_on_trans.svg"
10
+
inkscape:export-xdpi="96"
11
+
inkscape:export-ydpi="96"
12
+
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
13
+
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
14
+
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
15
+
xmlns="http://www.w3.org/2000/svg"
16
+
xmlns:svg="http://www.w3.org/2000/svg"
17
+
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
18
+
xmlns:cc="http://creativecommons.org/ns#">
19
+
<sodipodi:namedview
20
+
id="namedview1"
21
+
pagecolor="#ffffff"
22
+
bordercolor="#000000"
23
+
borderopacity="0.25"
24
+
inkscape:showpageshadow="2"
25
+
inkscape:pageopacity="0.0"
26
+
inkscape:pagecheckerboard="true"
27
+
inkscape:deskcolor="#d5d5d5"
28
+
inkscape:zoom="64"
29
+
inkscape:cx="4.96875"
30
+
inkscape:cy="13.429688"
31
+
inkscape:window-width="3840"
32
+
inkscape:window-height="2160"
33
+
inkscape:window-x="0"
34
+
inkscape:window-y="0"
35
+
inkscape:window-maximized="0"
36
+
inkscape:current-layer="g1"
37
+
borderlayer="true">
38
+
<inkscape:page
39
+
x="0"
40
+
y="0"
41
+
width="25"
42
+
height="25"
43
+
id="page2"
44
+
margin="0"
45
+
bleed="0" />
46
+
</sodipodi:namedview>
47
+
<g
48
+
inkscape:groupmode="layer"
49
+
inkscape:label="Image"
50
+
id="g1"
51
+
transform="translate(-0.42924038,-0.87777209)">
52
+
<path
53
+
class="dolly"
54
+
fill="currentColor"
55
+
style="stroke-width:0.111183"
56
+
d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z"
57
+
id="path7"
58
+
sodipodi:nodetypes="sccccccccccccccccccsscccccccccscccccccsc" />
59
+
</g>
60
+
<metadata
61
+
id="metadata1">
62
+
<rdf:RDF>
63
+
<cc:Work
64
+
rdf:about="">
65
+
<cc:license
66
+
rdf:resource="http://creativecommons.org/licenses/by/4.0/" />
67
+
</cc:Work>
68
+
<cc:License
69
+
rdf:about="http://creativecommons.org/licenses/by/4.0/">
70
+
<cc:permits
71
+
rdf:resource="http://creativecommons.org/ns#Reproduction" />
72
+
<cc:permits
73
+
rdf:resource="http://creativecommons.org/ns#Distribution" />
74
+
<cc:requires
75
+
rdf:resource="http://creativecommons.org/ns#Notice" />
76
+
<cc:requires
77
+
rdf:resource="http://creativecommons.org/ns#Attribution" />
78
+
<cc:permits
79
+
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
80
+
</cc:License>
81
+
</rdf:RDF>
82
+
</metadata>
83
+
</svg>
+6
-1
appview/pages/funcmap.go
+6
-1
appview/pages/funcmap.go
···
25
25
"github.com/dustin/go-humanize"
26
26
"github.com/go-enry/go-enry/v2"
27
27
"github.com/yuin/goldmark"
28
+
emoji "github.com/yuin/goldmark-emoji"
28
29
"tangled.org/core/appview/filetree"
29
30
"tangled.org/core/appview/models"
30
31
"tangled.org/core/appview/pages/markup"
···
261
262
},
262
263
"description": func(text string) template.HTML {
263
264
p.rctx.RendererType = markup.RendererTypeDefault
264
-
htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New())
265
+
htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New(
266
+
goldmark.WithExtensions(
267
+
emoji.Emoji,
268
+
),
269
+
))
265
270
sanitized := p.rctx.SanitizeDescription(htmlString)
266
271
return template.HTML(sanitized)
267
272
},
+13
-3
appview/pages/markup/extension/atlink.go
+13
-3
appview/pages/markup/extension/atlink.go
···
35
35
return KindAt
36
36
}
37
37
38
-
var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)`)
38
+
var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\b)`)
39
+
var markdownLinkRegexp = regexp.MustCompile(`(?ms)\[.*\]\(.*\)`)
39
40
40
41
type atParser struct{}
41
42
···
55
56
if m == nil {
56
57
return nil
57
58
}
59
+
60
+
// Check for all links in the markdown to see if the handle found is inside one
61
+
linksIndexes := markdownLinkRegexp.FindAllIndex(block.Source(), -1)
62
+
for _, linkMatch := range linksIndexes {
63
+
if linkMatch[0] < segment.Start && segment.Start < linkMatch[1] {
64
+
return nil
65
+
}
66
+
}
67
+
58
68
atSegment := text.NewSegment(segment.Start, segment.Start+m[1])
59
69
block.Advance(m[1])
60
70
node := &AtNode{}
···
87
97
88
98
func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
89
99
if entering {
90
-
w.WriteString(`<a href="/@`)
100
+
w.WriteString(`<a href="/`)
91
101
w.WriteString(n.(*AtNode).Handle)
92
-
w.WriteString(`" class="mention font-bold">`)
102
+
w.WriteString(`" class="mention">`)
93
103
} else {
94
104
w.WriteString("</a>")
95
105
}
+2
appview/pages/markup/markdown.go
+2
appview/pages/markup/markdown.go
···
13
13
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
14
14
"github.com/alecthomas/chroma/v2/styles"
15
15
"github.com/yuin/goldmark"
16
+
"github.com/yuin/goldmark-emoji"
16
17
highlighting "github.com/yuin/goldmark-highlighting/v2"
17
18
"github.com/yuin/goldmark/ast"
18
19
"github.com/yuin/goldmark/extension"
···
66
67
),
67
68
callout.CalloutExtention,
68
69
textension.AtExt,
70
+
emoji.Emoji,
69
71
),
70
72
goldmark.WithParserOptions(
71
73
parser.WithAutoHeadingID(),
+121
appview/pages/markup/markdown_test.go
+121
appview/pages/markup/markdown_test.go
···
1
+
package markup
2
+
3
+
import (
4
+
"bytes"
5
+
"testing"
6
+
)
7
+
8
+
func TestAtExtension_Rendering(t *testing.T) {
9
+
tests := []struct {
10
+
name string
11
+
markdown string
12
+
expected string
13
+
}{
14
+
{
15
+
name: "renders simple at mention",
16
+
markdown: "Hello @user.tngl.sh!",
17
+
expected: `<p>Hello <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>!</p>`,
18
+
},
19
+
{
20
+
name: "renders multiple at mentions",
21
+
markdown: "Hi @alice.tngl.sh and @bob.example.com",
22
+
expected: `<p>Hi <a href="/alice.tngl.sh" class="mention">@alice.tngl.sh</a> and <a href="/bob.example.com" class="mention">@bob.example.com</a></p>`,
23
+
},
24
+
{
25
+
name: "renders at mention in parentheses",
26
+
markdown: "Check this out (@user.tngl.sh)",
27
+
expected: `<p>Check this out (<a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>)</p>`,
28
+
},
29
+
{
30
+
name: "does not render email",
31
+
markdown: "Contact me at test@example.com",
32
+
expected: `<p>Contact me at <a href="mailto:test@example.com">test@example.com</a></p>`,
33
+
},
34
+
{
35
+
name: "renders at mention with hyphen",
36
+
markdown: "Follow @user-name.tngl.sh",
37
+
expected: `<p>Follow <a href="/user-name.tngl.sh" class="mention">@user-name.tngl.sh</a></p>`,
38
+
},
39
+
{
40
+
name: "renders at mention with numbers",
41
+
markdown: "@user123.test456.social",
42
+
expected: `<p><a href="/user123.test456.social" class="mention">@user123.test456.social</a></p>`,
43
+
},
44
+
{
45
+
name: "at mention at start of line",
46
+
markdown: "@user.tngl.sh is cool",
47
+
expected: `<p><a href="/user.tngl.sh" class="mention">@user.tngl.sh</a> is cool</p>`,
48
+
},
49
+
}
50
+
51
+
for _, tt := range tests {
52
+
t.Run(tt.name, func(t *testing.T) {
53
+
md := NewMarkdown()
54
+
55
+
var buf bytes.Buffer
56
+
if err := md.Convert([]byte(tt.markdown), &buf); err != nil {
57
+
t.Fatalf("failed to convert markdown: %v", err)
58
+
}
59
+
60
+
result := buf.String()
61
+
if result != tt.expected+"\n" {
62
+
t.Errorf("expected:\n%s\ngot:\n%s", tt.expected, result)
63
+
}
64
+
})
65
+
}
66
+
}
67
+
68
+
func TestAtExtension_WithOtherMarkdown(t *testing.T) {
69
+
tests := []struct {
70
+
name string
71
+
markdown string
72
+
contains string
73
+
}{
74
+
{
75
+
name: "at mention with bold",
76
+
markdown: "**Hello @user.tngl.sh**",
77
+
contains: `<strong>Hello <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a></strong>`,
78
+
},
79
+
{
80
+
name: "at mention with italic",
81
+
markdown: "*Check @user.tngl.sh*",
82
+
contains: `<em>Check <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a></em>`,
83
+
},
84
+
{
85
+
name: "at mention in list",
86
+
markdown: "- Item 1\n- @user.tngl.sh\n- Item 3",
87
+
contains: `<a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>`,
88
+
},
89
+
{
90
+
name: "at mention in link",
91
+
markdown: "[@regnault.dev](https://regnault.dev)",
92
+
contains: `<a href="https://regnault.dev">@regnault.dev</a>`,
93
+
},
94
+
{
95
+
name: "at mention in link again",
96
+
markdown: "[check out @regnault.dev](https://regnault.dev)",
97
+
contains: `<a href="https://regnault.dev">check out @regnault.dev</a>`,
98
+
},
99
+
{
100
+
name: "at mention in link again, multiline",
101
+
markdown: "[\ncheck out @regnault.dev](https://regnault.dev)",
102
+
contains: "<a href=\"https://regnault.dev\">\ncheck out @regnault.dev</a>",
103
+
},
104
+
}
105
+
106
+
for _, tt := range tests {
107
+
t.Run(tt.name, func(t *testing.T) {
108
+
md := NewMarkdown()
109
+
110
+
var buf bytes.Buffer
111
+
if err := md.Convert([]byte(tt.markdown), &buf); err != nil {
112
+
t.Fatalf("failed to convert markdown: %v", err)
113
+
}
114
+
115
+
result := buf.String()
116
+
if !bytes.Contains([]byte(result), []byte(tt.contains)) {
117
+
t.Errorf("expected output to contain:\n%s\ngot:\n%s", tt.contains, result)
118
+
}
119
+
})
120
+
}
121
+
}
+13
-10
appview/pages/pages.go
+13
-10
appview/pages/pages.go
···
210
210
return tpl.ExecuteTemplate(w, "layouts/base", params)
211
211
}
212
212
213
-
func (p *Pages) Favicon(w io.Writer) error {
214
-
return p.executePlain("fragments/dolly/silhouette", w, nil)
215
-
}
216
-
217
213
type LoginParams struct {
218
214
ReturnUrl string
219
215
ErrorCode string
···
640
636
}
641
637
642
638
func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error {
643
-
return p.executePlain("fragments/starBtn", w, params)
639
+
return p.executePlain("fragments/starBtn-oob", w, params)
644
640
}
645
641
646
642
type RepoIndexParams struct {
···
988
984
LoggedInUser *oauth.User
989
985
RepoInfo repoinfo.RepoInfo
990
986
Issue *models.Issue
991
-
Comment *models.IssueComment
987
+
Comment *models.Comment
992
988
}
993
989
994
990
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
···
999
995
LoggedInUser *oauth.User
1000
996
RepoInfo repoinfo.RepoInfo
1001
997
Issue *models.Issue
1002
-
Comment *models.IssueComment
998
+
Comment *models.Comment
1003
999
}
1004
1000
1005
1001
func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error {
···
1010
1006
LoggedInUser *oauth.User
1011
1007
RepoInfo repoinfo.RepoInfo
1012
1008
Issue *models.Issue
1013
-
Comment *models.IssueComment
1009
+
Comment *models.Comment
1014
1010
}
1015
1011
1016
1012
func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error {
···
1021
1017
LoggedInUser *oauth.User
1022
1018
RepoInfo repoinfo.RepoInfo
1023
1019
Issue *models.Issue
1024
-
Comment *models.IssueComment
1020
+
Comment *models.Comment
1025
1021
}
1026
1022
1027
1023
func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
···
1414
1410
return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
1415
1411
}
1416
1412
1413
+
func (p *Pages) StaticRedirect(target string) http.HandlerFunc {
1414
+
return func(w http.ResponseWriter, r *http.Request) {
1415
+
http.Redirect(w, r, target, http.StatusMovedPermanently)
1416
+
}
1417
+
}
1418
+
1417
1419
func Cache(h http.Handler) http.Handler {
1418
1420
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1419
1421
path := strings.Split(r.URL.Path, "?")[0]
1420
1422
1421
1423
if strings.HasSuffix(path, ".css") {
1422
-
// on day for css files
1424
+
// one day for css files
1423
1425
w.Header().Set("Cache-Control", "public, max-age=86400")
1424
1426
} else {
1427
+
// one year for others
1425
1428
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
1426
1429
}
1427
1430
h.ServeHTTP(w, r)
+5
appview/pages/templates/fragments/starBtn-oob.html
+5
appview/pages/templates/fragments/starBtn-oob.html
+1
-3
appview/pages/templates/fragments/starBtn.html
+1
-3
appview/pages/templates/fragments/starBtn.html
···
1
1
{{ define "fragments/starBtn" }}
2
+
{{/* NOTE: this fragment is always replaced with hx-swap-oob */}}
2
3
<button
3
4
id="starBtn"
4
5
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
···
10
11
{{ end }}
11
12
12
13
hx-trigger="click"
13
-
hx-target="this"
14
-
hx-swap="outerHTML"
15
-
hx-swap-oob='outerHTML:#starBtn[data-star-subject-at="{{ .SubjectAt }}"]'
16
14
hx-disabled-elt="#starBtn"
17
15
>
18
16
{{ if .IsStarred }}
+1
-1
appview/pages/templates/knots/index.html
+1
-1
appview/pages/templates/knots/index.html
···
105
105
{{ define "docsButton" }}
106
106
<a
107
107
class="btn flex items-center gap-2"
108
-
href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md">
108
+
href="https://docs.tangled.org/knot-self-hosting-guide.html#knot-self-hosting-guide">
109
109
{{ i "book" "size-4" }}
110
110
docs
111
111
</a>
+7
-2
appview/pages/templates/layouts/base.html
+7
-2
appview/pages/templates/layouts/base.html
···
7
7
<meta name="description" content="Social coding, but for real this time!"/>
8
8
<meta name="htmx-config" content='{"includeIndicatorStyles": false}'>
9
9
10
+
<!-- favicon/web manifest -->
11
+
<link rel="icon" href="/favicon.ico" sizes="48x48"/>
12
+
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml"/>
13
+
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png"/>
14
+
10
15
<script defer src="/static/htmx.min.js"></script>
11
16
<script defer src="/static/htmx-ext-ws.min.js"></script>
12
17
<script defer src="/static/actor-typeahead.js" type="module"></script>
···
15
20
<link rel="preconnect" href="https://avatar.tangled.sh" />
16
21
<link rel="preconnect" href="https://camo.tangled.sh" />
17
22
18
-
<!-- pwa manifest -->
19
-
<link rel="manifest" href="/pwa-manifest.json" />
23
+
<!-- web app manifest -->
24
+
<link rel="manifest" href="/manifest.webmanifest" />
20
25
21
26
<!-- preload main font -->
22
27
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
+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">
+6
-6
appview/pages/templates/repo/fragments/backlinks.html
+6
-6
appview/pages/templates/repo/fragments/backlinks.html
···
14
14
<div class="flex gap-2 items-center">
15
15
{{ if .State.IsClosed }}
16
16
<span class="text-gray-500 dark:text-gray-400">
17
-
{{ i "ban" "w-4 h-4" }}
17
+
{{ i "ban" "size-3" }}
18
18
</span>
19
19
{{ else if eq .Kind.String "issues" }}
20
20
<span class="text-green-600 dark:text-green-500">
21
-
{{ i "circle-dot" "w-4 h-4" }}
21
+
{{ i "circle-dot" "size-3" }}
22
22
</span>
23
23
{{ else if .State.IsOpen }}
24
24
<span class="text-green-600 dark:text-green-500">
25
-
{{ i "git-pull-request" "w-4 h-4" }}
25
+
{{ i "git-pull-request" "size-3" }}
26
26
</span>
27
27
{{ else if .State.IsMerged }}
28
28
<span class="text-purple-600 dark:text-purple-500">
29
-
{{ i "git-merge" "w-4 h-4" }}
29
+
{{ i "git-merge" "size-3" }}
30
30
</span>
31
31
{{ else }}
32
32
<span class="text-gray-600 dark:text-gray-300">
33
-
{{ i "git-pull-request-closed" "w-4 h-4" }}
33
+
{{ i "git-pull-request-closed" "size-3" }}
34
34
</span>
35
35
{{ end }}
36
-
<a href="{{ . }}"><span class="text-gray-500 dark:text-gray-400">#{{ .SubjectId }}</span> {{ .Title }}</a>
36
+
<a href="{{ . }}" class="line-clamp-1 text-sm"><span class="text-gray-500 dark:text-gray-400">#{{ .SubjectId }}</span> {{ .Title }}</a>
37
37
</div>
38
38
{{ if not (eq $.RepoInfo.FullName $repoUrl) }}
39
39
<div>
+1
-1
appview/pages/templates/repo/fragments/diff.html
+1
-1
appview/pages/templates/repo/fragments/diff.html
···
17
17
{{ else }}
18
18
{{ range $idx, $hunk := $diff }}
19
19
{{ with $hunk }}
20
-
<details open id="file-{{ .Name.New }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}">
20
+
<details open id="file-{{ .Id }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}">
21
21
<summary class="list-none cursor-pointer sticky top-0">
22
22
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
23
23
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
+35
-35
appview/pages/templates/repo/fragments/splitDiff.html
+35
-35
appview/pages/templates/repo/fragments/splitDiff.html
···
3
3
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}}
4
4
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
5
5
{{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
6
-
{{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
6
+
{{- $containerStyle := "inline-flex w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
7
7
{{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}}
8
8
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}}
9
9
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
10
10
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
11
11
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
12
12
<div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700">
13
-
<pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</div>
13
+
<div class="overflow-x-auto col-span-1 font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</span>
14
14
{{- range .LeftLines -}}
15
15
{{- if .IsEmpty -}}
16
-
<div class="{{ $emptyStyle }} {{ $containerStyle }}">
17
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div>
18
-
<div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div>
19
-
<div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div>
20
-
</div>
16
+
<span class="{{ $emptyStyle }} {{ $containerStyle }}">
17
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></span>
18
+
<span class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></span>
19
+
<span class="px-2 invisible" aria-hidden="true">{{ .Content }}</span>
20
+
</span>
21
21
{{- else if eq .Op.String "-" -}}
22
-
<div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
23
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div>
24
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
25
-
<div class="px-2">{{ .Content }}</div>
26
-
</div>
22
+
<span class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
23
+
<span class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></span>
24
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
25
+
<span class="px-2 whitespace-pre">{{ .Content }}</span>
26
+
</span>
27
27
{{- else if eq .Op.String " " -}}
28
-
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
29
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div>
30
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
31
-
<div class="px-2">{{ .Content }}</div>
32
-
</div>
28
+
<span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
29
+
<span class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></span>
30
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
31
+
<span class="px-2 whitespace-pre">{{ .Content }}</span>
32
+
</span>
33
33
{{- end -}}
34
34
{{- end -}}
35
-
{{- end -}}</div></div></pre>
35
+
{{- end -}}</div></div></div>
36
36
37
-
<pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</div>
37
+
<div class="overflow-x-auto col-span-1 font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</span>
38
38
{{- range .RightLines -}}
39
39
{{- if .IsEmpty -}}
40
-
<div class="{{ $emptyStyle }} {{ $containerStyle }}">
41
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div>
42
-
<div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div>
43
-
<div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div>
44
-
</div>
40
+
<span class="{{ $emptyStyle }} {{ $containerStyle }}">
41
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></span>
42
+
<span class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></span>
43
+
<span class="px-2 invisible" aria-hidden="true">{{ .Content }}</span>
44
+
</span>
45
45
{{- else if eq .Op.String "+" -}}
46
-
<div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
47
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div>
48
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
49
-
<div class="px-2" >{{ .Content }}</div>
50
-
</div>
46
+
<span class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
47
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></span>
48
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
49
+
<span class="px-2 whitespace-pre">{{ .Content }}</span>
50
+
</span>
51
51
{{- else if eq .Op.String " " -}}
52
-
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
53
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div>
54
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
55
-
<div class="px-2">{{ .Content }}</div>
56
-
</div>
52
+
<span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
53
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a> </span>
54
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
55
+
<span class="px-2 whitespace-pre">{{ .Content }}</span>
56
+
</span>
57
57
{{- end -}}
58
58
{{- end -}}
59
-
{{- end -}}</div></div></pre>
59
+
{{- end -}}</div></div></div>
60
60
</div>
61
61
{{ end }}
+21
-22
appview/pages/templates/repo/fragments/unifiedDiff.html
+21
-22
appview/pages/templates/repo/fragments/unifiedDiff.html
···
1
1
{{ define "repo/fragments/unifiedDiff" }}
2
2
{{ $name := .Id }}
3
-
<pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</div>
3
+
<div class="overflow-x-auto font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</span>
4
4
{{- $oldStart := .OldPosition -}}
5
5
{{- $newStart := .NewPosition -}}
6
6
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 target:bg-yellow-200 target:dark:bg-yellow-600" -}}
7
7
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
8
8
{{- $lineNrSepStyle1 := "" -}}
9
9
{{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
10
-
{{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
10
+
{{- $containerStyle := "inline-flex w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
11
11
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}}
12
12
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
13
13
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
14
14
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
15
15
{{- range .Lines -}}
16
16
{{- if eq .Op.String "+" -}}
17
-
<div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}">
18
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div>
19
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
20
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
21
-
<div class="px-2">{{ .Line }}</div>
22
-
</div>
17
+
<span class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}">
18
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></span>
19
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></span>
20
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
21
+
<span class="px-2 whitespace-pre">{{ .Line }}</span>
22
+
</span>
23
23
{{- $newStart = add64 $newStart 1 -}}
24
24
{{- end -}}
25
25
{{- if eq .Op.String "-" -}}
26
-
<div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}">
27
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
28
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div>
29
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
30
-
<div class="px-2">{{ .Line }}</div>
31
-
</div>
26
+
<span class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}">
27
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></span>
28
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></span>
29
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
30
+
<span class="px-2 whitespace-pre">{{ .Line }}</span>
31
+
</span>
32
32
{{- $oldStart = add64 $oldStart 1 -}}
33
33
{{- end -}}
34
34
{{- if eq .Op.String " " -}}
35
-
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}">
36
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></div>
37
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></div>
38
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
39
-
<div class="px-2">{{ .Line }}</div>
40
-
</div>
35
+
<span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}">
36
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></span>
37
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></span>
38
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
39
+
<span class="px-2 whitespace-pre">{{ .Line }}</span>
40
+
</span>
41
41
{{- $newStart = add64 $newStart 1 -}}
42
42
{{- $oldStart = add64 $oldStart 1 -}}
43
43
{{- end -}}
44
44
{{- end -}}
45
-
{{- end -}}</div></div></pre>
45
+
{{- end -}}</div></div></div>
46
46
{{ end }}
47
-
+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 }}
3
+
{{ template "user/fragments/picHandleLink" .Comment.Did.String }}
4
4
{{ template "hats" $ }}
5
5
{{ template "timestamp" . }}
6
-
{{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }}
6
+
{{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did.String) }}
7
7
{{ if and $isCommentOwner (not .Comment.Deleted) }}
8
8
{{ template "editIssueComment" . }}
9
9
{{ template "deleteIssueComment" . }}
+1
-1
appview/pages/templates/repo/pipelines/pipelines.html
+1
-1
appview/pages/templates/repo/pipelines/pipelines.html
···
23
23
</p>
24
24
<p>
25
25
<span class="{{ $bullet }}">2</span>Configure your CI/CD
26
-
<a href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/pipeline.md" class="underline">pipeline</a>.
26
+
<a href="https://docs.tangled.org/spindles.html#pipelines" class="underline">pipeline</a>.
27
27
</p>
28
28
<p><span class="{{ $bullet }}">3</span>Trigger a workflow with a push or a pull-request!</p>
29
29
</div>
+14
appview/pages/templates/repo/pipelines/workflow.html
+14
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
+
<!-- TODO(boltless): explictly check for pipeline cancel permission -->
16
+
{{ if $.RepoInfo.Roles.IsOwner }}
17
+
<div class="flex justify-between mb-2">
18
+
<div id="workflow-error" class="text-red-500 dark:text-red-400"></div>
19
+
<button
20
+
class="btn"
21
+
hx-post="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/workflow/{{ .Workflow }}/cancel"
22
+
hx-swap="none"
23
+
{{ if (index .Pipeline.Statuses .Workflow).Latest.Status.IsFinish -}}
24
+
disabled
25
+
{{- end }}
26
+
>Cancel</button>
27
+
</div>
28
+
{{ end }}
15
29
{{ block "logs" . }} {{ end }}
16
30
</div>
17
31
</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.OwnerDid }}
173
+
{{ template "user/fragments/picHandleLink" $c.Did.String }}
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 }}
+1
-1
appview/pages/templates/repo/settings/pipelines.html
+1
-1
appview/pages/templates/repo/settings/pipelines.html
···
22
22
<p class="text-gray-500 dark:text-gray-400">
23
23
Choose a spindle to execute your workflows on. Only repository owners
24
24
can configure spindles. Spindles can be selfhosted,
25
-
<a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md">
25
+
<a class="text-gray-500 dark:text-gray-400 underline" href="https://docs.tangled.org/spindles.html#self-hosting-guide">
26
26
click to learn more.
27
27
</a>
28
28
</p>
+1
-1
appview/pages/templates/spindles/index.html
+1
-1
appview/pages/templates/spindles/index.html
···
102
102
{{ define "docsButton" }}
103
103
<a
104
104
class="btn flex items-center gap-2"
105
-
href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md">
105
+
href="https://docs.tangled.org/spindles.html#self-hosting-guide">
106
106
{{ i "book" "size-4" }}
107
107
docs
108
108
</a>
+1
-1
appview/pages/templates/strings/fragments/form.html
+1
-1
appview/pages/templates/strings/fragments/form.html
···
31
31
name="content"
32
32
id="content-textarea"
33
33
wrap="off"
34
-
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 font-mono"
34
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
35
35
rows="20"
36
36
spellcheck="false"
37
37
placeholder="Paste your string here!"
+1
-1
appview/pages/templates/strings/string.html
+1
-1
appview/pages/templates/strings/string.html
···
17
17
<span class="select-none">/</span>
18
18
<a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a>
19
19
</div>
20
-
<div class="flex gap-2 text-base">
20
+
<div class="flex gap-2 items-stretch text-base">
21
21
{{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }}
22
22
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
23
23
hx-boost="true"
+1
-1
appview/pages/templates/user/completeSignup.html
+1
-1
appview/pages/templates/user/completeSignup.html
···
20
20
content="complete your signup for tangled"
21
21
/>
22
22
<script src="/static/htmx.min.js"></script>
23
-
<link rel="manifest" href="/pwa-manifest.json" />
23
+
<link rel="manifest" href="/manifest.webmanifest" />
24
24
<link
25
25
rel="stylesheet"
26
26
href="/static/tw.css?{{ cssContentHash }}"
+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>
+1
-1
appview/pages/templates/user/login.html
+1
-1
appview/pages/templates/user/login.html
···
8
8
<meta property="og:url" content="https://tangled.org/login" />
9
9
<meta property="og:description" content="login to for tangled" />
10
10
<script src="/static/htmx.min.js"></script>
11
-
<link rel="manifest" href="/pwa-manifest.json" />
11
+
<link rel="manifest" href="/manifest.webmanifest" />
12
12
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
13
13
<title>login · tangled</title>
14
14
</head>
+10
-7
appview/pages/templates/user/signup.html
+10
-7
appview/pages/templates/user/signup.html
···
8
8
<meta property="og:url" content="https://tangled.org/signup" />
9
9
<meta property="og:description" content="sign up for tangled" />
10
10
<script src="/static/htmx.min.js"></script>
11
-
<link rel="manifest" href="/pwa-manifest.json" />
11
+
<link rel="manifest" href="/manifest.webmanifest" />
12
12
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
13
13
<title>sign up · tangled</title>
14
14
···
43
43
page to complete your registration.
44
44
</span>
45
45
<div class="w-full mt-4 text-center">
46
-
<div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div>
46
+
<div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}" data-size="flexible"></div>
47
47
</div>
48
48
<button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
49
49
<span>join now</span>
50
50
</button>
51
+
<p class="text-sm text-gray-500">
52
+
Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>.
53
+
</p>
54
+
55
+
<p id="signup-msg" class="error w-full"></p>
56
+
<p class="text-sm text-gray-500 pt-4">
57
+
By signing up, you agree to our <a href="/terms" class="underline">Terms of Service</a> and <a href="/privacy" class="underline">Privacy Policy</a>.
58
+
</p>
51
59
</form>
52
-
<p class="text-sm text-gray-500">
53
-
Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>.
54
-
</p>
55
-
56
-
<p id="signup-msg" class="error w-full"></p>
57
60
</main>
58
61
</body>
59
62
</html>
+101
-15
appview/pipelines/pipelines.go
+101
-15
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/middleware"
17
+
"tangled.org/core/appview/models"
14
18
"tangled.org/core/appview/oauth"
15
19
"tangled.org/core/appview/pages"
16
20
"tangled.org/core/appview/reporesolver"
17
21
"tangled.org/core/eventconsumer"
18
22
"tangled.org/core/idresolver"
23
+
"tangled.org/core/orm"
19
24
"tangled.org/core/rbac"
20
25
spindlemodel "tangled.org/core/spindle/models"
21
26
···
35
40
logger *slog.Logger
36
41
}
37
42
38
-
func (p *Pipelines) Router() http.Handler {
43
+
func (p *Pipelines) Router(mw *middleware.Middleware) http.Handler {
39
44
r := chi.NewRouter()
40
45
r.Get("/", p.Index)
41
46
r.Get("/{pipeline}/workflow/{workflow}", p.Workflow)
42
47
r.Get("/{pipeline}/workflow/{workflow}/logs", p.Logs)
48
+
r.
49
+
With(mw.RepoPermissionMiddleware("repo:owner")).
50
+
Post("/{pipeline}/workflow/{workflow}/cancel", p.Cancel)
43
51
44
52
return r
45
53
}
···
81
89
ps, err := db.GetPipelineStatuses(
82
90
p.db,
83
91
30,
84
-
db.FilterEq("repo_owner", f.Did),
85
-
db.FilterEq("repo_name", f.Name),
86
-
db.FilterEq("knot", f.Knot),
92
+
orm.FilterEq("repo_owner", f.Did),
93
+
orm.FilterEq("repo_name", f.Name),
94
+
orm.FilterEq("knot", f.Knot),
87
95
)
88
96
if err != nil {
89
97
l.Error("failed to query db", "err", err)
···
122
130
ps, err := db.GetPipelineStatuses(
123
131
p.db,
124
132
1,
125
-
db.FilterEq("repo_owner", f.Did),
126
-
db.FilterEq("repo_name", f.Name),
127
-
db.FilterEq("knot", f.Knot),
128
-
db.FilterEq("id", pipelineId),
133
+
orm.FilterEq("repo_owner", f.Did),
134
+
orm.FilterEq("repo_name", f.Name),
135
+
orm.FilterEq("knot", f.Knot),
136
+
orm.FilterEq("id", pipelineId),
129
137
)
130
138
if err != nil {
131
139
l.Error("failed to query db", "err", err)
···
189
197
ps, err := db.GetPipelineStatuses(
190
198
p.db,
191
199
1,
192
-
db.FilterEq("repo_owner", f.Did),
193
-
db.FilterEq("repo_name", f.Name),
194
-
db.FilterEq("knot", f.Knot),
195
-
db.FilterEq("id", pipelineId),
200
+
orm.FilterEq("repo_owner", f.Did),
201
+
orm.FilterEq("repo_name", f.Name),
202
+
orm.FilterEq("knot", f.Knot),
203
+
orm.FilterEq("id", pipelineId),
196
204
)
197
205
if err != nil || len(ps) != 1 {
198
206
l.Error("pipeline query failed", "err", err, "count", len(ps))
···
211
219
}
212
220
213
221
scheme := "wss"
214
-
if p.config.Core.Dev {
215
-
scheme = "ws"
216
-
}
222
+
// if p.config.Core.Dev {
223
+
// scheme = "ws"
224
+
// }
217
225
218
226
url := scheme + "://" + strings.Join([]string{spindle, "logs", knot, rkey, workflow}, "/")
219
227
l = l.With("url", url)
···
313
321
}
314
322
}
315
323
}
324
+
}
325
+
326
+
func (p *Pipelines) Cancel(w http.ResponseWriter, r *http.Request) {
327
+
l := p.logger.With("handler", "Cancel")
328
+
329
+
var (
330
+
pipelineId = chi.URLParam(r, "pipeline")
331
+
workflow = chi.URLParam(r, "workflow")
332
+
)
333
+
if pipelineId == "" || workflow == "" {
334
+
http.Error(w, "missing pipeline ID or workflow", http.StatusBadRequest)
335
+
return
336
+
}
337
+
338
+
f, err := p.repoResolver.Resolve(r)
339
+
if err != nil {
340
+
l.Error("failed to get repo and knot", "err", err)
341
+
http.Error(w, "bad repo/knot", http.StatusBadRequest)
342
+
return
343
+
}
344
+
345
+
pipeline, err := func() (models.Pipeline, error) {
346
+
ps, err := db.GetPipelineStatuses(
347
+
p.db,
348
+
1,
349
+
orm.FilterEq("repo_owner", f.Did),
350
+
orm.FilterEq("repo_name", f.Name),
351
+
orm.FilterEq("knot", f.Knot),
352
+
orm.FilterEq("id", pipelineId),
353
+
)
354
+
if err != nil {
355
+
return models.Pipeline{}, err
356
+
}
357
+
if len(ps) != 1 {
358
+
return models.Pipeline{}, fmt.Errorf("wrong pipeline count %d", len(ps))
359
+
}
360
+
return ps[0], nil
361
+
}()
362
+
if err != nil {
363
+
l.Error("pipeline query failed", "err", err)
364
+
http.Error(w, "pipeline not found", http.StatusNotFound)
365
+
}
366
+
var (
367
+
spindle = f.Spindle
368
+
knot = f.Knot
369
+
rkey = pipeline.Rkey
370
+
)
371
+
372
+
if spindle == "" || knot == "" || rkey == "" {
373
+
http.Error(w, "invalid repo info", http.StatusBadRequest)
374
+
return
375
+
}
376
+
377
+
spindleClient, err := p.oauth.ServiceClient(
378
+
r,
379
+
oauth.WithService(f.Spindle),
380
+
oauth.WithLxm(tangled.PipelineCancelPipelineNSID),
381
+
oauth.WithDev(false),
382
+
oauth.WithTimeout(time.Second*30), // workflow cleanup usually takes time
383
+
)
384
+
385
+
err = tangled.PipelineCancelPipeline(
386
+
r.Context(),
387
+
spindleClient,
388
+
&tangled.PipelineCancelPipeline_Input{
389
+
Repo: string(f.RepoAt()),
390
+
Pipeline: pipeline.AtUri().String(),
391
+
Workflow: workflow,
392
+
},
393
+
)
394
+
err = fmt.Errorf("boo! new error")
395
+
errorId := "workflow-error"
396
+
if err != nil {
397
+
l.Error("failed to cancel workflow", "err", err)
398
+
p.pages.Notice(w, errorId, "Failed to cancel workflow")
399
+
return
400
+
}
401
+
l.Debug("canceled pipeline", "uri", pipeline.AtUri())
316
402
}
317
403
318
404
// either a message or an error
+2
-1
appview/pulls/opengraph.go
+2
-1
appview/pulls/opengraph.go
···
13
13
"tangled.org/core/appview/db"
14
14
"tangled.org/core/appview/models"
15
15
"tangled.org/core/appview/ogcard"
16
+
"tangled.org/core/orm"
16
17
"tangled.org/core/patchutil"
17
18
"tangled.org/core/types"
18
19
)
···
276
277
}
277
278
278
279
// Get comment count from database
279
-
comments, err := db.GetPullComments(s.db, db.FilterEq("pull_id", pull.ID))
280
+
comments, err := db.GetComments(s.db, orm.FilterEq("subject_at", pull.AtUri()))
280
281
if err != nil {
281
282
log.Printf("failed to get pull comments: %v", err)
282
283
}
+128
-106
appview/pulls/pulls.go
+128
-106
appview/pulls/pulls.go
···
19
19
"tangled.org/core/appview/config"
20
20
"tangled.org/core/appview/db"
21
21
pulls_indexer "tangled.org/core/appview/indexer/pulls"
22
+
"tangled.org/core/appview/mentions"
22
23
"tangled.org/core/appview/models"
23
24
"tangled.org/core/appview/notify"
24
25
"tangled.org/core/appview/oauth"
25
26
"tangled.org/core/appview/pages"
26
27
"tangled.org/core/appview/pages/markup"
27
28
"tangled.org/core/appview/pages/repoinfo"
28
-
"tangled.org/core/appview/refresolver"
29
29
"tangled.org/core/appview/reporesolver"
30
30
"tangled.org/core/appview/validator"
31
31
"tangled.org/core/appview/xrpcclient"
32
32
"tangled.org/core/idresolver"
33
+
"tangled.org/core/orm"
33
34
"tangled.org/core/patchutil"
34
35
"tangled.org/core/rbac"
35
36
"tangled.org/core/tid"
···
44
45
)
45
46
46
47
type Pulls struct {
47
-
oauth *oauth.OAuth
48
-
repoResolver *reporesolver.RepoResolver
49
-
pages *pages.Pages
50
-
idResolver *idresolver.Resolver
51
-
refResolver *refresolver.Resolver
52
-
db *db.DB
53
-
config *config.Config
54
-
notifier notify.Notifier
55
-
enforcer *rbac.Enforcer
56
-
logger *slog.Logger
57
-
validator *validator.Validator
58
-
indexer *pulls_indexer.Indexer
48
+
oauth *oauth.OAuth
49
+
repoResolver *reporesolver.RepoResolver
50
+
pages *pages.Pages
51
+
idResolver *idresolver.Resolver
52
+
mentionsResolver *mentions.Resolver
53
+
db *db.DB
54
+
config *config.Config
55
+
notifier notify.Notifier
56
+
enforcer *rbac.Enforcer
57
+
logger *slog.Logger
58
+
validator *validator.Validator
59
+
indexer *pulls_indexer.Indexer
59
60
}
60
61
61
62
func New(
···
63
64
repoResolver *reporesolver.RepoResolver,
64
65
pages *pages.Pages,
65
66
resolver *idresolver.Resolver,
66
-
refResolver *refresolver.Resolver,
67
+
mentionsResolver *mentions.Resolver,
67
68
db *db.DB,
68
69
config *config.Config,
69
70
notifier notify.Notifier,
···
73
74
logger *slog.Logger,
74
75
) *Pulls {
75
76
return &Pulls{
76
-
oauth: oauth,
77
-
repoResolver: repoResolver,
78
-
pages: pages,
79
-
idResolver: resolver,
80
-
refResolver: refResolver,
81
-
db: db,
82
-
config: config,
83
-
notifier: notifier,
84
-
enforcer: enforcer,
85
-
logger: logger,
86
-
validator: validator,
87
-
indexer: indexer,
77
+
oauth: oauth,
78
+
repoResolver: repoResolver,
79
+
pages: pages,
80
+
idResolver: resolver,
81
+
mentionsResolver: mentionsResolver,
82
+
db: db,
83
+
config: config,
84
+
notifier: notifier,
85
+
enforcer: enforcer,
86
+
logger: logger,
87
+
validator: validator,
88
+
indexer: indexer,
88
89
}
89
90
}
90
91
···
190
191
ps, err := db.GetPipelineStatuses(
191
192
s.db,
192
193
len(shas),
193
-
db.FilterEq("repo_owner", f.Did),
194
-
db.FilterEq("repo_name", f.Name),
195
-
db.FilterEq("knot", f.Knot),
196
-
db.FilterIn("sha", shas),
194
+
orm.FilterEq("repo_owner", f.Did),
195
+
orm.FilterEq("repo_name", f.Name),
196
+
orm.FilterEq("knot", f.Knot),
197
+
orm.FilterIn("sha", shas),
197
198
)
198
199
if err != nil {
199
200
log.Printf("failed to fetch pipeline statuses: %s", err)
···
217
218
218
219
labelDefs, err := db.GetLabelDefinitions(
219
220
s.db,
220
-
db.FilterIn("at_uri", f.Labels),
221
-
db.FilterContains("scope", tangled.RepoPullNSID),
221
+
orm.FilterIn("at_uri", f.Labels),
222
+
orm.FilterContains("scope", tangled.RepoPullNSID),
222
223
)
223
224
if err != nil {
224
225
log.Println("failed to fetch labels", err)
···
597
598
598
599
pulls, err := db.GetPulls(
599
600
s.db,
600
-
db.FilterIn("id", ids),
601
+
orm.FilterIn("id", ids),
601
602
)
602
603
if err != nil {
603
604
log.Println("failed to get pulls", err)
···
648
649
ps, err := db.GetPipelineStatuses(
649
650
s.db,
650
651
len(shas),
651
-
db.FilterEq("repo_owner", f.Did),
652
-
db.FilterEq("repo_name", f.Name),
653
-
db.FilterEq("knot", f.Knot),
654
-
db.FilterIn("sha", shas),
652
+
orm.FilterEq("repo_owner", f.Did),
653
+
orm.FilterEq("repo_name", f.Name),
654
+
orm.FilterEq("knot", f.Knot),
655
+
orm.FilterIn("sha", shas),
655
656
)
656
657
if err != nil {
657
658
log.Printf("failed to fetch pipeline statuses: %s", err)
···
664
665
665
666
labelDefs, err := db.GetLabelDefinitions(
666
667
s.db,
667
-
db.FilterIn("at_uri", f.Labels),
668
-
db.FilterContains("scope", tangled.RepoPullNSID),
668
+
orm.FilterIn("at_uri", f.Labels),
669
+
orm.FilterContains("scope", tangled.RepoPullNSID),
669
670
)
670
671
if err != nil {
671
672
log.Println("failed to fetch labels", err)
···
729
730
return
730
731
}
731
732
732
-
mentions, references := s.refResolver.Resolve(r.Context(), body)
733
+
mentions, references := s.mentionsResolver.Resolve(r.Context(), body)
733
734
734
735
// Start a transaction
735
736
tx, err := s.db.BeginTx(r.Context(), nil)
···
740
741
}
741
742
defer tx.Rollback()
742
743
743
-
createdAt := time.Now().Format(time.RFC3339)
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
761
745
762
client, err := s.oauth.AuthorizedClient(r)
746
763
if err != nil {
···
748
765
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
749
766
return
750
767
}
751
-
atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
752
-
Collection: tangled.RepoPullCommentNSID,
768
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
769
+
Collection: tangled.CommentNSID,
753
770
Repo: user.Did,
754
-
Rkey: tid.TID(),
771
+
Rkey: comment.Rkey,
755
772
Record: &lexutil.LexiconTypeDecoder{
756
-
Val: &tangled.RepoPullComment{
757
-
Pull: pull.AtUri().String(),
758
-
Body: body,
759
-
CreatedAt: createdAt,
760
-
},
773
+
Val: &record,
761
774
},
762
775
})
763
776
if err != nil {
···
766
779
return
767
780
}
768
781
769
-
comment := &models.PullComment{
770
-
OwnerDid: user.Did,
771
-
RepoAt: f.RepoAt().String(),
772
-
PullId: pull.PullId,
773
-
Body: body,
774
-
CommentAt: atResp.Uri,
775
-
SubmissionId: pull.Submissions[roundNumber].ID,
776
-
Mentions: mentions,
777
-
References: references,
778
-
}
779
-
780
782
// Create the pull comment in the database with the commentAt field
781
-
commentId, err := db.NewPullComment(tx, comment)
783
+
err = db.PutComment(tx, &comment)
782
784
if err != nil {
783
785
log.Println("failed to create pull comment", err)
784
786
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
···
792
794
return
793
795
}
794
796
795
-
s.notifier.NewPullComment(r.Context(), comment, mentions)
797
+
s.notifier.NewComment(r.Context(), &comment)
796
798
797
799
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
798
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId))
800
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, comment.Id))
799
801
return
800
802
}
801
803
}
···
1205
1207
}
1206
1208
}
1207
1209
1208
-
mentions, references := s.refResolver.Resolve(r.Context(), body)
1210
+
mentions, references := s.mentionsResolver.Resolve(r.Context(), body)
1209
1211
1210
1212
rkey := tid.TID()
1211
1213
initialSubmission := models.PullSubmission{
···
1240
1242
return
1241
1243
}
1242
1244
1245
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
1246
+
if err != nil {
1247
+
log.Println("failed to upload patch", err)
1248
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1249
+
return
1250
+
}
1251
+
1243
1252
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1244
1253
Collection: tangled.RepoPullNSID,
1245
1254
Repo: user.Did,
···
1251
1260
Repo: string(repo.RepoAt()),
1252
1261
Branch: targetBranch,
1253
1262
},
1254
-
Patch: patch,
1263
+
PatchBlob: blob.Blob,
1255
1264
Source: recordPullSource,
1256
1265
CreatedAt: time.Now().Format(time.RFC3339),
1257
1266
},
···
1327
1336
// apply all record creations at once
1328
1337
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1329
1338
for _, p := range stack {
1339
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(p.LatestPatch()))
1340
+
if err != nil {
1341
+
log.Println("failed to upload patch blob", err)
1342
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1343
+
return
1344
+
}
1345
+
1330
1346
record := p.AsRecord()
1331
-
write := comatproto.RepoApplyWrites_Input_Writes_Elem{
1347
+
record.PatchBlob = blob.Blob
1348
+
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1332
1349
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
1333
1350
Collection: tangled.RepoPullNSID,
1334
1351
Rkey: &p.Rkey,
···
1336
1353
Val: &record,
1337
1354
},
1338
1355
},
1339
-
}
1340
-
writes = append(writes, &write)
1356
+
})
1341
1357
}
1342
1358
_, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
1343
1359
Repo: user.Did,
···
1365
1381
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1366
1382
return
1367
1383
}
1384
+
1368
1385
}
1369
1386
1370
1387
if err = tx.Commit(); err != nil {
1371
1388
log.Println("failed to create pull request", err)
1372
1389
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1373
1390
return
1391
+
}
1392
+
1393
+
// notify about each pull
1394
+
//
1395
+
// this is performed after tx.Commit, because it could result in a locked DB otherwise
1396
+
for _, p := range stack {
1397
+
s.notifier.NewPull(r.Context(), p)
1374
1398
}
1375
1399
1376
1400
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
···
1498
1522
// fork repo
1499
1523
repo, err := db.GetRepo(
1500
1524
s.db,
1501
-
db.FilterEq("did", forkOwnerDid),
1502
-
db.FilterEq("name", forkName),
1525
+
orm.FilterEq("did", forkOwnerDid),
1526
+
orm.FilterEq("name", forkName),
1503
1527
)
1504
1528
if err != nil {
1505
1529
log.Println("failed to get repo", "did", forkOwnerDid, "name", forkName, "err", err)
···
1862
1886
return
1863
1887
}
1864
1888
1865
-
var recordPullSource *tangled.RepoPull_Source
1866
-
if pull.IsBranchBased() {
1867
-
recordPullSource = &tangled.RepoPull_Source{
1868
-
Branch: pull.PullSource.Branch,
1869
-
Sha: sourceRev,
1870
-
}
1889
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
1890
+
if err != nil {
1891
+
log.Println("failed to upload patch blob", err)
1892
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1893
+
return
1871
1894
}
1872
-
if pull.IsForkBased() {
1873
-
repoAt := pull.PullSource.RepoAt.String()
1874
-
recordPullSource = &tangled.RepoPull_Source{
1875
-
Branch: pull.PullSource.Branch,
1876
-
Repo: &repoAt,
1877
-
Sha: sourceRev,
1878
-
}
1879
-
}
1895
+
record := pull.AsRecord()
1896
+
record.PatchBlob = blob.Blob
1897
+
record.CreatedAt = time.Now().Format(time.RFC3339)
1880
1898
1881
1899
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1882
1900
Collection: tangled.RepoPullNSID,
···
1884
1902
Rkey: pull.Rkey,
1885
1903
SwapRecord: ex.Cid,
1886
1904
Record: &lexutil.LexiconTypeDecoder{
1887
-
Val: &tangled.RepoPull{
1888
-
Title: pull.Title,
1889
-
Target: &tangled.RepoPull_Target{
1890
-
Repo: string(repo.RepoAt()),
1891
-
Branch: pull.TargetBranch,
1892
-
},
1893
-
Patch: patch, // new patch
1894
-
Source: recordPullSource,
1895
-
CreatedAt: time.Now().Format(time.RFC3339),
1896
-
},
1905
+
Val: &record,
1897
1906
},
1898
1907
})
1899
1908
if err != nil {
···
1978
1987
return
1979
1988
}
1980
1989
defer tx.Rollback()
1990
+
1991
+
client, err := s.oauth.AuthorizedClient(r)
1992
+
if err != nil {
1993
+
log.Println("failed to authorize client")
1994
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1995
+
return
1996
+
}
1981
1997
1982
1998
// pds updates to make
1983
1999
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
···
2012
2028
return
2013
2029
}
2014
2030
2031
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
2032
+
if err != nil {
2033
+
log.Println("failed to upload patch blob", err)
2034
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
2035
+
return
2036
+
}
2015
2037
record := p.AsRecord()
2038
+
record.PatchBlob = blob.Blob
2016
2039
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2017
2040
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
2018
2041
Collection: tangled.RepoPullNSID,
···
2047
2070
return
2048
2071
}
2049
2072
2073
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
2074
+
if err != nil {
2075
+
log.Println("failed to upload patch blob", err)
2076
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
2077
+
return
2078
+
}
2050
2079
record := np.AsRecord()
2051
-
2080
+
record.PatchBlob = blob.Blob
2052
2081
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2053
2082
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
2054
2083
Collection: tangled.RepoPullNSID,
···
2066
2095
tx,
2067
2096
p.ParentChangeId,
2068
2097
// these should be enough filters to be unique per-stack
2069
-
db.FilterEq("repo_at", p.RepoAt.String()),
2070
-
db.FilterEq("owner_did", p.OwnerDid),
2071
-
db.FilterEq("change_id", p.ChangeId),
2098
+
orm.FilterEq("repo_at", p.RepoAt.String()),
2099
+
orm.FilterEq("owner_did", p.OwnerDid),
2100
+
orm.FilterEq("change_id", p.ChangeId),
2072
2101
)
2073
2102
2074
2103
if err != nil {
···
2082
2111
if err != nil {
2083
2112
log.Println("failed to resubmit pull", err)
2084
2113
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2085
-
return
2086
-
}
2087
-
2088
-
client, err := s.oauth.AuthorizedClient(r)
2089
-
if err != nil {
2090
-
log.Println("failed to authorize client")
2091
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
2092
2114
return
2093
2115
}
2094
2116
···
2397
2419
body := fp.Body
2398
2420
rkey := tid.TID()
2399
2421
2400
-
mentions, references := s.refResolver.Resolve(ctx, body)
2422
+
mentions, references := s.mentionsResolver.Resolve(ctx, body)
2401
2423
2402
2424
initialSubmission := models.PullSubmission{
2403
2425
Patch: fp.Raw,
-65
appview/refresolver/resolver.go
-65
appview/refresolver/resolver.go
···
1
-
package refresolver
2
-
3
-
import (
4
-
"context"
5
-
"log/slog"
6
-
7
-
"github.com/bluesky-social/indigo/atproto/syntax"
8
-
"tangled.org/core/appview/config"
9
-
"tangled.org/core/appview/db"
10
-
"tangled.org/core/appview/models"
11
-
"tangled.org/core/appview/pages/markup"
12
-
"tangled.org/core/idresolver"
13
-
)
14
-
15
-
type Resolver struct {
16
-
config *config.Config
17
-
idResolver *idresolver.Resolver
18
-
execer db.Execer
19
-
logger *slog.Logger
20
-
}
21
-
22
-
func New(
23
-
config *config.Config,
24
-
idResolver *idresolver.Resolver,
25
-
execer db.Execer,
26
-
logger *slog.Logger,
27
-
) *Resolver {
28
-
return &Resolver{
29
-
config,
30
-
idResolver,
31
-
execer,
32
-
logger,
33
-
}
34
-
}
35
-
36
-
func (r *Resolver) Resolve(ctx context.Context, source string) ([]syntax.DID, []syntax.ATURI) {
37
-
l := r.logger.With("method", "Resolve")
38
-
rawMentions, rawRefs := markup.FindReferences(r.config.Core.AppviewHost, source)
39
-
l.Debug("found possible references", "mentions", rawMentions, "refs", rawRefs)
40
-
idents := r.idResolver.ResolveIdents(ctx, rawMentions)
41
-
var mentions []syntax.DID
42
-
for _, ident := range idents {
43
-
if ident != nil && !ident.Handle.IsInvalidHandle() {
44
-
mentions = append(mentions, ident.DID)
45
-
}
46
-
}
47
-
l.Debug("found mentions", "mentions", mentions)
48
-
49
-
var resolvedRefs []models.ReferenceLink
50
-
for _, rawRef := range rawRefs {
51
-
ident, err := r.idResolver.ResolveIdent(ctx, rawRef.Handle)
52
-
if err != nil || ident == nil || ident.Handle.IsInvalidHandle() {
53
-
continue
54
-
}
55
-
rawRef.Handle = string(ident.DID)
56
-
resolvedRefs = append(resolvedRefs, rawRef)
57
-
}
58
-
aturiRefs, err := db.ValidateReferenceLinks(r.execer, resolvedRefs)
59
-
if err != nil {
60
-
l.Error("failed running query", "err", err)
61
-
}
62
-
l.Debug("found references", "refs", aturiRefs)
63
-
64
-
return mentions, aturiRefs
65
-
}
+10
-9
appview/repo/artifact.go
+10
-9
appview/repo/artifact.go
···
15
15
"tangled.org/core/appview/models"
16
16
"tangled.org/core/appview/pages"
17
17
"tangled.org/core/appview/xrpcclient"
18
+
"tangled.org/core/orm"
18
19
"tangled.org/core/tid"
19
20
"tangled.org/core/types"
20
21
···
155
156
156
157
artifacts, err := db.GetArtifact(
157
158
rp.db,
158
-
db.FilterEq("repo_at", f.RepoAt()),
159
-
db.FilterEq("tag", tag.Tag.Hash[:]),
160
-
db.FilterEq("name", filename),
159
+
orm.FilterEq("repo_at", f.RepoAt()),
160
+
orm.FilterEq("tag", tag.Tag.Hash[:]),
161
+
orm.FilterEq("name", filename),
161
162
)
162
163
if err != nil {
163
164
log.Println("failed to get artifacts", err)
···
234
235
235
236
artifacts, err := db.GetArtifact(
236
237
rp.db,
237
-
db.FilterEq("repo_at", f.RepoAt()),
238
-
db.FilterEq("tag", tag[:]),
239
-
db.FilterEq("name", filename),
238
+
orm.FilterEq("repo_at", f.RepoAt()),
239
+
orm.FilterEq("tag", tag[:]),
240
+
orm.FilterEq("name", filename),
240
241
)
241
242
if err != nil {
242
243
log.Println("failed to get artifacts", err)
···
276
277
defer tx.Rollback()
277
278
278
279
err = db.DeleteArtifact(tx,
279
-
db.FilterEq("repo_at", f.RepoAt()),
280
-
db.FilterEq("tag", artifact.Tag[:]),
281
-
db.FilterEq("name", filename),
280
+
orm.FilterEq("repo_at", f.RepoAt()),
281
+
orm.FilterEq("tag", artifact.Tag[:]),
282
+
orm.FilterEq("name", filename),
282
283
)
283
284
if err != nil {
284
285
log.Println("failed to remove artifact record from db", err)
+3
-2
appview/repo/feed.go
+3
-2
appview/repo/feed.go
···
11
11
"tangled.org/core/appview/db"
12
12
"tangled.org/core/appview/models"
13
13
"tangled.org/core/appview/pagination"
14
+
"tangled.org/core/orm"
14
15
15
16
"github.com/bluesky-social/indigo/atproto/identity"
16
17
"github.com/bluesky-social/indigo/atproto/syntax"
···
20
21
func (rp *Repo) getRepoFeed(ctx context.Context, repo *models.Repo, ownerSlashRepo string) (*feeds.Feed, error) {
21
22
const feedLimitPerType = 100
22
23
23
-
pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", repo.RepoAt()))
24
+
pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, orm.FilterEq("repo_at", repo.RepoAt()))
24
25
if err != nil {
25
26
return nil, err
26
27
}
···
28
29
issues, err := db.GetIssuesPaginated(
29
30
rp.db,
30
31
pagination.Page{Limit: feedLimitPerType},
31
-
db.FilterEq("repo_at", repo.RepoAt()),
32
+
orm.FilterEq("repo_at", repo.RepoAt()),
32
33
)
33
34
if err != nil {
34
35
return nil, err
+3
-2
appview/repo/index.go
+3
-2
appview/repo/index.go
···
23
23
"tangled.org/core/appview/models"
24
24
"tangled.org/core/appview/pages"
25
25
"tangled.org/core/appview/xrpcclient"
26
+
"tangled.org/core/orm"
26
27
"tangled.org/core/types"
27
28
28
29
"github.com/go-chi/chi/v5"
···
171
172
// first attempt to fetch from db
172
173
langs, err := db.GetRepoLanguages(
173
174
rp.db,
174
-
db.FilterEq("repo_at", repo.RepoAt()),
175
-
db.FilterEq("ref", currentRef),
175
+
orm.FilterEq("repo_at", repo.RepoAt()),
176
+
orm.FilterEq("ref", currentRef),
176
177
)
177
178
178
179
if err != nil || langs == nil {
+3
-2
appview/repo/opengraph.go
+3
-2
appview/repo/opengraph.go
···
16
16
"tangled.org/core/appview/db"
17
17
"tangled.org/core/appview/models"
18
18
"tangled.org/core/appview/ogcard"
19
+
"tangled.org/core/orm"
19
20
"tangled.org/core/types"
20
21
)
21
22
···
338
339
var languageStats []types.RepoLanguageDetails
339
340
langs, err := db.GetRepoLanguages(
340
341
rp.db,
341
-
db.FilterEq("repo_at", f.RepoAt()),
342
-
db.FilterEq("is_default_ref", 1),
342
+
orm.FilterEq("repo_at", f.RepoAt()),
343
+
orm.FilterEq("is_default_ref", 1),
343
344
)
344
345
if err != nil {
345
346
log.Printf("failed to get language stats from db: %v", err)
+17
-16
appview/repo/repo.go
+17
-16
appview/repo/repo.go
···
24
24
xrpcclient "tangled.org/core/appview/xrpcclient"
25
25
"tangled.org/core/eventconsumer"
26
26
"tangled.org/core/idresolver"
27
+
"tangled.org/core/orm"
27
28
"tangled.org/core/rbac"
28
29
"tangled.org/core/tid"
29
30
"tangled.org/core/xrpc/serviceauth"
···
345
346
// get form values
346
347
labelId := r.FormValue("label-id")
347
348
348
-
label, err := db.GetLabelDefinition(rp.db, db.FilterEq("id", labelId))
349
+
label, err := db.GetLabelDefinition(rp.db, orm.FilterEq("id", labelId))
349
350
if err != nil {
350
351
fail("Failed to find label definition.", err)
351
352
return
···
409
410
410
411
err = db.UnsubscribeLabel(
411
412
tx,
412
-
db.FilterEq("repo_at", f.RepoAt()),
413
-
db.FilterEq("label_at", removedAt),
413
+
orm.FilterEq("repo_at", f.RepoAt()),
414
+
orm.FilterEq("label_at", removedAt),
414
415
)
415
416
if err != nil {
416
417
fail("Failed to unsubscribe label.", err)
417
418
return
418
419
}
419
420
420
-
err = db.DeleteLabelDefinition(tx, db.FilterEq("id", label.Id))
421
+
err = db.DeleteLabelDefinition(tx, orm.FilterEq("id", label.Id))
421
422
if err != nil {
422
423
fail("Failed to delete label definition.", err)
423
424
return
···
456
457
}
457
458
458
459
labelAts := r.Form["label"]
459
-
_, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts))
460
+
_, err = db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", labelAts))
460
461
if err != nil {
461
462
fail("Failed to subscribe to label.", err)
462
463
return
···
542
543
}
543
544
544
545
labelAts := r.Form["label"]
545
-
_, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts))
546
+
_, err = db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", labelAts))
546
547
if err != nil {
547
548
fail("Failed to unsubscribe to label.", err)
548
549
return
···
582
583
583
584
err = db.UnsubscribeLabel(
584
585
rp.db,
585
-
db.FilterEq("repo_at", f.RepoAt()),
586
-
db.FilterIn("label_at", labelAts),
586
+
orm.FilterEq("repo_at", f.RepoAt()),
587
+
orm.FilterIn("label_at", labelAts),
587
588
)
588
589
if err != nil {
589
590
fail("Failed to unsubscribe label.", err)
···
612
613
613
614
labelDefs, err := db.GetLabelDefinitions(
614
615
rp.db,
615
-
db.FilterIn("at_uri", f.Labels),
616
-
db.FilterContains("scope", subject.Collection().String()),
616
+
orm.FilterIn("at_uri", f.Labels),
617
+
orm.FilterContains("scope", subject.Collection().String()),
617
618
)
618
619
if err != nil {
619
620
l.Error("failed to fetch label defs", "err", err)
···
625
626
defs[l.AtUri().String()] = &l
626
627
}
627
628
628
-
states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject))
629
+
states, err := db.GetLabels(rp.db, orm.FilterEq("subject", subject))
629
630
if err != nil {
630
631
l.Error("failed to build label state", "err", err)
631
632
return
···
660
661
661
662
labelDefs, err := db.GetLabelDefinitions(
662
663
rp.db,
663
-
db.FilterIn("at_uri", f.Labels),
664
-
db.FilterContains("scope", subject.Collection().String()),
664
+
orm.FilterIn("at_uri", f.Labels),
665
+
orm.FilterContains("scope", subject.Collection().String()),
665
666
)
666
667
if err != nil {
667
668
l.Error("failed to fetch labels", "err", err)
···
673
674
defs[l.AtUri().String()] = &l
674
675
}
675
676
676
-
states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject))
677
+
states, err := db.GetLabels(rp.db, orm.FilterEq("subject", subject))
677
678
if err != nil {
678
679
l.Error("failed to build label state", "err", err)
679
680
return
···
1036
1037
// in the user's account.
1037
1038
existingRepo, err := db.GetRepo(
1038
1039
rp.db,
1039
-
db.FilterEq("did", user.Did),
1040
-
db.FilterEq("name", forkName),
1040
+
orm.FilterEq("did", user.Did),
1041
+
orm.FilterEq("name", forkName),
1041
1042
)
1042
1043
if err != nil {
1043
1044
if !errors.Is(err, sql.ErrNoRows) {
+5
-4
appview/repo/repo_util.go
+5
-4
appview/repo/repo_util.go
···
8
8
9
9
"tangled.org/core/appview/db"
10
10
"tangled.org/core/appview/models"
11
+
"tangled.org/core/orm"
11
12
"tangled.org/core/types"
12
13
)
13
14
···
102
103
ps, err := db.GetPipelineStatuses(
103
104
d,
104
105
len(shas),
105
-
db.FilterEq("repo_owner", repo.Did),
106
-
db.FilterEq("repo_name", repo.Name),
107
-
db.FilterEq("knot", repo.Knot),
108
-
db.FilterIn("sha", shas),
106
+
orm.FilterEq("repo_owner", repo.Did),
107
+
orm.FilterEq("repo_name", repo.Name),
108
+
orm.FilterEq("knot", repo.Knot),
109
+
orm.FilterIn("sha", shas),
109
110
)
110
111
if err != nil {
111
112
return nil, err
+3
-2
appview/repo/settings.go
+3
-2
appview/repo/settings.go
···
14
14
"tangled.org/core/appview/oauth"
15
15
"tangled.org/core/appview/pages"
16
16
xrpcclient "tangled.org/core/appview/xrpcclient"
17
+
"tangled.org/core/orm"
17
18
"tangled.org/core/types"
18
19
19
20
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
210
211
return
211
212
}
212
213
213
-
defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", rp.config.Label.DefaultLabelDefs))
214
+
defaultLabels, err := db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", rp.config.Label.DefaultLabelDefs))
214
215
if err != nil {
215
216
l.Error("failed to fetch labels", "err", err)
216
217
rp.pages.Error503(w)
217
218
return
218
219
}
219
220
220
-
labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Labels))
221
+
labels, err := db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", f.Labels))
221
222
if err != nil {
222
223
l.Error("failed to fetch labels", "err", err)
223
224
rp.pages.Error503(w)
+5
-4
appview/serververify/verify.go
+5
-4
appview/serververify/verify.go
···
9
9
"tangled.org/core/api/tangled"
10
10
"tangled.org/core/appview/db"
11
11
"tangled.org/core/appview/xrpcclient"
12
+
"tangled.org/core/orm"
12
13
"tangled.org/core/rbac"
13
14
)
14
15
···
76
77
// mark this spindle as verified in the db
77
78
rowId, err := db.VerifySpindle(
78
79
tx,
79
-
db.FilterEq("owner", owner),
80
-
db.FilterEq("instance", instance),
80
+
orm.FilterEq("owner", owner),
81
+
orm.FilterEq("instance", instance),
81
82
)
82
83
if err != nil {
83
84
return 0, fmt.Errorf("failed to write to DB: %w", err)
···
115
116
// mark as registered
116
117
err = db.MarkRegistered(
117
118
tx,
118
-
db.FilterEq("did", owner),
119
-
db.FilterEq("domain", domain),
119
+
orm.FilterEq("did", owner),
120
+
orm.FilterEq("domain", domain),
120
121
)
121
122
if err != nil {
122
123
return fmt.Errorf("failed to register domain: %w", err)
+25
-24
appview/spindles/spindles.go
+25
-24
appview/spindles/spindles.go
···
20
20
"tangled.org/core/appview/serververify"
21
21
"tangled.org/core/appview/xrpcclient"
22
22
"tangled.org/core/idresolver"
23
+
"tangled.org/core/orm"
23
24
"tangled.org/core/rbac"
24
25
"tangled.org/core/tid"
25
26
···
71
72
user := s.OAuth.GetUser(r)
72
73
all, err := db.GetSpindles(
73
74
s.Db,
74
-
db.FilterEq("owner", user.Did),
75
+
orm.FilterEq("owner", user.Did),
75
76
)
76
77
if err != nil {
77
78
s.Logger.Error("failed to fetch spindles", "err", err)
···
101
102
102
103
spindles, err := db.GetSpindles(
103
104
s.Db,
104
-
db.FilterEq("instance", instance),
105
-
db.FilterEq("owner", user.Did),
106
-
db.FilterIsNot("verified", "null"),
105
+
orm.FilterEq("instance", instance),
106
+
orm.FilterEq("owner", user.Did),
107
+
orm.FilterIsNot("verified", "null"),
107
108
)
108
109
if err != nil || len(spindles) != 1 {
109
110
l.Error("failed to get spindle", "err", err, "len(spindles)", len(spindles))
···
123
124
repos, err := db.GetRepos(
124
125
s.Db,
125
126
0,
126
-
db.FilterEq("spindle", instance),
127
+
orm.FilterEq("spindle", instance),
127
128
)
128
129
if err != nil {
129
130
l.Error("failed to get spindle repos", "err", err)
···
290
291
291
292
spindles, err := db.GetSpindles(
292
293
s.Db,
293
-
db.FilterEq("owner", user.Did),
294
-
db.FilterEq("instance", instance),
294
+
orm.FilterEq("owner", user.Did),
295
+
orm.FilterEq("instance", instance),
295
296
)
296
297
if err != nil || len(spindles) != 1 {
297
298
l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
···
319
320
// remove spindle members first
320
321
err = db.RemoveSpindleMember(
321
322
tx,
322
-
db.FilterEq("did", user.Did),
323
-
db.FilterEq("instance", instance),
323
+
orm.FilterEq("did", user.Did),
324
+
orm.FilterEq("instance", instance),
324
325
)
325
326
if err != nil {
326
327
l.Error("failed to remove spindle members", "err", err)
···
330
331
331
332
err = db.DeleteSpindle(
332
333
tx,
333
-
db.FilterEq("owner", user.Did),
334
-
db.FilterEq("instance", instance),
334
+
orm.FilterEq("owner", user.Did),
335
+
orm.FilterEq("instance", instance),
335
336
)
336
337
if err != nil {
337
338
l.Error("failed to delete spindle", "err", err)
···
410
411
411
412
spindles, err := db.GetSpindles(
412
413
s.Db,
413
-
db.FilterEq("owner", user.Did),
414
-
db.FilterEq("instance", instance),
414
+
orm.FilterEq("owner", user.Did),
415
+
orm.FilterEq("instance", instance),
415
416
)
416
417
if err != nil || len(spindles) != 1 {
417
418
l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
···
453
454
454
455
verifiedSpindle, err := db.GetSpindles(
455
456
s.Db,
456
-
db.FilterEq("id", rowId),
457
+
orm.FilterEq("id", rowId),
457
458
)
458
459
if err != nil || len(verifiedSpindle) != 1 {
459
460
l.Error("failed get new spindle", "err", err)
···
486
487
487
488
spindles, err := db.GetSpindles(
488
489
s.Db,
489
-
db.FilterEq("owner", user.Did),
490
-
db.FilterEq("instance", instance),
490
+
orm.FilterEq("owner", user.Did),
491
+
orm.FilterEq("instance", instance),
491
492
)
492
493
if err != nil || len(spindles) != 1 {
493
494
l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
···
622
623
623
624
spindles, err := db.GetSpindles(
624
625
s.Db,
625
-
db.FilterEq("owner", user.Did),
626
-
db.FilterEq("instance", instance),
626
+
orm.FilterEq("owner", user.Did),
627
+
orm.FilterEq("instance", instance),
627
628
)
628
629
if err != nil || len(spindles) != 1 {
629
630
l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
···
672
673
// get the record from the DB first:
673
674
members, err := db.GetSpindleMembers(
674
675
s.Db,
675
-
db.FilterEq("did", user.Did),
676
-
db.FilterEq("instance", instance),
677
-
db.FilterEq("subject", memberId.DID),
676
+
orm.FilterEq("did", user.Did),
677
+
orm.FilterEq("instance", instance),
678
+
orm.FilterEq("subject", memberId.DID),
678
679
)
679
680
if err != nil || len(members) != 1 {
680
681
l.Error("failed to get member", "err", err)
···
685
686
// remove from db
686
687
if err = db.RemoveSpindleMember(
687
688
tx,
688
-
db.FilterEq("did", user.Did),
689
-
db.FilterEq("instance", instance),
690
-
db.FilterEq("subject", memberId.DID),
689
+
orm.FilterEq("did", user.Did),
690
+
orm.FilterEq("instance", instance),
691
+
orm.FilterEq("subject", memberId.DID),
691
692
); err != nil {
692
693
l.Error("failed to remove spindle member", "err", err)
693
694
fail()
+6
-5
appview/state/gfi.go
+6
-5
appview/state/gfi.go
···
11
11
"tangled.org/core/appview/pages"
12
12
"tangled.org/core/appview/pagination"
13
13
"tangled.org/core/consts"
14
+
"tangled.org/core/orm"
14
15
)
15
16
16
17
func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) {
···
20
21
21
22
goodFirstIssueLabel := s.config.Label.GoodFirstIssue
22
23
23
-
gfiLabelDef, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", goodFirstIssueLabel))
24
+
gfiLabelDef, err := db.GetLabelDefinition(s.db, orm.FilterEq("at_uri", goodFirstIssueLabel))
24
25
if err != nil {
25
26
log.Println("failed to get gfi label def", err)
26
27
s.pages.Error500(w)
27
28
return
28
29
}
29
30
30
-
repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel))
31
+
repoLabels, err := db.GetRepoLabels(s.db, orm.FilterEq("label_at", goodFirstIssueLabel))
31
32
if err != nil {
32
33
log.Println("failed to get repo labels", err)
33
34
s.pages.Error503(w)
···
55
56
pagination.Page{
56
57
Limit: 500,
57
58
},
58
-
db.FilterIn("repo_at", repoUris),
59
-
db.FilterEq("open", 1),
59
+
orm.FilterIn("repo_at", repoUris),
60
+
orm.FilterEq("open", 1),
60
61
)
61
62
if err != nil {
62
63
log.Println("failed to get issues", err)
···
132
133
}
133
134
134
135
if len(uriList) > 0 {
135
-
allLabelDefs, err = db.GetLabelDefinitions(s.db, db.FilterIn("at_uri", uriList))
136
+
allLabelDefs, err = db.GetLabelDefinitions(s.db, orm.FilterIn("at_uri", uriList))
136
137
if err != nil {
137
138
log.Println("failed to fetch labels", err)
138
139
}
+17
appview/state/git_http.go
+17
appview/state/git_http.go
···
25
25
26
26
}
27
27
28
+
func (s *State) UploadArchive(w http.ResponseWriter, r *http.Request) {
29
+
user, ok := r.Context().Value("resolvedId").(identity.Identity)
30
+
if !ok {
31
+
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
32
+
return
33
+
}
34
+
repo := r.Context().Value("repo").(*models.Repo)
35
+
36
+
scheme := "https"
37
+
if s.config.Core.Dev {
38
+
scheme = "http"
39
+
}
40
+
41
+
targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-archive?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery)
42
+
s.proxyRequest(w, r, targetURL)
43
+
}
44
+
28
45
func (s *State) UploadPack(w http.ResponseWriter, r *http.Request) {
29
46
user, ok := r.Context().Value("resolvedId").(identity.Identity)
30
47
if !ok {
+5
-90
appview/state/knotstream.go
+5
-90
appview/state/knotstream.go
···
16
16
ec "tangled.org/core/eventconsumer"
17
17
"tangled.org/core/eventconsumer/cursor"
18
18
"tangled.org/core/log"
19
+
"tangled.org/core/orm"
19
20
"tangled.org/core/rbac"
20
-
"tangled.org/core/workflow"
21
21
22
-
"github.com/bluesky-social/indigo/atproto/syntax"
23
22
"github.com/go-git/go-git/v5/plumbing"
24
23
"github.com/posthog/posthog-go"
25
24
)
···
30
29
31
30
knots, err := db.GetRegistrations(
32
31
d,
33
-
db.FilterIsNot("registered", "null"),
32
+
orm.FilterIsNot("registered", "null"),
34
33
)
35
34
if err != nil {
36
35
return nil, err
···
54
53
WorkerCount: c.Knotstream.WorkerCount,
55
54
QueueSize: c.Knotstream.QueueSize,
56
55
Logger: logger,
57
-
Dev: c.Core.Dev,
56
+
Dev: false,
58
57
CursorStore: &cursorStore,
59
58
}
60
59
···
66
65
switch msg.Nsid {
67
66
case tangled.GitRefUpdateNSID:
68
67
return ingestRefUpdate(d, enforcer, posthog, dev, source, msg)
69
-
case tangled.PipelineNSID:
70
-
return ingestPipeline(d, source, msg)
71
68
}
72
69
73
70
return nil
···
143
140
repos, err := db.GetRepos(
144
141
d,
145
142
0,
146
-
db.FilterEq("did", record.RepoDid),
147
-
db.FilterEq("name", record.RepoName),
143
+
orm.FilterEq("did", record.RepoDid),
144
+
orm.FilterEq("name", record.RepoName),
148
145
)
149
146
if err != nil {
150
147
return fmt.Errorf("failed to look for repo in DB (%s/%s): %w", record.RepoDid, record.RepoName, err)
···
189
186
190
187
return tx.Commit()
191
188
}
192
-
193
-
func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
194
-
var record tangled.Pipeline
195
-
err := json.Unmarshal(msg.EventJson, &record)
196
-
if err != nil {
197
-
return err
198
-
}
199
-
200
-
if record.TriggerMetadata == nil {
201
-
return fmt.Errorf("empty trigger metadata: nsid %s, rkey %s", msg.Nsid, msg.Rkey)
202
-
}
203
-
204
-
if record.TriggerMetadata.Repo == nil {
205
-
return fmt.Errorf("empty repo: nsid %s, rkey %s", msg.Nsid, msg.Rkey)
206
-
}
207
-
208
-
// does this repo have a spindle configured?
209
-
repos, err := db.GetRepos(
210
-
d,
211
-
0,
212
-
db.FilterEq("did", record.TriggerMetadata.Repo.Did),
213
-
db.FilterEq("name", record.TriggerMetadata.Repo.Repo),
214
-
)
215
-
if err != nil {
216
-
return fmt.Errorf("failed to look for repo in DB: nsid %s, rkey %s, %w", msg.Nsid, msg.Rkey, err)
217
-
}
218
-
if len(repos) != 1 {
219
-
return fmt.Errorf("incorrect number of repos returned: %d (expected 1)", len(repos))
220
-
}
221
-
if repos[0].Spindle == "" {
222
-
return fmt.Errorf("repo does not have a spindle configured yet: nsid %s, rkey %s", msg.Nsid, msg.Rkey)
223
-
}
224
-
225
-
// trigger info
226
-
var trigger models.Trigger
227
-
var sha string
228
-
trigger.Kind = workflow.TriggerKind(record.TriggerMetadata.Kind)
229
-
switch trigger.Kind {
230
-
case workflow.TriggerKindPush:
231
-
trigger.PushRef = &record.TriggerMetadata.Push.Ref
232
-
trigger.PushNewSha = &record.TriggerMetadata.Push.NewSha
233
-
trigger.PushOldSha = &record.TriggerMetadata.Push.OldSha
234
-
sha = *trigger.PushNewSha
235
-
case workflow.TriggerKindPullRequest:
236
-
trigger.PRSourceBranch = &record.TriggerMetadata.PullRequest.SourceBranch
237
-
trigger.PRTargetBranch = &record.TriggerMetadata.PullRequest.TargetBranch
238
-
trigger.PRSourceSha = &record.TriggerMetadata.PullRequest.SourceSha
239
-
trigger.PRAction = &record.TriggerMetadata.PullRequest.Action
240
-
sha = *trigger.PRSourceSha
241
-
}
242
-
243
-
tx, err := d.Begin()
244
-
if err != nil {
245
-
return fmt.Errorf("failed to start txn: %w", err)
246
-
}
247
-
248
-
triggerId, err := db.AddTrigger(tx, trigger)
249
-
if err != nil {
250
-
return fmt.Errorf("failed to add trigger entry: %w", err)
251
-
}
252
-
253
-
pipeline := models.Pipeline{
254
-
Rkey: msg.Rkey,
255
-
Knot: source.Key(),
256
-
RepoOwner: syntax.DID(record.TriggerMetadata.Repo.Did),
257
-
RepoName: record.TriggerMetadata.Repo.Repo,
258
-
TriggerId: int(triggerId),
259
-
Sha: sha,
260
-
}
261
-
262
-
err = db.AddPipeline(tx, pipeline)
263
-
if err != nil {
264
-
return fmt.Errorf("failed to add pipeline: %w", err)
265
-
}
266
-
267
-
err = tx.Commit()
268
-
if err != nil {
269
-
return fmt.Errorf("failed to commit txn: %w", err)
270
-
}
271
-
272
-
return nil
273
-
}
+13
-12
appview/state/profile.go
+13
-12
appview/state/profile.go
···
19
19
"tangled.org/core/appview/db"
20
20
"tangled.org/core/appview/models"
21
21
"tangled.org/core/appview/pages"
22
+
"tangled.org/core/orm"
22
23
)
23
24
24
25
func (s *State) Profile(w http.ResponseWriter, r *http.Request) {
···
56
57
return nil, fmt.Errorf("failed to get profile: %w", err)
57
58
}
58
59
59
-
repoCount, err := db.CountRepos(s.db, db.FilterEq("did", did))
60
+
repoCount, err := db.CountRepos(s.db, orm.FilterEq("did", did))
60
61
if err != nil {
61
62
return nil, fmt.Errorf("failed to get repo count: %w", err)
62
63
}
63
64
64
-
stringCount, err := db.CountStrings(s.db, db.FilterEq("did", did))
65
+
stringCount, err := db.CountStrings(s.db, orm.FilterEq("did", did))
65
66
if err != nil {
66
67
return nil, fmt.Errorf("failed to get string count: %w", err)
67
68
}
68
69
69
-
starredCount, err := db.CountStars(s.db, db.FilterEq("did", did))
70
+
starredCount, err := db.CountStars(s.db, orm.FilterEq("did", did))
70
71
if err != nil {
71
72
return nil, fmt.Errorf("failed to get starred repo count: %w", err)
72
73
}
···
86
87
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
87
88
punchcard, err := db.MakePunchcard(
88
89
s.db,
89
-
db.FilterEq("did", did),
90
-
db.FilterGte("date", startOfYear.Format(time.DateOnly)),
91
-
db.FilterLte("date", now.Format(time.DateOnly)),
90
+
orm.FilterEq("did", did),
91
+
orm.FilterGte("date", startOfYear.Format(time.DateOnly)),
92
+
orm.FilterLte("date", now.Format(time.DateOnly)),
92
93
)
93
94
if err != nil {
94
95
return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err)
···
123
124
repos, err := db.GetRepos(
124
125
s.db,
125
126
0,
126
-
db.FilterEq("did", profile.UserDid),
127
+
orm.FilterEq("did", profile.UserDid),
127
128
)
128
129
if err != nil {
129
130
l.Error("failed to fetch repos", "err", err)
···
193
194
repos, err := db.GetRepos(
194
195
s.db,
195
196
0,
196
-
db.FilterEq("did", profile.UserDid),
197
+
orm.FilterEq("did", profile.UserDid),
197
198
)
198
199
if err != nil {
199
200
l.Error("failed to get repos", "err", err)
···
219
220
}
220
221
l = l.With("profileDid", profile.UserDid)
221
222
222
-
stars, err := db.GetRepoStars(s.db, 0, db.FilterEq("did", profile.UserDid))
223
+
stars, err := db.GetRepoStars(s.db, 0, orm.FilterEq("did", profile.UserDid))
223
224
if err != nil {
224
225
l.Error("failed to get stars", "err", err)
225
226
s.pages.Error500(w)
···
248
249
}
249
250
l = l.With("profileDid", profile.UserDid)
250
251
251
-
strings, err := db.GetStrings(s.db, 0, db.FilterEq("did", profile.UserDid))
252
+
strings, err := db.GetStrings(s.db, 0, orm.FilterEq("did", profile.UserDid))
252
253
if err != nil {
253
254
l.Error("failed to get strings", "err", err)
254
255
s.pages.Error500(w)
···
300
301
followDids = append(followDids, extractDid(follow))
301
302
}
302
303
303
-
profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids))
304
+
profiles, err := db.GetProfiles(s.db, orm.FilterIn("did", followDids))
304
305
if err != nil {
305
306
l.Error("failed to get profiles", "followDids", followDids, "err", err)
306
307
return ¶ms, err
···
703
704
log.Printf("getting profile data for %s: %s", user.Did, err)
704
705
}
705
706
706
-
repos, err := db.GetRepos(s.db, 0, db.FilterEq("did", user.Did))
707
+
repos, err := db.GetRepos(s.db, 0, orm.FilterEq("did", user.Did))
707
708
if err != nil {
708
709
log.Printf("getting repos for %s: %s", user.Did, err)
709
710
}
+9
-8
appview/state/router.go
+9
-8
appview/state/router.go
···
32
32
s.pages,
33
33
)
34
34
35
-
router.Get("/favicon.svg", s.Favicon)
36
-
router.Get("/favicon.ico", s.Favicon)
37
-
router.Get("/pwa-manifest.json", s.PWAManifest)
35
+
router.Get("/favicon.ico", s.pages.StaticRedirect("/static/favicon.ico"))
36
+
router.Get("/favicon.svg", s.pages.StaticRedirect("/static/favicon.svg"))
37
+
router.Get("/manifest.webmanifest", s.WebAppManifest)
38
38
router.Get("/robots.txt", s.RobotsTxt)
39
39
40
40
userRouter := s.UserRouter(&middleware)
···
96
96
r.Mount("/", s.RepoRouter(mw))
97
97
r.Mount("/issues", s.IssuesRouter(mw))
98
98
r.Mount("/pulls", s.PullsRouter(mw))
99
-
r.Mount("/pipelines", s.PipelinesRouter())
99
+
r.Mount("/pipelines", s.PipelinesRouter(mw))
100
100
r.Mount("/labels", s.LabelsRouter())
101
101
102
102
// These routes get proxied to the knot
103
103
r.Get("/info/refs", s.InfoRefs)
104
+
r.Post("/git-upload-archive", s.UploadArchive)
104
105
r.Post("/git-upload-pack", s.UploadPack)
105
106
r.Post("/git-receive-pack", s.ReceivePack)
106
107
···
266
267
s.enforcer,
267
268
s.pages,
268
269
s.idResolver,
269
-
s.refResolver,
270
+
s.mentionsResolver,
270
271
s.db,
271
272
s.config,
272
273
s.notifier,
···
283
284
s.repoResolver,
284
285
s.pages,
285
286
s.idResolver,
286
-
s.refResolver,
287
+
s.mentionsResolver,
287
288
s.db,
288
289
s.config,
289
290
s.notifier,
···
312
313
return repo.Router(mw)
313
314
}
314
315
315
-
func (s *State) PipelinesRouter() http.Handler {
316
+
func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler {
316
317
pipes := pipelines.New(
317
318
s.oauth,
318
319
s.repoResolver,
···
324
325
s.enforcer,
325
326
log.SubLogger(s.logger, "pipelines"),
326
327
)
327
-
return pipes.Router()
328
+
return pipes.Router(mw)
328
329
}
329
330
330
331
func (s *State) LabelsRouter() http.Handler {
+92
-2
appview/state/spindlestream.go
+92
-2
appview/state/spindlestream.go
···
17
17
ec "tangled.org/core/eventconsumer"
18
18
"tangled.org/core/eventconsumer/cursor"
19
19
"tangled.org/core/log"
20
+
"tangled.org/core/orm"
20
21
"tangled.org/core/rbac"
21
22
spindle "tangled.org/core/spindle/models"
23
+
"tangled.org/core/workflow"
22
24
)
23
25
24
26
func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) {
···
27
29
28
30
spindles, err := db.GetSpindles(
29
31
d,
30
-
db.FilterIsNot("verified", "null"),
32
+
orm.FilterIsNot("verified", "null"),
31
33
)
32
34
if err != nil {
33
35
return nil, err
···
51
53
WorkerCount: c.Spindlestream.WorkerCount,
52
54
QueueSize: c.Spindlestream.QueueSize,
53
55
Logger: logger,
54
-
Dev: c.Core.Dev,
56
+
Dev: false,
55
57
CursorStore: &cursorStore,
56
58
}
57
59
···
61
63
func spindleIngester(ctx context.Context, logger *slog.Logger, d *db.DB) ec.ProcessFunc {
62
64
return func(ctx context.Context, source ec.Source, msg ec.Message) error {
63
65
switch msg.Nsid {
66
+
case tangled.PipelineNSID:
67
+
return ingestPipeline(logger, d, source, msg)
64
68
case tangled.PipelineStatusNSID:
65
69
return ingestPipelineStatus(ctx, logger, d, source, msg)
66
70
}
67
71
68
72
return nil
69
73
}
74
+
}
75
+
76
+
func ingestPipeline(l *slog.Logger, d *db.DB, source ec.Source, msg ec.Message) error {
77
+
var record tangled.Pipeline
78
+
err := json.Unmarshal(msg.EventJson, &record)
79
+
if err != nil {
80
+
return err
81
+
}
82
+
83
+
if record.TriggerMetadata == nil {
84
+
return fmt.Errorf("empty trigger metadata: nsid %s, rkey %s", msg.Nsid, msg.Rkey)
85
+
}
86
+
87
+
if record.TriggerMetadata.Repo == nil {
88
+
return fmt.Errorf("empty repo: nsid %s, rkey %s", msg.Nsid, msg.Rkey)
89
+
}
90
+
91
+
// does this repo have a spindle configured?
92
+
repos, err := db.GetRepos(
93
+
d,
94
+
0,
95
+
orm.FilterEq("did", record.TriggerMetadata.Repo.Did),
96
+
orm.FilterEq("name", record.TriggerMetadata.Repo.Repo),
97
+
)
98
+
if err != nil {
99
+
return fmt.Errorf("failed to look for repo in DB: nsid %s, rkey %s, %w", msg.Nsid, msg.Rkey, err)
100
+
}
101
+
if len(repos) != 1 {
102
+
return fmt.Errorf("incorrect number of repos returned: %d (expected 1)", len(repos))
103
+
}
104
+
if repos[0].Spindle == "" {
105
+
return fmt.Errorf("repo does not have a spindle configured yet: nsid %s, rkey %s", msg.Nsid, msg.Rkey)
106
+
}
107
+
108
+
// trigger info
109
+
var trigger models.Trigger
110
+
var sha string
111
+
trigger.Kind = workflow.TriggerKind(record.TriggerMetadata.Kind)
112
+
switch trigger.Kind {
113
+
case workflow.TriggerKindPush:
114
+
trigger.PushRef = &record.TriggerMetadata.Push.Ref
115
+
trigger.PushNewSha = &record.TriggerMetadata.Push.NewSha
116
+
trigger.PushOldSha = &record.TriggerMetadata.Push.OldSha
117
+
sha = *trigger.PushNewSha
118
+
case workflow.TriggerKindPullRequest:
119
+
trigger.PRSourceBranch = &record.TriggerMetadata.PullRequest.SourceBranch
120
+
trigger.PRTargetBranch = &record.TriggerMetadata.PullRequest.TargetBranch
121
+
trigger.PRSourceSha = &record.TriggerMetadata.PullRequest.SourceSha
122
+
trigger.PRAction = &record.TriggerMetadata.PullRequest.Action
123
+
sha = *trigger.PRSourceSha
124
+
}
125
+
126
+
tx, err := d.Begin()
127
+
if err != nil {
128
+
return fmt.Errorf("failed to start txn: %w", err)
129
+
}
130
+
131
+
triggerId, err := db.AddTrigger(tx, trigger)
132
+
if err != nil {
133
+
return fmt.Errorf("failed to add trigger entry: %w", err)
134
+
}
135
+
136
+
// TODO: we shouldn't even use knot to identify pipelines
137
+
knot := record.TriggerMetadata.Repo.Knot
138
+
pipeline := models.Pipeline{
139
+
Rkey: msg.Rkey,
140
+
Knot: knot,
141
+
RepoOwner: syntax.DID(record.TriggerMetadata.Repo.Did),
142
+
RepoName: record.TriggerMetadata.Repo.Repo,
143
+
TriggerId: int(triggerId),
144
+
Sha: sha,
145
+
}
146
+
147
+
err = db.AddPipeline(tx, pipeline)
148
+
if err != nil {
149
+
return fmt.Errorf("failed to add pipeline: %w", err)
150
+
}
151
+
152
+
err = tx.Commit()
153
+
if err != nil {
154
+
return fmt.Errorf("failed to commit txn: %w", err)
155
+
}
156
+
157
+
l.Info("added pipeline", "pipeline", pipeline)
158
+
159
+
return nil
70
160
}
71
161
72
162
func ingestPipelineStatus(ctx context.Context, logger *slog.Logger, d *db.DB, source ec.Source, msg ec.Message) error {
+33
-44
appview/state/state.go
+33
-44
appview/state/state.go
···
15
15
"tangled.org/core/appview/config"
16
16
"tangled.org/core/appview/db"
17
17
"tangled.org/core/appview/indexer"
18
+
"tangled.org/core/appview/mentions"
18
19
"tangled.org/core/appview/models"
19
20
"tangled.org/core/appview/notify"
20
21
dbnotify "tangled.org/core/appview/notify/db"
21
22
phnotify "tangled.org/core/appview/notify/posthog"
22
23
"tangled.org/core/appview/oauth"
23
24
"tangled.org/core/appview/pages"
24
-
"tangled.org/core/appview/refresolver"
25
25
"tangled.org/core/appview/reporesolver"
26
26
"tangled.org/core/appview/validator"
27
27
xrpcclient "tangled.org/core/appview/xrpcclient"
···
30
30
"tangled.org/core/jetstream"
31
31
"tangled.org/core/log"
32
32
tlog "tangled.org/core/log"
33
+
"tangled.org/core/orm"
33
34
"tangled.org/core/rbac"
34
35
"tangled.org/core/tid"
35
36
···
43
44
)
44
45
45
46
type State struct {
46
-
db *db.DB
47
-
notifier notify.Notifier
48
-
indexer *indexer.Indexer
49
-
oauth *oauth.OAuth
50
-
enforcer *rbac.Enforcer
51
-
pages *pages.Pages
52
-
idResolver *idresolver.Resolver
53
-
refResolver *refresolver.Resolver
54
-
posthog posthog.Client
55
-
jc *jetstream.JetstreamClient
56
-
config *config.Config
57
-
repoResolver *reporesolver.RepoResolver
58
-
knotstream *eventconsumer.Consumer
59
-
spindlestream *eventconsumer.Consumer
60
-
logger *slog.Logger
61
-
validator *validator.Validator
47
+
db *db.DB
48
+
notifier notify.Notifier
49
+
indexer *indexer.Indexer
50
+
oauth *oauth.OAuth
51
+
enforcer *rbac.Enforcer
52
+
pages *pages.Pages
53
+
idResolver *idresolver.Resolver
54
+
mentionsResolver *mentions.Resolver
55
+
posthog posthog.Client
56
+
jc *jetstream.JetstreamClient
57
+
config *config.Config
58
+
repoResolver *reporesolver.RepoResolver
59
+
knotstream *eventconsumer.Consumer
60
+
spindlestream *eventconsumer.Consumer
61
+
logger *slog.Logger
62
+
validator *validator.Validator
62
63
}
63
64
64
65
func Make(ctx context.Context, config *config.Config) (*State, error) {
···
100
101
101
102
repoResolver := reporesolver.New(config, enforcer, d)
102
103
103
-
refResolver := refresolver.New(config, res, d, log.SubLogger(logger, "refResolver"))
104
+
mentionsResolver := mentions.New(config, res, d, log.SubLogger(logger, "mentionsResolver"))
104
105
105
106
wrapper := db.DbWrapper{Execer: d}
106
107
jc, err := jetstream.NewJetstreamClient(
···
116
117
tangled.SpindleNSID,
117
118
tangled.StringNSID,
118
119
tangled.RepoIssueNSID,
119
-
tangled.RepoIssueCommentNSID,
120
+
tangled.CommentNSID,
120
121
tangled.LabelDefinitionNSID,
121
122
tangled.LabelOpNSID,
122
123
},
···
182
183
enforcer,
183
184
pages,
184
185
res,
185
-
refResolver,
186
+
mentionsResolver,
186
187
posthog,
187
188
jc,
188
189
config,
···
201
202
return s.db.Close()
202
203
}
203
204
204
-
func (s *State) Favicon(w http.ResponseWriter, r *http.Request) {
205
-
w.Header().Set("Content-Type", "image/svg+xml")
206
-
w.Header().Set("Cache-Control", "public, max-age=31536000") // one year
207
-
w.Header().Set("ETag", `"favicon-svg-v1"`)
208
-
209
-
if match := r.Header.Get("If-None-Match"); match == `"favicon-svg-v1"` {
210
-
w.WriteHeader(http.StatusNotModified)
211
-
return
212
-
}
213
-
214
-
s.pages.Favicon(w)
215
-
}
216
-
217
205
func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) {
218
206
w.Header().Set("Content-Type", "text/plain")
219
207
w.Header().Set("Cache-Control", "public, max-age=86400") // one day
···
225
213
}
226
214
227
215
// https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest
216
+
// https://www.w3.org/TR/appmanifest/
228
217
const manifestJson = `{
229
218
"name": "tangled",
230
219
"description": "tightly-knit social coding.",
···
235
224
}
236
225
],
237
226
"start_url": "/",
238
-
"id": "org.tangled",
227
+
"id": "https://tangled.org",
239
228
240
229
"display": "standalone",
241
230
"background_color": "#111827",
242
231
"theme_color": "#111827"
243
232
}`
244
233
245
-
func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) {
246
-
w.Header().Set("Content-Type", "application/json")
234
+
func (p *State) WebAppManifest(w http.ResponseWriter, r *http.Request) {
235
+
w.Header().Set("Content-Type", "application/manifest+json")
247
236
w.Write([]byte(manifestJson))
248
237
}
249
238
···
299
288
return
300
289
}
301
290
302
-
gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", s.config.Label.GoodFirstIssue))
291
+
gfiLabel, err := db.GetLabelDefinition(s.db, orm.FilterEq("at_uri", s.config.Label.GoodFirstIssue))
303
292
if err != nil {
304
293
// non-fatal
305
294
}
···
323
312
324
313
regs, err := db.GetRegistrations(
325
314
s.db,
326
-
db.FilterEq("did", user.Did),
327
-
db.FilterEq("needs_upgrade", 1),
315
+
orm.FilterEq("did", user.Did),
316
+
orm.FilterEq("needs_upgrade", 1),
328
317
)
329
318
if err != nil {
330
319
l.Error("non-fatal: failed to get registrations", "err", err)
···
332
321
333
322
spindles, err := db.GetSpindles(
334
323
s.db,
335
-
db.FilterEq("owner", user.Did),
336
-
db.FilterEq("needs_upgrade", 1),
324
+
orm.FilterEq("owner", user.Did),
325
+
orm.FilterEq("needs_upgrade", 1),
337
326
)
338
327
if err != nil {
339
328
l.Error("non-fatal: failed to get spindles", "err", err)
···
504
493
// Check for existing repos
505
494
existingRepo, err := db.GetRepo(
506
495
s.db,
507
-
db.FilterEq("did", user.Did),
508
-
db.FilterEq("name", repoName),
496
+
orm.FilterEq("did", user.Did),
497
+
orm.FilterEq("name", repoName),
509
498
)
510
499
if err == nil && existingRepo != nil {
511
500
l.Info("repo exists")
···
665
654
}
666
655
667
656
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error {
668
-
defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults))
657
+
defaultLabels, err := db.GetLabelDefinitions(e, orm.FilterIn("at_uri", defaults))
669
658
if err != nil {
670
659
return err
671
660
}
+7
-6
appview/strings/strings.go
+7
-6
appview/strings/strings.go
···
17
17
"tangled.org/core/appview/pages"
18
18
"tangled.org/core/appview/pages/markup"
19
19
"tangled.org/core/idresolver"
20
+
"tangled.org/core/orm"
20
21
"tangled.org/core/tid"
21
22
22
23
"github.com/bluesky-social/indigo/api/atproto"
···
108
109
strings, err := db.GetStrings(
109
110
s.Db,
110
111
0,
111
-
db.FilterEq("did", id.DID),
112
-
db.FilterEq("rkey", rkey),
112
+
orm.FilterEq("did", id.DID),
113
+
orm.FilterEq("rkey", rkey),
113
114
)
114
115
if err != nil {
115
116
l.Error("failed to fetch string", "err", err)
···
199
200
all, err := db.GetStrings(
200
201
s.Db,
201
202
0,
202
-
db.FilterEq("did", id.DID),
203
-
db.FilterEq("rkey", rkey),
203
+
orm.FilterEq("did", id.DID),
204
+
orm.FilterEq("rkey", rkey),
204
205
)
205
206
if err != nil {
206
207
l.Error("failed to fetch string", "err", err)
···
408
409
409
410
if err := db.DeleteString(
410
411
s.Db,
411
-
db.FilterEq("did", user.Did),
412
-
db.FilterEq("rkey", rkey),
412
+
orm.FilterEq("did", user.Did),
413
+
orm.FilterEq("rkey", rkey),
413
414
); err != nil {
414
415
fail("Failed to delete string.", err)
415
416
return
-26
appview/validator/issue.go
-26
appview/validator/issue.go
···
4
4
"fmt"
5
5
"strings"
6
6
7
-
"tangled.org/core/appview/db"
8
7
"tangled.org/core/appview/models"
9
8
)
10
-
11
-
func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error {
12
-
// if comments have parents, only ingest ones that are 1 level deep
13
-
if comment.ReplyTo != nil {
14
-
parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo))
15
-
if err != nil {
16
-
return fmt.Errorf("failed to fetch parent comment: %w", err)
17
-
}
18
-
if len(parents) != 1 {
19
-
return fmt.Errorf("incorrect number of parent comments returned: %d", len(parents))
20
-
}
21
-
22
-
// depth check
23
-
parent := parents[0]
24
-
if parent.ReplyTo != nil {
25
-
return fmt.Errorf("incorrect depth, this comment is replying at depth >1")
26
-
}
27
-
}
28
-
29
-
if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" {
30
-
return fmt.Errorf("body is empty after HTML sanitization")
31
-
}
32
-
33
-
return nil
34
-
}
35
9
36
10
func (v *Validator) ValidateIssue(issue *models.Issue) error {
37
11
if issue.Title == "" {
+1
cmd/cborgen/cborgen.go
+1
cmd/cborgen/cborgen.go
+6
-6
cmd/knot/main.go
+6
-6
cmd/knot/main.go
···
6
6
"os"
7
7
8
8
"github.com/urfave/cli/v3"
9
-
"tangled.org/core/knot2/guard"
10
-
"tangled.org/core/knot2/hook"
11
-
"tangled.org/core/knot2/keys"
12
-
"tangled.org/core/knot2/server"
9
+
"tangled.org/core/guard"
10
+
"tangled.org/core/hook"
11
+
"tangled.org/core/keyfetch"
12
+
"tangled.org/core/knotserver"
13
13
tlog "tangled.org/core/log"
14
14
)
15
15
···
19
19
Usage: "knot administration and operation tool",
20
20
Commands: []*cli.Command{
21
21
guard.Command(),
22
-
server.Command(),
23
-
keys.Command(),
22
+
knotserver.Command(),
23
+
keyfetch.Command(),
24
24
hook.Command(),
25
25
},
26
26
}
+11
contrib/certs/root.crt
+11
contrib/certs/root.crt
···
1
+
-----BEGIN CERTIFICATE-----
2
+
MIIBpDCCAUmgAwIBAgIQKU9d61/WZ56BCZVYfEC6sTAKBggqhkjOPQQDAjAwMS4w
3
+
LAYDVQQDEyVDYWRkeSBMb2NhbCBBdXRob3JpdHkgLSAyMDI1IEVDQyBSb290MB4X
4
+
DTI1MTIxNDE4MTgzNVoXDTM1MTAyMzE4MTgzNVowMDEuMCwGA1UEAxMlQ2FkZHkg
5
+
TG9jYWwgQXV0aG9yaXR5IC0gMjAyNSBFQ0MgUm9vdDBZMBMGByqGSM49AgEGCCqG
6
+
SM49AwEHA0IABPvHcpXJqjBY65eTkPvOVrYU7hG3mUHo2uKLNk4UU5pp0u8f0Lnr
7
+
qGfdnsE0OI5p/+VPlwWJADZYAU3sr6+wkRajRTBDMA4GA1UdDwEB/wQEAwIBBjAS
8
+
BgNVHRMBAf8ECDAGAQH/AgEBMB0GA1UdDgQWBBRdJ3V1QlZggp4ajYwGyLC6lNzq
9
+
JzAKBggqhkjOPQQDAgNJADBGAiEAr0hlnlWKC5PQXeguOcaEZZN/2+yxc5GdQTfv
10
+
66DO4XICIQC6yZaLrKjwPlghYsgT2ysgnboJTfrpwrO4+Naa5leZNg==
11
+
-----END CERTIFICATE-----
+31
contrib/example.env
+31
contrib/example.env
···
1
+
# NOTE: put actual DIDs here
2
+
alice_did=did:plc:alice-did
3
+
tangled_did=did:plc:tangled-did
4
+
5
+
#core
6
+
export TANGLED_DEV=true
7
+
export TANGLED_APPVIEW_HOST=http://127.0.0.1:3000
8
+
# plc
9
+
export TANGLED_PLC_URL=https://plc.tngl.boltless.dev
10
+
# jetstream
11
+
export TANGLED_JETSTREAM_ENDPOINT=wss://jetstream.tngl.boltless.dev/subscribe
12
+
# label
13
+
export TANGLED_LABEL_GFI=at://${tangled_did}/sh.tangled.label.definition/good-first-issue
14
+
export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_GFI
15
+
export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_DEFAULTS,at://${tangled_did}/sh.tangled.label.definition/assignee
16
+
export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_DEFAULTS,at://${tangled_did}/sh.tangled.label.definition/documentation
17
+
export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_DEFAULTS,at://${tangled_did}/sh.tangled.label.definition/duplicate
18
+
export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_DEFAULTS,at://${tangled_did}/sh.tangled.label.definition/wontfix
19
+
20
+
# vm settings
21
+
export TANGLED_VM_PLC_URL=https://plc.tngl.boltless.dev
22
+
export TANGLED_VM_JETSTREAM_ENDPOINT=wss://jetstream.tngl.boltless.dev/subscribe
23
+
export TANGLED_VM_KNOT_HOST=knot.tngl.boltless.dev
24
+
export TANGLED_VM_KNOT_OWNER=$alice_did
25
+
export TANGLED_VM_SPINDLE_HOST=spindle.tngl.boltless.dev
26
+
export TANGLED_VM_SPINDLE_OWNER=$alice_did
27
+
28
+
if [ -n "${TANGLED_RESEND_API_KEY:-}" ] && [ -n "${TANGLED_RESEND_SENT_FROM:-}" ]; then
29
+
export TANGLED_VM_PDS_EMAIL_SMTP_URL=smtps://resend:$TANGLED_RESEND_API_KEY@smtp.resend.com:465/
30
+
export TANGLED_VM_PDS_EMAIL_FROM_ADDRESS=$TANGLED_RESEND_SENT_FROM
31
+
fi
+12
contrib/pds.env
+12
contrib/pds.env
···
1
+
LOG_ENABLED=true
2
+
3
+
PDS_JWT_SECRET=8cae8bffcc73d9932819650791e4e89a
4
+
PDS_ADMIN_PASSWORD=d6a902588cd93bee1af83f924f60cfd3
5
+
PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=2e92e336a50a618458e1097d94a1db86ec3fd8829d7735020cbae80625c761d7
6
+
7
+
PDS_DATA_DIRECTORY=/pds
8
+
PDS_BLOBSTORE_DISK_LOCATION=/pds/blocks
9
+
10
+
PDS_DID_PLC_URL=http://localhost:8080
11
+
PDS_HOSTNAME=pds.tngl.boltless.dev
12
+
PDS_PORT=3000
+25
contrib/readme.md
+25
contrib/readme.md
···
1
+
# how to setup local appview dev environment
2
+
3
+
Appview requires several microservices from knot and spindle to entire atproto infra. This test environment is implemented under nixos vm.
4
+
5
+
1. copy `contrib/example.env` to `.env`, fill it and source it
6
+
2. run vm
7
+
```bash
8
+
nix run --impure .#vm
9
+
```
10
+
3. trust the generated cert from host machine
11
+
```bash
12
+
# for macos
13
+
sudo security add-trusted-cert -d -r trustRoot \
14
+
-k /Library/Keychains/System.keychain \
15
+
./nix/vm-data/caddy/.local/share/caddy/pki/authorities/local/root.crt
16
+
```
17
+
4. create test accounts with valid emails (use [`create-test-account.sh`](./scripts/create-test-account.sh))
18
+
5. create default labels (use [`setup-const-records`](./scripts/setup-const-records.sh))
19
+
6. restart vm with correct owner-did
20
+
21
+
for git-https, you should change your local git config:
22
+
```
23
+
[http "https://knot.tngl.boltless.dev"]
24
+
sslCAPath = /Users/boltless/repo/tangled/nix/vm-data/caddy/.local/share/caddy/pki/authorities/local/
25
+
```
+68
contrib/scripts/create-test-account.sh
+68
contrib/scripts/create-test-account.sh
···
1
+
#!/bin/bash
2
+
set -o errexit
3
+
set -o nounset
4
+
set -o pipefail
5
+
6
+
source "$(dirname "$0")/../pds.env"
7
+
8
+
# PDS_HOSTNAME=
9
+
# PDS_ADMIN_PASSWORD=
10
+
11
+
# curl a URL and fail if the request fails.
12
+
function curl_cmd_get {
13
+
curl --fail --silent --show-error "$@"
14
+
}
15
+
16
+
# curl a URL and fail if the request fails.
17
+
function curl_cmd_post {
18
+
curl --fail --silent --show-error --request POST --header "Content-Type: application/json" "$@"
19
+
}
20
+
21
+
# curl a URL but do not fail if the request fails.
22
+
function curl_cmd_post_nofail {
23
+
curl --silent --show-error --request POST --header "Content-Type: application/json" "$@"
24
+
}
25
+
26
+
USERNAME="${1:-}"
27
+
28
+
if [[ "${USERNAME}" == "" ]]; then
29
+
read -p "Enter a username: " USERNAME
30
+
fi
31
+
32
+
if [[ "${USERNAME}" == "" ]]; then
33
+
echo "ERROR: missing USERNAME parameter." >/dev/stderr
34
+
echo "Usage: $0 ${SUBCOMMAND} <USERNAME>" >/dev/stderr
35
+
exit 1
36
+
fi
37
+
38
+
EMAIL=${USERNAME}@${PDS_HOSTNAME}
39
+
40
+
PASSWORD="password"
41
+
INVITE_CODE="$(curl_cmd_post \
42
+
--user "admin:${PDS_ADMIN_PASSWORD}" \
43
+
--data '{"useCount": 1}' \
44
+
"https://${PDS_HOSTNAME}/xrpc/com.atproto.server.createInviteCode" | jq --raw-output '.code'
45
+
)"
46
+
RESULT="$(curl_cmd_post_nofail \
47
+
--data "{\"email\":\"${EMAIL}\", \"handle\":\"${USERNAME}.${PDS_HOSTNAME}\", \"password\":\"${PASSWORD}\", \"inviteCode\":\"${INVITE_CODE}\"}" \
48
+
"https://${PDS_HOSTNAME}/xrpc/com.atproto.server.createAccount"
49
+
)"
50
+
51
+
DID="$(echo $RESULT | jq --raw-output '.did')"
52
+
if [[ "${DID}" != did:* ]]; then
53
+
ERR="$(echo ${RESULT} | jq --raw-output '.message')"
54
+
echo "ERROR: ${ERR}" >/dev/stderr
55
+
echo "Usage: $0 <EMAIL> <HANDLE>" >/dev/stderr
56
+
exit 1
57
+
fi
58
+
59
+
echo
60
+
echo "Account created successfully!"
61
+
echo "-----------------------------"
62
+
echo "Handle : ${USERNAME}.${PDS_HOSTNAME}"
63
+
echo "DID : ${DID}"
64
+
echo "Password : ${PASSWORD}"
65
+
echo "-----------------------------"
66
+
echo "This is a test account with an insecure password."
67
+
echo "Make sure it's only used for development."
68
+
echo
+106
contrib/scripts/setup-const-records.sh
+106
contrib/scripts/setup-const-records.sh
···
1
+
#!/bin/bash
2
+
set -o errexit
3
+
set -o nounset
4
+
set -o pipefail
5
+
6
+
source "$(dirname "$0")/../pds.env"
7
+
8
+
# PDS_HOSTNAME=
9
+
10
+
# curl a URL and fail if the request fails.
11
+
function curl_cmd_get {
12
+
curl --fail --silent --show-error "$@"
13
+
}
14
+
15
+
# curl a URL and fail if the request fails.
16
+
function curl_cmd_post {
17
+
curl --fail --silent --show-error --request POST --header "Content-Type: application/json" "$@"
18
+
}
19
+
20
+
# curl a URL but do not fail if the request fails.
21
+
function curl_cmd_post_nofail {
22
+
curl --silent --show-error --request POST --header "Content-Type: application/json" "$@"
23
+
}
24
+
25
+
USERNAME="${1:-}"
26
+
27
+
if [[ "${USERNAME}" == "" ]]; then
28
+
read -p "Enter a username: " USERNAME
29
+
fi
30
+
31
+
if [[ "${USERNAME}" == "" ]]; then
32
+
echo "ERROR: missing USERNAME parameter." >/dev/stderr
33
+
echo "Usage: $0 ${SUBCOMMAND} <USERNAME>" >/dev/stderr
34
+
exit 1
35
+
fi
36
+
37
+
SESS_RESULT="$(curl_cmd_post \
38
+
--data "$(cat <<EOF
39
+
{
40
+
"identifier": "$USERNAME",
41
+
"password": "password"
42
+
}
43
+
EOF
44
+
)" \
45
+
https://pds.tngl.boltless.dev/xrpc/com.atproto.server.createSession
46
+
)"
47
+
48
+
echo $SESS_RESULT | jq
49
+
50
+
DID="$(echo $SESS_RESULT | jq --raw-output '.did')"
51
+
ACCESS_JWT="$(echo $SESS_RESULT | jq --raw-output '.accessJwt')"
52
+
53
+
function add_label_def {
54
+
local color=$1
55
+
local name=$2
56
+
echo $color
57
+
echo $name
58
+
local json_payload=$(cat <<EOF
59
+
{
60
+
"repo": "$DID",
61
+
"collection": "sh.tangled.label.definition",
62
+
"rkey": "$name",
63
+
"record": {
64
+
"name": "$name",
65
+
"color": "$color",
66
+
"scope": ["sh.tangled.repo.issue"],
67
+
"multiple": false,
68
+
"createdAt": "2025-09-22T11:14:35+01:00",
69
+
"valueType": {"type": "null", "format": "any"}
70
+
}
71
+
}
72
+
EOF
73
+
)
74
+
echo $json_payload
75
+
echo $json_payload | jq
76
+
RESULT="$(curl_cmd_post \
77
+
--data "$json_payload" \
78
+
-H "Authorization: Bearer ${ACCESS_JWT}" \
79
+
"https://${PDS_HOSTNAME}/xrpc/com.atproto.repo.createRecord")"
80
+
echo $RESULT | jq
81
+
}
82
+
83
+
add_label_def '#64748b' 'wontfix'
84
+
add_label_def '#8B5CF6' 'good-first-issue'
85
+
add_label_def '#ef4444' 'duplicate'
86
+
add_label_def '#06b6d4' 'documentation'
87
+
json_payload=$(cat <<EOF
88
+
{
89
+
"repo": "$DID",
90
+
"collection": "sh.tangled.label.definition",
91
+
"rkey": "assignee",
92
+
"record": {
93
+
"name": "assignee",
94
+
"color": "#10B981",
95
+
"scope": ["sh.tangled.repo.issue", "sh.tangled.repo.pull"],
96
+
"multiple": false,
97
+
"createdAt": "2025-09-22T11:14:35+01:00",
98
+
"valueType": {"type": "string", "format": "did"}
99
+
}
100
+
}
101
+
EOF
102
+
)
103
+
curl_cmd_post \
104
+
--data "$json_payload" \
105
+
-H "Authorization: Bearer ${ACCESS_JWT}" \
106
+
"https://${PDS_HOSTNAME}/xrpc/com.atproto.repo.createRecord"
+1529
docs/DOCS.md
+1529
docs/DOCS.md
···
1
+
---
2
+
title: Tangled docs
3
+
author: The Tangled Contributors
4
+
date: 21 Sun, Dec 2025
5
+
---
6
+
7
+
# Introduction
8
+
9
+
Tangled is a decentralized code hosting and collaboration
10
+
platform. Every component of Tangled is open-source and
11
+
self-hostable. [tangled.org](https://tangled.org) also
12
+
provides hosting and CI services that are free to use.
13
+
14
+
There are several models for decentralized code
15
+
collaboration platforms, ranging from ActivityPubโs
16
+
(Forgejo) federated model, to Radicleโs entirely P2P model.
17
+
Our approach attempts to be the best of both worlds by
18
+
adopting the AT Protocolโa protocol for building decentralized
19
+
social applications with a central identity
20
+
21
+
Our approach to this is the idea of โknotsโ. Knots are
22
+
lightweight, headless servers that enable users to host Git
23
+
repositories with ease. Knots are designed for either single
24
+
or multi-tenant use which is perfect for self-hosting on a
25
+
Raspberry Pi at home, or larger โcommunityโ servers. By
26
+
default, Tangled provides managed knots where you can host
27
+
your repositories for free.
28
+
29
+
The appview at tangled.org acts as a consolidated "view"
30
+
into the whole network, allowing users to access, clone and
31
+
contribute to repositories hosted across different knots
32
+
seamlessly.
33
+
34
+
# Quick start guide
35
+
36
+
## Login or sign up
37
+
38
+
You can [login](https://tangled.org) by using your AT Protocol
39
+
account. If you are unclear on what that means, simply head
40
+
to the [signup](https://tangled.org/signup) page and create
41
+
an account. By doing so, you will be choosing Tangled as
42
+
your account provider (you will be granted a handle of the
43
+
form `user.tngl.sh`).
44
+
45
+
In the AT Protocol network, users are free to choose their account
46
+
provider (known as a "Personal Data Service", or PDS), and
47
+
login to applications that support AT accounts.
48
+
49
+
You can think of it as "one account for all of the atmosphere"!
50
+
51
+
If you already have an AT account (you may have one if you
52
+
signed up to Bluesky, for example), you can login with the
53
+
same handle on Tangled (so just use `user.bsky.social` on
54
+
the login page).
55
+
56
+
## Add an SSH key
57
+
58
+
Once you are logged in, you can start creating repositories
59
+
and pushing code. Tangled supports pushing git repositories
60
+
over SSH.
61
+
62
+
First, you'll need to generate an SSH key if you don't
63
+
already have one:
64
+
65
+
```bash
66
+
ssh-keygen -t ed25519 -C "foo@bar.com"
67
+
```
68
+
69
+
When prompted, save the key to the default location
70
+
(`~/.ssh/id_ed25519`) and optionally set a passphrase.
71
+
72
+
Copy your public key to your clipboard:
73
+
74
+
```bash
75
+
# on X11
76
+
cat ~/.ssh/id_ed25519.pub | xclip -sel c
77
+
78
+
# on wayland
79
+
cat ~/.ssh/id_ed25519.pub | wl-copy
80
+
81
+
# on macos
82
+
cat ~/.ssh/id_ed25519.pub | pbcopy
83
+
```
84
+
85
+
Now, navigate to 'Settings' -> 'Keys' and hit 'Add Key',
86
+
paste your public key, give it a descriptive name, and hit
87
+
save.
88
+
89
+
## Create a repository
90
+
91
+
Once your SSH key is added, create your first repository:
92
+
93
+
1. Hit the green `+` icon on the topbar, and select
94
+
repository
95
+
2. Enter a repository name
96
+
3. Add a description
97
+
4. Choose a knotserver to host this repository on
98
+
5. Hit create
99
+
100
+
Knots are self-hostable, lightweight Git servers that can
101
+
host your repository. Unlike traditional code forges, your
102
+
code can live on any server. Read the [Knots](TODO) section
103
+
for more.
104
+
105
+
## Configure SSH
106
+
107
+
To ensure Git uses the correct SSH key and connects smoothly
108
+
to Tangled, add this configuration to your `~/.ssh/config`
109
+
file:
110
+
111
+
```
112
+
Host tangled.org
113
+
Hostname tangled.org
114
+
User git
115
+
IdentityFile ~/.ssh/id_ed25519
116
+
AddressFamily inet
117
+
```
118
+
119
+
This tells SSH to use your specific key when connecting to
120
+
Tangled and prevents authentication issues if you have
121
+
multiple SSH keys.
122
+
123
+
Note that this configuration only works for knotservers that
124
+
are hosted by tangled.org. If you use a custom knot, refer
125
+
to the [Knots](TODO) section.
126
+
127
+
## Push your first repository
128
+
129
+
Initialize a new Git repository:
130
+
131
+
```bash
132
+
mkdir my-project
133
+
cd my-project
134
+
135
+
git init
136
+
echo "# My Project" > README.md
137
+
```
138
+
139
+
Add some content and push!
140
+
141
+
```bash
142
+
git add README.md
143
+
git commit -m "Initial commit"
144
+
git remote add origin git@tangled.org:user.tngl.sh/my-project
145
+
git push -u origin main
146
+
```
147
+
148
+
That's it! Your code is now hosted on Tangled.
149
+
150
+
## Migrating an existing repository
151
+
152
+
Moving your repositories from GitHub, GitLab, Bitbucket, or
153
+
any other Git forge to Tangled is straightforward. You'll
154
+
simply change your repository's remote URL. At the moment,
155
+
Tangled does not have any tooling to migrate data such as
156
+
GitHub issues or pull requests.
157
+
158
+
First, create a new repository on tangled.org as described
159
+
in the [Quick Start Guide](#create-a-repository).
160
+
161
+
Navigate to your existing local repository:
162
+
163
+
```bash
164
+
cd /path/to/your/existing/repo
165
+
```
166
+
167
+
You can inspect your existing Git remote like so:
168
+
169
+
```bash
170
+
git remote -v
171
+
```
172
+
173
+
You'll see something like:
174
+
175
+
```
176
+
origin git@github.com:username/my-project (fetch)
177
+
origin git@github.com:username/my-project (push)
178
+
```
179
+
180
+
Update the remote URL to point to tangled:
181
+
182
+
```bash
183
+
git remote set-url origin git@tangled.org:user.tngl.sh/my-project
184
+
```
185
+
186
+
Verify the change:
187
+
188
+
```bash
189
+
git remote -v
190
+
```
191
+
192
+
You should now see:
193
+
194
+
```
195
+
origin git@tangled.org:user.tngl.sh/my-project (fetch)
196
+
origin git@tangled.org:user.tngl.sh/my-project (push)
197
+
```
198
+
199
+
Push all your branches and tags to Tangled:
200
+
201
+
```bash
202
+
git push -u origin --all
203
+
git push -u origin --tags
204
+
```
205
+
206
+
Your repository is now migrated to Tangled! All commit
207
+
history, branches, and tags have been preserved.
208
+
209
+
## Mirroring a repository to Tangled
210
+
211
+
If you want to maintain your repository on multiple forges
212
+
simultaneously, for example, keeping your primary repository
213
+
on GitHub while mirroring to Tangled for backup or
214
+
redundancy, you can do so by adding multiple remotes.
215
+
216
+
You can configure your local repository to push to both
217
+
Tangled and, say, GitHub. You may already have the following
218
+
setup:
219
+
220
+
```
221
+
$ git remote -v
222
+
origin git@github.com:username/my-project (fetch)
223
+
origin git@github.com:username/my-project (push)
224
+
```
225
+
226
+
Now add Tangled as an additional push URL to the same
227
+
remote:
228
+
229
+
```bash
230
+
git remote set-url --add --push origin git@tangled.org:user.tngl.sh/my-project
231
+
```
232
+
233
+
You also need to re-add the original URL as a push
234
+
destination (Git replaces the push URL when you use `--add`
235
+
the first time):
236
+
237
+
```bash
238
+
git remote set-url --add --push origin git@github.com:username/my-project
239
+
```
240
+
241
+
Verify your configuration:
242
+
243
+
```
244
+
$ git remote -v
245
+
origin git@github.com:username/repo (fetch)
246
+
origin git@tangled.org:username/my-project (push)
247
+
origin git@github.com:username/repo (push)
248
+
```
249
+
250
+
Notice that there's one fetch URL (the primary remote) and
251
+
two push URLs. Now, whenever you push, Git will
252
+
automatically push to both remotes:
253
+
254
+
```bash
255
+
git push origin main
256
+
```
257
+
258
+
This single command pushes your `main` branch to both GitHub
259
+
and Tangled simultaneously.
260
+
261
+
To push all branches and tags:
262
+
263
+
```bash
264
+
git push origin --all
265
+
git push origin --tags
266
+
```
267
+
268
+
If you prefer more control over which remote you push to,
269
+
you can maintain separate remotes:
270
+
271
+
```bash
272
+
git remote add github git@github.com:username/my-project
273
+
git remote add tangled git@tangled.org:username/my-project
274
+
```
275
+
276
+
Then push to each explicitly:
277
+
278
+
```bash
279
+
git push github main
280
+
git push tangled main
281
+
```
282
+
283
+
# Knot self-hosting guide
284
+
285
+
So you want to run your own knot server? Great! Here are a few prerequisites:
286
+
287
+
1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind.
288
+
2. A (sub)domain name. People generally use `knot.example.com`.
289
+
3. A valid SSL certificate for your domain.
290
+
291
+
## NixOS
292
+
293
+
Refer to the [knot
294
+
module](https://tangled.org/tangled.org/core/blob/master/nix/modules/knot.nix)
295
+
for a full list of options. Sample configurations:
296
+
297
+
- [The test VM](https://tangled.org/tangled.org/core/blob/master/nix/vm.nix#L85)
298
+
- [@pyrox.dev/nix](https://tangled.org/pyrox.dev/nix/blob/d19571cc1b5fe01035e1e6951ec8cf8a476b4dee/hosts/marvin/services/tangled.nix#L15-25)
299
+
300
+
## Docker
301
+
302
+
Refer to
303
+
[@tangled.org/knot-docker](https://tangled.org/@tangled.org/knot-docker).
304
+
Note that this is community maintained.
305
+
306
+
## Manual setup
307
+
308
+
First, clone this repository:
309
+
310
+
```
311
+
git clone https://tangled.org/@tangled.org/core
312
+
```
313
+
314
+
Then, build the `knot` CLI. This is the knot administration
315
+
and operation tool. For the purpose of this guide, we're
316
+
only concerned with these subcommands:
317
+
318
+
* `knot server`: the main knot server process, typically
319
+
run as a supervised service
320
+
* `knot guard`: handles role-based access control for git
321
+
over SSH (you'll never have to run this yourself)
322
+
* `knot keys`: fetches SSH keys associated with your knot;
323
+
we'll use this to generate the SSH
324
+
`AuthorizedKeysCommand`
325
+
326
+
```
327
+
cd core
328
+
export CGO_ENABLED=1
329
+
go build -o knot ./cmd/knot
330
+
```
331
+
332
+
Next, move the `knot` binary to a location owned by `root` --
333
+
`/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`:
334
+
335
+
```
336
+
sudo mv knot /usr/local/bin/knot
337
+
sudo chown root:root /usr/local/bin/knot
338
+
```
339
+
340
+
This is necessary because SSH `AuthorizedKeysCommand` requires [really
341
+
specific permissions](https://stackoverflow.com/a/27638306). The
342
+
`AuthorizedKeysCommand` specifies a command that is run by `sshd` to
343
+
retrieve a user's public SSH keys dynamically for authentication. Let's
344
+
set that up.
345
+
346
+
```
347
+
sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
348
+
Match User git
349
+
AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys
350
+
AuthorizedKeysCommandUser nobody
351
+
EOF
352
+
```
353
+
354
+
Then, reload `sshd`:
355
+
356
+
```
357
+
sudo systemctl reload ssh
358
+
```
359
+
360
+
Next, create the `git` user. We'll use the `git` user's home directory
361
+
to store repositories:
362
+
363
+
```
364
+
sudo adduser git
365
+
```
366
+
367
+
Create `/home/git/.knot.env` with the following, updating the values as
368
+
necessary. The `KNOT_SERVER_OWNER` should be set to your
369
+
DID, you can find your DID in the [Settings](https://tangled.sh/settings) page.
370
+
371
+
```
372
+
KNOT_REPO_SCAN_PATH=/home/git
373
+
KNOT_SERVER_HOSTNAME=knot.example.com
374
+
APPVIEW_ENDPOINT=https://tangled.org
375
+
KNOT_SERVER_OWNER=did:plc:foobar
376
+
KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444
377
+
KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
378
+
```
379
+
380
+
If you run a Linux distribution that uses systemd, you can use the provided
381
+
service file to run the server. Copy
382
+
[`knotserver.service`](/systemd/knotserver.service)
383
+
to `/etc/systemd/system/`. Then, run:
384
+
385
+
```
386
+
systemctl enable knotserver
387
+
systemctl start knotserver
388
+
```
389
+
390
+
The last step is to configure a reverse proxy like Nginx or Caddy to front your
391
+
knot. Here's an example configuration for Nginx:
392
+
393
+
```
394
+
server {
395
+
listen 80;
396
+
listen [::]:80;
397
+
server_name knot.example.com;
398
+
399
+
location / {
400
+
proxy_pass http://localhost:5555;
401
+
proxy_set_header Host $host;
402
+
proxy_set_header X-Real-IP $remote_addr;
403
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
404
+
proxy_set_header X-Forwarded-Proto $scheme;
405
+
}
406
+
407
+
# wss endpoint for git events
408
+
location /events {
409
+
proxy_set_header X-Forwarded-For $remote_addr;
410
+
proxy_set_header Host $http_host;
411
+
proxy_set_header Upgrade websocket;
412
+
proxy_set_header Connection Upgrade;
413
+
proxy_pass http://localhost:5555;
414
+
}
415
+
# additional config for SSL/TLS go here.
416
+
}
417
+
418
+
```
419
+
420
+
Remember to use Let's Encrypt or similar to procure a certificate for your
421
+
knot domain.
422
+
423
+
You should now have a running knot server! You can finalize
424
+
your registration by hitting the `verify` button on the
425
+
[/settings/knots](https://tangled.org/settings/knots) page. This simply creates
426
+
a record on your PDS to announce the existence of the knot.
427
+
428
+
### Custom paths
429
+
430
+
(This section applies to manual setup only. Docker users should edit the mounts
431
+
in `docker-compose.yml` instead.)
432
+
433
+
Right now, the database and repositories of your knot lives in `/home/git`. You
434
+
can move these paths if you'd like to store them in another folder. Be careful
435
+
when adjusting these paths:
436
+
437
+
* Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent
438
+
any possible side effects. Remember to restart it once you're done.
439
+
* Make backups before moving in case something goes wrong.
440
+
* Make sure the `git` user can read and write from the new paths.
441
+
442
+
#### Database
443
+
444
+
As an example, let's say the current database is at `/home/git/knotserver.db`,
445
+
and we want to move it to `/home/git/database/knotserver.db`.
446
+
447
+
Copy the current database to the new location. Make sure to copy the `.db-shm`
448
+
and `.db-wal` files if they exist.
449
+
450
+
```
451
+
mkdir /home/git/database
452
+
cp /home/git/knotserver.db* /home/git/database
453
+
```
454
+
455
+
In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to
456
+
the new file path (_not_ the directory):
457
+
458
+
```
459
+
KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db
460
+
```
461
+
462
+
#### Repositories
463
+
464
+
As an example, let's say the repositories are currently in `/home/git`, and we
465
+
want to move them into `/home/git/repositories`.
466
+
467
+
Create the new folder, then move the existing repositories (if there are any):
468
+
469
+
```
470
+
mkdir /home/git/repositories
471
+
# move all DIDs into the new folder; these will vary for you!
472
+
mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories
473
+
```
474
+
475
+
In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH`
476
+
to the new directory:
477
+
478
+
```
479
+
KNOT_REPO_SCAN_PATH=/home/git/repositories
480
+
```
481
+
482
+
Similarly, update your `sshd` `AuthorizedKeysCommand` to use the updated
483
+
repository path:
484
+
485
+
```
486
+
sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
487
+
Match User git
488
+
AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys -git-dir /home/git/repositories
489
+
AuthorizedKeysCommandUser nobody
490
+
EOF
491
+
```
492
+
493
+
Make sure to restart your SSH server!
494
+
495
+
#### MOTD (message of the day)
496
+
497
+
To configure the MOTD used ("Welcome to this knot!" by default), edit the
498
+
`/home/git/motd` file:
499
+
500
+
```
501
+
printf "Hi from this knot!\n" > /home/git/motd
502
+
```
503
+
504
+
Note that you should add a newline at the end if setting a non-empty message
505
+
since the knot won't do this for you.
506
+
507
+
# Spindles
508
+
509
+
## Pipelines
510
+
511
+
Spindle workflows allow you to write CI/CD pipelines in a
512
+
simple format. They're located in the `.tangled/workflows`
513
+
directory at the root of your repository, and are defined
514
+
using YAML.
515
+
516
+
The fields are:
517
+
518
+
- [Trigger](#trigger): A **required** field that defines
519
+
when a workflow should be triggered.
520
+
- [Engine](#engine): A **required** field that defines which
521
+
engine a workflow should run on.
522
+
- [Clone options](#clone-options): An **optional** field
523
+
that defines how the repository should be cloned.
524
+
- [Dependencies](#dependencies): An **optional** field that
525
+
allows you to list dependencies you may need.
526
+
- [Environment](#environment): An **optional** field that
527
+
allows you to define environment variables.
528
+
- [Steps](#steps): An **optional** field that allows you to
529
+
define what steps should run in the workflow.
530
+
531
+
### Trigger
532
+
533
+
The first thing to add to a workflow is the trigger, which
534
+
defines when a workflow runs. This is defined using a `when`
535
+
field, which takes in a list of conditions. Each condition
536
+
has the following fields:
537
+
538
+
- `event`: This is a **required** field that defines when
539
+
your workflow should run. It's a list that can take one or
540
+
more of the following values:
541
+
- `push`: The workflow should run every time a commit is
542
+
pushed to the repository.
543
+
- `pull_request`: The workflow should run every time a
544
+
pull request is made or updated.
545
+
- `manual`: The workflow can be triggered manually.
546
+
- `branch`: Defines which branches the workflow should run
547
+
for. If used with the `push` event, commits to the
548
+
branch(es) listed here will trigger the workflow. If used
549
+
with the `pull_request` event, updates to pull requests
550
+
targeting the branch(es) listed here will trigger the
551
+
workflow. This field has no effect with the `manual`
552
+
event. Supports glob patterns using `*` and `**` (e.g.,
553
+
`main`, `develop`, `release-*`). Either `branch` or `tag`
554
+
(or both) must be specified for `push` events.
555
+
- `tag`: Defines which tags the workflow should run for.
556
+
Only used with the `push` event - when tags matching the
557
+
pattern(s) listed here are pushed, the workflow will
558
+
trigger. This field has no effect with `pull_request` or
559
+
`manual` events. Supports glob patterns using `*` and `**`
560
+
(e.g., `v*`, `v1.*`, `release-**`). Either `branch` or
561
+
`tag` (or both) must be specified for `push` events.
562
+
563
+
For example, if you'd like to define a workflow that runs
564
+
when commits are pushed to the `main` and `develop`
565
+
branches, or when pull requests that target the `main`
566
+
branch are updated, or manually, you can do so with:
567
+
568
+
```yaml
569
+
when:
570
+
- event: ["push", "manual"]
571
+
branch: ["main", "develop"]
572
+
- event: ["pull_request"]
573
+
branch: ["main"]
574
+
```
575
+
576
+
You can also trigger workflows on tag pushes. For instance,
577
+
to run a deployment workflow when tags matching `v*` are
578
+
pushed:
579
+
580
+
```yaml
581
+
when:
582
+
- event: ["push"]
583
+
tag: ["v*"]
584
+
```
585
+
586
+
You can even combine branch and tag patterns in a single
587
+
constraint (the workflow triggers if either matches):
588
+
589
+
```yaml
590
+
when:
591
+
- event: ["push"]
592
+
branch: ["main", "release-*"]
593
+
tag: ["v*", "stable"]
594
+
```
595
+
596
+
### Engine
597
+
598
+
Next is the engine on which the workflow should run, defined
599
+
using the **required** `engine` field. The currently
600
+
supported engines are:
601
+
602
+
- `nixery`: This uses an instance of
603
+
[Nixery](https://nixery.dev) to run steps, which allows
604
+
you to add [dependencies](#dependencies) from
605
+
Nixpkgs (https://github.com/NixOS/nixpkgs). You can
606
+
search for packages on https://search.nixos.org, and
607
+
there's a pretty good chance the package(s) you're looking
608
+
for will be there.
609
+
610
+
Example:
611
+
612
+
```yaml
613
+
engine: "nixery"
614
+
```
615
+
616
+
### Clone options
617
+
618
+
When a workflow starts, the first step is to clone the
619
+
repository. You can customize this behavior using the
620
+
**optional** `clone` field. It has the following fields:
621
+
622
+
- `skip`: Setting this to `true` will skip cloning the
623
+
repository. This can be useful if your workflow is doing
624
+
something that doesn't require anything from the
625
+
repository itself. This is `false` by default.
626
+
- `depth`: This sets the number of commits, or the "clone
627
+
depth", to fetch from the repository. For example, if you
628
+
set this to 2, the last 2 commits will be fetched. By
629
+
default, the depth is set to 1, meaning only the most
630
+
recent commit will be fetched, which is the commit that
631
+
triggered the workflow.
632
+
- `submodules`: If you use Git submodules
633
+
(https://git-scm.com/book/en/v2/Git-Tools-Submodules)
634
+
in your repository, setting this field to `true` will
635
+
recursively fetch all submodules. This is `false` by
636
+
default.
637
+
638
+
The default settings are:
639
+
640
+
```yaml
641
+
clone:
642
+
skip: false
643
+
depth: 1
644
+
submodules: false
645
+
```
646
+
647
+
### Dependencies
648
+
649
+
Usually when you're running a workflow, you'll need
650
+
additional dependencies. The `dependencies` field lets you
651
+
define which dependencies to get, and from where. It's a
652
+
key-value map, with the key being the registry to fetch
653
+
dependencies from, and the value being the list of
654
+
dependencies to fetch.
655
+
656
+
Say you want to fetch Node.js and Go from `nixpkgs`, and a
657
+
package called `my_pkg` you've made from your own registry
658
+
at your repository at
659
+
`https://tangled.org/@example.com/my_pkg`. You can define
660
+
those dependencies like so:
661
+
662
+
```yaml
663
+
dependencies:
664
+
# nixpkgs
665
+
nixpkgs:
666
+
- nodejs
667
+
- go
668
+
# custom registry
669
+
git+https://tangled.org/@example.com/my_pkg:
670
+
- my_pkg
671
+
```
672
+
673
+
Now these dependencies are available to use in your
674
+
workflow!
675
+
676
+
### Environment
677
+
678
+
The `environment` field allows you define environment
679
+
variables that will be available throughout the entire
680
+
workflow. **Do not put secrets here, these environment
681
+
variables are visible to anyone viewing the repository. You
682
+
can add secrets for pipelines in your repository's
683
+
settings.**
684
+
685
+
Example:
686
+
687
+
```yaml
688
+
environment:
689
+
GOOS: "linux"
690
+
GOARCH: "arm64"
691
+
NODE_ENV: "production"
692
+
MY_ENV_VAR: "MY_ENV_VALUE"
693
+
```
694
+
695
+
### Steps
696
+
697
+
The `steps` field allows you to define what steps should run
698
+
in the workflow. It's a list of step objects, each with the
699
+
following fields:
700
+
701
+
- `name`: This field allows you to give your step a name.
702
+
This name is visible in your workflow runs, and is used to
703
+
describe what the step is doing.
704
+
- `command`: This field allows you to define a command to
705
+
run in that step. The step is run in a Bash shell, and the
706
+
logs from the command will be visible in the pipelines
707
+
page on the Tangled website. The
708
+
[dependencies](#dependencies) you added will be available
709
+
to use here.
710
+
- `environment`: Similar to the global
711
+
[environment](#environment) config, this **optional**
712
+
field is a key-value map that allows you to set
713
+
environment variables for the step. **Do not put secrets
714
+
here, these environment variables are visible to anyone
715
+
viewing the repository. You can add secrets for pipelines
716
+
in your repository's settings.**
717
+
718
+
Example:
719
+
720
+
```yaml
721
+
steps:
722
+
- name: "Build backend"
723
+
command: "go build"
724
+
environment:
725
+
GOOS: "darwin"
726
+
GOARCH: "arm64"
727
+
- name: "Build frontend"
728
+
command: "npm run build"
729
+
environment:
730
+
NODE_ENV: "production"
731
+
```
732
+
733
+
### Complete workflow
734
+
735
+
```yaml
736
+
# .tangled/workflows/build.yml
737
+
738
+
when:
739
+
- event: ["push", "manual"]
740
+
branch: ["main", "develop"]
741
+
- event: ["pull_request"]
742
+
branch: ["main"]
743
+
744
+
engine: "nixery"
745
+
746
+
# using the default values
747
+
clone:
748
+
skip: false
749
+
depth: 1
750
+
submodules: false
751
+
752
+
dependencies:
753
+
# nixpkgs
754
+
nixpkgs:
755
+
- nodejs
756
+
- go
757
+
# custom registry
758
+
git+https://tangled.org/@example.com/my_pkg:
759
+
- my_pkg
760
+
761
+
environment:
762
+
GOOS: "linux"
763
+
GOARCH: "arm64"
764
+
NODE_ENV: "production"
765
+
MY_ENV_VAR: "MY_ENV_VALUE"
766
+
767
+
steps:
768
+
- name: "Build backend"
769
+
command: "go build"
770
+
environment:
771
+
GOOS: "darwin"
772
+
GOARCH: "arm64"
773
+
- name: "Build frontend"
774
+
command: "npm run build"
775
+
environment:
776
+
NODE_ENV: "production"
777
+
```
778
+
779
+
If you want another example of a workflow, you can look at
780
+
the one [Tangled uses to build the
781
+
project](https://tangled.org/@tangled.org/core/blob/master/.tangled/workflows/build.yml).
782
+
783
+
## Self-hosting guide
784
+
785
+
### Prerequisites
786
+
787
+
* Go
788
+
* Docker (the only supported backend currently)
789
+
790
+
### Configuration
791
+
792
+
Spindle is configured using environment variables. The following environment variables are available:
793
+
794
+
* `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`).
795
+
* `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`).
796
+
* `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required).
797
+
* `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`).
798
+
* `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`).
799
+
* `SPINDLE_SERVER_OWNER`: The DID of the owner (required).
800
+
* `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`).
801
+
* `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`).
802
+
* `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`).
803
+
804
+
### Running spindle
805
+
806
+
1. **Set the environment variables.** For example:
807
+
808
+
```shell
809
+
export SPINDLE_SERVER_HOSTNAME="your-hostname"
810
+
export SPINDLE_SERVER_OWNER="your-did"
811
+
```
812
+
813
+
2. **Build the Spindle binary.**
814
+
815
+
```shell
816
+
cd core
817
+
go mod download
818
+
go build -o cmd/spindle/spindle cmd/spindle/main.go
819
+
```
820
+
821
+
3. **Create the log directory.**
822
+
823
+
```shell
824
+
sudo mkdir -p /var/log/spindle
825
+
sudo chown $USER:$USER -R /var/log/spindle
826
+
```
827
+
828
+
4. **Run the Spindle binary.**
829
+
830
+
```shell
831
+
./cmd/spindle/spindle
832
+
```
833
+
834
+
Spindle will now start, connect to the Jetstream server, and begin processing pipelines.
835
+
836
+
## Architecture
837
+
838
+
Spindle is a small CI runner service. Here's a high-level overview of how it operates:
839
+
840
+
* Listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and
841
+
[`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream.
842
+
* When a new repo record comes through (typically when you add a spindle to a
843
+
repo from the settings), spindle then resolves the underlying knot and
844
+
subscribes to repo events (see:
845
+
[`sh.tangled.pipeline`](/lexicons/pipeline.json)).
846
+
* The spindle engine then handles execution of the pipeline, with results and
847
+
logs beamed on the spindle event stream over WebSocket
848
+
849
+
### The engine
850
+
851
+
At present, the only supported backend is Docker (and Podman, if Docker
852
+
compatibility is enabled, so that `/run/docker.sock` is created). spindle
853
+
executes each step in the pipeline in a fresh container, with state persisted
854
+
across steps within the `/tangled/workspace` directory.
855
+
856
+
The base image for the container is constructed on the fly using
857
+
[Nixery](https://nixery.dev), which is handy for caching layers for frequently
858
+
used packages.
859
+
860
+
The pipeline manifest is [specified here](https://docs.tangled.org/spindles.html#pipelines).
861
+
862
+
## Secrets with openbao
863
+
864
+
This document covers setting up spindle to use OpenBao for secrets
865
+
management via OpenBao Proxy instead of the default SQLite backend.
866
+
867
+
### Overview
868
+
869
+
Spindle now uses OpenBao Proxy for secrets management. The proxy handles
870
+
authentication automatically using AppRole credentials, while spindle
871
+
connects to the local proxy instead of directly to the OpenBao server.
872
+
873
+
This approach provides better security, automatic token renewal, and
874
+
simplified application code.
875
+
876
+
### Installation
877
+
878
+
Install OpenBao from Nixpkgs:
879
+
880
+
```bash
881
+
nix shell nixpkgs#openbao # for a local server
882
+
```
883
+
884
+
### Setup
885
+
886
+
The setup process can is documented for both local development and production.
887
+
888
+
#### Local development
889
+
890
+
Start OpenBao in dev mode:
891
+
892
+
```bash
893
+
bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201
894
+
```
895
+
896
+
This starts OpenBao on `http://localhost:8201` with a root token.
897
+
898
+
Set up environment for bao CLI:
899
+
900
+
```bash
901
+
export BAO_ADDR=http://localhost:8200
902
+
export BAO_TOKEN=root
903
+
```
904
+
905
+
#### Production
906
+
907
+
You would typically use a systemd service with a
908
+
configuration file. Refer to
909
+
[@tangled.org/infra](https://tangled.org/@tangled.org/infra)
910
+
for how this can be achieved using Nix.
911
+
912
+
Then, initialize the bao server:
913
+
914
+
```bash
915
+
bao operator init -key-shares=1 -key-threshold=1
916
+
```
917
+
918
+
This will print out an unseal key and a root key. Save them
919
+
somewhere (like a password manager). Then unseal the vault
920
+
to begin setting it up:
921
+
922
+
```bash
923
+
bao operator unseal <unseal_key>
924
+
```
925
+
926
+
All steps below remain the same across both dev and
927
+
production setups.
928
+
929
+
#### Configure openbao server
930
+
931
+
Create the spindle KV mount:
932
+
933
+
```bash
934
+
bao secrets enable -path=spindle -version=2 kv
935
+
```
936
+
937
+
Set up AppRole authentication and policy:
938
+
939
+
Create a policy file `spindle-policy.hcl`:
940
+
941
+
```hcl
942
+
# Full access to spindle KV v2 data
943
+
path "spindle/data/*" {
944
+
capabilities = ["create", "read", "update", "delete"]
945
+
}
946
+
947
+
# Access to metadata for listing and management
948
+
path "spindle/metadata/*" {
949
+
capabilities = ["list", "read", "delete", "update"]
950
+
}
951
+
952
+
# Allow listing at root level
953
+
path "spindle/" {
954
+
capabilities = ["list"]
955
+
}
956
+
957
+
# Required for connection testing and health checks
958
+
path "auth/token/lookup-self" {
959
+
capabilities = ["read"]
960
+
}
961
+
```
962
+
963
+
Apply the policy and create an AppRole:
964
+
965
+
```bash
966
+
bao policy write spindle-policy spindle-policy.hcl
967
+
bao auth enable approle
968
+
bao write auth/approle/role/spindle \
969
+
token_policies="spindle-policy" \
970
+
token_ttl=1h \
971
+
token_max_ttl=4h \
972
+
bind_secret_id=true \
973
+
secret_id_ttl=0 \
974
+
secret_id_num_uses=0
975
+
```
976
+
977
+
Get the credentials:
978
+
979
+
```bash
980
+
# Get role ID (static)
981
+
ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id)
982
+
983
+
# Generate secret ID
984
+
SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id)
985
+
986
+
echo "Role ID: $ROLE_ID"
987
+
echo "Secret ID: $SECRET_ID"
988
+
```
989
+
990
+
#### Create proxy configuration
991
+
992
+
Create the credential files:
993
+
994
+
```bash
995
+
# Create directory for OpenBao files
996
+
mkdir -p /tmp/openbao
997
+
998
+
# Save credentials
999
+
echo "$ROLE_ID" > /tmp/openbao/role-id
1000
+
echo "$SECRET_ID" > /tmp/openbao/secret-id
1001
+
chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id
1002
+
```
1003
+
1004
+
Create a proxy configuration file `/tmp/openbao/proxy.hcl`:
1005
+
1006
+
```hcl
1007
+
# OpenBao server connection
1008
+
vault {
1009
+
address = "http://localhost:8200"
1010
+
}
1011
+
1012
+
# Auto-Auth using AppRole
1013
+
auto_auth {
1014
+
method "approle" {
1015
+
mount_path = "auth/approle"
1016
+
config = {
1017
+
role_id_file_path = "/tmp/openbao/role-id"
1018
+
secret_id_file_path = "/tmp/openbao/secret-id"
1019
+
}
1020
+
}
1021
+
1022
+
# Optional: write token to file for debugging
1023
+
sink "file" {
1024
+
config = {
1025
+
path = "/tmp/openbao/token"
1026
+
mode = 0640
1027
+
}
1028
+
}
1029
+
}
1030
+
1031
+
# Proxy listener for spindle
1032
+
listener "tcp" {
1033
+
address = "127.0.0.1:8201"
1034
+
tls_disable = true
1035
+
}
1036
+
1037
+
# Enable API proxy with auto-auth token
1038
+
api_proxy {
1039
+
use_auto_auth_token = true
1040
+
}
1041
+
1042
+
# Enable response caching
1043
+
cache {
1044
+
use_auto_auth_token = true
1045
+
}
1046
+
1047
+
# Logging
1048
+
log_level = "info"
1049
+
```
1050
+
1051
+
#### Start the proxy
1052
+
1053
+
Start OpenBao Proxy:
1054
+
1055
+
```bash
1056
+
bao proxy -config=/tmp/openbao/proxy.hcl
1057
+
```
1058
+
1059
+
The proxy will authenticate with OpenBao and start listening on
1060
+
`127.0.0.1:8201`.
1061
+
1062
+
#### Configure spindle
1063
+
1064
+
Set these environment variables for spindle:
1065
+
1066
+
```bash
1067
+
export SPINDLE_SERVER_SECRETS_PROVIDER=openbao
1068
+
export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201
1069
+
export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle
1070
+
```
1071
+
1072
+
On startup, spindle will now connect to the local proxy,
1073
+
which handles all authentication automatically.
1074
+
1075
+
### Production setup for proxy
1076
+
1077
+
For production, you'll want to run the proxy as a service:
1078
+
1079
+
Place your production configuration in
1080
+
`/etc/openbao/proxy.hcl` with proper TLS settings for the
1081
+
vault connection.
1082
+
1083
+
### Verifying setup
1084
+
1085
+
Test the proxy directly:
1086
+
1087
+
```bash
1088
+
# Check proxy health
1089
+
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health
1090
+
1091
+
# Test token lookup through proxy
1092
+
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self
1093
+
```
1094
+
1095
+
Test OpenBao operations through the server:
1096
+
1097
+
```bash
1098
+
# List all secrets
1099
+
bao kv list spindle/
1100
+
1101
+
# Add a test secret via the spindle API, then check it exists
1102
+
bao kv list spindle/repos/
1103
+
1104
+
# Get a specific secret
1105
+
bao kv get spindle/repos/your_repo_path/SECRET_NAME
1106
+
```
1107
+
1108
+
### How it works
1109
+
1110
+
- Spindle connects to OpenBao Proxy on localhost (typically
1111
+
port 8200 or 8201)
1112
+
- The proxy authenticates with OpenBao using AppRole
1113
+
credentials
1114
+
- All spindle requests go through the proxy, which injects
1115
+
authentication tokens
1116
+
- Secrets are stored at
1117
+
`spindle/repos/{sanitized_repo_path}/{secret_key}`
1118
+
- Repository paths like `did:plc:alice/myrepo` become
1119
+
`did_plc_alice_myrepo`
1120
+
- The proxy handles all token renewal automatically
1121
+
- Spindle no longer manages tokens or authentication
1122
+
directly
1123
+
1124
+
### Troubleshooting
1125
+
1126
+
**Connection refused**: Check that the OpenBao Proxy is
1127
+
running and listening on the configured address.
1128
+
1129
+
**403 errors**: Verify the AppRole credentials are correct
1130
+
and the policy has the necessary permissions.
1131
+
1132
+
**404 route errors**: The spindle KV mount probably doesn't
1133
+
existโrun the mount creation step again.
1134
+
1135
+
**Proxy authentication failures**: Check the proxy logs and
1136
+
verify the role-id and secret-id files are readable and
1137
+
contain valid credentials.
1138
+
1139
+
**Secret not found after writing**: This can indicate policy
1140
+
permission issues. Verify the policy includes both
1141
+
`spindle/data/*` and `spindle/metadata/*` paths with
1142
+
appropriate capabilities.
1143
+
1144
+
Check proxy logs:
1145
+
1146
+
```bash
1147
+
# If running as systemd service
1148
+
journalctl -u openbao-proxy -f
1149
+
1150
+
# If running directly, check the console output
1151
+
```
1152
+
1153
+
Test AppRole authentication manually:
1154
+
1155
+
```bash
1156
+
bao write auth/approle/login \
1157
+
role_id="$(cat /tmp/openbao/role-id)" \
1158
+
secret_id="$(cat /tmp/openbao/secret-id)"
1159
+
```
1160
+
1161
+
# Migrating knots and spindles
1162
+
1163
+
Sometimes, non-backwards compatible changes are made to the
1164
+
knot/spindle XRPC APIs. If you host a knot or a spindle, you
1165
+
will need to follow this guide to upgrade. Typically, this
1166
+
only requires you to deploy the newest version.
1167
+
1168
+
This document is laid out in reverse-chronological order.
1169
+
Newer migration guides are listed first, and older guides
1170
+
are further down the page.
1171
+
1172
+
## Upgrading from v1.8.x
1173
+
1174
+
After v1.8.2, the HTTP API for knots and spindles has been
1175
+
deprecated and replaced with XRPC. Repositories on outdated
1176
+
knots will not be viewable from the appview. Upgrading is
1177
+
straightforward however.
1178
+
1179
+
For knots:
1180
+
1181
+
- Upgrade to the latest tag (v1.9.0 or above)
1182
+
- Head to the [knot dashboard](https://tangled.org/settings/knots) and
1183
+
hit the "retry" button to verify your knot
1184
+
1185
+
For spindles:
1186
+
1187
+
- Upgrade to the latest tag (v1.9.0 or above)
1188
+
- Head to the [spindle
1189
+
dashboard](https://tangled.org/settings/spindles) and hit the
1190
+
"retry" button to verify your spindle
1191
+
1192
+
## Upgrading from v1.7.x
1193
+
1194
+
After v1.7.0, knot secrets have been deprecated. You no
1195
+
longer need a secret from the appview to run a knot. All
1196
+
authorized commands to knots are managed via [Inter-Service
1197
+
Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
1198
+
Knots will be read-only until upgraded.
1199
+
1200
+
Upgrading is quite easy, in essence:
1201
+
1202
+
- `KNOT_SERVER_SECRET` is no more, you can remove this
1203
+
environment variable entirely
1204
+
- `KNOT_SERVER_OWNER` is now required on boot, set this to
1205
+
your DID. You can find your DID in the
1206
+
[settings](https://tangled.org/settings) page.
1207
+
- Restart your knot once you have replaced the environment
1208
+
variable
1209
+
- Head to the [knot dashboard](https://tangled.org/settings/knots) and
1210
+
hit the "retry" button to verify your knot. This simply
1211
+
writes a `sh.tangled.knot` record to your PDS.
1212
+
1213
+
If you use the nix module, simply bump the flake to the
1214
+
latest revision, and change your config block like so:
1215
+
1216
+
```diff
1217
+
services.tangled.knot = {
1218
+
enable = true;
1219
+
server = {
1220
+
- secretFile = /path/to/secret;
1221
+
+ owner = "did:plc:foo";
1222
+
};
1223
+
};
1224
+
```
1225
+
1226
+
# Hacking on Tangled
1227
+
1228
+
We highly recommend [installing
1229
+
Nix](https://nixos.org/download/) (the package manager)
1230
+
before working on the codebase. The Nix flake provides a lot
1231
+
of helpers to get started and most importantly, builds and
1232
+
dev shells are entirely deterministic.
1233
+
1234
+
To set up your dev environment:
1235
+
1236
+
```bash
1237
+
nix develop
1238
+
```
1239
+
1240
+
Non-Nix users can look at the `devShell` attribute in the
1241
+
`flake.nix` file to determine necessary dependencies.
1242
+
1243
+
## Running the appview
1244
+
1245
+
The Nix flake also exposes a few `app` attributes (run `nix
1246
+
flake show` to see a full list of what the flake provides),
1247
+
one of the apps runs the appview with the `air`
1248
+
live-reloader:
1249
+
1250
+
```bash
1251
+
TANGLED_DEV=true nix run .#watch-appview
1252
+
1253
+
# TANGLED_DB_PATH might be of interest to point to
1254
+
# different sqlite DBs
1255
+
1256
+
# in a separate shell, you can live-reload tailwind
1257
+
nix run .#watch-tailwind
1258
+
```
1259
+
1260
+
To authenticate with the appview, you will need Redis and
1261
+
OAuth JWKs to be set up:
1262
+
1263
+
```
1264
+
# OAuth JWKs should already be set up by the Nix devshell:
1265
+
echo $TANGLED_OAUTH_CLIENT_SECRET
1266
+
z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc
1267
+
1268
+
echo $TANGLED_OAUTH_CLIENT_KID
1269
+
1761667908
1270
+
1271
+
# if not, you can set it up yourself:
1272
+
goat key generate -t P-256
1273
+
Key Type: P-256 / secp256r1 / ES256 private key
1274
+
Secret Key (Multibase Syntax): save this securely (eg, add to password manager)
1275
+
z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL
1276
+
Public Key (DID Key Syntax): share or publish this (eg, in DID document)
1277
+
did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR
1278
+
1279
+
# the secret key from above
1280
+
export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..."
1281
+
1282
+
# Run Redis in a new shell to store OAuth sessions
1283
+
redis-server
1284
+
```
1285
+
1286
+
## Running knots and spindles
1287
+
1288
+
An end-to-end knot setup requires setting up a machine with
1289
+
`sshd`, `AuthorizedKeysCommand`, and a Git user, which is
1290
+
quite cumbersome. So the Nix flake provides a
1291
+
`nixosConfiguration` to do so.
1292
+
1293
+
<details>
1294
+
<summary><strong>macOS users will have to set up a Nix Builder first</strong></summary>
1295
+
1296
+
In order to build Tangled's dev VM on macOS, you will
1297
+
first need to set up a Linux Nix builder. The recommended
1298
+
way to do so is to run a [`darwin.linux-builder`
1299
+
VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder)
1300
+
and to register it in `nix.conf` as a builder for Linux
1301
+
with the same architecture as your Mac (`linux-aarch64` if
1302
+
you are using Apple Silicon).
1303
+
1304
+
> IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside
1305
+
> the Tangled repo so that it doesn't conflict with the other VM. For example,
1306
+
> you can do
1307
+
>
1308
+
> ```shell
1309
+
> cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder
1310
+
> ```
1311
+
>
1312
+
> to store the builder VM in a temporary dir.
1313
+
>
1314
+
> You should read and follow [all the other intructions][darwin builder vm] to
1315
+
> avoid subtle problems.
1316
+
1317
+
Alternatively, you can use any other method to set up a
1318
+
Linux machine with Nix installed that you can `sudo ssh`
1319
+
into (in other words, root user on your Mac has to be able
1320
+
to ssh into the Linux machine without entering a password)
1321
+
and that has the same architecture as your Mac. See
1322
+
[remote builder
1323
+
instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements)
1324
+
for how to register such a builder in `nix.conf`.
1325
+
1326
+
> WARNING: If you'd like to use
1327
+
> [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or
1328
+
> [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo
1329
+
> ssh` works can be tricky. It seems to be [possible with
1330
+
> Orbstack](https://github.com/orgs/orbstack/discussions/1669).
1331
+
1332
+
</details>
1333
+
1334
+
To begin, grab your DID from http://localhost:3000/settings.
1335
+
Then, set `TANGLED_VM_KNOT_OWNER` and
1336
+
`TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a
1337
+
lightweight NixOS VM like so:
1338
+
1339
+
```bash
1340
+
nix run --impure .#vm
1341
+
1342
+
# type `poweroff` at the shell to exit the VM
1343
+
```
1344
+
1345
+
This starts a knot on port 6444, a spindle on port 6555
1346
+
with `ssh` exposed on port 2222.
1347
+
1348
+
Once the services are running, head to
1349
+
http://localhost:3000/settings/knots and hit "Verify". It should
1350
+
verify the ownership of the services instantly if everything
1351
+
went smoothly.
1352
+
1353
+
You can push repositories to this VM with this ssh config
1354
+
block on your main machine:
1355
+
1356
+
```bash
1357
+
Host nixos-shell
1358
+
Hostname localhost
1359
+
Port 2222
1360
+
User git
1361
+
IdentityFile ~/.ssh/my_tangled_key
1362
+
```
1363
+
1364
+
Set up a remote called `local-dev` on a git repo:
1365
+
1366
+
```bash
1367
+
git remote add local-dev git@nixos-shell:user/repo
1368
+
git push local-dev main
1369
+
```
1370
+
1371
+
The above VM should already be running a spindle on
1372
+
`localhost:6555`. Head to http://localhost:3000/settings/spindles and
1373
+
hit "Verify". You can then configure each repository to use
1374
+
this spindle and run CI jobs.
1375
+
1376
+
Of interest when debugging spindles:
1377
+
1378
+
```
1379
+
# Service logs from journald:
1380
+
journalctl -xeu spindle
1381
+
1382
+
# CI job logs from disk:
1383
+
ls /var/log/spindle
1384
+
1385
+
# Debugging spindle database:
1386
+
sqlite3 /var/lib/spindle/spindle.db
1387
+
1388
+
# litecli has a nicer REPL interface:
1389
+
litecli /var/lib/spindle/spindle.db
1390
+
```
1391
+
1392
+
If for any reason you wish to disable either one of the
1393
+
services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set
1394
+
`services.tangled.spindle.enable` (or
1395
+
`services.tangled.knot.enable`) to `false`.
1396
+
1397
+
# Contribution guide
1398
+
1399
+
## Commit guidelines
1400
+
1401
+
We follow a commit style similar to the Go project. Please keep commits:
1402
+
1403
+
* **atomic**: each commit should represent one logical change
1404
+
* **descriptive**: the commit message should clearly describe what the
1405
+
change does and why it's needed
1406
+
1407
+
### Message format
1408
+
1409
+
```
1410
+
<service/top-level directory>/<affected package/directory>: <short summary of change>
1411
+
1412
+
Optional longer description can go here, if necessary. Explain what the
1413
+
change does and why, especially if not obvious. Reference relevant
1414
+
issues or PRs when applicable. These can be links for now since we don't
1415
+
auto-link issues/PRs yet.
1416
+
```
1417
+
1418
+
Here are some examples:
1419
+
1420
+
```
1421
+
appview/state: fix token expiry check in middleware
1422
+
1423
+
The previous check did not account for clock drift, leading to premature
1424
+
token invalidation.
1425
+
```
1426
+
1427
+
```
1428
+
knotserver/git/service: improve error checking in upload-pack
1429
+
```
1430
+
1431
+
1432
+
### General notes
1433
+
1434
+
- PRs get merged "as-is" (fast-forward)โlike applying a patch-series
1435
+
using `git am`. At present, there is no squashingโso please author
1436
+
your commits as they would appear on `master`, following the above
1437
+
guidelines.
1438
+
- If there is a lot of nesting, for example "appview:
1439
+
pages/templates/repo/fragments: ...", these can be truncated down to
1440
+
just "appview: repo/fragments: ...". If the change affects a lot of
1441
+
subdirectories, you may abbreviate to just the top-level names, e.g.
1442
+
"appview: ..." or "knotserver: ...".
1443
+
- Keep commits lowercased with no trailing period.
1444
+
- Use the imperative mood in the summary line (e.g., "fix bug" not
1445
+
"fixed bug" or "fixes bug").
1446
+
- Try to keep the summary line under 72 characters, but we aren't too
1447
+
fussed about this.
1448
+
- Follow the same formatting for PR titles if filled manually.
1449
+
- Don't include unrelated changes in the same commit.
1450
+
- Avoid noisy commit messages like "wip" or "final fix"โrewrite history
1451
+
before submitting if necessary.
1452
+
1453
+
## Code formatting
1454
+
1455
+
We use a variety of tools to format our code, and multiplex them with
1456
+
[`treefmt`](https://treefmt.com). All you need to do to format your changes
1457
+
is run `nix run .#fmt` (or just `treefmt` if you're in the devshell).
1458
+
1459
+
## Proposals for bigger changes
1460
+
1461
+
Small fixes like typos, minor bugs, or trivial refactors can be
1462
+
submitted directly as PRs.
1463
+
1464
+
For larger changesโespecially those introducing new features, significant
1465
+
refactoring, or altering system behaviorโplease open a proposal first. This
1466
+
helps us evaluate the scope, design, and potential impact before implementation.
1467
+
1468
+
Create a new issue titled:
1469
+
1470
+
```
1471
+
proposal: <affected scope>: <summary of change>
1472
+
```
1473
+
1474
+
In the description, explain:
1475
+
1476
+
- What the change is
1477
+
- Why it's needed
1478
+
- How you plan to implement it (roughly)
1479
+
- Any open questions or tradeoffs
1480
+
1481
+
We'll use the issue thread to discuss and refine the idea before moving
1482
+
forward.
1483
+
1484
+
## Developer Certificate of Origin (DCO)
1485
+
1486
+
We require all contributors to certify that they have the right to
1487
+
submit the code they're contributing. To do this, we follow the
1488
+
[Developer Certificate of Origin
1489
+
(DCO)](https://developercertificate.org/).
1490
+
1491
+
By signing your commits, you're stating that the contribution is your
1492
+
own work, or that you have the right to submit it under the project's
1493
+
license. This helps us keep things clean and legally sound.
1494
+
1495
+
To sign your commit, just add the `-s` flag when committing:
1496
+
1497
+
```sh
1498
+
git commit -s -m "your commit message"
1499
+
```
1500
+
1501
+
This appends a line like:
1502
+
1503
+
```
1504
+
Signed-off-by: Your Name <your.email@example.com>
1505
+
```
1506
+
1507
+
We won't merge commits if they aren't signed off. If you forget, you can
1508
+
amend the last commit like this:
1509
+
1510
+
```sh
1511
+
git commit --amend -s
1512
+
```
1513
+
1514
+
If you're submitting a PR with multiple commits, make sure each one is
1515
+
signed.
1516
+
1517
+
For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command
1518
+
to make it sign off commits in the tangled repo:
1519
+
1520
+
```shell
1521
+
# Safety check, should say "No matching config key..."
1522
+
jj config list templates.commit_trailers
1523
+
# The command below may need to be adjusted if the command above returned something.
1524
+
jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)"
1525
+
```
1526
+
1527
+
Refer to the [jujutsu
1528
+
documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers)
1529
+
for more information.
-136
docs/contributing.md
-136
docs/contributing.md
···
1
-
# tangled contributing guide
2
-
3
-
## commit guidelines
4
-
5
-
We follow a commit style similar to the Go project. Please keep commits:
6
-
7
-
* **atomic**: each commit should represent one logical change
8
-
* **descriptive**: the commit message should clearly describe what the
9
-
change does and why it's needed
10
-
11
-
### message format
12
-
13
-
```
14
-
<service/top-level directory>/<affected package/directory>: <short summary of change>
15
-
16
-
17
-
Optional longer description can go here, if necessary. Explain what the
18
-
change does and why, especially if not obvious. Reference relevant
19
-
issues or PRs when applicable. These can be links for now since we don't
20
-
auto-link issues/PRs yet.
21
-
```
22
-
23
-
Here are some examples:
24
-
25
-
```
26
-
appview/state: fix token expiry check in middleware
27
-
28
-
The previous check did not account for clock drift, leading to premature
29
-
token invalidation.
30
-
```
31
-
32
-
```
33
-
knotserver/git/service: improve error checking in upload-pack
34
-
```
35
-
36
-
37
-
### general notes
38
-
39
-
- PRs get merged "as-is" (fast-forward) -- like applying a patch-series
40
-
using `git am`. At present, there is no squashing -- so please author
41
-
your commits as they would appear on `master`, following the above
42
-
guidelines.
43
-
- If there is a lot of nesting, for example "appview:
44
-
pages/templates/repo/fragments: ...", these can be truncated down to
45
-
just "appview: repo/fragments: ...". If the change affects a lot of
46
-
subdirectories, you may abbreviate to just the top-level names, e.g.
47
-
"appview: ..." or "knotserver: ...".
48
-
- Keep commits lowercased with no trailing period.
49
-
- Use the imperative mood in the summary line (e.g., "fix bug" not
50
-
"fixed bug" or "fixes bug").
51
-
- Try to keep the summary line under 72 characters, but we aren't too
52
-
fussed about this.
53
-
- Follow the same formatting for PR titles if filled manually.
54
-
- Don't include unrelated changes in the same commit.
55
-
- Avoid noisy commit messages like "wip" or "final fix"โrewrite history
56
-
before submitting if necessary.
57
-
58
-
## code formatting
59
-
60
-
We use a variety of tools to format our code, and multiplex them with
61
-
[`treefmt`](https://treefmt.com): all you need to do to format your changes
62
-
is run `nix run .#fmt` (or just `treefmt` if you're in the devshell).
63
-
64
-
## proposals for bigger changes
65
-
66
-
Small fixes like typos, minor bugs, or trivial refactors can be
67
-
submitted directly as PRs.
68
-
69
-
For larger changesโespecially those introducing new features, significant
70
-
refactoring, or altering system behaviorโplease open a proposal first. This
71
-
helps us evaluate the scope, design, and potential impact before implementation.
72
-
73
-
### proposal format
74
-
75
-
Create a new issue titled:
76
-
77
-
```
78
-
proposal: <affected scope>: <summary of change>
79
-
```
80
-
81
-
In the description, explain:
82
-
83
-
- What the change is
84
-
- Why it's needed
85
-
- How you plan to implement it (roughly)
86
-
- Any open questions or tradeoffs
87
-
88
-
We'll use the issue thread to discuss and refine the idea before moving
89
-
forward.
90
-
91
-
## developer certificate of origin (DCO)
92
-
93
-
We require all contributors to certify that they have the right to
94
-
submit the code they're contributing. To do this, we follow the
95
-
[Developer Certificate of Origin
96
-
(DCO)](https://developercertificate.org/).
97
-
98
-
By signing your commits, you're stating that the contribution is your
99
-
own work, or that you have the right to submit it under the project's
100
-
license. This helps us keep things clean and legally sound.
101
-
102
-
To sign your commit, just add the `-s` flag when committing:
103
-
104
-
```sh
105
-
git commit -s -m "your commit message"
106
-
```
107
-
108
-
This appends a line like:
109
-
110
-
```
111
-
Signed-off-by: Your Name <your.email@example.com>
112
-
```
113
-
114
-
We won't merge commits if they aren't signed off. If you forget, you can
115
-
amend the last commit like this:
116
-
117
-
```sh
118
-
git commit --amend -s
119
-
```
120
-
121
-
If you're submitting a PR with multiple commits, make sure each one is
122
-
signed.
123
-
124
-
For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command
125
-
to make it sign off commits in the tangled repo:
126
-
127
-
```shell
128
-
# Safety check, should say "No matching config key..."
129
-
jj config list templates.commit_trailers
130
-
# The command below may need to be adjusted if the command above returned something.
131
-
jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)"
132
-
```
133
-
134
-
Refer to the [jj
135
-
documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers)
136
-
for more information.
-172
docs/hacking.md
-172
docs/hacking.md
···
1
-
# hacking on tangled
2
-
3
-
We highly recommend [installing
4
-
nix](https://nixos.org/download/) (the package manager)
5
-
before working on the codebase. The nix flake provides a lot
6
-
of helpers to get started and most importantly, builds and
7
-
dev shells are entirely deterministic.
8
-
9
-
To set up your dev environment:
10
-
11
-
```bash
12
-
nix develop
13
-
```
14
-
15
-
Non-nix users can look at the `devShell` attribute in the
16
-
`flake.nix` file to determine necessary dependencies.
17
-
18
-
## running the appview
19
-
20
-
The nix flake also exposes a few `app` attributes (run `nix
21
-
flake show` to see a full list of what the flake provides),
22
-
one of the apps runs the appview with the `air`
23
-
live-reloader:
24
-
25
-
```bash
26
-
TANGLED_DEV=true nix run .#watch-appview
27
-
28
-
# TANGLED_DB_PATH might be of interest to point to
29
-
# different sqlite DBs
30
-
31
-
# in a separate shell, you can live-reload tailwind
32
-
nix run .#watch-tailwind
33
-
```
34
-
35
-
To authenticate with the appview, you will need redis and
36
-
OAUTH JWKs to be setup:
37
-
38
-
```
39
-
# oauth jwks should already be setup by the nix devshell:
40
-
echo $TANGLED_OAUTH_CLIENT_SECRET
41
-
z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc
42
-
43
-
echo $TANGLED_OAUTH_CLIENT_KID
44
-
1761667908
45
-
46
-
# if not, you can set it up yourself:
47
-
goat key generate -t P-256
48
-
Key Type: P-256 / secp256r1 / ES256 private key
49
-
Secret Key (Multibase Syntax): save this securely (eg, add to password manager)
50
-
z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL
51
-
Public Key (DID Key Syntax): share or publish this (eg, in DID document)
52
-
did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR
53
-
54
-
# the secret key from above
55
-
export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..."
56
-
57
-
# run redis in at a new shell to store oauth sessions
58
-
redis-server
59
-
```
60
-
61
-
## running knots and spindles
62
-
63
-
An end-to-end knot setup requires setting up a machine with
64
-
`sshd`, `AuthorizedKeysCommand`, and git user, which is
65
-
quite cumbersome. So the nix flake provides a
66
-
`nixosConfiguration` to do so.
67
-
68
-
<details>
69
-
<summary><strong>MacOS users will have to setup a Nix Builder first</strong></summary>
70
-
71
-
In order to build Tangled's dev VM on macOS, you will
72
-
first need to set up a Linux Nix builder. The recommended
73
-
way to do so is to run a [`darwin.linux-builder`
74
-
VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder)
75
-
and to register it in `nix.conf` as a builder for Linux
76
-
with the same architecture as your Mac (`linux-aarch64` if
77
-
you are using Apple Silicon).
78
-
79
-
> IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside
80
-
> the tangled repo so that it doesn't conflict with the other VM. For example,
81
-
> you can do
82
-
>
83
-
> ```shell
84
-
> cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder
85
-
> ```
86
-
>
87
-
> to store the builder VM in a temporary dir.
88
-
>
89
-
> You should read and follow [all the other intructions][darwin builder vm] to
90
-
> avoid subtle problems.
91
-
92
-
Alternatively, you can use any other method to set up a
93
-
Linux machine with `nix` installed that you can `sudo ssh`
94
-
into (in other words, root user on your Mac has to be able
95
-
to ssh into the Linux machine without entering a password)
96
-
and that has the same architecture as your Mac. See
97
-
[remote builder
98
-
instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements)
99
-
for how to register such a builder in `nix.conf`.
100
-
101
-
> WARNING: If you'd like to use
102
-
> [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or
103
-
> [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo
104
-
> ssh` works can be tricky. It seems to be [possible with
105
-
> Orbstack](https://github.com/orgs/orbstack/discussions/1669).
106
-
107
-
</details>
108
-
109
-
To begin, grab your DID from http://localhost:3000/settings.
110
-
Then, set `TANGLED_VM_KNOT_OWNER` and
111
-
`TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a
112
-
lightweight NixOS VM like so:
113
-
114
-
```bash
115
-
nix run --impure .#vm
116
-
117
-
# type `poweroff` at the shell to exit the VM
118
-
```
119
-
120
-
This starts a knot on port 6444, a spindle on port 6555
121
-
with `ssh` exposed on port 2222.
122
-
123
-
Once the services are running, head to
124
-
http://localhost:3000/settings/knots and hit verify. It should
125
-
verify the ownership of the services instantly if everything
126
-
went smoothly.
127
-
128
-
You can push repositories to this VM with this ssh config
129
-
block on your main machine:
130
-
131
-
```bash
132
-
Host nixos-shell
133
-
Hostname localhost
134
-
Port 2222
135
-
User git
136
-
IdentityFile ~/.ssh/my_tangled_key
137
-
```
138
-
139
-
Set up a remote called `local-dev` on a git repo:
140
-
141
-
```bash
142
-
git remote add local-dev git@nixos-shell:user/repo
143
-
git push local-dev main
144
-
```
145
-
146
-
### running a spindle
147
-
148
-
The above VM should already be running a spindle on
149
-
`localhost:6555`. Head to http://localhost:3000/settings/spindles and
150
-
hit verify. You can then configure each repository to use
151
-
this spindle and run CI jobs.
152
-
153
-
Of interest when debugging spindles:
154
-
155
-
```
156
-
# service logs from journald:
157
-
journalctl -xeu spindle
158
-
159
-
# CI job logs from disk:
160
-
ls /var/log/spindle
161
-
162
-
# debugging spindle db:
163
-
sqlite3 /var/lib/spindle/spindle.db
164
-
165
-
# litecli has a nicer REPL interface:
166
-
litecli /var/lib/spindle/spindle.db
167
-
```
168
-
169
-
If for any reason you wish to disable either one of the
170
-
services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set
171
-
`services.tangled.spindle.enable` (or
172
-
`services.tangled.knot.enable`) to `false`.
+93
docs/highlight.theme
+93
docs/highlight.theme
···
1
+
{
2
+
"text-color": null,
3
+
"background-color": null,
4
+
"line-number-color": null,
5
+
"line-number-background-color": null,
6
+
"text-styles": {
7
+
"Annotation": {
8
+
"text-color": null,
9
+
"background-color": null,
10
+
"bold": false,
11
+
"italic": true,
12
+
"underline": false
13
+
},
14
+
"ControlFlow": {
15
+
"text-color": null,
16
+
"background-color": null,
17
+
"bold": true,
18
+
"italic": false,
19
+
"underline": false
20
+
},
21
+
"Error": {
22
+
"text-color": null,
23
+
"background-color": null,
24
+
"bold": true,
25
+
"italic": false,
26
+
"underline": false
27
+
},
28
+
"Alert": {
29
+
"text-color": null,
30
+
"background-color": null,
31
+
"bold": true,
32
+
"italic": false,
33
+
"underline": false
34
+
},
35
+
"Preprocessor": {
36
+
"text-color": null,
37
+
"background-color": null,
38
+
"bold": true,
39
+
"italic": false,
40
+
"underline": false
41
+
},
42
+
"Information": {
43
+
"text-color": null,
44
+
"background-color": null,
45
+
"bold": false,
46
+
"italic": true,
47
+
"underline": false
48
+
},
49
+
"Warning": {
50
+
"text-color": null,
51
+
"background-color": null,
52
+
"bold": false,
53
+
"italic": true,
54
+
"underline": false
55
+
},
56
+
"Documentation": {
57
+
"text-color": null,
58
+
"background-color": null,
59
+
"bold": false,
60
+
"italic": true,
61
+
"underline": false
62
+
},
63
+
"DataType": {
64
+
"text-color": "#8f4e8b",
65
+
"background-color": null,
66
+
"bold": false,
67
+
"italic": false,
68
+
"underline": false
69
+
},
70
+
"Comment": {
71
+
"text-color": null,
72
+
"background-color": null,
73
+
"bold": false,
74
+
"italic": true,
75
+
"underline": false
76
+
},
77
+
"CommentVar": {
78
+
"text-color": null,
79
+
"background-color": null,
80
+
"bold": false,
81
+
"italic": true,
82
+
"underline": false
83
+
},
84
+
"Keyword": {
85
+
"text-color": null,
86
+
"background-color": null,
87
+
"bold": true,
88
+
"italic": false,
89
+
"underline": false
90
+
}
91
+
}
92
+
}
93
+
-214
docs/knot-hosting.md
-214
docs/knot-hosting.md
···
1
-
# knot self-hosting guide
2
-
3
-
So you want to run your own knot server? Great! Here are a few prerequisites:
4
-
5
-
1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind.
6
-
2. A (sub)domain name. People generally use `knot.example.com`.
7
-
3. A valid SSL certificate for your domain.
8
-
9
-
There's a couple of ways to get started:
10
-
* NixOS: refer to
11
-
[flake.nix](https://tangled.sh/@tangled.sh/core/blob/master/flake.nix)
12
-
* Docker: Documented at
13
-
[@tangled.sh/knot-docker](https://tangled.sh/@tangled.sh/knot-docker)
14
-
(community maintained: support is not guaranteed!)
15
-
* Manual: Documented below.
16
-
17
-
## manual setup
18
-
19
-
First, clone this repository:
20
-
21
-
```
22
-
git clone https://tangled.org/@tangled.org/core
23
-
```
24
-
25
-
Then, build the `knot` CLI. This is the knot administration and operation tool.
26
-
For the purpose of this guide, we're only concerned with these subcommands:
27
-
28
-
* `knot server`: the main knot server process, typically run as a
29
-
supervised service
30
-
* `knot guard`: handles role-based access control for git over SSH
31
-
(you'll never have to run this yourself)
32
-
* `knot keys`: fetches SSH keys associated with your knot; we'll use
33
-
this to generate the SSH `AuthorizedKeysCommand`
34
-
35
-
```
36
-
cd core
37
-
export CGO_ENABLED=1
38
-
go build -o knot ./cmd/knot
39
-
```
40
-
41
-
Next, move the `knot` binary to a location owned by `root` --
42
-
`/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`:
43
-
44
-
```
45
-
sudo mv knot /usr/local/bin/knot
46
-
sudo chown root:root /usr/local/bin/knot
47
-
```
48
-
49
-
This is necessary because SSH `AuthorizedKeysCommand` requires [really
50
-
specific permissions](https://stackoverflow.com/a/27638306). The
51
-
`AuthorizedKeysCommand` specifies a command that is run by `sshd` to
52
-
retrieve a user's public SSH keys dynamically for authentication. Let's
53
-
set that up.
54
-
55
-
```
56
-
sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
57
-
Match User git
58
-
AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys
59
-
AuthorizedKeysCommandUser nobody
60
-
EOF
61
-
```
62
-
63
-
Then, reload `sshd`:
64
-
65
-
```
66
-
sudo systemctl reload ssh
67
-
```
68
-
69
-
Next, create the `git` user. We'll use the `git` user's home directory
70
-
to store repositories:
71
-
72
-
```
73
-
sudo adduser git
74
-
```
75
-
76
-
Create `/home/git/.knot.env` with the following, updating the values as
77
-
necessary. The `KNOT_SERVER_OWNER` should be set to your
78
-
DID, you can find your DID in the [Settings](https://tangled.sh/settings) page.
79
-
80
-
```
81
-
KNOT_REPO_SCAN_PATH=/home/git
82
-
KNOT_SERVER_HOSTNAME=knot.example.com
83
-
APPVIEW_ENDPOINT=https://tangled.sh
84
-
KNOT_SERVER_OWNER=did:plc:foobar
85
-
KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444
86
-
KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
87
-
```
88
-
89
-
If you run a Linux distribution that uses systemd, you can use the provided
90
-
service file to run the server. Copy
91
-
[`knotserver.service`](/systemd/knotserver.service)
92
-
to `/etc/systemd/system/`. Then, run:
93
-
94
-
```
95
-
systemctl enable knotserver
96
-
systemctl start knotserver
97
-
```
98
-
99
-
The last step is to configure a reverse proxy like Nginx or Caddy to front your
100
-
knot. Here's an example configuration for Nginx:
101
-
102
-
```
103
-
server {
104
-
listen 80;
105
-
listen [::]:80;
106
-
server_name knot.example.com;
107
-
108
-
location / {
109
-
proxy_pass http://localhost:5555;
110
-
proxy_set_header Host $host;
111
-
proxy_set_header X-Real-IP $remote_addr;
112
-
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
113
-
proxy_set_header X-Forwarded-Proto $scheme;
114
-
}
115
-
116
-
# wss endpoint for git events
117
-
location /events {
118
-
proxy_set_header X-Forwarded-For $remote_addr;
119
-
proxy_set_header Host $http_host;
120
-
proxy_set_header Upgrade websocket;
121
-
proxy_set_header Connection Upgrade;
122
-
proxy_pass http://localhost:5555;
123
-
}
124
-
# additional config for SSL/TLS go here.
125
-
}
126
-
127
-
```
128
-
129
-
Remember to use Let's Encrypt or similar to procure a certificate for your
130
-
knot domain.
131
-
132
-
You should now have a running knot server! You can finalize
133
-
your registration by hitting the `verify` button on the
134
-
[/settings/knots](https://tangled.org/settings/knots) page. This simply creates
135
-
a record on your PDS to announce the existence of the knot.
136
-
137
-
### custom paths
138
-
139
-
(This section applies to manual setup only. Docker users should edit the mounts
140
-
in `docker-compose.yml` instead.)
141
-
142
-
Right now, the database and repositories of your knot lives in `/home/git`. You
143
-
can move these paths if you'd like to store them in another folder. Be careful
144
-
when adjusting these paths:
145
-
146
-
* Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent
147
-
any possible side effects. Remember to restart it once you're done.
148
-
* Make backups before moving in case something goes wrong.
149
-
* Make sure the `git` user can read and write from the new paths.
150
-
151
-
#### database
152
-
153
-
As an example, let's say the current database is at `/home/git/knotserver.db`,
154
-
and we want to move it to `/home/git/database/knotserver.db`.
155
-
156
-
Copy the current database to the new location. Make sure to copy the `.db-shm`
157
-
and `.db-wal` files if they exist.
158
-
159
-
```
160
-
mkdir /home/git/database
161
-
cp /home/git/knotserver.db* /home/git/database
162
-
```
163
-
164
-
In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to
165
-
the new file path (_not_ the directory):
166
-
167
-
```
168
-
KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db
169
-
```
170
-
171
-
#### repositories
172
-
173
-
As an example, let's say the repositories are currently in `/home/git`, and we
174
-
want to move them into `/home/git/repositories`.
175
-
176
-
Create the new folder, then move the existing repositories (if there are any):
177
-
178
-
```
179
-
mkdir /home/git/repositories
180
-
# move all DIDs into the new folder; these will vary for you!
181
-
mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories
182
-
```
183
-
184
-
In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH`
185
-
to the new directory:
186
-
187
-
```
188
-
KNOT_REPO_SCAN_PATH=/home/git/repositories
189
-
```
190
-
191
-
Similarly, update your `sshd` `AuthorizedKeysCommand` to use the updated
192
-
repository path:
193
-
194
-
```
195
-
sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
196
-
Match User git
197
-
AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys -git-dir /home/git/repositories
198
-
AuthorizedKeysCommandUser nobody
199
-
EOF
200
-
```
201
-
202
-
Make sure to restart your SSH server!
203
-
204
-
#### MOTD (message of the day)
205
-
206
-
To configure the MOTD used ("Welcome to this knot!" by default), edit the
207
-
`/home/git/motd` file:
208
-
209
-
```
210
-
printf "Hi from this knot!\n" > /home/git/motd
211
-
```
212
-
213
-
Note that you should add a newline at the end if setting a non-empty message
214
-
since the knot won't do this for you.
-59
docs/migrations.md
-59
docs/migrations.md
···
1
-
# Migrations
2
-
3
-
This document is laid out in reverse-chronological order.
4
-
Newer migration guides are listed first, and older guides
5
-
are further down the page.
6
-
7
-
## Upgrading from v1.8.x
8
-
9
-
After v1.8.2, the HTTP API for knot and spindles have been
10
-
deprecated and replaced with XRPC. Repositories on outdated
11
-
knots will not be viewable from the appview. Upgrading is
12
-
straightforward however.
13
-
14
-
For knots:
15
-
16
-
- Upgrade to latest tag (v1.9.0 or above)
17
-
- Head to the [knot dashboard](https://tangled.org/settings/knots) and
18
-
hit the "retry" button to verify your knot
19
-
20
-
For spindles:
21
-
22
-
- Upgrade to latest tag (v1.9.0 or above)
23
-
- Head to the [spindle
24
-
dashboard](https://tangled.org/settings/spindles) and hit the
25
-
"retry" button to verify your spindle
26
-
27
-
## Upgrading from v1.7.x
28
-
29
-
After v1.7.0, knot secrets have been deprecated. You no
30
-
longer need a secret from the appview to run a knot. All
31
-
authorized commands to knots are managed via [Inter-Service
32
-
Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
33
-
Knots will be read-only until upgraded.
34
-
35
-
Upgrading is quite easy, in essence:
36
-
37
-
- `KNOT_SERVER_SECRET` is no more, you can remove this
38
-
environment variable entirely
39
-
- `KNOT_SERVER_OWNER` is now required on boot, set this to
40
-
your DID. You can find your DID in the
41
-
[settings](https://tangled.org/settings) page.
42
-
- Restart your knot once you have replaced the environment
43
-
variable
44
-
- Head to the [knot dashboard](https://tangled.org/settings/knots) and
45
-
hit the "retry" button to verify your knot. This simply
46
-
writes a `sh.tangled.knot` record to your PDS.
47
-
48
-
If you use the nix module, simply bump the flake to the
49
-
latest revision, and change your config block like so:
50
-
51
-
```diff
52
-
services.tangled.knot = {
53
-
enable = true;
54
-
server = {
55
-
- secretFile = /path/to/secret;
56
-
+ owner = "did:plc:foo";
57
-
};
58
-
};
59
-
```
-25
docs/spindle/architecture.md
-25
docs/spindle/architecture.md
···
1
-
# spindle architecture
2
-
3
-
Spindle is a small CI runner service. Here's a high level overview of how it operates:
4
-
5
-
* listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and
6
-
[`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream.
7
-
* when a new repo record comes through (typically when you add a spindle to a
8
-
repo from the settings), spindle then resolves the underlying knot and
9
-
subscribes to repo events (see:
10
-
[`sh.tangled.pipeline`](/lexicons/pipeline.json)).
11
-
* the spindle engine then handles execution of the pipeline, with results and
12
-
logs beamed on the spindle event stream over wss
13
-
14
-
### the engine
15
-
16
-
At present, the only supported backend is Docker (and Podman, if Docker
17
-
compatibility is enabled, so that `/run/docker.sock` is created). Spindle
18
-
executes each step in the pipeline in a fresh container, with state persisted
19
-
across steps within the `/tangled/workspace` directory.
20
-
21
-
The base image for the container is constructed on the fly using
22
-
[Nixery](https://nixery.dev), which is handy for caching layers for frequently
23
-
used packages.
24
-
25
-
The pipeline manifest is [specified here](/docs/spindle/pipeline.md).
-52
docs/spindle/hosting.md
-52
docs/spindle/hosting.md
···
1
-
# spindle self-hosting guide
2
-
3
-
## prerequisites
4
-
5
-
* Go
6
-
* Docker (the only supported backend currently)
7
-
8
-
## configuration
9
-
10
-
Spindle is configured using environment variables. The following environment variables are available:
11
-
12
-
* `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`).
13
-
* `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`).
14
-
* `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required).
15
-
* `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`).
16
-
* `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`).
17
-
* `SPINDLE_SERVER_OWNER`: The DID of the owner (required).
18
-
* `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`).
19
-
* `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`).
20
-
* `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`).
21
-
22
-
## running spindle
23
-
24
-
1. **Set the environment variables.** For example:
25
-
26
-
```shell
27
-
export SPINDLE_SERVER_HOSTNAME="your-hostname"
28
-
export SPINDLE_SERVER_OWNER="your-did"
29
-
```
30
-
31
-
2. **Build the Spindle binary.**
32
-
33
-
```shell
34
-
cd core
35
-
go mod download
36
-
go build -o cmd/spindle/spindle cmd/spindle/main.go
37
-
```
38
-
39
-
3. **Create the log directory.**
40
-
41
-
```shell
42
-
sudo mkdir -p /var/log/spindle
43
-
sudo chown $USER:$USER -R /var/log/spindle
44
-
```
45
-
46
-
4. **Run the Spindle binary.**
47
-
48
-
```shell
49
-
./cmd/spindle/spindle
50
-
```
51
-
52
-
Spindle will now start, connect to the Jetstream server, and begin processing pipelines.
-285
docs/spindle/openbao.md
-285
docs/spindle/openbao.md
···
1
-
# spindle secrets with openbao
2
-
3
-
This document covers setting up Spindle to use OpenBao for secrets
4
-
management via OpenBao Proxy instead of the default SQLite backend.
5
-
6
-
## overview
7
-
8
-
Spindle now uses OpenBao Proxy for secrets management. The proxy handles
9
-
authentication automatically using AppRole credentials, while Spindle
10
-
connects to the local proxy instead of directly to the OpenBao server.
11
-
12
-
This approach provides better security, automatic token renewal, and
13
-
simplified application code.
14
-
15
-
## installation
16
-
17
-
Install OpenBao from nixpkgs:
18
-
19
-
```bash
20
-
nix shell nixpkgs#openbao # for a local server
21
-
```
22
-
23
-
## setup
24
-
25
-
The setup process can is documented for both local development and production.
26
-
27
-
### local development
28
-
29
-
Start OpenBao in dev mode:
30
-
31
-
```bash
32
-
bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201
33
-
```
34
-
35
-
This starts OpenBao on `http://localhost:8201` with a root token.
36
-
37
-
Set up environment for bao CLI:
38
-
39
-
```bash
40
-
export BAO_ADDR=http://localhost:8200
41
-
export BAO_TOKEN=root
42
-
```
43
-
44
-
### production
45
-
46
-
You would typically use a systemd service with a configuration file. Refer to
47
-
[@tangled.org/infra](https://tangled.org/@tangled.org/infra) for how this can be
48
-
achieved using Nix.
49
-
50
-
Then, initialize the bao server:
51
-
```bash
52
-
bao operator init -key-shares=1 -key-threshold=1
53
-
```
54
-
55
-
This will print out an unseal key and a root key. Save them somewhere (like a password manager). Then unseal the vault to begin setting it up:
56
-
```bash
57
-
bao operator unseal <unseal_key>
58
-
```
59
-
60
-
All steps below remain the same across both dev and production setups.
61
-
62
-
### configure openbao server
63
-
64
-
Create the spindle KV mount:
65
-
66
-
```bash
67
-
bao secrets enable -path=spindle -version=2 kv
68
-
```
69
-
70
-
Set up AppRole authentication and policy:
71
-
72
-
Create a policy file `spindle-policy.hcl`:
73
-
74
-
```hcl
75
-
# Full access to spindle KV v2 data
76
-
path "spindle/data/*" {
77
-
capabilities = ["create", "read", "update", "delete"]
78
-
}
79
-
80
-
# Access to metadata for listing and management
81
-
path "spindle/metadata/*" {
82
-
capabilities = ["list", "read", "delete", "update"]
83
-
}
84
-
85
-
# Allow listing at root level
86
-
path "spindle/" {
87
-
capabilities = ["list"]
88
-
}
89
-
90
-
# Required for connection testing and health checks
91
-
path "auth/token/lookup-self" {
92
-
capabilities = ["read"]
93
-
}
94
-
```
95
-
96
-
Apply the policy and create an AppRole:
97
-
98
-
```bash
99
-
bao policy write spindle-policy spindle-policy.hcl
100
-
bao auth enable approle
101
-
bao write auth/approle/role/spindle \
102
-
token_policies="spindle-policy" \
103
-
token_ttl=1h \
104
-
token_max_ttl=4h \
105
-
bind_secret_id=true \
106
-
secret_id_ttl=0 \
107
-
secret_id_num_uses=0
108
-
```
109
-
110
-
Get the credentials:
111
-
112
-
```bash
113
-
# Get role ID (static)
114
-
ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id)
115
-
116
-
# Generate secret ID
117
-
SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id)
118
-
119
-
echo "Role ID: $ROLE_ID"
120
-
echo "Secret ID: $SECRET_ID"
121
-
```
122
-
123
-
### create proxy configuration
124
-
125
-
Create the credential files:
126
-
127
-
```bash
128
-
# Create directory for OpenBao files
129
-
mkdir -p /tmp/openbao
130
-
131
-
# Save credentials
132
-
echo "$ROLE_ID" > /tmp/openbao/role-id
133
-
echo "$SECRET_ID" > /tmp/openbao/secret-id
134
-
chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id
135
-
```
136
-
137
-
Create a proxy configuration file `/tmp/openbao/proxy.hcl`:
138
-
139
-
```hcl
140
-
# OpenBao server connection
141
-
vault {
142
-
address = "http://localhost:8200"
143
-
}
144
-
145
-
# Auto-Auth using AppRole
146
-
auto_auth {
147
-
method "approle" {
148
-
mount_path = "auth/approle"
149
-
config = {
150
-
role_id_file_path = "/tmp/openbao/role-id"
151
-
secret_id_file_path = "/tmp/openbao/secret-id"
152
-
}
153
-
}
154
-
155
-
# Optional: write token to file for debugging
156
-
sink "file" {
157
-
config = {
158
-
path = "/tmp/openbao/token"
159
-
mode = 0640
160
-
}
161
-
}
162
-
}
163
-
164
-
# Proxy listener for Spindle
165
-
listener "tcp" {
166
-
address = "127.0.0.1:8201"
167
-
tls_disable = true
168
-
}
169
-
170
-
# Enable API proxy with auto-auth token
171
-
api_proxy {
172
-
use_auto_auth_token = true
173
-
}
174
-
175
-
# Enable response caching
176
-
cache {
177
-
use_auto_auth_token = true
178
-
}
179
-
180
-
# Logging
181
-
log_level = "info"
182
-
```
183
-
184
-
### start the proxy
185
-
186
-
Start OpenBao Proxy:
187
-
188
-
```bash
189
-
bao proxy -config=/tmp/openbao/proxy.hcl
190
-
```
191
-
192
-
The proxy will authenticate with OpenBao and start listening on
193
-
`127.0.0.1:8201`.
194
-
195
-
### configure spindle
196
-
197
-
Set these environment variables for Spindle:
198
-
199
-
```bash
200
-
export SPINDLE_SERVER_SECRETS_PROVIDER=openbao
201
-
export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201
202
-
export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle
203
-
```
204
-
205
-
Start Spindle:
206
-
207
-
Spindle will now connect to the local proxy, which handles all
208
-
authentication automatically.
209
-
210
-
## production setup for proxy
211
-
212
-
For production, you'll want to run the proxy as a service:
213
-
214
-
Place your production configuration in `/etc/openbao/proxy.hcl` with
215
-
proper TLS settings for the vault connection.
216
-
217
-
## verifying setup
218
-
219
-
Test the proxy directly:
220
-
221
-
```bash
222
-
# Check proxy health
223
-
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health
224
-
225
-
# Test token lookup through proxy
226
-
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self
227
-
```
228
-
229
-
Test OpenBao operations through the server:
230
-
231
-
```bash
232
-
# List all secrets
233
-
bao kv list spindle/
234
-
235
-
# Add a test secret via Spindle API, then check it exists
236
-
bao kv list spindle/repos/
237
-
238
-
# Get a specific secret
239
-
bao kv get spindle/repos/your_repo_path/SECRET_NAME
240
-
```
241
-
242
-
## how it works
243
-
244
-
- Spindle connects to OpenBao Proxy on localhost (typically port 8200 or 8201)
245
-
- The proxy authenticates with OpenBao using AppRole credentials
246
-
- All Spindle requests go through the proxy, which injects authentication tokens
247
-
- Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}`
248
-
- Repository paths like `did:plc:alice/myrepo` become `did_plc_alice_myrepo`
249
-
- The proxy handles all token renewal automatically
250
-
- Spindle no longer manages tokens or authentication directly
251
-
252
-
## troubleshooting
253
-
254
-
**Connection refused**: Check that the OpenBao Proxy is running and
255
-
listening on the configured address.
256
-
257
-
**403 errors**: Verify the AppRole credentials are correct and the policy
258
-
has the necessary permissions.
259
-
260
-
**404 route errors**: The spindle KV mount probably doesn't exist - run
261
-
the mount creation step again.
262
-
263
-
**Proxy authentication failures**: Check the proxy logs and verify the
264
-
role-id and secret-id files are readable and contain valid credentials.
265
-
266
-
**Secret not found after writing**: This can indicate policy permission
267
-
issues. Verify the policy includes both `spindle/data/*` and
268
-
`spindle/metadata/*` paths with appropriate capabilities.
269
-
270
-
Check proxy logs:
271
-
272
-
```bash
273
-
# If running as systemd service
274
-
journalctl -u openbao-proxy -f
275
-
276
-
# If running directly, check the console output
277
-
```
278
-
279
-
Test AppRole authentication manually:
280
-
281
-
```bash
282
-
bao write auth/approle/login \
283
-
role_id="$(cat /tmp/openbao/role-id)" \
284
-
secret_id="$(cat /tmp/openbao/secret-id)"
285
-
```
-183
docs/spindle/pipeline.md
-183
docs/spindle/pipeline.md
···
1
-
# spindle pipelines
2
-
3
-
Spindle workflows allow you to write CI/CD pipelines in a simple format. They're located in the `.tangled/workflows` directory at the root of your repository, and are defined using YAML.
4
-
5
-
The fields are:
6
-
7
-
- [Trigger](#trigger): A **required** field that defines when a workflow should be triggered.
8
-
- [Engine](#engine): A **required** field that defines which engine a workflow should run on.
9
-
- [Clone options](#clone-options): An **optional** field that defines how the repository should be cloned.
10
-
- [Dependencies](#dependencies): An **optional** field that allows you to list dependencies you may need.
11
-
- [Environment](#environment): An **optional** field that allows you to define environment variables.
12
-
- [Steps](#steps): An **optional** field that allows you to define what steps should run in the workflow.
13
-
14
-
## Trigger
15
-
16
-
The first thing to add to a workflow is the trigger, which defines when a workflow runs. This is defined using a `when` field, which takes in a list of conditions. Each condition has the following fields:
17
-
18
-
- `event`: This is a **required** field that defines when your workflow should run. It's a list that can take one or more of the following values:
19
-
- `push`: The workflow should run every time a commit is pushed to the repository.
20
-
- `pull_request`: The workflow should run every time a pull request is made or updated.
21
-
- `manual`: The workflow can be triggered manually.
22
-
- `branch`: Defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. Supports glob patterns using `*` and `**` (e.g., `main`, `develop`, `release-*`). Either `branch` or `tag` (or both) must be specified for `push` events.
23
-
- `tag`: Defines which tags the workflow should run for. Only used with the `push` event - when tags matching the pattern(s) listed here are pushed, the workflow will trigger. This field has no effect with `pull_request` or `manual` events. Supports glob patterns using `*` and `**` (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or `tag` (or both) must be specified for `push` events.
24
-
25
-
For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
26
-
27
-
```yaml
28
-
when:
29
-
- event: ["push", "manual"]
30
-
branch: ["main", "develop"]
31
-
- event: ["pull_request"]
32
-
branch: ["main"]
33
-
```
34
-
35
-
You can also trigger workflows on tag pushes. For instance, to run a deployment workflow when tags matching `v*` are pushed:
36
-
37
-
```yaml
38
-
when:
39
-
- event: ["push"]
40
-
tag: ["v*"]
41
-
```
42
-
43
-
You can even combine branch and tag patterns in a single constraint (the workflow triggers if either matches):
44
-
45
-
```yaml
46
-
when:
47
-
- event: ["push"]
48
-
branch: ["main", "release-*"]
49
-
tag: ["v*", "stable"]
50
-
```
51
-
52
-
## Engine
53
-
54
-
Next is the engine on which the workflow should run, defined using the **required** `engine` field. The currently supported engines are:
55
-
56
-
- `nixery`: This uses an instance of [Nixery](https://nixery.dev) to run steps, which allows you to add [dependencies](#dependencies) from [Nixpkgs](https://github.com/NixOS/nixpkgs). You can search for packages on https://search.nixos.org, and there's a pretty good chance the package(s) you're looking for will be there.
57
-
58
-
Example:
59
-
60
-
```yaml
61
-
engine: "nixery"
62
-
```
63
-
64
-
## Clone options
65
-
66
-
When a workflow starts, the first step is to clone the repository. You can customize this behavior using the **optional** `clone` field. It has the following fields:
67
-
68
-
- `skip`: Setting this to `true` will skip cloning the repository. This can be useful if your workflow is doing something that doesn't require anything from the repository itself. This is `false` by default.
69
-
- `depth`: This sets the number of commits, or the "clone depth", to fetch from the repository. For example, if you set this to 2, the last 2 commits will be fetched. By default, the depth is set to 1, meaning only the most recent commit will be fetched, which is the commit that triggered the workflow.
70
-
- `submodules`: If you use [git submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules) in your repository, setting this field to `true` will recursively fetch all submodules. This is `false` by default.
71
-
72
-
The default settings are:
73
-
74
-
```yaml
75
-
clone:
76
-
skip: false
77
-
depth: 1
78
-
submodules: false
79
-
```
80
-
81
-
## Dependencies
82
-
83
-
Usually when you're running a workflow, you'll need additional dependencies. The `dependencies` field lets you define which dependencies to get, and from where. It's a key-value map, with the key being the registry to fetch dependencies from, and the value being the list of dependencies to fetch.
84
-
85
-
Say you want to fetch Node.js and Go from `nixpkgs`, and a package called `my_pkg` you've made from your own registry at your repository at `https://tangled.sh/@example.com/my_pkg`. You can define those dependencies like so:
86
-
87
-
```yaml
88
-
dependencies:
89
-
# nixpkgs
90
-
nixpkgs:
91
-
- nodejs
92
-
- go
93
-
# custom registry
94
-
git+https://tangled.org/@example.com/my_pkg:
95
-
- my_pkg
96
-
```
97
-
98
-
Now these dependencies are available to use in your workflow!
99
-
100
-
## Environment
101
-
102
-
The `environment` field allows you define environment variables that will be available throughout the entire workflow. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.**
103
-
104
-
Example:
105
-
106
-
```yaml
107
-
environment:
108
-
GOOS: "linux"
109
-
GOARCH: "arm64"
110
-
NODE_ENV: "production"
111
-
MY_ENV_VAR: "MY_ENV_VALUE"
112
-
```
113
-
114
-
## Steps
115
-
116
-
The `steps` field allows you to define what steps should run in the workflow. It's a list of step objects, each with the following fields:
117
-
118
-
- `name`: This field allows you to give your step a name. This name is visible in your workflow runs, and is used to describe what the step is doing.
119
-
- `command`: This field allows you to define a command to run in that step. The step is run in a Bash shell, and the logs from the command will be visible in the pipelines page on the Tangled website. The [dependencies](#dependencies) you added will be available to use here.
120
-
- `environment`: Similar to the global [environment](#environment) config, this **optional** field is a key-value map that allows you to set environment variables for the step. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.**
121
-
122
-
Example:
123
-
124
-
```yaml
125
-
steps:
126
-
- name: "Build backend"
127
-
command: "go build"
128
-
environment:
129
-
GOOS: "darwin"
130
-
GOARCH: "arm64"
131
-
- name: "Build frontend"
132
-
command: "npm run build"
133
-
environment:
134
-
NODE_ENV: "production"
135
-
```
136
-
137
-
## Complete workflow
138
-
139
-
```yaml
140
-
# .tangled/workflows/build.yml
141
-
142
-
when:
143
-
- event: ["push", "manual"]
144
-
branch: ["main", "develop"]
145
-
- event: ["pull_request"]
146
-
branch: ["main"]
147
-
148
-
engine: "nixery"
149
-
150
-
# using the default values
151
-
clone:
152
-
skip: false
153
-
depth: 1
154
-
submodules: false
155
-
156
-
dependencies:
157
-
# nixpkgs
158
-
nixpkgs:
159
-
- nodejs
160
-
- go
161
-
# custom registry
162
-
git+https://tangled.org/@example.com/my_pkg:
163
-
- my_pkg
164
-
165
-
environment:
166
-
GOOS: "linux"
167
-
GOARCH: "arm64"
168
-
NODE_ENV: "production"
169
-
MY_ENV_VAR: "MY_ENV_VALUE"
170
-
171
-
steps:
172
-
- name: "Build backend"
173
-
command: "go build"
174
-
environment:
175
-
GOOS: "darwin"
176
-
GOARCH: "arm64"
177
-
- name: "Build frontend"
178
-
command: "npm run build"
179
-
environment:
180
-
NODE_ENV: "production"
181
-
```
182
-
183
-
If you want another example of a workflow, you can look at the one [Tangled uses to build the project](https://tangled.sh/@tangled.sh/core/blob/master/.tangled/workflows/build.yml).
+101
docs/styles.css
+101
docs/styles.css
···
1
+
svg {
2
+
width: 16px;
3
+
height: 16px;
4
+
}
5
+
6
+
:root {
7
+
--syntax-alert: #d20f39;
8
+
--syntax-annotation: #fe640b;
9
+
--syntax-attribute: #df8e1d;
10
+
--syntax-basen: #40a02b;
11
+
--syntax-builtin: #1e66f5;
12
+
--syntax-controlflow: #8839ef;
13
+
--syntax-char: #04a5e5;
14
+
--syntax-constant: #fe640b;
15
+
--syntax-comment: #9ca0b0;
16
+
--syntax-commentvar: #7c7f93;
17
+
--syntax-documentation: #9ca0b0;
18
+
--syntax-datatype: #df8e1d;
19
+
--syntax-decval: #40a02b;
20
+
--syntax-error: #d20f39;
21
+
--syntax-extension: #4c4f69;
22
+
--syntax-float: #40a02b;
23
+
--syntax-function: #1e66f5;
24
+
--syntax-import: #40a02b;
25
+
--syntax-information: #04a5e5;
26
+
--syntax-keyword: #8839ef;
27
+
--syntax-operator: #179299;
28
+
--syntax-other: #8839ef;
29
+
--syntax-preprocessor: #ea76cb;
30
+
--syntax-specialchar: #04a5e5;
31
+
--syntax-specialstring: #ea76cb;
32
+
--syntax-string: #40a02b;
33
+
--syntax-variable: #8839ef;
34
+
--syntax-verbatimstring: #40a02b;
35
+
--syntax-warning: #df8e1d;
36
+
}
37
+
38
+
@media (prefers-color-scheme: dark) {
39
+
:root {
40
+
--syntax-alert: #f38ba8;
41
+
--syntax-annotation: #fab387;
42
+
--syntax-attribute: #f9e2af;
43
+
--syntax-basen: #a6e3a1;
44
+
--syntax-builtin: #89b4fa;
45
+
--syntax-controlflow: #cba6f7;
46
+
--syntax-char: #89dceb;
47
+
--syntax-constant: #fab387;
48
+
--syntax-comment: #6c7086;
49
+
--syntax-commentvar: #585b70;
50
+
--syntax-documentation: #6c7086;
51
+
--syntax-datatype: #f9e2af;
52
+
--syntax-decval: #a6e3a1;
53
+
--syntax-error: #f38ba8;
54
+
--syntax-extension: #cdd6f4;
55
+
--syntax-float: #a6e3a1;
56
+
--syntax-function: #89b4fa;
57
+
--syntax-import: #a6e3a1;
58
+
--syntax-information: #89dceb;
59
+
--syntax-keyword: #cba6f7;
60
+
--syntax-operator: #94e2d5;
61
+
--syntax-other: #cba6f7;
62
+
--syntax-preprocessor: #f5c2e7;
63
+
--syntax-specialchar: #89dceb;
64
+
--syntax-specialstring: #f5c2e7;
65
+
--syntax-string: #a6e3a1;
66
+
--syntax-variable: #cba6f7;
67
+
--syntax-verbatimstring: #a6e3a1;
68
+
--syntax-warning: #f9e2af;
69
+
}
70
+
}
71
+
72
+
/* pandoc syntax highlighting classes */
73
+
code span.al { color: var(--syntax-alert); font-weight: bold; } /* alert */
74
+
code span.an { color: var(--syntax-annotation); font-weight: bold; font-style: italic; } /* annotation */
75
+
code span.at { color: var(--syntax-attribute); } /* attribute */
76
+
code span.bn { color: var(--syntax-basen); } /* basen */
77
+
code span.bu { color: var(--syntax-builtin); } /* builtin */
78
+
code span.cf { color: var(--syntax-controlflow); font-weight: bold; } /* controlflow */
79
+
code span.ch { color: var(--syntax-char); } /* char */
80
+
code span.cn { color: var(--syntax-constant); } /* constant */
81
+
code span.co { color: var(--syntax-comment); font-style: italic; } /* comment */
82
+
code span.cv { color: var(--syntax-commentvar); font-weight: bold; font-style: italic; } /* commentvar */
83
+
code span.do { color: var(--syntax-documentation); font-style: italic; } /* documentation */
84
+
code span.dt { color: var(--syntax-datatype); } /* datatype */
85
+
code span.dv { color: var(--syntax-decval); } /* decval */
86
+
code span.er { color: var(--syntax-error); font-weight: bold; } /* error */
87
+
code span.ex { color: var(--syntax-extension); } /* extension */
88
+
code span.fl { color: var(--syntax-float); } /* float */
89
+
code span.fu { color: var(--syntax-function); } /* function */
90
+
code span.im { color: var(--syntax-import); font-weight: bold; } /* import */
91
+
code span.in { color: var(--syntax-information); font-weight: bold; font-style: italic; } /* information */
92
+
code span.kw { color: var(--syntax-keyword); font-weight: bold; } /* keyword */
93
+
code span.op { color: var(--syntax-operator); } /* operator */
94
+
code span.ot { color: var(--syntax-other); } /* other */
95
+
code span.pp { color: var(--syntax-preprocessor); } /* preprocessor */
96
+
code span.sc { color: var(--syntax-specialchar); } /* specialchar */
97
+
code span.ss { color: var(--syntax-specialstring); } /* specialstring */
98
+
code span.st { color: var(--syntax-string); } /* string */
99
+
code span.va { color: var(--syntax-variable); } /* variable */
100
+
code span.vs { color: var(--syntax-verbatimstring); } /* verbatimstring */
101
+
code span.wa { color: var(--syntax-warning); font-weight: bold; font-style: italic; } /* warning */
+117
docs/template.html
+117
docs/template.html
···
1
+
<!DOCTYPE html>
2
+
<html xmlns="http://www.w3.org/1999/xhtml" lang="$lang$" xml:lang="$lang$"$if(dir)$ dir="$dir$"$endif$>
3
+
<head>
4
+
<meta charset="utf-8" />
5
+
<meta name="generator" content="pandoc" />
6
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
7
+
$for(author-meta)$
8
+
<meta name="author" content="$author-meta$" />
9
+
$endfor$
10
+
11
+
$if(date-meta)$
12
+
<meta name="dcterms.date" content="$date-meta$" />
13
+
$endif$
14
+
15
+
$if(keywords)$
16
+
<meta name="keywords" content="$for(keywords)$$keywords$$sep$, $endfor$" />
17
+
$endif$
18
+
19
+
$if(description-meta)$
20
+
<meta name="description" content="$description-meta$" />
21
+
$endif$
22
+
23
+
<title>$pagetitle$</title>
24
+
25
+
<style>
26
+
$styles.css()$
27
+
</style>
28
+
29
+
$for(css)$
30
+
<link rel="stylesheet" href="$css$" />
31
+
$endfor$
32
+
33
+
$for(header-includes)$
34
+
$header-includes$
35
+
$endfor$
36
+
37
+
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
38
+
39
+
</head>
40
+
<body class="bg-white dark:bg-gray-900 min-h-screen flex flex-col min-h-screen">
41
+
$for(include-before)$
42
+
$include-before$
43
+
$endfor$
44
+
45
+
$if(toc)$
46
+
<!-- mobile topbar toc -->
47
+
<details id="mobile-$idprefix$TOC" role="doc-toc" class="md:hidden bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 z-50 space-y-4 group px-6 py-4">
48
+
<summary class="cursor-pointer list-none text-sm font-semibold select-none flex gap-2 justify-between items-center dark:text-white">
49
+
$if(toc-title)$$toc-title$$else$Table of Contents$endif$
50
+
<span class="group-open:hidden inline">${ menu.svg() }</span>
51
+
<span class="hidden group-open:inline">${ x.svg() }</span>
52
+
</summary>
53
+
${ table-of-contents:toc.html() }
54
+
</details>
55
+
<!-- desktop sidebar toc -->
56
+
<nav id="$idprefix$TOC" role="doc-toc" class="hidden md:block fixed left-0 top-0 w-80 h-screen bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 overflow-y-auto p-4 z-50">
57
+
$if(toc-title)$
58
+
<h2 id="$idprefix$toc-title" class="text-lg font-semibold mb-4 text-gray-900">$toc-title$</h2>
59
+
$endif$
60
+
${ table-of-contents:toc.html() }
61
+
</nav>
62
+
$endif$
63
+
64
+
<div class="$if(toc)$md:ml-80$endif$ flex-1 flex flex-col">
65
+
<main class="max-w-4xl w-full mx-auto p-6 flex-1">
66
+
$if(top)$
67
+
$-- only print title block if this is NOT the top page
68
+
$else$
69
+
$if(title)$
70
+
<header id="title-block-header" class="mb-8 pb-8 border-b border-gray-200 dark:border-gray-700">
71
+
<h1 class="text-4xl font-bold mb-2 text-black dark:text-white">$title$</h1>
72
+
$if(subtitle)$
73
+
<p class="text-xl text-gray-500 dark:text-gray-400 mb-2">$subtitle$</p>
74
+
$endif$
75
+
$for(author)$
76
+
<p class="text-sm text-gray-500 dark:text-gray-400">$author$</p>
77
+
$endfor$
78
+
$if(date)$
79
+
<p class="text-sm text-gray-500 dark:text-gray-400">Updated on $date$</p>
80
+
$endif$
81
+
$if(abstract)$
82
+
<div class="mt-6 p-4 bg-gray-50 rounded-lg">
83
+
<div class="text-sm font-semibold text-gray-700 uppercase mb-2">$abstract-title$</div>
84
+
<div class="text-gray-700">$abstract$</div>
85
+
</div>
86
+
$endif$
87
+
$endif$
88
+
</header>
89
+
$endif$
90
+
<article class="prose dark:prose-invert max-w-none">
91
+
$body$
92
+
</article>
93
+
</main>
94
+
<nav id="sitenav" class="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 ">
95
+
<div class="max-w-4xl mx-auto px-8 py-4">
96
+
<div class="flex justify-between gap-4">
97
+
<span class="flex-1">
98
+
$if(previous.url)$
99
+
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase block mb-1">Previous</span>
100
+
<a href="$previous.url$" accesskey="p" rel="previous">$previous.title$</a>
101
+
$endif$
102
+
</span>
103
+
<span class="flex-1 text-right">
104
+
$if(next.url)$
105
+
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase block mb-1">Next</span>
106
+
<a href="$next.url$" accesskey="n" rel="next">$next.title$</a>
107
+
$endif$
108
+
</span>
109
+
</div>
110
+
</div>
111
+
</nav>
112
+
</div>
113
+
$for(include-after)$
114
+
$include-after$
115
+
$endfor$
116
+
</body>
117
+
</html>
+4
docs/toc.html
+4
docs/toc.html
+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": 1751984180,
154
-
"narHash": "sha256-LwWRsENAZJKUdD3SpLluwDmdXY9F45ZEgCb0X+xgOL0=",
153
+
"lastModified": 1766070988,
154
+
"narHash": "sha256-G/WVghka6c4bAzMhTwT2vjLccg/awmHkdKSd2JrycLc=",
155
155
"owner": "nixos",
156
156
"repo": "nixpkgs",
157
-
"rev": "9807714d6944a957c2e036f84b0ff8caf9930bc0",
157
+
"rev": "c6245e83d836d0433170a16eb185cefe0572f8b8",
158
158
"type": "github"
159
159
},
160
160
"original": {
+35
-5
flake.nix
+35
-5
flake.nix
···
80
80
}).buildGoApplication;
81
81
modules = ./nix/gomod2nix.toml;
82
82
sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix {
83
-
inherit (pkgs) gcc;
84
83
inherit sqlite-lib-src;
85
84
};
86
85
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
···
89
88
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src;
90
89
};
91
90
appview = self.callPackage ./nix/pkgs/appview.nix {};
91
+
docs = self.callPackage ./nix/pkgs/docs.nix {
92
+
inherit inter-fonts-src ibm-plex-mono-src lucide-src;
93
+
};
92
94
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
93
95
knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {};
94
96
knot = self.callPackage ./nix/pkgs/knot.nix {};
97
+
did-method-plc = self.callPackage ./nix/pkgs/did-method-plc.nix {};
98
+
bluesky-jetstream = self.callPackage ./nix/pkgs/bluesky-jetstream.nix {};
99
+
bluesky-relay = self.callPackage ./nix/pkgs/bluesky-relay.nix {};
100
+
tap = self.callPackage ./nix/pkgs/tap.nix {};
95
101
});
96
102
in {
97
103
overlays.default = final: prev: {
98
-
inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview;
104
+
inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs did-method-plc bluesky-jetstream bluesky-relay tap;
99
105
};
100
106
101
107
packages = forAllSystems (system: let
···
104
110
staticPackages = mkPackageSet pkgs.pkgsStatic;
105
111
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
106
112
in {
107
-
inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib;
113
+
inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib docs did-method-plc bluesky-jetstream bluesky-relay tap;
108
114
109
115
pkgsStatic-appview = staticPackages.appview;
110
116
pkgsStatic-knot = staticPackages.knot;
···
156
162
nativeBuildInputs = [
157
163
pkgs.go
158
164
pkgs.air
159
-
pkgs.tilt
160
165
pkgs.gopls
161
166
pkgs.httpie
162
167
pkgs.litecli
···
232
237
rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1)
233
238
cd "$rootDir"
234
239
235
-
mkdir -p nix/vm-data/{knot,repos,spindle,spindle-logs}
240
+
mkdir -p nix/vm-data/{caddy,knot,repos,spindle,spindle-logs}
236
241
237
242
export TANGLED_VM_DATA_DIR="$rootDir/nix/vm-data"
238
243
exec ${pkgs.lib.getExe
···
304
309
imports = [./nix/modules/spindle.nix];
305
310
306
311
services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.spindle;
312
+
services.tangled.spindle.tap-package = lib.mkDefault self.packages.${pkgs.system}.tap;
313
+
};
314
+
nixosModules.did-method-plc = {
315
+
lib,
316
+
pkgs,
317
+
...
318
+
}: {
319
+
imports = [./nix/modules/did-method-plc.nix];
320
+
services.did-method-plc.package = lib.mkDefault self.packages.${pkgs.system}.did-method-plc;
321
+
};
322
+
nixosModules.bluesky-relay = {
323
+
lib,
324
+
pkgs,
325
+
...
326
+
}: {
327
+
imports = [./nix/modules/bluesky-relay.nix];
328
+
services.bluesky-relay.package = lib.mkDefault self.packages.${pkgs.system}.bluesky-relay;
329
+
};
330
+
nixosModules.bluesky-jetstream = {
331
+
lib,
332
+
pkgs,
333
+
...
334
+
}: {
335
+
imports = [./nix/modules/bluesky-jetstream.nix];
336
+
services.bluesky-jetstream.package = lib.mkDefault self.packages.${pkgs.system}.bluesky-jetstream;
307
337
};
308
338
};
309
339
}
+4
-3
go.mod
+4
-3
go.mod
···
1
1
module tangled.org/core
2
2
3
-
go 1.24.4
3
+
go 1.25.0
4
4
5
5
require (
6
6
github.com/Blank-Xu/sql-adapter v1.1.1
···
18
18
github.com/cloudflare/cloudflare-go v0.115.0
19
19
github.com/cyphar/filepath-securejoin v0.4.1
20
20
github.com/dgraph-io/ristretto v0.2.0
21
-
github.com/did-method-plc/go-didplc v0.0.0-20250716171643-635da8b4e038
22
21
github.com/docker/docker v28.2.2+incompatible
23
22
github.com/dustin/go-humanize v1.0.1
24
23
github.com/gliderlabs/ssh v0.3.8
···
30
29
github.com/gorilla/feeds v1.2.0
31
30
github.com/gorilla/sessions v1.4.0
32
31
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
32
+
github.com/hashicorp/go-version v1.8.0
33
33
github.com/hiddeco/sshsig v0.2.0
34
34
github.com/hpcloud/tail v1.0.0
35
35
github.com/ipfs/go-cid v0.5.0
···
46
46
github.com/urfave/cli/v3 v3.3.3
47
47
github.com/whyrusleeping/cbor-gen v0.3.1
48
48
github.com/yuin/goldmark v1.7.13
49
+
github.com/yuin/goldmark-emoji v1.0.6
49
50
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
50
51
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab
51
52
golang.org/x/crypto v0.40.0
52
53
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
53
54
golang.org/x/image v0.31.0
54
55
golang.org/x/net v0.42.0
55
-
golang.org/x/sync v0.17.0
56
56
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
57
57
gopkg.in/yaml.v3 v3.0.1
58
58
)
···
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
+4
-2
go.sum
+4
-2
go.sum
···
131
131
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
132
132
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
133
133
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
134
-
github.com/did-method-plc/go-didplc v0.0.0-20250716171643-635da8b4e038 h1:AGh+Vn9fXhf9eo8erG1CK4+LACduPo64P1OICQLDv88=
135
-
github.com/did-method-plc/go-didplc v0.0.0-20250716171643-635da8b4e038/go.mod h1:ddIXqTTSXWtj5kMsHAPj8SvbIx2GZdAkBFgFa6e6+CM=
136
134
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
137
135
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
138
136
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
···
266
264
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
267
265
github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw=
268
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=
269
269
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
270
270
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
271
271
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
···
507
507
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
508
508
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
509
509
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
510
+
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
511
+
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
510
512
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
511
513
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
512
514
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A=
+4
-4
hook/hook.go
+4
-4
hook/hook.go
···
48
48
},
49
49
Commands: []*cli.Command{
50
50
{
51
-
Name: "post-recieve",
52
-
Usage: "sends a post-recieve hook to the knot (waits for stdin)",
53
-
Action: postRecieve,
51
+
Name: "post-receive",
52
+
Usage: "sends a post-receive hook to the knot (waits for stdin)",
53
+
Action: postReceive,
54
54
},
55
55
},
56
56
}
57
57
}
58
58
59
-
func postRecieve(ctx context.Context, cmd *cli.Command) error {
59
+
func postReceive(ctx context.Context, cmd *cli.Command) error {
60
60
gitDir := cmd.String("git-dir")
61
61
userDid := cmd.String("user-did")
62
62
userHandle := cmd.String("user-handle")
+1
-1
hook/setup.go
+1
-1
hook/setup.go
···
138
138
option_var="GIT_PUSH_OPTION_$i"
139
139
push_options+=(-push-option "${!option_var}")
140
140
done
141
-
%s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-recieve
141
+
%s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-receive
142
142
`, executablePath, config.internalApi)
143
143
144
144
return os.WriteFile(hookPath, []byte(hookContent), 0755)
+2
-2
input.css
+2
-2
input.css
···
96
96
@apply border border-gray-400 block rounded bg-gray-50 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400;
97
97
}
98
98
textarea {
99
-
@apply border border-gray-400 block rounded bg-gray-50 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400;
99
+
@apply border border-gray-400 block rounded bg-gray-50 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400 font-mono;
100
100
}
101
101
details summary::-webkit-details-marker {
102
102
display: none;
···
162
162
}
163
163
164
164
.prose a.mention {
165
-
@apply no-underline hover:underline;
165
+
@apply no-underline hover:underline font-bold;
166
166
}
167
167
168
168
.prose li {
+2
-1
jetstream/jetstream.go
+2
-1
jetstream/jetstream.go
···
159
159
j.cancelMu.Unlock()
160
160
161
161
if err := j.client.ConnectAndRead(connCtx, cursor); err != nil {
162
-
l.Error("error reading jetstream", "error", err)
162
+
l.Error("error reading jetstream, retry in 3s", "error", err)
163
163
cancel()
164
+
time.Sleep(3 * time.Second)
164
165
continue
165
166
}
166
167
-101
knot2/config/config.go
-101
knot2/config/config.go
···
1
-
package config
2
-
3
-
import (
4
-
"context"
5
-
"fmt"
6
-
"net"
7
-
"os"
8
-
"path"
9
-
10
-
"github.com/bluesky-social/indigo/atproto/syntax"
11
-
"github.com/sethvargo/go-envconfig"
12
-
"gopkg.in/yaml.v3"
13
-
)
14
-
15
-
type Config struct {
16
-
Dev bool `yaml:"dev"`
17
-
HostName string `yaml:"hostname"`
18
-
OwnerDid syntax.DID `yaml:"owner_did"`
19
-
ListenHost string `yaml:"listen_host"`
20
-
ListenPort string `yaml:"listen_port"`
21
-
DataDir string `yaml:"data_dir"`
22
-
RepoDir string `yaml:"repo_dir"`
23
-
PlcUrl string `yaml:"plc_url"`
24
-
JetstreamEndpoint string `yaml:"jetstream_endpoint"`
25
-
AppviewEndpoint string `yaml:"appview_endpoint"`
26
-
GitUserName string `yaml:"git_user_name"`
27
-
GitUserEmail string `yaml:"git_user_email"`
28
-
OAuth OAuthConfig
29
-
}
30
-
31
-
type OAuthConfig struct {
32
-
CookieSecret string `env:"KNOT2_COOKIE_SECRET, default=00000000000000000000000000000000"`
33
-
ClientSecret string `env:"KNOT2_OAUTH_CLIENT_SECRET"`
34
-
ClientKid string `env:"KNOT2_OAUTH_CLIENT_KID"`
35
-
}
36
-
37
-
func (c *Config) Uri() string {
38
-
// TODO: make port configurable
39
-
if c.Dev {
40
-
return "http://127.0.0.1:6444"
41
-
}
42
-
return "https://" + c.HostName
43
-
}
44
-
45
-
func (c *Config) ListenAddr() string {
46
-
return net.JoinHostPort(c.ListenHost, c.ListenPort)
47
-
}
48
-
49
-
func (c *Config) DbPath() string {
50
-
return path.Join(c.DataDir, "knot.db")
51
-
}
52
-
53
-
func (c *Config) GitMotdFilePath() string {
54
-
return path.Join(c.DataDir, "motd")
55
-
}
56
-
57
-
func (c *Config) Validate() error {
58
-
if c.HostName == "" {
59
-
return fmt.Errorf("knot hostname cannot be empty")
60
-
}
61
-
if c.OwnerDid == "" {
62
-
return fmt.Errorf("knot owner did cannot be empty")
63
-
}
64
-
return nil
65
-
}
66
-
67
-
func Load(ctx context.Context, path string) (Config, error) {
68
-
// NOTE: yaml.v3 package doesn't support "default" struct tag
69
-
cfg := Config{
70
-
Dev: true,
71
-
ListenHost: "0.0.0.0",
72
-
ListenPort: "5555",
73
-
DataDir: "/home/git",
74
-
RepoDir: "/home/git",
75
-
PlcUrl: "https://plc.directory",
76
-
JetstreamEndpoint: "wss://jetstream1.us-west.bsky.network/subscribe",
77
-
AppviewEndpoint: "https://tangled.org",
78
-
GitUserName: "Tangled",
79
-
GitUserEmail: "noreply@tangled.org",
80
-
}
81
-
// load config from env vars
82
-
err := envconfig.Process(ctx, &cfg.OAuth)
83
-
if err != nil {
84
-
return cfg, err
85
-
}
86
-
87
-
// load config from toml config file
88
-
bytes, err := os.ReadFile(path)
89
-
if err != nil {
90
-
return cfg, err
91
-
}
92
-
if err := yaml.Unmarshal(bytes, &cfg); err != nil {
93
-
return cfg, err
94
-
}
95
-
96
-
// validate the config
97
-
if err = cfg.Validate(); err != nil {
98
-
return cfg, err
99
-
}
100
-
return cfg, nil
101
-
}
-52
knot2/db/db.go
-52
knot2/db/db.go
···
1
-
package db
2
-
3
-
import (
4
-
"database/sql"
5
-
"strings"
6
-
7
-
_ "github.com/mattn/go-sqlite3"
8
-
)
9
-
10
-
func New(dbPath string) (*sql.DB, error) {
11
-
// https://github.com/mattn/go-sqlite3#connection-string
12
-
opts := []string{
13
-
"_foreign_keys=1",
14
-
"_journal_mode=WAL",
15
-
"_synchronous=NORMAL",
16
-
"_auto_vacuum=incremental",
17
-
}
18
-
19
-
return sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
20
-
}
21
-
22
-
func Init(d *sql.DB) error {
23
-
_, err := d.Exec(`
24
-
create table if not exists _jetstream (
25
-
id integer primary key autoincrement,
26
-
last_time_us integer not null
27
-
);
28
-
29
-
create table if not exists events (
30
-
rkey text not null,
31
-
nsid text not null,
32
-
event text not null, -- json
33
-
created integer not null -- unix nanos
34
-
);
35
-
36
-
create table if not exists users (
37
-
id integer primary key autoincrement,
38
-
did text not null unique,
39
-
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
40
-
);
41
-
42
-
create table if not exists public_keys (
43
-
id integer primary key autoincrement,
44
-
did text not null,
45
-
key text not null,
46
-
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
47
-
unique(did, key)
48
-
);
49
-
`)
50
-
51
-
return err
52
-
}
-10
knot2/db/pubkeys.go
-10
knot2/db/pubkeys.go
-12
knot2/db/users.go
-12
knot2/db/users.go
-31
knot2/guard/guard.go
-31
knot2/guard/guard.go
···
1
-
package guard
2
-
3
-
import (
4
-
"context"
5
-
6
-
"github.com/urfave/cli/v3"
7
-
"tangled.org/core/log"
8
-
)
9
-
10
-
func Command() *cli.Command {
11
-
return &cli.Command{
12
-
Name: "guard",
13
-
Usage: "role-based access control for git over ssh (not for manual use)",
14
-
Action: Run,
15
-
Flags: []cli.Flag{
16
-
&cli.StringFlag{
17
-
Name: "user",
18
-
Usage: "allowed git user",
19
-
Required: true,
20
-
},
21
-
},
22
-
}
23
-
}
24
-
25
-
func Run(ctx context.Context, cmd *cli.Command) error {
26
-
l := log.FromContext(ctx)
27
-
l = log.SubLogger(l, cmd.Name)
28
-
ctx = log.IntoContext(ctx, l)
29
-
30
-
panic("unimplemented")
31
-
}
-27
knot2/hook/hook.go
-27
knot2/hook/hook.go
···
1
-
package hook
2
-
3
-
import (
4
-
"context"
5
-
6
-
"github.com/urfave/cli/v3"
7
-
"tangled.org/core/log"
8
-
)
9
-
10
-
func Command() *cli.Command {
11
-
return &cli.Command{
12
-
Name: "hook",
13
-
Usage: "run git hooks",
14
-
Action: Run,
15
-
Flags: []cli.Flag{
16
-
// TODO:
17
-
},
18
-
}
19
-
}
20
-
21
-
func Run(ctx context.Context, cmd *cli.Command) error {
22
-
l := log.FromContext(ctx)
23
-
l = log.SubLogger(l, cmd.Name)
24
-
ctx = log.IntoContext(ctx, l)
25
-
26
-
panic("unimplemented")
27
-
}
-103
knot2/keys/keys.go
-103
knot2/keys/keys.go
···
1
-
package keys
2
-
3
-
import (
4
-
"context"
5
-
"encoding/json"
6
-
"fmt"
7
-
"os"
8
-
"strings"
9
-
10
-
"github.com/urfave/cli/v3"
11
-
"tangled.org/core/knot2/config"
12
-
"tangled.org/core/knot2/db"
13
-
"tangled.org/core/log"
14
-
)
15
-
16
-
func Command() *cli.Command {
17
-
return &cli.Command{
18
-
Name: "keys",
19
-
Usage: "fetch public keys from the knot server",
20
-
Action: Run,
21
-
Flags: []cli.Flag{
22
-
&cli.StringFlag{
23
-
Name: "config",
24
-
Aliases: []string{"c"},
25
-
Usage: "config path",
26
-
Required: true,
27
-
},
28
-
&cli.StringFlag{
29
-
Name: "output",
30
-
Aliases: []string{"o"},
31
-
Usage: "output format (table, json, authorized-keys)",
32
-
Value: "table",
33
-
},
34
-
},
35
-
}
36
-
}
37
-
38
-
func Run(ctx context.Context, cmd *cli.Command) error {
39
-
l := log.FromContext(ctx)
40
-
l = log.SubLogger(l, cmd.Name)
41
-
ctx = log.IntoContext(ctx, l)
42
-
43
-
var (
44
-
output = cmd.String("output")
45
-
configPath = cmd.String("config")
46
-
)
47
-
48
-
cfg, err := config.Load(ctx, configPath)
49
-
if err != nil {
50
-
return fmt.Errorf("failed to load config: %w", err)
51
-
}
52
-
53
-
d, err := db.New(cfg.DbPath())
54
-
if err != nil {
55
-
return fmt.Errorf("failed to load db: %w", err)
56
-
}
57
-
58
-
pubkeyDidListMap, err := db.GetPubkeyDidListMap(d)
59
-
if err != nil {
60
-
return err
61
-
}
62
-
63
-
switch output {
64
-
case "json":
65
-
prettyJSON, err := json.MarshalIndent(pubkeyDidListMap, "", " ")
66
-
if err != nil {
67
-
return err
68
-
}
69
-
if _, err := os.Stdout.Write(prettyJSON); err != nil {
70
-
return err
71
-
}
72
-
case "table":
73
-
fmt.Printf("%-40s %-40s\n", "KEY", "DID")
74
-
fmt.Println(strings.Repeat("-", 80))
75
-
76
-
for key, didList := range pubkeyDidListMap {
77
-
fmt.Printf("%-40s %-40s\n", key, strings.Join(didList, ","))
78
-
}
79
-
case "authorized-keys":
80
-
for key, didList := range pubkeyDidListMap {
81
-
executablePath, err := os.Executable()
82
-
if err != nil {
83
-
l.Error("error getting path of executable", "error", err)
84
-
return err
85
-
}
86
-
command := fmt.Sprintf("%s guard", executablePath)
87
-
for _, did := range didList {
88
-
command += fmt.Sprintf(" -user %s", did)
89
-
}
90
-
fmt.Printf(
91
-
`command="%s",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty %s`+"\n",
92
-
command,
93
-
key,
94
-
)
95
-
}
96
-
if err != nil {
97
-
l.Error("error writing to stdout", "error", err)
98
-
return err
99
-
}
100
-
}
101
-
102
-
return nil
103
-
}
-8
knot2/models/pubkeys.go
-8
knot2/models/pubkeys.go
-18
knot2/server/handler/events.go
-18
knot2/server/handler/events.go
···
1
-
package handler
2
-
3
-
import (
4
-
"net/http"
5
-
6
-
"github.com/gorilla/websocket"
7
-
)
8
-
9
-
var upgrader = websocket.Upgrader{
10
-
ReadBufferSize: 1024,
11
-
WriteBufferSize: 1024,
12
-
}
13
-
14
-
func Events() http.HandlerFunc {
15
-
return func(w http.ResponseWriter, r *http.Request) {
16
-
panic("unimplemented")
17
-
}
18
-
}
-9
knot2/server/handler/git_receive_pack.go
-9
knot2/server/handler/git_receive_pack.go
-9
knot2/server/handler/git_upload_pack.go
-9
knot2/server/handler/git_upload_pack.go
-9
knot2/server/handler/info_refs.go
-9
knot2/server/handler/info_refs.go
-241
knot2/server/handler/register.go
-241
knot2/server/handler/register.go
···
1
-
package handler
2
-
3
-
import (
4
-
"context"
5
-
"database/sql"
6
-
_ "embed"
7
-
"encoding/json"
8
-
"fmt"
9
-
"html/template"
10
-
"net/http"
11
-
"strings"
12
-
13
-
"github.com/bluesky-social/indigo/api/agnostic"
14
-
"github.com/bluesky-social/indigo/api/atproto"
15
-
"github.com/bluesky-social/indigo/atproto/auth/oauth"
16
-
"github.com/bluesky-social/indigo/atproto/syntax"
17
-
"github.com/did-method-plc/go-didplc"
18
-
"github.com/gorilla/sessions"
19
-
"tangled.org/core/knot2/config"
20
-
"tangled.org/core/knot2/db"
21
-
"tangled.org/core/log"
22
-
)
23
-
24
-
const (
25
-
// atproto
26
-
serviceId = "tangled_knot"
27
-
serviceType = "TangledKnot"
28
-
// cookies
29
-
sessionName = "oauth-demo"
30
-
sessionId = "sessionId"
31
-
sessionDid = "sessionDID"
32
-
)
33
-
34
-
//go:embed "templates/register.html"
35
-
var tmplRegisgerText string
36
-
var tmplRegister = template.Must(template.New("register.html").Parse(tmplRegisgerText))
37
-
38
-
func Register(jar *sessions.CookieStore) http.HandlerFunc {
39
-
return func(w http.ResponseWriter, r *http.Request) {
40
-
ctx := r.Context()
41
-
l := log.FromContext(ctx).With("handler", "Register")
42
-
43
-
sess, _ := jar.Get(r, sessionName)
44
-
var data map[string]any
45
-
46
-
if !sess.IsNew {
47
-
// render Register { Handle, Web: true }
48
-
did := syntax.DID(sess.Values[sessionDid].(string))
49
-
plcop := did.Method() == "plc" && r.URL.Query().Get("method") != "web"
50
-
data = map[string]any{
51
-
"Did": did,
52
-
"PlcOp": plcop,
53
-
}
54
-
}
55
-
56
-
err := tmplRegister.Execute(w, data)
57
-
if err != nil {
58
-
l.Error("failed to render", "err", err)
59
-
}
60
-
}
61
-
}
62
-
63
-
func OauthClientMetadata(cfg *config.Config, clientApp *oauth.ClientApp) http.HandlerFunc {
64
-
return func(w http.ResponseWriter, r *http.Request) {
65
-
doc := clientApp.Config.ClientMetadata()
66
-
var (
67
-
clientName = cfg.HostName
68
-
clientUri = cfg.Uri()
69
-
jwksUri = clientUri + "/oauth/jwks.json"
70
-
)
71
-
doc.ClientName = &clientName
72
-
doc.ClientURI = &clientUri
73
-
doc.JWKSURI = &jwksUri
74
-
75
-
w.Header().Set("Content-Type", "application/json")
76
-
if err := json.NewEncoder(w).Encode(doc); err != nil {
77
-
http.Error(w, err.Error(), http.StatusInternalServerError)
78
-
return
79
-
}
80
-
}
81
-
}
82
-
83
-
func OauthJwks(clientApp *oauth.ClientApp) http.HandlerFunc {
84
-
return func(w http.ResponseWriter, r *http.Request) {
85
-
w.Header().Set("Content-Type", "application/json")
86
-
body := clientApp.Config.PublicJWKS()
87
-
if err := json.NewEncoder(w).Encode(body); err != nil {
88
-
http.Error(w, err.Error(), http.StatusInternalServerError)
89
-
return
90
-
}
91
-
}
92
-
}
93
-
94
-
func OauthLoginPost(clientApp *oauth.ClientApp) http.HandlerFunc {
95
-
return func(w http.ResponseWriter, r *http.Request) {
96
-
ctx := r.Context()
97
-
l := log.FromContext(ctx).With("handler", "OauthLoginPost")
98
-
99
-
handle := r.FormValue("handle")
100
-
101
-
handle = strings.TrimPrefix(handle, "\u202a")
102
-
handle = strings.TrimSuffix(handle, "\u202c")
103
-
// `@` is harmless
104
-
handle = strings.TrimPrefix(handle, "@")
105
-
106
-
redirectURL, err := clientApp.StartAuthFlow(ctx, handle)
107
-
if err != nil {
108
-
l.Error("failed to start auth flow", "err", err)
109
-
panic(err)
110
-
}
111
-
112
-
w.Header().Set("HX-Redirect", redirectURL)
113
-
w.WriteHeader(http.StatusOK)
114
-
}
115
-
}
116
-
117
-
func OauthCallback(oauth *oauth.ClientApp, jar *sessions.CookieStore) http.HandlerFunc {
118
-
return func(w http.ResponseWriter, r *http.Request) {
119
-
ctx := r.Context()
120
-
l := log.FromContext(ctx).With("handler", "OauthCallback")
121
-
122
-
data, err := oauth.ProcessCallback(ctx, r.URL.Query())
123
-
if err != nil {
124
-
l.Error("failed to process oauth callback", "err", err)
125
-
panic(err)
126
-
}
127
-
128
-
// store session data to cookie jar
129
-
sess, _ := jar.Get(r, sessionName)
130
-
sess.Values[sessionDid] = data.AccountDID.String()
131
-
sess.Values[sessionId] = data.SessionID
132
-
if err = sess.Save(r, w); err != nil {
133
-
l.Error("failed to save session", "err", err)
134
-
panic(err)
135
-
}
136
-
137
-
if data.AccountDID.Method() == "plc" {
138
-
sess, err := oauth.ResumeSession(ctx, data.AccountDID, data.SessionID)
139
-
if err != nil {
140
-
l.Error("failed to resume atproto session", "err", err)
141
-
panic(err)
142
-
}
143
-
client := sess.APIClient()
144
-
err = atproto.IdentityRequestPlcOperationSignature(ctx, client)
145
-
if err != nil {
146
-
l.Error("failed to request plc operation signature", "err", err)
147
-
panic(err)
148
-
}
149
-
}
150
-
151
-
http.Redirect(w, r, "/register", http.StatusSeeOther)
152
-
}
153
-
}
154
-
155
-
func RegisterPost(cfg *config.Config, d *sql.DB, clientApp *oauth.ClientApp, jar *sessions.CookieStore) http.HandlerFunc {
156
-
plcop := func(ctx context.Context, did syntax.DID, sessId, token string) error {
157
-
sess, err := clientApp.ResumeSession(ctx, did, sessId)
158
-
if err != nil {
159
-
return fmt.Errorf("failed to resume atproto session: %w", err)
160
-
}
161
-
client := sess.APIClient()
162
-
163
-
identity, err := clientApp.Dir.LookupDID(ctx, did)
164
-
services := make(map[string]didplc.OpService)
165
-
for id, service := range identity.Services {
166
-
services[id] = didplc.OpService{
167
-
Type: service.Type,
168
-
Endpoint: service.URL,
169
-
}
170
-
}
171
-
services[serviceId] = didplc.OpService{
172
-
Type: serviceType,
173
-
Endpoint: cfg.Uri(),
174
-
}
175
-
176
-
rawServices, err := json.Marshal(services)
177
-
if err != nil {
178
-
return fmt.Errorf("failed to marshal services map: %w", err)
179
-
}
180
-
raw := json.RawMessage(rawServices)
181
-
182
-
signed, err := agnostic.IdentitySignPlcOperation(ctx, client, &agnostic.IdentitySignPlcOperation_Input{
183
-
Services: &raw,
184
-
Token: &token,
185
-
})
186
-
if err != nil {
187
-
return fmt.Errorf("failed to sign plc operatino: %w", err)
188
-
}
189
-
190
-
err = agnostic.IdentitySubmitPlcOperation(ctx, client, &agnostic.IdentitySubmitPlcOperation_Input{
191
-
Operation: signed.Operation,
192
-
})
193
-
if err != nil {
194
-
return fmt.Errorf("failed to submit plc operatino: %w", err)
195
-
}
196
-
197
-
return nil
198
-
}
199
-
return func(w http.ResponseWriter, r *http.Request) {
200
-
ctx := r.Context()
201
-
l := log.FromContext(ctx).With("handler", "RegisterPost")
202
-
203
-
sess, _ := jar.Get(r, sessionName)
204
-
205
-
var (
206
-
did = syntax.DID(sess.Values[sessionDid].(string))
207
-
sessId = sess.Values[sessionId].(string)
208
-
token = r.FormValue("token")
209
-
doPlcOp = r.FormValue("plcop") == "on"
210
-
)
211
-
212
-
tx, err := d.BeginTx(ctx, nil)
213
-
if err != nil {
214
-
l.Error("failed to begin db tx", "err", err)
215
-
panic(err)
216
-
}
217
-
defer tx.Rollback()
218
-
219
-
if err := db.AddUser(tx, did); err != nil {
220
-
l.Error("failed to add user", "err", err)
221
-
http.Error(w, err.Error(), http.StatusInternalServerError)
222
-
return
223
-
}
224
-
225
-
if doPlcOp {
226
-
l.Debug("performing plc op", "did", did, "token", token)
227
-
if err := plcop(ctx, did, sessId, token); err != nil {
228
-
l.Error("failed to perform plc op", "err", err)
229
-
http.Error(w, err.Error(), http.StatusInternalServerError)
230
-
}
231
-
} else {
232
-
// TODO: check if did doc already include the knot service
233
-
tx.Rollback()
234
-
panic("unimplemented")
235
-
}
236
-
if err := tx.Commit(); err != nil {
237
-
l.Error("failed to commit tx", "err", err)
238
-
http.Error(w, err.Error(), http.StatusInternalServerError)
239
-
}
240
-
}
241
-
}
-41
knot2/server/handler/templates/register.html
-41
knot2/server/handler/templates/register.html
···
1
-
<!doctype html>
2
-
<html lang="en" class="dark:bg-gray-900">
3
-
<head>
4
-
<meta charset="UTF-8" />
5
-
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
-
<meta name="description" content="knot server"/>
7
-
<title>Register to Knot</title>
8
-
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js" integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz" crossorigin="anonymous"></script>
9
-
</head>
10
-
<body>
11
-
{{ if (not .) }}
12
-
{{/* step 1. login */}}
13
-
<form hx-post="/oauth/login" hx-swap="none">
14
-
<input type="text" name="handle">
15
-
<button type="submit">Login</button>
16
-
</form>
17
-
{{ else }}
18
-
{{/* step 2. register user with plc operation */}}
19
-
<form hx-post="/register" hx-swap="none">
20
-
<input type="hidden" name="plcop" value="{{ if .PlcOp }}on{{ end }}">
21
-
22
-
<div>
23
-
<label for="handle">User Handle:</label>
24
-
<input type="text" name="handle" value="{{ .Did }}" readonly>
25
-
</div>
26
-
27
-
{{ if (not .Web) }}
28
-
<h2>Please enter your PLC Token you received in an email</h2>
29
-
<div>
30
-
<label for="token">PLC Token:</label>
31
-
<input type="text" name="token" required placeholder="XXXXX-XXXXX">
32
-
</div>
33
-
34
-
<button type="submit">add Knot to identity</button>
35
-
{{ else }}
36
-
<button type="submit">register to Knot</button>
37
-
{{ end }}
38
-
</form>
39
-
{{ end }}
40
-
</body>
41
-
</html>
-87
knot2/server/handler/xrpc_git_keep_commit.go
-87
knot2/server/handler/xrpc_git_keep_commit.go
···
1
-
package handler
2
-
3
-
import (
4
-
"encoding/json"
5
-
"fmt"
6
-
"net/http"
7
-
"os/exec"
8
-
"path"
9
-
10
-
"github.com/bluesky-social/indigo/atproto/syntax"
11
-
"github.com/go-git/go-git/v5"
12
-
"github.com/go-git/go-git/v5/plumbing"
13
-
"tangled.org/core/api/tangled"
14
-
"tangled.org/core/knot2/config"
15
-
"tangled.org/core/log"
16
-
xrpcerr "tangled.org/core/xrpc/errors"
17
-
)
18
-
19
-
func XrpcGitKeepCommit(cfg *config.Config) http.HandlerFunc {
20
-
return func(w http.ResponseWriter, r *http.Request) {
21
-
ctx := r.Context()
22
-
l := log.FromContext(ctx).With("handler", "XrpcGitKeepCommit")
23
-
24
-
// TODO: get session did
25
-
actorDid := syntax.DID("")
26
-
27
-
var input tangled.GitKeepCommit_Input
28
-
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
29
-
l.Error("failed to decode body", "err", err)
30
-
panic("unimplemented")
31
-
}
32
-
33
-
repoAt, err := syntax.ParseATURI(input.Repo)
34
-
if err != nil {
35
-
l.Error("failed to decode body", "err", err)
36
-
panic("unimplemented")
37
-
}
38
-
repoPath := repoPathFromAtUri(cfg, repoAt)
39
-
40
-
// ensure repo exist (if not, clone it)
41
-
repo, err := git.PlainOpen(repoPath)
42
-
if err != nil {
43
-
// TODO: clone the ref from source repo if repo doesn't exist in this knot yet
44
-
l.Info("repo missing in knot", "err", err)
45
-
panic("unimplemented")
46
-
}
47
-
48
-
commitId, err := repo.ResolveRevision(plumbing.Revision(input.Ref))
49
-
if err != nil {
50
-
l.Error("failed to resolve revision", "ref", input.Ref, "err", err)
51
-
panic("unimplemented")
52
-
}
53
-
54
-
// set keep-ref for given commit
55
-
refspec := fmt.Sprintf("refs/knot/%s/keep/%s", actorDid, commitId)
56
-
updateRefCmd := exec.Command("git", "-C", repoPath, "update-ref", refspec, commitId.String())
57
-
if err := updateRefCmd.Run(); err != nil {
58
-
writeError(w, xrpcerr.GenericError(err), http.StatusBadRequest)
59
-
return
60
-
}
61
-
62
-
output := tangled.GitKeepCommit_Output{
63
-
CommitId: commitId.String(),
64
-
}
65
-
66
-
w.WriteHeader(http.StatusOK)
67
-
writeJson(w, output)
68
-
}
69
-
}
70
-
71
-
func repoPathFromAtUri(cfg *config.Config, repoAt syntax.ATURI) string {
72
-
return path.Join(cfg.RepoDir, repoAt.Authority().String(), repoAt.RecordKey().String())
73
-
}
74
-
75
-
func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
76
-
w.Header().Set("Content-Type", "application/json")
77
-
w.WriteHeader(status)
78
-
json.NewEncoder(w).Encode(e)
79
-
}
80
-
81
-
func writeJson(w http.ResponseWriter, response any) {
82
-
w.Header().Set("Content-Type", "application/json")
83
-
if err := json.NewEncoder(w).Encode(response); err != nil {
84
-
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
85
-
return
86
-
}
87
-
}
-21
knot2/server/middleware/cors.go
-21
knot2/server/middleware/cors.go
···
1
-
package middleware
2
-
3
-
import "net/http"
4
-
5
-
func CORS(next http.Handler) http.Handler {
6
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
7
-
// Set CORS headers
8
-
w.Header().Set("Access-Control-Allow-Origin", "*")
9
-
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
10
-
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
11
-
w.Header().Set("Access-Control-Max-Age", "86400")
12
-
13
-
// Handle preflight requests
14
-
if r.Method == "OPTIONS" {
15
-
w.WriteHeader(http.StatusOK)
16
-
return
17
-
}
18
-
19
-
next.ServeHTTP(w, r)
20
-
})
21
-
}
-40
knot2/server/middleware/requestlogger.go
-40
knot2/server/middleware/requestlogger.go
···
1
-
package middleware
2
-
3
-
import (
4
-
"log/slog"
5
-
"net/http"
6
-
"time"
7
-
8
-
"tangled.org/core/log"
9
-
)
10
-
11
-
func RequestLogger(next http.Handler) http.Handler {
12
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13
-
ctx := r.Context()
14
-
l := log.FromContext(ctx)
15
-
16
-
start := time.Now()
17
-
18
-
next.ServeHTTP(w, r)
19
-
20
-
// Build query params as slog.Attrs for the group
21
-
queryParams := r.URL.Query()
22
-
queryAttrs := make([]any, 0, len(queryParams))
23
-
for key, values := range queryParams {
24
-
if len(values) == 1 {
25
-
queryAttrs = append(queryAttrs, slog.String(key, values[0]))
26
-
} else {
27
-
queryAttrs = append(queryAttrs, slog.Any(key, values))
28
-
}
29
-
}
30
-
31
-
l.LogAttrs(ctx, slog.LevelInfo, "",
32
-
slog.Group("request",
33
-
slog.String("method", r.Method),
34
-
slog.String("path", r.URL.Path),
35
-
slog.Group("query", queryAttrs...),
36
-
slog.Duration("duration", time.Since(start)),
37
-
),
38
-
)
39
-
})
40
-
}
-40
knot2/server/oauth.go
-40
knot2/server/oauth.go
···
1
-
package server
2
-
3
-
import (
4
-
"net/http"
5
-
6
-
atcrypto "github.com/bluesky-social/indigo/atproto/crypto"
7
-
"github.com/bluesky-social/indigo/atproto/auth/oauth"
8
-
"tangled.org/core/idresolver"
9
-
"tangled.org/core/knot2/config"
10
-
)
11
-
12
-
func newAtClientApp(cfg *config.Config) *oauth.ClientApp {
13
-
idResolver := idresolver.DefaultResolver(cfg.PlcUrl)
14
-
scopes := []string{"atproto", "identity:*"}
15
-
var oauthConfig oauth.ClientConfig
16
-
if cfg.Dev {
17
-
oauthConfig = oauth.NewLocalhostConfig(
18
-
cfg.Uri()+"/oauth/callback",
19
-
scopes,
20
-
)
21
-
} else {
22
-
oauthConfig = oauth.NewPublicConfig(
23
-
cfg.Uri()+"/oauth/client-metadata.json",
24
-
cfg.Uri()+"/oauth/callback",
25
-
scopes,
26
-
)
27
-
}
28
-
priv, err := atcrypto.ParsePrivateMultibase(cfg.OAuth.ClientSecret)
29
-
if err != nil {
30
-
panic(err)
31
-
}
32
-
if err := oauthConfig.SetClientSecret(priv, cfg.OAuth.ClientKid); err != nil {
33
-
panic(err)
34
-
}
35
-
// we can just use in-memory auth store
36
-
clientApp := oauth.NewClientApp(&oauthConfig, oauth.NewMemStore())
37
-
clientApp.Dir = idResolver.Directory()
38
-
clientApp.Resolver.Client.Transport = http.DefaultTransport
39
-
return clientApp
40
-
}
-52
knot2/server/routes.go
-52
knot2/server/routes.go
···
1
-
package server
2
-
3
-
import (
4
-
"database/sql"
5
-
"net/http"
6
-
7
-
"github.com/bluesky-social/indigo/atproto/auth/oauth"
8
-
"github.com/go-chi/chi/v5"
9
-
"github.com/gorilla/sessions"
10
-
"tangled.org/core/api/tangled"
11
-
"tangled.org/core/knot2/config"
12
-
"tangled.org/core/knot2/server/handler"
13
-
"tangled.org/core/knot2/server/middleware"
14
-
)
15
-
16
-
func Routes(
17
-
cfg *config.Config,
18
-
d *sql.DB,
19
-
clientApp *oauth.ClientApp,
20
-
) http.Handler {
21
-
r := chi.NewRouter()
22
-
23
-
r.Use(middleware.CORS)
24
-
r.Use(middleware.RequestLogger)
25
-
26
-
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
27
-
w.Write([]byte("This is a knot server. More info at https://tangled.sh"))
28
-
})
29
-
30
-
jar := sessions.NewCookieStore([]byte(cfg.OAuth.CookieSecret))
31
-
32
-
r.Get("/register", handler.Register(jar))
33
-
r.Post("/register", handler.RegisterPost(cfg, d, clientApp, jar))
34
-
r.Post("/oauth/login", handler.OauthLoginPost(clientApp))
35
-
r.Get("/oauth/client-metadata.json", handler.OauthClientMetadata(cfg, clientApp))
36
-
r.Get("/oauth/jwks.json", handler.OauthJwks(clientApp))
37
-
r.Get("/oauth/callback", handler.OauthCallback(clientApp, jar))
38
-
39
-
r.Route("/{did}/{name}", func(r chi.Router) {
40
-
r.Get("/info/refs", handler.InfoRefs())
41
-
r.Post("/git-upload-pack", handler.GitUploadPack())
42
-
r.Post("/git-receive-pack", handler.GitReceivePack())
43
-
})
44
-
45
-
r.Get("/events", handler.Events())
46
-
47
-
r.Route("/xrpc", func(r chi.Router) {
48
-
r.Post("/"+tangled.GitKeepCommitNSID, handler.XrpcGitKeepCommit(cfg))
49
-
})
50
-
51
-
return r
52
-
}
-65
knot2/server/server.go
-65
knot2/server/server.go
···
1
-
package server
2
-
3
-
import (
4
-
"context"
5
-
"fmt"
6
-
"net/http"
7
-
8
-
"github.com/urfave/cli/v3"
9
-
"tangled.org/core/knot2/config"
10
-
"tangled.org/core/knot2/db"
11
-
"tangled.org/core/log"
12
-
)
13
-
14
-
func Command() *cli.Command {
15
-
return &cli.Command{
16
-
Name: "server",
17
-
Usage: "run a knot server",
18
-
Action: Run,
19
-
Flags: []cli.Flag{
20
-
&cli.StringFlag{
21
-
Name: "config",
22
-
Aliases: []string{"c"},
23
-
Usage: "config path",
24
-
Required: true,
25
-
},
26
-
},
27
-
}
28
-
}
29
-
30
-
func Run(ctx context.Context, cmd *cli.Command) error {
31
-
l := log.FromContext(ctx)
32
-
l = log.SubLogger(l, cmd.Name)
33
-
ctx = log.IntoContext(ctx, l)
34
-
35
-
configPath := cmd.String("config")
36
-
37
-
cfg, err := config.Load(ctx, configPath)
38
-
if err != nil {
39
-
return fmt.Errorf("failed to load config: %w", err)
40
-
}
41
-
fmt.Println("config:", cfg)
42
-
43
-
// TODO: start listening to jetstream
44
-
45
-
d, err := db.New(cfg.DbPath())
46
-
if err != nil {
47
-
panic(err)
48
-
}
49
-
err = db.Init(d)
50
-
if err != nil {
51
-
panic(err)
52
-
}
53
-
54
-
clientApp := newAtClientApp(&cfg)
55
-
56
-
mux := Routes(&cfg, d, clientApp)
57
-
58
-
l.Info("starting knot server", "address", cfg.ListenAddr())
59
-
err = http.ListenAndServe(cfg.ListenAddr(), mux)
60
-
if err != nil {
61
-
l.Error("server error", "err", err)
62
-
}
63
-
64
-
return nil
65
-
}
+81
knotserver/db/db.go
+81
knotserver/db/db.go
···
1
+
package db
2
+
3
+
import (
4
+
"context"
5
+
"database/sql"
6
+
"log/slog"
7
+
"strings"
8
+
9
+
_ "github.com/mattn/go-sqlite3"
10
+
"tangled.org/core/log"
11
+
)
12
+
13
+
type DB struct {
14
+
db *sql.DB
15
+
logger *slog.Logger
16
+
}
17
+
18
+
func Setup(ctx context.Context, dbPath string) (*DB, error) {
19
+
// https://github.com/mattn/go-sqlite3#connection-string
20
+
opts := []string{
21
+
"_foreign_keys=1",
22
+
"_journal_mode=WAL",
23
+
"_synchronous=NORMAL",
24
+
"_auto_vacuum=incremental",
25
+
}
26
+
27
+
logger := log.FromContext(ctx)
28
+
logger = log.SubLogger(logger, "db")
29
+
30
+
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
31
+
if err != nil {
32
+
return nil, err
33
+
}
34
+
35
+
conn, err := db.Conn(ctx)
36
+
if err != nil {
37
+
return nil, err
38
+
}
39
+
defer conn.Close()
40
+
41
+
_, err = conn.ExecContext(ctx, `
42
+
create table if not exists known_dids (
43
+
did text primary key
44
+
);
45
+
46
+
create table if not exists public_keys (
47
+
id integer primary key autoincrement,
48
+
did text not null,
49
+
key text not null,
50
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
51
+
unique(did, key),
52
+
foreign key (did) references known_dids(did) on delete cascade
53
+
);
54
+
55
+
create table if not exists _jetstream (
56
+
id integer primary key autoincrement,
57
+
last_time_us integer not null
58
+
);
59
+
60
+
create table if not exists events (
61
+
rkey text not null,
62
+
nsid text not null,
63
+
event text not null, -- json
64
+
created integer not null default (strftime('%s', 'now')),
65
+
primary key (rkey, nsid)
66
+
);
67
+
68
+
create table if not exists migrations (
69
+
id integer primary key autoincrement,
70
+
name text unique
71
+
);
72
+
`)
73
+
if err != nil {
74
+
return nil, err
75
+
}
76
+
77
+
return &DB{
78
+
db: db,
79
+
logger: logger,
80
+
}, nil
81
+
}
-64
knotserver/db/init.go
-64
knotserver/db/init.go
···
1
-
package db
2
-
3
-
import (
4
-
"database/sql"
5
-
"strings"
6
-
7
-
_ "github.com/mattn/go-sqlite3"
8
-
)
9
-
10
-
type DB struct {
11
-
db *sql.DB
12
-
}
13
-
14
-
func Setup(dbPath string) (*DB, error) {
15
-
// https://github.com/mattn/go-sqlite3#connection-string
16
-
opts := []string{
17
-
"_foreign_keys=1",
18
-
"_journal_mode=WAL",
19
-
"_synchronous=NORMAL",
20
-
"_auto_vacuum=incremental",
21
-
}
22
-
23
-
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
24
-
if err != nil {
25
-
return nil, err
26
-
}
27
-
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.
31
-
32
-
_, err = db.Exec(`
33
-
create table if not exists known_dids (
34
-
did text primary key
35
-
);
36
-
37
-
create table if not exists public_keys (
38
-
id integer primary key autoincrement,
39
-
did text not null,
40
-
key text not null,
41
-
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
42
-
unique(did, key),
43
-
foreign key (did) references known_dids(did) on delete cascade
44
-
);
45
-
46
-
create table if not exists _jetstream (
47
-
id integer primary key autoincrement,
48
-
last_time_us integer not null
49
-
);
50
-
51
-
create table if not exists events (
52
-
rkey text not null,
53
-
nsid text not null,
54
-
event text not null, -- json
55
-
created integer not null default (strftime('%s', 'now')),
56
-
primary key (rkey, nsid)
57
-
);
58
-
`)
59
-
if err != nil {
60
-
return nil, err
61
-
}
62
-
63
-
return &DB{db: db}, nil
64
-
}
+13
-1
knotserver/git/service/service.go
+13
-1
knotserver/git/service/service.go
···
95
95
return c.RunService(cmd)
96
96
}
97
97
98
+
func (c *ServiceCommand) UploadArchive() error {
99
+
cmd := exec.Command("git", []string{
100
+
"upload-archive",
101
+
".",
102
+
}...)
103
+
104
+
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
105
+
cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", c.GitProtocol))
106
+
cmd.Dir = c.Dir
107
+
108
+
return c.RunService(cmd)
109
+
}
110
+
98
111
func (c *ServiceCommand) UploadPack() error {
99
112
cmd := exec.Command("git", []string{
100
-
"-c", "uploadpack.allowFilter=true",
101
113
"upload-pack",
102
114
"--stateless-rpc",
103
115
".",
+47
knotserver/git.go
+47
knotserver/git.go
···
56
56
}
57
57
}
58
58
59
+
func (h *Knot) UploadArchive(w http.ResponseWriter, r *http.Request) {
60
+
did := chi.URLParam(r, "did")
61
+
name := chi.URLParam(r, "name")
62
+
repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name))
63
+
if err != nil {
64
+
gitError(w, err.Error(), http.StatusInternalServerError)
65
+
h.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err)
66
+
return
67
+
}
68
+
69
+
const expectedContentType = "application/x-git-upload-archive-request"
70
+
contentType := r.Header.Get("Content-Type")
71
+
if contentType != expectedContentType {
72
+
gitError(w, fmt.Sprintf("Expected Content-Type: '%s', but received '%s'.", expectedContentType, contentType), http.StatusUnsupportedMediaType)
73
+
}
74
+
75
+
var bodyReader io.ReadCloser = r.Body
76
+
if r.Header.Get("Content-Encoding") == "gzip" {
77
+
gzipReader, err := gzip.NewReader(r.Body)
78
+
if err != nil {
79
+
gitError(w, err.Error(), http.StatusInternalServerError)
80
+
h.l.Error("git: failed to create gzip reader", "handler", "UploadArchive", "error", err)
81
+
return
82
+
}
83
+
defer gzipReader.Close()
84
+
bodyReader = gzipReader
85
+
}
86
+
87
+
w.Header().Set("Content-Type", "application/x-git-upload-archive-result")
88
+
89
+
h.l.Info("git: executing git-upload-archive", "handler", "UploadArchive", "repo", repo)
90
+
91
+
cmd := service.ServiceCommand{
92
+
GitProtocol: r.Header.Get("Git-Protocol"),
93
+
Dir: repo,
94
+
Stdout: w,
95
+
Stdin: bodyReader,
96
+
}
97
+
98
+
w.WriteHeader(http.StatusOK)
99
+
100
+
if err := cmd.UploadArchive(); err != nil {
101
+
h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err)
102
+
return
103
+
}
104
+
}
105
+
59
106
func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) {
60
107
did := chi.URLParam(r, "did")
61
108
name := chi.URLParam(r, "name")
-136
knotserver/ingester.go
-136
knotserver/ingester.go
···
7
7
"io"
8
8
"net/http"
9
9
"net/url"
10
-
"path/filepath"
11
10
"strings"
12
11
13
12
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
17
16
securejoin "github.com/cyphar/filepath-securejoin"
18
17
"tangled.org/core/api/tangled"
19
18
"tangled.org/core/knotserver/db"
20
-
"tangled.org/core/knotserver/git"
21
19
"tangled.org/core/log"
22
20
"tangled.org/core/rbac"
23
-
"tangled.org/core/workflow"
24
21
)
25
22
26
23
func (h *Knot) processPublicKey(ctx context.Context, event *models.Event) error {
···
85
82
return nil
86
83
}
87
84
88
-
func (h *Knot) processPull(ctx context.Context, event *models.Event) error {
89
-
raw := json.RawMessage(event.Commit.Record)
90
-
did := event.Did
91
-
92
-
var record tangled.RepoPull
93
-
if err := json.Unmarshal(raw, &record); err != nil {
94
-
return fmt.Errorf("failed to unmarshal record: %w", err)
95
-
}
96
-
97
-
l := log.FromContext(ctx)
98
-
l = l.With("handler", "processPull")
99
-
l = l.With("did", did)
100
-
101
-
if record.Target == nil {
102
-
return fmt.Errorf("ignoring pull record: target repo is nil")
103
-
}
104
-
105
-
l = l.With("target_repo", record.Target.Repo)
106
-
l = l.With("target_branch", record.Target.Branch)
107
-
108
-
if record.Source == nil {
109
-
return fmt.Errorf("ignoring pull record: not a branch-based pull request")
110
-
}
111
-
112
-
if record.Source.Repo != nil {
113
-
return fmt.Errorf("ignoring pull record: fork based pull")
114
-
}
115
-
116
-
repoAt, err := syntax.ParseATURI(record.Target.Repo)
117
-
if err != nil {
118
-
return fmt.Errorf("failed to parse ATURI: %w", err)
119
-
}
120
-
121
-
// resolve this aturi to extract the repo record
122
-
ident, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String())
123
-
if err != nil || ident.Handle.IsInvalidHandle() {
124
-
return fmt.Errorf("failed to resolve handle: %w", err)
125
-
}
126
-
127
-
xrpcc := xrpc.Client{
128
-
Host: ident.PDSEndpoint(),
129
-
}
130
-
131
-
resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
132
-
if err != nil {
133
-
return fmt.Errorf("failed to resolver repo: %w", err)
134
-
}
135
-
136
-
repo := resp.Value.Val.(*tangled.Repo)
137
-
138
-
if repo.Knot != h.c.Server.Hostname {
139
-
return fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname)
140
-
}
141
-
142
-
didSlashRepo, err := securejoin.SecureJoin(ident.DID.String(), repo.Name)
143
-
if err != nil {
144
-
return fmt.Errorf("failed to construct relative repo path: %w", err)
145
-
}
146
-
147
-
repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
148
-
if err != nil {
149
-
return fmt.Errorf("failed to construct absolute repo path: %w", err)
150
-
}
151
-
152
-
gr, err := git.Open(repoPath, record.Source.Sha)
153
-
if err != nil {
154
-
return fmt.Errorf("failed to open git repository: %w", err)
155
-
}
156
-
157
-
workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir)
158
-
if err != nil {
159
-
return fmt.Errorf("failed to open workflow directory: %w", err)
160
-
}
161
-
162
-
var pipeline workflow.RawPipeline
163
-
for _, e := range workflowDir {
164
-
if !e.IsFile() {
165
-
continue
166
-
}
167
-
168
-
fpath := filepath.Join(workflow.WorkflowDir, e.Name)
169
-
contents, err := gr.RawContent(fpath)
170
-
if err != nil {
171
-
continue
172
-
}
173
-
174
-
pipeline = append(pipeline, workflow.RawWorkflow{
175
-
Name: e.Name,
176
-
Contents: contents,
177
-
})
178
-
}
179
-
180
-
trigger := tangled.Pipeline_PullRequestTriggerData{
181
-
Action: "create",
182
-
SourceBranch: record.Source.Branch,
183
-
SourceSha: record.Source.Sha,
184
-
TargetBranch: record.Target.Branch,
185
-
}
186
-
187
-
compiler := workflow.Compiler{
188
-
Trigger: tangled.Pipeline_TriggerMetadata{
189
-
Kind: string(workflow.TriggerKindPullRequest),
190
-
PullRequest: &trigger,
191
-
Repo: &tangled.Pipeline_TriggerRepo{
192
-
Did: ident.DID.String(),
193
-
Knot: repo.Knot,
194
-
Repo: repo.Name,
195
-
},
196
-
},
197
-
}
198
-
199
-
cp := compiler.Compile(compiler.Parse(pipeline))
200
-
eventJson, err := json.Marshal(cp)
201
-
if err != nil {
202
-
return fmt.Errorf("failed to marshal pipeline event: %w", err)
203
-
}
204
-
205
-
// do not run empty pipelines
206
-
if cp.Workflows == nil {
207
-
return nil
208
-
}
209
-
210
-
ev := db.Event{
211
-
Rkey: TID(),
212
-
Nsid: tangled.PipelineNSID,
213
-
EventJson: string(eventJson),
214
-
}
215
-
216
-
return h.db.InsertEvent(ev, h.n)
217
-
}
218
-
219
85
// duplicated from add collaborator
220
86
func (h *Knot) processCollaborator(ctx context.Context, event *models.Event) error {
221
87
raw := json.RawMessage(event.Commit.Record)
···
338
204
err = h.processPublicKey(ctx, event)
339
205
case tangled.KnotMemberNSID:
340
206
err = h.processKnotMember(ctx, event)
341
-
case tangled.RepoPullNSID:
342
-
err = h.processPull(ctx, event)
343
207
case tangled.RepoCollaboratorNSID:
344
208
err = h.processCollaborator(ctx, event)
345
209
}
+1
-109
knotserver/internal.go
+1
-109
knotserver/internal.go
···
23
23
"tangled.org/core/log"
24
24
"tangled.org/core/notifier"
25
25
"tangled.org/core/rbac"
26
-
"tangled.org/core/workflow"
27
26
)
28
27
29
28
type InternalHandle struct {
···
176
175
}
177
176
178
177
for _, line := range lines {
178
+
// TODO: pass pushOptions to refUpdate
179
179
err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName)
180
180
if err != nil {
181
181
l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
···
185
185
err = h.emitCompareLink(&resp.Messages, line, repoDid, repoName)
186
186
if err != nil {
187
187
l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
188
-
// non-fatal
189
-
}
190
-
191
-
err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
192
-
if err != nil {
193
-
l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
194
188
// non-fatal
195
189
}
196
190
}
···
241
235
}
242
236
243
237
return errors.Join(errs, h.db.InsertEvent(event, h.n))
244
-
}
245
-
246
-
func (h *InternalHandle) triggerPipeline(
247
-
clientMsgs *[]string,
248
-
line git.PostReceiveLine,
249
-
gitUserDid string,
250
-
repoDid string,
251
-
repoName string,
252
-
pushOptions PushOptions,
253
-
) error {
254
-
if pushOptions.skipCi {
255
-
return nil
256
-
}
257
-
258
-
didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
259
-
if err != nil {
260
-
return err
261
-
}
262
-
263
-
repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
264
-
if err != nil {
265
-
return err
266
-
}
267
-
268
-
gr, err := git.Open(repoPath, line.Ref)
269
-
if err != nil {
270
-
return err
271
-
}
272
-
273
-
workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir)
274
-
if err != nil {
275
-
return err
276
-
}
277
-
278
-
var pipeline workflow.RawPipeline
279
-
for _, e := range workflowDir {
280
-
if !e.IsFile() {
281
-
continue
282
-
}
283
-
284
-
fpath := filepath.Join(workflow.WorkflowDir, e.Name)
285
-
contents, err := gr.RawContent(fpath)
286
-
if err != nil {
287
-
continue
288
-
}
289
-
290
-
pipeline = append(pipeline, workflow.RawWorkflow{
291
-
Name: e.Name,
292
-
Contents: contents,
293
-
})
294
-
}
295
-
296
-
trigger := tangled.Pipeline_PushTriggerData{
297
-
Ref: line.Ref,
298
-
OldSha: line.OldSha.String(),
299
-
NewSha: line.NewSha.String(),
300
-
}
301
-
302
-
compiler := workflow.Compiler{
303
-
Trigger: tangled.Pipeline_TriggerMetadata{
304
-
Kind: string(workflow.TriggerKindPush),
305
-
Push: &trigger,
306
-
Repo: &tangled.Pipeline_TriggerRepo{
307
-
Did: repoDid,
308
-
Knot: h.c.Server.Hostname,
309
-
Repo: repoName,
310
-
},
311
-
},
312
-
}
313
-
314
-
cp := compiler.Compile(compiler.Parse(pipeline))
315
-
eventJson, err := json.Marshal(cp)
316
-
if err != nil {
317
-
return err
318
-
}
319
-
320
-
for _, e := range compiler.Diagnostics.Errors {
321
-
*clientMsgs = append(*clientMsgs, e.String())
322
-
}
323
-
324
-
if pushOptions.verboseCi {
325
-
if compiler.Diagnostics.IsEmpty() {
326
-
*clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics")
327
-
}
328
-
329
-
for _, w := range compiler.Diagnostics.Warnings {
330
-
*clientMsgs = append(*clientMsgs, w.String())
331
-
}
332
-
}
333
-
334
-
// do not run empty pipelines
335
-
if cp.Workflows == nil {
336
-
return nil
337
-
}
338
-
339
-
event := db.Event{
340
-
Rkey: TID(),
341
-
Nsid: tangled.PipelineNSID,
342
-
EventJson: string(eventJson),
343
-
}
344
-
345
-
return h.db.InsertEvent(event, h.n)
346
238
}
347
239
348
240
func (h *InternalHandle) emitCompareLink(
+26
knotserver/router.go
+26
knotserver/router.go
···
5
5
"fmt"
6
6
"log/slog"
7
7
"net/http"
8
+
"strings"
8
9
9
10
"github.com/go-chi/chi/v5"
10
11
"tangled.org/core/idresolver"
···
79
80
})
80
81
81
82
r.Route("/{did}", func(r chi.Router) {
83
+
r.Use(h.resolveDidRedirect)
82
84
r.Route("/{name}", func(r chi.Router) {
83
85
// routes for git operations
84
86
r.Get("/info/refs", h.InfoRefs)
87
+
r.Post("/git-upload-archive", h.UploadArchive)
85
88
r.Post("/git-upload-pack", h.UploadPack)
86
89
r.Post("/git-receive-pack", h.ReceivePack)
87
90
})
···
113
116
}
114
117
115
118
return xrpc.Router()
119
+
}
120
+
121
+
func (h *Knot) resolveDidRedirect(next http.Handler) http.Handler {
122
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
123
+
didOrHandle := chi.URLParam(r, "did")
124
+
if strings.HasPrefix(didOrHandle, "did:") {
125
+
next.ServeHTTP(w, r)
126
+
return
127
+
}
128
+
129
+
trimmed := strings.TrimPrefix(didOrHandle, "@")
130
+
id, err := h.resolver.ResolveIdent(r.Context(), trimmed)
131
+
if err != nil {
132
+
// invalid did or handle
133
+
h.l.Error("failed to resolve did/handle", "handle", trimmed, "err", err)
134
+
http.Error(w, fmt.Sprintf("failed to resolve did/handle: %s", trimmed), http.StatusInternalServerError)
135
+
return
136
+
}
137
+
138
+
suffix := strings.TrimPrefix(r.URL.Path, "/"+didOrHandle)
139
+
newPath := fmt.Sprintf("/%s/%s?%s", id.DID.String(), suffix, r.URL.RawQuery)
140
+
http.Redirect(w, r, newPath, http.StatusTemporaryRedirect)
141
+
})
116
142
}
117
143
118
144
func (h *Knot) configureOwner() error {
+1
-2
knotserver/server.go
+1
-2
knotserver/server.go
···
64
64
logger.Info("running in dev mode, signature verification is disabled")
65
65
}
66
66
67
-
db, err := db.Setup(c.Server.DBPath)
67
+
db, err := db.Setup(ctx, c.Server.DBPath)
68
68
if err != nil {
69
69
return fmt.Errorf("failed to load db: %w", err)
70
70
}
···
79
79
jc, err := jetstream.NewJetstreamClient(c.Server.JetstreamEndpoint, "knotserver", []string{
80
80
tangled.PublicKeyNSID,
81
81
tangled.KnotMemberNSID,
82
-
tangled.RepoPullNSID,
83
82
tangled.RepoCollaboratorNSID,
84
83
}, nil, log.SubLogger(logger, "jetstream"), db, true, c.Server.LogDids)
85
84
if err != nil {
+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
+
}
-46
lexicons/git/keepCommit.json
-46
lexicons/git/keepCommit.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.git.keepCommit",
4
-
"defs": {
5
-
"main": {
6
-
"type": "procedure",
7
-
"input": {
8
-
"encoding": "application/json",
9
-
"schema": {
10
-
"type": "object",
11
-
"required": ["repo", "ref"],
12
-
"properties": {
13
-
"repo": {
14
-
"type": "string",
15
-
"format": "at-uri",
16
-
"description": "AT-URI of the repository"
17
-
},
18
-
"ref": {
19
-
"type": "string",
20
-
"description": "ref to keep"
21
-
}
22
-
}
23
-
}
24
-
},
25
-
"output": {
26
-
"encoding": "application/json",
27
-
"schema": {
28
-
"type": "object",
29
-
"required": ["commitId"],
30
-
"properties": {
31
-
"commitId": {
32
-
"type": "string",
33
-
"description": "Keeped commit hash"
34
-
}
35
-
}
36
-
}
37
-
},
38
-
"errors": [
39
-
{
40
-
"name": "InternalServerError",
41
-
"description": "Failed to keep commit"
42
-
}
43
-
]
44
-
}
45
-
}
46
-
}
+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
+
}
+8
-2
lexicons/pulls/pull.json
+8
-2
lexicons/pulls/pull.json
···
12
12
"required": [
13
13
"target",
14
14
"title",
15
-
"patch",
15
+
"patchBlob",
16
16
"createdAt"
17
17
],
18
18
"properties": {
···
27
27
"type": "string"
28
28
},
29
29
"patch": {
30
-
"type": "string"
30
+
"type": "string",
31
+
"description": "(deprecated) use patchBlob instead"
32
+
},
33
+
"patchBlob": {
34
+
"type": "blob",
35
+
"accept": "text/x-patch",
36
+
"description": "patch content"
31
37
},
32
38
"source": {
33
39
"type": "ref",
+6
-3
nix/gomod2nix.toml
+6
-3
nix/gomod2nix.toml
···
171
171
[mod."github.com/dgryski/go-rendezvous"]
172
172
version = "v0.0.0-20200823014737-9f7001d12a5f"
173
173
hash = "sha256-n/7xo5CQqo4yLaWMSzSN1Muk/oqK6O5dgDOFWapeDUI="
174
-
[mod."github.com/did-method-plc/go-didplc"]
175
-
version = "v0.0.0-20250716171643-635da8b4e038"
176
-
hash = "sha256-o0uB/5tryjdB44ssALFr49PtfY3nRJnEENmE187md1w="
177
174
[mod."github.com/distribution/reference"]
178
175
version = "v0.6.0"
179
176
hash = "sha256-gr4tL+qz4jKyAtl8LINcxMSanztdt+pybj1T+2ulQv4="
···
307
304
[mod."github.com/hashicorp/go-sockaddr"]
308
305
version = "v1.0.7"
309
306
hash = "sha256-p6eDOrGzN1jMmT/F/f/VJMq0cKNFhUcEuVVwTE6vSrs="
307
+
[mod."github.com/hashicorp/go-version"]
308
+
version = "v1.8.0"
309
+
hash = "sha256-KXtqERmYrWdpqPCViWcHbe6jnuH7k16bvBIcuJuevj8="
310
310
[mod."github.com/hashicorp/golang-lru"]
311
311
version = "v1.0.2"
312
312
hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48="
···
533
533
[mod."github.com/yuin/goldmark"]
534
534
version = "v1.7.13"
535
535
hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE="
536
+
[mod."github.com/yuin/goldmark-emoji"]
537
+
version = "v1.0.6"
538
+
hash = "sha256-+d6bZzOPE+JSFsZbQNZMCWE+n3jgcQnkPETVk47mxSY="
536
539
[mod."github.com/yuin/goldmark-highlighting/v2"]
537
540
version = "v2.0.0-20230729083705-37449abec8cc"
538
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
+
}
+5
-18
nix/modules/knot.nix
+5
-18
nix/modules/knot.nix
···
170
170
description = "Enable development mode (disables signature verification)";
171
171
};
172
172
};
173
-
174
-
environmentFile = mkOption {
175
-
type = with types; nullOr path;
176
-
default = null;
177
-
example = "/etc/appview.env";
178
-
description = ''
179
-
Additional environment file as defined in {manpage}`systemd.exec(5)`.
180
-
181
-
Sensitive secrets such as {env}`KNOT_COOKIE_SECRET`,
182
-
{env}`KNOT_OAUTH_CLIENT_SECRET`, and {env}`KNOT_OAUTH_CLIENT_KID`
183
-
may be passed to the service without making them world readable in the nix store.
184
-
'';
185
-
};
186
173
};
187
174
};
188
175
···
218
205
text = ''
219
206
#!${pkgs.stdenv.shell}
220
207
${cfg.package}/bin/knot keys \
221
-
-config ${cfg.stateDir}/config.yml \
222
-
-output authorized-keys
208
+
-output authorized-keys \
209
+
-internal-api "http://${cfg.server.internalListenAddr}" \
210
+
-git-dir "${cfg.repo.scanPath}" \
211
+
-log-path /tmp/knotguard.log
223
212
'';
224
213
};
225
214
···
284
273
else "false"
285
274
}"
286
275
];
287
-
EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile;
288
-
ExecStart = "${cfg.package}/bin/knot server -config ${cfg.stateDir}/config.yml";
276
+
ExecStart = "${cfg.package}/bin/knot server";
289
277
Restart = "always";
290
-
RestartSec = 5;
291
278
};
292
279
};
293
280
+46
-12
nix/modules/spindle.nix
+46
-12
nix/modules/spindle.nix
···
1
1
{
2
2
config,
3
+
pkgs,
3
4
lib,
4
5
...
5
6
}: let
···
17
18
type = types.package;
18
19
description = "Package to use for the spindle";
19
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";
30
+
};
20
31
21
32
server = {
22
33
listenAddr = mkOption {
···
25
36
description = "Address to listen on";
26
37
};
27
38
28
-
dbPath = mkOption {
39
+
stateDir = mkOption {
29
40
type = types.path;
30
-
default = "/var/lib/spindle/spindle.db";
31
-
description = "Path to the database file";
41
+
default = "/var/lib/spindle";
42
+
description = "Tangled spindle data directory";
32
43
};
33
44
34
45
hostname = mkOption {
···
41
52
type = types.str;
42
53
default = "https://plc.directory";
43
54
description = "atproto PLC directory";
44
-
};
45
-
46
-
jetstreamEndpoint = mkOption {
47
-
type = types.str;
48
-
default = "wss://jetstream1.us-west.bsky.network/subscribe";
49
-
description = "Jetstream endpoint to subscribe to";
50
55
};
51
56
52
57
dev = mkOption {
···
114
119
config = mkIf cfg.enable {
115
120
virtualisation.docker.enable = true;
116
121
122
+
systemd.services.spindle-tap = {
123
+
description = "spindle tap service";
124
+
after = ["network.target" "docker.service"];
125
+
wantedBy = ["multi-user.target"];
126
+
serviceConfig = {
127
+
LogsDirectory = "spindle-tap";
128
+
StateDirectory = "spindle-tap";
129
+
Environment = [
130
+
"TAP_BIND=:2480"
131
+
"TAP_PLC_URL=${cfg.server.plcUrl}"
132
+
"TAP_RELAY_URL=${cfg.atpRelayUrl}"
133
+
"TAP_DATABASE_URL=sqlite:///var/lib/spindle-tap/tap.db"
134
+
"TAP_RETRY_TIMEOUT=3s"
135
+
"TAP_COLLECTION_FILTERS=${concatStringsSep "," [
136
+
"sh.tangled.repo"
137
+
"sh.tangled.repo.collaborator"
138
+
"sh.tangled.spindle.member"
139
+
"sh.tangled.repo.pull"
140
+
]}"
141
+
# temporary hack to listen for repo.pull from non-tangled users
142
+
"TAP_SIGNAL_COLLECTION=sh.tangled.repo.pull"
143
+
];
144
+
ExecStart = "${getExe cfg.tap-package} run";
145
+
};
146
+
};
147
+
117
148
systemd.services.spindle = {
118
149
description = "spindle service";
119
-
after = ["network.target" "docker.service"];
150
+
after = ["network.target" "docker.service" "spindle-tap.service"];
120
151
wantedBy = ["multi-user.target"];
152
+
path = [
153
+
pkgs.git
154
+
];
121
155
serviceConfig = {
122
156
LogsDirectory = "spindle";
123
157
StateDirectory = "spindle";
124
158
Environment = [
125
159
"SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
126
-
"SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}"
160
+
"SPINDLE_SERVER_DATA_DIR=${cfg.server.stateDir}"
127
161
"SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}"
128
162
"SPINDLE_SERVER_PLC_URL=${cfg.server.plcUrl}"
129
-
"SPINDLE_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}"
130
163
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
131
164
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
132
165
"SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}"
···
134
167
"SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}"
135
168
"SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}"
136
169
"SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}"
170
+
"SPINDLE_SERVER_TAP_URL=http://localhost:2480"
137
171
"SPINDLE_NIXERY_PIPELINES_NIXERY=${cfg.pipelines.nixery}"
138
172
"SPINDLE_NIXERY_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}"
139
173
];
+1
nix/pkgs/appview-static-files.nix
+1
nix/pkgs/appview-static-files.nix
···
26
26
cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/
27
27
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
28
28
cp -f ${actor-typeahead-src}/actor-typeahead.js .
29
+
cp -f ${src}/appview/pages/assets/* .
29
30
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
30
31
# for whatever reason (produces broken css), so we are doing this instead
31
32
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
+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 = "7fe70a304d795b998f354d2b7b2050b909709c99";
12
+
sha256 = "sha256-+h34x67cqH5t30+8rua53/ucvbn3BanrmH0Og3moHok=";
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
+
})
+41
nix/pkgs/docs.nix
+41
nix/pkgs/docs.nix
···
1
+
{
2
+
pandoc,
3
+
tailwindcss,
4
+
runCommandLocal,
5
+
inter-fonts-src,
6
+
ibm-plex-mono-src,
7
+
lucide-src,
8
+
src,
9
+
}:
10
+
runCommandLocal "docs" {} ''
11
+
mkdir -p working
12
+
13
+
# copy templates, themes, styles, filters to working directory
14
+
cp ${src}/docs/*.html working/
15
+
cp ${src}/docs/*.theme working/
16
+
cp ${src}/docs/*.css working/
17
+
18
+
# icons
19
+
cp -rf ${lucide-src}/*.svg working/
20
+
21
+
# content
22
+
${pandoc}/bin/pandoc ${src}/docs/DOCS.md \
23
+
-o $out/ \
24
+
-t chunkedhtml \
25
+
--variable toc \
26
+
--toc-depth=2 \
27
+
--css=stylesheet.css \
28
+
--chunk-template="%i.html" \
29
+
--highlight-style=working/highlight.theme \
30
+
--template=working/template.html
31
+
32
+
# fonts
33
+
mkdir -p $out/static/fonts
34
+
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 $out/static/fonts/
35
+
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 $out/static/fonts/
36
+
cp -f ${inter-fonts-src}/InterVariable*.ttf $out/static/fonts/
37
+
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 $out/static/fonts/
38
+
39
+
# styles
40
+
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/stylesheet.css
41
+
''
+7
-5
nix/pkgs/sqlite-lib.nix
+7
-5
nix/pkgs/sqlite-lib.nix
···
1
1
{
2
-
gcc,
3
2
stdenv,
4
3
sqlite-lib-src,
5
4
}:
6
5
stdenv.mkDerivation {
7
6
name = "sqlite-lib";
8
7
src = sqlite-lib-src;
9
-
nativeBuildInputs = [gcc];
8
+
10
9
buildPhase = ''
11
-
gcc -c sqlite3.c
12
-
ar rcs libsqlite3.a sqlite3.o
13
-
ranlib libsqlite3.a
10
+
$CC -c sqlite3.c
11
+
$AR rcs libsqlite3.a sqlite3.o
12
+
$RANLIB libsqlite3.a
13
+
'';
14
+
15
+
installPhase = ''
14
16
mkdir -p $out/include $out/lib
15
17
cp *.h $out/include
16
18
cp libsqlite3.a $out/lib
+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 = "498ecb9693e8ae050f73234c86f340f51ad896a9";
12
+
sha256 = "sha256-KASCdwkg/hlKBt7RTW3e3R5J3hqJkphoarFbaMgtN1k=";
13
+
};
14
+
subPackages = ["cmd/tap"];
15
+
vendorHash = "sha256-UOedwNYnM8Jx6B7Y9tFcZX8IeUBESAFAPTRYk7n0yo8=";
16
+
doCheck = false;
17
+
meta = {
18
+
mainProgram = "tap";
19
+
};
20
+
}
+133
-5
nix/vm.nix
+133
-5
nix/vm.nix
···
8
8
var = builtins.getEnv name;
9
9
in
10
10
if var == ""
11
-
then throw "\$${name} must be defined, see docs/hacking.md for more details"
11
+
then throw "\$${name} must be defined, see https://docs.tangled.org/hacking-on-tangled.html#hacking-on-tangled for more details"
12
12
else var;
13
13
envVarOr = name: default: let
14
14
var = builtins.getEnv name;
···
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;
25
26
modules = [
27
+
self.nixosModules.did-method-plc
28
+
self.nixosModules.bluesky-jetstream
29
+
self.nixosModules.bluesky-relay
26
30
self.nixosModules.knot
27
31
self.nixosModules.spindle
28
32
({
···
39
43
diskSize = 10 * 1024;
40
44
cores = 2;
41
45
forwardPorts = [
46
+
# caddy
47
+
{
48
+
from = "host";
49
+
host.port = 80;
50
+
guest.port = 80;
51
+
}
52
+
{
53
+
from = "host";
54
+
host.port = 443;
55
+
guest.port = 443;
56
+
}
57
+
{
58
+
from = "host";
59
+
proto = "udp";
60
+
host.port = 443;
61
+
guest.port = 443;
62
+
}
42
63
# ssh
43
64
{
44
65
from = "host";
···
57
78
host.port = 6555;
58
79
guest.port = 6555;
59
80
}
81
+
{
82
+
from = "host";
83
+
host.port = 6556;
84
+
guest.port = 2480;
85
+
}
60
86
];
61
87
sharedDirectories = {
62
88
# We can't use the 9p mounts directly for most of these
63
89
# as SQLite is incompatible with them. So instead we
64
90
# mount the shared directories to a different location
65
91
# and copy the contents around on service start/stop.
92
+
caddyData = {
93
+
source = "$TANGLED_VM_DATA_DIR/caddy";
94
+
target = config.services.caddy.dataDir;
95
+
};
66
96
knotData = {
67
97
source = "$TANGLED_VM_DATA_DIR/knot";
68
98
target = "/mnt/knot-data";
···
79
109
};
80
110
# This is fine because any and all ports that are forwarded to host are explicitly marked above, we don't need a separate guest firewall
81
111
networking.firewall.enable = false;
112
+
# resolve `*.tngl.boltless.dev` to host
113
+
services.dnsmasq.enable = true;
114
+
services.dnsmasq.settings.address = "/tngl.boltless.dev/10.0.2.2";
115
+
security.pki.certificates = [
116
+
(builtins.readFile ../contrib/certs/root.crt)
117
+
];
82
118
time.timeZone = "Europe/London";
119
+
services.timesyncd.enable = lib.mkVMOverride true;
83
120
services.getty.autologinUser = "root";
84
121
environment.systemPackages = with pkgs; [curl vim git sqlite litecli];
122
+
virtualisation.docker.extraOptions = ''
123
+
--dns 172.17.0.1
124
+
'';
85
125
services.tangled.knot = {
86
126
enable = true;
87
127
motd = "Welcome to the development knot!\n";
···
91
131
plcUrl = plcUrl;
92
132
jetstreamEndpoint = jetstream;
93
133
listenAddr = "0.0.0.0:6444";
134
+
dev = true;
94
135
};
95
-
environmentFile = "${config.services.tangled.knot.stateDir}/.env";
96
136
};
97
137
services.tangled.spindle = {
98
138
enable = true;
139
+
atpRelayUrl = relayUrl;
99
140
server = {
100
141
owner = envVar "TANGLED_VM_SPINDLE_OWNER";
101
142
hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555";
102
143
plcUrl = plcUrl;
103
-
jetstreamEndpoint = jetstream;
104
144
listenAddr = "0.0.0.0:6555";
105
-
dev = true;
145
+
dev = false;
106
146
queueSize = 100;
107
147
maxJobCount = 2;
108
148
secrets = {
···
110
150
};
111
151
};
112
152
};
153
+
services.did-method-plc.enable = true;
154
+
services.bluesky-pds = {
155
+
enable = true;
156
+
# overriding package version to support emails
157
+
package = pkgs.bluesky-pds.overrideAttrs (old: rec {
158
+
version = "0.4.188";
159
+
src = pkgs.fetchFromGitHub {
160
+
owner = "bluesky-social";
161
+
repo = "pds";
162
+
tag = "v${version}";
163
+
hash = "sha256-t8KdyEygXdbj/5Rhj8W40e1o8mXprELpjsKddHExmo0=";
164
+
};
165
+
pnpmDeps = pkgs.fetchPnpmDeps {
166
+
inherit version src;
167
+
pname = old.pname;
168
+
sourceRoot = old.sourceRoot;
169
+
fetcherVersion = 2;
170
+
hash = "sha256-lQie7f8JbWKSpoavnMjHegBzH3GB9teXsn+S2SLJHHU=";
171
+
};
172
+
});
173
+
settings = {
174
+
LOG_ENABLED = "true";
175
+
176
+
PDS_JWT_SECRET = "8cae8bffcc73d9932819650791e4e89a";
177
+
PDS_ADMIN_PASSWORD = "d6a902588cd93bee1af83f924f60cfd3";
178
+
PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX = "2e92e336a50a618458e1097d94a1db86ec3fd8829d7735020cbae80625c761d7";
179
+
180
+
PDS_EMAIL_SMTP_URL = envVarOr "TANGLED_VM_PDS_EMAIL_SMTP_URL" null;
181
+
PDS_EMAIL_FROM_ADDRESS = envVarOr "TANGLED_VM_PDS_EMAIL_FROM_ADDRESS" null;
182
+
183
+
PDS_DID_PLC_URL = "http://localhost:8080";
184
+
PDS_CRAWLERS = "https://relay.tngl.boltless.dev";
185
+
PDS_HOSTNAME = "pds.tngl.boltless.dev";
186
+
PDS_PORT = 3000;
187
+
};
188
+
};
189
+
services.bluesky-relay = {
190
+
enable = true;
191
+
};
192
+
services.bluesky-jetstream = {
193
+
enable = true;
194
+
livenessTtl = 300;
195
+
websocketUrl = "ws://localhost:3000/xrpc/com.atproto.sync.subscribeRepos";
196
+
};
197
+
services.caddy = {
198
+
enable = true;
199
+
configFile = pkgs.writeText "Caddyfile" ''
200
+
{
201
+
debug
202
+
cert_lifetime 3601d
203
+
pki {
204
+
ca local {
205
+
intermediate_lifetime 3599d
206
+
}
207
+
}
208
+
}
209
+
210
+
plc.tngl.boltless.dev {
211
+
tls internal
212
+
reverse_proxy http://localhost:8080
213
+
}
214
+
215
+
*.pds.tngl.boltless.dev, pds.tngl.boltless.dev {
216
+
tls internal
217
+
reverse_proxy http://localhost:3000
218
+
}
219
+
220
+
jetstream.tngl.boltless.dev {
221
+
tls internal
222
+
reverse_proxy http://localhost:6008
223
+
}
224
+
225
+
relay.tngl.boltless.dev {
226
+
tls internal
227
+
reverse_proxy http://localhost:2470
228
+
}
229
+
230
+
knot.tngl.boltless.dev {
231
+
tls internal
232
+
reverse_proxy http://localhost:6444
233
+
}
234
+
235
+
spindle.tngl.boltless.dev {
236
+
tls internal
237
+
reverse_proxy http://localhost:6555
238
+
}
239
+
'';
240
+
};
113
241
users = {
114
242
# So we don't have to deal with permission clashing between
115
243
# blank disk VMs and existing state
···
135
263
};
136
264
in {
137
265
knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled.knot.stateDir;
138
-
spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled.spindle.server.dbPath);
266
+
spindle = mkDataSyncScripts "/mnt/spindle-data" config.services.tangled.spindle.server.stateDir;
139
267
};
140
268
})
141
269
];
+132
orm/orm.go
+132
orm/orm.go
···
1
+
package orm
2
+
3
+
import (
4
+
"context"
5
+
"database/sql"
6
+
"fmt"
7
+
"log/slog"
8
+
"reflect"
9
+
"strings"
10
+
)
11
+
12
+
type migrationFn = func(*sql.Tx) error
13
+
14
+
func RunMigration(c *sql.Conn, logger *slog.Logger, name string, migrationFn migrationFn) error {
15
+
logger = logger.With("migration", name)
16
+
17
+
tx, err := c.BeginTx(context.Background(), nil)
18
+
if err != nil {
19
+
return err
20
+
}
21
+
defer tx.Rollback()
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
+
33
+
var exists bool
34
+
err = tx.QueryRow("select exists (select 1 from migrations where name = ?)", name).Scan(&exists)
35
+
if err != nil {
36
+
return err
37
+
}
38
+
39
+
if !exists {
40
+
// run migration
41
+
err = migrationFn(tx)
42
+
if err != nil {
43
+
logger.Error("failed to run migration", "err", err)
44
+
return err
45
+
}
46
+
47
+
// mark migration as complete
48
+
_, err = tx.Exec("insert into migrations (name) values (?)", name)
49
+
if err != nil {
50
+
logger.Error("failed to mark migration as complete", "err", err)
51
+
return err
52
+
}
53
+
54
+
// commit the transaction
55
+
if err := tx.Commit(); err != nil {
56
+
return err
57
+
}
58
+
59
+
logger.Info("migration applied successfully")
60
+
} else {
61
+
logger.Warn("skipped migration, already applied")
62
+
}
63
+
64
+
return nil
65
+
}
66
+
67
+
type Filter struct {
68
+
Key string
69
+
arg any
70
+
Cmp string
71
+
}
72
+
73
+
func newFilter(key, cmp string, arg any) Filter {
74
+
return Filter{
75
+
Key: key,
76
+
arg: arg,
77
+
Cmp: cmp,
78
+
}
79
+
}
80
+
81
+
func FilterEq(key string, arg any) Filter { return newFilter(key, "=", arg) }
82
+
func FilterNotEq(key string, arg any) Filter { return newFilter(key, "<>", arg) }
83
+
func FilterGte(key string, arg any) Filter { return newFilter(key, ">=", arg) }
84
+
func FilterLte(key string, arg any) Filter { return newFilter(key, "<=", arg) }
85
+
func FilterIs(key string, arg any) Filter { return newFilter(key, "is", arg) }
86
+
func FilterIsNot(key string, arg any) Filter { return newFilter(key, "is not", arg) }
87
+
func FilterIn(key string, arg any) Filter { return newFilter(key, "in", arg) }
88
+
func FilterLike(key string, arg any) Filter { return newFilter(key, "like", arg) }
89
+
func FilterNotLike(key string, arg any) Filter { return newFilter(key, "not like", arg) }
90
+
func FilterContains(key string, arg any) Filter {
91
+
return newFilter(key, "like", fmt.Sprintf("%%%v%%", arg))
92
+
}
93
+
94
+
func (f Filter) Condition() string {
95
+
rv := reflect.ValueOf(f.arg)
96
+
kind := rv.Kind()
97
+
98
+
// if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)`
99
+
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
100
+
if rv.Len() == 0 {
101
+
// always false
102
+
return "1 = 0"
103
+
}
104
+
105
+
placeholders := make([]string, rv.Len())
106
+
for i := range placeholders {
107
+
placeholders[i] = "?"
108
+
}
109
+
110
+
return fmt.Sprintf("%s %s (%s)", f.Key, f.Cmp, strings.Join(placeholders, ", "))
111
+
}
112
+
113
+
return fmt.Sprintf("%s %s ?", f.Key, f.Cmp)
114
+
}
115
+
116
+
func (f Filter) Arg() []any {
117
+
rv := reflect.ValueOf(f.arg)
118
+
kind := rv.Kind()
119
+
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
120
+
if rv.Len() == 0 {
121
+
return nil
122
+
}
123
+
124
+
out := make([]any, rv.Len())
125
+
for i := range rv.Len() {
126
+
out[i] = rv.Index(i).Interface()
127
+
}
128
+
return out
129
+
}
130
+
131
+
return []any{f.arg}
132
+
}
+52
rbac2/bytesadapter/adapter.go
+52
rbac2/bytesadapter/adapter.go
···
1
+
package bytesadapter
2
+
3
+
import (
4
+
"bufio"
5
+
"bytes"
6
+
"errors"
7
+
"strings"
8
+
9
+
"github.com/casbin/casbin/v2/model"
10
+
"github.com/casbin/casbin/v2/persist"
11
+
)
12
+
13
+
var (
14
+
errNotImplemented = errors.New("not implemented")
15
+
)
16
+
17
+
type Adapter struct {
18
+
b []byte
19
+
}
20
+
21
+
var _ persist.Adapter = &Adapter{}
22
+
23
+
func NewAdapter(b []byte) *Adapter {
24
+
return &Adapter{b}
25
+
}
26
+
27
+
func (a *Adapter) LoadPolicy(model model.Model) error {
28
+
scanner := bufio.NewScanner(bytes.NewReader(a.b))
29
+
for scanner.Scan() {
30
+
line := strings.TrimSpace(scanner.Text())
31
+
if err := persist.LoadPolicyLine(line, model); err != nil {
32
+
return err
33
+
}
34
+
}
35
+
return scanner.Err()
36
+
}
37
+
38
+
func (a *Adapter) AddPolicy(sec string, ptype string, rule []string) error {
39
+
return errNotImplemented
40
+
}
41
+
42
+
func (a *Adapter) RemoveFilteredPolicy(sec string, ptype string, fieldIndex int, fieldValues ...string) error {
43
+
return errNotImplemented
44
+
}
45
+
46
+
func (a *Adapter) RemovePolicy(sec string, ptype string, rule []string) error {
47
+
return errNotImplemented
48
+
}
49
+
50
+
func (a *Adapter) SavePolicy(model model.Model) error {
51
+
return errNotImplemented
52
+
}
+139
rbac2/rbac2.go
+139
rbac2/rbac2.go
···
1
+
package rbac2
2
+
3
+
import (
4
+
"database/sql"
5
+
_ "embed"
6
+
"fmt"
7
+
8
+
adapter "github.com/Blank-Xu/sql-adapter"
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
"github.com/casbin/casbin/v2"
11
+
"github.com/casbin/casbin/v2/model"
12
+
"github.com/casbin/casbin/v2/util"
13
+
"tangled.org/core/rbac2/bytesadapter"
14
+
)
15
+
16
+
const (
17
+
Model = `
18
+
[request_definition]
19
+
r = sub, dom, obj, act
20
+
21
+
[policy_definition]
22
+
p = sub, dom, obj, act
23
+
24
+
[role_definition]
25
+
g = _, _, _
26
+
27
+
[policy_effect]
28
+
e = some(where (p.eft == allow))
29
+
30
+
[matchers]
31
+
m = g(r.sub, p.sub, r.dom) && keyMatch4(r.dom, p.dom) && r.obj == p.obj && r.act == p.act
32
+
`
33
+
)
34
+
35
+
type Enforcer struct {
36
+
e *casbin.Enforcer
37
+
}
38
+
39
+
//go:embed tangled_policy.csv
40
+
var tangledPolicy []byte
41
+
42
+
func NewEnforcer(path string) (*Enforcer, error) {
43
+
db, err := sql.Open("sqlite3", path+"?_foreign_keys=1")
44
+
if err != nil {
45
+
return nil, err
46
+
}
47
+
return NewEnforcerWithDB(db)
48
+
}
49
+
50
+
func NewEnforcerWithDB(db *sql.DB) (*Enforcer, error) {
51
+
m, err := model.NewModelFromString(Model)
52
+
if err != nil {
53
+
return nil, err
54
+
}
55
+
56
+
a, err := adapter.NewAdapter(db, "sqlite3", "acl")
57
+
if err != nil {
58
+
return nil, err
59
+
}
60
+
61
+
// // PATCH: create unique index to make `AddPoliciesEx` work
62
+
// _, err = db.Exec(fmt.Sprintf(
63
+
// `create unique index if not exists uq_%[1]s on %[1]s (p_type,v0,v1,v2,v3,v4,v5);`,
64
+
// tableName,
65
+
// ))
66
+
// if err != nil {
67
+
// return nil, err
68
+
// }
69
+
70
+
e, _ := casbin.NewEnforcer() // NewEnforcer() without param won't return error
71
+
// e.EnableLog(true)
72
+
73
+
// NOTE: casbin clears the model on init, so we should intialize with temporary adapter first
74
+
// and then override the adapter to sql-adapter.
75
+
// `e.SetModel(m)` after init doesn't work for some reason
76
+
if err := e.InitWithModelAndAdapter(m, bytesadapter.NewAdapter(tangledPolicy)); err != nil {
77
+
return nil, err
78
+
}
79
+
80
+
// load dynamic policy from db
81
+
e.EnableAutoSave(false)
82
+
if err := a.LoadPolicy(e.GetModel()); err != nil {
83
+
return nil, err
84
+
}
85
+
e.AddNamedDomainMatchingFunc("g", "keyMatch4", util.KeyMatch4)
86
+
e.BuildRoleLinks()
87
+
e.SetAdapter(a)
88
+
e.EnableAutoSave(true)
89
+
90
+
return &Enforcer{e}, nil
91
+
}
92
+
93
+
// CaptureModel returns copy of current model. Used for testing
94
+
func (e *Enforcer) CaptureModel() model.Model {
95
+
return e.e.GetModel().Copy()
96
+
}
97
+
98
+
func (e *Enforcer) hasImplicitRoleForUser(name string, role string, domain ...string) (bool, error) {
99
+
roles, err := e.e.GetImplicitRolesForUser(name, domain...)
100
+
if err != nil {
101
+
return false, err
102
+
}
103
+
for _, r := range roles {
104
+
if r == role {
105
+
return true, nil
106
+
}
107
+
}
108
+
return false, nil
109
+
}
110
+
111
+
// setRoleForUser sets single user role for specified domain.
112
+
// All existing users with that role will be removed.
113
+
func (e *Enforcer) setRoleForUser(name string, role string, domain ...string) error {
114
+
currentUsers, err := e.e.GetUsersForRole(role, domain...)
115
+
if err != nil {
116
+
return err
117
+
}
118
+
119
+
for _, oldUser := range currentUsers {
120
+
_, err = e.e.DeleteRoleForUser(oldUser, role, domain...)
121
+
if err != nil {
122
+
return err
123
+
}
124
+
}
125
+
126
+
_, err = e.e.AddRoleForUser(name, role, domain...)
127
+
return err
128
+
}
129
+
130
+
// validateAtUri enforeces AT-URI to have valid did as authority and match collection NSID.
131
+
func validateAtUri(uri syntax.ATURI, expected string) error {
132
+
if !uri.Authority().IsDID() {
133
+
return fmt.Errorf("expected at-uri with did")
134
+
}
135
+
if expected != "" && uri.Collection().String() != expected {
136
+
return fmt.Errorf("incorrect repo at-uri collection nsid '%s' (expected '%s')", uri.Collection(), expected)
137
+
}
138
+
return nil
139
+
}
+150
rbac2/rbac2_test.go
+150
rbac2/rbac2_test.go
···
1
+
package rbac2_test
2
+
3
+
import (
4
+
"database/sql"
5
+
"testing"
6
+
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
8
+
_ "github.com/mattn/go-sqlite3"
9
+
"github.com/stretchr/testify/assert"
10
+
"tangled.org/core/rbac2"
11
+
)
12
+
13
+
func setup(t *testing.T) *rbac2.Enforcer {
14
+
enforcer, err := rbac2.NewEnforcer(":memory:")
15
+
assert.NoError(t, err)
16
+
17
+
return enforcer
18
+
}
19
+
20
+
func TestNewEnforcer(t *testing.T) {
21
+
db, err := sql.Open("sqlite3", "/tmp/test/test.db?_foreign_keys=1")
22
+
assert.NoError(t, err)
23
+
24
+
enforcer1, err := rbac2.NewEnforcerWithDB(db)
25
+
assert.NoError(t, err)
26
+
enforcer1.AddRepo(syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey"))
27
+
model1 := enforcer1.CaptureModel()
28
+
29
+
enforcer2, err := rbac2.NewEnforcerWithDB(db)
30
+
assert.NoError(t, err)
31
+
model2 := enforcer2.CaptureModel()
32
+
33
+
// model1.GetLogger().EnableLog(true)
34
+
// model1.PrintModel()
35
+
// model1.PrintPolicy()
36
+
// model1.GetLogger().EnableLog(false)
37
+
38
+
model2.GetLogger().EnableLog(true)
39
+
model2.PrintModel()
40
+
model2.PrintPolicy()
41
+
model2.GetLogger().EnableLog(false)
42
+
43
+
assert.Equal(t, model1, model2)
44
+
}
45
+
46
+
func TestRepoOwnerPermissions(t *testing.T) {
47
+
var (
48
+
e = setup(t)
49
+
ok bool
50
+
err error
51
+
fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey")
52
+
fooUser = syntax.DID("did:plc:foo")
53
+
)
54
+
55
+
assert.NoError(t, e.AddRepo(fooRepo))
56
+
57
+
ok, err = e.IsRepoOwner(fooUser, fooRepo)
58
+
assert.NoError(t, err)
59
+
assert.True(t, ok, "repo author should be repo owner")
60
+
61
+
ok, err = e.IsRepoWriteAllowed(fooUser, fooRepo)
62
+
assert.NoError(t, err)
63
+
assert.True(t, ok, "repo owner should be able to modify the repo itself")
64
+
65
+
ok, err = e.IsRepoCollaborator(fooUser, fooRepo)
66
+
assert.NoError(t, err)
67
+
assert.True(t, ok, "repo owner should inherit role role:collaborator")
68
+
69
+
ok, err = e.IsRepoSettingsWriteAllowed(fooUser, fooRepo)
70
+
assert.NoError(t, err)
71
+
assert.True(t, ok, "repo owner should inherit collaborator permissions")
72
+
}
73
+
74
+
func TestRepoCollaboratorPermissions(t *testing.T) {
75
+
var (
76
+
e = setup(t)
77
+
ok bool
78
+
err error
79
+
fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey")
80
+
barUser = syntax.DID("did:plc:bar")
81
+
)
82
+
83
+
assert.NoError(t, e.AddRepo(fooRepo))
84
+
assert.NoError(t, e.AddRepoCollaborator(barUser, fooRepo))
85
+
86
+
ok, err = e.IsRepoCollaborator(barUser, fooRepo)
87
+
assert.NoError(t, err)
88
+
assert.True(t, ok, "should set repo collaborator")
89
+
90
+
ok, err = e.IsRepoSettingsWriteAllowed(barUser, fooRepo)
91
+
assert.NoError(t, err)
92
+
assert.True(t, ok, "repo collaborator should be able to edit repo settings")
93
+
94
+
ok, err = e.IsRepoWriteAllowed(barUser, fooRepo)
95
+
assert.NoError(t, err)
96
+
assert.False(t, ok, "repo collaborator shouldn't be able to modify the repo itself")
97
+
}
98
+
99
+
func TestGetByRole(t *testing.T) {
100
+
var (
101
+
e = setup(t)
102
+
err error
103
+
fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey")
104
+
owner = syntax.DID("did:plc:foo")
105
+
collaborator1 = syntax.DID("did:plc:bar")
106
+
collaborator2 = syntax.DID("did:plc:baz")
107
+
)
108
+
109
+
assert.NoError(t, e.AddRepo(fooRepo))
110
+
assert.NoError(t, e.AddRepoCollaborator(collaborator1, fooRepo))
111
+
assert.NoError(t, e.AddRepoCollaborator(collaborator2, fooRepo))
112
+
113
+
collaborators, err := e.GetRepoCollaborators(fooRepo)
114
+
assert.NoError(t, err)
115
+
assert.ElementsMatch(t, []syntax.DID{
116
+
owner,
117
+
collaborator1,
118
+
collaborator2,
119
+
}, collaborators)
120
+
}
121
+
122
+
func TestSpindleOwnerPermissions(t *testing.T) {
123
+
var (
124
+
e = setup(t)
125
+
ok bool
126
+
err error
127
+
spindle = syntax.DID("did:web:spindle.example.com")
128
+
owner = syntax.DID("did:plc:foo")
129
+
member = syntax.DID("did:plc:bar")
130
+
)
131
+
132
+
assert.NoError(t, e.SetSpindleOwner(owner, spindle))
133
+
assert.NoError(t, e.AddSpindleMember(member, spindle))
134
+
135
+
ok, err = e.IsSpindleMember(owner, spindle)
136
+
assert.NoError(t, err)
137
+
assert.True(t, ok, "spindle owner is spindle member")
138
+
139
+
ok, err = e.IsSpindleMember(member, spindle)
140
+
assert.NoError(t, err)
141
+
assert.True(t, ok, "spindle member is spindle member")
142
+
143
+
ok, err = e.IsSpindleMemberInviteAllowed(owner, spindle)
144
+
assert.NoError(t, err)
145
+
assert.True(t, ok, "spindle owner can invite members")
146
+
147
+
ok, err = e.IsSpindleMemberInviteAllowed(member, spindle)
148
+
assert.NoError(t, err)
149
+
assert.False(t, ok, "spindle member cannot invite members")
150
+
}
+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.hasImplicitRoleForUser(user.String(), "server:member", intoSpindle(spindle))
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
+
}
+19
rbac2/tangled_policy.csv
+19
rbac2/tangled_policy.csv
···
1
+
#, policies
2
+
#, sub, dom, obj, act
3
+
p, repo:owner, at://{did}/sh.tangled.repo/{rkey}, /, write
4
+
p, repo:owner, at://{did}/sh.tangled.repo/{rkey}, /collaborator, write
5
+
p, repo:collaborator, at://{did}/sh.tangled.repo/{rkey}, /settings, write
6
+
p, repo:collaborator, at://{did}/sh.tangled.repo/{rkey}, /git, write
7
+
8
+
p, server:owner, /knot/{did}, /member, write
9
+
p, server:member, /knot/{did}, /git, write
10
+
11
+
p, server:owner, /spindle/{did}, /member, write
12
+
13
+
14
+
#, group policies
15
+
#, sub, role, dom
16
+
g, repo:owner, repo:collaborator, at://{did}/sh.tangled.repo/{rkey}
17
+
18
+
g, server:owner, server:member, /knot/{did}
19
+
g, server:owner, server:member, /spindle/{did}
+3
-3
readme.md
+3
-3
readme.md
···
10
10
11
11
## docs
12
12
13
-
* [knot hosting guide](/docs/knot-hosting.md)
14
-
* [contributing guide](/docs/contributing.md) **please read before opening a PR!**
15
-
* [hacking on tangled](/docs/hacking.md)
13
+
- [knot hosting guide](https://docs.tangled.org/knot-self-hosting-guide.html#knot-self-hosting-guide)
14
+
- [contributing guide](https://docs.tangled.org/contribution-guide.html#contribution-guide) **please read before opening a PR!**
15
+
- [hacking on tangled](https://docs.tangled.org/hacking-on-tangled.html#hacking-on-tangled)
16
16
17
17
## security
18
18
+31
sets/gen.go
+31
sets/gen.go
···
1
+
package sets
2
+
3
+
import (
4
+
"math/rand"
5
+
"reflect"
6
+
"testing/quick"
7
+
)
8
+
9
+
func (_ Set[T]) Generate(rand *rand.Rand, size int) reflect.Value {
10
+
s := New[T]()
11
+
12
+
var zero T
13
+
itemType := reflect.TypeOf(zero)
14
+
15
+
for {
16
+
if s.Len() >= size {
17
+
break
18
+
}
19
+
20
+
item, ok := quick.Value(itemType, rand)
21
+
if !ok {
22
+
continue
23
+
}
24
+
25
+
if val, ok := item.Interface().(T); ok {
26
+
s.Insert(val)
27
+
}
28
+
}
29
+
30
+
return reflect.ValueOf(s)
31
+
}
+35
sets/readme.txt
+35
sets/readme.txt
···
1
+
sets
2
+
----
3
+
set datastructure for go with generics and iterators. the
4
+
api is supposed to mimic rust's std::collections::HashSet api.
5
+
6
+
s1 := sets.Collect(slices.Values([]int{1, 2, 3, 4}))
7
+
s2 := sets.Collect(slices.Values([]int{1, 2, 3, 4, 5, 6}))
8
+
9
+
union := sets.Collect(s1.Union(s2))
10
+
intersect := sets.Collect(s1.Intersection(s2))
11
+
diff := sets.Collect(s1.Difference(s2))
12
+
symdiff := sets.Collect(s1.SymmetricDifference(s2))
13
+
14
+
s1.Len() // 4
15
+
s1.Contains(1) // true
16
+
s1.IsEmpty() // false
17
+
s1.IsSubset(s2) // true
18
+
s1.IsSuperset(s2) // false
19
+
s1.IsDisjoint(s2) // false
20
+
21
+
if exists := s1.Insert(1); exists {
22
+
// already existed in set
23
+
}
24
+
25
+
if existed := s1.Remove(1); existed {
26
+
// existed in set, now removed
27
+
}
28
+
29
+
30
+
testing
31
+
-------
32
+
includes property-based tests using the wonderful
33
+
testing/quick module!
34
+
35
+
go test -v
+174
sets/set.go
+174
sets/set.go
···
1
+
package sets
2
+
3
+
import (
4
+
"iter"
5
+
"maps"
6
+
)
7
+
8
+
type Set[T comparable] struct {
9
+
data map[T]struct{}
10
+
}
11
+
12
+
func New[T comparable]() Set[T] {
13
+
return Set[T]{
14
+
data: make(map[T]struct{}),
15
+
}
16
+
}
17
+
18
+
func (s *Set[T]) Insert(item T) bool {
19
+
_, exists := s.data[item]
20
+
s.data[item] = struct{}{}
21
+
return !exists
22
+
}
23
+
24
+
func Singleton[T comparable](item T) Set[T] {
25
+
n := New[T]()
26
+
_ = n.Insert(item)
27
+
return n
28
+
}
29
+
30
+
func (s *Set[T]) Remove(item T) bool {
31
+
_, exists := s.data[item]
32
+
if exists {
33
+
delete(s.data, item)
34
+
}
35
+
return exists
36
+
}
37
+
38
+
func (s Set[T]) Contains(item T) bool {
39
+
_, exists := s.data[item]
40
+
return exists
41
+
}
42
+
43
+
func (s Set[T]) Len() int {
44
+
return len(s.data)
45
+
}
46
+
47
+
func (s Set[T]) IsEmpty() bool {
48
+
return len(s.data) == 0
49
+
}
50
+
51
+
func (s *Set[T]) Clear() {
52
+
s.data = make(map[T]struct{})
53
+
}
54
+
55
+
func (s Set[T]) All() iter.Seq[T] {
56
+
return func(yield func(T) bool) {
57
+
for item := range s.data {
58
+
if !yield(item) {
59
+
return
60
+
}
61
+
}
62
+
}
63
+
}
64
+
65
+
func (s Set[T]) Clone() Set[T] {
66
+
return Set[T]{
67
+
data: maps.Clone(s.data),
68
+
}
69
+
}
70
+
71
+
func (s Set[T]) Union(other Set[T]) iter.Seq[T] {
72
+
if s.Len() >= other.Len() {
73
+
return chain(s.All(), other.Difference(s))
74
+
} else {
75
+
return chain(other.All(), s.Difference(other))
76
+
}
77
+
}
78
+
79
+
func chain[T any](seqs ...iter.Seq[T]) iter.Seq[T] {
80
+
return func(yield func(T) bool) {
81
+
for _, seq := range seqs {
82
+
for item := range seq {
83
+
if !yield(item) {
84
+
return
85
+
}
86
+
}
87
+
}
88
+
}
89
+
}
90
+
91
+
func (s Set[T]) Intersection(other Set[T]) iter.Seq[T] {
92
+
return func(yield func(T) bool) {
93
+
for item := range s.data {
94
+
if other.Contains(item) {
95
+
if !yield(item) {
96
+
return
97
+
}
98
+
}
99
+
}
100
+
}
101
+
}
102
+
103
+
func (s Set[T]) Difference(other Set[T]) iter.Seq[T] {
104
+
return func(yield func(T) bool) {
105
+
for item := range s.data {
106
+
if !other.Contains(item) {
107
+
if !yield(item) {
108
+
return
109
+
}
110
+
}
111
+
}
112
+
}
113
+
}
114
+
115
+
func (s Set[T]) SymmetricDifference(other Set[T]) iter.Seq[T] {
116
+
return func(yield func(T) bool) {
117
+
for item := range s.data {
118
+
if !other.Contains(item) {
119
+
if !yield(item) {
120
+
return
121
+
}
122
+
}
123
+
}
124
+
for item := range other.data {
125
+
if !s.Contains(item) {
126
+
if !yield(item) {
127
+
return
128
+
}
129
+
}
130
+
}
131
+
}
132
+
}
133
+
134
+
func (s Set[T]) IsSubset(other Set[T]) bool {
135
+
for item := range s.data {
136
+
if !other.Contains(item) {
137
+
return false
138
+
}
139
+
}
140
+
return true
141
+
}
142
+
143
+
func (s Set[T]) IsSuperset(other Set[T]) bool {
144
+
return other.IsSubset(s)
145
+
}
146
+
147
+
func (s Set[T]) IsDisjoint(other Set[T]) bool {
148
+
for item := range s.data {
149
+
if other.Contains(item) {
150
+
return false
151
+
}
152
+
}
153
+
return true
154
+
}
155
+
156
+
func (s Set[T]) Equal(other Set[T]) bool {
157
+
if s.Len() != other.Len() {
158
+
return false
159
+
}
160
+
for item := range s.data {
161
+
if !other.Contains(item) {
162
+
return false
163
+
}
164
+
}
165
+
return true
166
+
}
167
+
168
+
func Collect[T comparable](seq iter.Seq[T]) Set[T] {
169
+
result := New[T]()
170
+
for item := range seq {
171
+
result.Insert(item)
172
+
}
173
+
return result
174
+
}
+411
sets/set_test.go
+411
sets/set_test.go
···
1
+
package sets
2
+
3
+
import (
4
+
"slices"
5
+
"testing"
6
+
"testing/quick"
7
+
)
8
+
9
+
func TestNew(t *testing.T) {
10
+
s := New[int]()
11
+
if s.Len() != 0 {
12
+
t.Errorf("New set should be empty, got length %d", s.Len())
13
+
}
14
+
if !s.IsEmpty() {
15
+
t.Error("New set should be empty")
16
+
}
17
+
}
18
+
19
+
func TestFromSlice(t *testing.T) {
20
+
s := Collect(slices.Values([]int{1, 2, 3, 2, 1}))
21
+
if s.Len() != 3 {
22
+
t.Errorf("Expected length 3, got %d", s.Len())
23
+
}
24
+
if !s.Contains(1) || !s.Contains(2) || !s.Contains(3) {
25
+
t.Error("Set should contain all unique elements from slice")
26
+
}
27
+
}
28
+
29
+
func TestInsert(t *testing.T) {
30
+
s := New[string]()
31
+
32
+
if !s.Insert("hello") {
33
+
t.Error("First insert should return true")
34
+
}
35
+
if s.Insert("hello") {
36
+
t.Error("Duplicate insert should return false")
37
+
}
38
+
if s.Len() != 1 {
39
+
t.Errorf("Expected length 1, got %d", s.Len())
40
+
}
41
+
}
42
+
43
+
func TestRemove(t *testing.T) {
44
+
s := Collect(slices.Values([]int{1, 2, 3}))
45
+
46
+
if !s.Remove(2) {
47
+
t.Error("Remove existing element should return true")
48
+
}
49
+
if s.Remove(2) {
50
+
t.Error("Remove non-existing element should return false")
51
+
}
52
+
if s.Contains(2) {
53
+
t.Error("Element should be removed")
54
+
}
55
+
if s.Len() != 2 {
56
+
t.Errorf("Expected length 2, got %d", s.Len())
57
+
}
58
+
}
59
+
60
+
func TestContains(t *testing.T) {
61
+
s := Collect(slices.Values([]int{1, 2, 3}))
62
+
63
+
if !s.Contains(1) {
64
+
t.Error("Should contain 1")
65
+
}
66
+
if s.Contains(4) {
67
+
t.Error("Should not contain 4")
68
+
}
69
+
}
70
+
71
+
func TestClear(t *testing.T) {
72
+
s := Collect(slices.Values([]int{1, 2, 3}))
73
+
s.Clear()
74
+
75
+
if !s.IsEmpty() {
76
+
t.Error("Set should be empty after clear")
77
+
}
78
+
if s.Len() != 0 {
79
+
t.Errorf("Expected length 0, got %d", s.Len())
80
+
}
81
+
}
82
+
83
+
func TestIterator(t *testing.T) {
84
+
s := Collect(slices.Values([]int{1, 2, 3}))
85
+
var items []int
86
+
87
+
for item := range s.All() {
88
+
items = append(items, item)
89
+
}
90
+
91
+
slices.Sort(items)
92
+
expected := []int{1, 2, 3}
93
+
if !slices.Equal(items, expected) {
94
+
t.Errorf("Expected %v, got %v", expected, items)
95
+
}
96
+
}
97
+
98
+
func TestClone(t *testing.T) {
99
+
s1 := Collect(slices.Values([]int{1, 2, 3}))
100
+
s2 := s1.Clone()
101
+
102
+
if !s1.Equal(s2) {
103
+
t.Error("Cloned set should be equal to original")
104
+
}
105
+
106
+
s2.Insert(4)
107
+
if s1.Contains(4) {
108
+
t.Error("Modifying clone should not affect original")
109
+
}
110
+
}
111
+
112
+
func TestUnion(t *testing.T) {
113
+
s1 := Collect(slices.Values([]int{1, 2}))
114
+
s2 := Collect(slices.Values([]int{2, 3}))
115
+
116
+
result := Collect(s1.Union(s2))
117
+
expected := Collect(slices.Values([]int{1, 2, 3}))
118
+
119
+
if !result.Equal(expected) {
120
+
t.Errorf("Expected %v, got %v", expected, result)
121
+
}
122
+
}
123
+
124
+
func TestIntersection(t *testing.T) {
125
+
s1 := Collect(slices.Values([]int{1, 2, 3}))
126
+
s2 := Collect(slices.Values([]int{2, 3, 4}))
127
+
128
+
expected := Collect(slices.Values([]int{2, 3}))
129
+
result := Collect(s1.Intersection(s2))
130
+
131
+
if !result.Equal(expected) {
132
+
t.Errorf("Expected %v, got %v", expected, result)
133
+
}
134
+
}
135
+
136
+
func TestDifference(t *testing.T) {
137
+
s1 := Collect(slices.Values([]int{1, 2, 3}))
138
+
s2 := Collect(slices.Values([]int{2, 3, 4}))
139
+
140
+
expected := Collect(slices.Values([]int{1}))
141
+
result := Collect(s1.Difference(s2))
142
+
143
+
if !result.Equal(expected) {
144
+
t.Errorf("Expected %v, got %v", expected, result)
145
+
}
146
+
}
147
+
148
+
func TestSymmetricDifference(t *testing.T) {
149
+
s1 := Collect(slices.Values([]int{1, 2, 3}))
150
+
s2 := Collect(slices.Values([]int{2, 3, 4}))
151
+
152
+
expected := Collect(slices.Values([]int{1, 4}))
153
+
result := Collect(s1.SymmetricDifference(s2))
154
+
155
+
if !result.Equal(expected) {
156
+
t.Errorf("Expected %v, got %v", expected, result)
157
+
}
158
+
}
159
+
160
+
func TestSymmetricDifferenceCommutativeProperty(t *testing.T) {
161
+
s1 := Collect(slices.Values([]int{1, 2, 3}))
162
+
s2 := Collect(slices.Values([]int{2, 3, 4}))
163
+
164
+
result1 := Collect(s1.SymmetricDifference(s2))
165
+
result2 := Collect(s2.SymmetricDifference(s1))
166
+
167
+
if !result1.Equal(result2) {
168
+
t.Errorf("Expected %v, got %v", result1, result2)
169
+
}
170
+
}
171
+
172
+
func TestIsSubset(t *testing.T) {
173
+
s1 := Collect(slices.Values([]int{1, 2}))
174
+
s2 := Collect(slices.Values([]int{1, 2, 3}))
175
+
176
+
if !s1.IsSubset(s2) {
177
+
t.Error("s1 should be subset of s2")
178
+
}
179
+
if s2.IsSubset(s1) {
180
+
t.Error("s2 should not be subset of s1")
181
+
}
182
+
}
183
+
184
+
func TestIsSuperset(t *testing.T) {
185
+
s1 := Collect(slices.Values([]int{1, 2, 3}))
186
+
s2 := Collect(slices.Values([]int{1, 2}))
187
+
188
+
if !s1.IsSuperset(s2) {
189
+
t.Error("s1 should be superset of s2")
190
+
}
191
+
if s2.IsSuperset(s1) {
192
+
t.Error("s2 should not be superset of s1")
193
+
}
194
+
}
195
+
196
+
func TestIsDisjoint(t *testing.T) {
197
+
s1 := Collect(slices.Values([]int{1, 2}))
198
+
s2 := Collect(slices.Values([]int{3, 4}))
199
+
s3 := Collect(slices.Values([]int{2, 3}))
200
+
201
+
if !s1.IsDisjoint(s2) {
202
+
t.Error("s1 and s2 should be disjoint")
203
+
}
204
+
if s1.IsDisjoint(s3) {
205
+
t.Error("s1 and s3 should not be disjoint")
206
+
}
207
+
}
208
+
209
+
func TestEqual(t *testing.T) {
210
+
s1 := Collect(slices.Values([]int{1, 2, 3}))
211
+
s2 := Collect(slices.Values([]int{3, 2, 1}))
212
+
s3 := Collect(slices.Values([]int{1, 2}))
213
+
214
+
if !s1.Equal(s2) {
215
+
t.Error("s1 and s2 should be equal")
216
+
}
217
+
if s1.Equal(s3) {
218
+
t.Error("s1 and s3 should not be equal")
219
+
}
220
+
}
221
+
222
+
func TestCollect(t *testing.T) {
223
+
s1 := Collect(slices.Values([]int{1, 2}))
224
+
s2 := Collect(slices.Values([]int{2, 3}))
225
+
226
+
unionSet := Collect(s1.Union(s2))
227
+
if unionSet.Len() != 3 {
228
+
t.Errorf("Expected union set length 3, got %d", unionSet.Len())
229
+
}
230
+
if !unionSet.Contains(1) || !unionSet.Contains(2) || !unionSet.Contains(3) {
231
+
t.Error("Union set should contain 1, 2, and 3")
232
+
}
233
+
234
+
diffSet := Collect(s1.Difference(s2))
235
+
if diffSet.Len() != 1 {
236
+
t.Errorf("Expected difference set length 1, got %d", diffSet.Len())
237
+
}
238
+
if !diffSet.Contains(1) {
239
+
t.Error("Difference set should contain 1")
240
+
}
241
+
}
242
+
243
+
func TestPropertySingleonLen(t *testing.T) {
244
+
f := func(item int) bool {
245
+
single := Singleton(item)
246
+
return single.Len() == 1
247
+
}
248
+
249
+
if err := quick.Check(f, nil); err != nil {
250
+
t.Error(err)
251
+
}
252
+
}
253
+
254
+
func TestPropertyInsertIdempotent(t *testing.T) {
255
+
f := func(s Set[int], item int) bool {
256
+
clone := s.Clone()
257
+
258
+
clone.Insert(item)
259
+
firstLen := clone.Len()
260
+
261
+
clone.Insert(item)
262
+
secondLen := clone.Len()
263
+
264
+
return firstLen == secondLen
265
+
}
266
+
267
+
if err := quick.Check(f, nil); err != nil {
268
+
t.Error(err)
269
+
}
270
+
}
271
+
272
+
func TestPropertyUnionCommutative(t *testing.T) {
273
+
f := func(s1 Set[int], s2 Set[int]) bool {
274
+
union1 := Collect(s1.Union(s2))
275
+
union2 := Collect(s2.Union(s1))
276
+
return union1.Equal(union2)
277
+
}
278
+
279
+
if err := quick.Check(f, nil); err != nil {
280
+
t.Error(err)
281
+
}
282
+
}
283
+
284
+
func TestPropertyIntersectionCommutative(t *testing.T) {
285
+
f := func(s1 Set[int], s2 Set[int]) bool {
286
+
inter1 := Collect(s1.Intersection(s2))
287
+
inter2 := Collect(s2.Intersection(s1))
288
+
return inter1.Equal(inter2)
289
+
}
290
+
291
+
if err := quick.Check(f, nil); err != nil {
292
+
t.Error(err)
293
+
}
294
+
}
295
+
296
+
func TestPropertyCloneEquals(t *testing.T) {
297
+
f := func(s Set[int]) bool {
298
+
clone := s.Clone()
299
+
return s.Equal(clone)
300
+
}
301
+
302
+
if err := quick.Check(f, nil); err != nil {
303
+
t.Error(err)
304
+
}
305
+
}
306
+
307
+
func TestPropertyIntersectionIsSubset(t *testing.T) {
308
+
f := func(s1 Set[int], s2 Set[int]) bool {
309
+
inter := Collect(s1.Intersection(s2))
310
+
return inter.IsSubset(s1) && inter.IsSubset(s2)
311
+
}
312
+
313
+
if err := quick.Check(f, nil); err != nil {
314
+
t.Error(err)
315
+
}
316
+
}
317
+
318
+
func TestPropertyUnionIsSuperset(t *testing.T) {
319
+
f := func(s1 Set[int], s2 Set[int]) bool {
320
+
union := Collect(s1.Union(s2))
321
+
return union.IsSuperset(s1) && union.IsSuperset(s2)
322
+
}
323
+
324
+
if err := quick.Check(f, nil); err != nil {
325
+
t.Error(err)
326
+
}
327
+
}
328
+
329
+
func TestPropertyDifferenceDisjoint(t *testing.T) {
330
+
f := func(s1 Set[int], s2 Set[int]) bool {
331
+
diff := Collect(s1.Difference(s2))
332
+
return diff.IsDisjoint(s2)
333
+
}
334
+
335
+
if err := quick.Check(f, nil); err != nil {
336
+
t.Error(err)
337
+
}
338
+
}
339
+
340
+
func TestPropertySymmetricDifferenceCommutative(t *testing.T) {
341
+
f := func(s1 Set[int], s2 Set[int]) bool {
342
+
symDiff1 := Collect(s1.SymmetricDifference(s2))
343
+
symDiff2 := Collect(s2.SymmetricDifference(s1))
344
+
return symDiff1.Equal(symDiff2)
345
+
}
346
+
347
+
if err := quick.Check(f, nil); err != nil {
348
+
t.Error(err)
349
+
}
350
+
}
351
+
352
+
func TestPropertyRemoveWorks(t *testing.T) {
353
+
f := func(s Set[int], item int) bool {
354
+
clone := s.Clone()
355
+
clone.Insert(item)
356
+
clone.Remove(item)
357
+
return !clone.Contains(item)
358
+
}
359
+
360
+
if err := quick.Check(f, nil); err != nil {
361
+
t.Error(err)
362
+
}
363
+
}
364
+
365
+
func TestPropertyClearEmpty(t *testing.T) {
366
+
f := func(s Set[int]) bool {
367
+
s.Clear()
368
+
return s.IsEmpty() && s.Len() == 0
369
+
}
370
+
371
+
if err := quick.Check(f, nil); err != nil {
372
+
t.Error(err)
373
+
}
374
+
}
375
+
376
+
func TestPropertyIsSubsetReflexive(t *testing.T) {
377
+
f := func(s Set[int]) bool {
378
+
return s.IsSubset(s)
379
+
}
380
+
381
+
if err := quick.Check(f, nil); err != nil {
382
+
t.Error(err)
383
+
}
384
+
}
385
+
386
+
func TestPropertyDeMorganUnion(t *testing.T) {
387
+
f := func(s1 Set[int], s2 Set[int], universe Set[int]) bool {
388
+
// create a universe that contains both sets
389
+
u := universe.Clone()
390
+
for item := range s1.All() {
391
+
u.Insert(item)
392
+
}
393
+
for item := range s2.All() {
394
+
u.Insert(item)
395
+
}
396
+
397
+
// (A u B)' = A' n B'
398
+
union := Collect(s1.Union(s2))
399
+
complementUnion := Collect(u.Difference(union))
400
+
401
+
complementS1 := Collect(u.Difference(s1))
402
+
complementS2 := Collect(u.Difference(s2))
403
+
intersectionComplements := Collect(complementS1.Intersection(complementS2))
404
+
405
+
return complementUnion.Equal(intersectionComplements)
406
+
}
407
+
408
+
if err := quick.Check(f, nil); err != nil {
409
+
t.Error(err)
410
+
}
411
+
}
+20
-11
spindle/config/config.go
+20
-11
spindle/config/config.go
···
3
3
import (
4
4
"context"
5
5
"fmt"
6
+
"path/filepath"
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
+
Hostname string `env:"HOSTNAME, required"`
15
+
TapUrl string `env:"TAP_URL, required"`
16
+
PlcUrl string `env:"PLC_URL, default=https://plc.directory"`
17
+
Dev bool `env:"DEV, default=false"`
18
+
Owner syntax.DID `env:"OWNER, required"`
19
+
Secrets Secrets `env:",prefix=SECRETS_"`
20
+
LogDir string `env:"LOG_DIR, default=/var/log/spindle"`
21
+
DataDir string `env:"DATA_DIR, default=/var/lib/spindle"`
22
+
QueueSize int `env:"QUEUE_SIZE, default=100"`
23
+
MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of jobs that run at a time
23
24
}
24
25
25
26
func (s Server) Did() syntax.DID {
26
27
return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname))
28
+
}
29
+
30
+
func (s Server) RepoDir() string {
31
+
return filepath.Join(s.DataDir, "repos")
32
+
}
33
+
34
+
func (s Server) DBPath() string {
35
+
return filepath.Join(s.DataDir, "spindle.db")
27
36
}
28
37
29
38
type Secrets struct {
+73
-18
spindle/db/db.go
+73
-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",
···
20
24
"_auto_vacuum=incremental",
21
25
}
22
26
27
+
logger := log.FromContext(ctx)
28
+
logger = log.SubLogger(logger, "db")
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 (
···
49
58
unique(owner, name)
50
59
);
51
60
61
+
create table if not exists repo_collaborators (
62
+
-- identifiers
63
+
id integer primary key autoincrement,
64
+
did text not null,
65
+
rkey text not null,
66
+
at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.collaborator' || '/' || rkey) stored,
67
+
68
+
repo text not null,
69
+
subject text not null,
70
+
71
+
addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
72
+
unique(did, rkey)
73
+
);
74
+
52
75
create table if not exists spindle_members (
53
76
-- identifiers for the record
54
77
id integer primary key autoincrement,
···
76
99
return nil, err
77
100
}
78
101
102
+
// run migrations
103
+
104
+
// NOTE: this won't migrate existing records
105
+
// they will be fetched again with tap instead
106
+
orm.RunMigration(conn, logger, "add-rkey-to-repos", func(tx *sql.Tx) error {
107
+
// archive legacy repos (just in case)
108
+
_, err = tx.Exec(`alter table repos rename to repos_old`)
109
+
if err != nil {
110
+
return err
111
+
}
112
+
113
+
_, err := tx.Exec(`
114
+
create table repos (
115
+
-- identifiers
116
+
id integer primary key autoincrement,
117
+
did text not null,
118
+
rkey text not null,
119
+
at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo' || '/' || rkey) stored,
120
+
121
+
name text not null,
122
+
knot text not null,
123
+
124
+
addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
125
+
unique(did, rkey)
126
+
);
127
+
`)
128
+
if err != nil {
129
+
return err
130
+
}
131
+
132
+
return nil
133
+
})
134
+
79
135
return &DB{db}, nil
80
136
}
81
137
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
89
-
}
90
-
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
138
+
func (d *DB) IsKnownDid(did syntax.DID) (bool, error) {
139
+
// is spindle member / repo collaborator
140
+
var exists bool
141
+
err := d.QueryRow(
142
+
`select exists (
143
+
select 1 from repo_collaborators where subject = ?
144
+
union all
145
+
select 1 from spindle_members where did = ?
146
+
)`,
147
+
did,
148
+
did,
149
+
).Scan(&exists)
150
+
return exists, err
96
151
}
+10
-8
spindle/db/events.go
+10
-8
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)
73
+
func (d *DB) CreatePipelineEvent(rkey string, pipeline tangled.Pipeline, n *notifier.Notifier) error {
74
+
eventJson, err := json.Marshal(pipeline)
75
75
if err != nil {
76
76
return err
77
77
}
78
-
79
78
event := Event{
80
79
Rkey: rkey,
81
-
Nsid: tangled.PipelineStatusNSID,
80
+
Nsid: tangled.PipelineNSID,
82
81
Created: time.Now().UnixNano(),
83
82
EventJson: string(eventJson),
84
83
}
85
-
86
-
return d.InsertEvent(event, n)
84
+
return d.insertEvent(event, n)
87
85
}
88
86
89
87
func (d *DB) createStatusEvent(
···
116
114
EventJson: string(eventJson),
117
115
}
118
116
119
-
return d.InsertEvent(event, n)
117
+
return d.insertEvent(event, n)
120
118
121
119
}
122
120
···
164
162
165
163
func (d *DB) StatusFailed(workflowId models.WorkflowId, workflowError string, exitCode int64, n *notifier.Notifier) error {
166
164
return d.createStatusEvent(workflowId, models.StatusKindFailed, &workflowError, &exitCode, n)
165
+
}
166
+
167
+
func (d *DB) StatusCancelled(workflowId models.WorkflowId, workflowError string, exitCode int64, n *notifier.Notifier) error {
168
+
return d.createStatusEvent(workflowId, models.StatusKindCancelled, &workflowError, &exitCode, n)
167
169
}
168
170
169
171
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
···
16
45
if err != nil {
17
46
return nil, err
18
47
}
48
+
defer rows.Close()
19
49
20
50
var knots []string
21
51
for rows.Next() {
···
33
63
return knots, nil
34
64
}
35
65
36
-
func (d *DB) GetRepo(knot, owner, name string) (*Repo, error) {
66
+
func (d *DB) GetRepo(repoAt syntax.ATURI) (*Repo, error) {
37
67
var repo Repo
68
+
err := d.DB.QueryRow(
69
+
`select
70
+
did,
71
+
rkey,
72
+
name,
73
+
knot
74
+
from repos where at_uri = ?`,
75
+
repoAt,
76
+
).Scan(
77
+
&repo.Did,
78
+
&repo.Rkey,
79
+
&repo.Name,
80
+
&repo.Knot,
81
+
)
82
+
if err != nil {
83
+
return nil, err
84
+
}
85
+
return &repo, nil
86
+
}
38
87
39
-
query := "select knot, owner, name from repos where knot = ? and owner = ? and name = ?"
40
-
err := d.DB.QueryRow(query, knot, owner, name).
41
-
Scan(&repo.Knot, &repo.Owner, &repo.Name)
88
+
func (d *DB) GetRepoWithName(did syntax.DID, name string) (*Repo, error) {
89
+
var repo Repo
90
+
err := d.DB.QueryRow(
91
+
`select
92
+
did,
93
+
rkey,
94
+
name,
95
+
knot
96
+
from repos where did = ? and name = ?`,
97
+
did,
98
+
name,
99
+
).Scan(
100
+
&repo.Did,
101
+
&repo.Rkey,
102
+
&repo.Name,
103
+
&repo.Knot,
104
+
)
105
+
if err != nil {
106
+
return nil, err
107
+
}
108
+
return &repo, nil
109
+
}
110
+
111
+
func (d *DB) PutRepoCollaborator(collaborator *RepoCollaborator) error {
112
+
_, err := d.Exec(
113
+
`insert into repo_collaborators (did, rkey, repo, subject)
114
+
values (?, ?, ?, ?)
115
+
on conflict(did, rkey) do update set
116
+
repo = excluded.repo,
117
+
subject = excluded.subject`,
118
+
collaborator.Did,
119
+
collaborator.Rkey,
120
+
collaborator.Repo,
121
+
collaborator.Subject,
122
+
)
123
+
return err
124
+
}
125
+
126
+
func (d *DB) RemoveRepoCollaborator(did syntax.DID, rkey syntax.RecordKey) error {
127
+
_, err := d.Exec(
128
+
`delete from repo_collaborators where did = ? and rkey = ?`,
129
+
did,
130
+
rkey,
131
+
)
132
+
return err
133
+
}
42
134
135
+
func (d *DB) GetRepoCollaborator(did syntax.DID, rkey syntax.RecordKey) (*RepoCollaborator, error) {
136
+
var collaborator RepoCollaborator
137
+
err := d.DB.QueryRow(
138
+
`select
139
+
did,
140
+
rkey,
141
+
repo,
142
+
subject
143
+
from repo_collaborators
144
+
where did = ? and rkey = ?`,
145
+
did,
146
+
rkey,
147
+
).Scan(
148
+
&collaborator.Did,
149
+
&collaborator.Rkey,
150
+
&collaborator.Repo,
151
+
&collaborator.Subject,
152
+
)
43
153
if err != nil {
44
154
return nil, err
45
155
}
46
-
47
-
return &repo, nil
156
+
return &collaborator, nil
48
157
}
+22
-21
spindle/engine/engine.go
+22
-21
spindle/engine/engine.go
···
3
3
import (
4
4
"context"
5
5
"errors"
6
-
"fmt"
7
6
"log/slog"
7
+
"sync"
8
8
9
9
securejoin "github.com/cyphar/filepath-securejoin"
10
-
"golang.org/x/sync/errgroup"
11
10
"tangled.org/core/notifier"
12
11
"tangled.org/core/spindle/config"
13
12
"tangled.org/core/spindle/db"
···
31
30
}
32
31
}
33
32
34
-
eg, ctx := errgroup.WithContext(ctx)
33
+
var wg sync.WaitGroup
35
34
for eng, wfs := range pipeline.Workflows {
36
35
workflowTimeout := eng.WorkflowTimeout()
37
36
l.Info("using workflow timeout", "timeout", workflowTimeout)
38
37
39
38
for _, w := range wfs {
40
-
eg.Go(func() error {
39
+
wg.Add(1)
40
+
go func() {
41
+
defer wg.Done()
42
+
41
43
wid := models.WorkflowId{
42
44
PipelineId: pipelineId,
43
45
Name: w.Name,
···
45
47
46
48
err := db.StatusRunning(wid, n)
47
49
if err != nil {
48
-
return err
50
+
l.Error("failed to set workflow status to running", "wid", wid, "err", err)
51
+
return
49
52
}
50
53
51
54
err = eng.SetupWorkflow(ctx, wid, &w)
···
61
64
62
65
dbErr := db.StatusFailed(wid, err.Error(), -1, n)
63
66
if dbErr != nil {
64
-
return dbErr
67
+
l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr)
65
68
}
66
-
return err
69
+
return
67
70
}
68
71
defer eng.DestroyWorkflow(ctx, wid)
69
72
70
-
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)
71
78
if err != nil {
72
79
l.Warn("failed to setup step logger; logs will not be persisted", "error", err)
73
80
wfLogger = nil
···
99
106
if errors.Is(err, ErrTimedOut) {
100
107
dbErr := db.StatusTimeout(wid, n)
101
108
if dbErr != nil {
102
-
return dbErr
109
+
l.Error("failed to set workflow status to timeout", "wid", wid, "err", dbErr)
103
110
}
104
111
} else {
105
112
dbErr := db.StatusFailed(wid, err.Error(), -1, n)
106
113
if dbErr != nil {
107
-
return dbErr
114
+
l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr)
108
115
}
109
116
}
110
-
111
-
return fmt.Errorf("starting steps image: %w", err)
117
+
return
112
118
}
113
119
}
114
120
115
121
err = db.StatusSuccess(wid, n)
116
122
if err != nil {
117
-
return err
123
+
l.Error("failed to set workflow status to success", "wid", wid, "err", err)
118
124
}
119
-
120
-
return nil
121
-
})
125
+
}()
122
126
}
123
127
}
124
128
125
-
if err := eg.Wait(); err != nil {
126
-
l.Error("failed to run one or more workflows", "err", err)
127
-
} else {
128
-
l.Info("successfully ran full pipeline")
129
-
}
129
+
wg.Wait()
130
+
l.Info("all workflows completed")
130
131
}
+29
-16
spindle/engines/nixery/engine.go
+29
-16
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
+
if err := e.docker.NetworkRemove(ctx, networkName(wid)); err != nil {
183
+
return fmt.Errorf("removing network: %w", err)
184
+
}
185
+
return nil
183
186
})
184
187
185
188
addl := wf.Data.(addlFields)
···
229
232
return fmt.Errorf("creating container: %w", err)
230
233
}
231
234
e.registerCleanup(wid, func(ctx context.Context) error {
232
-
err = e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{})
233
-
if err != nil {
234
-
return err
235
+
if err := e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{}); err != nil {
236
+
return fmt.Errorf("stopping container: %w", err)
235
237
}
236
238
237
-
return e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{
239
+
err := e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{
238
240
RemoveVolumes: true,
239
241
RemoveLinks: false,
240
242
Force: false,
241
243
})
244
+
if err != nil {
245
+
return fmt.Errorf("removing container: %w", err)
246
+
}
247
+
return nil
242
248
})
243
249
244
-
err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{})
245
-
if err != nil {
250
+
if err := e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
246
251
return fmt.Errorf("starting container: %w", err)
247
252
}
248
253
···
294
299
workflowEnvs.AddEnv(s.Key, s.Value)
295
300
}
296
301
297
-
step := w.Steps[idx].(Step)
302
+
step := w.Steps[idx]
298
303
299
304
select {
300
305
case <-ctx.Done():
···
303
308
}
304
309
305
310
envs := append(EnvVars(nil), workflowEnvs...)
306
-
for k, v := range step.environment {
307
-
envs.AddEnv(k, v)
311
+
if nixStep, ok := step.(Step); ok {
312
+
for k, v := range nixStep.environment {
313
+
envs.AddEnv(k, v)
314
+
}
308
315
}
309
316
envs.AddEnv("HOME", homeDir)
310
317
···
392
399
}
393
400
394
401
func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error {
395
-
e.cleanupMu.Lock()
396
-
key := wid.String()
397
-
398
-
fns := e.cleanup[key]
399
-
delete(e.cleanup, key)
400
-
e.cleanupMu.Unlock()
402
+
fns := e.drainCleanups(wid)
401
403
402
404
for _, fn := range fns {
403
405
if err := fn(ctx); err != nil {
···
413
415
414
416
key := wid.String()
415
417
e.cleanup[key] = append(e.cleanup[key], fn)
418
+
}
419
+
420
+
func (e *Engine) drainCleanups(wid models.WorkflowId) []cleanupFunc {
421
+
e.cleanupMu.Lock()
422
+
key := wid.String()
423
+
424
+
fns := e.cleanup[key]
425
+
delete(e.cleanup, key)
426
+
e.cleanupMu.Unlock()
427
+
428
+
return fns
416
429
}
417
430
418
431
func networkName(wid models.WorkflowId) string {
+73
spindle/git/git.go
+73
spindle/git/git.go
···
1
+
package git
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"fmt"
7
+
"os"
8
+
"os/exec"
9
+
"strings"
10
+
11
+
"github.com/hashicorp/go-version"
12
+
)
13
+
14
+
func Version() (*version.Version, error) {
15
+
var buf bytes.Buffer
16
+
cmd := exec.Command("git", "version")
17
+
cmd.Stdout = &buf
18
+
cmd.Stderr = os.Stderr
19
+
err := cmd.Run()
20
+
if err != nil {
21
+
return nil, err
22
+
}
23
+
fields := strings.Fields(buf.String())
24
+
if len(fields) < 3 {
25
+
return nil, fmt.Errorf("invalid git version: %s", buf.String())
26
+
}
27
+
28
+
// version string is like: "git version 2.29.3" or "git version 2.29.3.windows.1"
29
+
versionString := fields[2]
30
+
if pos := strings.Index(versionString, "windows"); pos >= 1 {
31
+
versionString = versionString[:pos-1]
32
+
}
33
+
return version.NewVersion(versionString)
34
+
}
35
+
36
+
const WorkflowDir = `/.tangled/workflows`
37
+
38
+
func SparseSyncGitRepo(ctx context.Context, cloneUri, path, rev string) error {
39
+
exist, err := isDir(path)
40
+
if err != nil {
41
+
return err
42
+
}
43
+
if rev == "" {
44
+
rev = "HEAD"
45
+
}
46
+
if !exist {
47
+
if err := exec.Command("git", "clone", "--no-checkout", "--depth=1", "--filter=tree:0", "--revision="+rev, cloneUri, path).Run(); err != nil {
48
+
return fmt.Errorf("git clone: %w", err)
49
+
}
50
+
if err := exec.Command("git", "-C", path, "sparse-checkout", "set", "--no-cone", WorkflowDir).Run(); err != nil {
51
+
return fmt.Errorf("git sparse-checkout set: %w", err)
52
+
}
53
+
} else {
54
+
if err := exec.Command("git", "-C", path, "fetch", "--depth=1", "--filter=tree:0", "origin", rev).Run(); err != nil {
55
+
return fmt.Errorf("git pull: %w", err)
56
+
}
57
+
}
58
+
if err := exec.Command("git", "-C", path, "checkout", rev).Run(); err != nil {
59
+
return fmt.Errorf("git checkout: %w", err)
60
+
}
61
+
return nil
62
+
}
63
+
64
+
func isDir(path string) (bool, error) {
65
+
info, err := os.Stat(path)
66
+
if err == nil && info.IsDir() {
67
+
return true, nil
68
+
}
69
+
if os.IsNotExist(err) {
70
+
return false, nil
71
+
}
72
+
return false, err
73
+
}
-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
-
}
+1
-1
spindle/models/clone.go
+1
-1
spindle/models/clone.go
···
69
69
commands: []string{
70
70
"git init",
71
71
fmt.Sprintf("git remote add origin %s", repoURL),
72
-
fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " ")),
72
+
fmt.Sprintf("GIT_SSL_NO_VERIFY=true git -c http.sslVerify=false fetch %s", strings.Join(fetchArgs, " ")),
73
73
"git checkout FETCH_HEAD",
74
74
},
75
75
}
+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
+
}
+1
-1
spindle/motd
+1
-1
spindle/motd
+223
-150
spindle/server.go
+223
-150
spindle/server.go
···
4
4
"context"
5
5
_ "embed"
6
6
"encoding/json"
7
+
"errors"
7
8
"fmt"
8
9
"log/slog"
9
10
"maps"
10
11
"net/http"
12
+
"path/filepath"
11
13
14
+
"github.com/bluesky-social/indigo/atproto/syntax"
12
15
"github.com/go-chi/chi/v5"
16
+
"github.com/go-git/go-git/v5/plumbing/object"
17
+
"github.com/hashicorp/go-version"
13
18
"tangled.org/core/api/tangled"
14
19
"tangled.org/core/eventconsumer"
15
20
"tangled.org/core/eventconsumer/cursor"
16
21
"tangled.org/core/idresolver"
17
-
"tangled.org/core/jetstream"
22
+
kgit "tangled.org/core/knotserver/git"
18
23
"tangled.org/core/log"
19
24
"tangled.org/core/notifier"
20
-
"tangled.org/core/rbac"
25
+
"tangled.org/core/rbac2"
21
26
"tangled.org/core/spindle/config"
22
27
"tangled.org/core/spindle/db"
23
28
"tangled.org/core/spindle/engine"
24
29
"tangled.org/core/spindle/engines/nixery"
30
+
"tangled.org/core/spindle/git"
25
31
"tangled.org/core/spindle/models"
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"
36
+
"tangled.org/core/tid"
37
+
"tangled.org/core/workflow"
29
38
"tangled.org/core/xrpc/serviceauth"
30
39
)
31
40
32
41
//go:embed motd
33
42
var motd []byte
34
-
35
-
const (
36
-
rbacDomain = "thisserver"
37
-
)
38
43
39
44
type Spindle struct {
40
-
jc *jetstream.JetstreamClient
45
+
tap *tap.Client
41
46
db *db.DB
42
-
e *rbac.Enforcer
47
+
e *rbac2.Enforcer
43
48
l *slog.Logger
44
49
n *notifier.Notifier
45
50
engs map[string]models.Engine
···
54
59
func New(ctx context.Context, cfg *config.Config, engines map[string]models.Engine) (*Spindle, error) {
55
60
logger := log.FromContext(ctx)
56
61
57
-
d, err := db.Make(cfg.Server.DBPath)
62
+
if err := ensureGitVersion(); err != nil {
63
+
return nil, fmt.Errorf("ensuring git version: %w", err)
64
+
}
65
+
66
+
d, err := db.Make(ctx, cfg.Server.DBPath())
58
67
if err != nil {
59
68
return nil, fmt.Errorf("failed to setup db: %w", err)
60
69
}
61
70
62
-
e, err := rbac.NewEnforcer(cfg.Server.DBPath)
71
+
e, err := rbac2.NewEnforcer(cfg.Server.DBPath())
63
72
if err != nil {
64
73
return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err)
65
74
}
66
-
e.E.EnableAutoSave(true)
67
75
68
76
n := notifier.New()
69
77
···
83
91
}
84
92
logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount)
85
93
case "sqlite", "":
86
-
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
94
+
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath(), secrets.WithTableName("secrets"))
87
95
if err != nil {
88
96
return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
89
97
}
90
-
logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath)
98
+
logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath())
91
99
default:
92
100
return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
93
101
}
···
95
103
jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount)
96
104
logger.Info("initialized queue", "queueSize", cfg.Server.QueueSize, "numWorkers", cfg.Server.MaxJobCount)
97
105
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
-
}
106
+
tap := tap.NewClient(cfg.Server.TapUrl, "")
117
107
118
108
resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl)
119
109
120
110
spindle := &Spindle{
121
-
jc: jc,
111
+
tap: &tap,
122
112
e: e,
123
113
db: d,
124
114
l: logger,
···
130
120
vault: vault,
131
121
}
132
122
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()
123
+
err = e.SetSpindleOwner(spindle.cfg.Server.Owner, spindle.cfg.Server.Did())
138
124
if err != nil {
139
125
return nil, err
140
126
}
141
127
logger.Info("owner set", "did", cfg.Server.Owner)
142
128
143
-
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
129
+
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath())
144
130
if err != nil {
145
131
return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
146
132
}
147
133
148
-
err = jc.StartJetstream(ctx, spindle.ingest())
149
-
if err != nil {
150
-
return nil, fmt.Errorf("failed to start jetstream consumer: %w", err)
151
-
}
152
-
153
-
// for each incoming sh.tangled.pipeline, we execute
154
-
// spindle.processPipeline, which in turn enqueues the pipeline
155
-
// job in the above registered queue.
134
+
// spindle listen to knot stream for sh.tangled.git.refUpdate
135
+
// which will sync the local workflow files in spindle and enqueues the
136
+
// pipeline job for on-push workflows
156
137
ccfg := eventconsumer.NewConsumerConfig()
157
138
ccfg.Logger = log.SubLogger(logger, "eventconsumer")
158
139
ccfg.Dev = cfg.Server.Dev
159
-
ccfg.ProcessFunc = spindle.processPipeline
140
+
ccfg.ProcessFunc = spindle.processKnotStream
160
141
ccfg.CursorStore = cursorStore
161
142
knownKnots, err := d.Knots()
162
143
if err != nil {
···
197
178
}
198
179
199
180
// Enforcer returns the RBAC enforcer instance.
200
-
func (s *Spindle) Enforcer() *rbac.Enforcer {
181
+
func (s *Spindle) Enforcer() *rbac2.Enforcer {
201
182
return s.e
202
183
}
203
184
···
217
198
s.ks.Start(ctx)
218
199
}()
219
200
201
+
// ensure server owner is tracked
202
+
if err := s.tap.AddRepos(ctx, []syntax.DID{s.cfg.Server.Owner}); err != nil {
203
+
return err
204
+
}
205
+
206
+
go func() {
207
+
s.l.Info("starting tap stream consumer")
208
+
s.tap.Connect(ctx, &tap.SimpleIndexer{
209
+
EventHandler: s.processEvent,
210
+
})
211
+
}()
212
+
220
213
s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr)
221
214
return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router())
222
215
}
···
268
261
Config: s.cfg,
269
262
Resolver: s.res,
270
263
Vault: s.vault,
264
+
Notifier: s.Notifier(),
271
265
ServiceAuth: serviceAuth,
272
266
}
273
267
274
268
return x.Router()
275
269
}
276
270
277
-
func (s *Spindle) processPipeline(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error {
278
-
if msg.Nsid == tangled.PipelineNSID {
279
-
tpl := tangled.Pipeline{}
280
-
err := json.Unmarshal(msg.EventJson, &tpl)
281
-
if err != nil {
282
-
fmt.Println("error unmarshalling", err)
271
+
func (s *Spindle) processKnotStream(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error {
272
+
l := log.FromContext(ctx).With("handler", "processKnotStream")
273
+
l = l.With("src", src.Key(), "msg.Nsid", msg.Nsid, "msg.Rkey", msg.Rkey)
274
+
if msg.Nsid == tangled.GitRefUpdateNSID {
275
+
event := tangled.GitRefUpdate{}
276
+
if err := json.Unmarshal(msg.EventJson, &event); err != nil {
277
+
l.Error("error unmarshalling", "err", err)
283
278
return err
284
279
}
280
+
l = l.With("repoDid", event.RepoDid, "repoName", event.RepoName)
285
281
286
-
if tpl.TriggerMetadata == nil {
287
-
return fmt.Errorf("no trigger metadata found")
282
+
// resolve repo name to rkey
283
+
// TODO: git.refUpdate should respond with rkey instead of repo name
284
+
repo, err := s.db.GetRepoWithName(syntax.DID(event.RepoDid), event.RepoName)
285
+
if err != nil {
286
+
return fmt.Errorf("get repo with did and name (%s/%s): %w", event.RepoDid, event.RepoName, err)
288
287
}
289
288
290
-
if tpl.TriggerMetadata.Repo == nil {
291
-
return fmt.Errorf("no repo data found")
289
+
// NOTE: we are blindly trusting the knot that it will return only repos it own
290
+
repoCloneUri := s.newRepoCloneUrl(src.Key(), event.RepoDid, event.RepoName)
291
+
repoPath := s.newRepoPath(repo.Did, repo.Rkey)
292
+
if err := git.SparseSyncGitRepo(ctx, repoCloneUri, repoPath, event.NewSha); err != nil {
293
+
return fmt.Errorf("sync git repo: %w", err)
292
294
}
295
+
l.Info("synced git repo")
293
296
294
-
if src.Key() != tpl.TriggerMetadata.Repo.Knot {
295
-
return fmt.Errorf("repo knot does not match event source: %s != %s", src.Key(), tpl.TriggerMetadata.Repo.Knot)
297
+
compiler := workflow.Compiler{
298
+
Trigger: tangled.Pipeline_TriggerMetadata{
299
+
Kind: string(workflow.TriggerKindPush),
300
+
Push: &tangled.Pipeline_PushTriggerData{
301
+
Ref: event.Ref,
302
+
OldSha: event.OldSha,
303
+
NewSha: event.NewSha,
304
+
},
305
+
Repo: &tangled.Pipeline_TriggerRepo{
306
+
Did: repo.Did.String(),
307
+
Knot: repo.Knot,
308
+
Repo: repo.Name,
309
+
},
310
+
},
296
311
}
297
312
298
-
// filter by repos
299
-
_, err = s.db.GetRepo(
300
-
tpl.TriggerMetadata.Repo.Knot,
301
-
tpl.TriggerMetadata.Repo.Did,
302
-
tpl.TriggerMetadata.Repo.Repo,
303
-
)
313
+
// load workflow definitions from rev (without spindle context)
314
+
rawPipeline, err := s.loadPipeline(ctx, repoCloneUri, repoPath, event.NewSha)
304
315
if err != nil {
305
-
return err
316
+
return fmt.Errorf("loading pipeline: %w", err)
317
+
}
318
+
if len(rawPipeline) == 0 {
319
+
l.Info("no workflow definition find for the repo. skipping the event")
320
+
return nil
321
+
}
322
+
tpl := compiler.Compile(compiler.Parse(rawPipeline))
323
+
// TODO: pass compile error to workflow log
324
+
for _, w := range compiler.Diagnostics.Errors {
325
+
l.Error(w.String())
326
+
}
327
+
for _, w := range compiler.Diagnostics.Warnings {
328
+
l.Warn(w.String())
306
329
}
307
330
308
331
pipelineId := models.PipelineId{
309
-
Knot: src.Key(),
310
-
Rkey: msg.Rkey,
332
+
Knot: tpl.TriggerMetadata.Repo.Knot,
333
+
Rkey: tid.TID(),
311
334
}
335
+
if err := s.db.CreatePipelineEvent(pipelineId.Rkey, tpl, s.n); err != nil {
336
+
l.Error("failed to create pipeline event", "err", err)
337
+
return nil
338
+
}
339
+
err = s.processPipeline(ctx, tpl, pipelineId)
340
+
if err != nil {
341
+
return err
342
+
}
343
+
}
312
344
313
-
workflows := make(map[models.Engine][]models.Workflow)
345
+
return nil
346
+
}
347
+
348
+
func (s *Spindle) loadPipeline(ctx context.Context, repoUri, repoPath, rev string) (workflow.RawPipeline, error) {
349
+
if err := git.SparseSyncGitRepo(ctx, repoUri, repoPath, rev); err != nil {
350
+
return nil, fmt.Errorf("syncing git repo: %w", err)
351
+
}
352
+
gr, err := kgit.Open(repoPath, rev)
353
+
if err != nil {
354
+
return nil, fmt.Errorf("opening git repo: %w", err)
355
+
}
356
+
357
+
workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir)
358
+
if errors.Is(err, object.ErrDirectoryNotFound) {
359
+
// return empty RawPipeline when directory doesn't exist
360
+
return nil, nil
361
+
} else if err != nil {
362
+
return nil, fmt.Errorf("loading file tree: %w", err)
363
+
}
314
364
315
-
// Build pipeline environment variables once for all workflows
316
-
pipelineEnv := models.PipelineEnvVars(tpl.TriggerMetadata, pipelineId, s.cfg.Server.Dev)
365
+
var rawPipeline workflow.RawPipeline
366
+
for _, e := range workflowDir {
367
+
if !e.IsFile() {
368
+
continue
369
+
}
317
370
318
-
for _, w := range tpl.Workflows {
319
-
if w != nil {
320
-
if _, ok := s.engs[w.Engine]; !ok {
321
-
err = s.db.StatusFailed(models.WorkflowId{
322
-
PipelineId: pipelineId,
323
-
Name: w.Name,
324
-
}, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n)
325
-
if err != nil {
326
-
return err
327
-
}
371
+
fpath := filepath.Join(workflow.WorkflowDir, e.Name)
372
+
contents, err := gr.RawContent(fpath)
373
+
if err != nil {
374
+
return nil, fmt.Errorf("reading raw content of '%s': %w", fpath, err)
375
+
}
328
376
329
-
continue
330
-
}
377
+
rawPipeline = append(rawPipeline, workflow.RawWorkflow{
378
+
Name: e.Name,
379
+
Contents: contents,
380
+
})
381
+
}
331
382
332
-
eng := s.engs[w.Engine]
383
+
return rawPipeline, nil
384
+
}
333
385
334
-
if _, ok := workflows[eng]; !ok {
335
-
workflows[eng] = []models.Workflow{}
336
-
}
386
+
func (s *Spindle) processPipeline(ctx context.Context, tpl tangled.Pipeline, pipelineId models.PipelineId) error {
387
+
// Build pipeline environment variables once for all workflows
388
+
pipelineEnv := models.PipelineEnvVars(tpl.TriggerMetadata, pipelineId, s.cfg.Server.Dev)
337
389
338
-
ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl)
339
-
if err != nil {
340
-
return err
341
-
}
390
+
// filter & init workflows
391
+
workflows := make(map[models.Engine][]models.Workflow)
392
+
for _, w := range tpl.Workflows {
393
+
if w == nil {
394
+
continue
395
+
}
396
+
if _, ok := s.engs[w.Engine]; !ok {
397
+
err := s.db.StatusFailed(models.WorkflowId{
398
+
PipelineId: pipelineId,
399
+
Name: w.Name,
400
+
}, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n)
401
+
if err != nil {
402
+
return fmt.Errorf("db.StatusFailed: %w", err)
403
+
}
342
404
343
-
// inject TANGLED_* env vars after InitWorkflow
344
-
// This prevents user-defined env vars from overriding them
345
-
if ewf.Environment == nil {
346
-
ewf.Environment = make(map[string]string)
347
-
}
348
-
maps.Copy(ewf.Environment, pipelineEnv)
405
+
continue
406
+
}
349
407
350
-
workflows[eng] = append(workflows[eng], *ewf)
408
+
eng := s.engs[w.Engine]
351
409
352
-
err = s.db.StatusPending(models.WorkflowId{
353
-
PipelineId: pipelineId,
354
-
Name: w.Name,
355
-
}, s.n)
356
-
if err != nil {
357
-
return err
358
-
}
359
-
}
410
+
if _, ok := workflows[eng]; !ok {
411
+
workflows[eng] = []models.Workflow{}
360
412
}
361
413
362
-
ok := s.jq.Enqueue(queue.Job{
363
-
Run: func() error {
364
-
engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{
365
-
RepoOwner: tpl.TriggerMetadata.Repo.Did,
366
-
RepoName: tpl.TriggerMetadata.Repo.Repo,
367
-
Workflows: workflows,
368
-
}, pipelineId)
369
-
return nil
370
-
},
371
-
OnFail: func(jobError error) {
372
-
s.l.Error("pipeline run failed", "error", jobError)
373
-
},
374
-
})
375
-
if ok {
376
-
s.l.Info("pipeline enqueued successfully", "id", msg.Rkey)
377
-
} else {
378
-
s.l.Error("failed to enqueue pipeline: queue is full")
414
+
ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl)
415
+
if err != nil {
416
+
return fmt.Errorf("init workflow: %w", err)
379
417
}
418
+
419
+
// inject TANGLED_* env vars after InitWorkflow
420
+
// This prevents user-defined env vars from overriding them
421
+
if ewf.Environment == nil {
422
+
ewf.Environment = make(map[string]string)
423
+
}
424
+
maps.Copy(ewf.Environment, pipelineEnv)
425
+
426
+
workflows[eng] = append(workflows[eng], *ewf)
380
427
}
381
428
429
+
// enqueue pipeline
430
+
ok := s.jq.Enqueue(queue.Job{
431
+
Run: func() error {
432
+
engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{
433
+
RepoOwner: tpl.TriggerMetadata.Repo.Did,
434
+
RepoName: tpl.TriggerMetadata.Repo.Repo,
435
+
Workflows: workflows,
436
+
}, pipelineId)
437
+
return nil
438
+
},
439
+
OnFail: func(jobError error) {
440
+
s.l.Error("pipeline run failed", "error", jobError)
441
+
},
442
+
})
443
+
if !ok {
444
+
return fmt.Errorf("failed to enqueue pipeline: queue is full")
445
+
}
446
+
s.l.Info("pipeline enqueued successfully", "id", pipelineId)
447
+
448
+
// emit StatusPending for all workflows here (after successful enqueue)
449
+
for _, ewfs := range workflows {
450
+
for _, ewf := range ewfs {
451
+
err := s.db.StatusPending(models.WorkflowId{
452
+
PipelineId: pipelineId,
453
+
Name: ewf.Name,
454
+
}, s.n)
455
+
if err != nil {
456
+
return fmt.Errorf("db.StatusPending: %w", err)
457
+
}
458
+
}
459
+
}
382
460
return nil
383
461
}
384
462
385
-
func (s *Spindle) configureOwner() error {
386
-
cfgOwner := s.cfg.Server.Owner
463
+
// newRepoPath creates a path to store repository by its did and rkey.
464
+
// The path format would be: `/data/repos/did:plc:foo/sh.tangled.repo/repo-rkey
465
+
func (s *Spindle) newRepoPath(did syntax.DID, rkey syntax.RecordKey) string {
466
+
return filepath.Join(s.cfg.Server.RepoDir(), did.String(), tangled.RepoNSID, rkey.String())
467
+
}
387
468
388
-
existing, err := s.e.GetSpindleUsersByRole("server:owner", rbacDomain)
389
-
if err != nil {
390
-
return err
469
+
func (s *Spindle) newRepoCloneUrl(knot, did, name string) string {
470
+
scheme := "https://"
471
+
if s.cfg.Server.Dev {
472
+
scheme = "http://"
391
473
}
392
-
393
-
switch len(existing) {
394
-
case 0:
395
-
// no owner configured, continue
396
-
case 1:
397
-
// find existing owner
398
-
existingOwner := existing[0]
474
+
return fmt.Sprintf("%s%s/%s/%s", scheme, knot, did, name)
475
+
}
399
476
400
-
// no ownership change, this is okay
401
-
if existingOwner == s.cfg.Server.Owner {
402
-
break
403
-
}
477
+
const RequiredVersion = "2.49.0"
404
478
405
-
// remove existing owner
406
-
err = s.e.RemoveSpindleOwner(rbacDomain, existingOwner)
407
-
if err != nil {
408
-
return nil
409
-
}
410
-
default:
411
-
return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", s.cfg.Server.DBPath)
479
+
func ensureGitVersion() error {
480
+
v, err := git.Version()
481
+
if err != nil {
482
+
return fmt.Errorf("fetching git version: %w", err)
483
+
}
484
+
if v.LessThan(version.Must(version.NewVersion(RequiredVersion))) {
485
+
return fmt.Errorf("installed git version %q is not supported, Spindle requires git version >= %q", v, RequiredVersion)
412
486
}
413
-
414
-
return s.e.AddSpindleOwner(rbacDomain, cfgOwner)
487
+
return nil
415
488
}
+391
spindle/tap.go
+391
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/spindle/git"
14
+
"tangled.org/core/spindle/models"
15
+
"tangled.org/core/tap"
16
+
"tangled.org/core/tid"
17
+
"tangled.org/core/workflow"
18
+
)
19
+
20
+
func (s *Spindle) processEvent(ctx context.Context, evt tap.Event) error {
21
+
l := s.l.With("component", "tapIndexer")
22
+
23
+
var err error
24
+
switch evt.Type {
25
+
case tap.EvtRecord:
26
+
switch evt.Record.Collection.String() {
27
+
case tangled.SpindleMemberNSID:
28
+
err = s.processMember(ctx, evt)
29
+
case tangled.RepoNSID:
30
+
err = s.processRepo(ctx, evt)
31
+
case tangled.RepoCollaboratorNSID:
32
+
err = s.processCollaborator(ctx, evt)
33
+
case tangled.RepoPullNSID:
34
+
err = s.processPull(ctx, evt)
35
+
}
36
+
case tap.EvtIdentity:
37
+
// no-op
38
+
}
39
+
40
+
if err != nil {
41
+
l.Error("failed to process message. will retry later", "event.ID", evt.ID, "err", err)
42
+
return err
43
+
}
44
+
return nil
45
+
}
46
+
47
+
// NOTE: make sure to return nil if we don't need to retry (e.g. forbidden, unrelated)
48
+
49
+
func (s *Spindle) processMember(ctx context.Context, evt tap.Event) error {
50
+
l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri())
51
+
52
+
l.Info("processing spindle.member record")
53
+
54
+
// only listen to members
55
+
if ok, err := s.e.IsSpindleMemberInviteAllowed(evt.Record.Did, s.cfg.Server.Did()); !ok || err != nil {
56
+
l.Warn("forbidden request: member invite not allowed", "did", evt.Record.Did, "error", err)
57
+
return nil
58
+
}
59
+
60
+
switch evt.Record.Action {
61
+
case tap.RecordCreateAction, tap.RecordUpdateAction:
62
+
record := tangled.SpindleMember{}
63
+
if err := json.Unmarshal(evt.Record.Record, &record); err != nil {
64
+
return fmt.Errorf("parsing record: %w", err)
65
+
}
66
+
67
+
domain := s.cfg.Server.Hostname
68
+
if record.Instance != domain {
69
+
l.Info("domain mismatch", "domain", record.Instance, "expected", domain)
70
+
return nil
71
+
}
72
+
73
+
created, err := time.Parse(record.CreatedAt, time.RFC3339)
74
+
if err != nil {
75
+
created = time.Now()
76
+
}
77
+
if err := db.AddSpindleMember(s.db, db.SpindleMember{
78
+
Did: evt.Record.Did,
79
+
Rkey: evt.Record.Rkey.String(),
80
+
Instance: record.Instance,
81
+
Subject: syntax.DID(record.Subject),
82
+
Created: created,
83
+
}); err != nil {
84
+
l.Error("failed to add member", "error", err)
85
+
return fmt.Errorf("adding member to db: %w", err)
86
+
}
87
+
if err := s.e.AddSpindleMember(syntax.DID(record.Subject), s.cfg.Server.Did()); err != nil {
88
+
return fmt.Errorf("adding member to rbac: %w", err)
89
+
}
90
+
if err := s.tap.AddRepos(ctx, []syntax.DID{syntax.DID(record.Subject)}); err != nil {
91
+
return fmt.Errorf("adding did to tap", err)
92
+
}
93
+
94
+
l.Info("added member", "member", record.Subject)
95
+
return nil
96
+
97
+
case tap.RecordDeleteAction:
98
+
var (
99
+
did = evt.Record.Did.String()
100
+
rkey = evt.Record.Rkey.String()
101
+
)
102
+
member, err := db.GetSpindleMember(s.db, did, rkey)
103
+
if err != nil {
104
+
return fmt.Errorf("finding member: %w", err)
105
+
}
106
+
107
+
if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil {
108
+
return fmt.Errorf("removing member from db: %w", err)
109
+
}
110
+
if err := s.e.RemoveSpindleMember(member.Subject, s.cfg.Server.Did()); err != nil {
111
+
return fmt.Errorf("removing member from rbac: %w", err)
112
+
}
113
+
if err := s.tapSafeRemoveDid(ctx, member.Subject); err != nil {
114
+
return fmt.Errorf("removing did from tap: %w", err)
115
+
}
116
+
117
+
l.Info("removed member", "member", member.Subject)
118
+
return nil
119
+
}
120
+
return nil
121
+
}
122
+
123
+
func (s *Spindle) processCollaborator(ctx context.Context, evt tap.Event) error {
124
+
l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri())
125
+
126
+
l.Info("processing repo.collaborator record")
127
+
128
+
// only listen to members
129
+
if ok, err := s.e.IsSpindleMember(evt.Record.Did, s.cfg.Server.Did()); !ok || err != nil {
130
+
l.Warn("forbidden request: not spindle member", "did", evt.Record.Did, "err", err)
131
+
return nil
132
+
}
133
+
134
+
switch evt.Record.Action {
135
+
case tap.RecordCreateAction, tap.RecordUpdateAction:
136
+
record := tangled.RepoCollaborator{}
137
+
if err := json.Unmarshal(evt.Record.Record, &record); err != nil {
138
+
l.Error("invalid record", "err", err)
139
+
return fmt.Errorf("parsing record: %w", err)
140
+
}
141
+
142
+
// retry later if target repo is not ingested yet
143
+
if _, err := s.db.GetRepo(syntax.ATURI(record.Repo)); err != nil {
144
+
l.Warn("target repo is not ingested yet", "repo", record.Repo, "err", err)
145
+
return fmt.Errorf("target repo is unknown")
146
+
}
147
+
148
+
// check perms for this user
149
+
if ok, err := s.e.IsRepoCollaboratorInviteAllowed(evt.Record.Did, syntax.ATURI(record.Repo)); !ok || err != nil {
150
+
l.Warn("forbidden request collaborator invite not allowed", "did", evt.Record.Did, "err", err)
151
+
return nil
152
+
}
153
+
154
+
if err := s.db.PutRepoCollaborator(&db.RepoCollaborator{
155
+
Did: evt.Record.Did,
156
+
Rkey: evt.Record.Rkey,
157
+
Repo: syntax.ATURI(record.Repo),
158
+
Subject: syntax.DID(record.Subject),
159
+
}); err != nil {
160
+
return fmt.Errorf("adding collaborator to db: %w", err)
161
+
}
162
+
if err := s.e.AddRepoCollaborator(syntax.DID(record.Subject), syntax.ATURI(record.Repo)); err != nil {
163
+
return fmt.Errorf("adding collaborator to rbac: %w", err)
164
+
}
165
+
if err := s.tap.AddRepos(ctx, []syntax.DID{syntax.DID(record.Subject)}); err != nil {
166
+
return fmt.Errorf("adding did to tap: %w", err)
167
+
}
168
+
169
+
l.Info("add repo collaborator", "subejct", record.Subject, "repo", record.Repo)
170
+
return nil
171
+
172
+
case tap.RecordDeleteAction:
173
+
// get existing collaborator
174
+
collaborator, err := s.db.GetRepoCollaborator(evt.Record.Did, evt.Record.Rkey)
175
+
if err != nil {
176
+
return fmt.Errorf("failed to get existing collaborator info: %w", err)
177
+
}
178
+
179
+
// check perms for this user
180
+
if ok, err := s.e.IsRepoCollaboratorInviteAllowed(evt.Record.Did, collaborator.Repo); !ok || err != nil {
181
+
l.Warn("forbidden request collaborator invite not allowed", "did", evt.Record.Did, "err", err)
182
+
return nil
183
+
}
184
+
185
+
if err := s.db.RemoveRepoCollaborator(collaborator.Subject, collaborator.Rkey); err != nil {
186
+
return fmt.Errorf("removing collaborator from db: %w", err)
187
+
}
188
+
if err := s.e.RemoveRepoCollaborator(collaborator.Subject, collaborator.Repo); err != nil {
189
+
return fmt.Errorf("removing collaborator from rbac: %w", err)
190
+
}
191
+
if err := s.tapSafeRemoveDid(ctx, collaborator.Subject); err != nil {
192
+
return fmt.Errorf("removing did from tap: %w", err)
193
+
}
194
+
195
+
l.Info("removed repo collaborator", "subejct", collaborator.Subject, "repo", collaborator.Repo)
196
+
return nil
197
+
}
198
+
return nil
199
+
}
200
+
201
+
func (s *Spindle) processRepo(ctx context.Context, evt tap.Event) error {
202
+
l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri())
203
+
204
+
l.Info("processing repo record")
205
+
206
+
// only listen to members
207
+
if ok, err := s.e.IsSpindleMember(evt.Record.Did, s.cfg.Server.Did()); !ok || err != nil {
208
+
l.Warn("forbidden request: not spindle member", "did", evt.Record.Did, "err", err)
209
+
return nil
210
+
}
211
+
212
+
switch evt.Record.Action {
213
+
case tap.RecordCreateAction, tap.RecordUpdateAction:
214
+
record := tangled.Repo{}
215
+
if err := json.Unmarshal(evt.Record.Record, &record); err != nil {
216
+
return fmt.Errorf("parsing record: %w", err)
217
+
}
218
+
219
+
domain := s.cfg.Server.Hostname
220
+
if record.Spindle == nil || *record.Spindle != domain {
221
+
if record.Spindle == nil {
222
+
l.Info("spindle isn't configured", "name", record.Name)
223
+
} else {
224
+
l.Info("different spindle configured", "name", record.Name, "spindle", *record.Spindle, "domain", domain)
225
+
}
226
+
if err := s.db.DeleteRepo(evt.Record.Did, evt.Record.Rkey); err != nil {
227
+
return fmt.Errorf("deleting repo from db: %w", err)
228
+
}
229
+
return nil
230
+
}
231
+
232
+
repo := &db.Repo{
233
+
Did: evt.Record.Did,
234
+
Rkey: evt.Record.Rkey,
235
+
Name: record.Name,
236
+
Knot: record.Knot,
237
+
}
238
+
239
+
if err := s.db.PutRepo(repo); err != nil {
240
+
return fmt.Errorf("adding repo to db: %w", err)
241
+
}
242
+
243
+
if err := s.e.AddRepo(evt.Record.AtUri()); err != nil {
244
+
return fmt.Errorf("adding repo to rbac")
245
+
}
246
+
247
+
// add this knot to the event consumer
248
+
src := eventconsumer.NewKnotSource(record.Knot)
249
+
s.ks.AddSource(context.Background(), src)
250
+
251
+
// setup sparse sync
252
+
repoCloneUri := s.newRepoCloneUrl(repo.Knot, repo.Did.String(), repo.Name)
253
+
repoPath := s.newRepoPath(repo.Did, repo.Rkey)
254
+
if err := git.SparseSyncGitRepo(ctx, repoCloneUri, repoPath, ""); err != nil {
255
+
return fmt.Errorf("setting up sparse-clone git repo: %w", err)
256
+
}
257
+
258
+
l.Info("added repo", "repo", evt.Record.AtUri())
259
+
return nil
260
+
261
+
case tap.RecordDeleteAction:
262
+
// check perms for this user
263
+
if ok, err := s.e.IsRepoOwner(evt.Record.Did, evt.Record.AtUri()); !ok || err != nil {
264
+
l.Warn("forbidden request: not repo owner", "did", evt.Record.Did, "err", err)
265
+
return nil
266
+
}
267
+
268
+
if err := s.db.DeleteRepo(evt.Record.Did, evt.Record.Rkey); err != nil {
269
+
return fmt.Errorf("deleting repo from db: %w", err)
270
+
}
271
+
272
+
if err := s.e.DeleteRepo(evt.Record.AtUri()); err != nil {
273
+
return fmt.Errorf("deleting repo from rbac: %w", err)
274
+
}
275
+
276
+
l.Info("deleted repo", "repo", evt.Record.AtUri())
277
+
return nil
278
+
}
279
+
return nil
280
+
}
281
+
282
+
func (s *Spindle) processPull(ctx context.Context, evt tap.Event) error {
283
+
l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri())
284
+
285
+
l.Info("processing pull record")
286
+
287
+
// only listen to live events
288
+
if !evt.Record.Live {
289
+
l.Info("skipping backfill event", "event", evt.Record.AtUri())
290
+
return nil
291
+
}
292
+
293
+
switch evt.Record.Action {
294
+
case tap.RecordCreateAction, tap.RecordUpdateAction:
295
+
record := tangled.RepoPull{}
296
+
if err := json.Unmarshal(evt.Record.Record, &record); err != nil {
297
+
l.Error("invalid record", "err", err)
298
+
return fmt.Errorf("parsing record: %w", err)
299
+
}
300
+
301
+
// ignore legacy records
302
+
if record.Target == nil {
303
+
l.Info("ignoring pull record: target repo is nil")
304
+
return nil
305
+
}
306
+
307
+
// ignore patch-based and fork-based PRs
308
+
if record.Source == nil || record.Source.Repo != nil {
309
+
l.Info("ignoring pull record: not a branch-based pull request")
310
+
return nil
311
+
}
312
+
313
+
// skip if target repo is unknown
314
+
repo, err := s.db.GetRepo(syntax.ATURI(record.Target.Repo))
315
+
if err != nil {
316
+
l.Warn("target repo is not ingested yet", "repo", record.Target.Repo, "err", err)
317
+
return fmt.Errorf("target repo is unknown")
318
+
}
319
+
320
+
compiler := workflow.Compiler{
321
+
Trigger: tangled.Pipeline_TriggerMetadata{
322
+
Kind: string(workflow.TriggerKindPullRequest),
323
+
PullRequest: &tangled.Pipeline_PullRequestTriggerData{
324
+
Action: "create",
325
+
SourceBranch: record.Source.Branch,
326
+
SourceSha: record.Source.Sha,
327
+
TargetBranch: record.Target.Branch,
328
+
},
329
+
Repo: &tangled.Pipeline_TriggerRepo{
330
+
Did: repo.Did.String(),
331
+
Knot: repo.Knot,
332
+
Repo: repo.Name,
333
+
},
334
+
},
335
+
}
336
+
337
+
repoUri := s.newRepoCloneUrl(repo.Knot, repo.Did.String(), repo.Name)
338
+
repoPath := s.newRepoPath(repo.Did, repo.Rkey)
339
+
340
+
// load workflow definitions from rev (without spindle context)
341
+
rawPipeline, err := s.loadPipeline(ctx, repoUri, repoPath, record.Source.Sha)
342
+
if err != nil {
343
+
// don't retry
344
+
l.Error("failed loading pipeline", "err", err)
345
+
return nil
346
+
}
347
+
if len(rawPipeline) == 0 {
348
+
l.Info("no workflow definition find for the repo. skipping the event")
349
+
return nil
350
+
}
351
+
tpl := compiler.Compile(compiler.Parse(rawPipeline))
352
+
// TODO: pass compile error to workflow log
353
+
for _, w := range compiler.Diagnostics.Errors {
354
+
l.Error(w.String())
355
+
}
356
+
for _, w := range compiler.Diagnostics.Warnings {
357
+
l.Warn(w.String())
358
+
}
359
+
360
+
pipelineId := models.PipelineId{
361
+
Knot: tpl.TriggerMetadata.Repo.Knot,
362
+
Rkey: tid.TID(),
363
+
}
364
+
if err := s.db.CreatePipelineEvent(pipelineId.Rkey, tpl, s.n); err != nil {
365
+
l.Error("failed to create pipeline event", "err", err)
366
+
return nil
367
+
}
368
+
err = s.processPipeline(ctx, tpl, pipelineId)
369
+
if err != nil {
370
+
// don't retry
371
+
l.Error("failed processing pipeline", "err", err)
372
+
return nil
373
+
}
374
+
case tap.RecordDeleteAction:
375
+
// no-op
376
+
}
377
+
return nil
378
+
}
379
+
380
+
func (s *Spindle) tapSafeRemoveDid(ctx context.Context, did syntax.DID) error {
381
+
known, err := s.db.IsKnownDid(syntax.DID(did))
382
+
if err != nil {
383
+
return fmt.Errorf("ensuring did known state: %w", err)
384
+
}
385
+
if !known {
386
+
if err := s.tap.RemoveRepos(ctx, []syntax.DID{did}); err != nil {
387
+
return fmt.Errorf("removing did from tap: %w", err)
388
+
}
389
+
}
390
+
return nil
391
+
}
+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)
+1
-1
tailwind.config.js
+1
-1
tailwind.config.js
···
2
2
const colors = require("tailwindcss/colors");
3
3
4
4
module.exports = {
5
-
content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go"],
5
+
content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go", "./docs/*.html"],
6
6
darkMode: "media",
7
7
theme: {
8
8
container: {
+24
tap/simpleIndexer.go
+24
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
+
if i.EventHandler == nil {
14
+
return nil
15
+
}
16
+
return i.EventHandler(ctx, evt)
17
+
}
18
+
19
+
func (i *SimpleIndexer) OnError(ctx context.Context, err error) {
20
+
if i.ErrorHandler == nil {
21
+
return
22
+
}
23
+
i.ErrorHandler(ctx, err)
24
+
}
+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
+
)
+6
-1
types/commit.go
+6
-1
types/commit.go
···
174
174
175
175
func (commit Commit) CoAuthors() []object.Signature {
176
176
var coAuthors []object.Signature
177
-
177
+
seen := make(map[string]bool)
178
178
matches := coAuthorRegex.FindAllStringSubmatch(commit.Message, -1)
179
179
180
180
for _, match := range matches {
181
181
if len(match) >= 3 {
182
182
name := strings.TrimSpace(match[1])
183
183
email := strings.TrimSpace(match[2])
184
+
185
+
if seen[email] {
186
+
continue
187
+
}
188
+
seen[email] = true
184
189
185
190
coAuthors = append(coAuthors, object.Signature{
186
191
Name: name,
+3
types/diff.go
+3
types/diff.go
+112
types/diff_test.go
+112
types/diff_test.go
···
1
+
package types
2
+
3
+
import "testing"
4
+
5
+
func TestDiffId(t *testing.T) {
6
+
tests := []struct {
7
+
name string
8
+
diff Diff
9
+
expected string
10
+
}{
11
+
{
12
+
name: "regular file uses new name",
13
+
diff: Diff{
14
+
Name: struct {
15
+
Old string `json:"old"`
16
+
New string `json:"new"`
17
+
}{Old: "", New: "src/main.go"},
18
+
},
19
+
expected: "src/main.go",
20
+
},
21
+
{
22
+
name: "new file uses new name",
23
+
diff: Diff{
24
+
Name: struct {
25
+
Old string `json:"old"`
26
+
New string `json:"new"`
27
+
}{Old: "", New: "src/new.go"},
28
+
IsNew: true,
29
+
},
30
+
expected: "src/new.go",
31
+
},
32
+
{
33
+
name: "deleted file uses old name",
34
+
diff: Diff{
35
+
Name: struct {
36
+
Old string `json:"old"`
37
+
New string `json:"new"`
38
+
}{Old: "src/deleted.go", New: ""},
39
+
IsDelete: true,
40
+
},
41
+
expected: "src/deleted.go",
42
+
},
43
+
{
44
+
name: "renamed file uses new name",
45
+
diff: Diff{
46
+
Name: struct {
47
+
Old string `json:"old"`
48
+
New string `json:"new"`
49
+
}{Old: "src/old.go", New: "src/renamed.go"},
50
+
IsRename: true,
51
+
},
52
+
expected: "src/renamed.go",
53
+
},
54
+
}
55
+
56
+
for _, tt := range tests {
57
+
t.Run(tt.name, func(t *testing.T) {
58
+
if got := tt.diff.Id(); got != tt.expected {
59
+
t.Errorf("Diff.Id() = %q, want %q", got, tt.expected)
60
+
}
61
+
})
62
+
}
63
+
}
64
+
65
+
func TestChangedFilesMatchesDiffId(t *testing.T) {
66
+
// ChangedFiles() must return values matching each Diff's Id()
67
+
// so that sidebar links point to the correct anchors.
68
+
// Tests existing, deleted, new, and renamed files.
69
+
nd := NiceDiff{
70
+
Diff: []Diff{
71
+
{
72
+
Name: struct {
73
+
Old string `json:"old"`
74
+
New string `json:"new"`
75
+
}{Old: "", New: "src/modified.go"},
76
+
},
77
+
{
78
+
Name: struct {
79
+
Old string `json:"old"`
80
+
New string `json:"new"`
81
+
}{Old: "src/deleted.go", New: ""},
82
+
IsDelete: true,
83
+
},
84
+
{
85
+
Name: struct {
86
+
Old string `json:"old"`
87
+
New string `json:"new"`
88
+
}{Old: "", New: "src/new.go"},
89
+
IsNew: true,
90
+
},
91
+
{
92
+
Name: struct {
93
+
Old string `json:"old"`
94
+
New string `json:"new"`
95
+
}{Old: "src/old.go", New: "src/renamed.go"},
96
+
IsRename: true,
97
+
},
98
+
},
99
+
}
100
+
101
+
changedFiles := nd.ChangedFiles()
102
+
103
+
if len(changedFiles) != len(nd.Diff) {
104
+
t.Fatalf("ChangedFiles() returned %d items, want %d", len(changedFiles), len(nd.Diff))
105
+
}
106
+
107
+
for i, diff := range nd.Diff {
108
+
if changedFiles[i] != diff.Id() {
109
+
t.Errorf("ChangedFiles()[%d] = %q, but Diff.Id() = %q", i, changedFiles[i], diff.Id())
110
+
}
111
+
}
112
+
}