+2
-1
.tangled/workflows/fmt.yml
+2
-1
.tangled/workflows/fmt.yml
+740
-2
api/tangled/cbor_gen.go
+740
-2
api/tangled/cbor_gen.go
···
504
505
return nil
506
}
507
func (t *FeedStar) MarshalCBOR(w io.Writer) error {
508
if t == nil {
509
_, err := w.Write(cbg.CborNull)
···
1011
}
1012
1013
cw := cbg.NewCborWriter(w)
1014
1015
-
if _, err := cw.Write([]byte{162}); err != nil {
1016
return err
1017
}
1018
···
1047
if err := cbg.WriteBool(w, t.IsDefaultRef); err != nil {
1048
return err
1049
}
1050
return nil
1051
}
1052
···
1075
1076
n := extra
1077
1078
-
nameBuf := make([]byte, 12)
1079
for i := uint64(0); i < n; i++ {
1080
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
1081
if err != nil {
···
1128
t.IsDefaultRef = true
1129
default:
1130
return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
1131
}
1132
1133
default:
···
1425
}
1426
1427
t.Email = string(sval)
1428
}
1429
1430
default:
···
5291
}
5292
}
5293
5294
}
5295
// t.CreatedAt (string) (string)
5296
case "createdAt":
···
504
505
return nil
506
}
507
+
func (t *FeedReaction) MarshalCBOR(w io.Writer) error {
508
+
if t == nil {
509
+
_, err := w.Write(cbg.CborNull)
510
+
return err
511
+
}
512
+
513
+
cw := cbg.NewCborWriter(w)
514
+
515
+
if _, err := cw.Write([]byte{164}); err != nil {
516
+
return err
517
+
}
518
+
519
+
// t.LexiconTypeID (string) (string)
520
+
if len("$type") > 1000000 {
521
+
return xerrors.Errorf("Value in field \"$type\" was too long")
522
+
}
523
+
524
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
525
+
return err
526
+
}
527
+
if _, err := cw.WriteString(string("$type")); err != nil {
528
+
return err
529
+
}
530
+
531
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.feed.reaction"))); err != nil {
532
+
return err
533
+
}
534
+
if _, err := cw.WriteString(string("sh.tangled.feed.reaction")); err != nil {
535
+
return err
536
+
}
537
+
538
+
// t.Subject (string) (string)
539
+
if len("subject") > 1000000 {
540
+
return xerrors.Errorf("Value in field \"subject\" was too long")
541
+
}
542
+
543
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil {
544
+
return err
545
+
}
546
+
if _, err := cw.WriteString(string("subject")); err != nil {
547
+
return err
548
+
}
549
+
550
+
if len(t.Subject) > 1000000 {
551
+
return xerrors.Errorf("Value in field t.Subject was too long")
552
+
}
553
+
554
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil {
555
+
return err
556
+
}
557
+
if _, err := cw.WriteString(string(t.Subject)); err != nil {
558
+
return err
559
+
}
560
+
561
+
// t.Reaction (string) (string)
562
+
if len("reaction") > 1000000 {
563
+
return xerrors.Errorf("Value in field \"reaction\" was too long")
564
+
}
565
+
566
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("reaction"))); err != nil {
567
+
return err
568
+
}
569
+
if _, err := cw.WriteString(string("reaction")); err != nil {
570
+
return err
571
+
}
572
+
573
+
if len(t.Reaction) > 1000000 {
574
+
return xerrors.Errorf("Value in field t.Reaction was too long")
575
+
}
576
+
577
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Reaction))); err != nil {
578
+
return err
579
+
}
580
+
if _, err := cw.WriteString(string(t.Reaction)); err != nil {
581
+
return err
582
+
}
583
+
584
+
// t.CreatedAt (string) (string)
585
+
if len("createdAt") > 1000000 {
586
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
587
+
}
588
+
589
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
590
+
return err
591
+
}
592
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
593
+
return err
594
+
}
595
+
596
+
if len(t.CreatedAt) > 1000000 {
597
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
598
+
}
599
+
600
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
601
+
return err
602
+
}
603
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
604
+
return err
605
+
}
606
+
return nil
607
+
}
608
+
609
+
func (t *FeedReaction) UnmarshalCBOR(r io.Reader) (err error) {
610
+
*t = FeedReaction{}
611
+
612
+
cr := cbg.NewCborReader(r)
613
+
614
+
maj, extra, err := cr.ReadHeader()
615
+
if err != nil {
616
+
return err
617
+
}
618
+
defer func() {
619
+
if err == io.EOF {
620
+
err = io.ErrUnexpectedEOF
621
+
}
622
+
}()
623
+
624
+
if maj != cbg.MajMap {
625
+
return fmt.Errorf("cbor input should be of type map")
626
+
}
627
+
628
+
if extra > cbg.MaxLength {
629
+
return fmt.Errorf("FeedReaction: map struct too large (%d)", extra)
630
+
}
631
+
632
+
n := extra
633
+
634
+
nameBuf := make([]byte, 9)
635
+
for i := uint64(0); i < n; i++ {
636
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
637
+
if err != nil {
638
+
return err
639
+
}
640
+
641
+
if !ok {
642
+
// Field doesn't exist on this type, so ignore it
643
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
644
+
return err
645
+
}
646
+
continue
647
+
}
648
+
649
+
switch string(nameBuf[:nameLen]) {
650
+
// t.LexiconTypeID (string) (string)
651
+
case "$type":
652
+
653
+
{
654
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
655
+
if err != nil {
656
+
return err
657
+
}
658
+
659
+
t.LexiconTypeID = string(sval)
660
+
}
661
+
// t.Subject (string) (string)
662
+
case "subject":
663
+
664
+
{
665
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
666
+
if err != nil {
667
+
return err
668
+
}
669
+
670
+
t.Subject = string(sval)
671
+
}
672
+
// t.Reaction (string) (string)
673
+
case "reaction":
674
+
675
+
{
676
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
677
+
if err != nil {
678
+
return err
679
+
}
680
+
681
+
t.Reaction = string(sval)
682
+
}
683
+
// t.CreatedAt (string) (string)
684
+
case "createdAt":
685
+
686
+
{
687
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
688
+
if err != nil {
689
+
return err
690
+
}
691
+
692
+
t.CreatedAt = string(sval)
693
+
}
694
+
695
+
default:
696
+
// Field doesn't exist on this type, so ignore it
697
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
698
+
return err
699
+
}
700
+
}
701
+
}
702
+
703
+
return nil
704
+
}
705
func (t *FeedStar) MarshalCBOR(w io.Writer) error {
706
if t == nil {
707
_, err := w.Write(cbg.CborNull)
···
1209
}
1210
1211
cw := cbg.NewCborWriter(w)
1212
+
fieldCount := 3
1213
1214
+
if t.LangBreakdown == nil {
1215
+
fieldCount--
1216
+
}
1217
+
1218
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
1219
return err
1220
}
1221
···
1250
if err := cbg.WriteBool(w, t.IsDefaultRef); err != nil {
1251
return err
1252
}
1253
+
1254
+
// t.LangBreakdown (tangled.GitRefUpdate_Meta_LangBreakdown) (struct)
1255
+
if t.LangBreakdown != nil {
1256
+
1257
+
if len("langBreakdown") > 1000000 {
1258
+
return xerrors.Errorf("Value in field \"langBreakdown\" was too long")
1259
+
}
1260
+
1261
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("langBreakdown"))); err != nil {
1262
+
return err
1263
+
}
1264
+
if _, err := cw.WriteString(string("langBreakdown")); err != nil {
1265
+
return err
1266
+
}
1267
+
1268
+
if err := t.LangBreakdown.MarshalCBOR(cw); err != nil {
1269
+
return err
1270
+
}
1271
+
}
1272
return nil
1273
}
1274
···
1297
1298
n := extra
1299
1300
+
nameBuf := make([]byte, 13)
1301
for i := uint64(0); i < n; i++ {
1302
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
1303
if err != nil {
···
1350
t.IsDefaultRef = true
1351
default:
1352
return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
1353
+
}
1354
+
// t.LangBreakdown (tangled.GitRefUpdate_Meta_LangBreakdown) (struct)
1355
+
case "langBreakdown":
1356
+
1357
+
{
1358
+
1359
+
b, err := cr.ReadByte()
1360
+
if err != nil {
1361
+
return err
1362
+
}
1363
+
if b != cbg.CborNull[0] {
1364
+
if err := cr.UnreadByte(); err != nil {
1365
+
return err
1366
+
}
1367
+
t.LangBreakdown = new(GitRefUpdate_Meta_LangBreakdown)
1368
+
if err := t.LangBreakdown.UnmarshalCBOR(cr); err != nil {
1369
+
return xerrors.Errorf("unmarshaling t.LangBreakdown pointer: %w", err)
1370
+
}
1371
+
}
1372
+
1373
}
1374
1375
default:
···
1667
}
1668
1669
t.Email = string(sval)
1670
+
}
1671
+
1672
+
default:
1673
+
// Field doesn't exist on this type, so ignore it
1674
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
1675
+
return err
1676
+
}
1677
+
}
1678
+
}
1679
+
1680
+
return nil
1681
+
}
1682
+
func (t *GitRefUpdate_Meta_LangBreakdown) MarshalCBOR(w io.Writer) error {
1683
+
if t == nil {
1684
+
_, err := w.Write(cbg.CborNull)
1685
+
return err
1686
+
}
1687
+
1688
+
cw := cbg.NewCborWriter(w)
1689
+
fieldCount := 1
1690
+
1691
+
if t.Inputs == nil {
1692
+
fieldCount--
1693
+
}
1694
+
1695
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
1696
+
return err
1697
+
}
1698
+
1699
+
// t.Inputs ([]*tangled.GitRefUpdate_Pair) (slice)
1700
+
if t.Inputs != nil {
1701
+
1702
+
if len("inputs") > 1000000 {
1703
+
return xerrors.Errorf("Value in field \"inputs\" was too long")
1704
+
}
1705
+
1706
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("inputs"))); err != nil {
1707
+
return err
1708
+
}
1709
+
if _, err := cw.WriteString(string("inputs")); err != nil {
1710
+
return err
1711
+
}
1712
+
1713
+
if len(t.Inputs) > 8192 {
1714
+
return xerrors.Errorf("Slice value in field t.Inputs was too long")
1715
+
}
1716
+
1717
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Inputs))); err != nil {
1718
+
return err
1719
+
}
1720
+
for _, v := range t.Inputs {
1721
+
if err := v.MarshalCBOR(cw); err != nil {
1722
+
return err
1723
+
}
1724
+
1725
+
}
1726
+
}
1727
+
return nil
1728
+
}
1729
+
1730
+
func (t *GitRefUpdate_Meta_LangBreakdown) UnmarshalCBOR(r io.Reader) (err error) {
1731
+
*t = GitRefUpdate_Meta_LangBreakdown{}
1732
+
1733
+
cr := cbg.NewCborReader(r)
1734
+
1735
+
maj, extra, err := cr.ReadHeader()
1736
+
if err != nil {
1737
+
return err
1738
+
}
1739
+
defer func() {
1740
+
if err == io.EOF {
1741
+
err = io.ErrUnexpectedEOF
1742
+
}
1743
+
}()
1744
+
1745
+
if maj != cbg.MajMap {
1746
+
return fmt.Errorf("cbor input should be of type map")
1747
+
}
1748
+
1749
+
if extra > cbg.MaxLength {
1750
+
return fmt.Errorf("GitRefUpdate_Meta_LangBreakdown: map struct too large (%d)", extra)
1751
+
}
1752
+
1753
+
n := extra
1754
+
1755
+
nameBuf := make([]byte, 6)
1756
+
for i := uint64(0); i < n; i++ {
1757
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
1758
+
if err != nil {
1759
+
return err
1760
+
}
1761
+
1762
+
if !ok {
1763
+
// Field doesn't exist on this type, so ignore it
1764
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
1765
+
return err
1766
+
}
1767
+
continue
1768
+
}
1769
+
1770
+
switch string(nameBuf[:nameLen]) {
1771
+
// t.Inputs ([]*tangled.GitRefUpdate_Pair) (slice)
1772
+
case "inputs":
1773
+
1774
+
maj, extra, err = cr.ReadHeader()
1775
+
if err != nil {
1776
+
return err
1777
+
}
1778
+
1779
+
if extra > 8192 {
1780
+
return fmt.Errorf("t.Inputs: array too large (%d)", extra)
1781
+
}
1782
+
1783
+
if maj != cbg.MajArray {
1784
+
return fmt.Errorf("expected cbor array")
1785
+
}
1786
+
1787
+
if extra > 0 {
1788
+
t.Inputs = make([]*GitRefUpdate_Pair, extra)
1789
+
}
1790
+
1791
+
for i := 0; i < int(extra); i++ {
1792
+
{
1793
+
var maj byte
1794
+
var extra uint64
1795
+
var err error
1796
+
_ = maj
1797
+
_ = extra
1798
+
_ = err
1799
+
1800
+
{
1801
+
1802
+
b, err := cr.ReadByte()
1803
+
if err != nil {
1804
+
return err
1805
+
}
1806
+
if b != cbg.CborNull[0] {
1807
+
if err := cr.UnreadByte(); err != nil {
1808
+
return err
1809
+
}
1810
+
t.Inputs[i] = new(GitRefUpdate_Pair)
1811
+
if err := t.Inputs[i].UnmarshalCBOR(cr); err != nil {
1812
+
return xerrors.Errorf("unmarshaling t.Inputs[i] pointer: %w", err)
1813
+
}
1814
+
}
1815
+
1816
+
}
1817
+
1818
+
}
1819
+
}
1820
+
1821
+
default:
1822
+
// Field doesn't exist on this type, so ignore it
1823
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
1824
+
return err
1825
+
}
1826
+
}
1827
+
}
1828
+
1829
+
return nil
1830
+
}
1831
+
func (t *GitRefUpdate_Pair) MarshalCBOR(w io.Writer) error {
1832
+
if t == nil {
1833
+
_, err := w.Write(cbg.CborNull)
1834
+
return err
1835
+
}
1836
+
1837
+
cw := cbg.NewCborWriter(w)
1838
+
1839
+
if _, err := cw.Write([]byte{162}); err != nil {
1840
+
return err
1841
+
}
1842
+
1843
+
// t.Lang (string) (string)
1844
+
if len("lang") > 1000000 {
1845
+
return xerrors.Errorf("Value in field \"lang\" was too long")
1846
+
}
1847
+
1848
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("lang"))); err != nil {
1849
+
return err
1850
+
}
1851
+
if _, err := cw.WriteString(string("lang")); err != nil {
1852
+
return err
1853
+
}
1854
+
1855
+
if len(t.Lang) > 1000000 {
1856
+
return xerrors.Errorf("Value in field t.Lang was too long")
1857
+
}
1858
+
1859
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Lang))); err != nil {
1860
+
return err
1861
+
}
1862
+
if _, err := cw.WriteString(string(t.Lang)); err != nil {
1863
+
return err
1864
+
}
1865
+
1866
+
// t.Size (int64) (int64)
1867
+
if len("size") > 1000000 {
1868
+
return xerrors.Errorf("Value in field \"size\" was too long")
1869
+
}
1870
+
1871
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("size"))); err != nil {
1872
+
return err
1873
+
}
1874
+
if _, err := cw.WriteString(string("size")); err != nil {
1875
+
return err
1876
+
}
1877
+
1878
+
if t.Size >= 0 {
1879
+
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Size)); err != nil {
1880
+
return err
1881
+
}
1882
+
} else {
1883
+
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Size-1)); err != nil {
1884
+
return err
1885
+
}
1886
+
}
1887
+
1888
+
return nil
1889
+
}
1890
+
1891
+
func (t *GitRefUpdate_Pair) UnmarshalCBOR(r io.Reader) (err error) {
1892
+
*t = GitRefUpdate_Pair{}
1893
+
1894
+
cr := cbg.NewCborReader(r)
1895
+
1896
+
maj, extra, err := cr.ReadHeader()
1897
+
if err != nil {
1898
+
return err
1899
+
}
1900
+
defer func() {
1901
+
if err == io.EOF {
1902
+
err = io.ErrUnexpectedEOF
1903
+
}
1904
+
}()
1905
+
1906
+
if maj != cbg.MajMap {
1907
+
return fmt.Errorf("cbor input should be of type map")
1908
+
}
1909
+
1910
+
if extra > cbg.MaxLength {
1911
+
return fmt.Errorf("GitRefUpdate_Pair: map struct too large (%d)", extra)
1912
+
}
1913
+
1914
+
n := extra
1915
+
1916
+
nameBuf := make([]byte, 4)
1917
+
for i := uint64(0); i < n; i++ {
1918
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
1919
+
if err != nil {
1920
+
return err
1921
+
}
1922
+
1923
+
if !ok {
1924
+
// Field doesn't exist on this type, so ignore it
1925
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
1926
+
return err
1927
+
}
1928
+
continue
1929
+
}
1930
+
1931
+
switch string(nameBuf[:nameLen]) {
1932
+
// t.Lang (string) (string)
1933
+
case "lang":
1934
+
1935
+
{
1936
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
1937
+
if err != nil {
1938
+
return err
1939
+
}
1940
+
1941
+
t.Lang = string(sval)
1942
+
}
1943
+
// t.Size (int64) (int64)
1944
+
case "size":
1945
+
{
1946
+
maj, extra, err := cr.ReadHeader()
1947
+
if err != nil {
1948
+
return err
1949
+
}
1950
+
var extraI int64
1951
+
switch maj {
1952
+
case cbg.MajUnsignedInt:
1953
+
extraI = int64(extra)
1954
+
if extraI < 0 {
1955
+
return fmt.Errorf("int64 positive overflow")
1956
+
}
1957
+
case cbg.MajNegativeInt:
1958
+
extraI = int64(extra)
1959
+
if extraI < 0 {
1960
+
return fmt.Errorf("int64 negative overflow")
1961
+
}
1962
+
extraI = -1 - extraI
1963
+
default:
1964
+
return fmt.Errorf("wrong type for int64 field: %d", maj)
1965
+
}
1966
+
1967
+
t.Size = int64(extraI)
1968
}
1969
1970
default:
···
5831
}
5832
}
5833
5834
+
}
5835
+
// t.CreatedAt (string) (string)
5836
+
case "createdAt":
5837
+
5838
+
{
5839
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
5840
+
if err != nil {
5841
+
return err
5842
+
}
5843
+
5844
+
t.CreatedAt = string(sval)
5845
+
}
5846
+
5847
+
default:
5848
+
// Field doesn't exist on this type, so ignore it
5849
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
5850
+
return err
5851
+
}
5852
+
}
5853
+
}
5854
+
5855
+
return nil
5856
+
}
5857
+
func (t *RepoCollaborator) MarshalCBOR(w io.Writer) error {
5858
+
if t == nil {
5859
+
_, err := w.Write(cbg.CborNull)
5860
+
return err
5861
+
}
5862
+
5863
+
cw := cbg.NewCborWriter(w)
5864
+
5865
+
if _, err := cw.Write([]byte{164}); err != nil {
5866
+
return err
5867
+
}
5868
+
5869
+
// t.Repo (string) (string)
5870
+
if len("repo") > 1000000 {
5871
+
return xerrors.Errorf("Value in field \"repo\" was too long")
5872
+
}
5873
+
5874
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil {
5875
+
return err
5876
+
}
5877
+
if _, err := cw.WriteString(string("repo")); err != nil {
5878
+
return err
5879
+
}
5880
+
5881
+
if len(t.Repo) > 1000000 {
5882
+
return xerrors.Errorf("Value in field t.Repo was too long")
5883
+
}
5884
+
5885
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil {
5886
+
return err
5887
+
}
5888
+
if _, err := cw.WriteString(string(t.Repo)); err != nil {
5889
+
return err
5890
+
}
5891
+
5892
+
// t.LexiconTypeID (string) (string)
5893
+
if len("$type") > 1000000 {
5894
+
return xerrors.Errorf("Value in field \"$type\" was too long")
5895
+
}
5896
+
5897
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
5898
+
return err
5899
+
}
5900
+
if _, err := cw.WriteString(string("$type")); err != nil {
5901
+
return err
5902
+
}
5903
+
5904
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.repo.collaborator"))); err != nil {
5905
+
return err
5906
+
}
5907
+
if _, err := cw.WriteString(string("sh.tangled.repo.collaborator")); err != nil {
5908
+
return err
5909
+
}
5910
+
5911
+
// t.Subject (string) (string)
5912
+
if len("subject") > 1000000 {
5913
+
return xerrors.Errorf("Value in field \"subject\" was too long")
5914
+
}
5915
+
5916
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil {
5917
+
return err
5918
+
}
5919
+
if _, err := cw.WriteString(string("subject")); err != nil {
5920
+
return err
5921
+
}
5922
+
5923
+
if len(t.Subject) > 1000000 {
5924
+
return xerrors.Errorf("Value in field t.Subject was too long")
5925
+
}
5926
+
5927
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil {
5928
+
return err
5929
+
}
5930
+
if _, err := cw.WriteString(string(t.Subject)); err != nil {
5931
+
return err
5932
+
}
5933
+
5934
+
// t.CreatedAt (string) (string)
5935
+
if len("createdAt") > 1000000 {
5936
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
5937
+
}
5938
+
5939
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
5940
+
return err
5941
+
}
5942
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
5943
+
return err
5944
+
}
5945
+
5946
+
if len(t.CreatedAt) > 1000000 {
5947
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
5948
+
}
5949
+
5950
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
5951
+
return err
5952
+
}
5953
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
5954
+
return err
5955
+
}
5956
+
return nil
5957
+
}
5958
+
5959
+
func (t *RepoCollaborator) UnmarshalCBOR(r io.Reader) (err error) {
5960
+
*t = RepoCollaborator{}
5961
+
5962
+
cr := cbg.NewCborReader(r)
5963
+
5964
+
maj, extra, err := cr.ReadHeader()
5965
+
if err != nil {
5966
+
return err
5967
+
}
5968
+
defer func() {
5969
+
if err == io.EOF {
5970
+
err = io.ErrUnexpectedEOF
5971
+
}
5972
+
}()
5973
+
5974
+
if maj != cbg.MajMap {
5975
+
return fmt.Errorf("cbor input should be of type map")
5976
+
}
5977
+
5978
+
if extra > cbg.MaxLength {
5979
+
return fmt.Errorf("RepoCollaborator: map struct too large (%d)", extra)
5980
+
}
5981
+
5982
+
n := extra
5983
+
5984
+
nameBuf := make([]byte, 9)
5985
+
for i := uint64(0); i < n; i++ {
5986
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
5987
+
if err != nil {
5988
+
return err
5989
+
}
5990
+
5991
+
if !ok {
5992
+
// Field doesn't exist on this type, so ignore it
5993
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
5994
+
return err
5995
+
}
5996
+
continue
5997
+
}
5998
+
5999
+
switch string(nameBuf[:nameLen]) {
6000
+
// t.Repo (string) (string)
6001
+
case "repo":
6002
+
6003
+
{
6004
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6005
+
if err != nil {
6006
+
return err
6007
+
}
6008
+
6009
+
t.Repo = string(sval)
6010
+
}
6011
+
// t.LexiconTypeID (string) (string)
6012
+
case "$type":
6013
+
6014
+
{
6015
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6016
+
if err != nil {
6017
+
return err
6018
+
}
6019
+
6020
+
t.LexiconTypeID = string(sval)
6021
+
}
6022
+
// t.Subject (string) (string)
6023
+
case "subject":
6024
+
6025
+
{
6026
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6027
+
if err != nil {
6028
+
return err
6029
+
}
6030
+
6031
+
t.Subject = string(sval)
6032
}
6033
// t.CreatedAt (string) (string)
6034
case "createdAt":
+24
api/tangled/feedreaction.go
+24
api/tangled/feedreaction.go
···
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.feed.reaction
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
FeedReactionNSID = "sh.tangled.feed.reaction"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.feed.reaction", &FeedReaction{})
17
+
} //
18
+
// RECORDTYPE: FeedReaction
19
+
type FeedReaction struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.feed.reaction" cborgen:"$type,const=sh.tangled.feed.reaction"`
21
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
22
+
Reaction string `json:"reaction" cborgen:"reaction"`
23
+
Subject string `json:"subject" cborgen:"subject"`
24
+
}
+13
-2
api/tangled/gitrefUpdate.go
+13
-2
api/tangled/gitrefUpdate.go
···
34
}
35
36
type GitRefUpdate_Meta struct {
37
-
CommitCount *GitRefUpdate_Meta_CommitCount `json:"commitCount" cborgen:"commitCount"`
38
-
IsDefaultRef bool `json:"isDefaultRef" cborgen:"isDefaultRef"`
39
}
40
41
type GitRefUpdate_Meta_CommitCount struct {
···
46
Count int64 `json:"count" cborgen:"count"`
47
Email string `json:"email" cborgen:"email"`
48
}
···
34
}
35
36
type GitRefUpdate_Meta struct {
37
+
CommitCount *GitRefUpdate_Meta_CommitCount `json:"commitCount" cborgen:"commitCount"`
38
+
IsDefaultRef bool `json:"isDefaultRef" cborgen:"isDefaultRef"`
39
+
LangBreakdown *GitRefUpdate_Meta_LangBreakdown `json:"langBreakdown,omitempty" cborgen:"langBreakdown,omitempty"`
40
}
41
42
type GitRefUpdate_Meta_CommitCount struct {
···
47
Count int64 `json:"count" cborgen:"count"`
48
Email string `json:"email" cborgen:"email"`
49
}
50
+
51
+
type GitRefUpdate_Meta_LangBreakdown struct {
52
+
Inputs []*GitRefUpdate_Pair `json:"inputs,omitempty" cborgen:"inputs,omitempty"`
53
+
}
54
+
55
+
// GitRefUpdate_Pair is a "pair" in the sh.tangled.git.refUpdate schema.
56
+
type GitRefUpdate_Pair struct {
57
+
Lang string `json:"lang" cborgen:"lang"`
58
+
Size int64 `json:"size" cborgen:"size"`
59
+
}
+31
api/tangled/repoaddSecret.go
+31
api/tangled/repoaddSecret.go
···
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.addSecret
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoAddSecretNSID = "sh.tangled.repo.addSecret"
15
+
)
16
+
17
+
// RepoAddSecret_Input is the input argument to a sh.tangled.repo.addSecret call.
18
+
type RepoAddSecret_Input struct {
19
+
Key string `json:"key" cborgen:"key"`
20
+
Repo string `json:"repo" cborgen:"repo"`
21
+
Value string `json:"value" cborgen:"value"`
22
+
}
23
+
24
+
// RepoAddSecret calls the XRPC method "sh.tangled.repo.addSecret".
25
+
func RepoAddSecret(ctx context.Context, c util.LexClient, input *RepoAddSecret_Input) error {
26
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.addSecret", nil, input, nil); err != nil {
27
+
return err
28
+
}
29
+
30
+
return nil
31
+
}
+25
api/tangled/repocollaborator.go
+25
api/tangled/repocollaborator.go
···
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.collaborator
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
RepoCollaboratorNSID = "sh.tangled.repo.collaborator"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.repo.collaborator", &RepoCollaborator{})
17
+
} //
18
+
// RECORDTYPE: RepoCollaborator
19
+
type RepoCollaborator struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.collaborator" cborgen:"$type,const=sh.tangled.repo.collaborator"`
21
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
22
+
// repo: repo to add this user to
23
+
Repo string `json:"repo" cborgen:"repo"`
24
+
Subject string `json:"subject" cborgen:"subject"`
25
+
}
+41
api/tangled/repolistSecrets.go
+41
api/tangled/repolistSecrets.go
···
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.listSecrets
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoListSecretsNSID = "sh.tangled.repo.listSecrets"
15
+
)
16
+
17
+
// RepoListSecrets_Output is the output of a sh.tangled.repo.listSecrets call.
18
+
type RepoListSecrets_Output struct {
19
+
Secrets []*RepoListSecrets_Secret `json:"secrets" cborgen:"secrets"`
20
+
}
21
+
22
+
// RepoListSecrets_Secret is a "secret" in the sh.tangled.repo.listSecrets schema.
23
+
type RepoListSecrets_Secret struct {
24
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
25
+
CreatedBy string `json:"createdBy" cborgen:"createdBy"`
26
+
Key string `json:"key" cborgen:"key"`
27
+
Repo string `json:"repo" cborgen:"repo"`
28
+
}
29
+
30
+
// RepoListSecrets calls the XRPC method "sh.tangled.repo.listSecrets".
31
+
func RepoListSecrets(ctx context.Context, c util.LexClient, repo string) (*RepoListSecrets_Output, error) {
32
+
var out RepoListSecrets_Output
33
+
34
+
params := map[string]interface{}{}
35
+
params["repo"] = repo
36
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.listSecrets", params, nil, &out); err != nil {
37
+
return nil, err
38
+
}
39
+
40
+
return &out, nil
41
+
}
+30
api/tangled/reporemoveSecret.go
+30
api/tangled/reporemoveSecret.go
···
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.removeSecret
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoRemoveSecretNSID = "sh.tangled.repo.removeSecret"
15
+
)
16
+
17
+
// RepoRemoveSecret_Input is the input argument to a sh.tangled.repo.removeSecret call.
18
+
type RepoRemoveSecret_Input struct {
19
+
Key string `json:"key" cborgen:"key"`
20
+
Repo string `json:"repo" cborgen:"repo"`
21
+
}
22
+
23
+
// RepoRemoveSecret calls the XRPC method "sh.tangled.repo.removeSecret".
24
+
func RepoRemoveSecret(ctx context.Context, c util.LexClient, input *RepoRemoveSecret_Input) error {
25
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.removeSecret", nil, input, nil); err != nil {
26
+
return err
27
+
}
28
+
29
+
return nil
30
+
}
+30
api/tangled/reposetDefaultBranch.go
+30
api/tangled/reposetDefaultBranch.go
···
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.setDefaultBranch
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoSetDefaultBranchNSID = "sh.tangled.repo.setDefaultBranch"
15
+
)
16
+
17
+
// RepoSetDefaultBranch_Input is the input argument to a sh.tangled.repo.setDefaultBranch call.
18
+
type RepoSetDefaultBranch_Input struct {
19
+
DefaultBranch string `json:"defaultBranch" cborgen:"defaultBranch"`
20
+
Repo string `json:"repo" cborgen:"repo"`
21
+
}
22
+
23
+
// RepoSetDefaultBranch calls the XRPC method "sh.tangled.repo.setDefaultBranch".
24
+
func RepoSetDefaultBranch(ctx context.Context, c util.LexClient, input *RepoSetDefaultBranch_Input) error {
25
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.setDefaultBranch", nil, input, nil); err != nil {
26
+
return err
27
+
}
28
+
29
+
return nil
30
+
}
+3
-1
api/tangled/stateclosed.go
+3
-1
api/tangled/stateclosed.go
+3
-1
api/tangled/stateopen.go
+3
-1
api/tangled/stateopen.go
+3
-1
api/tangled/statusclosed.go
+3
-1
api/tangled/statusclosed.go
+3
-1
api/tangled/statusmerged.go
+3
-1
api/tangled/statusmerged.go
+3
-1
api/tangled/statusopen.go
+3
-1
api/tangled/statusopen.go
+18
-5
appview/config/config.go
+18
-5
appview/config/config.go
···
10
)
11
12
type CoreConfig struct {
13
-
CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"`
14
-
DbPath string `env:"DB_PATH, default=appview.db"`
15
-
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"`
16
-
AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"`
17
-
Dev bool `env:"DEV, default=false"`
18
}
19
20
type OAuthConfig struct {
···
59
DB int `env:"DB, default=0"`
60
}
61
62
func (cfg RedisConfig) ToURL() string {
63
u := &url.URL{
64
Scheme: "redis",
···
84
Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"`
85
OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"`
86
Redis RedisConfig `env:",prefix=TANGLED_REDIS_"`
87
}
88
89
func LoadConfig(ctx context.Context) (*Config, error) {
···
10
)
11
12
type CoreConfig struct {
13
+
CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"`
14
+
DbPath string `env:"DB_PATH, default=appview.db"`
15
+
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"`
16
+
AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"`
17
+
Dev bool `env:"DEV, default=false"`
18
+
DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"`
19
}
20
21
type OAuthConfig struct {
···
60
DB int `env:"DB, default=0"`
61
}
62
63
+
type PdsConfig struct {
64
+
Host string `env:"HOST, default=https://tngl.sh"`
65
+
AdminSecret string `env:"ADMIN_SECRET"`
66
+
}
67
+
68
+
type Cloudflare struct {
69
+
ApiToken string `env:"API_TOKEN"`
70
+
ZoneId string `env:"ZONE_ID"`
71
+
}
72
+
73
func (cfg RedisConfig) ToURL() string {
74
u := &url.URL{
75
Scheme: "redis",
···
95
Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"`
96
OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"`
97
Redis RedisConfig `env:",prefix=TANGLED_REDIS_"`
98
+
Pds PdsConfig `env:",prefix=TANGLED_PDS_"`
99
+
Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"`
100
}
101
102
func LoadConfig(ctx context.Context) (*Config, error) {
+76
appview/db/collaborators.go
+76
appview/db/collaborators.go
···
···
1
+
package db
2
+
3
+
import (
4
+
"fmt"
5
+
"strings"
6
+
"time"
7
+
8
+
"github.com/bluesky-social/indigo/atproto/syntax"
9
+
)
10
+
11
+
type Collaborator struct {
12
+
// identifiers for the record
13
+
Id int64
14
+
Did syntax.DID
15
+
Rkey string
16
+
17
+
// content
18
+
SubjectDid syntax.DID
19
+
RepoAt syntax.ATURI
20
+
21
+
// meta
22
+
Created time.Time
23
+
}
24
+
25
+
func AddCollaborator(e Execer, c Collaborator) error {
26
+
_, err := e.Exec(
27
+
`insert into collaborators (did, rkey, subject_did, repo_at) values (?, ?, ?, ?);`,
28
+
c.Did, c.Rkey, c.SubjectDid, c.RepoAt,
29
+
)
30
+
return err
31
+
}
32
+
33
+
func DeleteCollaborator(e Execer, filters ...filter) error {
34
+
var conditions []string
35
+
var args []any
36
+
for _, filter := range filters {
37
+
conditions = append(conditions, filter.Condition())
38
+
args = append(args, filter.Arg()...)
39
+
}
40
+
41
+
whereClause := ""
42
+
if conditions != nil {
43
+
whereClause = " where " + strings.Join(conditions, " and ")
44
+
}
45
+
46
+
query := fmt.Sprintf(`delete from collaborators %s`, whereClause)
47
+
48
+
_, err := e.Exec(query, args...)
49
+
return err
50
+
}
51
+
52
+
func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) {
53
+
rows, err := e.Query(`select repo_at from collaborators where subject_did = ?`, collaborator)
54
+
if err != nil {
55
+
return nil, err
56
+
}
57
+
defer rows.Close()
58
+
59
+
var repoAts []string
60
+
for rows.Next() {
61
+
var aturi string
62
+
err := rows.Scan(&aturi)
63
+
if err != nil {
64
+
return nil, err
65
+
}
66
+
repoAts = append(repoAts, aturi)
67
+
}
68
+
if err := rows.Err(); err != nil {
69
+
return nil, err
70
+
}
71
+
if repoAts == nil {
72
+
return nil, nil
73
+
}
74
+
75
+
return GetRepos(e, 0, FilterIn("at_uri", repoAts))
76
+
}
+91
-3
appview/db/db.go
+91
-3
appview/db/db.go
···
199
unique(starred_by_did, repo_at)
200
);
201
202
create table if not exists emails (
203
id integer primary key autoincrement,
204
did text not null,
···
345
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
346
347
-- constraints
348
-
foreign key (did, instance) references spindles(owner, instance) on delete cascade,
349
unique (did, instance, subject)
350
);
351
···
411
on delete cascade
412
);
413
414
create table if not exists migrations (
415
id integer primary key autoincrement,
416
name text unique
···
553
return nil
554
})
555
556
return &DB{db}, nil
557
}
558
···
628
// if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)`
629
if kind == reflect.Slice || kind == reflect.Array {
630
if rv.Len() == 0 {
631
-
panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key))
632
}
633
634
placeholders := make([]string, rv.Len())
···
647
kind := rv.Kind()
648
if kind == reflect.Slice || kind == reflect.Array {
649
if rv.Len() == 0 {
650
-
panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key))
651
}
652
653
out := make([]any, rv.Len())
···
199
unique(starred_by_did, repo_at)
200
);
201
202
+
create table if not exists reactions (
203
+
id integer primary key autoincrement,
204
+
reacted_by_did text not null,
205
+
thread_at text not null,
206
+
kind text not null,
207
+
rkey text not null,
208
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
209
+
unique(reacted_by_did, thread_at, kind)
210
+
);
211
+
212
create table if not exists emails (
213
id integer primary key autoincrement,
214
did text not null,
···
355
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
356
357
-- constraints
358
unique (did, instance, subject)
359
);
360
···
420
on delete cascade
421
);
422
423
+
create table if not exists repo_languages (
424
+
-- identifiers
425
+
id integer primary key autoincrement,
426
+
427
+
-- repo identifiers
428
+
repo_at text not null,
429
+
ref text not null,
430
+
is_default_ref integer not null default 0,
431
+
432
+
-- language breakdown
433
+
language text not null,
434
+
bytes integer not null check (bytes >= 0),
435
+
436
+
unique(repo_at, ref, language)
437
+
);
438
+
439
+
create table if not exists signups_inflight (
440
+
id integer primary key autoincrement,
441
+
email text not null unique,
442
+
invite_code text not null,
443
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
444
+
);
445
+
446
create table if not exists migrations (
447
id integer primary key autoincrement,
448
name text unique
···
585
return nil
586
})
587
588
+
// recreate and add rkey + created columns with default constraint
589
+
runMigration(db, "rework-collaborators-table", func(tx *sql.Tx) error {
590
+
// create new table
591
+
// - repo_at instead of repo integer
592
+
// - rkey field
593
+
// - created field
594
+
_, err := tx.Exec(`
595
+
create table collaborators_new (
596
+
-- identifiers for the record
597
+
id integer primary key autoincrement,
598
+
did text not null,
599
+
rkey text,
600
+
601
+
-- content
602
+
subject_did text not null,
603
+
repo_at text not null,
604
+
605
+
-- meta
606
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
607
+
608
+
-- constraints
609
+
foreign key (repo_at) references repos(at_uri) on delete cascade
610
+
)
611
+
`)
612
+
if err != nil {
613
+
return err
614
+
}
615
+
616
+
// copy data
617
+
_, err = tx.Exec(`
618
+
insert into collaborators_new (id, did, rkey, subject_did, repo_at)
619
+
select
620
+
c.id,
621
+
r.did,
622
+
'',
623
+
c.did,
624
+
r.at_uri
625
+
from collaborators c
626
+
join repos r on c.repo = r.id
627
+
`)
628
+
if err != nil {
629
+
return err
630
+
}
631
+
632
+
// drop old table
633
+
_, err = tx.Exec(`drop table collaborators`)
634
+
if err != nil {
635
+
return err
636
+
}
637
+
638
+
// rename new table
639
+
_, err = tx.Exec(`alter table collaborators_new rename to collaborators`)
640
+
return err
641
+
})
642
+
643
return &DB{db}, nil
644
}
645
···
715
// if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)`
716
if kind == reflect.Slice || kind == reflect.Array {
717
if rv.Len() == 0 {
718
+
// always false
719
+
return "1 = 0"
720
}
721
722
placeholders := make([]string, rv.Len())
···
735
kind := rv.Kind()
736
if kind == reflect.Slice || kind == reflect.Array {
737
if rv.Len() == 0 {
738
+
return nil
739
}
740
741
out := make([]any, rv.Len())
+16
-2
appview/db/email.go
+16
-2
appview/db/email.go
···
103
query := `
104
select email, did
105
from emails
106
+
where
107
+
verified = ?
108
and email in (` + strings.Join(placeholders, ",") + `)
109
`
110
···
153
`
154
var count int
155
err := e.QueryRow(query, did, email).Scan(&count)
156
+
if err != nil {
157
+
return false, err
158
+
}
159
+
return count > 0, nil
160
+
}
161
+
162
+
func CheckEmailExistsAtAll(e Execer, email string) (bool, error) {
163
+
query := `
164
+
select count(*)
165
+
from emails
166
+
where email = ?
167
+
`
168
+
var count int
169
+
err := e.QueryRow(query, email).Scan(&count)
170
if err != nil {
171
return false, err
172
}
+2
-2
appview/db/follow.go
+2
-2
appview/db/follow.go
+17
-12
appview/db/issues.go
+17
-12
appview/db/issues.go
···
9
)
10
11
type Issue struct {
12
RepoAt syntax.ATURI
13
OwnerDid string
14
IssueId int
···
65
66
issue.IssueId = nextId
67
68
-
_, err = tx.Exec(`
69
insert into issues (repo_at, owner_did, issue_id, title, body)
70
values (?, ?, ?, ?, ?)
71
`, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body)
72
if err != nil {
73
return err
74
}
75
76
if err := tx.Commit(); err != nil {
77
return err
···
89
var issueAt string
90
err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt)
91
return issueAt, err
92
-
}
93
-
94
-
func GetIssueId(e Execer, repoAt syntax.ATURI) (int, error) {
95
-
var issueId int
96
-
err := e.QueryRow(`select next_issue_id from repo_issue_seqs where repo_at = ?`, repoAt).Scan(&issueId)
97
-
return issueId - 1, err
98
}
99
100
func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
···
114
`
115
with numbered_issue as (
116
select
117
i.owner_did,
118
i.issue_id,
119
i.created,
···
132
i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
133
)
134
select
135
owner_did,
136
issue_id,
137
created,
···
153
var issue Issue
154
var createdAt string
155
var metadata IssueMetadata
156
-
err := rows.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount)
157
if err != nil {
158
return nil, err
159
}
···
182
183
rows, err := e.Query(
184
`select
185
i.owner_did,
186
i.repo_at,
187
i.issue_id,
···
213
var issueCreatedAt, repoCreatedAt string
214
var repo Repo
215
err := rows.Scan(
216
&issue.OwnerDid,
217
&issue.RepoAt,
218
&issue.IssueId,
···
257
}
258
259
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) {
260
-
query := `select owner_did, created, title, body, open from issues where repo_at = ? and issue_id = ?`
261
row := e.QueryRow(query, repoAt, issueId)
262
263
var issue Issue
264
var createdAt string
265
-
err := row.Scan(&issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open)
266
if err != nil {
267
return nil, err
268
}
···
277
}
278
279
func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) {
280
-
query := `select owner_did, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?`
281
row := e.QueryRow(query, repoAt, issueId)
282
283
var issue Issue
284
var createdAt string
285
-
err := row.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open)
286
if err != nil {
287
return nil, nil, err
288
}
···
9
)
10
11
type Issue struct {
12
+
ID int64
13
RepoAt syntax.ATURI
14
OwnerDid string
15
IssueId int
···
66
67
issue.IssueId = nextId
68
69
+
res, err := tx.Exec(`
70
insert into issues (repo_at, owner_did, issue_id, title, body)
71
values (?, ?, ?, ?, ?)
72
`, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body)
73
if err != nil {
74
return err
75
}
76
+
77
+
lastID, err := res.LastInsertId()
78
+
if err != nil {
79
+
return err
80
+
}
81
+
issue.ID = lastID
82
83
if err := tx.Commit(); err != nil {
84
return err
···
96
var issueAt string
97
err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt)
98
return issueAt, err
99
}
100
101
func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
···
115
`
116
with numbered_issue as (
117
select
118
+
i.id,
119
i.owner_did,
120
i.issue_id,
121
i.created,
···
134
i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
135
)
136
select
137
+
id,
138
owner_did,
139
issue_id,
140
created,
···
156
var issue Issue
157
var createdAt string
158
var metadata IssueMetadata
159
+
err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount)
160
if err != nil {
161
return nil, err
162
}
···
185
186
rows, err := e.Query(
187
`select
188
+
i.id,
189
i.owner_did,
190
i.repo_at,
191
i.issue_id,
···
217
var issueCreatedAt, repoCreatedAt string
218
var repo Repo
219
err := rows.Scan(
220
+
&issue.ID,
221
&issue.OwnerDid,
222
&issue.RepoAt,
223
&issue.IssueId,
···
262
}
263
264
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) {
265
+
query := `select id, owner_did, created, title, body, open from issues where repo_at = ? and issue_id = ?`
266
row := e.QueryRow(query, repoAt, issueId)
267
268
var issue Issue
269
var createdAt string
270
+
err := row.Scan(&issue.ID, &issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open)
271
if err != nil {
272
return nil, err
273
}
···
282
}
283
284
func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) {
285
+
query := `select id, owner_did, issue_id, created, title, body, open, issue_at from issues where repo_at = ? and issue_id = ?`
286
row := e.QueryRow(query, repoAt, issueId)
287
288
var issue Issue
289
var createdAt string
290
+
err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt)
291
if err != nil {
292
return nil, nil, err
293
}
+93
appview/db/language.go
+93
appview/db/language.go
···
···
1
+
package db
2
+
3
+
import (
4
+
"fmt"
5
+
"strings"
6
+
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
8
+
)
9
+
10
+
type RepoLanguage struct {
11
+
Id int64
12
+
RepoAt syntax.ATURI
13
+
Ref string
14
+
IsDefaultRef bool
15
+
Language string
16
+
Bytes int64
17
+
}
18
+
19
+
func GetRepoLanguages(e Execer, filters ...filter) ([]RepoLanguage, error) {
20
+
var conditions []string
21
+
var args []any
22
+
for _, filter := range filters {
23
+
conditions = append(conditions, filter.Condition())
24
+
args = append(args, filter.Arg()...)
25
+
}
26
+
27
+
whereClause := ""
28
+
if conditions != nil {
29
+
whereClause = " where " + strings.Join(conditions, " and ")
30
+
}
31
+
32
+
query := fmt.Sprintf(
33
+
`select id, repo_at, ref, is_default_ref, language, bytes from repo_languages %s`,
34
+
whereClause,
35
+
)
36
+
rows, err := e.Query(query, args...)
37
+
38
+
if err != nil {
39
+
return nil, fmt.Errorf("failed to execute query: %w ", err)
40
+
}
41
+
42
+
var langs []RepoLanguage
43
+
for rows.Next() {
44
+
var rl RepoLanguage
45
+
var isDefaultRef int
46
+
47
+
err := rows.Scan(
48
+
&rl.Id,
49
+
&rl.RepoAt,
50
+
&rl.Ref,
51
+
&isDefaultRef,
52
+
&rl.Language,
53
+
&rl.Bytes,
54
+
)
55
+
if err != nil {
56
+
return nil, fmt.Errorf("failed to scan: %w ", err)
57
+
}
58
+
59
+
if isDefaultRef != 0 {
60
+
rl.IsDefaultRef = true
61
+
}
62
+
63
+
langs = append(langs, rl)
64
+
}
65
+
if err = rows.Err(); err != nil {
66
+
return nil, fmt.Errorf("failed to scan rows: %w ", err)
67
+
}
68
+
69
+
return langs, nil
70
+
}
71
+
72
+
func InsertRepoLanguages(e Execer, langs []RepoLanguage) error {
73
+
stmt, err := e.Prepare(
74
+
"insert or replace into repo_languages (repo_at, ref, is_default_ref, language, bytes) values (?, ?, ?, ?, ?)",
75
+
)
76
+
if err != nil {
77
+
return err
78
+
}
79
+
80
+
for _, l := range langs {
81
+
isDefaultRef := 0
82
+
if l.IsDefaultRef {
83
+
isDefaultRef = 1
84
+
}
85
+
86
+
_, err := stmt.Exec(l.RepoAt, l.Ref, isDefaultRef, l.Language, l.Bytes)
87
+
if err != nil {
88
+
return err
89
+
}
90
+
}
91
+
92
+
return nil
93
+
}
+108
appview/db/profile.go
+108
appview/db/profile.go
···
348
return tx.Commit()
349
}
350
351
+
func GetProfiles(e Execer, filters ...filter) ([]Profile, error) {
352
+
var conditions []string
353
+
var args []any
354
+
for _, filter := range filters {
355
+
conditions = append(conditions, filter.Condition())
356
+
args = append(args, filter.Arg()...)
357
+
}
358
+
359
+
whereClause := ""
360
+
if conditions != nil {
361
+
whereClause = " where " + strings.Join(conditions, " and ")
362
+
}
363
+
364
+
profilesQuery := fmt.Sprintf(
365
+
`select
366
+
id,
367
+
did,
368
+
description,
369
+
include_bluesky,
370
+
location
371
+
from
372
+
profile
373
+
%s`,
374
+
whereClause,
375
+
)
376
+
rows, err := e.Query(profilesQuery, args...)
377
+
if err != nil {
378
+
return nil, err
379
+
}
380
+
381
+
profileMap := make(map[string]*Profile)
382
+
for rows.Next() {
383
+
var profile Profile
384
+
var includeBluesky int
385
+
386
+
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location)
387
+
if err != nil {
388
+
return nil, err
389
+
}
390
+
391
+
if includeBluesky != 0 {
392
+
profile.IncludeBluesky = true
393
+
}
394
+
395
+
profileMap[profile.Did] = &profile
396
+
}
397
+
if err = rows.Err(); err != nil {
398
+
return nil, err
399
+
}
400
+
401
+
// populate profile links
402
+
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(profileMap)), ", ")
403
+
args = make([]any, len(profileMap))
404
+
i := 0
405
+
for did := range profileMap {
406
+
args[i] = did
407
+
i++
408
+
}
409
+
410
+
linksQuery := fmt.Sprintf("select link, did from profile_links where did in (%s)", inClause)
411
+
rows, err = e.Query(linksQuery, args...)
412
+
if err != nil {
413
+
return nil, err
414
+
}
415
+
idxs := make(map[string]int)
416
+
for did := range profileMap {
417
+
idxs[did] = 0
418
+
}
419
+
for rows.Next() {
420
+
var link, did string
421
+
if err = rows.Scan(&link, &did); err != nil {
422
+
return nil, err
423
+
}
424
+
425
+
idx := idxs[did]
426
+
profileMap[did].Links[idx] = link
427
+
idxs[did] = idx + 1
428
+
}
429
+
430
+
pinsQuery := fmt.Sprintf("select at_uri, did from profile_pinned_repositories where did in (%s)", inClause)
431
+
rows, err = e.Query(pinsQuery, args...)
432
+
if err != nil {
433
+
return nil, err
434
+
}
435
+
idxs = make(map[string]int)
436
+
for did := range profileMap {
437
+
idxs[did] = 0
438
+
}
439
+
for rows.Next() {
440
+
var link syntax.ATURI
441
+
var did string
442
+
if err = rows.Scan(&link, &did); err != nil {
443
+
return nil, err
444
+
}
445
+
446
+
idx := idxs[did]
447
+
profileMap[did].PinnedRepos[idx] = link
448
+
idxs[did] = idx + 1
449
+
}
450
+
451
+
var profiles []Profile
452
+
for _, p := range profileMap {
453
+
profiles = append(profiles, *p)
454
+
}
455
+
456
+
return profiles, nil
457
+
}
458
+
459
func GetProfile(e Execer, did string) (*Profile, error) {
460
var profile Profile
461
profile.Did = did
+141
appview/db/reaction.go
+141
appview/db/reaction.go
···
···
1
+
package db
2
+
3
+
import (
4
+
"log"
5
+
"time"
6
+
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
8
+
)
9
+
10
+
type ReactionKind string
11
+
12
+
const (
13
+
Like ReactionKind = "๐"
14
+
Unlike = "๐"
15
+
Laugh = "๐"
16
+
Celebration = "๐"
17
+
Confused = "๐ซค"
18
+
Heart = "โค๏ธ"
19
+
Rocket = "๐"
20
+
Eyes = "๐"
21
+
)
22
+
23
+
func (rk ReactionKind) String() string {
24
+
return string(rk)
25
+
}
26
+
27
+
var OrderedReactionKinds = []ReactionKind{
28
+
Like,
29
+
Unlike,
30
+
Laugh,
31
+
Celebration,
32
+
Confused,
33
+
Heart,
34
+
Rocket,
35
+
Eyes,
36
+
}
37
+
38
+
func ParseReactionKind(raw string) (ReactionKind, bool) {
39
+
k, ok := (map[string]ReactionKind{
40
+
"๐": Like,
41
+
"๐": Unlike,
42
+
"๐": Laugh,
43
+
"๐": Celebration,
44
+
"๐ซค": Confused,
45
+
"โค๏ธ": Heart,
46
+
"๐": Rocket,
47
+
"๐": Eyes,
48
+
})[raw]
49
+
return k, ok
50
+
}
51
+
52
+
type Reaction struct {
53
+
ReactedByDid string
54
+
ThreadAt syntax.ATURI
55
+
Created time.Time
56
+
Rkey string
57
+
Kind ReactionKind
58
+
}
59
+
60
+
func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind, rkey string) error {
61
+
query := `insert or ignore into reactions (reacted_by_did, thread_at, kind, rkey) values (?, ?, ?, ?)`
62
+
_, err := e.Exec(query, reactedByDid, threadAt, kind, rkey)
63
+
return err
64
+
}
65
+
66
+
// Get a reaction record
67
+
func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) (*Reaction, error) {
68
+
query := `
69
+
select reacted_by_did, thread_at, created, rkey
70
+
from reactions
71
+
where reacted_by_did = ? and thread_at = ? and kind = ?`
72
+
row := e.QueryRow(query, reactedByDid, threadAt, kind)
73
+
74
+
var reaction Reaction
75
+
var created string
76
+
err := row.Scan(&reaction.ReactedByDid, &reaction.ThreadAt, &created, &reaction.Rkey)
77
+
if err != nil {
78
+
return nil, err
79
+
}
80
+
81
+
createdAtTime, err := time.Parse(time.RFC3339, created)
82
+
if err != nil {
83
+
log.Println("unable to determine followed at time")
84
+
reaction.Created = time.Now()
85
+
} else {
86
+
reaction.Created = createdAtTime
87
+
}
88
+
89
+
return &reaction, nil
90
+
}
91
+
92
+
// Remove a reaction
93
+
func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) error {
94
+
_, err := e.Exec(`delete from reactions where reacted_by_did = ? and thread_at = ? and kind = ?`, reactedByDid, threadAt, kind)
95
+
return err
96
+
}
97
+
98
+
// Remove a reaction
99
+
func DeleteReactionByRkey(e Execer, reactedByDid string, rkey string) error {
100
+
_, err := e.Exec(`delete from reactions where reacted_by_did = ? and rkey = ?`, reactedByDid, rkey)
101
+
return err
102
+
}
103
+
104
+
func GetReactionCount(e Execer, threadAt syntax.ATURI, kind ReactionKind) (int, error) {
105
+
count := 0
106
+
err := e.QueryRow(
107
+
`select count(reacted_by_did) from reactions where thread_at = ? and kind = ?`, threadAt, kind).Scan(&count)
108
+
if err != nil {
109
+
return 0, err
110
+
}
111
+
return count, nil
112
+
}
113
+
114
+
func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[ReactionKind]int, error) {
115
+
countMap := map[ReactionKind]int{}
116
+
for _, kind := range OrderedReactionKinds {
117
+
count, err := GetReactionCount(e, threadAt, kind)
118
+
if err != nil {
119
+
return map[ReactionKind]int{}, nil
120
+
}
121
+
countMap[kind] = count
122
+
}
123
+
return countMap, nil
124
+
}
125
+
126
+
func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind ReactionKind) bool {
127
+
if _, err := GetReaction(e, userDid, threadAt, kind); err != nil {
128
+
return false
129
+
} else {
130
+
return true
131
+
}
132
+
}
133
+
134
+
func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[ReactionKind]bool {
135
+
statusMap := map[ReactionKind]bool{}
136
+
for _, kind := range OrderedReactionKinds {
137
+
count := GetReactionStatus(e, userDid, threadAt, kind)
138
+
statusMap[kind] = count
139
+
}
140
+
return statusMap
141
+
}
+5
-4
appview/db/registration.go
+5
-4
appview/db/registration.go
···
10
)
11
12
type Registration struct {
13
Domain string
14
ByDid string
15
Created *time.Time
···
36
var registrations []Registration
37
38
rows, err := e.Query(`
39
-
select domain, did, created, registered from registrations
40
where did = ?
41
`, did)
42
if err != nil {
···
47
var createdAt *string
48
var registeredAt *string
49
var registration Registration
50
-
err = rows.Scan(®istration.Domain, ®istration.ByDid, &createdAt, ®isteredAt)
51
52
if err != nil {
53
log.Println(err)
···
75
var registration Registration
76
77
err := e.QueryRow(`
78
-
select domain, did, created, registered from registrations
79
where domain = ?
80
-
`, domain).Scan(®istration.Domain, ®istration.ByDid, &createdAt, ®isteredAt)
81
82
if err != nil {
83
if err == sql.ErrNoRows {
···
10
)
11
12
type Registration struct {
13
+
Id int64
14
Domain string
15
ByDid string
16
Created *time.Time
···
37
var registrations []Registration
38
39
rows, err := e.Query(`
40
+
select id, domain, did, created, registered from registrations
41
where did = ?
42
`, did)
43
if err != nil {
···
48
var createdAt *string
49
var registeredAt *string
50
var registration Registration
51
+
err = rows.Scan(®istration.Id, ®istration.Domain, ®istration.ByDid, &createdAt, ®isteredAt)
52
53
if err != nil {
54
log.Println(err)
···
76
var registration Registration
77
78
err := e.QueryRow(`
79
+
select id, domain, did, created, registered from registrations
80
where domain = ?
81
+
`, domain).Scan(®istration.Id, ®istration.Domain, ®istration.ByDid, &createdAt, ®isteredAt)
82
83
if err != nil {
84
if err == sql.ErrNoRows {
+68
-70
appview/db/repos.go
+68
-70
appview/db/repos.go
···
3
import (
4
"database/sql"
5
"fmt"
6
"strings"
7
"time"
8
···
71
return repos, nil
72
}
73
74
-
func GetRepos(e Execer, filters ...filter) ([]Repo, error) {
75
-
repoMap := make(map[syntax.ATURI]Repo)
76
77
var conditions []string
78
var args []any
···
86
whereClause = " where " + strings.Join(conditions, " and ")
87
}
88
89
repoQuery := fmt.Sprintf(
90
`select
91
did,
···
98
spindle
99
from
100
repos r
101
%s`,
102
whereClause,
103
)
104
rows, err := e.Query(repoQuery, args...)
105
···
139
repo.Spindle = spindle.String
140
}
141
142
-
repoMap[repo.RepoAt()] = repo
143
}
144
145
if err = rows.Err(); err != nil {
···
148
149
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(repoMap)), ", ")
150
args = make([]any, len(repoMap))
151
for _, r := range repoMap {
152
-
args = append(args, r.RepoAt())
153
}
154
155
starCountQuery := fmt.Sprintf(
···
168
var repoat string
169
var count int
170
if err := rows.Scan(&repoat, &count); err != nil {
171
continue
172
}
173
if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
···
196
var repoat string
197
var open, closed int
198
if err := rows.Scan(&repoat, &open, &closed); err != nil {
199
continue
200
}
201
if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
···
236
var repoat string
237
var open, merged, closed, deleted int
238
if err := rows.Scan(&repoat, &open, &merged, &closed, &deleted); err != nil {
239
continue
240
}
241
if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
···
251
252
var repos []Repo
253
for _, r := range repoMap {
254
-
repos = append(repos, r)
255
}
256
257
return repos, nil
258
}
259
···
488
return &repo, nil
489
}
490
491
-
func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error {
492
-
_, err := e.Exec(
493
-
`insert into collaborators (did, repo)
494
-
values (?, (select id from repos where did = ? and name = ? and knot = ?));`,
495
-
collaborator, repoOwnerDid, repoName, repoKnot)
496
-
return err
497
-
}
498
-
499
func UpdateDescription(e Execer, repoAt, newDescription string) error {
500
_, err := e.Exec(
501
`update repos set description = ? where at_uri = ?`, newDescription, repoAt)
···
508
return err
509
}
510
511
-
func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) {
512
-
var repos []Repo
513
-
514
-
rows, err := e.Query(
515
-
`select
516
-
r.did, r.name, r.knot, r.rkey, r.description, r.created, count(s.id) as star_count
517
-
from
518
-
repos r
519
-
join
520
-
collaborators c on r.id = c.repo
521
-
left join
522
-
stars s on r.at_uri = s.repo_at
523
-
where
524
-
c.did = ?
525
-
group by
526
-
r.id;`, collaborator)
527
-
if err != nil {
528
-
return nil, err
529
-
}
530
-
defer rows.Close()
531
-
532
-
for rows.Next() {
533
-
var repo Repo
534
-
var repoStats RepoStats
535
-
var createdAt string
536
-
var nullableDescription sql.NullString
537
-
538
-
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repoStats.StarCount)
539
-
if err != nil {
540
-
return nil, err
541
-
}
542
-
543
-
if nullableDescription.Valid {
544
-
repo.Description = nullableDescription.String
545
-
} else {
546
-
repo.Description = ""
547
-
}
548
-
549
-
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
550
-
if err != nil {
551
-
repo.Created = time.Now()
552
-
} else {
553
-
repo.Created = createdAtTime
554
-
}
555
-
556
-
repo.RepoStats = &repoStats
557
-
558
-
repos = append(repos, repo)
559
-
}
560
-
561
-
if err := rows.Err(); err != nil {
562
-
return nil, err
563
-
}
564
-
565
-
return repos, nil
566
-
}
567
-
568
type RepoStats struct {
569
StarCount int
570
IssueCount IssueCount
571
PullCount PullCount
···
3
import (
4
"database/sql"
5
"fmt"
6
+
"log"
7
+
"slices"
8
"strings"
9
"time"
10
···
73
return repos, nil
74
}
75
76
+
func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) {
77
+
repoMap := make(map[syntax.ATURI]*Repo)
78
79
var conditions []string
80
var args []any
···
88
whereClause = " where " + strings.Join(conditions, " and ")
89
}
90
91
+
limitClause := ""
92
+
if limit != 0 {
93
+
limitClause = fmt.Sprintf(" limit %d", limit)
94
+
}
95
+
96
repoQuery := fmt.Sprintf(
97
`select
98
did,
···
105
spindle
106
from
107
repos r
108
+
%s
109
+
order by created desc
110
%s`,
111
whereClause,
112
+
limitClause,
113
)
114
rows, err := e.Query(repoQuery, args...)
115
···
149
repo.Spindle = spindle.String
150
}
151
152
+
repo.RepoStats = &RepoStats{}
153
+
repoMap[repo.RepoAt()] = &repo
154
}
155
156
if err = rows.Err(); err != nil {
···
159
160
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(repoMap)), ", ")
161
args = make([]any, len(repoMap))
162
+
163
+
i := 0
164
for _, r := range repoMap {
165
+
args[i] = r.RepoAt()
166
+
i++
167
+
}
168
+
169
+
languageQuery := fmt.Sprintf(
170
+
`
171
+
select
172
+
repo_at, language
173
+
from
174
+
repo_languages r1
175
+
where
176
+
repo_at IN (%s)
177
+
and is_default_ref = 1
178
+
and id = (
179
+
select id
180
+
from repo_languages r2
181
+
where r2.repo_at = r1.repo_at
182
+
and r2.is_default_ref = 1
183
+
order by bytes desc
184
+
limit 1
185
+
);
186
+
`,
187
+
inClause,
188
+
)
189
+
rows, err = e.Query(languageQuery, args...)
190
+
if err != nil {
191
+
return nil, fmt.Errorf("failed to execute lang query: %w ", err)
192
+
}
193
+
for rows.Next() {
194
+
var repoat, lang string
195
+
if err := rows.Scan(&repoat, &lang); err != nil {
196
+
log.Println("err", "err", err)
197
+
continue
198
+
}
199
+
if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
200
+
r.RepoStats.Language = lang
201
+
}
202
+
}
203
+
if err = rows.Err(); err != nil {
204
+
return nil, fmt.Errorf("failed to execute lang query: %w ", err)
205
}
206
207
starCountQuery := fmt.Sprintf(
···
220
var repoat string
221
var count int
222
if err := rows.Scan(&repoat, &count); err != nil {
223
+
log.Println("err", "err", err)
224
continue
225
}
226
if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
···
249
var repoat string
250
var open, closed int
251
if err := rows.Scan(&repoat, &open, &closed); err != nil {
252
+
log.Println("err", "err", err)
253
continue
254
}
255
if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
···
290
var repoat string
291
var open, merged, closed, deleted int
292
if err := rows.Scan(&repoat, &open, &merged, &closed, &deleted); err != nil {
293
+
log.Println("err", "err", err)
294
continue
295
}
296
if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
···
306
307
var repos []Repo
308
for _, r := range repoMap {
309
+
repos = append(repos, *r)
310
}
311
312
+
slices.SortFunc(repos, func(a, b Repo) int {
313
+
if a.Created.After(b.Created) {
314
+
return 1
315
+
}
316
+
return -1
317
+
})
318
+
319
return repos, nil
320
}
321
···
550
return &repo, nil
551
}
552
553
func UpdateDescription(e Execer, repoAt, newDescription string) error {
554
_, err := e.Exec(
555
`update repos set description = ? where at_uri = ?`, newDescription, repoAt)
···
562
return err
563
}
564
565
type RepoStats struct {
566
+
Language string
567
StarCount int
568
IssueCount IssueCount
569
PullCount PullCount
+29
appview/db/signup.go
+29
appview/db/signup.go
···
···
1
+
package db
2
+
3
+
import "time"
4
+
5
+
type InflightSignup struct {
6
+
Id int64
7
+
Email string
8
+
InviteCode string
9
+
Created time.Time
10
+
}
11
+
12
+
func AddInflightSignup(e Execer, signup InflightSignup) error {
13
+
query := `insert into signups_inflight (email, invite_code) values (?, ?)`
14
+
_, err := e.Exec(query, signup.Email, signup.InviteCode)
15
+
return err
16
+
}
17
+
18
+
func DeleteInflightSignup(e Execer, email string) error {
19
+
query := `delete from signups_inflight where email = ?`
20
+
_, err := e.Exec(query, email)
21
+
return err
22
+
}
23
+
24
+
func GetEmailForCode(e Execer, inviteCode string) (string, error) {
25
+
query := `select email from signups_inflight where invite_code = ?`
26
+
var email string
27
+
err := e.QueryRow(query, inviteCode).Scan(&email)
28
+
return email, err
29
+
}
+92
-2
appview/db/star.go
+92
-2
appview/db/star.go
···
1
package db
2
3
import (
4
"log"
5
"time"
6
7
"github.com/bluesky-social/indigo/atproto/syntax"
···
31
return nil
32
}
33
34
-
func AddStar(e Execer, starredByDid string, repoAt syntax.ATURI, rkey string) error {
35
query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)`
36
-
_, err := e.Exec(query, starredByDid, repoAt, rkey)
37
return err
38
}
39
···
91
} else {
92
return true
93
}
94
}
95
96
func GetAllStars(e Execer, limit int) ([]Star, error) {
···
1
package db
2
3
import (
4
+
"fmt"
5
"log"
6
+
"strings"
7
"time"
8
9
"github.com/bluesky-social/indigo/atproto/syntax"
···
33
return nil
34
}
35
36
+
func AddStar(e Execer, star *Star) error {
37
query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)`
38
+
_, err := e.Exec(
39
+
query,
40
+
star.StarredByDid,
41
+
star.RepoAt.String(),
42
+
star.Rkey,
43
+
)
44
return err
45
}
46
···
98
} else {
99
return true
100
}
101
+
}
102
+
103
+
func GetStars(e Execer, limit int, filters ...filter) ([]Star, error) {
104
+
var conditions []string
105
+
var args []any
106
+
for _, filter := range filters {
107
+
conditions = append(conditions, filter.Condition())
108
+
args = append(args, filter.Arg()...)
109
+
}
110
+
111
+
whereClause := ""
112
+
if conditions != nil {
113
+
whereClause = " where " + strings.Join(conditions, " and ")
114
+
}
115
+
116
+
limitClause := ""
117
+
if limit != 0 {
118
+
limitClause = fmt.Sprintf(" limit %d", limit)
119
+
}
120
+
121
+
repoQuery := fmt.Sprintf(
122
+
`select starred_by_did, repo_at, created, rkey
123
+
from stars
124
+
%s
125
+
order by created desc
126
+
%s`,
127
+
whereClause,
128
+
limitClause,
129
+
)
130
+
rows, err := e.Query(repoQuery, args...)
131
+
if err != nil {
132
+
return nil, err
133
+
}
134
+
135
+
starMap := make(map[string][]Star)
136
+
for rows.Next() {
137
+
var star Star
138
+
var created string
139
+
err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
140
+
if err != nil {
141
+
return nil, err
142
+
}
143
+
144
+
star.Created = time.Now()
145
+
if t, err := time.Parse(time.RFC3339, created); err == nil {
146
+
star.Created = t
147
+
}
148
+
149
+
repoAt := string(star.RepoAt)
150
+
starMap[repoAt] = append(starMap[repoAt], star)
151
+
}
152
+
153
+
// populate *Repo in each star
154
+
args = make([]any, len(starMap))
155
+
i := 0
156
+
for r := range starMap {
157
+
args[i] = r
158
+
i++
159
+
}
160
+
161
+
if len(args) == 0 {
162
+
return nil, nil
163
+
}
164
+
165
+
repos, err := GetRepos(e, 0, FilterIn("at_uri", args))
166
+
if err != nil {
167
+
return nil, err
168
+
}
169
+
170
+
for _, r := range repos {
171
+
if stars, ok := starMap[string(r.RepoAt())]; ok {
172
+
for i := range stars {
173
+
stars[i].Repo = &r
174
+
}
175
+
}
176
+
}
177
+
178
+
var stars []Star
179
+
for _, s := range starMap {
180
+
stars = append(stars, s...)
181
+
}
182
+
183
+
return stars, nil
184
}
185
186
func GetAllStars(e Execer, limit int) ([]Star, error) {
+136
-27
appview/db/timeline.go
+136
-27
appview/db/timeline.go
···
14
15
// optional: populate only if Repo is a fork
16
Source *Repo
17
}
18
19
// TODO: this gathers heterogenous events from different sources and aggregates
20
// them in code; if we did this entirely in sql, we could order and limit and paginate easily
21
func MakeTimeline(e Execer) ([]TimelineEvent, error) {
22
var events []TimelineEvent
23
-
limit := 50
24
25
-
repos, err := GetAllRepos(e, limit)
26
if err != nil {
27
return nil, err
28
}
29
30
-
follows, err := GetAllFollows(e, limit)
31
if err != nil {
32
return nil, err
33
}
34
35
-
stars, err := GetAllStars(e, limit)
36
if err != nil {
37
return nil, err
38
}
39
40
-
for _, repo := range repos {
41
-
var sourceRepo *Repo
42
-
if repo.Source != "" {
43
-
sourceRepo, err = GetRepoByAtUri(e, repo.Source)
44
-
if err != nil {
45
-
return nil, err
46
}
47
}
48
49
events = append(events, TimelineEvent{
50
-
Repo: &repo,
51
-
EventAt: repo.Created,
52
-
Source: sourceRepo,
53
})
54
}
55
56
-
for _, follow := range follows {
57
events = append(events, TimelineEvent{
58
-
Follow: &follow,
59
-
EventAt: follow.FollowedAt,
60
})
61
}
62
63
-
for _, star := range stars {
64
-
events = append(events, TimelineEvent{
65
-
Star: &star,
66
-
EventAt: star.Created,
67
-
})
68
}
69
70
-
sort.Slice(events, func(i, j int) bool {
71
-
return events[i].EventAt.After(events[j].EventAt)
72
-
})
73
74
-
// Limit the slice to 100 events
75
-
if len(events) > limit {
76
-
events = events[:limit]
77
}
78
79
return events, nil
···
14
15
// optional: populate only if Repo is a fork
16
Source *Repo
17
+
18
+
// optional: populate only if event is Follow
19
+
*Profile
20
+
*FollowStats
21
}
22
+
23
+
type FollowStats struct {
24
+
Followers int
25
+
Following int
26
+
}
27
+
28
+
const Limit = 50
29
30
// TODO: this gathers heterogenous events from different sources and aggregates
31
// them in code; if we did this entirely in sql, we could order and limit and paginate easily
32
func MakeTimeline(e Execer) ([]TimelineEvent, error) {
33
var events []TimelineEvent
34
35
+
repos, err := getTimelineRepos(e)
36
if err != nil {
37
return nil, err
38
}
39
40
+
stars, err := getTimelineStars(e)
41
if err != nil {
42
return nil, err
43
}
44
45
+
follows, err := getTimelineFollows(e)
46
if err != nil {
47
return nil, err
48
}
49
50
+
events = append(events, repos...)
51
+
events = append(events, stars...)
52
+
events = append(events, follows...)
53
+
54
+
sort.Slice(events, func(i, j int) bool {
55
+
return events[i].EventAt.After(events[j].EventAt)
56
+
})
57
+
58
+
// Limit the slice to 100 events
59
+
if len(events) > Limit {
60
+
events = events[:Limit]
61
+
}
62
+
63
+
return events, nil
64
+
}
65
+
66
+
func getTimelineRepos(e Execer) ([]TimelineEvent, error) {
67
+
repos, err := GetRepos(e, Limit)
68
+
if err != nil {
69
+
return nil, err
70
+
}
71
+
72
+
// fetch all source repos
73
+
var args []string
74
+
for _, r := range repos {
75
+
if r.Source != "" {
76
+
args = append(args, r.Source)
77
+
}
78
+
}
79
+
80
+
var origRepos []Repo
81
+
if args != nil {
82
+
origRepos, err = GetRepos(e, 0, FilterIn("at_uri", args))
83
+
}
84
+
if err != nil {
85
+
return nil, err
86
+
}
87
+
88
+
uriToRepo := make(map[string]Repo)
89
+
for _, r := range origRepos {
90
+
uriToRepo[r.RepoAt().String()] = r
91
+
}
92
+
93
+
var events []TimelineEvent
94
+
for _, r := range repos {
95
+
var source *Repo
96
+
if r.Source != "" {
97
+
if origRepo, ok := uriToRepo[r.Source]; ok {
98
+
source = &origRepo
99
}
100
}
101
102
events = append(events, TimelineEvent{
103
+
Repo: &r,
104
+
EventAt: r.Created,
105
+
Source: source,
106
})
107
}
108
109
+
return events, nil
110
+
}
111
+
112
+
func getTimelineStars(e Execer) ([]TimelineEvent, error) {
113
+
stars, err := GetStars(e, Limit)
114
+
if err != nil {
115
+
return nil, err
116
+
}
117
+
118
+
// filter star records without a repo
119
+
n := 0
120
+
for _, s := range stars {
121
+
if s.Repo != nil {
122
+
stars[n] = s
123
+
n++
124
+
}
125
+
}
126
+
stars = stars[:n]
127
+
128
+
var events []TimelineEvent
129
+
for _, s := range stars {
130
events = append(events, TimelineEvent{
131
+
Star: &s,
132
+
EventAt: s.Created,
133
})
134
}
135
136
+
return events, nil
137
+
}
138
+
139
+
func getTimelineFollows(e Execer) ([]TimelineEvent, error) {
140
+
follows, err := GetAllFollows(e, Limit)
141
+
if err != nil {
142
+
return nil, err
143
+
}
144
+
145
+
var subjects []string
146
+
for _, f := range follows {
147
+
subjects = append(subjects, f.SubjectDid)
148
+
}
149
+
150
+
if subjects == nil {
151
+
return nil, nil
152
+
}
153
+
154
+
profileMap := make(map[string]Profile)
155
+
profiles, err := GetProfiles(e, FilterIn("did", subjects))
156
+
if err != nil {
157
+
return nil, err
158
+
}
159
+
for _, p := range profiles {
160
+
profileMap[p.Did] = p
161
}
162
163
+
followStatMap := make(map[string]FollowStats)
164
+
for _, s := range subjects {
165
+
followers, following, err := GetFollowerFollowing(e, s)
166
+
if err != nil {
167
+
return nil, err
168
+
}
169
+
followStatMap[s] = FollowStats{
170
+
Followers: followers,
171
+
Following: following,
172
+
}
173
+
}
174
175
+
var events []TimelineEvent
176
+
for _, f := range follows {
177
+
profile, _ := profileMap[f.SubjectDid]
178
+
followStatMap, _ := followStatMap[f.SubjectDid]
179
+
180
+
events = append(events, TimelineEvent{
181
+
Follow: &f,
182
+
Profile: &profile,
183
+
FollowStats: &followStatMap,
184
+
EventAt: f.FollowedAt,
185
+
})
186
}
187
188
return events, nil
+53
appview/dns/cloudflare.go
+53
appview/dns/cloudflare.go
···
···
1
+
package dns
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
7
+
"github.com/cloudflare/cloudflare-go"
8
+
"tangled.sh/tangled.sh/core/appview/config"
9
+
)
10
+
11
+
type Record struct {
12
+
Type string
13
+
Name string
14
+
Content string
15
+
TTL int
16
+
Proxied bool
17
+
}
18
+
19
+
type Cloudflare struct {
20
+
api *cloudflare.API
21
+
zone string
22
+
}
23
+
24
+
func NewCloudflare(c *config.Config) (*Cloudflare, error) {
25
+
apiToken := c.Cloudflare.ApiToken
26
+
api, err := cloudflare.NewWithAPIToken(apiToken)
27
+
if err != nil {
28
+
return nil, err
29
+
}
30
+
return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil
31
+
}
32
+
33
+
func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) error {
34
+
_, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{
35
+
Type: record.Type,
36
+
Name: record.Name,
37
+
Content: record.Content,
38
+
TTL: record.TTL,
39
+
Proxied: &record.Proxied,
40
+
})
41
+
if err != nil {
42
+
return fmt.Errorf("failed to create DNS record: %w", err)
43
+
}
44
+
return nil
45
+
}
46
+
47
+
func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error {
48
+
err := cf.api.DeleteDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), recordID)
49
+
if err != nil {
50
+
return fmt.Errorf("failed to delete DNS record: %w", err)
51
+
}
52
+
return nil
53
+
}
-113
appview/idresolver/resolver.go
-113
appview/idresolver/resolver.go
···
1
-
package idresolver
2
-
3
-
import (
4
-
"context"
5
-
"net"
6
-
"net/http"
7
-
"sync"
8
-
"time"
9
-
10
-
"github.com/bluesky-social/indigo/atproto/identity"
11
-
"github.com/bluesky-social/indigo/atproto/identity/redisdir"
12
-
"github.com/bluesky-social/indigo/atproto/syntax"
13
-
"github.com/carlmjohnson/versioninfo"
14
-
"tangled.sh/tangled.sh/core/appview/config"
15
-
)
16
-
17
-
type Resolver struct {
18
-
directory identity.Directory
19
-
}
20
-
21
-
func BaseDirectory() identity.Directory {
22
-
base := identity.BaseDirectory{
23
-
PLCURL: identity.DefaultPLCURL,
24
-
HTTPClient: http.Client{
25
-
Timeout: time.Second * 10,
26
-
Transport: &http.Transport{
27
-
// would want this around 100ms for services doing lots of handle resolution. Impacts PLC connections as well, but not too bad.
28
-
IdleConnTimeout: time.Millisecond * 1000,
29
-
MaxIdleConns: 100,
30
-
},
31
-
},
32
-
Resolver: net.Resolver{
33
-
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
34
-
d := net.Dialer{Timeout: time.Second * 3}
35
-
return d.DialContext(ctx, network, address)
36
-
},
37
-
},
38
-
TryAuthoritativeDNS: true,
39
-
// primary Bluesky PDS instance only supports HTTP resolution method
40
-
SkipDNSDomainSuffixes: []string{".bsky.social"},
41
-
UserAgent: "indigo-identity/" + versioninfo.Short(),
42
-
}
43
-
return &base
44
-
}
45
-
46
-
func RedisDirectory(url string) (identity.Directory, error) {
47
-
hitTTL := time.Hour * 24
48
-
errTTL := time.Second * 30
49
-
invalidHandleTTL := time.Minute * 5
50
-
return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000)
51
-
}
52
-
53
-
func DefaultResolver() *Resolver {
54
-
return &Resolver{
55
-
directory: identity.DefaultDirectory(),
56
-
}
57
-
}
58
-
59
-
func RedisResolver(config config.RedisConfig) (*Resolver, error) {
60
-
directory, err := RedisDirectory(config.ToURL())
61
-
if err != nil {
62
-
return nil, err
63
-
}
64
-
return &Resolver{
65
-
directory: directory,
66
-
}, nil
67
-
}
68
-
69
-
func (r *Resolver) ResolveIdent(ctx context.Context, arg string) (*identity.Identity, error) {
70
-
id, err := syntax.ParseAtIdentifier(arg)
71
-
if err != nil {
72
-
return nil, err
73
-
}
74
-
75
-
return r.directory.Lookup(ctx, *id)
76
-
}
77
-
78
-
func (r *Resolver) ResolveIdents(ctx context.Context, idents []string) []*identity.Identity {
79
-
results := make([]*identity.Identity, len(idents))
80
-
var wg sync.WaitGroup
81
-
82
-
done := make(chan struct{})
83
-
defer close(done)
84
-
85
-
for idx, ident := range idents {
86
-
wg.Add(1)
87
-
go func(index int, id string) {
88
-
defer wg.Done()
89
-
90
-
select {
91
-
case <-ctx.Done():
92
-
results[index] = nil
93
-
case <-done:
94
-
results[index] = nil
95
-
default:
96
-
identity, _ := r.ResolveIdent(ctx, id)
97
-
results[index] = identity
98
-
}
99
-
}(idx, ident)
100
-
}
101
-
102
-
wg.Wait()
103
-
return results
104
-
}
105
-
106
-
func (r *Resolver) InvalidateIdent(ctx context.Context, arg string) error {
107
-
id, err := syntax.ParseAtIdentifier(arg)
108
-
if err != nil {
109
-
return err
110
-
}
111
-
112
-
return r.directory.Purge(ctx, *id)
113
-
}
···
+26
-6
appview/ingester.go
+26
-6
appview/ingester.go
···
14
"tangled.sh/tangled.sh/core/api/tangled"
15
"tangled.sh/tangled.sh/core/appview/config"
16
"tangled.sh/tangled.sh/core/appview/db"
17
-
"tangled.sh/tangled.sh/core/appview/idresolver"
18
"tangled.sh/tangled.sh/core/appview/spindleverify"
19
"tangled.sh/tangled.sh/core/rbac"
20
)
21
···
100
l.Error("invalid record", "err", err)
101
return err
102
}
103
-
err = db.AddStar(i.Db, did, subjectUri, e.Commit.RKey)
104
case models.CommitOperationDelete:
105
err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
106
}
···
129
return err
130
}
131
132
-
subjectDid := record.Subject
133
-
err = db.AddFollow(i.Db, did, subjectDid, e.Commit.RKey)
134
case models.CommitOperationDelete:
135
err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey)
136
}
···
492
if err != nil || len(spindles) != 1 {
493
return fmt.Errorf("failed to get spindles: %w, len(spindles) = %d", err, len(spindles))
494
}
495
496
tx, err := ddb.Begin()
497
if err != nil {
···
502
i.Enforcer.E.LoadPolicy()
503
}()
504
505
-
err = db.DeleteSpindle(
506
tx,
507
db.FilterEq("owner", did),
508
db.FilterEq("instance", instance),
···
511
return err
512
}
513
514
-
err = i.Enforcer.RemoveSpindle(instance)
515
if err != nil {
516
return err
517
}
518
519
err = tx.Commit()
···
14
"tangled.sh/tangled.sh/core/api/tangled"
15
"tangled.sh/tangled.sh/core/appview/config"
16
"tangled.sh/tangled.sh/core/appview/db"
17
"tangled.sh/tangled.sh/core/appview/spindleverify"
18
+
"tangled.sh/tangled.sh/core/idresolver"
19
"tangled.sh/tangled.sh/core/rbac"
20
)
21
···
100
l.Error("invalid record", "err", err)
101
return err
102
}
103
+
err = db.AddStar(i.Db, &db.Star{
104
+
StarredByDid: did,
105
+
RepoAt: subjectUri,
106
+
Rkey: e.Commit.RKey,
107
+
})
108
case models.CommitOperationDelete:
109
err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
110
}
···
133
return err
134
}
135
136
+
err = db.AddFollow(i.Db, &db.Follow{
137
+
UserDid: did,
138
+
SubjectDid: record.Subject,
139
+
Rkey: e.Commit.RKey,
140
+
})
141
case models.CommitOperationDelete:
142
err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey)
143
}
···
499
if err != nil || len(spindles) != 1 {
500
return fmt.Errorf("failed to get spindles: %w, len(spindles) = %d", err, len(spindles))
501
}
502
+
spindle := spindles[0]
503
504
tx, err := ddb.Begin()
505
if err != nil {
···
510
i.Enforcer.E.LoadPolicy()
511
}()
512
513
+
// remove spindle members first
514
+
err = db.RemoveSpindleMember(
515
tx,
516
db.FilterEq("owner", did),
517
db.FilterEq("instance", instance),
···
520
return err
521
}
522
523
+
err = db.DeleteSpindle(
524
+
tx,
525
+
db.FilterEq("owner", did),
526
+
db.FilterEq("instance", instance),
527
+
)
528
if err != nil {
529
return err
530
+
}
531
+
532
+
if spindle.Verified != nil {
533
+
err = i.Enforcer.RemoveSpindle(instance)
534
+
if err != nil {
535
+
return err
536
+
}
537
}
538
539
err = tx.Commit()
+32
-31
appview/issues/issues.go
+32
-31
appview/issues/issues.go
···
11
12
comatproto "github.com/bluesky-social/indigo/api/atproto"
13
"github.com/bluesky-social/indigo/atproto/data"
14
lexutil "github.com/bluesky-social/indigo/lex/util"
15
"github.com/go-chi/chi/v5"
16
-
"github.com/posthog/posthog-go"
17
18
"tangled.sh/tangled.sh/core/api/tangled"
19
-
"tangled.sh/tangled.sh/core/appview"
20
"tangled.sh/tangled.sh/core/appview/config"
21
"tangled.sh/tangled.sh/core/appview/db"
22
-
"tangled.sh/tangled.sh/core/appview/idresolver"
23
"tangled.sh/tangled.sh/core/appview/oauth"
24
"tangled.sh/tangled.sh/core/appview/pages"
25
"tangled.sh/tangled.sh/core/appview/pagination"
26
"tangled.sh/tangled.sh/core/appview/reporesolver"
27
)
28
29
type Issues struct {
···
33
idResolver *idresolver.Resolver
34
db *db.DB
35
config *config.Config
36
-
posthog posthog.Client
37
}
38
39
func New(
···
43
idResolver *idresolver.Resolver,
44
db *db.DB,
45
config *config.Config,
46
-
posthog posthog.Client,
47
) *Issues {
48
return &Issues{
49
oauth: oauth,
···
52
idResolver: idResolver,
53
db: db,
54
config: config,
55
-
posthog: posthog,
56
}
57
}
58
···
79
return
80
}
81
82
issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid)
83
if err != nil {
84
log.Println("failed to resolve issue owner", err)
···
106
107
IssueOwnerHandle: issueOwnerIdent.Handle.String(),
108
DidHandleMap: didHandleMap,
109
})
110
111
}
···
155
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
156
Collection: tangled.RepoIssueStateNSID,
157
Repo: user.Did,
158
-
Rkey: appview.TID(),
159
Record: &lexutil.LexiconTypeDecoder{
160
Val: &tangled.RepoIssueState{
161
Issue: issue.IssueAt,
···
259
}
260
261
commentId := mathrand.IntN(1000000)
262
-
rkey := appview.TID()
263
264
err := db.NewIssueComment(rp.db, &db.Comment{
265
OwnerDid: user.Did,
···
687
return
688
}
689
690
-
err = db.NewIssue(tx, &db.Issue{
691
RepoAt: f.RepoAt,
692
Title: title,
693
Body: body,
694
OwnerDid: user.Did,
695
-
})
696
-
if err != nil {
697
-
log.Println("failed to create issue", err)
698
-
rp.pages.Notice(w, "issues", "Failed to create issue.")
699
-
return
700
}
701
-
702
-
issueId, err := db.GetIssueId(rp.db, f.RepoAt)
703
if err != nil {
704
-
log.Println("failed to get issue id", err)
705
rp.pages.Notice(w, "issues", "Failed to create issue.")
706
return
707
}
···
716
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
717
Collection: tangled.RepoIssueNSID,
718
Repo: user.Did,
719
-
Rkey: appview.TID(),
720
Record: &lexutil.LexiconTypeDecoder{
721
Val: &tangled.RepoIssue{
722
Repo: atUri,
723
Title: title,
724
Body: &body,
725
Owner: user.Did,
726
-
IssueId: int64(issueId),
727
},
728
},
729
})
···
733
return
734
}
735
736
-
err = db.SetIssueAt(rp.db, f.RepoAt, issueId, resp.Uri)
737
if err != nil {
738
log.Println("failed to set issue at", err)
739
rp.pages.Notice(w, "issues", "Failed to create issue.")
740
return
741
}
742
743
-
if !rp.config.Core.Dev {
744
-
err = rp.posthog.Enqueue(posthog.Capture{
745
-
DistinctId: user.Did,
746
-
Event: "new_issue",
747
-
Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "issue_id": issueId},
748
-
})
749
-
if err != nil {
750
-
log.Println("failed to enqueue posthog event:", err)
751
-
}
752
-
}
753
754
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
755
return
756
}
757
}
···
11
12
comatproto "github.com/bluesky-social/indigo/api/atproto"
13
"github.com/bluesky-social/indigo/atproto/data"
14
+
"github.com/bluesky-social/indigo/atproto/syntax"
15
lexutil "github.com/bluesky-social/indigo/lex/util"
16
"github.com/go-chi/chi/v5"
17
18
"tangled.sh/tangled.sh/core/api/tangled"
19
"tangled.sh/tangled.sh/core/appview/config"
20
"tangled.sh/tangled.sh/core/appview/db"
21
+
"tangled.sh/tangled.sh/core/appview/notify"
22
"tangled.sh/tangled.sh/core/appview/oauth"
23
"tangled.sh/tangled.sh/core/appview/pages"
24
"tangled.sh/tangled.sh/core/appview/pagination"
25
"tangled.sh/tangled.sh/core/appview/reporesolver"
26
+
"tangled.sh/tangled.sh/core/idresolver"
27
+
"tangled.sh/tangled.sh/core/tid"
28
)
29
30
type Issues struct {
···
34
idResolver *idresolver.Resolver
35
db *db.DB
36
config *config.Config
37
+
notifier notify.Notifier
38
}
39
40
func New(
···
44
idResolver *idresolver.Resolver,
45
db *db.DB,
46
config *config.Config,
47
+
notifier notify.Notifier,
48
) *Issues {
49
return &Issues{
50
oauth: oauth,
···
53
idResolver: idResolver,
54
db: db,
55
config: config,
56
+
notifier: notifier,
57
}
58
}
59
···
80
return
81
}
82
83
+
reactionCountMap, err := db.GetReactionCountMap(rp.db, syntax.ATURI(issue.IssueAt))
84
+
if err != nil {
85
+
log.Println("failed to get issue reactions")
86
+
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
87
+
}
88
+
89
+
userReactions := map[db.ReactionKind]bool{}
90
+
if user != nil {
91
+
userReactions = db.GetReactionStatusMap(rp.db, user.Did, syntax.ATURI(issue.IssueAt))
92
+
}
93
+
94
issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid)
95
if err != nil {
96
log.Println("failed to resolve issue owner", err)
···
118
119
IssueOwnerHandle: issueOwnerIdent.Handle.String(),
120
DidHandleMap: didHandleMap,
121
+
122
+
OrderedReactionKinds: db.OrderedReactionKinds,
123
+
Reactions: reactionCountMap,
124
+
UserReacted: userReactions,
125
})
126
127
}
···
171
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
172
Collection: tangled.RepoIssueStateNSID,
173
Repo: user.Did,
174
+
Rkey: tid.TID(),
175
Record: &lexutil.LexiconTypeDecoder{
176
Val: &tangled.RepoIssueState{
177
Issue: issue.IssueAt,
···
275
}
276
277
commentId := mathrand.IntN(1000000)
278
+
rkey := tid.TID()
279
280
err := db.NewIssueComment(rp.db, &db.Comment{
281
OwnerDid: user.Did,
···
703
return
704
}
705
706
+
issue := &db.Issue{
707
RepoAt: f.RepoAt,
708
Title: title,
709
Body: body,
710
OwnerDid: user.Did,
711
}
712
+
err = db.NewIssue(tx, issue)
713
if err != nil {
714
+
log.Println("failed to create issue", err)
715
rp.pages.Notice(w, "issues", "Failed to create issue.")
716
return
717
}
···
726
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
727
Collection: tangled.RepoIssueNSID,
728
Repo: user.Did,
729
+
Rkey: tid.TID(),
730
Record: &lexutil.LexiconTypeDecoder{
731
Val: &tangled.RepoIssue{
732
Repo: atUri,
733
Title: title,
734
Body: &body,
735
Owner: user.Did,
736
+
IssueId: int64(issue.IssueId),
737
},
738
},
739
})
···
743
return
744
}
745
746
+
err = db.SetIssueAt(rp.db, f.RepoAt, issue.IssueId, resp.Uri)
747
if err != nil {
748
log.Println("failed to set issue at", err)
749
rp.pages.Notice(w, "issues", "Failed to create issue.")
750
return
751
}
752
753
+
rp.notifier.NewIssue(r.Context(), issue)
754
755
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
756
return
757
}
758
}
+494
appview/knots/knots.go
+494
appview/knots/knots.go
···
···
1
+
package knots
2
+
3
+
import (
4
+
"context"
5
+
"crypto/hmac"
6
+
"crypto/sha256"
7
+
"encoding/hex"
8
+
"fmt"
9
+
"log/slog"
10
+
"net/http"
11
+
"strings"
12
+
"time"
13
+
14
+
"github.com/go-chi/chi/v5"
15
+
"tangled.sh/tangled.sh/core/api/tangled"
16
+
"tangled.sh/tangled.sh/core/appview/config"
17
+
"tangled.sh/tangled.sh/core/appview/db"
18
+
"tangled.sh/tangled.sh/core/appview/middleware"
19
+
"tangled.sh/tangled.sh/core/appview/oauth"
20
+
"tangled.sh/tangled.sh/core/appview/pages"
21
+
"tangled.sh/tangled.sh/core/eventconsumer"
22
+
"tangled.sh/tangled.sh/core/idresolver"
23
+
"tangled.sh/tangled.sh/core/knotclient"
24
+
"tangled.sh/tangled.sh/core/rbac"
25
+
"tangled.sh/tangled.sh/core/tid"
26
+
27
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
28
+
lexutil "github.com/bluesky-social/indigo/lex/util"
29
+
)
30
+
31
+
type Knots struct {
32
+
Db *db.DB
33
+
OAuth *oauth.OAuth
34
+
Pages *pages.Pages
35
+
Config *config.Config
36
+
Enforcer *rbac.Enforcer
37
+
IdResolver *idresolver.Resolver
38
+
Logger *slog.Logger
39
+
Knotstream *eventconsumer.Consumer
40
+
}
41
+
42
+
func (k *Knots) Router(mw *middleware.Middleware) http.Handler {
43
+
r := chi.NewRouter()
44
+
45
+
r.Use(middleware.AuthMiddleware(k.OAuth))
46
+
47
+
r.Get("/", k.index)
48
+
r.Post("/key", k.generateKey)
49
+
50
+
r.Route("/{domain}", func(r chi.Router) {
51
+
r.Post("/init", k.init)
52
+
r.Get("/", k.dashboard)
53
+
r.Route("/member", func(r chi.Router) {
54
+
r.Use(mw.KnotOwner())
55
+
r.Get("/", k.members)
56
+
r.Put("/", k.addMember)
57
+
r.Delete("/", k.removeMember)
58
+
})
59
+
})
60
+
61
+
return r
62
+
}
63
+
64
+
// get knots registered by this user
65
+
func (k *Knots) index(w http.ResponseWriter, r *http.Request) {
66
+
l := k.Logger.With("handler", "index")
67
+
68
+
user := k.OAuth.GetUser(r)
69
+
registrations, err := db.RegistrationsByDid(k.Db, user.Did)
70
+
if err != nil {
71
+
l.Error("failed to get registrations by did", "err", err)
72
+
}
73
+
74
+
k.Pages.Knots(w, pages.KnotsParams{
75
+
LoggedInUser: user,
76
+
Registrations: registrations,
77
+
})
78
+
}
79
+
80
+
// requires auth
81
+
func (k *Knots) generateKey(w http.ResponseWriter, r *http.Request) {
82
+
l := k.Logger.With("handler", "generateKey")
83
+
84
+
user := k.OAuth.GetUser(r)
85
+
did := user.Did
86
+
l = l.With("did", did)
87
+
88
+
// check if domain is valid url, and strip extra bits down to just host
89
+
domain := r.FormValue("domain")
90
+
if domain == "" {
91
+
l.Error("empty domain")
92
+
http.Error(w, "Invalid form", http.StatusBadRequest)
93
+
return
94
+
}
95
+
l = l.With("domain", domain)
96
+
97
+
noticeId := "registration-error"
98
+
fail := func() {
99
+
k.Pages.Notice(w, noticeId, "Failed to generate registration key.")
100
+
}
101
+
102
+
key, err := db.GenerateRegistrationKey(k.Db, domain, did)
103
+
if err != nil {
104
+
l.Error("failed to generate registration key", "err", err)
105
+
fail()
106
+
return
107
+
}
108
+
109
+
allRegs, err := db.RegistrationsByDid(k.Db, did)
110
+
if err != nil {
111
+
l.Error("failed to generate registration key", "err", err)
112
+
fail()
113
+
return
114
+
}
115
+
116
+
k.Pages.KnotListingFull(w, pages.KnotListingFullParams{
117
+
Registrations: allRegs,
118
+
})
119
+
k.Pages.KnotSecret(w, pages.KnotSecretParams{
120
+
Secret: key,
121
+
})
122
+
}
123
+
124
+
// create a signed request and check if a node responds to that
125
+
func (k *Knots) init(w http.ResponseWriter, r *http.Request) {
126
+
l := k.Logger.With("handler", "init")
127
+
user := k.OAuth.GetUser(r)
128
+
129
+
noticeId := "operation-error"
130
+
defaultErr := "Failed to initialize knot. Try again later."
131
+
fail := func() {
132
+
k.Pages.Notice(w, noticeId, defaultErr)
133
+
}
134
+
135
+
domain := chi.URLParam(r, "domain")
136
+
if domain == "" {
137
+
http.Error(w, "malformed url", http.StatusBadRequest)
138
+
return
139
+
}
140
+
l = l.With("domain", domain)
141
+
142
+
l.Info("checking domain")
143
+
144
+
registration, err := db.RegistrationByDomain(k.Db, domain)
145
+
if err != nil {
146
+
l.Error("failed to get registration for domain", "err", err)
147
+
fail()
148
+
return
149
+
}
150
+
if registration.ByDid != user.Did {
151
+
l.Error("unauthorized", "wantedDid", registration.ByDid, "gotDid", user.Did)
152
+
w.WriteHeader(http.StatusUnauthorized)
153
+
return
154
+
}
155
+
156
+
secret, err := db.GetRegistrationKey(k.Db, domain)
157
+
if err != nil {
158
+
l.Error("failed to get registration key for domain", "err", err)
159
+
fail()
160
+
return
161
+
}
162
+
163
+
client, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev)
164
+
if err != nil {
165
+
l.Error("failed to create knotclient", "err", err)
166
+
fail()
167
+
return
168
+
}
169
+
170
+
resp, err := client.Init(user.Did)
171
+
if err != nil {
172
+
k.Pages.Notice(w, noticeId, fmt.Sprintf("Failed to make request: %s", err.Error()))
173
+
l.Error("failed to make init request", "err", err)
174
+
return
175
+
}
176
+
177
+
if resp.StatusCode == http.StatusConflict {
178
+
k.Pages.Notice(w, noticeId, "This knot is already registered")
179
+
l.Error("knot already registered", "statuscode", resp.StatusCode)
180
+
return
181
+
}
182
+
183
+
if resp.StatusCode != http.StatusNoContent {
184
+
k.Pages.Notice(w, noticeId, fmt.Sprintf("Received status %d from knot, expected %d", resp.StatusCode, http.StatusNoContent))
185
+
l.Error("incorrect statuscode returned", "statuscode", resp.StatusCode, "expected", http.StatusNoContent)
186
+
return
187
+
}
188
+
189
+
// verify response mac
190
+
signature := resp.Header.Get("X-Signature")
191
+
signatureBytes, err := hex.DecodeString(signature)
192
+
if err != nil {
193
+
return
194
+
}
195
+
196
+
expectedMac := hmac.New(sha256.New, []byte(secret))
197
+
expectedMac.Write([]byte("ok"))
198
+
199
+
if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) {
200
+
k.Pages.Notice(w, noticeId, "Response signature mismatch, consider regenerating the secret and retrying.")
201
+
l.Error("signature mismatch", "bytes", signatureBytes)
202
+
return
203
+
}
204
+
205
+
tx, err := k.Db.BeginTx(r.Context(), nil)
206
+
if err != nil {
207
+
l.Error("failed to start tx", "err", err)
208
+
fail()
209
+
return
210
+
}
211
+
defer func() {
212
+
tx.Rollback()
213
+
err = k.Enforcer.E.LoadPolicy()
214
+
if err != nil {
215
+
l.Error("rollback failed", "err", err)
216
+
}
217
+
}()
218
+
219
+
// mark as registered
220
+
err = db.Register(tx, domain)
221
+
if err != nil {
222
+
l.Error("failed to register domain", "err", err)
223
+
fail()
224
+
return
225
+
}
226
+
227
+
// set permissions for this did as owner
228
+
reg, err := db.RegistrationByDomain(tx, domain)
229
+
if err != nil {
230
+
l.Error("failed get registration by domain", "err", err)
231
+
fail()
232
+
return
233
+
}
234
+
235
+
// add basic acls for this domain
236
+
err = k.Enforcer.AddKnot(domain)
237
+
if err != nil {
238
+
l.Error("failed to add knot to enforcer", "err", err)
239
+
fail()
240
+
return
241
+
}
242
+
243
+
// add this did as owner of this domain
244
+
err = k.Enforcer.AddKnotOwner(domain, reg.ByDid)
245
+
if err != nil {
246
+
l.Error("failed to add knot owner to enforcer", "err", err)
247
+
fail()
248
+
return
249
+
}
250
+
251
+
err = tx.Commit()
252
+
if err != nil {
253
+
l.Error("failed to commit changes", "err", err)
254
+
fail()
255
+
return
256
+
}
257
+
258
+
err = k.Enforcer.E.SavePolicy()
259
+
if err != nil {
260
+
l.Error("failed to update ACLs", "err", err)
261
+
fail()
262
+
return
263
+
}
264
+
265
+
// add this knot to knotstream
266
+
go k.Knotstream.AddSource(
267
+
context.Background(),
268
+
eventconsumer.NewKnotSource(domain),
269
+
)
270
+
271
+
k.Pages.KnotListing(w, pages.KnotListingParams{
272
+
Registration: *reg,
273
+
})
274
+
}
275
+
276
+
func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) {
277
+
l := k.Logger.With("handler", "dashboard")
278
+
fail := func() {
279
+
w.WriteHeader(http.StatusInternalServerError)
280
+
}
281
+
282
+
domain := chi.URLParam(r, "domain")
283
+
if domain == "" {
284
+
http.Error(w, "malformed url", http.StatusBadRequest)
285
+
return
286
+
}
287
+
l = l.With("domain", domain)
288
+
289
+
user := k.OAuth.GetUser(r)
290
+
l = l.With("did", user.Did)
291
+
292
+
// dashboard is only available to owners
293
+
ok, err := k.Enforcer.IsKnotOwner(user.Did, domain)
294
+
if err != nil {
295
+
l.Error("failed to query enforcer", "err", err)
296
+
fail()
297
+
}
298
+
if !ok {
299
+
http.Error(w, "only owners can view dashboards", http.StatusUnauthorized)
300
+
return
301
+
}
302
+
303
+
reg, err := db.RegistrationByDomain(k.Db, domain)
304
+
if err != nil {
305
+
l.Error("failed to get registration by domain", "err", err)
306
+
fail()
307
+
return
308
+
}
309
+
310
+
var members []string
311
+
if reg.Registered != nil {
312
+
members, err = k.Enforcer.GetUserByRole("server:member", domain)
313
+
if err != nil {
314
+
l.Error("failed to get members list", "err", err)
315
+
fail()
316
+
return
317
+
}
318
+
}
319
+
320
+
repos, err := db.GetRepos(
321
+
k.Db,
322
+
0,
323
+
db.FilterEq("knot", domain),
324
+
db.FilterIn("did", members),
325
+
)
326
+
if err != nil {
327
+
l.Error("failed to get repos list", "err", err)
328
+
fail()
329
+
return
330
+
}
331
+
// convert to map
332
+
repoByMember := make(map[string][]db.Repo)
333
+
for _, r := range repos {
334
+
repoByMember[r.Did] = append(repoByMember[r.Did], r)
335
+
}
336
+
337
+
var didsToResolve []string
338
+
for _, m := range members {
339
+
didsToResolve = append(didsToResolve, m)
340
+
}
341
+
didsToResolve = append(didsToResolve, reg.ByDid)
342
+
resolvedIds := k.IdResolver.ResolveIdents(r.Context(), didsToResolve)
343
+
didHandleMap := make(map[string]string)
344
+
for _, identity := range resolvedIds {
345
+
if !identity.Handle.IsInvalidHandle() {
346
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
347
+
} else {
348
+
didHandleMap[identity.DID.String()] = identity.DID.String()
349
+
}
350
+
}
351
+
352
+
k.Pages.Knot(w, pages.KnotParams{
353
+
LoggedInUser: user,
354
+
DidHandleMap: didHandleMap,
355
+
Registration: reg,
356
+
Members: members,
357
+
Repos: repoByMember,
358
+
IsOwner: true,
359
+
})
360
+
}
361
+
362
+
// list members of domain, requires auth and requires owner status
363
+
func (k *Knots) members(w http.ResponseWriter, r *http.Request) {
364
+
l := k.Logger.With("handler", "members")
365
+
366
+
domain := chi.URLParam(r, "domain")
367
+
if domain == "" {
368
+
http.Error(w, "malformed url", http.StatusBadRequest)
369
+
return
370
+
}
371
+
l = l.With("domain", domain)
372
+
373
+
// list all members for this domain
374
+
memberDids, err := k.Enforcer.GetUserByRole("server:member", domain)
375
+
if err != nil {
376
+
w.Write([]byte("failed to fetch member list"))
377
+
return
378
+
}
379
+
380
+
w.Write([]byte(strings.Join(memberDids, "\n")))
381
+
}
382
+
383
+
// add member to domain, requires auth and requires invite access
384
+
func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) {
385
+
l := k.Logger.With("handler", "members")
386
+
387
+
domain := chi.URLParam(r, "domain")
388
+
if domain == "" {
389
+
http.Error(w, "malformed url", http.StatusBadRequest)
390
+
return
391
+
}
392
+
l = l.With("domain", domain)
393
+
394
+
reg, err := db.RegistrationByDomain(k.Db, domain)
395
+
if err != nil {
396
+
l.Error("failed to get registration by domain", "err", err)
397
+
http.Error(w, "malformed url", http.StatusBadRequest)
398
+
return
399
+
}
400
+
401
+
noticeId := fmt.Sprintf("add-member-error-%d", reg.Id)
402
+
l = l.With("notice-id", noticeId)
403
+
defaultErr := "Failed to add member. Try again later."
404
+
fail := func() {
405
+
k.Pages.Notice(w, noticeId, defaultErr)
406
+
}
407
+
408
+
subjectIdentifier := r.FormValue("subject")
409
+
if subjectIdentifier == "" {
410
+
http.Error(w, "malformed form", http.StatusBadRequest)
411
+
return
412
+
}
413
+
l = l.With("subjectIdentifier", subjectIdentifier)
414
+
415
+
subjectIdentity, err := k.IdResolver.ResolveIdent(r.Context(), subjectIdentifier)
416
+
if err != nil {
417
+
l.Error("failed to resolve identity", "err", err)
418
+
k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
419
+
return
420
+
}
421
+
l = l.With("subjectDid", subjectIdentity.DID)
422
+
423
+
l.Info("adding member to knot")
424
+
425
+
// announce this relation into the firehose, store into owners' pds
426
+
client, err := k.OAuth.AuthorizedClient(r)
427
+
if err != nil {
428
+
l.Error("failed to create client", "err", err)
429
+
fail()
430
+
return
431
+
}
432
+
433
+
currentUser := k.OAuth.GetUser(r)
434
+
createdAt := time.Now().Format(time.RFC3339)
435
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
436
+
Collection: tangled.KnotMemberNSID,
437
+
Repo: currentUser.Did,
438
+
Rkey: tid.TID(),
439
+
Record: &lexutil.LexiconTypeDecoder{
440
+
Val: &tangled.KnotMember{
441
+
Subject: subjectIdentity.DID.String(),
442
+
Domain: domain,
443
+
CreatedAt: createdAt,
444
+
}},
445
+
})
446
+
// invalid record
447
+
if err != nil {
448
+
l.Error("failed to write to PDS", "err", err)
449
+
fail()
450
+
return
451
+
}
452
+
l = l.With("at-uri", resp.Uri)
453
+
l.Info("wrote record to PDS")
454
+
455
+
secret, err := db.GetRegistrationKey(k.Db, domain)
456
+
if err != nil {
457
+
l.Error("failed to get registration key", "err", err)
458
+
fail()
459
+
return
460
+
}
461
+
462
+
ksClient, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev)
463
+
if err != nil {
464
+
l.Error("failed to create client", "err", err)
465
+
fail()
466
+
return
467
+
}
468
+
469
+
ksResp, err := ksClient.AddMember(subjectIdentity.DID.String())
470
+
if err != nil {
471
+
l.Error("failed to reach knotserver", "err", err)
472
+
k.Pages.Notice(w, noticeId, "Failed to reach to knotserver.")
473
+
return
474
+
}
475
+
476
+
if ksResp.StatusCode != http.StatusNoContent {
477
+
l.Error("status mismatch", "got", ksResp.StatusCode, "expected", http.StatusNoContent)
478
+
k.Pages.Notice(w, noticeId, fmt.Sprintf("Unexpected status code from knotserver %d, expected %d", ksResp.StatusCode, http.StatusNoContent))
479
+
return
480
+
}
481
+
482
+
err = k.Enforcer.AddKnotMember(domain, subjectIdentity.DID.String())
483
+
if err != nil {
484
+
l.Error("failed to add member to enforcer", "err", err)
485
+
fail()
486
+
return
487
+
}
488
+
489
+
// success
490
+
k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain))
491
+
}
492
+
493
+
func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) {
494
+
}
+1
-1
appview/middleware/middleware.go
+1
-1
appview/middleware/middleware.go
···
13
"github.com/bluesky-social/indigo/atproto/identity"
14
"github.com/go-chi/chi/v5"
15
"tangled.sh/tangled.sh/core/appview/db"
16
-
"tangled.sh/tangled.sh/core/appview/idresolver"
17
"tangled.sh/tangled.sh/core/appview/oauth"
18
"tangled.sh/tangled.sh/core/appview/pages"
19
"tangled.sh/tangled.sh/core/appview/pagination"
20
"tangled.sh/tangled.sh/core/appview/reporesolver"
21
"tangled.sh/tangled.sh/core/rbac"
22
)
23
···
13
"github.com/bluesky-social/indigo/atproto/identity"
14
"github.com/go-chi/chi/v5"
15
"tangled.sh/tangled.sh/core/appview/db"
16
"tangled.sh/tangled.sh/core/appview/oauth"
17
"tangled.sh/tangled.sh/core/appview/pages"
18
"tangled.sh/tangled.sh/core/appview/pagination"
19
"tangled.sh/tangled.sh/core/appview/reporesolver"
20
+
"tangled.sh/tangled.sh/core/idresolver"
21
"tangled.sh/tangled.sh/core/rbac"
22
)
23
+68
appview/notify/merged_notifier.go
+68
appview/notify/merged_notifier.go
···
···
1
+
package notify
2
+
3
+
import (
4
+
"context"
5
+
6
+
"tangled.sh/tangled.sh/core/appview/db"
7
+
)
8
+
9
+
type mergedNotifier struct {
10
+
notifiers []Notifier
11
+
}
12
+
13
+
func NewMergedNotifier(notifiers ...Notifier) Notifier {
14
+
return &mergedNotifier{notifiers}
15
+
}
16
+
17
+
var _ Notifier = &mergedNotifier{}
18
+
19
+
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *db.Repo) {
20
+
for _, notifier := range m.notifiers {
21
+
notifier.NewRepo(ctx, repo)
22
+
}
23
+
}
24
+
25
+
func (m *mergedNotifier) NewStar(ctx context.Context, star *db.Star) {
26
+
for _, notifier := range m.notifiers {
27
+
notifier.NewStar(ctx, star)
28
+
}
29
+
}
30
+
func (m *mergedNotifier) DeleteStar(ctx context.Context, star *db.Star) {
31
+
for _, notifier := range m.notifiers {
32
+
notifier.DeleteStar(ctx, star)
33
+
}
34
+
}
35
+
36
+
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *db.Issue) {
37
+
for _, notifier := range m.notifiers {
38
+
notifier.NewIssue(ctx, issue)
39
+
}
40
+
}
41
+
42
+
func (m *mergedNotifier) NewFollow(ctx context.Context, follow *db.Follow) {
43
+
for _, notifier := range m.notifiers {
44
+
notifier.NewFollow(ctx, follow)
45
+
}
46
+
}
47
+
func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {
48
+
for _, notifier := range m.notifiers {
49
+
notifier.DeleteFollow(ctx, follow)
50
+
}
51
+
}
52
+
53
+
func (m *mergedNotifier) NewPull(ctx context.Context, pull *db.Pull) {
54
+
for _, notifier := range m.notifiers {
55
+
notifier.NewPull(ctx, pull)
56
+
}
57
+
}
58
+
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {
59
+
for _, notifier := range m.notifiers {
60
+
notifier.NewPullComment(ctx, comment)
61
+
}
62
+
}
63
+
64
+
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {
65
+
for _, notifier := range m.notifiers {
66
+
notifier.UpdateProfile(ctx, profile)
67
+
}
68
+
}
+44
appview/notify/notifier.go
+44
appview/notify/notifier.go
···
···
1
+
package notify
2
+
3
+
import (
4
+
"context"
5
+
6
+
"tangled.sh/tangled.sh/core/appview/db"
7
+
)
8
+
9
+
type Notifier interface {
10
+
NewRepo(ctx context.Context, repo *db.Repo)
11
+
12
+
NewStar(ctx context.Context, star *db.Star)
13
+
DeleteStar(ctx context.Context, star *db.Star)
14
+
15
+
NewIssue(ctx context.Context, issue *db.Issue)
16
+
17
+
NewFollow(ctx context.Context, follow *db.Follow)
18
+
DeleteFollow(ctx context.Context, follow *db.Follow)
19
+
20
+
NewPull(ctx context.Context, pull *db.Pull)
21
+
NewPullComment(ctx context.Context, comment *db.PullComment)
22
+
23
+
UpdateProfile(ctx context.Context, profile *db.Profile)
24
+
}
25
+
26
+
// BaseNotifier is a listener that does nothing
27
+
type BaseNotifier struct{}
28
+
29
+
var _ Notifier = &BaseNotifier{}
30
+
31
+
func (m *BaseNotifier) NewRepo(ctx context.Context, repo *db.Repo) {}
32
+
33
+
func (m *BaseNotifier) NewStar(ctx context.Context, star *db.Star) {}
34
+
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *db.Star) {}
35
+
36
+
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *db.Issue) {}
37
+
38
+
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *db.Follow) {}
39
+
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {}
40
+
41
+
func (m *BaseNotifier) NewPull(ctx context.Context, pull *db.Pull) {}
42
+
func (m *BaseNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {}
43
+
44
+
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {}
+1
-1
appview/oauth/handler/handler.go
+1
-1
appview/oauth/handler/handler.go
···
16
sessioncache "tangled.sh/tangled.sh/core/appview/cache/session"
17
"tangled.sh/tangled.sh/core/appview/config"
18
"tangled.sh/tangled.sh/core/appview/db"
19
-
"tangled.sh/tangled.sh/core/appview/idresolver"
20
"tangled.sh/tangled.sh/core/appview/middleware"
21
"tangled.sh/tangled.sh/core/appview/oauth"
22
"tangled.sh/tangled.sh/core/appview/oauth/client"
23
"tangled.sh/tangled.sh/core/appview/pages"
24
"tangled.sh/tangled.sh/core/knotclient"
25
"tangled.sh/tangled.sh/core/rbac"
26
)
···
16
sessioncache "tangled.sh/tangled.sh/core/appview/cache/session"
17
"tangled.sh/tangled.sh/core/appview/config"
18
"tangled.sh/tangled.sh/core/appview/db"
19
"tangled.sh/tangled.sh/core/appview/middleware"
20
"tangled.sh/tangled.sh/core/appview/oauth"
21
"tangled.sh/tangled.sh/core/appview/oauth/client"
22
"tangled.sh/tangled.sh/core/appview/pages"
23
+
"tangled.sh/tangled.sh/core/idresolver"
24
"tangled.sh/tangled.sh/core/knotclient"
25
"tangled.sh/tangled.sh/core/rbac"
26
)
+73
appview/oauth/oauth.go
+73
appview/oauth/oauth.go
···
7
"net/url"
8
"time"
9
10
+
indigo_xrpc "github.com/bluesky-social/indigo/xrpc"
11
"github.com/gorilla/sessions"
12
oauth "tangled.sh/icyphox.sh/atproto-oauth"
13
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
···
205
})
206
207
return xrpcClient, nil
208
+
}
209
+
210
+
// use this to create a client to communicate with knots or spindles
211
+
//
212
+
// this is a higher level abstraction on ServerGetServiceAuth
213
+
type ServiceClientOpts struct {
214
+
service string
215
+
exp int64
216
+
lxm string
217
+
dev bool
218
+
}
219
+
220
+
type ServiceClientOpt func(*ServiceClientOpts)
221
+
222
+
func WithService(service string) ServiceClientOpt {
223
+
return func(s *ServiceClientOpts) {
224
+
s.service = service
225
+
}
226
+
}
227
+
func WithExp(exp int64) ServiceClientOpt {
228
+
return func(s *ServiceClientOpts) {
229
+
s.exp = exp
230
+
}
231
+
}
232
+
233
+
func WithLxm(lxm string) ServiceClientOpt {
234
+
return func(s *ServiceClientOpts) {
235
+
s.lxm = lxm
236
+
}
237
+
}
238
+
239
+
func WithDev(dev bool) ServiceClientOpt {
240
+
return func(s *ServiceClientOpts) {
241
+
s.dev = dev
242
+
}
243
+
}
244
+
245
+
func (s *ServiceClientOpts) Audience() string {
246
+
return fmt.Sprintf("did:web:%s", s.service)
247
+
}
248
+
249
+
func (s *ServiceClientOpts) Host() string {
250
+
scheme := "https://"
251
+
if s.dev {
252
+
scheme = "http://"
253
+
}
254
+
255
+
return scheme + s.service
256
+
}
257
+
258
+
func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) {
259
+
opts := ServiceClientOpts{}
260
+
for _, o := range os {
261
+
o(&opts)
262
+
}
263
+
264
+
authorizedClient, err := o.AuthorizedClient(r)
265
+
if err != nil {
266
+
return nil, err
267
+
}
268
+
269
+
resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm)
270
+
if err != nil {
271
+
return nil, err
272
+
}
273
+
274
+
return &indigo_xrpc.Client{
275
+
Auth: &indigo_xrpc.AuthInfo{
276
+
AccessJwt: resp.Token,
277
+
},
278
+
Host: opts.Host(),
279
+
}, nil
280
}
281
282
type ClientMetadata struct {
+67
-31
appview/pages/funcmap.go
+67
-31
appview/pages/funcmap.go
···
17
"time"
18
19
"github.com/dustin/go-humanize"
20
"github.com/microcosm-cc/bluemonday"
21
"tangled.sh/tangled.sh/core/appview/filetree"
22
"tangled.sh/tangled.sh/core/appview/pages/markup"
···
105
s = append(s, values...)
106
return s
107
},
108
-
"timeFmt": humanize.Time,
109
-
"longTimeFmt": func(t time.Time) string {
110
-
return t.Format("2006-01-02 * 3:04 PM")
111
-
},
112
-
"commaFmt": humanize.Comma,
113
-
"shortTimeFmt": func(t time.Time) string {
114
return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{
115
{time.Second, "now", time.Second},
116
{2 * time.Second, "1s %s", 1},
···
129
{math.MaxInt64, "a long while %s", 1},
130
})
131
},
132
-
"durationFmt": func(duration time.Duration) string {
133
days := int64(duration.Hours() / 24)
134
hours := int64(math.Mod(duration.Hours(), 24))
135
minutes := int64(math.Mod(duration.Minutes(), 60))
136
seconds := int64(math.Mod(duration.Seconds(), 60))
137
-
138
-
chunks := []struct {
139
-
name string
140
-
amount int64
141
-
}{
142
-
{"d", days},
143
-
{"hr", hours},
144
-
{"min", minutes},
145
-
{"s", seconds},
146
-
}
147
-
148
-
parts := []string{}
149
-
150
-
for _, chunk := range chunks {
151
-
if chunk.amount != 0 {
152
-
parts = append(parts, fmt.Sprintf("%d%s", chunk.amount, chunk.name))
153
-
}
154
-
}
155
-
156
-
return strings.Join(parts, " ")
157
},
158
"byteFmt": humanize.Bytes,
159
"length": func(slice any) int {
···
200
if v.Len() == 0 {
201
return nil
202
}
203
-
return v.Slice(0, min(n, v.Len()-1)).Interface()
204
},
205
206
"markdown": func(text string) template.HTML {
···
250
return u
251
},
252
253
-
"tinyAvatar": p.tinyAvatar,
254
}
255
}
256
257
-
func (p *Pages) tinyAvatar(handle string) string {
258
handle = strings.TrimPrefix(handle, "@")
259
secret := p.avatar.SharedSecret
260
h := hmac.New(sha256.New, []byte(secret))
261
h.Write([]byte(handle))
262
signature := hex.EncodeToString(h.Sum(nil))
263
-
return fmt.Sprintf("%s/%s/%s?size=tiny", p.avatar.Host, signature, handle)
264
}
265
266
func icon(name string, classes []string) (template.HTML, error) {
···
288
modifiedSVG := svgStr[:svgTagEnd] + classTag + svgStr[svgTagEnd:]
289
return template.HTML(modifiedSVG), nil
290
}
···
17
"time"
18
19
"github.com/dustin/go-humanize"
20
+
"github.com/go-enry/go-enry/v2"
21
"github.com/microcosm-cc/bluemonday"
22
"tangled.sh/tangled.sh/core/appview/filetree"
23
"tangled.sh/tangled.sh/core/appview/pages/markup"
···
106
s = append(s, values...)
107
return s
108
},
109
+
"commaFmt": humanize.Comma,
110
+
"relTimeFmt": humanize.Time,
111
+
"shortRelTimeFmt": func(t time.Time) string {
112
return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{
113
{time.Second, "now", time.Second},
114
{2 * time.Second, "1s %s", 1},
···
127
{math.MaxInt64, "a long while %s", 1},
128
})
129
},
130
+
"longTimeFmt": func(t time.Time) string {
131
+
return t.Format("Jan 2, 2006, 3:04 PM MST")
132
+
},
133
+
"iso8601DateTimeFmt": func(t time.Time) string {
134
+
return t.Format("2006-01-02T15:04:05-07:00")
135
+
},
136
+
"iso8601DurationFmt": func(duration time.Duration) string {
137
days := int64(duration.Hours() / 24)
138
hours := int64(math.Mod(duration.Hours(), 24))
139
minutes := int64(math.Mod(duration.Minutes(), 60))
140
seconds := int64(math.Mod(duration.Seconds(), 60))
141
+
return fmt.Sprintf("P%dD%dH%dM%dS", days, hours, minutes, seconds)
142
+
},
143
+
"durationFmt": func(duration time.Duration) string {
144
+
return durationFmt(duration, [4]string{"d", "hr", "min", "s"})
145
+
},
146
+
"longDurationFmt": func(duration time.Duration) string {
147
+
return durationFmt(duration, [4]string{"days", "hours", "minutes", "seconds"})
148
},
149
"byteFmt": humanize.Bytes,
150
"length": func(slice any) int {
···
191
if v.Len() == 0 {
192
return nil
193
}
194
+
return v.Slice(0, min(n, v.Len())).Interface()
195
},
196
197
"markdown": func(text string) template.HTML {
···
241
return u
242
},
243
244
+
"tinyAvatar": func(handle string) string {
245
+
return p.avatarUri(handle, "tiny")
246
+
},
247
+
"fullAvatar": func(handle string) string {
248
+
return p.avatarUri(handle, "")
249
+
},
250
+
"langColor": enry.GetColor,
251
+
"layoutSide": func() string {
252
+
return "col-span-1 md:col-span-2 lg:col-span-3"
253
+
},
254
+
"layoutCenter": func() string {
255
+
return "col-span-1 md:col-span-8 lg:col-span-6"
256
+
},
257
}
258
}
259
260
+
func (p *Pages) avatarUri(handle, size string) string {
261
handle = strings.TrimPrefix(handle, "@")
262
+
263
secret := p.avatar.SharedSecret
264
h := hmac.New(sha256.New, []byte(secret))
265
h.Write([]byte(handle))
266
signature := hex.EncodeToString(h.Sum(nil))
267
+
268
+
sizeArg := ""
269
+
if size != "" {
270
+
sizeArg = fmt.Sprintf("size=%s", size)
271
+
}
272
+
return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg)
273
}
274
275
func icon(name string, classes []string) (template.HTML, error) {
···
297
modifiedSVG := svgStr[:svgTagEnd] + classTag + svgStr[svgTagEnd:]
298
return template.HTML(modifiedSVG), nil
299
}
300
+
301
+
func durationFmt(duration time.Duration, names [4]string) string {
302
+
days := int64(duration.Hours() / 24)
303
+
hours := int64(math.Mod(duration.Hours(), 24))
304
+
minutes := int64(math.Mod(duration.Minutes(), 60))
305
+
seconds := int64(math.Mod(duration.Seconds(), 60))
306
+
307
+
chunks := []struct {
308
+
name string
309
+
amount int64
310
+
}{
311
+
{names[0], days},
312
+
{names[1], hours},
313
+
{names[2], minutes},
314
+
{names[3], seconds},
315
+
}
316
+
317
+
parts := []string{}
318
+
319
+
for _, chunk := range chunks {
320
+
if chunk.amount != 0 {
321
+
parts = append(parts, fmt.Sprintf("%d%s", chunk.amount, chunk.name))
322
+
}
323
+
}
324
+
325
+
return strings.Join(parts, " ")
326
+
}
+2
-2
appview/pages/markup/camo.go
+2
-2
appview/pages/markup/camo.go
···
9
"github.com/yuin/goldmark/ast"
10
)
11
12
-
func generateCamoURL(baseURL, secret, imageURL string) string {
13
h := hmac.New(sha256.New, []byte(secret))
14
h.Write([]byte(imageURL))
15
signature := hex.EncodeToString(h.Sum(nil))
···
24
}
25
26
if rctx.CamoUrl != "" && rctx.CamoSecret != "" {
27
-
return generateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst)
28
}
29
30
return dst
···
9
"github.com/yuin/goldmark/ast"
10
)
11
12
+
func GenerateCamoURL(baseURL, secret, imageURL string) string {
13
h := hmac.New(sha256.New, []byte(secret))
14
h.Write([]byte(imageURL))
15
signature := hex.EncodeToString(h.Sum(nil))
···
24
}
25
26
if rctx.CamoUrl != "" && rctx.CamoSecret != "" {
27
+
return GenerateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst)
28
}
29
30
return dst
+157
-26
appview/pages/pages.go
+157
-26
appview/pages/pages.go
···
14
"os"
15
"path/filepath"
16
"strings"
17
18
"tangled.sh/tangled.sh/core/appview/commitverify"
19
"tangled.sh/tangled.sh/core/appview/config"
20
"tangled.sh/tangled.sh/core/appview/db"
···
32
"github.com/bluesky-social/indigo/atproto/syntax"
33
"github.com/go-git/go-git/v5/plumbing"
34
"github.com/go-git/go-git/v5/plumbing/object"
35
-
"github.com/microcosm-cc/bluemonday"
36
)
37
38
//go:embed templates/* static
39
var Files embed.FS
40
41
type Pages struct {
42
-
t map[string]*template.Template
43
avatar config.AvatarConfig
44
dev bool
45
embedFS embed.FS
···
56
}
57
58
p := &Pages{
59
t: make(map[string]*template.Template),
60
dev: config.Core.Dev,
61
avatar: config.Avatar,
···
147
}
148
149
log.Printf("total templates loaded: %d", len(templates))
150
p.t = templates
151
}
152
···
207
}
208
209
// Update the template in the map
210
p.t[name] = tmpl
211
log.Printf("template reloaded from disk: %s", name)
212
return nil
···
221
}
222
}
223
224
tmpl, exists := p.t[templateName]
225
if !exists {
226
return fmt.Errorf("template not found: %s", templateName)
···
252
return p.executePlain("user/login", w, params)
253
}
254
255
type TimelineParams struct {
256
LoggedInUser *oauth.User
257
Timeline []db.TimelineEvent
···
278
}
279
280
func (p *Pages) Knots(w io.Writer, params KnotsParams) error {
281
-
return p.execute("knots", w, params)
282
}
283
284
type KnotParams struct {
···
286
DidHandleMap map[string]string
287
Registration *db.Registration
288
Members []string
289
IsOwner bool
290
}
291
292
func (p *Pages) Knot(w io.Writer, params KnotParams) error {
293
-
return p.execute("knot", w, params)
294
}
295
296
type SpindlesParams struct {
···
413
return p.executePlain("user/fragments/editPins", w, params)
414
}
415
416
-
type RepoActionsFragmentParams struct {
417
IsStarred bool
418
RepoAt syntax.ATURI
419
Stats db.RepoStats
420
}
421
422
-
func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error {
423
-
return p.executePlain("repo/fragments/repoActions", w, params)
424
}
425
426
type RepoDescriptionParams struct {
···
467
ext := filepath.Ext(params.ReadmeFileName)
468
switch ext {
469
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
470
htmlString = p.rctx.RenderMarkdown(params.Readme)
471
params.Raw = false
472
-
params.HTMLReadme = template.HTML(p.rctx.Sanitize(htmlString))
473
default:
474
-
htmlString = string(params.Readme)
475
params.Raw = true
476
-
params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString))
477
}
478
}
479
···
502
Active string
503
EmailToDidOrHandle map[string]string
504
Pipeline *db.Pipeline
505
506
// singular because it's always going to be just one
507
VerifiedCommit commitverify.VerifiedCommits
···
519
RepoInfo repoinfo.RepoInfo
520
Active string
521
BreadCrumbs [][]string
522
-
BaseTreeLink string
523
-
BaseBlobLink string
524
types.RepoTreeResponse
525
}
526
···
590
LoggedInUser *oauth.User
591
RepoInfo repoinfo.RepoInfo
592
Active string
593
BreadCrumbs [][]string
594
ShowRendered bool
595
RenderToggle bool
···
657
Branches []types.Branch
658
Spindles []string
659
CurrentSpindle string
660
// TODO: use repoinfo.roles
661
IsCollaboratorInviteAllowed bool
662
}
···
666
return p.executeRepo("repo/settings", w, params)
667
}
668
669
type RepoIssuesParams struct {
670
LoggedInUser *oauth.User
671
RepoInfo repoinfo.RepoInfo
···
690
IssueOwnerHandle string
691
DidHandleMap map[string]string
692
693
State string
694
}
695
696
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
697
params.Active = "issues"
698
if params.Issue.Open {
···
762
DidHandleMap map[string]string
763
FilteringBy db.PullState
764
Stacks map[string]db.Stack
765
}
766
767
func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
···
798
MergeCheck types.MergeCheckResponse
799
ResubmitCheck ResubmitResult
800
Pipelines map[string]db.Pipeline
801
}
802
803
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
806
}
807
808
type RepoPullPatchParams struct {
809
-
LoggedInUser *oauth.User
810
-
DidHandleMap map[string]string
811
-
RepoInfo repoinfo.RepoInfo
812
-
Pull *db.Pull
813
-
Stack db.Stack
814
-
Diff *types.NiceDiff
815
-
Round int
816
-
Submission *db.PullSubmission
817
}
818
819
// this name is a mouthful
···
822
}
823
824
type RepoPullInterdiffParams struct {
825
-
LoggedInUser *oauth.User
826
-
DidHandleMap map[string]string
827
-
RepoInfo repoinfo.RepoInfo
828
-
Pull *db.Pull
829
-
Round int
830
-
Interdiff *patchutil.InterdiffResult
831
}
832
833
// this name is a mouthful
···
918
Base string
919
Head string
920
Diff *types.NiceDiff
921
922
Active string
923
}
···
14
"os"
15
"path/filepath"
16
"strings"
17
+
"sync"
18
19
+
"tangled.sh/tangled.sh/core/api/tangled"
20
"tangled.sh/tangled.sh/core/appview/commitverify"
21
"tangled.sh/tangled.sh/core/appview/config"
22
"tangled.sh/tangled.sh/core/appview/db"
···
34
"github.com/bluesky-social/indigo/atproto/syntax"
35
"github.com/go-git/go-git/v5/plumbing"
36
"github.com/go-git/go-git/v5/plumbing/object"
37
)
38
39
//go:embed templates/* static
40
var Files embed.FS
41
42
type Pages struct {
43
+
mu sync.RWMutex
44
+
t map[string]*template.Template
45
+
46
avatar config.AvatarConfig
47
dev bool
48
embedFS embed.FS
···
59
}
60
61
p := &Pages{
62
+
mu: sync.RWMutex{},
63
t: make(map[string]*template.Template),
64
dev: config.Core.Dev,
65
avatar: config.Avatar,
···
151
}
152
153
log.Printf("total templates loaded: %d", len(templates))
154
+
p.mu.Lock()
155
+
defer p.mu.Unlock()
156
p.t = templates
157
}
158
···
213
}
214
215
// Update the template in the map
216
+
p.mu.Lock()
217
+
defer p.mu.Unlock()
218
p.t[name] = tmpl
219
log.Printf("template reloaded from disk: %s", name)
220
return nil
···
229
}
230
}
231
232
+
p.mu.RLock()
233
+
defer p.mu.RUnlock()
234
tmpl, exists := p.t[templateName]
235
if !exists {
236
return fmt.Errorf("template not found: %s", templateName)
···
262
return p.executePlain("user/login", w, params)
263
}
264
265
+
type SignupParams struct{}
266
+
267
+
func (p *Pages) CompleteSignup(w io.Writer, params SignupParams) error {
268
+
return p.executePlain("user/completeSignup", w, params)
269
+
}
270
+
271
+
type TermsOfServiceParams struct {
272
+
LoggedInUser *oauth.User
273
+
}
274
+
275
+
func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error {
276
+
return p.execute("legal/terms", w, params)
277
+
}
278
+
279
+
type PrivacyPolicyParams struct {
280
+
LoggedInUser *oauth.User
281
+
}
282
+
283
+
func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error {
284
+
return p.execute("legal/privacy", w, params)
285
+
}
286
+
287
type TimelineParams struct {
288
LoggedInUser *oauth.User
289
Timeline []db.TimelineEvent
···
310
}
311
312
func (p *Pages) Knots(w io.Writer, params KnotsParams) error {
313
+
return p.execute("knots/index", w, params)
314
}
315
316
type KnotParams struct {
···
318
DidHandleMap map[string]string
319
Registration *db.Registration
320
Members []string
321
+
Repos map[string][]db.Repo
322
IsOwner bool
323
}
324
325
func (p *Pages) Knot(w io.Writer, params KnotParams) error {
326
+
return p.execute("knots/dashboard", w, params)
327
+
}
328
+
329
+
type KnotListingParams struct {
330
+
db.Registration
331
+
}
332
+
333
+
func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error {
334
+
return p.executePlain("knots/fragments/knotListing", w, params)
335
+
}
336
+
337
+
type KnotListingFullParams struct {
338
+
Registrations []db.Registration
339
+
}
340
+
341
+
func (p *Pages) KnotListingFull(w io.Writer, params KnotListingFullParams) error {
342
+
return p.executePlain("knots/fragments/knotListingFull", w, params)
343
+
}
344
+
345
+
type KnotSecretParams struct {
346
+
Secret string
347
+
}
348
+
349
+
func (p *Pages) KnotSecret(w io.Writer, params KnotSecretParams) error {
350
+
return p.executePlain("knots/fragments/secret", w, params)
351
}
352
353
type SpindlesParams struct {
···
470
return p.executePlain("user/fragments/editPins", w, params)
471
}
472
473
+
type RepoStarFragmentParams struct {
474
IsStarred bool
475
RepoAt syntax.ATURI
476
Stats db.RepoStats
477
}
478
479
+
func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error {
480
+
return p.executePlain("repo/fragments/repoStar", w, params)
481
}
482
483
type RepoDescriptionParams struct {
···
524
ext := filepath.Ext(params.ReadmeFileName)
525
switch ext {
526
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
527
+
htmlString = p.rctx.Sanitize(htmlString)
528
htmlString = p.rctx.RenderMarkdown(params.Readme)
529
params.Raw = false
530
+
params.HTMLReadme = template.HTML(htmlString)
531
default:
532
params.Raw = true
533
}
534
}
535
···
558
Active string
559
EmailToDidOrHandle map[string]string
560
Pipeline *db.Pipeline
561
+
DiffOpts types.DiffOpts
562
563
// singular because it's always going to be just one
564
VerifiedCommit commitverify.VerifiedCommits
···
576
RepoInfo repoinfo.RepoInfo
577
Active string
578
BreadCrumbs [][]string
579
+
TreePath string
580
types.RepoTreeResponse
581
}
582
···
646
LoggedInUser *oauth.User
647
RepoInfo repoinfo.RepoInfo
648
Active string
649
+
Unsupported bool
650
+
IsImage bool
651
+
IsVideo bool
652
+
ContentSrc string
653
BreadCrumbs [][]string
654
ShowRendered bool
655
RenderToggle bool
···
717
Branches []types.Branch
718
Spindles []string
719
CurrentSpindle string
720
+
Secrets []*tangled.RepoListSecrets_Secret
721
+
722
// TODO: use repoinfo.roles
723
IsCollaboratorInviteAllowed bool
724
}
···
728
return p.executeRepo("repo/settings", w, params)
729
}
730
731
+
type RepoGeneralSettingsParams struct {
732
+
LoggedInUser *oauth.User
733
+
RepoInfo repoinfo.RepoInfo
734
+
Active string
735
+
Tabs []map[string]any
736
+
Tab string
737
+
Branches []types.Branch
738
+
}
739
+
740
+
func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error {
741
+
params.Active = "settings"
742
+
return p.executeRepo("repo/settings/general", w, params)
743
+
}
744
+
745
+
type RepoAccessSettingsParams struct {
746
+
LoggedInUser *oauth.User
747
+
RepoInfo repoinfo.RepoInfo
748
+
Active string
749
+
Tabs []map[string]any
750
+
Tab string
751
+
Collaborators []Collaborator
752
+
}
753
+
754
+
func (p *Pages) RepoAccessSettings(w io.Writer, params RepoAccessSettingsParams) error {
755
+
params.Active = "settings"
756
+
return p.executeRepo("repo/settings/access", w, params)
757
+
}
758
+
759
+
type RepoPipelineSettingsParams struct {
760
+
LoggedInUser *oauth.User
761
+
RepoInfo repoinfo.RepoInfo
762
+
Active string
763
+
Tabs []map[string]any
764
+
Tab string
765
+
Spindles []string
766
+
CurrentSpindle string
767
+
Secrets []map[string]any
768
+
}
769
+
770
+
func (p *Pages) RepoPipelineSettings(w io.Writer, params RepoPipelineSettingsParams) error {
771
+
params.Active = "settings"
772
+
return p.executeRepo("repo/settings/pipelines", w, params)
773
+
}
774
+
775
type RepoIssuesParams struct {
776
LoggedInUser *oauth.User
777
RepoInfo repoinfo.RepoInfo
···
796
IssueOwnerHandle string
797
DidHandleMap map[string]string
798
799
+
OrderedReactionKinds []db.ReactionKind
800
+
Reactions map[db.ReactionKind]int
801
+
UserReacted map[db.ReactionKind]bool
802
+
803
State string
804
}
805
806
+
type ThreadReactionFragmentParams struct {
807
+
ThreadAt syntax.ATURI
808
+
Kind db.ReactionKind
809
+
Count int
810
+
IsReacted bool
811
+
}
812
+
813
+
func (p *Pages) ThreadReactionFragment(w io.Writer, params ThreadReactionFragmentParams) error {
814
+
return p.executePlain("repo/fragments/reaction", w, params)
815
+
}
816
+
817
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
818
params.Active = "issues"
819
if params.Issue.Open {
···
883
DidHandleMap map[string]string
884
FilteringBy db.PullState
885
Stacks map[string]db.Stack
886
+
Pipelines map[string]db.Pipeline
887
}
888
889
func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
···
920
MergeCheck types.MergeCheckResponse
921
ResubmitCheck ResubmitResult
922
Pipelines map[string]db.Pipeline
923
+
924
+
OrderedReactionKinds []db.ReactionKind
925
+
Reactions map[db.ReactionKind]int
926
+
UserReacted map[db.ReactionKind]bool
927
}
928
929
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
932
}
933
934
type RepoPullPatchParams struct {
935
+
LoggedInUser *oauth.User
936
+
DidHandleMap map[string]string
937
+
RepoInfo repoinfo.RepoInfo
938
+
Pull *db.Pull
939
+
Stack db.Stack
940
+
Diff *types.NiceDiff
941
+
Round int
942
+
Submission *db.PullSubmission
943
+
OrderedReactionKinds []db.ReactionKind
944
+
DiffOpts types.DiffOpts
945
}
946
947
// this name is a mouthful
···
950
}
951
952
type RepoPullInterdiffParams struct {
953
+
LoggedInUser *oauth.User
954
+
DidHandleMap map[string]string
955
+
RepoInfo repoinfo.RepoInfo
956
+
Pull *db.Pull
957
+
Round int
958
+
Interdiff *patchutil.InterdiffResult
959
+
OrderedReactionKinds []db.ReactionKind
960
+
DiffOpts types.DiffOpts
961
}
962
963
// this name is a mouthful
···
1048
Base string
1049
Head string
1050
Diff *types.NiceDiff
1051
+
DiffOpts types.DiffOpts
1052
1053
Active string
1054
}
-98
appview/pages/templates/knot.html
-98
appview/pages/templates/knot.html
···
1
-
{{ define "title" }}{{ .Registration.Domain }}{{ end }}
2
-
3
-
{{ define "content" }}
4
-
<div class="p-6">
5
-
<p class="text-xl font-bold dark:text-white">{{ .Registration.Domain }}</p>
6
-
</div>
7
-
8
-
<div class="flex flex-col">
9
-
{{ block "registration-info" . }} {{ end }}
10
-
{{ block "members" . }} {{ end }}
11
-
{{ block "add-member" . }} {{ end }}
12
-
</div>
13
-
{{ end }}
14
-
15
-
{{ define "registration-info" }}
16
-
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
17
-
<dl class="grid grid-cols-[auto_1fr] gap-x-4 dark:text-gray-200">
18
-
<dt class="font-bold">opened by</dt>
19
-
<dd>
20
-
<span>
21
-
{{ index $.DidHandleMap .Registration.ByDid }} <span class="text-gray-500 dark:text-gray-400 font-mono">{{ .Registration.ByDid }}</span>
22
-
</span>
23
-
{{ if eq $.LoggedInUser.Did $.Registration.ByDid }}
24
-
<span class="text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded ml-2">you</span>
25
-
{{ end }}
26
-
</dd>
27
-
28
-
<dt class="font-bold">opened</dt>
29
-
<dd>{{ .Registration.Created | timeFmt }}</dd>
30
-
31
-
{{ if .Registration.Registered }}
32
-
<dt class="font-bold">registered</dt>
33
-
<dd>{{ .Registration.Registered | timeFmt }}</dd>
34
-
{{ else }}
35
-
<dt class="font-bold">status</dt>
36
-
<dd class="text-yellow-800 dark:text-yellow-200 bg-yellow-100 dark:bg-yellow-900 rounded px-2 py-1 inline-block">
37
-
Pending Registration
38
-
</dd>
39
-
{{ end }}
40
-
</dl>
41
-
42
-
{{ if not .Registration.Registered }}
43
-
<div class="mt-4">
44
-
<button
45
-
class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
46
-
hx-post="/knots/{{.Domain}}/init"
47
-
hx-swap="none">
48
-
Initialize Registration
49
-
</button>
50
-
</div>
51
-
{{ end }}
52
-
</section>
53
-
{{ end }}
54
-
55
-
{{ define "members" }}
56
-
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">members</h2>
57
-
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
58
-
{{ if .Registration.Registered }}
59
-
<div id="member-list" class="flex flex-col gap-4">
60
-
{{ range $.Members }}
61
-
<div class="inline-flex items-center gap-4">
62
-
{{ i "user" "w-4 h-4 dark:text-gray-300" }}
63
-
<a href="/{{index $.DidHandleMap .}}" class="text-gray-900 dark:text-white">{{index $.DidHandleMap .}}
64
-
<span class="text-gray-500 dark:text-gray-400 font-mono">{{.}}</span>
65
-
</a>
66
-
</div>
67
-
{{ else }}
68
-
<p class="text-gray-500 dark:text-gray-400">No members have been added yet.</p>
69
-
{{ end }}
70
-
</div>
71
-
{{ else }}
72
-
<p class="text-gray-500 dark:text-gray-400">Members can be added after registration is complete.</p>
73
-
{{ end }}
74
-
</section>
75
-
{{ end }}
76
-
77
-
{{ define "add-member" }}
78
-
{{ if $.IsOwner }}
79
-
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">add member</h2>
80
-
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
81
-
<form
82
-
hx-put="/knots/{{.Registration.Domain}}/member"
83
-
class="max-w-2xl space-y-4">
84
-
<input
85
-
type="text"
86
-
id="subject"
87
-
name="subject"
88
-
placeholder="did or handle"
89
-
required
90
-
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/>
91
-
92
-
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" type="submit">add member</button>
93
-
94
-
<div id="add-member-error" class="error dark:text-red-400"></div>
95
-
</form>
96
-
</section>
97
-
{{ end }}
98
-
{{ end }}
···
+63
appview/pages/templates/knots/dashboard.html
+63
appview/pages/templates/knots/dashboard.html
···
···
1
+
{{ define "title" }}{{ .Registration.Domain }}{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="px-6 py-4">
5
+
<div class="flex justify-between items-center">
6
+
<div id="left-side" class="flex gap-2 items-center">
7
+
<h1 class="text-xl font-bold dark:text-white">
8
+
{{ .Registration.Domain }}
9
+
</h1>
10
+
<span class="text-gray-500 text-base">
11
+
{{ template "repo/fragments/shortTimeAgo" .Registration.Created }}
12
+
</span>
13
+
</div>
14
+
<div id="right-side" class="flex gap-2">
15
+
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }}
16
+
{{ if .Registration.Registered }}
17
+
<span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span>
18
+
{{ template "knots/fragments/addMemberModal" .Registration }}
19
+
{{ else }}
20
+
<span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} pending</span>
21
+
{{ end }}
22
+
</div>
23
+
</div>
24
+
<div id="operation-error" class="dark:text-red-400"></div>
25
+
</div>
26
+
27
+
{{ if .Members }}
28
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
29
+
<div class="flex flex-col gap-2">
30
+
{{ block "knotMember" . }} {{ end }}
31
+
</div>
32
+
</section>
33
+
{{ end }}
34
+
{{ end }}
35
+
36
+
{{ define "knotMember" }}
37
+
{{ range .Members }}
38
+
<div>
39
+
<div class="flex justify-between items-center">
40
+
<div class="flex items-center gap-2">
41
+
{{ i "user" "size-4" }}
42
+
{{ $user := index $.DidHandleMap . }}
43
+
<a href="/{{ $user }}">{{ $user }} <span class="ml-2 font-mono text-gray-500">{{.}}</span></a>
44
+
</div>
45
+
</div>
46
+
<div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700">
47
+
{{ $repos := index $.Repos . }}
48
+
{{ range $repos }}
49
+
<div class="flex gap-2 items-center">
50
+
{{ i "book-marked" "size-4" }}
51
+
<a href="/{{ .Did }}/{{ .Name }}">
52
+
{{ .Name }}
53
+
</a>
54
+
</div>
55
+
{{ else }}
56
+
<div class="text-gray-500 dark:text-gray-400">
57
+
No repositories created yet.
58
+
</div>
59
+
{{ end }}
60
+
</div>
61
+
</div>
62
+
{{ end }}
63
+
{{ end }}
+58
appview/pages/templates/knots/fragments/addMemberModal.html
+58
appview/pages/templates/knots/fragments/addMemberModal.html
···
···
1
+
{{ define "knots/fragments/addMemberModal" }}
2
+
<button
3
+
class="btn gap-2 group"
4
+
title="Add member to this spindle"
5
+
popovertarget="add-member-{{ .Id }}"
6
+
popovertargetaction="toggle"
7
+
>
8
+
{{ i "user-plus" "w-5 h-5" }}
9
+
<span class="hidden md:inline">add member</span>
10
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
11
+
</button>
12
+
13
+
<div
14
+
id="add-member-{{ .Id }}"
15
+
popover
16
+
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
17
+
{{ block "addKnotMemberPopover" . }} {{ end }}
18
+
</div>
19
+
{{ end }}
20
+
21
+
{{ define "addKnotMemberPopover" }}
22
+
<form
23
+
hx-put="/knots/{{ .Domain }}/member"
24
+
hx-indicator="#spinner"
25
+
hx-swap="none"
26
+
class="flex flex-col gap-2"
27
+
>
28
+
<label for="member-did-{{ .Id }}" class="uppercase p-0">
29
+
ADD MEMBER
30
+
</label>
31
+
<p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories on this knot.</p>
32
+
<input
33
+
type="text"
34
+
id="member-did-{{ .Id }}"
35
+
name="subject"
36
+
required
37
+
placeholder="@foo.bsky.social"
38
+
/>
39
+
<div class="flex gap-2 pt-2">
40
+
<button
41
+
type="button"
42
+
popovertarget="add-member-{{ .Id }}"
43
+
popovertargetaction="hide"
44
+
class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
45
+
>
46
+
{{ i "x" "size-4" }} cancel
47
+
</button>
48
+
<button type="submit" class="btn w-1/2 flex items-center">
49
+
<span class="inline-flex gap-2 items-center">{{ i "user-plus" "size-4" }} add</span>
50
+
<span id="spinner" class="group">
51
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
52
+
</span>
53
+
</button>
54
+
</div>
55
+
<div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div>
56
+
</form>
57
+
{{ end }}
58
+
+51
appview/pages/templates/knots/fragments/knotListing.html
+51
appview/pages/templates/knots/fragments/knotListing.html
···
···
1
+
{{ define "knots/fragments/knotListing" }}
2
+
<div
3
+
id="knot-{{.Id}}"
4
+
hx-swap-oob="true"
5
+
class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700">
6
+
{{ block "listLeftSide" . }} {{ end }}
7
+
{{ block "listRightSide" . }} {{ end }}
8
+
</div>
9
+
{{ end }}
10
+
11
+
{{ define "listLeftSide" }}
12
+
<div class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]">
13
+
{{ i "hard-drive" "w-4 h-4" }}
14
+
{{ if .Registered }}
15
+
<a href="/knots/{{ .Domain }}">
16
+
{{ .Domain }}
17
+
</a>
18
+
{{ else }}
19
+
{{ .Domain }}
20
+
{{ end }}
21
+
<span class="text-gray-500">
22
+
{{ template "repo/fragments/shortTimeAgo" .Created }}
23
+
</span>
24
+
</div>
25
+
{{ end }}
26
+
27
+
{{ define "listRightSide" }}
28
+
<div id="right-side" class="flex gap-2">
29
+
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }}
30
+
{{ if .Registered }}
31
+
<span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span>
32
+
{{ template "knots/fragments/addMemberModal" . }}
33
+
{{ else }}
34
+
<span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} pending</span>
35
+
{{ block "initializeButton" . }} {{ end }}
36
+
{{ end }}
37
+
</div>
38
+
{{ end }}
39
+
40
+
{{ define "initializeButton" }}
41
+
<button
42
+
class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center group"
43
+
hx-post="/knots/{{ .Domain }}/init"
44
+
hx-swap="none"
45
+
>
46
+
{{ i "square-play" "w-5 h-5" }}
47
+
<span class="hidden md:inline">initialize</span>
48
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
49
+
</button>
50
+
{{ end }}
51
+
+18
appview/pages/templates/knots/fragments/knotListingFull.html
+18
appview/pages/templates/knots/fragments/knotListingFull.html
···
···
1
+
{{ define "knots/fragments/knotListingFull" }}
2
+
<section
3
+
id="knot-listing-full"
4
+
hx-swap-oob="true"
5
+
class="rounded w-full flex flex-col gap-2">
6
+
<h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your knots</h2>
7
+
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full">
8
+
{{ range $knot := .Registrations }}
9
+
{{ template "knots/fragments/knotListing" . }}
10
+
{{ else }}
11
+
<div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500">
12
+
no knots registered yet
13
+
</div>
14
+
{{ end }}
15
+
</div>
16
+
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
17
+
</section>
18
+
{{ end }}
+10
appview/pages/templates/knots/fragments/secret.html
+10
appview/pages/templates/knots/fragments/secret.html
···
···
1
+
{{ define "knots/fragments/secret" }}
2
+
<div
3
+
id="secret"
4
+
hx-swap-oob="true"
5
+
class="bg-gray-50 dark:bg-gray-700 border border-black dark:border-gray-500 rounded px-6 py-2 w-full lg:w-3xl">
6
+
<h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">generated secret</h2>
7
+
<p class="pb-2">Configure your knot to use this secret, and then hit initialize.</p>
8
+
<span class="font-mono overflow-x">{{ .Secret }}</span>
9
+
</div>
10
+
{{ end }}
+69
appview/pages/templates/knots/index.html
+69
appview/pages/templates/knots/index.html
···
···
1
+
{{ define "title" }}knots{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="px-6 py-4">
5
+
<h1 class="text-xl font-bold dark:text-white">Knots</h1>
6
+
</div>
7
+
8
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
9
+
<div class="flex flex-col gap-6">
10
+
{{ block "about" . }} {{ end }}
11
+
{{ template "knots/fragments/knotListingFull" . }}
12
+
{{ block "register" . }} {{ end }}
13
+
</div>
14
+
</section>
15
+
{{ end }}
16
+
17
+
{{ define "about" }}
18
+
<section class="rounded flex flex-col gap-2">
19
+
<p class="dark:text-gray-300">
20
+
Knots are lightweight headless servers that enable users to host Git repositories with ease.
21
+
Knots are designed for either single or multi-tenant use which is perfect for self-hosting on a Raspberry Pi at home, or larger โcommunityโ servers.
22
+
When creating a repository, you can choose a knot to store it on.
23
+
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md">
24
+
Checkout the documentation if you're interested in self-hosting.
25
+
</a>
26
+
</p>
27
+
</section>
28
+
{{ end }}
29
+
30
+
{{ define "register" }}
31
+
<section class="rounded max-w-2xl flex flex-col gap-2">
32
+
<h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a knot</h2>
33
+
<p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to generate a key.</p>
34
+
<form
35
+
hx-post="/knots/key"
36
+
class="space-y-4"
37
+
hx-indicator="#register-button"
38
+
hx-swap="none"
39
+
>
40
+
<div class="flex gap-2">
41
+
<input
42
+
type="text"
43
+
id="domain"
44
+
name="domain"
45
+
placeholder="knot.example.com"
46
+
required
47
+
class="flex-1 w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded"
48
+
>
49
+
<button
50
+
type="submit"
51
+
id="register-button"
52
+
class="btn rounded flex items-center py-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group"
53
+
>
54
+
<span class="inline-flex items-center gap-2">
55
+
{{ i "plus" "w-4 h-4" }}
56
+
generate
57
+
</span>
58
+
<span class="pl-2 hidden group-[.htmx-request]:inline">
59
+
{{ i "loader-circle" "w-4 h-4 animate-spin" }}
60
+
</span>
61
+
</button>
62
+
</div>
63
+
64
+
<div id="registration-error" class="error dark:text-red-400"></div>
65
+
</form>
66
+
67
+
<div id="secret"></div>
68
+
</section>
69
+
{{ end }}
-93
appview/pages/templates/knots.html
-93
appview/pages/templates/knots.html
···
1
-
{{ define "title" }}knots{{ end }}
2
-
{{ define "content" }}
3
-
<div class="p-6">
4
-
<p class="text-xl font-bold dark:text-white">Knots</p>
5
-
</div>
6
-
<div class="flex flex-col">
7
-
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">register a knot</h2>
8
-
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
9
-
<p class="mb-8 dark:text-gray-300">Generate a key to initialize your knot server.</p>
10
-
<form
11
-
hx-post="/knots/key"
12
-
class="max-w-2xl mb-8 space-y-4"
13
-
hx-indicator="#generate-knot-key-spinner"
14
-
>
15
-
<input
16
-
type="text"
17
-
id="domain"
18
-
name="domain"
19
-
placeholder="knot.example.com"
20
-
required
21
-
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
22
-
>
23
-
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex items-center" type="submit">
24
-
<span>generate key</span>
25
-
<span id="generate-knot-key-spinner" class="group">
26
-
{{ i "loader-circle" "pl-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
27
-
</span>
28
-
</button>
29
-
<div id="settings-knots-error" class="error dark:text-red-400"></div>
30
-
</form>
31
-
</section>
32
-
33
-
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">my knots</h2>
34
-
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
35
-
<div id="knots-list" class="flex flex-col gap-6 mb-8">
36
-
{{ range .Registrations }}
37
-
{{ if .Registered }}
38
-
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
39
-
<div class="flex flex-col gap-1">
40
-
<div class="inline-flex items-center gap-4">
41
-
{{ i "git-branch" "w-3 h-3 dark:text-gray-300" }}
42
-
<a href="/knots/{{ .Domain }}">
43
-
<p class="font-bold dark:text-white">{{ .Domain }}</p>
44
-
</a>
45
-
</div>
46
-
<p class="text-sm text-gray-500 dark:text-gray-400">owned by {{ .ByDid }}</p>
47
-
<p class="text-sm text-gray-500 dark:text-gray-400">registered {{ .Registered | timeFmt }}</p>
48
-
</div>
49
-
</div>
50
-
{{ end }}
51
-
{{ else }}
52
-
<p class="text-sm text-gray-500 dark:text-gray-400">No knots registered</p>
53
-
{{ end }}
54
-
</div>
55
-
</section>
56
-
57
-
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">pending registrations</h2>
58
-
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
59
-
<div id="pending-knots-list" class="flex flex-col gap-6 mb-8">
60
-
{{ range .Registrations }}
61
-
{{ if not .Registered }}
62
-
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
63
-
<div class="flex flex-col gap-1">
64
-
<div class="inline-flex items-center gap-4">
65
-
<p class="font-bold dark:text-white">{{ .Domain }}</p>
66
-
<div class="inline-flex items-center gap-1">
67
-
<span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">
68
-
pending
69
-
</span>
70
-
</div>
71
-
</div>
72
-
<p class="text-sm text-gray-500 dark:text-gray-400">opened by {{ .ByDid }}</p>
73
-
<p class="text-sm text-gray-500 dark:text-gray-400">created {{ .Created | timeFmt }}</p>
74
-
</div>
75
-
<div class="flex gap-2 items-center">
76
-
<button
77
-
class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center group"
78
-
hx-post="/knots/{{ .Domain }}/init"
79
-
>
80
-
{{ i "square-play" "w-5 h-5" }}
81
-
<span class="hidden md:inline">initialize</span>
82
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
83
-
</button>
84
-
</div>
85
-
</div>
86
-
{{ end }}
87
-
{{ else }}
88
-
<p class="text-sm text-gray-500 dark:text-gray-400">No pending registrations</p>
89
-
{{ end }}
90
-
</div>
91
-
</section>
92
-
</div>
93
-
{{ end }}
···
+37
-11
appview/pages/templates/layouts/base.html
+37
-11
appview/pages/templates/layouts/base.html
···
14
<title>{{ block "title" . }}{{ end }} ยท tangled</title>
15
{{ block "extrameta" . }}{{ end }}
16
</head>
17
-
<body class="bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
18
-
<div class="container mx-auto px-1 md:pt-4 min-h-screen flex flex-col">
19
-
<header style="z-index: 20">
20
-
{{ block "topbar" . }}
21
{{ template "layouts/topbar" . }}
22
-
{{ end }}
23
</header>
24
-
<main class="content grow">{{ block "content" . }}{{ end }}</main>
25
-
<footer class="mt-16">
26
-
{{ block "footer" . }}
27
-
{{ template "layouts/footer" . }}
28
-
{{ end }}
29
</footer>
30
-
</div>
31
</body>
32
</html>
33
{{ end }}
···
14
<title>{{ block "title" . }}{{ end }} ยท tangled</title>
15
{{ block "extrameta" . }}{{ end }}
16
</head>
17
+
<body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
18
+
{{ block "topbarLayout" . }}
19
+
<header class="px-1 col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;">
20
{{ template "layouts/topbar" . }}
21
</header>
22
+
{{ end }}
23
+
24
+
{{ block "mainLayout" . }}
25
+
<div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4">
26
+
{{ block "contentLayout" . }}
27
+
<div class="col-span-1 md:col-span-2">
28
+
{{ block "contentLeft" . }} {{ end }}
29
+
</div>
30
+
<main class="col-span-1 md:col-span-8">
31
+
{{ block "content" . }}{{ end }}
32
+
</main>
33
+
<div class="col-span-1 md:col-span-2">
34
+
{{ block "contentRight" . }} {{ end }}
35
+
</div>
36
+
{{ end }}
37
+
38
+
{{ block "contentAfterLayout" . }}
39
+
<div class="col-span-1 md:col-span-2">
40
+
{{ block "contentAfterLeft" . }} {{ end }}
41
+
</div>
42
+
<main class="col-span-1 md:col-span-8">
43
+
{{ block "contentAfter" . }}{{ end }}
44
+
</main>
45
+
<div class="col-span-1 md:col-span-2">
46
+
{{ block "contentAfterRight" . }} {{ end }}
47
+
</div>
48
+
{{ end }}
49
+
</div>
50
+
{{ end }}
51
+
52
+
{{ block "footerLayout" . }}
53
+
<footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12">
54
+
{{ template "layouts/footer" . }}
55
</footer>
56
+
{{ end }}
57
</body>
58
</html>
59
{{ end }}
+26
-4
appview/pages/templates/layouts/repobase.html
+26
-4
appview/pages/templates/layouts/repobase.html
···
19
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
20
</div>
21
22
-
{{ template "repo/fragments/repoActions" .RepoInfo }}
23
</div>
24
{{ template "repo/fragments/repoDescription" . }}
25
</section>
26
27
<section
28
-
class="min-h-screen w-full flex flex-col drop-shadow-sm"
29
>
30
<nav class="w-full pl-4 overflow-auto">
31
<div class="flex z-60">
···
47
{{ if eq $.Active $key }}
48
{{ $activeTabStyles }}
49
{{ else }}
50
-
group-hover:bg-gray-200 dark:group-hover:bg-gray-700
51
{{ end }}
52
"
53
>
···
64
</div>
65
</nav>
66
<section
67
-
class="bg-white dark:bg-gray-800 p-6 rounded relative w-full drop-shadow-sm dark:text-white"
68
>
69
{{ block "repoContent" . }}{{ end }}
70
</section>
···
19
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
20
</div>
21
22
+
<div class="flex items-center gap-2 z-auto">
23
+
{{ template "repo/fragments/repoStar" .RepoInfo }}
24
+
{{ if .RepoInfo.DisableFork }}
25
+
<button
26
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
27
+
disabled
28
+
title="Empty repositories cannot be forked"
29
+
>
30
+
{{ i "git-fork" "w-4 h-4" }}
31
+
fork
32
+
</button>
33
+
{{ else }}
34
+
<a
35
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
36
+
hx-boost="true"
37
+
href="/{{ .RepoInfo.FullName }}/fork"
38
+
>
39
+
{{ i "git-fork" "w-4 h-4" }}
40
+
fork
41
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
42
+
</a>
43
+
{{ end }}
44
+
</div>
45
</div>
46
{{ template "repo/fragments/repoDescription" . }}
47
</section>
48
49
<section
50
+
class="w-full flex flex-col drop-shadow-sm"
51
>
52
<nav class="w-full pl-4 overflow-auto">
53
<div class="flex z-60">
···
69
{{ if eq $.Active $key }}
70
{{ $activeTabStyles }}
71
{{ else }}
72
+
group-hover:bg-gray-100/25 group-hover:dark:bg-gray-700/25
73
{{ end }}
74
"
75
>
···
86
</div>
87
</nav>
88
<section
89
+
class="bg-white dark:bg-gray-800 p-6 rounded relative w-full dark:text-white"
90
>
91
{{ block "repoContent" . }}{{ end }}
92
</section>
+7
-17
appview/pages/templates/layouts/topbar.html
+7
-17
appview/pages/templates/layouts/topbar.html
···
1
{{ define "layouts/topbar" }}
2
-
<nav class="space-x-4 mb-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
3
-
<div class="container flex justify-between p-0 items-center">
4
<div id="left-items">
5
<a href="/" hx-boost="true" class="flex gap-2 font-semibold italic">
6
tangled<sub>alpha</sub>
7
</a>
8
</div>
9
-
<div class="hidden md:flex gap-4 items-center">
10
-
<a href="https://chat.tangled.sh" class="inline-flex gap-1 items-center">
11
-
{{ i "message-circle" "size-4" }} discord
12
-
</a>
13
-
14
-
<a href="https://web.libera.chat/#tangled" class="inline-flex gap-1 items-center">
15
-
{{ i "hash" "size-4" }} irc
16
-
</a>
17
18
-
<a href="https://tangled.sh/@tangled.sh/core" class="inline-flex gap-1 items-center">
19
-
{{ i "code" "size-4" }} source
20
-
</a>
21
-
</div>
22
<div id="right-items" class="flex items-center gap-4">
23
{{ with .LoggedInUser }}
24
<a href="/repo/new" hx-boost="true" class="btn-create hover:no-underline hover:text-white">
···
36
{{ define "dropDown" }}
37
<details class="relative inline-block text-left">
38
<summary
39
-
class="cursor-pointer list-none"
40
>
41
-
{{ didOrHandle .Did .Handle }}
42
</summary>
43
<div
44
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
45
>
46
-
<a href="/{{ didOrHandle .Did .Handle }}">profile</a>
47
<a href="/knots">knots</a>
48
<a href="/spindles">spindles</a>
49
<a href="/settings">settings</a>
···
1
{{ define "layouts/topbar" }}
2
+
<nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
3
+
<div class="flex justify-between p-0 items-center">
4
<div id="left-items">
5
<a href="/" hx-boost="true" class="flex gap-2 font-semibold italic">
6
tangled<sub>alpha</sub>
7
</a>
8
</div>
9
10
<div id="right-items" class="flex items-center gap-4">
11
{{ with .LoggedInUser }}
12
<a href="/repo/new" hx-boost="true" class="btn-create hover:no-underline hover:text-white">
···
24
{{ define "dropDown" }}
25
<details class="relative inline-block text-left">
26
<summary
27
+
class="cursor-pointer list-none flex items-center"
28
>
29
+
{{ $user := didOrHandle .Did .Handle }}
30
+
{{ template "user/fragments/picHandle" $user }}
31
</summary>
32
<div
33
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
34
>
35
+
<a href="/{{ $user }}">profile</a>
36
+
<a href="/{{ $user }}?tab=repos">repositories</a>
37
<a href="/knots">knots</a>
38
<a href="/spindles">spindles</a>
39
<a href="/settings">settings</a>
+133
appview/pages/templates/legal/privacy.html
+133
appview/pages/templates/legal/privacy.html
···
···
1
+
{{ define "title" }} privacy policy {{ end }}
2
+
{{ define "content" }}
3
+
<div class="max-w-4xl mx-auto px-4 py-8">
4
+
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
5
+
<div class="prose prose-gray dark:prose-invert max-w-none">
6
+
<h1>Privacy Policy</h1>
7
+
8
+
<p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p>
9
+
10
+
<p>This Privacy Policy describes how Tangled ("we," "us," or "our") collects, uses, and shares your personal information when you use our platform and services (the "Service").</p>
11
+
12
+
<h2>1. Information We Collect</h2>
13
+
14
+
<h3>Account Information</h3>
15
+
<p>When you create an account, we collect:</p>
16
+
<ul>
17
+
<li>Your chosen username</li>
18
+
<li>Email address</li>
19
+
<li>Profile information you choose to provide</li>
20
+
<li>Authentication data</li>
21
+
</ul>
22
+
23
+
<h3>Content and Activity</h3>
24
+
<p>We store:</p>
25
+
<ul>
26
+
<li>Code repositories and associated metadata</li>
27
+
<li>Issues, pull requests, and comments</li>
28
+
<li>Activity logs and usage patterns</li>
29
+
<li>Public keys for authentication</li>
30
+
</ul>
31
+
32
+
<h2>2. Data Location and Hosting</h2>
33
+
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 my-6">
34
+
<h3 class="text-blue-800 dark:text-blue-200 font-semibold mb-2">EU Data Hosting</h3>
35
+
<p class="text-blue-700 dark:text-blue-300">
36
+
<strong>All Tangled service data is hosted within the European Union.</strong> Specifically:
37
+
</p>
38
+
<ul class="text-blue-700 dark:text-blue-300 mt-2">
39
+
<li><strong>Personal Data Servers (PDS):</strong> Accounts hosted on Tangled PDS (*.tngl.sh) are located in Finland</li>
40
+
<li><strong>Application Data:</strong> All other service data is stored on EU-based servers</li>
41
+
<li><strong>Data Processing:</strong> All data processing occurs within EU jurisdiction</li>
42
+
</ul>
43
+
</div>
44
+
45
+
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 my-6">
46
+
<h3 class="text-yellow-800 dark:text-yellow-200 font-semibold mb-2">External PDS Notice</h3>
47
+
<p class="text-yellow-700 dark:text-yellow-300">
48
+
<strong>Important:</strong> If your account is hosted on Bluesky's PDS or other self-hosted Personal Data Servers (not *.tngl.sh), we do not control that data. The data protection, storage location, and privacy practices for such accounts are governed by the respective PDS provider's policies, not this Privacy Policy. We only control data processing within our own services and infrastructure.
49
+
</p>
50
+
</div>
51
+
52
+
<h2>3. Third-Party Data Processors</h2>
53
+
<p>We only share your data with the following third-party processors:</p>
54
+
55
+
<h3>Resend (Email Services)</h3>
56
+
<ul>
57
+
<li><strong>Purpose:</strong> Sending transactional emails (account verification, notifications)</li>
58
+
<li><strong>Data Shared:</strong> Email address and necessary message content</li>
59
+
<li><strong>Location:</strong> EU-compliant email delivery service</li>
60
+
</ul>
61
+
62
+
<h3>Cloudflare (Image Caching)</h3>
63
+
<ul>
64
+
<li><strong>Purpose:</strong> Caching and optimizing image delivery</li>
65
+
<li><strong>Data Shared:</strong> Public images and associated metadata for caching purposes</li>
66
+
<li><strong>Location:</strong> Global CDN with EU data protection compliance</li>
67
+
</ul>
68
+
69
+
<h2>4. How We Use Your Information</h2>
70
+
<p>We use your information to:</p>
71
+
<ul>
72
+
<li>Provide and maintain the Service</li>
73
+
<li>Process your transactions and requests</li>
74
+
<li>Send you technical notices and support messages</li>
75
+
<li>Improve and develop new features</li>
76
+
<li>Ensure security and prevent fraud</li>
77
+
<li>Comply with legal obligations</li>
78
+
</ul>
79
+
80
+
<h2>5. Data Sharing and Disclosure</h2>
81
+
<p>We do not sell, trade, or rent your personal information. We may share your information only in the following circumstances:</p>
82
+
<ul>
83
+
<li>With the third-party processors listed above</li>
84
+
<li>When required by law or legal process</li>
85
+
<li>To protect our rights, property, or safety, or that of our users</li>
86
+
<li>In connection with a merger, acquisition, or sale of assets (with appropriate protections)</li>
87
+
</ul>
88
+
89
+
<h2>6. Data Security</h2>
90
+
<p>We implement appropriate technical and organizational measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction. However, no method of transmission over the Internet is 100% secure.</p>
91
+
92
+
<h2>7. Data Retention</h2>
93
+
<p>We retain your personal information for as long as necessary to provide the Service and fulfill the purposes outlined in this Privacy Policy, unless a longer retention period is required by law.</p>
94
+
95
+
<h2>8. Your Rights</h2>
96
+
<p>Under applicable data protection laws, you have the right to:</p>
97
+
<ul>
98
+
<li>Access your personal information</li>
99
+
<li>Correct inaccurate information</li>
100
+
<li>Request deletion of your information</li>
101
+
<li>Object to processing of your information</li>
102
+
<li>Data portability</li>
103
+
<li>Withdraw consent (where applicable)</li>
104
+
</ul>
105
+
106
+
<h2>9. Cookies and Tracking</h2>
107
+
<p>We use cookies and similar technologies to:</p>
108
+
<ul>
109
+
<li>Maintain your login session</li>
110
+
<li>Remember your preferences</li>
111
+
<li>Analyze usage patterns to improve the Service</li>
112
+
</ul>
113
+
<p>You can control cookie settings through your browser preferences.</p>
114
+
115
+
<h2>10. Children's Privacy</h2>
116
+
<p>The Service is not intended for children under 16 years of age. We do not knowingly collect personal information from children under 16. If we become aware that we have collected such information, we will take steps to delete it.</p>
117
+
118
+
<h2>11. International Data Transfers</h2>
119
+
<p>While all our primary data processing occurs within the EU, some of our third-party processors may process data outside the EU. When this occurs, we ensure appropriate safeguards are in place, such as Standard Contractual Clauses or adequacy decisions.</p>
120
+
121
+
<h2>12. Changes to This Privacy Policy</h2>
122
+
<p>We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last updated" date.</p>
123
+
124
+
<h2>13. Contact Information</h2>
125
+
<p>If you have any questions about this Privacy Policy or wish to exercise your rights, please contact us through our platform or via email.</p>
126
+
127
+
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400">
128
+
<p>This Privacy Policy complies with the EU General Data Protection Regulation (GDPR) and other applicable data protection laws.</p>
129
+
</div>
130
+
</div>
131
+
</div>
132
+
</div>
133
+
{{ end }}
+71
appview/pages/templates/legal/terms.html
+71
appview/pages/templates/legal/terms.html
···
···
1
+
{{ define "title" }}terms of service{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="max-w-4xl mx-auto px-4 py-8">
5
+
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
6
+
<div class="prose prose-gray dark:prose-invert max-w-none">
7
+
<h1>Terms of Service</h1>
8
+
9
+
<p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p>
10
+
11
+
<p>Welcome to Tangled. These Terms of Service ("Terms") govern your access to and use of the Tangled platform and services (the "Service") operated by us ("Tangled," "we," "us," or "our").</p>
12
+
13
+
<h2>1. Acceptance of Terms</h2>
14
+
<p>By accessing or using our Service, you agree to be bound by these Terms. If you disagree with any part of these terms, then you may not access the Service.</p>
15
+
16
+
<h2>2. Account Registration</h2>
17
+
<p>To use certain features of the Service, you must register for an account. You agree to provide accurate, current, and complete information during the registration process and to update such information to keep it accurate, current, and complete.</p>
18
+
19
+
<h2>3. Account Termination</h2>
20
+
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 my-6">
21
+
<h3 class="text-red-800 dark:text-red-200 font-semibold mb-2">Important Notice</h3>
22
+
<p class="text-red-700 dark:text-red-300">
23
+
<strong>We reserve the right to terminate, suspend, or restrict access to your account at any time, for any reason, or for no reason at all, at our sole discretion.</strong> This includes, but is not limited to, termination for violation of these Terms, inappropriate conduct, spam, abuse, or any other behavior we deem harmful to the Service or other users.
24
+
</p>
25
+
<p class="text-red-700 dark:text-red-300 mt-2">
26
+
Account termination may result in the loss of access to your repositories, data, and other content associated with your account. We are not obligated to provide advance notice of termination, though we may do so in our discretion.
27
+
</p>
28
+
</div>
29
+
30
+
<h2>4. Acceptable Use</h2>
31
+
<p>You agree not to use the Service to:</p>
32
+
<ul>
33
+
<li>Violate any applicable laws or regulations</li>
34
+
<li>Infringe upon the rights of others</li>
35
+
<li>Upload, store, or share content that is illegal, harmful, threatening, abusive, harassing, defamatory, vulgar, obscene, or otherwise objectionable</li>
36
+
<li>Engage in spam, phishing, or other deceptive practices</li>
37
+
<li>Attempt to gain unauthorized access to the Service or other users' accounts</li>
38
+
<li>Interfere with or disrupt the Service or servers connected to the Service</li>
39
+
</ul>
40
+
41
+
<h2>5. Content and Intellectual Property</h2>
42
+
<p>You retain ownership of the content you upload to the Service. By uploading content, you grant us a non-exclusive, worldwide, royalty-free license to use, reproduce, modify, and distribute your content as necessary to provide the Service.</p>
43
+
44
+
<h2>6. Privacy</h2>
45
+
<p>Your privacy is important to us. Please review our <a href="/privacy" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">Privacy Policy</a>, which also governs your use of the Service.</p>
46
+
47
+
<h2>7. Disclaimers</h2>
48
+
<p>The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make no warranties, expressed or implied, and hereby disclaim and negate all other warranties including without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights.</p>
49
+
50
+
<h2>8. Limitation of Liability</h2>
51
+
<p>In no event shall Tangled, nor its directors, employees, partners, agents, suppliers, or affiliates, be liable for any indirect, incidental, special, consequential, or punitive damages, including without limitation, loss of profits, data, use, goodwill, or other intangible losses, resulting from your use of the Service.</p>
52
+
53
+
<h2>9. Indemnification</h2>
54
+
<p>You agree to defend, indemnify, and hold harmless Tangled and its affiliates, officers, directors, employees, and agents from and against any and all claims, damages, obligations, losses, liabilities, costs, or debt, and expenses (including attorney's fees).</p>
55
+
56
+
<h2>10. Governing Law</h2>
57
+
<p>These Terms shall be interpreted and governed by the laws of Finland, without regard to its conflict of law provisions.</p>
58
+
59
+
<h2>11. Changes to Terms</h2>
60
+
<p>We reserve the right to modify or replace these Terms at any time. If a revision is material, we will try to provide at least 30 days notice prior to any new terms taking effect.</p>
61
+
62
+
<h2>12. Contact Information</h2>
63
+
<p>If you have any questions about these Terms of Service, please contact us through our platform or via email.</p>
64
+
65
+
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400">
66
+
<p>These terms are effective as of the last updated date shown above and will remain in effect except with respect to any changes in their provisions in the future, which will be in effect immediately after being posted on this page.</p>
67
+
</div>
68
+
</div>
69
+
</div>
70
+
</div>
71
+
{{ end }}
+19
-6
appview/pages/templates/repo/blob.html
+19
-6
appview/pages/templates/repo/blob.html
···
5
6
{{ $title := printf "%s at %s · %s" .Path .Ref .RepoInfo.FullName }}
7
{{ $url := printf "https://tangled.sh/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }}
8
-
9
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10
-
11
{{ end }}
12
13
{{ define "repoContent" }}
···
44
<a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a>
45
{{ if .RenderToggle }}
46
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
47
-
<a
48
-
href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}"
49
hx-boost="true"
50
>view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a>
51
{{ end }}
52
</div>
53
</div>
54
</div>
55
-
{{ if .IsBinary }}
56
<p class="text-center text-gray-400 dark:text-gray-500">
57
-
This is a binary file and will not be displayed.
58
</p>
59
{{ else }}
60
<div class="overflow-auto relative">
61
{{ if .ShowRendered }}
···
5
6
{{ $title := printf "%s at %s · %s" .Path .Ref .RepoInfo.FullName }}
7
{{ $url := printf "https://tangled.sh/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }}
8
+
9
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10
+
11
{{ end }}
12
13
{{ define "repoContent" }}
···
44
<a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a>
45
{{ if .RenderToggle }}
46
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
47
+
<a
48
+
href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}"
49
hx-boost="true"
50
>view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a>
51
{{ end }}
52
</div>
53
</div>
54
</div>
55
+
{{ if and .IsBinary .Unsupported }}
56
<p class="text-center text-gray-400 dark:text-gray-500">
57
+
Previews are not supported for this file type.
58
</p>
59
+
{{ else if .IsBinary }}
60
+
<div class="text-center">
61
+
{{ if .IsImage }}
62
+
<img src="{{ .ContentSrc }}"
63
+
alt="{{ .Path }}"
64
+
class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" />
65
+
{{ else if .IsVideo }}
66
+
<video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded">
67
+
<source src="{{ .ContentSrc }}">
68
+
Your browser does not support the video tag.
69
+
</video>
70
+
{{ end }}
71
+
</div>
72
{{ else }}
73
<div class="overflow-auto relative">
74
{{ if .ShowRendered }}
+2
-2
appview/pages/templates/repo/branches.html
+2
-2
appview/pages/templates/repo/branches.html
···
59
</td>
60
<td class="py-3 whitespace-nowrap text-gray-500 dark:text-gray-400">
61
{{ if .Commit }}
62
-
{{ .Commit.Committer.When | timeFmt }}
63
{{ end }}
64
</td>
65
</tr>
···
98
</a>
99
</span>
100
<div class="inline-block px-1 select-none after:content-['ยท']"></div>
101
-
<span>{{ .Commit.Committer.When | timeFmt }}</span>
102
</div>
103
{{ end }}
104
</div>
···
59
</td>
60
<td class="py-3 whitespace-nowrap text-gray-500 dark:text-gray-400">
61
{{ if .Commit }}
62
+
{{ template "repo/fragments/time" .Commit.Committer.When }}
63
{{ end }}
64
</td>
65
</tr>
···
98
</a>
99
</span>
100
<div class="inline-block px-1 select-none after:content-['ยท']"></div>
101
+
{{ template "repo/fragments/time" .Commit.Committer.When }}
102
</div>
103
{{ end }}
104
</div>
+43
-6
appview/pages/templates/repo/commit.html
+43
-6
appview/pages/templates/repo/commit.html
···
34
<a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a>
35
{{ end }}
36
<span class="px-1 select-none before:content-['\00B7']"></span>
37
-
{{ timeFmt $commit.Author.When }}
38
<span class="px-1 select-none before:content-['\00B7']"></span>
39
</p>
40
···
59
<div class="flex items-center gap-2 my-2">
60
{{ i "user" "w-4 h-4" }}
61
{{ $committerDidOrHandle := index $.EmailToDidOrHandle $commit.Committer.Email }}
62
-
<a href="/{{ $committerDidOrHandle }}">{{ $committerDidOrHandle }}</a>
63
</div>
64
<div class="my-1 pt-2 text-xs border-t">
65
<div class="text-gray-600 dark:text-gray-300">SSH Key Fingerprint:</div>
···
77
</div>
78
79
</section>
80
81
{{end}}
82
83
-
{{ define "repoAfter" }}
84
-
<div class="-z-[9999]">
85
-
{{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }}
86
-
</div>
87
{{end}}
···
34
<a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a>
35
{{ end }}
36
<span class="px-1 select-none before:content-['\00B7']"></span>
37
+
{{ template "repo/fragments/time" $commit.Author.When }}
38
<span class="px-1 select-none before:content-['\00B7']"></span>
39
</p>
40
···
59
<div class="flex items-center gap-2 my-2">
60
{{ i "user" "w-4 h-4" }}
61
{{ $committerDidOrHandle := index $.EmailToDidOrHandle $commit.Committer.Email }}
62
+
<a href="/{{ $committerDidOrHandle }}">{{ template "user/fragments/picHandleLink" $committerDidOrHandle }}</a>
63
</div>
64
<div class="my-1 pt-2 text-xs border-t">
65
<div class="text-gray-600 dark:text-gray-300">SSH Key Fingerprint:</div>
···
77
</div>
78
79
</section>
80
+
{{end}}
81
82
+
{{ define "topbarLayout" }}
83
+
<header class="px-1 col-span-full" style="z-index: 20;">
84
+
{{ template "layouts/topbar" . }}
85
+
</header>
86
+
{{ end }}
87
+
88
+
{{ define "mainLayout" }}
89
+
<div class="px-1 col-span-full flex flex-col gap-4">
90
+
{{ block "contentLayout" . }}
91
+
{{ block "content" . }}{{ end }}
92
+
{{ end }}
93
+
94
+
{{ block "contentAfterLayout" . }}
95
+
<div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4">
96
+
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
97
+
{{ block "contentAfterLeft" . }} {{ end }}
98
+
</div>
99
+
<main class="col-span-1 md:col-span-10">
100
+
{{ block "contentAfter" . }}{{ end }}
101
+
</main>
102
+
</div>
103
+
{{ end }}
104
+
</div>
105
+
{{ end }}
106
+
107
+
{{ define "footerLayout" }}
108
+
<footer class="px-1 col-span-full mt-12">
109
+
{{ template "layouts/footer" . }}
110
+
</footer>
111
+
{{ end }}
112
+
113
+
{{ define "contentAfter" }}
114
+
{{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }}
115
{{end}}
116
117
+
{{ define "contentAfterLeft" }}
118
+
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
119
+
{{ template "repo/fragments/diffOpts" .DiffOpts }}
120
+
</div>
121
+
<div class="sticky top-0 flex-grow max-h-screen">
122
+
{{ template "repo/fragments/diffChangedFiles" .Diff }}
123
+
</div>
124
{{end}}
+42
-2
appview/pages/templates/repo/compare/compare.html
+42
-2
appview/pages/templates/repo/compare/compare.html
···
10
{{ end }}
11
{{ end }}
12
13
+
{{ define "topbarLayout" }}
14
+
<header class="px-1 col-span-full" style="z-index: 20;">
15
+
{{ template "layouts/topbar" . }}
16
+
</header>
17
{{ end }}
18
+
19
+
{{ define "mainLayout" }}
20
+
<div class="px-1 col-span-full flex flex-col gap-4">
21
+
{{ block "contentLayout" . }}
22
+
{{ block "content" . }}{{ end }}
23
+
{{ end }}
24
+
25
+
{{ block "contentAfterLayout" . }}
26
+
<div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4">
27
+
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
28
+
{{ block "contentAfterLeft" . }} {{ end }}
29
+
</div>
30
+
<main class="col-span-1 md:col-span-10">
31
+
{{ block "contentAfter" . }}{{ end }}
32
+
</main>
33
+
</div>
34
+
{{ end }}
35
+
</div>
36
+
{{ end }}
37
+
38
+
{{ define "footerLayout" }}
39
+
<footer class="px-1 col-span-full mt-12">
40
+
{{ template "layouts/footer" . }}
41
+
</footer>
42
+
{{ end }}
43
+
44
+
{{ define "contentAfter" }}
45
+
{{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }}
46
+
{{end}}
47
+
48
+
{{ define "contentAfterLeft" }}
49
+
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
50
+
{{ template "repo/fragments/diffOpts" .DiffOpts }}
51
+
</div>
52
+
<div class="sticky top-0 flex-grow max-h-screen">
53
+
{{ template "repo/fragments/diffChangedFiles" .Diff }}
54
+
</div>
55
+
{{end}}
+1
-1
appview/pages/templates/repo/compare/new.html
+1
-1
appview/pages/templates/repo/compare/new.html
···
19
<a href="/{{ $.RepoInfo.FullName }}/compare?head={{ $br.Name | urlquery }}" class="no-underline hover:no-underline">
20
<div class="flex items-center justify-between p-2">
21
{{ $br.Name }}
22
-
<time class="text-gray-500 dark:text-gray-400">{{ timeFmt $br.Commit.Committer.When }}</time>
23
</div>
24
</a>
25
{{ end }}
···
19
<a href="/{{ $.RepoInfo.FullName }}/compare?head={{ $br.Name | urlquery }}" class="no-underline hover:no-underline">
20
<div class="flex items-center justify-between p-2">
21
{{ $br.Name }}
22
+
<span class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" $br.Commit.Committer.When }}</span>
23
</div>
24
</a>
25
{{ end }}
+16
-4
appview/pages/templates/repo/empty.html
+16
-4
appview/pages/templates/repo/empty.html
···
17
<a href="/{{ $.RepoInfo.FullName }}/tree/{{$br.Name | urlquery }}" class="no-underline hover:no-underline">
18
<div class="flex items-center justify-between p-2">
19
{{ $br.Name }}
20
-
<time class="text-gray-500 dark:text-gray-400">{{ timeFmt $br.Commit.Committer.When }}</time>
21
</div>
22
</a>
23
{{ end }}
24
</div>
25
</div>
26
{{ else }}
27
-
<p class="text-center pt-5 text-gray-400 dark:text-gray-500">
28
-
This is an empty repository. Push some commits here.
29
-
</p>
30
{{ end }}
31
</main>
32
{{ end }}
···
17
<a href="/{{ $.RepoInfo.FullName }}/tree/{{$br.Name | urlquery }}" class="no-underline hover:no-underline">
18
<div class="flex items-center justify-between p-2">
19
{{ $br.Name }}
20
+
<span class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" $br.Commit.Committer.When }}</span>
21
</div>
22
</a>
23
{{ end }}
24
</div>
25
</div>
26
+
{{ else if (and .LoggedInUser (eq .LoggedInUser.Did .RepoInfo.OwnerDid)) }}
27
+
{{ $knot := .RepoInfo.Knot }}
28
+
{{ if eq $knot "knot1.tangled.sh" }}
29
+
{{ $knot = "tangled.sh" }}
30
+
{{ end }}
31
+
<div class="w-full flex place-content-center">
32
+
<div class="py-6 w-fit flex flex-col gap-4">
33
+
<p>This is an empty repository. To get started:</p>
34
+
{{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }}
35
+
<p><span class="{{$bullet}}">1</span>Add a public key to your account from the <a href="/settings" class="underline">settings</a> page</p>
36
+
<p><span class="{{$bullet}}">2</span>Configure your remote to <span class="font-mono p-1 rounded bg-gray-100 dark:bg-gray-700 ">git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}<span></p>
37
+
<p><span class="{{$bullet}}">3</span>Push!</p>
38
+
</div>
39
+
</div>
40
{{ else }}
41
+
<p class="text-gray-400 dark:text-gray-500 py-6 text-center">This is an empty repository.</p>
42
{{ end }}
43
</main>
44
{{ end }}
+2
-2
appview/pages/templates/repo/fragments/artifact.html
+2
-2
appview/pages/templates/repo/fragments/artifact.html
···
10
</div>
11
12
<div id="right-side" class="text-gray-500 dark:text-gray-400 flex items-center flex-shrink-0 gap-2 text-sm">
13
-
<span title="{{ longTimeFmt .Artifact.CreatedAt }}" class="hidden md:inline">{{ timeFmt .Artifact.CreatedAt }}</span>
14
-
<span title="{{ longTimeFmt .Artifact.CreatedAt }}" class=" md:hidden">{{ shortTimeFmt .Artifact.CreatedAt }}</span>
15
16
<span class="select-none after:content-['ยท'] hidden md:inline"></span>
17
<span class="truncate max-w-[100px] hidden md:inline">{{ .Artifact.MimeType }}</span>
···
10
</div>
11
12
<div id="right-side" class="text-gray-500 dark:text-gray-400 flex items-center flex-shrink-0 gap-2 text-sm">
13
+
<span class="hidden md:inline">{{ template "repo/fragments/time" .Artifact.CreatedAt }}</span>
14
+
<span class=" md:hidden">{{ template "repo/fragments/shortTime" .Artifact.CreatedAt }}</span>
15
16
<span class="select-none after:content-['ยท'] hidden md:inline"></span>
17
<span class="truncate max-w-[100px] hidden md:inline">{{ .Artifact.MimeType }}</span>
+90
-145
appview/pages/templates/repo/fragments/diff.html
+90
-145
appview/pages/templates/repo/fragments/diff.html
···
1
{{ define "repo/fragments/diff" }}
2
-
{{ $repo := index . 0 }}
3
-
{{ $diff := index . 1 }}
4
-
{{ $commit := $diff.Commit }}
5
-
{{ $stat := $diff.Stat }}
6
-
{{ $fileTree := fileTree $diff.ChangedFiles }}
7
-
{{ $diff := $diff.Diff }}
8
9
{{ $this := $commit.This }}
10
{{ $parent := $commit.Parent }}
11
12
-
<section class="mt-6 p-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
13
-
<div class="diff-stat">
14
-
<div class="flex gap-2 items-center">
15
-
<strong class="text-sm uppercase dark:text-gray-200">Changed files</strong>
16
-
{{ block "statPill" $stat }} {{ end }}
17
-
</div>
18
-
{{ block "fileTree" $fileTree }} {{ end }}
19
-
</div>
20
-
</section>
21
22
-
{{ $last := sub (len $diff) 1 }}
23
-
{{ range $idx, $hunk := $diff }}
24
-
{{ with $hunk }}
25
-
<section class="mt-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
26
-
<div id="file-{{ .Name.New }}">
27
-
<div id="diff-file">
28
-
<details open>
29
-
<summary class="list-none cursor-pointer sticky top-0">
30
-
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
31
-
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
32
-
<div class="flex gap-1 items-center">
33
-
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
34
-
{{ if .IsNew }}
35
-
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">ADDED</span>
36
-
{{ else if .IsDelete }}
37
-
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">DELETED</span>
38
-
{{ else if .IsCopy }}
39
-
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">COPIED</span>
40
-
{{ else if .IsRename }}
41
-
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">RENAMED</span>
42
-
{{ else }}
43
-
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">MODIFIED</span>
44
-
{{ end }}
45
46
-
{{ block "statPill" .Stats }} {{ end }}
47
</div>
48
49
-
<div class="flex gap-2 items-center overflow-x-auto">
50
-
{{ if .IsDelete }}
51
-
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}>
52
-
{{ .Name.Old }}
53
-
</a>
54
-
{{ else if (or .IsCopy .IsRename) }}
55
-
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $parent}}href="/{{ $repo }}/blob/{{ $parent }}/{{ .Name.Old }}"{{end}}>
56
-
{{ .Name.Old }}
57
-
</a>
58
-
{{ i "arrow-right" "w-4 h-4" }}
59
-
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
60
-
{{ .Name.New }}
61
-
</a>
62
{{ else }}
63
-
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
64
-
{{ .Name.New }}
65
-
</a>
66
{{ end }}
67
-
</div>
68
</div>
69
70
-
{{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }}
71
-
<div id="right-side-items" class="p-2 flex items-center">
72
-
<a title="top of file" href="#file-{{ .Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a>
73
-
{{ if gt $idx 0 }}
74
-
{{ $prev := index $diff (sub $idx 1) }}
75
-
<a title="previous file" href="#file-{{ $prev.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a>
76
-
{{ end }}
77
78
-
{{ if lt $idx $last }}
79
-
{{ $next := index $diff (add $idx 1) }}
80
-
<a title="next file" href="#file-{{ $next.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a>
81
-
{{ end }}
82
-
</div>
83
-
84
-
</div>
85
-
</summary>
86
-
87
-
<div class="transition-all duration-700 ease-in-out">
88
-
{{ if .IsDelete }}
89
-
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
90
-
This file has been deleted.
91
-
</p>
92
-
{{ else if .IsCopy }}
93
-
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
94
-
This file has been copied.
95
-
</p>
96
-
{{ else if .IsBinary }}
97
-
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
98
-
This is a binary file and will not be displayed.
99
-
</p>
100
-
{{ else }}
101
-
{{ $name := .Name.New }}
102
-
<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>
103
-
{{- $oldStart := .OldPosition -}}
104
-
{{- $newStart := .NewPosition -}}
105
-
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 scroll-mt-10 target:border target:border-amber-500 target:rounded " -}}
106
-
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
107
-
{{- $lineNrSepStyle1 := "" -}}
108
-
{{- $lineNrSepStyle2 := "pr-2" -}}
109
-
{{- range .Lines -}}
110
-
{{- if eq .Op.String "+" -}}
111
-
<div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full items-center">
112
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div>
113
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
114
-
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
115
-
<div class="px-2">{{ .Line }}</div>
116
-
</div>
117
-
{{- $newStart = add64 $newStart 1 -}}
118
-
{{- end -}}
119
-
{{- if eq .Op.String "-" -}}
120
-
<div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full items-center">
121
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
122
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div>
123
-
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
124
-
<div class="px-2">{{ .Line }}</div>
125
-
</div>
126
-
{{- $oldStart = add64 $oldStart 1 -}}
127
-
{{- end -}}
128
-
{{- if eq .Op.String " " -}}
129
-
<div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full items-center">
130
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
131
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
132
-
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
133
-
<div class="px-2">{{ .Line }}</div>
134
-
</div>
135
-
{{- $newStart = add64 $newStart 1 -}}
136
-
{{- $oldStart = add64 $oldStart 1 -}}
137
-
{{- end -}}
138
-
{{- end -}}
139
-
{{- end -}}</div></div></pre>
140
-
{{- end -}}
141
</div>
142
-
143
-
</details>
144
-
145
-
</div>
146
-
</div>
147
-
</section>
148
-
{{ end }}
149
-
{{ end }}
150
-
{{ end }}
151
-
152
-
{{ define "statPill" }}
153
-
<div class="flex items-center font-mono text-sm">
154
-
{{ if and .Insertions .Deletions }}
155
-
<span class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span>
156
-
<span class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span>
157
-
{{ else if .Insertions }}
158
-
<span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span>
159
-
{{ else if .Deletions }}
160
-
<span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span>
161
{{ end }}
162
</div>
163
{{ end }}
···
1
{{ define "repo/fragments/diff" }}
2
+
{{ $repo := index . 0 }}
3
+
{{ $diff := index . 1 }}
4
+
{{ $opts := index . 2 }}
5
6
+
{{ $commit := $diff.Commit }}
7
+
{{ $diff := $diff.Diff }}
8
+
{{ $isSplit := $opts.Split }}
9
{{ $this := $commit.This }}
10
{{ $parent := $commit.Parent }}
11
+
{{ $last := sub (len $diff) 1 }}
12
13
+
<div class="flex flex-col gap-4">
14
+
{{ range $idx, $hunk := $diff }}
15
+
{{ with $hunk }}
16
+
<section class="border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
17
+
<div id="file-{{ .Name.New }}">
18
+
<div id="diff-file">
19
+
<details open>
20
+
<summary class="list-none cursor-pointer sticky top-0">
21
+
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
22
+
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
23
+
<div class="flex gap-1 items-center">
24
+
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
25
+
{{ if .IsNew }}
26
+
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">ADDED</span>
27
+
{{ else if .IsDelete }}
28
+
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">DELETED</span>
29
+
{{ else if .IsCopy }}
30
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">COPIED</span>
31
+
{{ else if .IsRename }}
32
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">RENAMED</span>
33
+
{{ else }}
34
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">MODIFIED</span>
35
+
{{ end }}
36
37
+
{{ template "repo/fragments/diffStatPill" .Stats }}
38
+
</div>
39
+
40
+
<div class="flex gap-2 items-center overflow-x-auto">
41
+
{{ if .IsDelete }}
42
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}>
43
+
{{ .Name.Old }}
44
+
</a>
45
+
{{ else if (or .IsCopy .IsRename) }}
46
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $parent}}href="/{{ $repo }}/blob/{{ $parent }}/{{ .Name.Old }}"{{end}}>
47
+
{{ .Name.Old }}
48
+
</a>
49
+
{{ i "arrow-right" "w-4 h-4" }}
50
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
51
+
{{ .Name.New }}
52
+
</a>
53
+
{{ else }}
54
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
55
+
{{ .Name.New }}
56
+
</a>
57
+
{{ end }}
58
+
</div>
59
+
</div>
60
+
61
+
{{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }}
62
+
<div id="right-side-items" class="p-2 flex items-center">
63
+
<a title="top of file" href="#file-{{ .Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a>
64
+
{{ if gt $idx 0 }}
65
+
{{ $prev := index $diff (sub $idx 1) }}
66
+
<a title="previous file" href="#file-{{ $prev.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a>
67
+
{{ end }}
68
+
69
+
{{ if lt $idx $last }}
70
+
{{ $next := index $diff (add $idx 1) }}
71
+
<a title="next file" href="#file-{{ $next.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a>
72
+
{{ end }}
73
+
</div>
74
75
</div>
76
+
</summary>
77
78
+
<div class="transition-all duration-700 ease-in-out">
79
+
{{ if .IsDelete }}
80
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
81
+
This file has been deleted.
82
+
</p>
83
+
{{ else if .IsCopy }}
84
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
85
+
This file has been copied.
86
+
</p>
87
+
{{ else if .IsBinary }}
88
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
89
+
This is a binary file and will not be displayed.
90
+
</p>
91
+
{{ else }}
92
+
{{ if $isSplit }}
93
+
{{- template "repo/fragments/splitDiff" .Split -}}
94
{{ else }}
95
+
{{- template "repo/fragments/unifiedDiff" . -}}
96
{{ end }}
97
+
{{- end -}}
98
</div>
99
100
+
</details>
101
102
</div>
103
+
</div>
104
+
</section>
105
+
{{ end }}
106
{{ end }}
107
</div>
108
{{ end }}
+13
appview/pages/templates/repo/fragments/diffChangedFiles.html
+13
appview/pages/templates/repo/fragments/diffChangedFiles.html
···
···
1
+
{{ define "repo/fragments/diffChangedFiles" }}
2
+
{{ $stat := .Stat }}
3
+
{{ $fileTree := fileTree .ChangedFiles }}
4
+
<section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto min-h-full rounded bg-white dark:bg-gray-800 drop-shadow-sm">
5
+
<div class="diff-stat">
6
+
<div class="flex gap-2 items-center">
7
+
<strong class="text-sm uppercase dark:text-gray-200">Changed files</strong>
8
+
{{ template "repo/fragments/diffStatPill" $stat }}
9
+
</div>
10
+
{{ template "repo/fragments/fileTree" $fileTree }}
11
+
</div>
12
+
</section>
13
+
{{ end }}
+28
appview/pages/templates/repo/fragments/diffOpts.html
+28
appview/pages/templates/repo/fragments/diffOpts.html
···
···
1
+
{{ define "repo/fragments/diffOpts" }}
2
+
<section class="flex flex-col gap-2 overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
3
+
<strong class="text-sm uppercase dark:text-gray-200">options</strong>
4
+
{{ $active := "unified" }}
5
+
{{ if .Split }}
6
+
{{ $active = "split" }}
7
+
{{ end }}
8
+
{{ $values := list "unified" "split" }}
9
+
{{ block "tabSelector" (dict "Name" "diff" "Values" $values "Active" $active) }} {{ end }}
10
+
</section>
11
+
{{ end }}
12
+
13
+
{{ define "tabSelector" }}
14
+
{{ $name := .Name }}
15
+
{{ $all := .Values }}
16
+
{{ $active := .Active }}
17
+
<div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden">
18
+
{{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }}
19
+
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }}
20
+
{{ range $index, $value := $all }}
21
+
{{ $isActive := eq $value $active }}
22
+
<a href="?{{ $name }}={{ $value }}"
23
+
class="py-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}">
24
+
{{ $value }}
25
+
</a>
26
+
{{ end }}
27
+
</div>
28
+
{{ end }}
+13
appview/pages/templates/repo/fragments/diffStatPill.html
+13
appview/pages/templates/repo/fragments/diffStatPill.html
···
···
1
+
{{ define "repo/fragments/diffStatPill" }}
2
+
<div class="flex items-center font-mono text-sm">
3
+
{{ if and .Insertions .Deletions }}
4
+
<span class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span>
5
+
<span class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span>
6
+
{{ else if .Insertions }}
7
+
<span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span>
8
+
{{ else if .Deletions }}
9
+
<span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span>
10
+
{{ end }}
11
+
</div>
12
+
{{ end }}
13
+
+27
appview/pages/templates/repo/fragments/fileTree.html
+27
appview/pages/templates/repo/fragments/fileTree.html
···
···
1
+
{{ define "repo/fragments/fileTree" }}
2
+
{{ if and .Name .IsDirectory }}
3
+
<details open>
4
+
<summary class="cursor-pointer list-none pt-1">
5
+
<span class="tree-directory inline-flex items-center gap-2 ">
6
+
{{ i "folder" "size-4 fill-current" }}
7
+
<span class="filename text-black dark:text-white">{{ .Name }}</span>
8
+
</span>
9
+
</summary>
10
+
<div class="ml-1 pl-2 border-l border-gray-200 dark:border-gray-700">
11
+
{{ range $child := .Children }}
12
+
{{ template "repo/fragments/fileTree" $child }}
13
+
{{ end }}
14
+
</div>
15
+
</details>
16
+
{{ else if .Name }}
17
+
<div class="tree-file flex items-center gap-2 pt-1">
18
+
{{ i "file" "size-4" }}
19
+
<a href="#file-{{ .Path }}" class="filename text-black dark:text-white no-underline hover:underline">{{ .Name }}</a>
20
+
</div>
21
+
{{ else }}
22
+
{{ range $child := .Children }}
23
+
{{ template "repo/fragments/fileTree" $child }}
24
+
{{ end }}
25
+
{{ end }}
26
+
{{ end }}
27
+
-27
appview/pages/templates/repo/fragments/filetree.html
-27
appview/pages/templates/repo/fragments/filetree.html
···
1
-
{{ define "fileTree" }}
2
-
{{ if and .Name .IsDirectory }}
3
-
<details open>
4
-
<summary class="cursor-pointer list-none pt-1">
5
-
<span class="tree-directory inline-flex items-center gap-2 ">
6
-
{{ i "folder" "size-4 fill-current" }}
7
-
<span class="filename text-black dark:text-white">{{ .Name }}</span>
8
-
</span>
9
-
</summary>
10
-
<div class="ml-1 pl-4 border-l border-gray-200 dark:border-gray-700">
11
-
{{ range $child := .Children }}
12
-
{{ block "fileTree" $child }} {{ end }}
13
-
{{ end }}
14
-
</div>
15
-
</details>
16
-
{{ else if .Name }}
17
-
<div class="tree-file flex items-center gap-2 pt-1">
18
-
{{ i "file" "size-4" }}
19
-
<a href="#file-{{ .Path }}" class="filename text-black dark:text-white no-underline hover:underline">{{ .Name }}</a>
20
-
</div>
21
-
{{ else }}
22
-
{{ range $child := .Children }}
23
-
{{ block "fileTree" $child }} {{ end }}
24
-
{{ end }}
25
-
{{ end }}
26
-
{{ end }}
27
-
···
+72
-123
appview/pages/templates/repo/fragments/interdiff.html
+72
-123
appview/pages/templates/repo/fragments/interdiff.html
···
1
{{ define "repo/fragments/interdiff" }}
2
{{ $repo := index . 0 }}
3
{{ $x := index . 1 }}
4
{{ $fileTree := fileTree $x.AffectedFiles }}
5
{{ $diff := $x.Files }}
6
7
-
<section class="mt-6 p-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
8
-
<div class="diff-stat">
9
-
<div class="flex gap-2 items-center">
10
-
<strong class="text-sm uppercase dark:text-gray-200">files</strong>
11
-
</div>
12
-
{{ block "fileTree" $fileTree }} {{ end }}
13
-
</div>
14
-
</section>
15
-
16
-
{{ $last := sub (len $diff) 1 }}
17
{{ range $idx, $hunk := $diff }}
18
-
{{ with $hunk }}
19
-
<section class="mt-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
20
-
<div id="file-{{ .Name }}">
21
-
<div id="diff-file">
22
-
<details {{ if not (.Status.IsOnlyInOne) }}open{{end}}>
23
-
<summary class="list-none cursor-pointer sticky top-0">
24
-
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
25
-
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
26
-
<div class="flex gap-1 items-center" style="direction: ltr;">
27
-
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
28
-
{{ if .Status.IsOk }}
29
-
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span>
30
-
{{ else if .Status.IsUnchanged }}
31
-
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span>
32
-
{{ else if .Status.IsOnlyInOne }}
33
-
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span>
34
-
{{ else if .Status.IsOnlyInTwo }}
35
-
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span>
36
-
{{ else if .Status.IsRebased }}
37
-
<span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span>
38
-
{{ else }}
39
-
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span>
40
-
{{ end }}
41
</div>
42
43
-
<div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;">
44
-
<a class="dark:text-white whitespace-nowrap overflow-x-auto" href="">
45
-
{{ .Name }}
46
-
</a>
47
</div>
48
</div>
49
50
-
{{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }}
51
-
<div id="right-side-items" class="p-2 flex items-center">
52
-
<a title="top of file" href="#file-{{ .Name }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a>
53
-
{{ if gt $idx 0 }}
54
-
{{ $prev := index $diff (sub $idx 1) }}
55
-
<a title="previous file" href="#file-{{ $prev.Name }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a>
56
{{ end }}
57
-
58
-
{{ if lt $idx $last }}
59
-
{{ $next := index $diff (add $idx 1) }}
60
-
<a title="next file" href="#file-{{ $next.Name }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a>
61
-
{{ end }}
62
-
</div>
63
-
64
</div>
65
-
</summary>
66
67
-
<div class="transition-all duration-700 ease-in-out">
68
-
{{ if .Status.IsUnchanged }}
69
-
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
70
-
This file has not been changed.
71
-
</p>
72
-
{{ else if .Status.IsRebased }}
73
-
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
74
-
This patch was likely rebased, as context lines do not match.
75
-
</p>
76
-
{{ else if .Status.IsError }}
77
-
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
78
-
Failed to calculate interdiff for this file.
79
-
</p>
80
-
{{ else }}
81
-
{{ $name := .Name }}
82
-
<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>
83
-
{{- $oldStart := .OldPosition -}}
84
-
{{- $newStart := .NewPosition -}}
85
-
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 scroll-mt-10 target:border target:border-amber-500 target:rounded " -}}
86
-
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
87
-
{{- $lineNrSepStyle1 := "" -}}
88
-
{{- $lineNrSepStyle2 := "pr-2" -}}
89
-
{{- range .Lines -}}
90
-
{{- if eq .Op.String "+" -}}
91
-
<div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full items-center">
92
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div>
93
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
94
-
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
95
-
<div class="px-2">{{ .Line }}</div>
96
-
</div>
97
-
{{- $newStart = add64 $newStart 1 -}}
98
-
{{- end -}}
99
-
{{- if eq .Op.String "-" -}}
100
-
<div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full items-center">
101
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
102
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div>
103
-
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
104
-
<div class="px-2">{{ .Line }}</div>
105
-
</div>
106
-
{{- $oldStart = add64 $oldStart 1 -}}
107
-
{{- end -}}
108
-
{{- if eq .Op.String " " -}}
109
-
<div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full items-center">
110
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
111
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
112
-
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
113
-
<div class="px-2">{{ .Line }}</div>
114
-
</div>
115
-
{{- $newStart = add64 $newStart 1 -}}
116
-
{{- $oldStart = add64 $oldStart 1 -}}
117
-
{{- end -}}
118
-
{{- end -}}
119
-
{{- end -}}</div></div></pre>
120
-
{{- end -}}
121
-
</div>
122
123
-
</details>
124
-
125
</div>
126
-
</div>
127
-
</section>
128
-
{{ end }}
129
{{ end }}
130
{{ end }}
131
132
-
{{ define "statPill" }}
133
-
<div class="flex items-center font-mono text-sm">
134
-
{{ if and .Insertions .Deletions }}
135
-
<span class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span>
136
-
<span class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span>
137
-
{{ else if .Insertions }}
138
-
<span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span>
139
-
{{ else if .Deletions }}
140
-
<span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span>
141
-
{{ end }}
142
-
</div>
143
-
{{ end }}
···
1
{{ define "repo/fragments/interdiff" }}
2
{{ $repo := index . 0 }}
3
{{ $x := index . 1 }}
4
+
{{ $opts := index . 2 }}
5
{{ $fileTree := fileTree $x.AffectedFiles }}
6
{{ $diff := $x.Files }}
7
+
{{ $last := sub (len $diff) 1 }}
8
+
{{ $isSplit := $opts.Split }}
9
10
+
<div class="flex flex-col gap-4">
11
{{ range $idx, $hunk := $diff }}
12
+
{{ with $hunk }}
13
+
<section class="border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
14
+
<div id="file-{{ .Name }}">
15
+
<div id="diff-file">
16
+
<details {{ if not (.Status.IsOnlyInOne) }}open{{end}}>
17
+
<summary class="list-none cursor-pointer sticky top-0">
18
+
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
19
+
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
20
+
<div class="flex gap-1 items-center" style="direction: ltr;">
21
+
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
22
+
{{ if .Status.IsOk }}
23
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span>
24
+
{{ else if .Status.IsUnchanged }}
25
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span>
26
+
{{ else if .Status.IsOnlyInOne }}
27
+
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span>
28
+
{{ else if .Status.IsOnlyInTwo }}
29
+
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span>
30
+
{{ else if .Status.IsRebased }}
31
+
<span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span>
32
+
{{ else }}
33
+
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span>
34
+
{{ end }}
35
+
</div>
36
+
37
+
<div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;">
38
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" href="">
39
+
{{ .Name }}
40
+
</a>
41
+
</div>
42
</div>
43
44
+
{{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }}
45
+
<div id="right-side-items" class="p-2 flex items-center">
46
+
<a title="top of file" href="#file-{{ .Name }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a>
47
+
{{ if gt $idx 0 }}
48
+
{{ $prev := index $diff (sub $idx 1) }}
49
+
<a title="previous file" href="#file-{{ $prev.Name }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a>
50
+
{{ end }}
51
+
52
+
{{ if lt $idx $last }}
53
+
{{ $next := index $diff (add $idx 1) }}
54
+
<a title="next file" href="#file-{{ $next.Name }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a>
55
+
{{ end }}
56
</div>
57
+
58
</div>
59
+
</summary>
60
61
+
<div class="transition-all duration-700 ease-in-out">
62
+
{{ if .Status.IsUnchanged }}
63
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
64
+
This file has not been changed.
65
+
</p>
66
+
{{ else if .Status.IsRebased }}
67
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
68
+
This patch was likely rebased, as context lines do not match.
69
+
</p>
70
+
{{ else if .Status.IsError }}
71
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
72
+
Failed to calculate interdiff for this file.
73
+
</p>
74
+
{{ else }}
75
+
{{ if $isSplit }}
76
+
{{- template "repo/fragments/splitDiff" .Split -}}
77
+
{{ else }}
78
+
{{- template "repo/fragments/unifiedDiff" . -}}
79
{{ end }}
80
+
{{- end -}}
81
</div>
82
83
+
</details>
84
85
+
</div>
86
</div>
87
+
</section>
88
+
{{ end }}
89
{{ end }}
90
+
</div>
91
{{ end }}
92
+11
appview/pages/templates/repo/fragments/interdiffFiles.html
+11
appview/pages/templates/repo/fragments/interdiffFiles.html
···
···
1
+
{{ define "repo/fragments/interdiffFiles" }}
2
+
{{ $fileTree := fileTree .AffectedFiles }}
3
+
<section class="mt-4 px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm min-h-full text-sm">
4
+
<div class="diff-stat">
5
+
<div class="flex gap-2 items-center">
6
+
<strong class="text-sm uppercase dark:text-gray-200">files</strong>
7
+
</div>
8
+
{{ template "repo/fragments/fileTree" $fileTree }}
9
+
</div>
10
+
</section>
11
+
{{ end }}
+34
appview/pages/templates/repo/fragments/reaction.html
+34
appview/pages/templates/repo/fragments/reaction.html
···
···
1
+
{{ define "repo/fragments/reaction" }}
2
+
<button
3
+
id="reactIndi-{{ .Kind }}"
4
+
class="flex justify-center items-center min-w-8 min-h-8 rounded border
5
+
leading-4 px-3 gap-1
6
+
{{ if eq .Count 0 }}
7
+
hidden
8
+
{{ end }}
9
+
{{ if .IsReacted }}
10
+
bg-sky-100
11
+
border-sky-400
12
+
dark:bg-sky-900
13
+
dark:border-sky-500
14
+
{{ else }}
15
+
border-gray-200
16
+
hover:bg-gray-50
17
+
hover:border-gray-300
18
+
dark:border-gray-700
19
+
dark:hover:bg-gray-700
20
+
dark:hover:border-gray-600
21
+
{{ end }}
22
+
"
23
+
{{ if .IsReacted }}
24
+
hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}"
25
+
{{ else }}
26
+
hx-post="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}"
27
+
{{ end }}
28
+
hx-swap="outerHTML"
29
+
hx-trigger="click from:(#reactBtn-{{ .Kind }}, #reactIndi-{{ .Kind }})"
30
+
hx-disabled-elt="this"
31
+
>
32
+
<span>{{ .Kind }}</span> <span>{{ .Count }}</span>
33
+
</button>
34
+
{{ end }}
+30
appview/pages/templates/repo/fragments/reactionsPopUp.html
+30
appview/pages/templates/repo/fragments/reactionsPopUp.html
···
···
1
+
{{ define "repo/fragments/reactionsPopUp" }}
2
+
<details
3
+
id="reactionsPopUp"
4
+
class="relative inline-block"
5
+
>
6
+
<summary
7
+
class="flex justify-center items-center min-w-8 min-h-8 rounded border border-gray-200 dark:border-gray-700
8
+
hover:bg-gray-50
9
+
hover:border-gray-300
10
+
dark:hover:bg-gray-700
11
+
dark:hover:border-gray-600
12
+
cursor-pointer list-none"
13
+
>
14
+
{{ i "smile" "size-4" }}
15
+
</summary>
16
+
<div
17
+
class="absolute flex left-0 z-10 mt-4 rounded bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 shadow-lg"
18
+
>
19
+
{{ range $kind := . }}
20
+
<button
21
+
id="reactBtn-{{ $kind }}"
22
+
class="size-12 hover:bg-gray-100 dark:hover:bg-gray-700"
23
+
hx-on:click="this.parentElement.parentElement.removeAttribute('open')"
24
+
>
25
+
{{ $kind }}
26
+
</button>
27
+
{{ end }}
28
+
</div>
29
+
</details>
30
+
{{ end }}
-48
appview/pages/templates/repo/fragments/repoActions.html
-48
appview/pages/templates/repo/fragments/repoActions.html
···
1
-
{{ define "repo/fragments/repoActions" }}
2
-
<div class="flex items-center gap-2 z-auto">
3
-
<button
4
-
id="starBtn"
5
-
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
6
-
{{ if .IsStarred }}
7
-
hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
8
-
{{ else }}
9
-
hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
10
-
{{ end }}
11
-
12
-
hx-trigger="click"
13
-
hx-target="#starBtn"
14
-
hx-swap="outerHTML"
15
-
hx-disabled-elt="#starBtn"
16
-
>
17
-
{{ if .IsStarred }}
18
-
{{ i "star" "w-4 h-4 fill-current" }}
19
-
{{ else }}
20
-
{{ i "star" "w-4 h-4" }}
21
-
{{ end }}
22
-
<span class="text-sm">
23
-
{{ .Stats.StarCount }}
24
-
</span>
25
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
26
-
</button>
27
-
{{ if .DisableFork }}
28
-
<button
29
-
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
30
-
disabled
31
-
title="Empty repositories cannot be forked"
32
-
>
33
-
{{ i "git-fork" "w-4 h-4" }}
34
-
fork
35
-
</button>
36
-
{{ else }}
37
-
<a
38
-
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
39
-
hx-boost="true"
40
-
href="/{{ .FullName }}/fork"
41
-
>
42
-
{{ i "git-fork" "w-4 h-4" }}
43
-
fork
44
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
45
-
</a>
46
-
{{ end }}
47
-
</div>
48
-
{{ end }}
···
+26
appview/pages/templates/repo/fragments/repoStar.html
+26
appview/pages/templates/repo/fragments/repoStar.html
···
···
1
+
{{ define "repo/fragments/repoStar" }}
2
+
<button
3
+
id="starBtn"
4
+
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
5
+
{{ if .IsStarred }}
6
+
hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
7
+
{{ else }}
8
+
hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
9
+
{{ end }}
10
+
11
+
hx-trigger="click"
12
+
hx-target="this"
13
+
hx-swap="outerHTML"
14
+
hx-disabled-elt="#starBtn"
15
+
>
16
+
{{ if .IsStarred }}
17
+
{{ i "star" "w-4 h-4 fill-current" }}
18
+
{{ else }}
19
+
{{ i "star" "w-4 h-4" }}
20
+
{{ end }}
21
+
<span class="text-sm">
22
+
{{ .Stats.StarCount }}
23
+
</span>
24
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
25
+
</button>
26
+
{{ end }}
+61
appview/pages/templates/repo/fragments/splitDiff.html
+61
appview/pages/templates/repo/fragments/splitDiff.html
···
···
1
+
{{ define "repo/fragments/splitDiff" }}
2
+
{{ $name := .Id }}
3
+
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}}
4
+
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
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" -}}
7
+
{{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}}
8
+
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}}
9
+
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
10
+
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
11
+
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
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>
14
+
{{- range .LeftLines -}}
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>
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>
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>
33
+
{{- end -}}
34
+
{{- end -}}
35
+
{{- end -}}</div></div></pre>
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>
38
+
{{- range .RightLines -}}
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>
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>
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>
57
+
{{- end -}}
58
+
{{- end -}}
59
+
{{- end -}}</div></div></pre>
60
+
</div>
61
+
{{ end }}
+19
appview/pages/templates/repo/fragments/time.html
+19
appview/pages/templates/repo/fragments/time.html
···
···
1
+
{{ define "repo/fragments/timeWrapper" }}
2
+
<time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time>
3
+
{{ end }}
4
+
5
+
{{ define "repo/fragments/time" }}
6
+
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | relTimeFmt)) }}
7
+
{{ end }}
8
+
9
+
{{ define "repo/fragments/shortTime" }}
10
+
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }}
11
+
{{ end }}
12
+
13
+
{{ define "repo/fragments/shortTimeAgo" }}
14
+
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (print (. | shortRelTimeFmt) " ago")) }}
15
+
{{ end }}
16
+
17
+
{{ define "repo/fragments/duration" }}
18
+
<time datetime="{{ . | iso8601DurationFmt }}" title="{{ . | longDurationFmt }}">{{ . | durationFmt }}</time>
19
+
{{ end }}
+47
appview/pages/templates/repo/fragments/unifiedDiff.html
+47
appview/pages/templates/repo/fragments/unifiedDiff.html
···
···
1
+
{{ define "repo/fragments/unifiedDiff" }}
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>
4
+
{{- $oldStart := .OldPosition -}}
5
+
{{- $newStart := .NewPosition -}}
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
+
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
8
+
{{- $lineNrSepStyle1 := "" -}}
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" -}}
11
+
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}}
12
+
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
13
+
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
14
+
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
15
+
{{- range .Lines -}}
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>
23
+
{{- $newStart = add64 $newStart 1 -}}
24
+
{{- end -}}
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>
32
+
{{- $oldStart = add64 $oldStart 1 -}}
33
+
{{- end -}}
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>
41
+
{{- $newStart = add64 $newStart 1 -}}
42
+
{{- $oldStart = add64 $oldStart 1 -}}
43
+
{{- end -}}
44
+
{{- end -}}
45
+
{{- end -}}</div></div></pre>
46
+
{{ end }}
47
+
+43
-79
appview/pages/templates/repo/index.html
+43
-79
appview/pages/templates/repo/index.html
···
127
{{ end }}
128
129
{{ define "fileTree" }}
130
-
<div
131
-
id="file-tree"
132
-
class="col-span-1 pr-2 md:border-r md:border-gray-200 dark:md:border-gray-700"
133
-
>
134
-
{{ $containerstyle := "py-1" }}
135
-
{{ $linkstyle := "no-underline hover:underline dark:text-white" }}
136
137
-
{{ range .Files }}
138
-
{{ if not .IsFile }}
139
-
<div class="{{ $containerstyle }}">
140
-
<div class="flex justify-between items-center">
141
-
<a
142
-
href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref | urlquery }}/{{ .Name }}"
143
-
class="{{ $linkstyle }}"
144
-
>
145
-
<div class="flex items-center gap-2">
146
-
{{ i "folder" "size-4 fill-current" }}
147
-
{{ .Name }}
148
-
</div>
149
-
</a>
150
151
-
{{ if .LastCommit }}
152
-
<time class="text-xs text-gray-500 dark:text-gray-400"
153
-
>{{ timeFmt .LastCommit.When }}</time
154
-
>
155
-
{{ end }}
156
-
</div>
157
-
</div>
158
-
{{ end }}
159
-
{{ end }}
160
-
161
-
{{ range .Files }}
162
-
{{ if .IsFile }}
163
-
<div class="{{ $containerstyle }}">
164
-
<div class="flex justify-between items-center">
165
-
<a
166
-
href="/{{ $.RepoInfo.FullName }}/blob/{{ $.Ref | urlquery }}/{{ .Name }}"
167
-
class="{{ $linkstyle }}"
168
-
>
169
-
<div class="flex items-center gap-2">
170
-
{{ i "file" "size-4" }}{{ .Name }}
171
-
</div>
172
-
</a>
173
174
-
{{ if .LastCommit }}
175
-
<time class="text-xs text-gray-500 dark:text-gray-400"
176
-
>{{ timeFmt .LastCommit.When }}</time
177
-
>
178
-
{{ end }}
179
-
</div>
180
-
</div>
181
-
{{ end }}
182
-
{{ end }}
183
-
</div>
184
{{ end }}
185
186
{{ define "rightInfo" }}
···
194
{{ define "commitLog" }}
195
<div id="commit-log" class="md:col-span-1 px-2 pb-4">
196
<div class="flex justify-between items-center">
197
-
<a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group">
198
-
<div class="flex gap-2 items-center font-bold">
199
-
{{ i "logs" "w-4 h-4" }} commits
200
-
</div>
201
-
<span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 ">
202
-
view {{ .TotalCommits }} commits {{ i "chevron-right" "w-4 h-4" }}
203
-
</span>
204
</a>
205
</div>
206
<div class="flex flex-col gap-6">
···
266
{{ end }}"
267
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
268
>{{ if $didOrHandle }}
269
-
{{ $didOrHandle }}
270
{{ else }}
271
{{ .Author.Name }}
272
{{ end }}</a
273
>
274
</span>
275
<div class="inline-block px-1 select-none after:content-['ยท']"></div>
276
-
<span>{{ timeFmt .Committer.When }}</span>
277
278
<!-- tags/branches -->
279
{{ $tagsForCommit := index $.TagMap .Hash.String }}
···
302
{{ define "branchList" }}
303
{{ if gt (len .BranchesTrunc) 0 }}
304
<div id="branches" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700">
305
-
<a href="/{{ .RepoInfo.FullName }}/branches" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group">
306
-
<div class="flex gap-2 items-center font-bold">
307
-
{{ i "git-branch" "w-4 h-4" }} branches
308
-
</div>
309
-
<span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 ">
310
-
view {{ len .Branches }} branches {{ i "chevron-right" "w-4 h-4" }}
311
-
</span>
312
</a>
313
<div class="flex flex-col gap-1">
314
{{ range .BranchesTrunc }}
···
320
</a>
321
{{ if .Commit }}
322
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>
323
-
<time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .Commit.Committer.When }}</time>
324
{{ end }}
325
{{ if .IsDefault }}
326
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>
···
345
{{ if gt (len .TagsTrunc) 0 }}
346
<div id="tags" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700">
347
<div class="flex justify-between items-center">
348
-
<a href="/{{ .RepoInfo.FullName }}/tags" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group">
349
-
<div class="flex gap-2 items-center font-bold">
350
-
{{ i "tags" "w-4 h-4" }} tags
351
-
</div>
352
-
<span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 ">
353
-
view {{ len .Tags }} tags {{ i "chevron-right" "w-4 h-4" }}
354
-
</span>
355
</a>
356
</div>
357
<div class="flex flex-col gap-1">
···
366
</div>
367
<div>
368
{{ with .Tag }}
369
-
<time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .Tagger.When }}</time>
370
{{ end }}
371
{{ if eq $idx 0 }}
372
{{ with .Tag }}<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>{{ end }}
···
382
{{ end }}
383
384
{{ define "repoAfter" }}
385
-
{{- if .HTMLReadme -}}
386
<section
387
class="p-6 mt-4 rounded-br rounded-bl bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm w-full mx-auto overflow-auto {{ if not .Raw }}
388
prose dark:prose-invert dark:[&_pre]:bg-gray-900
···
390
dark:[&_pre]:border dark:[&_pre]:border-gray-700
391
{{ end }}"
392
>
393
-
<article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-scroll">
394
-
{{- .HTMLReadme -}}
395
</pre>
396
{{- else -}}
397
{{ .HTMLReadme }}
···
127
{{ end }}
128
129
{{ define "fileTree" }}
130
+
<div id="file-tree" class="col-span-1 pr-2 md:border-r md:border-gray-200 dark:md:border-gray-700" >
131
+
{{ $linkstyle := "no-underline hover:underline dark:text-white" }}
132
133
+
{{ range .Files }}
134
+
<div class="grid grid-cols-2 gap-4 items-center py-1">
135
+
<div class="col-span-1">
136
+
{{ $link := printf "/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) .Name }}
137
+
{{ $icon := "folder" }}
138
+
{{ $iconStyle := "size-4 fill-current" }}
139
140
+
{{ if .IsFile }}
141
+
{{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }}
142
+
{{ $icon = "file" }}
143
+
{{ $iconStyle = "size-4" }}
144
+
{{ end }}
145
+
<a href="{{ $link }}" class="{{ $linkstyle }}">
146
+
<div class="flex items-center gap-2">
147
+
{{ i $icon $iconStyle }}{{ .Name }}
148
+
</div>
149
+
</a>
150
+
</div>
151
152
+
<div class="text-xs col-span-1 text-right">
153
+
{{ with .LastCommit }}
154
+
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a>
155
+
{{ end }}
156
+
</div>
157
+
</div>
158
+
{{ end }}
159
+
</div>
160
{{ end }}
161
162
{{ define "rightInfo" }}
···
170
{{ define "commitLog" }}
171
<div id="commit-log" class="md:col-span-1 px-2 pb-4">
172
<div class="flex justify-between items-center">
173
+
<a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline">
174
+
{{ i "logs" "w-4 h-4" }} commits
175
+
<span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ .TotalCommits }}</span>
176
</a>
177
</div>
178
<div class="flex flex-col gap-6">
···
238
{{ end }}"
239
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
240
>{{ if $didOrHandle }}
241
+
{{ template "user/fragments/picHandleLink" $didOrHandle }}
242
{{ else }}
243
{{ .Author.Name }}
244
{{ end }}</a
245
>
246
</span>
247
<div class="inline-block px-1 select-none after:content-['ยท']"></div>
248
+
{{ template "repo/fragments/time" .Committer.When }}
249
250
<!-- tags/branches -->
251
{{ $tagsForCommit := index $.TagMap .Hash.String }}
···
274
{{ define "branchList" }}
275
{{ if gt (len .BranchesTrunc) 0 }}
276
<div id="branches" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700">
277
+
<a href="/{{ .RepoInfo.FullName }}/branches" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline">
278
+
{{ i "git-branch" "w-4 h-4" }} branches
279
+
<span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Branches }}</span>
280
</a>
281
<div class="flex flex-col gap-1">
282
{{ range .BranchesTrunc }}
···
288
</a>
289
{{ if .Commit }}
290
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>
291
+
<span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .Commit.Committer.When }}</span>
292
{{ end }}
293
{{ if .IsDefault }}
294
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>
···
313
{{ if gt (len .TagsTrunc) 0 }}
314
<div id="tags" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700">
315
<div class="flex justify-between items-center">
316
+
<a href="/{{ .RepoInfo.FullName }}/tags" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline">
317
+
{{ i "tags" "w-4 h-4" }} tags
318
+
<span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Tags }}</span>
319
</a>
320
</div>
321
<div class="flex flex-col gap-1">
···
330
</div>
331
<div>
332
{{ with .Tag }}
333
+
<span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .Tagger.When }}</span>
334
{{ end }}
335
{{ if eq $idx 0 }}
336
{{ with .Tag }}<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>{{ end }}
···
346
{{ end }}
347
348
{{ define "repoAfter" }}
349
+
{{- if or .HTMLReadme .Readme -}}
350
<section
351
class="p-6 mt-4 rounded-br rounded-bl bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm w-full mx-auto overflow-auto {{ if not .Raw }}
352
prose dark:prose-invert dark:[&_pre]:bg-gray-900
···
354
dark:[&_pre]:border dark:[&_pre]:border-gray-700
355
{{ end }}"
356
>
357
+
<article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto">
358
+
{{- .Readme -}}
359
</pre>
360
{{- else -}}
361
{{ .HTMLReadme }}
+3
-5
appview/pages/templates/repo/issues/fragments/editIssueComment.html
+3
-5
appview/pages/templates/repo/issues/fragments/editIssueComment.html
···
1
{{ define "repo/issues/fragments/editIssueComment" }}
2
{{ with .Comment }}
3
<div id="comment-container-{{.CommentId}}">
4
-
<div class="flex items-center gap-2 mb-2 text-gray-500 text-sm">
5
{{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }}
6
<a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a>
7
···
9
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
10
{{ if $isIssueAuthor }}
11
<span class="before:content-['ยท']"></span>
12
-
<span class="rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center">
13
author
14
-
</span>
15
{{ end }}
16
17
<span class="before:content-['ยท']"></span>
18
<a
19
href="#{{ .CommentId }}"
20
-
class="text-gray-500 hover:text-gray-500 hover:underline no-underline"
21
id="{{ .CommentId }}">
22
-
{{ .Created | timeFmt }}
23
</a>
24
25
<button
···
1
{{ define "repo/issues/fragments/editIssueComment" }}
2
{{ with .Comment }}
3
<div id="comment-container-{{.CommentId}}">
4
+
<div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap">
5
{{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }}
6
<a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a>
7
···
9
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
10
{{ if $isIssueAuthor }}
11
<span class="before:content-['ยท']"></span>
12
author
13
{{ end }}
14
15
<span class="before:content-['ยท']"></span>
16
<a
17
href="#{{ .CommentId }}"
18
+
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
19
id="{{ .CommentId }}">
20
+
{{ template "repo/fragments/time" .Created }}
21
</a>
22
23
<button
+15
-16
appview/pages/templates/repo/issues/fragments/issueComment.html
+15
-16
appview/pages/templates/repo/issues/fragments/issueComment.html
···
1
{{ define "repo/issues/fragments/issueComment" }}
2
{{ with .Comment }}
3
<div id="comment-container-{{.CommentId}}">
4
-
<div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm">
5
{{ $owner := index $.DidHandleMap .OwnerDid }}
6
-
<a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a>
7
8
<span class="before:content-['ยท']"></span>
9
<a
···
11
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
12
id="{{ .CommentId }}">
13
{{ if .Deleted }}
14
-
deleted {{ .Deleted | timeFmt }}
15
{{ else if .Edited }}
16
-
edited {{ .Edited | timeFmt }}
17
{{ else }}
18
-
{{ .Created | timeFmt }}
19
{{ end }}
20
</a>
21
-
22
-
<!-- show user "hats" -->
23
-
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
24
-
{{ if $isIssueAuthor }}
25
-
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
26
-
author
27
-
</span>
28
-
{{ end }}
29
30
{{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }}
31
{{ if and $isCommentOwner (not .Deleted) }}
32
-
<button
33
-
class="btn px-2 py-1 text-sm"
34
hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
35
hx-swap="outerHTML"
36
hx-target="#comment-container-{{.CommentId}}"
37
>
38
{{ i "pencil" "w-4 h-4" }}
39
</button>
40
-
<button
41
class="btn px-2 py-1 text-sm text-red-500 flex gap-2 items-center group"
42
hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/"
43
hx-confirm="Are you sure you want to delete your comment?"
···
1
{{ define "repo/issues/fragments/issueComment" }}
2
{{ with .Comment }}
3
<div id="comment-container-{{.CommentId}}">
4
+
<div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap">
5
{{ $owner := index $.DidHandleMap .OwnerDid }}
6
+
{{ template "user/fragments/picHandleLink" $owner }}
7
+
8
+
<!-- show user "hats" -->
9
+
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
10
+
{{ if $isIssueAuthor }}
11
+
<span class="before:content-['ยท']"></span>
12
+
author
13
+
{{ end }}
14
15
<span class="before:content-['ยท']"></span>
16
<a
···
18
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
19
id="{{ .CommentId }}">
20
{{ if .Deleted }}
21
+
deleted {{ template "repo/fragments/time" .Deleted }}
22
{{ else if .Edited }}
23
+
edited {{ template "repo/fragments/time" .Edited }}
24
{{ else }}
25
+
{{ template "repo/fragments/time" .Created }}
26
{{ end }}
27
</a>
28
29
{{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }}
30
{{ if and $isCommentOwner (not .Deleted) }}
31
+
<button
32
+
class="btn px-2 py-1 text-sm"
33
hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
34
hx-swap="outerHTML"
35
hx-target="#comment-container-{{.CommentId}}"
36
>
37
{{ i "pencil" "w-4 h-4" }}
38
</button>
39
+
<button
40
class="btn px-2 py-1 text-sm text-red-500 flex gap-2 items-center group"
41
hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/"
42
hx-confirm="Are you sure you want to delete your comment?"
+17
-5
appview/pages/templates/repo/issues/issue.html
+17
-5
appview/pages/templates/repo/issues/issue.html
···
33
<span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1">
34
opened by
35
{{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }}
36
-
{{ template "user/fragments/picHandle" $owner }}
37
<span class="select-none before:content-['\00B7']"></span>
38
-
<time title="{{ .Issue.Created | longTimeFmt }}">
39
-
{{ .Issue.Created | timeFmt }}
40
-
</time>
41
</span>
42
</div>
43
···
46
{{ .Issue.Body | markdown }}
47
</article>
48
{{ end }}
49
</section>
50
{{ end }}
51
···
76
>
77
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full md:w-3/5">
78
<div class="text-sm pb-2 text-gray-500 dark:text-gray-400">
79
-
{{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }}
80
</div>
81
<textarea
82
id="comment-textarea"
···
33
<span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1">
34
opened by
35
{{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }}
36
+
{{ template "user/fragments/picHandleLink" $owner }}
37
<span class="select-none before:content-['\00B7']"></span>
38
+
{{ template "repo/fragments/time" .Issue.Created }}
39
</span>
40
</div>
41
···
44
{{ .Issue.Body | markdown }}
45
</article>
46
{{ end }}
47
+
48
+
<div class="flex items-center gap-2 mt-2">
49
+
{{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }}
50
+
{{ range $kind := .OrderedReactionKinds }}
51
+
{{
52
+
template "repo/fragments/reaction"
53
+
(dict
54
+
"Kind" $kind
55
+
"Count" (index $.Reactions $kind)
56
+
"IsReacted" (index $.UserReacted $kind)
57
+
"ThreadAt" $.Issue.IssueAt)
58
+
}}
59
+
{{ end }}
60
+
</div>
61
</section>
62
{{ end }}
63
···
88
>
89
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full md:w-3/5">
90
<div class="text-sm pb-2 text-gray-500 dark:text-gray-400">
91
+
{{ template "user/fragments/picHandleLink" (didOrHandle .LoggedInUser.Did .LoggedInUser.Handle) }}
92
</div>
93
<textarea
94
id="comment-textarea"
+2
-4
appview/pages/templates/repo/issues/issues.html
+2
-4
appview/pages/templates/repo/issues/issues.html
+76
-79
appview/pages/templates/repo/log.html
+76
-79
appview/pages/templates/repo/log.html
···
14
</h2>
15
16
<!-- desktop view (hidden on small screens) -->
17
-
<table class="w-full border-collapse hidden md:table">
18
-
<thead>
19
-
<tr>
20
-
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Author</th>
21
-
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Commit</th>
22
-
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Message</th>
23
-
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold"></th>
24
-
<th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Date</th>
25
-
</tr>
26
-
</thead>
27
-
<tbody>
28
-
{{ range $index, $commit := .Commits }}
29
-
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
30
-
<tr class="{{ if ne $index (sub (len $.Commits) 1) }}border-b border-gray-200 dark:border-gray-700{{ end }}">
31
-
<td class=" py-3 align-top">
32
-
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
33
-
{{ if $didOrHandle }}
34
-
<a href="/{{ $didOrHandle }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $didOrHandle }}</a>
35
-
{{ else }}
36
-
<a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a>
37
-
{{ end }}
38
-
</td>
39
-
<td class="py-3 align-top font-mono flex items-center">
40
-
{{ $verified := $.VerifiedCommits.IsVerified $commit.Hash.String }}
41
-
{{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }}
42
-
{{ if $verified }}
43
-
{{ $hashStyle = "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 rounded" }}
44
-
{{ end }}
45
-
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="no-underline hover:underline {{ $hashStyle }} px-2 py-1/2 rounded flex items-center gap-2">
46
-
{{ slice $commit.Hash.String 0 8 }}
47
-
{{ if $verified }}
48
-
{{ i "shield-check" "w-4 h-4" }}
49
-
{{ end }}
50
-
</a>
51
-
<div class="{{ if not $verified }} ml-6 {{ end }}inline-flex">
52
-
<button class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
53
-
title="Copy SHA"
54
-
onclick="navigator.clipboard.writeText('{{ $commit.Hash.String }}'); this.innerHTML=`{{ i "copy-check" "w-4 h-4" }}`; setTimeout(() => this.innerHTML=`{{ i "copy" "w-4 h-4" }}`, 1500)">
55
-
{{ i "copy" "w-4 h-4" }}
56
-
</button>
57
-
<a href="/{{ $.RepoInfo.FullName }}/tree/{{ $commit.Hash.String }}" class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" title="Browse repository at this commit">
58
-
{{ i "folder-code" "w-4 h-4" }}
59
-
</a>
60
-
</div>
61
62
-
</td>
63
-
<td class=" py-3 align-top">
64
-
<div class="flex items-center justify-start gap-2">
65
-
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white no-underline hover:underline">{{ index $messageParts 0 }}</a>
66
-
{{ if gt (len $messageParts) 1 }}
67
-
<button class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-600 rounded" hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">{{ i "ellipsis" "w-3 h-3" }}</button>
68
-
{{ end }}
69
70
-
{{ if index $.TagMap $commit.Hash.String }}
71
-
{{ range $tag := index $.TagMap $commit.Hash.String }}
72
-
<span class="ml-2 text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 inline-flex items-center">
73
-
{{ $tag }}
74
-
</span>
75
-
{{ end }}
76
-
{{ end }}
77
-
</div>
78
79
-
{{ if gt (len $messageParts) 1 }}
80
-
<p class="hidden mt-1 text-sm text-gray-600 dark:text-gray-400">{{ nl2br (index $messageParts 1) }}</p>
81
-
{{ end }}
82
-
</td>
83
-
<td class="py-3 align-top">
84
-
<!-- ci status -->
85
-
{{ $pipeline := index $.Pipelines .Hash.String }}
86
-
{{ if and $pipeline (gt (len $pipeline.Statuses) 0) }}
87
-
{{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }}
88
-
{{ end }}
89
-
</td>
90
-
<td class=" py-3 align-top text-gray-500 dark:text-gray-400">{{ timeFmt $commit.Committer.When }}</td>
91
-
</tr>
92
-
{{ end }}
93
-
</tbody>
94
-
</table>
95
96
<!-- mobile view (visible only on small screens) -->
97
<div class="md:hidden">
···
102
<div class="text-base cursor-pointer">
103
<div class="flex items-center justify-between">
104
<div class="flex-1">
105
-
<div class="inline-flex items-end">
106
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}"
107
class="inline no-underline hover:underline dark:text-white">
108
{{ index $messageParts 0 }}
109
</a>
110
{{ if gt (len $messageParts) 1 }}
111
<button
112
-
class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600 ml-2"
113
hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">
114
{{ i "ellipsis" "w-3 h-3" }}
115
</button>
···
159
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
160
<a href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}"
161
class="text-gray-500 dark:text-gray-400 no-underline hover:underline">
162
-
{{ if $didOrHandle }}{{ $didOrHandle }}{{ else }}{{ $commit.Author.Name }}{{ end }}
163
</a>
164
</span>
165
<div class="inline-block px-1 select-none after:content-['ยท']"></div>
166
-
<span>{{ shortTimeFmt $commit.Committer.When }}</span>
167
168
<!-- ci status -->
169
{{ $pipeline := index $.Pipelines .Hash.String }}
···
14
</h2>
15
16
<!-- desktop view (hidden on small screens) -->
17
+
<div class="hidden md:flex md:flex-col divide-y divide-gray-200 dark:divide-gray-700">
18
+
{{ $grid := "grid grid-cols-14 gap-4" }}
19
+
<div class="{{ $grid }}">
20
+
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2">Author</div>
21
+
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-3">Commit</div>
22
+
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-6">Message</div>
23
+
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-1"></div>
24
+
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2 justify-self-end">Date</div>
25
+
</div>
26
+
{{ range $index, $commit := .Commits }}
27
+
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
28
+
<div class="{{ $grid }} py-3">
29
+
<div class="align-top truncate col-span-2">
30
+
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
31
+
{{ if $didOrHandle }}
32
+
{{ template "user/fragments/picHandleLink" $didOrHandle }}
33
+
{{ else }}
34
+
<a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a>
35
+
{{ end }}
36
+
</div>
37
+
<div class="align-top font-mono flex items-start col-span-3">
38
+
{{ $verified := $.VerifiedCommits.IsVerified $commit.Hash.String }}
39
+
{{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }}
40
+
{{ if $verified }}
41
+
{{ $hashStyle = "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 rounded" }}
42
+
{{ end }}
43
+
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="no-underline hover:underline {{ $hashStyle }} px-2 py-1/2 rounded flex items-center gap-2">
44
+
{{ slice $commit.Hash.String 0 8 }}
45
+
{{ if $verified }}
46
+
{{ i "shield-check" "w-4 h-4" }}
47
+
{{ end }}
48
+
</a>
49
+
<div class="{{ if not $verified }} ml-6 {{ end }}inline-flex">
50
+
<button class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
51
+
title="Copy SHA"
52
+
onclick="navigator.clipboard.writeText('{{ $commit.Hash.String }}'); this.innerHTML=`{{ i "copy-check" "w-4 h-4" }}`; setTimeout(() => this.innerHTML=`{{ i "copy" "w-4 h-4" }}`, 1500)">
53
+
{{ i "copy" "w-4 h-4" }}
54
+
</button>
55
+
<a href="/{{ $.RepoInfo.FullName }}/tree/{{ $commit.Hash.String }}" class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" title="Browse repository at this commit">
56
+
{{ i "folder-code" "w-4 h-4" }}
57
+
</a>
58
+
</div>
59
60
+
</div>
61
+
<div class="align-top col-span-6">
62
+
<div>
63
+
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white no-underline hover:underline">{{ index $messageParts 0 }}</a>
64
+
{{ if gt (len $messageParts) 1 }}
65
+
<button class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-600 rounded" hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">{{ i "ellipsis" "w-3 h-3" }}</button>
66
+
{{ end }}
67
68
+
{{ if index $.TagMap $commit.Hash.String }}
69
+
{{ range $tag := index $.TagMap $commit.Hash.String }}
70
+
<span class="ml-2 text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 inline-flex items-center">
71
+
{{ $tag }}
72
+
</span>
73
+
{{ end }}
74
+
{{ end }}
75
+
</div>
76
77
+
{{ if gt (len $messageParts) 1 }}
78
+
<p class="hidden mt-1 text-sm text-gray-600 dark:text-gray-400">{{ nl2br (index $messageParts 1) }}</p>
79
+
{{ end }}
80
+
</div>
81
+
<div class="align-top col-span-1">
82
+
<!-- ci status -->
83
+
{{ $pipeline := index $.Pipelines .Hash.String }}
84
+
{{ if and $pipeline (gt (len $pipeline.Statuses) 0) }}
85
+
{{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }}
86
+
{{ end }}
87
+
</div>
88
+
<div class="align-top justify-self-end text-gray-500 dark:text-gray-400 col-span-2">{{ template "repo/fragments/shortTimeAgo" $commit.Committer.When }}</div>
89
+
</div>
90
+
{{ end }}
91
+
</div>
92
93
<!-- mobile view (visible only on small screens) -->
94
<div class="md:hidden">
···
99
<div class="text-base cursor-pointer">
100
<div class="flex items-center justify-between">
101
<div class="flex-1">
102
+
<div>
103
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}"
104
class="inline no-underline hover:underline dark:text-white">
105
{{ index $messageParts 0 }}
106
</a>
107
{{ if gt (len $messageParts) 1 }}
108
<button
109
+
class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600"
110
hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">
111
{{ i "ellipsis" "w-3 h-3" }}
112
</button>
···
156
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
157
<a href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}"
158
class="text-gray-500 dark:text-gray-400 no-underline hover:underline">
159
+
{{ if $didOrHandle }}{{ template "user/fragments/picHandleLink" $didOrHandle }}{{ else }}{{ $commit.Author.Name }}{{ end }}
160
</a>
161
</span>
162
<div class="inline-block px-1 select-none after:content-['ยท']"></div>
163
+
<span>{{ template "repo/fragments/shortTime" $commit.Committer.When }}</span>
164
165
<!-- ci status -->
166
{{ $pipeline := index $.Pipelines .Hash.String }}
+3
-3
appview/pages/templates/repo/pipelines/fragments/logBlock.html
+3
-3
appview/pages/templates/repo/pipelines/fragments/logBlock.html
···
1
{{ define "repo/pipelines/fragments/logBlock" }}
2
<div id="lines" hx-swap-oob="beforeend">
3
-
<details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group bg-gray-100 pb-2 px-2 dark:bg-gray-900">
4
-
<summary class="sticky top-0 pt-2 group-open:pb-2 list-none cursor-pointer bg-gray-100 dark:bg-gray-900 hover:text-gray-500 hover:dark:text-gray-400">
5
<div class="group-open:hidden flex items-center gap-1">
6
{{ i "chevron-right" "w-4 h-4" }} {{ .Name }}
7
</div>
···
9
{{ i "chevron-down" "w-4 h-4" }} {{ .Name }}
10
</div>
11
</summary>
12
-
<div class="font-mono whitespace-pre overflow-x-auto"><div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div><div id="step-body-{{ .Id }}"></div></div>
13
</details>
14
</div>
15
{{ end }}
···
1
{{ define "repo/pipelines/fragments/logBlock" }}
2
<div id="lines" hx-swap-oob="beforeend">
3
+
<details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group pb-2 rounded-sm border border-gray-200 dark:border-gray-700">
4
+
<summary class="sticky top-0 pt-2 px-2 group-open:pb-2 group-open:mb-2 list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:text-gray-500 hover:dark:text-gray-400">
5
<div class="group-open:hidden flex items-center gap-1">
6
{{ i "chevron-right" "w-4 h-4" }} {{ .Name }}
7
</div>
···
9
{{ i "chevron-down" "w-4 h-4" }} {{ .Name }}
10
</div>
11
</summary>
12
+
<div class="font-mono whitespace-pre overflow-x-auto px-2"><div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div><div id="step-body-{{ .Id }}"></div></div>
13
</details>
14
</div>
15
{{ end }}
+5
-9
appview/pages/templates/repo/pipelines/fragments/tooltip.html
+5
-9
appview/pages/templates/repo/pipelines/fragments/tooltip.html
···
10
{{ $lastStatus := $all.Latest }}
11
{{ $kind := $lastStatus.Status.String }}
12
13
-
{{ $t := .TimeTaken }}
14
-
{{ $time := "" }}
15
-
{{ if $t }}
16
-
{{ $time = durationFmt $t }}
17
-
{{ else }}
18
-
{{ $time = printf "%s ago" (shortTimeFmt $pipeline.Created) }}
19
-
{{ end }}
20
-
21
<div id="left" class="flex items-center gap-2 flex-shrink-0">
22
{{ template "repo/pipelines/fragments/workflowSymbol" $all }}
23
{{ $name }}
24
</div>
25
<div id="right" class="flex items-center gap-2 flex-shrink-0">
26
<span class="font-bold">{{ $kind }}</span>
27
-
<time>{{ $time }}</time>
28
</div>
29
</div>
30
</a>
···
10
{{ $lastStatus := $all.Latest }}
11
{{ $kind := $lastStatus.Status.String }}
12
13
<div id="left" class="flex items-center gap-2 flex-shrink-0">
14
{{ template "repo/pipelines/fragments/workflowSymbol" $all }}
15
{{ $name }}
16
</div>
17
<div id="right" class="flex items-center gap-2 flex-shrink-0">
18
<span class="font-bold">{{ $kind }}</span>
19
+
{{ if .TimeTaken }}
20
+
{{ template "repo/fragments/duration" .TimeTaken }}
21
+
{{ else }}
22
+
{{ template "repo/fragments/shortTimeAgo" $pipeline.Created }}
23
+
{{ end }}
24
</div>
25
</div>
26
</a>
+1
-3
appview/pages/templates/repo/pipelines/pipelines.html
+1
-3
appview/pages/templates/repo/pipelines/pipelines.html
+5
-13
appview/pages/templates/repo/pipelines/workflow.html
+5
-13
appview/pages/templates/repo/pipelines/workflow.html
···
17
</section>
18
{{ end }}
19
20
-
{{ define "repoAfter" }}
21
-
{{ end }}
22
-
23
{{ define "sidebar" }}
24
{{ $active := .Workflow }}
25
{{ with .Pipeline }}
···
32
{{ $lastStatus := $all.Latest }}
33
{{ $kind := $lastStatus.Status.String }}
34
35
-
{{ $t := .TimeTaken }}
36
-
{{ $time := "" }}
37
-
38
-
{{ if $t }}
39
-
{{ $time = durationFmt $t }}
40
-
{{ else }}
41
-
{{ $time = printf "%s ago" (shortTimeFmt $lastStatus.Created) }}
42
-
{{ end }}
43
-
44
<div id="left" class="flex items-center gap-2 flex-shrink-0">
45
{{ template "repo/pipelines/fragments/workflowSymbol" $all }}
46
{{ $name }}
47
</div>
48
<div id="right" class="flex items-center gap-2 flex-shrink-0">
49
<span class="font-bold">{{ $kind }}</span>
50
-
<time>{{ $time }}</time>
51
</div>
52
</div>
53
</a>
···
17
</section>
18
{{ end }}
19
20
{{ define "sidebar" }}
21
{{ $active := .Workflow }}
22
{{ with .Pipeline }}
···
29
{{ $lastStatus := $all.Latest }}
30
{{ $kind := $lastStatus.Status.String }}
31
32
<div id="left" class="flex items-center gap-2 flex-shrink-0">
33
{{ template "repo/pipelines/fragments/workflowSymbol" $all }}
34
{{ $name }}
35
</div>
36
<div id="right" class="flex items-center gap-2 flex-shrink-0">
37
<span class="font-bold">{{ $kind }}</span>
38
+
{{ if .TimeTaken }}
39
+
{{ template "repo/fragments/duration" .TimeTaken }}
40
+
{{ else }}
41
+
{{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }}
42
+
{{ end }}
43
</div>
44
</div>
45
</a>
+18
-2
appview/pages/templates/repo/pulls/fragments/pullHeader.html
+18
-2
appview/pages/templates/repo/pulls/fragments/pullHeader.html
···
29
<span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1">
30
opened by
31
{{ $owner := index $.DidHandleMap .Pull.OwnerDid }}
32
-
{{ template "user/fragments/picHandle" $owner }}
33
<span class="select-none before:content-['\00B7']"></span>
34
-
<time>{{ .Pull.Created | timeFmt }}</time>
35
36
<span class="select-none before:content-['\00B7']"></span>
37
<span>
···
60
<article id="body" class="mt-8 prose dark:prose-invert">
61
{{ .Pull.Body | markdown }}
62
</article>
63
{{ end }}
64
</section>
65
···
29
<span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1">
30
opened by
31
{{ $owner := index $.DidHandleMap .Pull.OwnerDid }}
32
+
{{ template "user/fragments/picHandleLink" $owner }}
33
<span class="select-none before:content-['\00B7']"></span>
34
+
{{ template "repo/fragments/time" .Pull.Created }}
35
36
<span class="select-none before:content-['\00B7']"></span>
37
<span>
···
60
<article id="body" class="mt-8 prose dark:prose-invert">
61
{{ .Pull.Body | markdown }}
62
</article>
63
+
{{ end }}
64
+
65
+
{{ with .OrderedReactionKinds }}
66
+
<div class="flex items-center gap-2 mt-2">
67
+
{{ template "repo/fragments/reactionsPopUp" . }}
68
+
{{ range $kind := . }}
69
+
{{
70
+
template "repo/fragments/reaction"
71
+
(dict
72
+
"Kind" $kind
73
+
"Count" (index $.Reactions $kind)
74
+
"IsReacted" (index $.UserReacted $kind)
75
+
"ThreadAt" $.Pull.PullAt)
76
+
}}
77
+
{{ end }}
78
+
</div>
79
{{ end }}
80
</section>
81
+2
-3
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
+2
-3
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
···
1
{{ define "repo/pulls/fragments/pullNewComment" }}
2
-
<div
3
-
id="pull-comment-card-{{ .RoundNumber }}"
4
class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2">
5
<div class="text-sm text-gray-500 dark:text-gray-400">
6
{{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }}
···
38
</form>
39
</div>
40
{{ end }}
41
-
···
1
{{ define "repo/pulls/fragments/pullNewComment" }}
2
+
<div
3
+
id="pull-comment-card-{{ .RoundNumber }}"
4
class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2">
5
<div class="text-sm text-gray-500 dark:text-gray-400">
6
{{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }}
···
38
</form>
39
</div>
40
{{ end }}
-1
appview/pages/templates/repo/pulls/fragments/pullStack.html
-1
appview/pages/templates/repo/pulls/fragments/pullStack.html
+6
-8
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
+6
-8
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
···
13
</span>
14
</div>
15
16
-
<div class="flex-shrink-0 flex items-center">
17
{{ $latestRound := .LastRoundNumber }}
18
{{ $lastSubmission := index .Submissions $latestRound }}
19
{{ $commentCount := len $lastSubmission.Comments }}
20
-
{{ if $pipeline }}
21
-
<div class="inline-flex items-center gap-2">
22
-
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
23
-
<span class="mx-2 before:content-['ยท'] before:select-none"></span>
24
-
</div>
25
{{ end }}
26
<span>
27
-
<div class="inline-flex items-center gap-2">
28
{{ i "message-square" "w-3 h-3 md:hidden" }}
29
{{ $commentCount }}
30
<span class="hidden md:inline">comment{{if ne $commentCount 1}}s{{end}}</span>
31
</div>
32
</span>
33
-
<span class="mx-2 before:content-['ยท'] before:select-none"></span>
34
<span>
35
<span class="hidden md:inline">round</span>
36
<span class="font-mono">#{{ $latestRound }}</span>
···
13
</span>
14
</div>
15
16
+
<div class="flex-shrink-0 flex items-center gap-2">
17
{{ $latestRound := .LastRoundNumber }}
18
{{ $lastSubmission := index .Submissions $latestRound }}
19
{{ $commentCount := len $lastSubmission.Comments }}
20
+
{{ if and $pipeline $pipeline.Id }}
21
+
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
22
+
<span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span>
23
{{ end }}
24
<span>
25
+
<div class="inline-flex items-center gap-1">
26
{{ i "message-square" "w-3 h-3 md:hidden" }}
27
{{ $commentCount }}
28
<span class="hidden md:inline">comment{{if ne $commentCount 1}}s{{end}}</span>
29
</div>
30
</span>
31
+
<span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span>
32
<span>
33
<span class="hidden md:inline">round</span>
34
<span class="font-mono">#{{ $latestRound }}</span>
+44
-3
appview/pages/templates/repo/pulls/interdiff.html
+44
-3
appview/pages/templates/repo/pulls/interdiff.html
···
26
</header>
27
</section>
28
29
+
{{ end }}
30
+
31
+
{{ define "topbarLayout" }}
32
+
<header class="px-1 col-span-full" style="z-index: 20;">
33
+
{{ template "layouts/topbar" . }}
34
+
</header>
35
+
{{ end }}
36
+
37
+
{{ define "mainLayout" }}
38
+
<div class="px-1 col-span-full flex flex-col gap-4">
39
+
{{ block "contentLayout" . }}
40
+
{{ block "content" . }}{{ end }}
41
+
{{ end }}
42
+
43
+
{{ block "contentAfterLayout" . }}
44
+
<div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4">
45
+
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
46
+
{{ block "contentAfterLeft" . }} {{ end }}
47
+
</div>
48
+
<main class="col-span-1 md:col-span-10">
49
+
{{ block "contentAfter" . }}{{ end }}
50
+
</main>
51
+
</div>
52
+
{{ end }}
53
+
</div>
54
{{ end }}
55
56
+
{{ define "footerLayout" }}
57
+
<footer class="px-1 col-span-full mt-12">
58
+
{{ template "layouts/footer" . }}
59
+
</footer>
60
+
{{ end }}
61
+
62
+
63
+
{{ define "contentAfter" }}
64
+
{{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }}
65
+
{{end}}
66
+
67
+
{{ define "contentAfterLeft" }}
68
+
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
69
+
{{ template "repo/fragments/diffOpts" .DiffOpts }}
70
+
</div>
71
+
<div class="sticky top-0 flex-grow max-h-screen">
72
+
{{ template "repo/fragments/interdiffFiles" .Interdiff }}
73
+
</div>
74
+
{{end}}
+44
-1
appview/pages/templates/repo/pulls/patch.html
+44
-1
appview/pages/templates/repo/pulls/patch.html
···
31
<div class="border-t border-gray-200 dark:border-gray-700 my-2"></div>
32
{{ template "repo/pulls/fragments/pullHeader" . }}
33
</section>
34
</section>
35
{{ end }}
36
+
37
+
{{ define "topbarLayout" }}
38
+
<header class="px-1 col-span-full" style="z-index: 20;">
39
+
{{ template "layouts/topbar" . }}
40
+
</header>
41
+
{{ end }}
42
+
43
+
{{ define "mainLayout" }}
44
+
<div class="px-1 col-span-full flex flex-col gap-4">
45
+
{{ block "contentLayout" . }}
46
+
{{ block "content" . }}{{ end }}
47
+
{{ end }}
48
+
49
+
{{ block "contentAfterLayout" . }}
50
+
<div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4">
51
+
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
52
+
{{ block "contentAfterLeft" . }} {{ end }}
53
+
</div>
54
+
<main class="col-span-1 md:col-span-10">
55
+
{{ block "contentAfter" . }}{{ end }}
56
+
</main>
57
+
</div>
58
+
{{ end }}
59
+
</div>
60
+
{{ end }}
61
+
62
+
{{ define "footerLayout" }}
63
+
<footer class="px-1 col-span-full mt-12">
64
+
{{ template "layouts/footer" . }}
65
+
</footer>
66
+
{{ end }}
67
+
68
+
{{ define "contentAfter" }}
69
+
{{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }}
70
+
{{end}}
71
+
72
+
{{ define "contentAfterLeft" }}
73
+
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
74
+
{{ template "repo/fragments/diffOpts" .DiffOpts }}
75
+
</div>
76
+
<div class="sticky top-0 flex-grow max-h-screen">
77
+
{{ template "repo/fragments/diffChangedFiles" .Diff }}
78
+
</div>
79
+
{{end}}
+14
-20
appview/pages/templates/repo/pulls/pull.html
+14
-20
appview/pages/templates/repo/pulls/pull.html
···
5
{{ define "extrameta" }}
6
{{ $title := printf "%s · pull #%d · %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }}
7
{{ $url := printf "https://tangled.sh/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }}
8
-
9
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10
{{ end }}
11
···
46
</div>
47
<!-- round summary -->
48
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
49
-
<span>
50
{{ $owner := index $.DidHandleMap $.Pull.OwnerDid }}
51
{{ $re := "re" }}
52
{{ if eq .RoundNumber 0 }}
53
{{ $re = "" }}
54
{{ end }}
55
<span class="hidden md:inline">{{$re}}submitted</span>
56
-
by <a href="/{{ $owner }}">{{ $owner }}</a>
57
<span class="select-none before:content-['\00B7']"></span>
58
-
<a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}"><time>{{ .Created | shortTimeFmt }}</time></a>
59
<span class="select-none before:content-['ยท']"></span>
60
{{ $s := "s" }}
61
{{ if eq (len .Comments) 1 }}
···
68
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
69
hx-boost="true"
70
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}">
71
-
{{ i "file-diff" "w-4 h-4" }}
72
<span class="hidden md:inline">diff</span>
73
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
74
</a>
···
150
{{ if gt $cidx 0 }}
151
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
152
{{ end }}
153
-
<div class="text-sm text-gray-500 dark:text-gray-400">
154
-
{{ $owner := index $.DidHandleMap $c.OwnerDid }}
155
-
<a href="/{{$owner}}">{{$owner}}</a>
156
<span class="before:content-['ยท']"></span>
157
-
<a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}"><time>{{ $c.Created | shortTimeFmt }}</time></a>
158
</div>
159
<div class="prose dark:prose-invert">
160
{{ $c.Body | markdown }}
···
179
{{ end }}
180
</div>
181
</details>
182
-
<hr class="md:hidden border-t border-gray-300 dark:border-gray-600"/>
183
{{ end }}
184
{{ end }}
185
{{ end }}
···
277
{{ $lastStatus := $all.Latest }}
278
{{ $kind := $lastStatus.Status.String }}
279
280
-
{{ $t := .TimeTaken }}
281
-
{{ $time := "" }}
282
-
283
-
{{ if $t }}
284
-
{{ $time = durationFmt $t }}
285
-
{{ else }}
286
-
{{ $time = printf "%s ago" (shortTimeFmt $lastStatus.Created) }}
287
-
{{ end }}
288
-
289
<div id="left" class="flex items-center gap-2 flex-shrink-0">
290
{{ template "repo/pipelines/fragments/workflowSymbol" $all }}
291
{{ $name }}
292
</div>
293
<div id="right" class="flex items-center gap-2 flex-shrink-0">
294
<span class="font-bold">{{ $kind }}</span>
295
-
<time>{{ $time }}</time>
296
</div>
297
</div>
298
</a>
···
5
{{ define "extrameta" }}
6
{{ $title := printf "%s · pull #%d · %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }}
7
{{ $url := printf "https://tangled.sh/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }}
8
+
9
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10
{{ end }}
11
···
46
</div>
47
<!-- round summary -->
48
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
49
+
<span class="gap-1 flex items-center">
50
{{ $owner := index $.DidHandleMap $.Pull.OwnerDid }}
51
{{ $re := "re" }}
52
{{ if eq .RoundNumber 0 }}
53
{{ $re = "" }}
54
{{ end }}
55
<span class="hidden md:inline">{{$re}}submitted</span>
56
+
by {{ template "user/fragments/picHandleLink" $owner }}
57
<span class="select-none before:content-['\00B7']"></span>
58
+
<a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}">{{ template "repo/fragments/shortTime" .Created }}</a>
59
<span class="select-none before:content-['ยท']"></span>
60
{{ $s := "s" }}
61
{{ if eq (len .Comments) 1 }}
···
68
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
69
hx-boost="true"
70
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}">
71
+
{{ i "file-diff" "w-4 h-4" }}
72
<span class="hidden md:inline">diff</span>
73
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
74
</a>
···
150
{{ if gt $cidx 0 }}
151
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
152
{{ end }}
153
+
<div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1">
154
+
{{ $owner := index $.DidHandleMap $c.OwnerDid }}
155
+
{{ template "user/fragments/picHandleLink" $owner }}
156
<span class="before:content-['ยท']"></span>
157
+
<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>
158
</div>
159
<div class="prose dark:prose-invert">
160
{{ $c.Body | markdown }}
···
179
{{ end }}
180
</div>
181
</details>
182
{{ end }}
183
{{ end }}
184
{{ end }}
···
276
{{ $lastStatus := $all.Latest }}
277
{{ $kind := $lastStatus.Status.String }}
278
279
<div id="left" class="flex items-center gap-2 flex-shrink-0">
280
{{ template "repo/pipelines/fragments/workflowSymbol" $all }}
281
{{ $name }}
282
</div>
283
<div id="right" class="flex items-center gap-2 flex-shrink-0">
284
<span class="font-bold">{{ $kind }}</span>
285
+
{{ if .TimeTaken }}
286
+
{{ template "repo/fragments/duration" .TimeTaken }}
287
+
{{ else }}
288
+
{{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }}
289
+
{{ end }}
290
</div>
291
</div>
292
</a>
+46
-57
appview/pages/templates/repo/pulls/pulls.html
+46
-57
appview/pages/templates/repo/pulls/pulls.html
···
54
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
55
</a>
56
</div>
57
-
<p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
58
{{ $owner := index $.DidHandleMap .OwnerDid }}
59
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
60
{{ $icon := "ban" }}
···
76
</span>
77
78
<span class="ml-1">
79
-
{{ template "user/fragments/picHandle" $owner }}
80
</span>
81
82
-
<span>
83
-
<time>
84
-
{{ .Created | timeFmt }}
85
-
</time>
86
</span>
87
88
<span class="before:content-['ยท']">
89
-
targeting
90
-
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
91
-
{{ .TargetBranch }}
92
-
</span>
93
</span>
94
-
{{ if not .IsPatchBased }}
95
-
from
96
-
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
97
-
{{ if .IsForkBased }}
98
-
{{ if .PullSource.Repo }}
99
-
<a href="/{{ $owner }}/{{ .PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .PullSource.Repo.Name }}</a>:
100
-
{{- else -}}
101
-
<span class="italic">[deleted fork]</span>
102
-
{{- end -}}
103
-
{{- end -}}
104
-
{{- .PullSource.Branch -}}
105
</span>
106
{{ end }}
107
-
<span class="before:content-['ยท']">
108
-
{{ $latestRound := .LastRoundNumber }}
109
-
{{ $lastSubmission := index .Submissions $latestRound }}
110
-
round
111
-
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
112
-
#{{ .LastRoundNumber }}
113
-
</span>
114
-
{{ $commentCount := len $lastSubmission.Comments }}
115
-
{{ $s := "s" }}
116
-
{{ if eq $commentCount 1 }}
117
-
{{ $s = "" }}
118
-
{{ end }}
119
-
120
-
{{ if eq $commentCount 0 }}
121
-
awaiting comments
122
-
{{ else }}
123
-
recieved {{ len $lastSubmission.Comments}} comment{{$s}}
124
-
{{ end }}
125
-
</span>
126
-
</p>
127
</div>
128
{{ if .StackId }}
129
{{ $otherPulls := index $.Stacks .StackId }}
130
-
<details class="bg-white dark:bg-gray-800 group">
131
-
<summary class="pb-4 px-6 text-xs list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
132
-
{{ $s := "s" }}
133
-
{{ if eq (len $otherPulls) 1 }}
134
-
{{ $s = "" }}
135
-
{{ end }}
136
-
<div class="group-open:hidden flex items-center gap-2">
137
-
{{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $otherPulls }} pull{{$s}} in this stack
138
-
</div>
139
-
<div class="hidden group-open:flex items-center gap-2">
140
-
{{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack
141
-
</div>
142
-
</summary>
143
-
{{ block "pullList" (list $otherPulls $) }} {{ end }}
144
-
</details>
145
{{ end }}
146
</div>
147
{{ end }}
···
153
{{ $root := index . 1 }}
154
<div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900">
155
{{ range $pull := $list }}
156
<a href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $pull.PullId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
157
<div class="flex gap-2 items-center px-6">
158
<div class="flex-grow min-w-0 w-full py-2">
159
-
{{ template "repo/pulls/fragments/summarizedHeader" (list $pull 0) }}
160
</div>
161
</div>
162
</a>
···
54
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
55
</a>
56
</div>
57
+
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
58
{{ $owner := index $.DidHandleMap .OwnerDid }}
59
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
60
{{ $icon := "ban" }}
···
76
</span>
77
78
<span class="ml-1">
79
+
{{ template "user/fragments/picHandleLink" $owner }}
80
</span>
81
82
+
<span class="before:content-['ยท']">
83
+
{{ template "repo/fragments/time" .Created }}
84
</span>
85
+
86
+
87
+
{{ $latestRound := .LastRoundNumber }}
88
+
{{ $lastSubmission := index .Submissions $latestRound }}
89
90
<span class="before:content-['ยท']">
91
+
{{ $commentCount := len $lastSubmission.Comments }}
92
+
{{ $s := "s" }}
93
+
{{ if eq $commentCount 1 }}
94
+
{{ $s = "" }}
95
+
{{ end }}
96
+
97
+
{{ len $lastSubmission.Comments}} comment{{$s}}
98
</span>
99
+
100
+
<span class="before:content-['ยท']">
101
+
round
102
+
<span class="font-mono">
103
+
#{{ .LastRoundNumber }}
104
+
</span>
105
</span>
106
+
107
+
{{ $pipeline := index $.Pipelines .LatestSha }}
108
+
{{ if and $pipeline $pipeline.Id }}
109
+
<span class="before:content-['ยท']"></span>
110
+
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
111
{{ end }}
112
+
</div>
113
</div>
114
{{ if .StackId }}
115
{{ $otherPulls := index $.Stacks .StackId }}
116
+
{{ if gt (len $otherPulls) 0 }}
117
+
<details class="bg-white dark:bg-gray-800 group">
118
+
<summary class="pb-4 px-6 text-xs list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
119
+
{{ $s := "s" }}
120
+
{{ if eq (len $otherPulls) 1 }}
121
+
{{ $s = "" }}
122
+
{{ end }}
123
+
<div class="group-open:hidden flex items-center gap-2">
124
+
{{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $otherPulls }} pull{{$s}} in this stack
125
+
</div>
126
+
<div class="hidden group-open:flex items-center gap-2">
127
+
{{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack
128
+
</div>
129
+
</summary>
130
+
{{ block "pullList" (list $otherPulls $) }} {{ end }}
131
+
</details>
132
+
{{ end }}
133
{{ end }}
134
</div>
135
{{ end }}
···
141
{{ $root := index . 1 }}
142
<div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900">
143
{{ range $pull := $list }}
144
+
{{ $pipeline := index $root.Pipelines $pull.LatestSha }}
145
<a href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $pull.PullId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
146
<div class="flex gap-2 items-center px-6">
147
<div class="flex-grow min-w-0 w-full py-2">
148
+
{{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }}
149
</div>
150
</div>
151
</a>
+110
appview/pages/templates/repo/settings/access.html
+110
appview/pages/templates/repo/settings/access.html
···
···
1
+
{{ define "title" }}{{ .Tab }} settings · {{ .RepoInfo.FullName }}{{ end }}
2
+
3
+
{{ define "repoContent" }}
4
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2">
5
+
<div class="col-span-1">
6
+
{{ template "repo/settings/fragments/sidebar" . }}
7
+
</div>
8
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2">
9
+
{{ template "collaboratorSettings" . }}
10
+
</div>
11
+
</section>
12
+
{{ end }}
13
+
14
+
{{ define "collaboratorSettings" }}
15
+
<div class="grid grid-cols-1 gap-4 items-center">
16
+
<div class="col-span-1">
17
+
<h2 class="text-sm pb-2 uppercase font-bold">Collaborators</h2>
18
+
<p class="text-gray-500 dark:text-gray-400">
19
+
Any user added as a collaborator will be able to push commits and tags to this repository, upload releases, and workflows.
20
+
</p>
21
+
</div>
22
+
{{ template "collaboratorsGrid" . }}
23
+
</div>
24
+
{{ end }}
25
+
26
+
{{ define "collaboratorsGrid" }}
27
+
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
28
+
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
29
+
{{ template "addCollaboratorButton" . }}
30
+
{{ end }}
31
+
{{ range .Collaborators }}
32
+
<div class="border border-gray-200 dark:border-gray-700 rounded p-4">
33
+
<div class="flex items-center gap-3">
34
+
<img
35
+
src="{{ fullAvatar .Handle }}"
36
+
alt="{{ .Handle }}"
37
+
class="rounded-full h-10 w-10 border border-gray-300 dark:border-gray-600 flex-shrink-0"/>
38
+
39
+
<div class="flex-1 min-w-0">
40
+
<a href="/{{ .Handle }}" class="block truncate">
41
+
{{ didOrHandle .Did .Handle }}
42
+
</a>
43
+
<p class="text-sm text-gray-500 dark:text-gray-400">{{ .Role }}</p>
44
+
</div>
45
+
</div>
46
+
</div>
47
+
{{ end }}
48
+
</div>
49
+
{{ end }}
50
+
51
+
{{ define "addCollaboratorButton" }}
52
+
<button
53
+
class="btn block rounded p-4"
54
+
popovertarget="add-collaborator-modal"
55
+
popovertargetaction="toggle">
56
+
<div class="flex items-center gap-3">
57
+
<div class="w-10 h-10 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center">
58
+
{{ i "user-plus" "size-4" }}
59
+
</div>
60
+
61
+
<div class="text-left flex-1 min-w-0 block truncate">
62
+
Add collaborator
63
+
</div>
64
+
</div>
65
+
</button>
66
+
<div
67
+
id="add-collaborator-modal"
68
+
popover
69
+
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
70
+
{{ template "addCollaboratorModal" . }}
71
+
</div>
72
+
{{ end }}
73
+
74
+
{{ define "addCollaboratorModal" }}
75
+
<form
76
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"
77
+
hx-indicator="#spinner"
78
+
hx-swap="none"
79
+
class="flex flex-col gap-2"
80
+
>
81
+
<label for="add-collaborator" class="uppercase p-0">
82
+
ADD COLLABORATOR
83
+
</label>
84
+
<p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p>
85
+
<input
86
+
type="text"
87
+
id="add-collaborator"
88
+
name="collaborator"
89
+
required
90
+
placeholder="@foo.bsky.social"
91
+
/>
92
+
<div class="flex gap-2 pt-2">
93
+
<button
94
+
type="button"
95
+
popovertarget="add-collaborator-modal"
96
+
popovertargetaction="hide"
97
+
class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
98
+
>
99
+
{{ i "x" "size-4" }} cancel
100
+
</button>
101
+
<button type="submit" class="btn w-1/2 flex items-center">
102
+
<span class="inline-flex gap-2 items-center">{{ i "user-plus" "size-4" }} add</span>
103
+
<span id="spinner" class="group">
104
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
105
+
</span>
106
+
</button>
107
+
</div>
108
+
<div id="add-collaborator-error" class="text-red-500 dark:text-red-400"></div>
109
+
</form>
110
+
{{ end }}
+29
appview/pages/templates/repo/settings/fragments/secretListing.html
+29
appview/pages/templates/repo/settings/fragments/secretListing.html
···
···
1
+
{{ define "repo/settings/fragments/secretListing" }}
2
+
{{ $root := index . 0 }}
3
+
{{ $secret := index . 1 }}
4
+
<div id="secret-{{$secret.Key}}" class="flex items-center justify-between p-2">
5
+
<div class="hover:no-underline flex flex-col gap-1 text-sm min-w-0 max-w-[80%]">
6
+
<span class="font-mono">
7
+
{{ $secret.Key }}
8
+
</span>
9
+
<div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
10
+
<span>added by</span>
11
+
<span>{{ template "user/fragments/picHandleLink" $secret.CreatedBy }}</span>
12
+
<span class="before:content-['ยท'] before:select-none"></span>
13
+
<span>{{ template "repo/fragments/shortTimeAgo" $secret.CreatedAt }}</span>
14
+
</div>
15
+
</div>
16
+
<button
17
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
18
+
title="Delete secret"
19
+
hx-delete="/{{ $root.RepoInfo.FullName }}/settings/secrets"
20
+
hx-swap="none"
21
+
hx-vals='{"key": "{{ $secret.Key }}"}'
22
+
hx-confirm="Are you sure you want to delete the secret {{ $secret.Key }}?"
23
+
>
24
+
{{ i "trash-2" "w-5 h-5" }}
25
+
<span class="hidden md:inline">delete</span>
26
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
27
+
</button>
28
+
</div>
29
+
{{ end }}
+68
appview/pages/templates/repo/settings/general.html
+68
appview/pages/templates/repo/settings/general.html
···
···
1
+
{{ define "title" }}{{ .Tab }} settings · {{ .RepoInfo.FullName }}{{ end }}
2
+
3
+
{{ define "repoContent" }}
4
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2">
5
+
<div class="col-span-1">
6
+
{{ template "repo/settings/fragments/sidebar" . }}
7
+
</div>
8
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2">
9
+
{{ template "branchSettings" . }}
10
+
{{ template "deleteRepo" . }}
11
+
</div>
12
+
</section>
13
+
{{ end }}
14
+
15
+
{{ define "branchSettings" }}
16
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
17
+
<div class="col-span-1 md:col-span-2">
18
+
<h2 class="text-sm pb-2 uppercase font-bold">Default Branch</h2>
19
+
<p class="text-gray-500 dark:text-gray-400">
20
+
The default branch is considered the โbaseโ branch in your repository,
21
+
against which all pull requests and code commits are automatically made,
22
+
unless you specify a different branch.
23
+
</p>
24
+
</div>
25
+
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
26
+
<select id="branch" name="branch" required class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
27
+
<option value="" disabled selected >
28
+
Choose a default branch
29
+
</option>
30
+
{{ range .Branches }}
31
+
<option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} >
32
+
{{ .Name }}
33
+
</option>
34
+
{{ end }}
35
+
</select>
36
+
<button class="btn flex gap-2 items-center" type="submit">
37
+
{{ i "check" "size-4" }}
38
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
39
+
</button>
40
+
</form>
41
+
</div>
42
+
{{ end }}
43
+
44
+
{{ define "deleteRepo" }}
45
+
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
46
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
47
+
<div class="col-span-1 md:col-span-2">
48
+
<h2 class="text-sm pb-2 uppercase text-red-500 dark:text-red-400 font-bold">Delete Repository</h2>
49
+
<p class="text-red-500 dark:text-red-400 ">
50
+
Deleting a repository is irreversible and permanent. Be certain before deleting a repository.
51
+
</p>
52
+
</div>
53
+
<div class="col-span-1 md:col-span-1 md:justify-self-end">
54
+
<button
55
+
class="btn group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center"
56
+
type="button"
57
+
hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete"
58
+
hx-confirm="Are you sure you want to delete {{ $.RepoInfo.FullName }}?">
59
+
{{ i "trash-2" "size-4" }}
60
+
delete
61
+
<span class="ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline">
62
+
{{ i "loader-circle" "w-4 h-4" }}
63
+
</span>
64
+
</button>
65
+
</div>
66
+
</div>
67
+
{{ end }}
68
+
{{ end }}
+140
appview/pages/templates/repo/settings/pipelines.html
+140
appview/pages/templates/repo/settings/pipelines.html
···
···
1
+
{{ define "title" }}{{ .Tab }} settings · {{ .RepoInfo.FullName }}{{ end }}
2
+
3
+
{{ define "repoContent" }}
4
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2">
5
+
<div class="col-span-1">
6
+
{{ template "repo/settings/fragments/sidebar" . }}
7
+
</div>
8
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2">
9
+
{{ template "spindleSettings" . }}
10
+
{{ if $.CurrentSpindle }}
11
+
{{ template "secretSettings" . }}
12
+
{{ end }}
13
+
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
14
+
</div>
15
+
</section>
16
+
{{ end }}
17
+
18
+
{{ define "spindleSettings" }}
19
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
20
+
<div class="col-span-1 md:col-span-2">
21
+
<h2 class="text-sm pb-2 uppercase font-bold">Spindle</h2>
22
+
<p class="text-gray-500 dark:text-gray-400">
23
+
Choose a spindle to execute your workflows on. Only repository owners
24
+
can configure spindles. Spindles can be selfhosted,
25
+
<a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">
26
+
click to learn more.
27
+
</a>
28
+
</p>
29
+
</div>
30
+
{{ if not $.RepoInfo.Roles.IsOwner }}
31
+
<div class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
32
+
{{ or $.CurrentSpindle "No spindle configured" }}
33
+
</div>
34
+
{{ else }}
35
+
<form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
36
+
<select
37
+
id="spindle"
38
+
name="spindle"
39
+
required
40
+
class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
41
+
<option value="" disabled>
42
+
Choose a spindle
43
+
</option>
44
+
{{ range $.Spindles }}
45
+
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
46
+
{{ . }}
47
+
</option>
48
+
{{ end }}
49
+
</select>
50
+
<button class="btn flex gap-2 items-center" type="submit" {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}>
51
+
{{ i "check" "size-4" }}
52
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
53
+
</button>
54
+
</form>
55
+
{{ end }}
56
+
</div>
57
+
{{ end }}
58
+
59
+
{{ define "secretSettings" }}
60
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
61
+
<div class="col-span-1 md:col-span-2">
62
+
<h2 class="text-sm pb-2 uppercase font-bold">SECRETS</h2>
63
+
<p class="text-gray-500 dark:text-gray-400">
64
+
Secrets are accessible in workflow runs via environment variables. Anyone
65
+
with collaborator access to this repository can add and use secrets in
66
+
workflow runs.
67
+
</p>
68
+
</div>
69
+
<div class="col-span-1 md:col-span-1 md:justify-self-end">
70
+
{{ template "addSecretButton" . }}
71
+
</div>
72
+
</div>
73
+
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
74
+
{{ range .Secrets }}
75
+
{{ template "repo/settings/fragments/secretListing" (list $ .) }}
76
+
{{ else }}
77
+
<div class="flex items-center justify-center p-2 text-gray-500">
78
+
no secrets added yet
79
+
</div>
80
+
{{ end }}
81
+
</div>
82
+
{{ end }}
83
+
84
+
{{ define "addSecretButton" }}
85
+
<button
86
+
class="btn flex items-center gap-2"
87
+
popovertarget="add-secret-modal"
88
+
popovertargetaction="toggle">
89
+
{{ i "plus" "size-4" }}
90
+
add secret
91
+
</button>
92
+
<div
93
+
id="add-secret-modal"
94
+
popover
95
+
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
96
+
{{ template "addSecretModal" . }}
97
+
</div>
98
+
{{ end}}
99
+
100
+
{{ define "addSecretModal" }}
101
+
<form
102
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets"
103
+
hx-indicator="#spinner"
104
+
hx-swap="none"
105
+
class="flex flex-col gap-2"
106
+
>
107
+
<p class="uppercase p-0">ADD SECRET</p>
108
+
<p class="text-sm text-gray-500 dark:text-gray-400">Secrets are available as environment variables in the workflow.</p>
109
+
<input
110
+
type="text"
111
+
id="secret-key"
112
+
name="key"
113
+
required
114
+
placeholder="SECRET_NAME"
115
+
/>
116
+
<textarea
117
+
type="text"
118
+
id="secret-value"
119
+
name="value"
120
+
required
121
+
placeholder="secret value"></textarea>
122
+
<div class="flex gap-2 pt-2">
123
+
<button
124
+
type="button"
125
+
popovertarget="add-secret-modal"
126
+
popovertargetaction="hide"
127
+
class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
128
+
>
129
+
{{ i "x" "size-4" }} cancel
130
+
</button>
131
+
<button type="submit" class="btn w-1/2 flex items-center">
132
+
<span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span>
133
+
<span id="spinner" class="group">
134
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
135
+
</span>
136
+
</button>
137
+
</div>
138
+
<div id="add-secret-error" class="text-red-500 dark:text-red-400"></div>
139
+
</form>
140
+
{{ end }}
+150
-120
appview/pages/templates/repo/settings.html
+150
-120
appview/pages/templates/repo/settings.html
···
1
{{ define "title" }}settings · {{ .RepoInfo.FullName }}{{ end }}
2
{{ define "repoContent" }}
3
-
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
4
-
Collaborators
5
-
</header>
6
7
-
<div id="collaborator-list" class="flex flex-col gap-2 mb-2">
8
-
{{ range .Collaborators }}
9
-
<div id="collaborator" class="mb-2">
10
-
<a
11
-
href="/{{ didOrHandle .Did .Handle }}"
12
-
class="no-underline hover:underline text-black dark:text-white"
13
-
>
14
-
{{ didOrHandle .Did .Handle }}
15
-
</a>
16
-
<div>
17
-
<span class="text-sm text-gray-500 dark:text-gray-400">
18
-
{{ .Role }}
19
-
</span>
20
-
</div>
21
-
</div>
22
-
{{ end }}
23
-
</div>
24
25
-
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
26
-
<form
27
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"
28
-
class="group"
29
>
30
-
<label for="collaborator" class="dark:text-white">
31
-
add collaborator
32
-
</label>
33
-
<input
34
-
type="text"
35
-
id="collaborator"
36
-
name="collaborator"
37
-
required
38
-
class="dark:bg-gray-700 dark:text-white"
39
-
placeholder="enter did or handle"
40
-
>
41
-
<button
42
-
class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700"
43
-
type="text"
44
-
>
45
-
<span>add</span>
46
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
47
-
</button>
48
-
</form>
49
{{ end }}
50
51
<form
52
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default"
53
-
class="mt-6 group"
54
>
55
-
<label for="branch">default branch</label>
56
-
<div class="flex gap-2 items-center">
57
-
<select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
58
-
<option
59
-
value=""
60
-
disabled
61
-
selected
62
-
>
63
-
Choose a default branch
64
-
</option>
65
-
{{ range .Branches }}
66
-
<option
67
-
value="{{ .Name }}"
68
-
class="py-1"
69
-
{{ if .IsDefault }}
70
-
selected
71
-
{{ end }}
72
-
>
73
-
{{ .Name }}
74
-
</option>
75
-
{{ end }}
76
-
</select>
77
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
78
-
<span>save</span>
79
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
80
-
</button>
81
-
</div>
82
-
</form>
83
-
84
-
{{ if .RepoInfo.Roles.IsOwner }}
85
-
<form
86
-
hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle"
87
-
class="mt-6 group"
88
-
>
89
-
<label for="spindle">spindle</label>
90
-
<div class="flex gap-2 items-center">
91
-
<select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
92
-
<option
93
-
value=""
94
-
selected
95
-
>
96
-
None
97
-
</option>
98
-
{{ range .Spindles }}
99
-
<option
100
-
value="{{ . }}"
101
-
class="py-1"
102
-
{{ if eq . $.CurrentSpindle }}
103
-
selected
104
-
{{ end }}
105
-
>
106
-
{{ . }}
107
-
</option>
108
-
{{ end }}
109
-
</select>
110
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
111
-
<span>save</span>
112
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
113
-
</button>
114
-
</div>
115
</form>
116
-
{{ end }}
117
118
-
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
119
<form
120
hx-confirm="Are you sure you want to delete this repository?"
121
hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete"
122
class="mt-6"
123
-
hx-indicator="#delete-repo-spinner"
124
-
>
125
-
<label for="branch">delete repository</label>
126
-
<button class="btn my-2 flex items-center" type="text">
127
-
<span>delete</span>
128
-
<span id="delete-repo-spinner" class="group">
129
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
130
-
</span>
131
-
</button>
132
-
<span>
133
-
Deleting a repository is irreversible and permanent.
134
-
</span>
135
</form>
136
-
{{ end }}
137
138
{{ end }}
···
1
{{ define "title" }}settings · {{ .RepoInfo.FullName }}{{ end }}
2
+
3
{{ define "repoContent" }}
4
+
{{ template "collaboratorSettings" . }}
5
+
{{ template "branchSettings" . }}
6
+
{{ template "dangerZone" . }}
7
+
{{ template "spindleSelector" . }}
8
+
{{ template "spindleSecrets" . }}
9
+
{{ end }}
10
11
+
{{ define "collaboratorSettings" }}
12
+
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
13
+
Collaborators
14
+
</header>
15
16
+
<div id="collaborator-list" class="flex flex-col gap-2 mb-2">
17
+
{{ range .Collaborators }}
18
+
<div id="collaborator" class="mb-2">
19
+
<a
20
+
href="/{{ didOrHandle .Did .Handle }}"
21
+
class="no-underline hover:underline text-black dark:text-white"
22
>
23
+
{{ didOrHandle .Did .Handle }}
24
+
</a>
25
+
<div>
26
+
<span class="text-sm text-gray-500 dark:text-gray-400">
27
+
{{ .Role }}
28
+
</span>
29
+
</div>
30
+
</div>
31
{{ end }}
32
+
</div>
33
34
+
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
35
<form
36
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"
37
+
class="group"
38
>
39
+
<label for="collaborator" class="dark:text-white">
40
+
add collaborator
41
+
</label>
42
+
<input
43
+
type="text"
44
+
id="collaborator"
45
+
name="collaborator"
46
+
required
47
+
class="dark:bg-gray-700 dark:text-white"
48
+
placeholder="enter did or handle">
49
+
<button class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700" type="text">
50
+
<span>add</span>
51
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
52
+
</button>
53
</form>
54
+
{{ end }}
55
+
{{ end }}
56
57
+
{{ define "dangerZone" }}
58
+
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
59
<form
60
hx-confirm="Are you sure you want to delete this repository?"
61
hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete"
62
class="mt-6"
63
+
hx-indicator="#delete-repo-spinner">
64
+
<label for="branch">delete repository</label>
65
+
<button class="btn my-2 flex items-center" type="text">
66
+
<span>delete</span>
67
+
<span id="delete-repo-spinner" class="group">
68
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
69
+
</span>
70
+
</button>
71
+
<span>
72
+
Deleting a repository is irreversible and permanent.
73
+
</span>
74
</form>
75
+
{{ end }}
76
+
{{ end }}
77
+
78
+
{{ define "branchSettings" }}
79
+
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="mt-6 group">
80
+
<label for="branch">default branch</label>
81
+
<div class="flex gap-2 items-center">
82
+
<select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
83
+
<option value="" disabled selected >
84
+
Choose a default branch
85
+
</option>
86
+
{{ range .Branches }}
87
+
<option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} >
88
+
{{ .Name }}
89
+
</option>
90
+
{{ end }}
91
+
</select>
92
+
<button class="btn my-2 flex gap-2 items-center" type="submit">
93
+
<span>save</span>
94
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
95
+
</button>
96
+
</div>
97
+
</form>
98
+
{{ end }}
99
+
100
+
{{ define "spindleSelector" }}
101
+
{{ if .RepoInfo.Roles.IsOwner }}
102
+
<form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="mt-6 group" >
103
+
<label for="spindle">spindle</label>
104
+
<div class="flex gap-2 items-center">
105
+
<select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
106
+
<option value="" selected >
107
+
None
108
+
</option>
109
+
{{ range .Spindles }}
110
+
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
111
+
{{ . }}
112
+
</option>
113
+
{{ end }}
114
+
</select>
115
+
<button class="btn my-2 flex gap-2 items-center" type="submit">
116
+
<span>save</span>
117
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
118
+
</button>
119
+
</div>
120
+
</form>
121
+
{{ end }}
122
+
{{ end }}
123
+
124
+
{{ define "spindleSecrets" }}
125
+
{{ if $.CurrentSpindle }}
126
+
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
127
+
Secrets
128
+
</header>
129
+
130
+
<div id="secret-list" class="flex flex-col gap-2 mb-2">
131
+
{{ range $idx, $secret := .Secrets }}
132
+
{{ with $secret }}
133
+
<div id="secret-{{$idx}}" class="mb-2">
134
+
{{ .Key }} created on {{ .CreatedAt }} by {{ .CreatedBy }}
135
+
</div>
136
+
{{ end }}
137
+
{{ end }}
138
+
</div>
139
+
<form
140
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets"
141
+
class="mt-6"
142
+
hx-indicator="#add-secret-spinner">
143
+
<label for="key">secret key</label>
144
+
<input
145
+
type="text"
146
+
id="key"
147
+
name="key"
148
+
required
149
+
class="dark:bg-gray-700 dark:text-white"
150
+
placeholder="SECRET_KEY" />
151
+
<label for="value">secret value</label>
152
+
<input
153
+
type="text"
154
+
id="value"
155
+
name="value"
156
+
required
157
+
class="dark:bg-gray-700 dark:text-white"
158
+
placeholder="SECRET VALUE" />
159
160
+
<button class="btn my-2 flex items-center" type="text">
161
+
<span>add</span>
162
+
<span id="add-secret-spinner" class="group">
163
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
164
+
</span>
165
+
</button>
166
+
</form>
167
+
{{ end }}
168
{{ end }}
+28
-30
appview/pages/templates/repo/tree.html
+28
-30
appview/pages/templates/repo/tree.html
···
11
{{ template "repo/fragments/meta" . }}
12
{{ $title := printf "%s at %s · %s" $path .Ref .RepoInfo.FullName }}
13
{{ $url := printf "https://tangled.sh/%s/tree/%s%s" .RepoInfo.FullName .Ref $path }}
14
-
15
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
16
{{ end }}
17
···
19
{{define "repoContent"}}
20
<main>
21
<div class="tree">
22
-
{{ $containerstyle := "py-1" }}
23
{{ $linkstyle := "no-underline hover:underline" }}
24
25
<div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
···
54
</div>
55
56
{{ range .Files }}
57
-
{{ if not .IsFile }}
58
-
<div class="{{ $containerstyle }}">
59
-
<div class="flex justify-between items-center">
60
-
<a href="/{{ $.BaseTreeLink }}/{{ .Name }}" class="{{ $linkstyle }}">
61
-
<div class="flex items-center gap-2">
62
-
{{ i "folder" "size-4 fill-current" }}{{ .Name }}
63
-
</div>
64
-
</a>
65
-
{{ if .LastCommit}}
66
-
<time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time>
67
-
{{ end }}
68
</div>
69
-
</div>
70
-
{{ end }}
71
-
{{ end }}
72
73
-
{{ range .Files }}
74
-
{{ if .IsFile }}
75
-
<div class="{{ $containerstyle }}">
76
-
<div class="flex justify-between items-center">
77
-
<a href="/{{ $.BaseBlobLink }}/{{ .Name }}" class="{{ $linkstyle }}">
78
-
<div class="flex items-center gap-2">
79
-
{{ i "file" "size-4" }}{{ .Name }}
80
-
</div>
81
-
</a>
82
-
{{ if .LastCommit}}
83
-
<time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time>
84
-
{{ end }}
85
</div>
86
-
</div>
87
{{ end }}
88
-
{{ end }}
89
</div>
90
</main>
91
{{end}}
···
11
{{ template "repo/fragments/meta" . }}
12
{{ $title := printf "%s at %s · %s" $path .Ref .RepoInfo.FullName }}
13
{{ $url := printf "https://tangled.sh/%s/tree/%s%s" .RepoInfo.FullName .Ref $path }}
14
+
15
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
16
{{ end }}
17
···
19
{{define "repoContent"}}
20
<main>
21
<div class="tree">
22
{{ $linkstyle := "no-underline hover:underline" }}
23
24
<div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
···
53
</div>
54
55
{{ range .Files }}
56
+
<div class="grid grid-cols-12 gap-4 items-center py-1">
57
+
<div class="col-span-6 md:col-span-3">
58
+
{{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) $.TreePath .Name }}
59
+
{{ $icon := "folder" }}
60
+
{{ $iconStyle := "size-4 fill-current" }}
61
+
62
+
{{ if .IsFile }}
63
+
{{ $icon = "file" }}
64
+
{{ $iconStyle = "size-4" }}
65
+
{{ end }}
66
+
<a href="{{ $link }}" class="{{ $linkstyle }}">
67
+
<div class="flex items-center gap-2">
68
+
{{ i $icon $iconStyle }}{{ .Name }}
69
+
</div>
70
+
</a>
71
</div>
72
+
73
+
<div class="col-span-0 md:col-span-7 hidden md:block overflow-hidden">
74
+
{{ with .LastCommit }}
75
+
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400 block truncate">{{ .Message }}</a>
76
+
{{ end }}
77
+
</div>
78
79
+
<div class="col-span-6 md:col-span-2 text-right">
80
+
{{ with .LastCommit }}
81
+
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a>
82
+
{{ end }}
83
</div>
84
+
</div>
85
{{ end }}
86
+
87
</div>
88
</main>
89
{{end}}
+2
-2
appview/pages/templates/settings.html
+2
-2
appview/pages/templates/settings.html
···
39
{{ i "key" "w-3 h-3 dark:text-gray-300" }}
40
<p class="font-bold dark:text-white">{{ .Name }}</p>
41
</div>
42
-
<p class="text-sm text-gray-500 dark:text-gray-400">added {{ .Created | timeFmt }}</p>
43
<div class="overflow-x-auto whitespace-nowrap flex-1 max-w-full">
44
<code class="text-sm text-gray-500 dark:text-gray-400">{{ .Key }}</code>
45
</div>
···
112
{{ end }}
113
</div>
114
</div>
115
-
<p class="text-sm text-gray-500 dark:text-gray-400">added {{ .CreatedAt | timeFmt }}</p>
116
</div>
117
<div class="flex gap-2 items-center">
118
{{ if not .Verified }}
···
39
{{ i "key" "w-3 h-3 dark:text-gray-300" }}
40
<p class="font-bold dark:text-white">{{ .Name }}</p>
41
</div>
42
+
<p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .Created }}</p>
43
<div class="overflow-x-auto whitespace-nowrap flex-1 max-w-full">
44
<code class="text-sm text-gray-500 dark:text-gray-400">{{ .Key }}</code>
45
</div>
···
112
{{ end }}
113
</div>
114
</div>
115
+
<p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .CreatedAt }}</p>
116
</div>
117
<div class="flex gap-2 items-center">
118
{{ if not .Verified }}
+1
-1
appview/pages/templates/spindles/fragments/addMemberModal.html
+1
-1
appview/pages/templates/spindles/fragments/addMemberModal.html
···
13
<div
14
id="add-member-{{ .Instance }}"
15
popover
16
+
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
17
{{ block "addMemberPopover" . }} {{ end }}
18
</div>
19
{{ end }}
+2
-2
appview/pages/templates/spindles/fragments/spindleListing.html
+2
-2
appview/pages/templates/spindles/fragments/spindleListing.html
···
11
{{ i "hard-drive" "w-4 h-4" }}
12
{{ .Instance }}
13
<span class="text-gray-500">
14
-
{{ .Created | shortTimeFmt }} ago
15
</span>
16
</a>
17
{{ else }}
···
19
{{ i "hard-drive" "w-4 h-4" }}
20
{{ .Instance }}
21
<span class="text-gray-500">
22
-
{{ .Created | shortTimeFmt }} ago
23
</span>
24
</div>
25
{{ end }}
···
11
{{ i "hard-drive" "w-4 h-4" }}
12
{{ .Instance }}
13
<span class="text-gray-500">
14
+
{{ template "repo/fragments/shortTimeAgo" .Created }}
15
</span>
16
</a>
17
{{ else }}
···
19
{{ i "hard-drive" "w-4 h-4" }}
20
{{ .Instance }}
21
<span class="text-gray-500">
22
+
{{ template "repo/fragments/shortTimeAgo" .Created }}
23
</span>
24
</div>
25
{{ end }}
+14
-2
appview/pages/templates/spindles/index.html
+14
-2
appview/pages/templates/spindles/index.html
···
7
8
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
9
<div class="flex flex-col gap-6">
10
-
{{ block "all" . }} {{ end }}
11
{{ block "register" . }} {{ end }}
12
</div>
13
</section>
14
{{ end }}
15
16
-
{{ define "all" }}
17
<section class="rounded w-full flex flex-col gap-2">
18
<h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your spindles</h2>
19
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full">
···
7
8
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
9
<div class="flex flex-col gap-6">
10
+
{{ block "about" . }} {{ end }}
11
+
{{ block "list" . }} {{ end }}
12
{{ block "register" . }} {{ end }}
13
</div>
14
</section>
15
{{ end }}
16
17
+
{{ define "about" }}
18
+
<section class="rounded flex flex-col gap-2">
19
+
<p class="dark:text-gray-300">
20
+
Spindles are small CI runners.
21
+
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">
22
+
Checkout the documentation if you're interested in self-hosting.
23
+
</a>
24
+
</p>
25
+
</section>
26
+
{{ end }}
27
+
28
+
{{ define "list" }}
29
<section class="rounded w-full flex flex-col gap-2">
30
<h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your spindles</h2>
31
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full">
+106
-75
appview/pages/templates/timeline.html
+106
-75
appview/pages/templates/timeline.html
···
49
<p class="text-xl font-bold dark:text-white">Timeline</p>
50
</div>
51
52
-
<div class="flex flex-col gap-3 relative">
53
-
<div
54
-
class="absolute left-8 top-0 bottom-0 w-px bg-gray-300 dark:bg-gray-600"
55
-
></div>
56
-
{{ range .Timeline }}
57
-
<div
58
-
class="px-6 py-2 bg-white dark:bg-gray-800 rounded drop-shadow-sm w-fit"
59
-
>
60
-
{{ if .Repo }}
61
-
{{ $userHandle := index $.DidHandleMap .Repo.Did }}
62
-
<div class="flex items-center">
63
-
<p class="text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2">
64
-
{{ template "user/fragments/picHandle" $userHandle }}
65
-
{{ if .Source }}
66
-
forked
67
-
<a
68
-
href="/{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}"
69
-
class="no-underline hover:underline"
70
-
>
71
-
{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}</a
72
-
>
73
-
to
74
-
<a
75
-
href="/{{ $userHandle }}/{{ .Repo.Name }}"
76
-
class="no-underline hover:underline"
77
-
>{{ .Repo.Name }}</a
78
-
>
79
-
{{ else }}
80
-
created
81
-
<a
82
-
href="/{{ $userHandle }}/{{ .Repo.Name }}"
83
-
class="no-underline hover:underline"
84
-
>{{ .Repo.Name }}</a
85
-
>
86
-
{{ end }}
87
-
<time
88
-
class="text-gray-700 dark:text-gray-400 text-xs"
89
-
>{{ .Repo.Created | timeFmt }}</time
90
-
>
91
-
</p>
92
-
</div>
93
-
{{ else if .Follow }}
94
-
{{ $userHandle := index $.DidHandleMap .Follow.UserDid }}
95
-
{{ $subjectHandle := index $.DidHandleMap .Follow.SubjectDid }}
96
-
<div class="flex items-center">
97
-
<p class="text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2">
98
-
{{ template "user/fragments/picHandle" $userHandle }}
99
-
followed
100
-
{{ template "user/fragments/picHandle" $subjectHandle }}
101
-
<time
102
-
class="text-gray-700 dark:text-gray-400 text-xs"
103
-
>{{ .Follow.FollowedAt | timeFmt }}</time
104
-
>
105
-
</p>
106
-
</div>
107
-
{{ else if .Star }}
108
-
{{ $starrerHandle := index $.DidHandleMap .Star.StarredByDid }}
109
-
{{ $repoOwnerHandle := index $.DidHandleMap .Star.Repo.Did }}
110
-
<div class="flex items-center">
111
-
<p class="text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2">
112
-
{{ template "user/fragments/picHandle" $starrerHandle }}
113
-
starred
114
-
<a
115
-
href="/{{ $repoOwnerHandle }}/{{ .Star.Repo.Name }}"
116
-
class="no-underline hover:underline"
117
-
>{{ $repoOwnerHandle | truncateAt30 }}/{{ .Star.Repo.Name }}</a
118
-
>
119
-
<time
120
-
class="text-gray-700 dark:text-gray-400 text-xs"
121
-
>{{ .Star.Created | timeFmt }}</time
122
-
>
123
-
</p>
124
-
</div>
125
-
{{ end }}
126
</div>
127
-
{{ end }}
128
</div>
129
</div>
130
{{ end }}
···
49
<p class="text-xl font-bold dark:text-white">Timeline</p>
50
</div>
51
52
+
<div class="flex flex-col gap-4">
53
+
{{ range $i, $e := .Timeline }}
54
+
<div class="relative">
55
+
{{ if ne $i 0 }}
56
+
<div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div>
57
+
{{ end }}
58
+
{{ with $e }}
59
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
60
+
{{ if .Repo }}
61
+
{{ block "repoEvent" (list $ .Repo .Source) }} {{ end }}
62
+
{{ else if .Star }}
63
+
{{ block "starEvent" (list $ .Star) }} {{ end }}
64
+
{{ else if .Follow }}
65
+
{{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }}
66
+
{{ end }}
67
</div>
68
+
{{ end }}
69
+
</div>
70
+
{{ end }}
71
</div>
72
</div>
73
{{ end }}
74
+
75
+
{{ define "repoEvent" }}
76
+
{{ $root := index . 0 }}
77
+
{{ $repo := index . 1 }}
78
+
{{ $source := index . 2 }}
79
+
{{ $userHandle := index $root.DidHandleMap $repo.Did }}
80
+
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
81
+
{{ template "user/fragments/picHandleLink" $userHandle }}
82
+
{{ with $source }}
83
+
forked
84
+
<a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}"class="no-underline hover:underline">
85
+
{{ index $root.DidHandleMap .Did }}/{{ .Name }}
86
+
</a>
87
+
to
88
+
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a>
89
+
{{ else }}
90
+
created
91
+
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">
92
+
{{ $repo.Name }}
93
+
</a>
94
+
{{ end }}
95
+
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
96
+
</div>
97
+
{{ with $repo }}
98
+
{{ template "user/fragments/repoCard" (list $root . true) }}
99
+
{{ end }}
100
+
{{ end }}
101
+
102
+
{{ define "starEvent" }}
103
+
{{ $root := index . 0 }}
104
+
{{ $star := index . 1 }}
105
+
{{ with $star }}
106
+
{{ $starrerHandle := index $root.DidHandleMap .StarredByDid }}
107
+
{{ $repoOwnerHandle := index $root.DidHandleMap .Repo.Did }}
108
+
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
109
+
{{ template "user/fragments/picHandleLink" $starrerHandle }}
110
+
starred
111
+
<a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">
112
+
{{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }}
113
+
</a>
114
+
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
115
+
</div>
116
+
{{ with .Repo }}
117
+
{{ template "user/fragments/repoCard" (list $root . true) }}
118
+
{{ end }}
119
+
{{ end }}
120
+
{{ end }}
121
+
122
+
123
+
{{ define "followEvent" }}
124
+
{{ $root := index . 0 }}
125
+
{{ $follow := index . 1 }}
126
+
{{ $profile := index . 2 }}
127
+
{{ $stat := index . 3 }}
128
+
129
+
{{ $userHandle := index $root.DidHandleMap $follow.UserDid }}
130
+
{{ $subjectHandle := index $root.DidHandleMap $follow.SubjectDid }}
131
+
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
132
+
{{ template "user/fragments/picHandleLink" $userHandle }}
133
+
followed
134
+
{{ template "user/fragments/picHandleLink" $subjectHandle }}
135
+
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
136
+
</div>
137
+
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
138
+
<div class="flex-shrink-0 max-h-full w-24 h-24">
139
+
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
140
+
</div>
141
+
142
+
<div class="flex-1 min-h-0 justify-around flex flex-col">
143
+
<a href="/{{ $subjectHandle }}">
144
+
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
145
+
</a>
146
+
{{ with $profile }}
147
+
{{ with .Description }}
148
+
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
149
+
{{ end }}
150
+
{{ end }}
151
+
{{ with $stat }}
152
+
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
153
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
154
+
<span id="followers">{{ .Followers }} followers</span>
155
+
<span class="select-none after:content-['ยท']"></span>
156
+
<span id="following">{{ .Following }} following</span>
157
+
</div>
158
+
{{ end }}
159
+
</div>
160
+
</div>
161
+
{{ end }}
+104
appview/pages/templates/user/completeSignup.html
+104
appview/pages/templates/user/completeSignup.html
···
···
1
+
{{ define "user/completeSignup" }}
2
+
<!doctype html>
3
+
<html lang="en" class="dark:bg-gray-900">
4
+
<head>
5
+
<meta charset="UTF-8" />
6
+
<meta
7
+
name="viewport"
8
+
content="width=device-width, initial-scale=1.0"
9
+
/>
10
+
<meta
11
+
property="og:title"
12
+
content="complete signup ยท tangled"
13
+
/>
14
+
<meta
15
+
property="og:url"
16
+
content="https://tangled.sh/complete-signup"
17
+
/>
18
+
<meta
19
+
property="og:description"
20
+
content="complete your signup for tangled"
21
+
/>
22
+
<script src="/static/htmx.min.js"></script>
23
+
<link
24
+
rel="stylesheet"
25
+
href="/static/tw.css?{{ cssContentHash }}"
26
+
type="text/css"
27
+
/>
28
+
<title>complete signup · tangled</title>
29
+
</head>
30
+
<body class="flex items-center justify-center min-h-screen">
31
+
<main class="max-w-md px-6 -mt-4">
32
+
<h1
33
+
class="text-center text-2xl font-semibold italic dark:text-white"
34
+
>
35
+
tangled
36
+
</h1>
37
+
<h2 class="text-center text-xl italic dark:text-white">
38
+
tightly-knit social coding.
39
+
</h2>
40
+
<form
41
+
class="mt-4 max-w-sm mx-auto"
42
+
hx-post="/signup/complete"
43
+
hx-swap="none"
44
+
hx-disabled-elt="#complete-signup-button"
45
+
>
46
+
<div class="flex flex-col">
47
+
<label for="code">verification code</label>
48
+
<input
49
+
type="text"
50
+
id="code"
51
+
name="code"
52
+
tabindex="1"
53
+
required
54
+
placeholder="tngl-sh-foo-bar"
55
+
/>
56
+
<span class="text-sm text-gray-500 mt-1">
57
+
Enter the code sent to your email.
58
+
</span>
59
+
</div>
60
+
61
+
<div class="flex flex-col mt-4">
62
+
<label for="username">desired username</label>
63
+
<input
64
+
type="text"
65
+
id="username"
66
+
name="username"
67
+
tabindex="2"
68
+
required
69
+
placeholder="jason"
70
+
/>
71
+
<span class="text-sm text-gray-500 mt-1">
72
+
Your complete handle will be of the form <code>user.tngl.sh</code>.
73
+
</span>
74
+
</div>
75
+
76
+
<div class="flex flex-col mt-4">
77
+
<label for="password">password</label>
78
+
<input
79
+
type="password"
80
+
id="password"
81
+
name="password"
82
+
tabindex="3"
83
+
required
84
+
/>
85
+
<span class="text-sm text-gray-500 mt-1">
86
+
Choose a strong password for your account.
87
+
</span>
88
+
</div>
89
+
90
+
<button
91
+
class="btn-create w-full my-2 mt-6"
92
+
type="submit"
93
+
id="complete-signup-button"
94
+
tabindex="4"
95
+
>
96
+
<span>complete signup</span>
97
+
</button>
98
+
</form>
99
+
<p id="signup-error" class="error w-full"></p>
100
+
<p id="signup-msg" class="dark:text-white w-full"></p>
101
+
</main>
102
+
</body>
103
+
</html>
104
+
{{ end }}
+6
-8
appview/pages/templates/user/fragments/picHandle.html
+6
-8
appview/pages/templates/user/fragments/picHandle.html
+5
appview/pages/templates/user/fragments/picHandleLink.html
+5
appview/pages/templates/user/fragments/picHandleLink.html
+57
appview/pages/templates/user/fragments/repoCard.html
+57
appview/pages/templates/user/fragments/repoCard.html
···
···
1
+
{{ define "user/fragments/repoCard" }}
2
+
{{ $root := index . 0 }}
3
+
{{ $repo := index . 1 }}
4
+
{{ $fullName := index . 2 }}
5
+
6
+
{{ with $repo }}
7
+
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
8
+
<div class="font-medium dark:text-white flex gap-2 items-center">
9
+
{{- if $fullName -}}
10
+
<a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}">{{ index $root.DidHandleMap .Did }}/{{ .Name }}</a>
11
+
{{- else -}}
12
+
<a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}">{{ .Name }}</a>
13
+
{{- end -}}
14
+
</div>
15
+
{{ with .Description }}
16
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
17
+
{{ . }}
18
+
</div>
19
+
{{ end }}
20
+
21
+
{{ if .RepoStats }}
22
+
{{ block "repoStats" .RepoStats }} {{ end }}
23
+
{{ end }}
24
+
</div>
25
+
{{ end }}
26
+
{{ end }}
27
+
28
+
{{ define "repoStats" }}
29
+
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-4 mt-auto">
30
+
{{ with .Language }}
31
+
<div class="flex gap-2 items-center text-sm">
32
+
<div class="size-2 rounded-full" style="background-color: {{ langColor . }};"></div>
33
+
<span>{{ . }}</span>
34
+
</div>
35
+
{{ end }}
36
+
{{ with .StarCount }}
37
+
<div class="flex gap-1 items-center text-sm">
38
+
{{ i "star" "w-3 h-3 fill-current" }}
39
+
<span>{{ . }}</span>
40
+
</div>
41
+
{{ end }}
42
+
{{ with .IssueCount.Open }}
43
+
<div class="flex gap-1 items-center text-sm">
44
+
{{ i "circle-dot" "w-3 h-3" }}
45
+
<span>{{ . }}</span>
46
+
</div>
47
+
{{ end }}
48
+
{{ with .PullCount.Open }}
49
+
<div class="flex gap-1 items-center text-sm">
50
+
{{ i "git-pull-request" "w-3 h-3" }}
51
+
<span>{{ . }}</span>
52
+
</div>
53
+
{{ end }}
54
+
</div>
55
+
{{ end }}
56
+
57
+
+54
-7
appview/pages/templates/user/login.html
+54
-7
appview/pages/templates/user/login.html
···
17
/>
18
<meta
19
property="og:description"
20
-
content="login to tangled"
21
/>
22
<script src="/static/htmx.min.js"></script>
23
<link
···
25
href="/static/tw.css?{{ cssContentHash }}"
26
type="text/css"
27
/>
28
-
<title>login · tangled</title>
29
</head>
30
<body class="flex items-center justify-center min-h-screen">
31
<main class="max-w-md px-6 -mt-4">
···
51
name="handle"
52
tabindex="1"
53
required
54
/>
55
<span class="text-sm text-gray-500 mt-1">
56
-
Use your
57
-
<a href="https://bsky.app">Bluesky</a> handle to log
58
-
in. You will then be redirected to your PDS to
59
-
complete authentication.
60
</span>
61
</div>
62
···
69
<span>login</span>
70
</button>
71
</form>
72
-
<p class="text-sm text-gray-500">
73
Join our <a href="https://chat.tangled.sh">Discord</a> or
74
IRC channel:
75
<a href="https://web.libera.chat/#tangled"
···
17
/>
18
<meta
19
property="og:description"
20
+
content="login to or sign up for tangled"
21
/>
22
<script src="/static/htmx.min.js"></script>
23
<link
···
25
href="/static/tw.css?{{ cssContentHash }}"
26
type="text/css"
27
/>
28
+
<title>login or sign up · tangled</title>
29
</head>
30
<body class="flex items-center justify-center min-h-screen">
31
<main class="max-w-md px-6 -mt-4">
···
51
name="handle"
52
tabindex="1"
53
required
54
+
placeholder="foo.tngl.sh"
55
/>
56
<span class="text-sm text-gray-500 mt-1">
57
+
Use your <a href="https://atproto.com">ATProto</a>
58
+
handle to log in. If you're unsure, this is likely
59
+
your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account.
60
</span>
61
</div>
62
···
69
<span>login</span>
70
</button>
71
</form>
72
+
<hr class="my-4">
73
+
<p class="text-sm text-gray-500 mt-4">
74
+
Alternatively, you may create an account on Tangled below. You will
75
+
get a <code>user.tngl.sh</code> handle.
76
+
</p>
77
+
78
+
<details class="group">
79
+
80
+
<summary
81
+
class="btn cursor-pointer w-full mt-4 flex items-center justify-center gap-2"
82
+
>
83
+
create an account
84
+
85
+
<div class="group-open:hidden flex">{{ i "arrow-right" "w-4 h-4" }}</div>
86
+
<div class="hidden group-open:flex">{{ i "arrow-down" "w-4 h-4" }}</div>
87
+
</summary>
88
+
<form
89
+
class="mt-4 max-w-sm mx-auto"
90
+
hx-post="/signup"
91
+
hx-swap="none"
92
+
hx-disabled-elt="#signup-button"
93
+
>
94
+
<div class="flex flex-col mt-2">
95
+
<label for="email">email</label>
96
+
<input
97
+
type="email"
98
+
id="email"
99
+
name="email"
100
+
tabindex="4"
101
+
required
102
+
placeholder="jason@bourne.co"
103
+
/>
104
+
</div>
105
+
<span class="text-sm text-gray-500 mt-1">
106
+
You will receive an email with a code. Enter that, along with your
107
+
desired username and password in the next page to complete your registration.
108
+
</span>
109
+
<button
110
+
class="btn w-full my-2 mt-6"
111
+
type="submit"
112
+
id="signup-button"
113
+
tabindex="7"
114
+
>
115
+
<span>sign up</span>
116
+
</button>
117
+
</form>
118
+
</details>
119
+
<p class="text-sm text-gray-500 mt-6">
120
Join our <a href="https://chat.tangled.sh">Discord</a> or
121
IRC channel:
122
<a href="https://web.libera.chat/#tangled"
+7
-49
appview/pages/templates/user/profile.html
+7
-49
appview/pages/templates/user/profile.html
···
8
{{ end }}
9
10
{{ define "content" }}
11
-
<div class="grid grid-cols-1 md:grid-cols-8 gap-4">
12
-
<div class="md:col-span-2 order-1 md:order-1">
13
<div class="grid grid-cols-1 gap-4">
14
{{ template "user/fragments/profileCard" .Card }}
15
{{ block "punchcard" .Punchcard }} {{ end }}
16
</div>
17
</div>
18
-
<div id="all-repos" class="md:col-span-3 order-2 md:order-2">
19
<div class="grid grid-cols-1 gap-4">
20
{{ block "ownRepos" . }}{{ end }}
21
{{ block "collaboratingRepos" . }}{{ end }}
22
</div>
23
</div>
24
-
<div class="md:col-span-3 order-3 md:order-3">
25
{{ block "profileTimeline" . }}{{ end }}
26
</div>
27
</div>
···
258
</button>
259
{{ end }}
260
</div>
261
-
<div id="repos" class="grid grid-cols-1 gap-4">
262
{{ range .Repos }}
263
-
<div
264
-
id="repo-card"
265
-
class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
266
-
<div id="repo-card-name" class="font-medium">
267
-
<a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}/{{ .Name }}"
268
-
>{{ .Name }}</a
269
-
>
270
-
</div>
271
-
{{ if .Description }}
272
-
<div class="text-gray-600 dark:text-gray-300 text-sm">
273
-
{{ .Description }}
274
-
</div>
275
-
{{ end }}
276
-
<div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto">
277
-
{{ if .RepoStats.StarCount }}
278
-
<div class="flex gap-1 items-center text-sm">
279
-
{{ i "star" "w-3 h-3 fill-current" }}
280
-
<span>{{ .RepoStats.StarCount }}</span>
281
-
</div>
282
-
{{ end }}
283
-
</div>
284
-
</div>
285
{{ else }}
286
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
287
{{ end }}
···
295
<p class="text-sm font-bold p-2 dark:text-white">COLLABORATING ON</p>
296
<div id="collaborating" class="grid grid-cols-1 gap-4">
297
{{ range .CollaboratingRepos }}
298
-
<div
299
-
id="repo-card"
300
-
class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
301
-
<div id="repo-card-name" class="font-medium dark:text-white">
302
-
<a href="/{{ index $.DidHandleMap .Did }}/{{ .Name }}">
303
-
{{ index $.DidHandleMap .Did }}/{{ .Name }}
304
-
</a>
305
-
</div>
306
-
{{ if .Description }}
307
-
<div class="text-gray-600 dark:text-gray-300 text-sm">
308
-
{{ .Description }}
309
-
</div>
310
-
{{ end }}
311
-
<div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto">
312
-
{{ if .RepoStats.StarCount }}
313
-
<div class="flex gap-1 items-center text-sm">
314
-
{{ i "star" "w-3 h-3 fill-current" }}
315
-
<span>{{ .RepoStats.StarCount }}</span>
316
-
</div>
317
-
{{ end }}
318
-
</div>
319
-
</div>
320
{{ else }}
321
<p class="px-6 dark:text-white">This user is not collaborating.</p>
322
{{ end }}
···
8
{{ end }}
9
10
{{ define "content" }}
11
+
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
12
+
<div class="md:col-span-3 order-1 md:order-1">
13
<div class="grid grid-cols-1 gap-4">
14
{{ template "user/fragments/profileCard" .Card }}
15
{{ block "punchcard" .Punchcard }} {{ end }}
16
</div>
17
</div>
18
+
<div id="all-repos" class="md:col-span-4 order-2 md:order-2">
19
<div class="grid grid-cols-1 gap-4">
20
{{ block "ownRepos" . }}{{ end }}
21
{{ block "collaboratingRepos" . }}{{ end }}
22
</div>
23
</div>
24
+
<div class="md:col-span-4 order-3 md:order-3">
25
{{ block "profileTimeline" . }}{{ end }}
26
</div>
27
</div>
···
258
</button>
259
{{ end }}
260
</div>
261
+
<div id="repos" class="grid grid-cols-1 gap-4 items-stretch">
262
{{ range .Repos }}
263
+
{{ template "user/fragments/repoCard" (list $ . false) }}
264
{{ else }}
265
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
266
{{ end }}
···
274
<p class="text-sm font-bold p-2 dark:text-white">COLLABORATING ON</p>
275
<div id="collaborating" class="grid grid-cols-1 gap-4">
276
{{ range .CollaboratingRepos }}
277
+
{{ template "user/fragments/repoCard" (list $ . true) }}
278
{{ else }}
279
<p class="px-6 dark:text-white">This user is not collaborating.</p>
280
{{ end }}
+4
-25
appview/pages/templates/user/repos.html
+4
-25
appview/pages/templates/user/repos.html
···
8
{{ end }}
9
10
{{ define "content" }}
11
-
<div class="grid grid-cols-1 md:grid-cols-8 gap-4">
12
-
<div class="md:col-span-2 order-1 md:order-1">
13
{{ template "user/fragments/profileCard" .Card }}
14
</div>
15
-
<div id="all-repos" class="md:col-span-6 order-2 md:order-2">
16
{{ block "ownRepos" . }}{{ end }}
17
</div>
18
</div>
···
22
<p class="text-sm font-bold p-2 dark:text-white">ALL REPOSITORIES</p>
23
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
24
{{ range .Repos }}
25
-
<div
26
-
id="repo-card"
27
-
class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
28
-
<div id="repo-card-name" class="font-medium">
29
-
<a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}/{{ .Name }}"
30
-
>{{ .Name }}</a
31
-
>
32
-
</div>
33
-
{{ if .Description }}
34
-
<div class="text-gray-600 dark:text-gray-300 text-sm">
35
-
{{ .Description }}
36
-
</div>
37
-
{{ end }}
38
-
<div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto">
39
-
{{ if .RepoStats.StarCount }}
40
-
<div class="flex gap-1 items-center text-sm">
41
-
{{ i "star" "w-3 h-3 fill-current" }}
42
-
<span>{{ .RepoStats.StarCount }}</span>
43
-
</div>
44
-
{{ end }}
45
-
</div>
46
-
</div>
47
{{ else }}
48
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
49
{{ end }}
···
8
{{ end }}
9
10
{{ define "content" }}
11
+
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
12
+
<div class="md:col-span-3 order-1 md:order-1">
13
{{ template "user/fragments/profileCard" .Card }}
14
</div>
15
+
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
16
{{ block "ownRepos" . }}{{ end }}
17
</div>
18
</div>
···
22
<p class="text-sm font-bold p-2 dark:text-white">ALL REPOSITORIES</p>
23
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
24
{{ range .Repos }}
25
+
{{ template "user/fragments/repoCard" (list $ . false) }}
26
{{ else }}
27
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
28
{{ end }}
+1
-5
appview/pipelines/pipelines.go
+1
-5
appview/pipelines/pipelines.go
···
11
12
"tangled.sh/tangled.sh/core/appview/config"
13
"tangled.sh/tangled.sh/core/appview/db"
14
-
"tangled.sh/tangled.sh/core/appview/idresolver"
15
"tangled.sh/tangled.sh/core/appview/oauth"
16
"tangled.sh/tangled.sh/core/appview/pages"
17
"tangled.sh/tangled.sh/core/appview/reporesolver"
18
"tangled.sh/tangled.sh/core/eventconsumer"
19
"tangled.sh/tangled.sh/core/log"
20
"tangled.sh/tangled.sh/core/rbac"
21
spindlemodel "tangled.sh/tangled.sh/core/spindle/models"
22
23
"github.com/go-chi/chi/v5"
24
"github.com/gorilla/websocket"
25
-
"github.com/posthog/posthog-go"
26
)
27
28
type Pipelines struct {
···
34
spindlestream *eventconsumer.Consumer
35
db *db.DB
36
enforcer *rbac.Enforcer
37
-
posthog posthog.Client
38
logger *slog.Logger
39
}
40
···
46
idResolver *idresolver.Resolver,
47
db *db.DB,
48
config *config.Config,
49
-
posthog posthog.Client,
50
enforcer *rbac.Enforcer,
51
) *Pipelines {
52
logger := log.New("pipelines")
···
58
config: config,
59
spindlestream: spindlestream,
60
db: db,
61
-
posthog: posthog,
62
enforcer: enforcer,
63
logger: logger,
64
}
···
11
12
"tangled.sh/tangled.sh/core/appview/config"
13
"tangled.sh/tangled.sh/core/appview/db"
14
"tangled.sh/tangled.sh/core/appview/oauth"
15
"tangled.sh/tangled.sh/core/appview/pages"
16
"tangled.sh/tangled.sh/core/appview/reporesolver"
17
"tangled.sh/tangled.sh/core/eventconsumer"
18
+
"tangled.sh/tangled.sh/core/idresolver"
19
"tangled.sh/tangled.sh/core/log"
20
"tangled.sh/tangled.sh/core/rbac"
21
spindlemodel "tangled.sh/tangled.sh/core/spindle/models"
22
23
"github.com/go-chi/chi/v5"
24
"github.com/gorilla/websocket"
25
)
26
27
type Pipelines struct {
···
33
spindlestream *eventconsumer.Consumer
34
db *db.DB
35
enforcer *rbac.Enforcer
36
logger *slog.Logger
37
}
38
···
44
idResolver *idresolver.Resolver,
45
db *db.DB,
46
config *config.Config,
47
enforcer *rbac.Enforcer,
48
) *Pipelines {
49
logger := log.New("pipelines")
···
55
config: config,
56
spindlestream: spindlestream,
57
db: db,
58
enforcer: enforcer,
59
logger: logger,
60
}
+131
appview/posthog/notifier.go
+131
appview/posthog/notifier.go
···
···
1
+
package posthog_service
2
+
3
+
import (
4
+
"context"
5
+
"log"
6
+
7
+
"github.com/posthog/posthog-go"
8
+
"tangled.sh/tangled.sh/core/appview/db"
9
+
"tangled.sh/tangled.sh/core/appview/notify"
10
+
)
11
+
12
+
type posthogNotifier struct {
13
+
client posthog.Client
14
+
notify.BaseNotifier
15
+
}
16
+
17
+
func NewPosthogNotifier(client posthog.Client) notify.Notifier {
18
+
return &posthogNotifier{
19
+
client,
20
+
notify.BaseNotifier{},
21
+
}
22
+
}
23
+
24
+
var _ notify.Notifier = &posthogNotifier{}
25
+
26
+
func (n *posthogNotifier) NewRepo(ctx context.Context, repo *db.Repo) {
27
+
err := n.client.Enqueue(posthog.Capture{
28
+
DistinctId: repo.Did,
29
+
Event: "new_repo",
30
+
Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()},
31
+
})
32
+
if err != nil {
33
+
log.Println("failed to enqueue posthog event:", err)
34
+
}
35
+
}
36
+
37
+
func (n *posthogNotifier) NewStar(ctx context.Context, star *db.Star) {
38
+
err := n.client.Enqueue(posthog.Capture{
39
+
DistinctId: star.StarredByDid,
40
+
Event: "star",
41
+
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
42
+
})
43
+
if err != nil {
44
+
log.Println("failed to enqueue posthog event:", err)
45
+
}
46
+
}
47
+
48
+
func (n *posthogNotifier) DeleteStar(ctx context.Context, star *db.Star) {
49
+
err := n.client.Enqueue(posthog.Capture{
50
+
DistinctId: star.StarredByDid,
51
+
Event: "unstar",
52
+
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
53
+
})
54
+
if err != nil {
55
+
log.Println("failed to enqueue posthog event:", err)
56
+
}
57
+
}
58
+
59
+
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) {
60
+
err := n.client.Enqueue(posthog.Capture{
61
+
DistinctId: issue.OwnerDid,
62
+
Event: "new_issue",
63
+
Properties: posthog.Properties{
64
+
"repo_at": issue.RepoAt.String(),
65
+
"issue_id": issue.IssueId,
66
+
},
67
+
})
68
+
if err != nil {
69
+
log.Println("failed to enqueue posthog event:", err)
70
+
}
71
+
}
72
+
73
+
func (n *posthogNotifier) NewPull(ctx context.Context, pull *db.Pull) {
74
+
err := n.client.Enqueue(posthog.Capture{
75
+
DistinctId: pull.OwnerDid,
76
+
Event: "new_pull",
77
+
Properties: posthog.Properties{
78
+
"repo_at": pull.RepoAt,
79
+
"pull_id": pull.PullId,
80
+
},
81
+
})
82
+
if err != nil {
83
+
log.Println("failed to enqueue posthog event:", err)
84
+
}
85
+
}
86
+
87
+
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {
88
+
err := n.client.Enqueue(posthog.Capture{
89
+
DistinctId: comment.OwnerDid,
90
+
Event: "new_pull_comment",
91
+
Properties: posthog.Properties{
92
+
"repo_at": comment.RepoAt,
93
+
"pull_id": comment.PullId,
94
+
},
95
+
})
96
+
if err != nil {
97
+
log.Println("failed to enqueue posthog event:", err)
98
+
}
99
+
}
100
+
101
+
func (n *posthogNotifier) NewFollow(ctx context.Context, follow *db.Follow) {
102
+
err := n.client.Enqueue(posthog.Capture{
103
+
DistinctId: follow.UserDid,
104
+
Event: "follow",
105
+
Properties: posthog.Properties{"subject": follow.SubjectDid},
106
+
})
107
+
if err != nil {
108
+
log.Println("failed to enqueue posthog event:", err)
109
+
}
110
+
}
111
+
112
+
func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {
113
+
err := n.client.Enqueue(posthog.Capture{
114
+
DistinctId: follow.UserDid,
115
+
Event: "unfollow",
116
+
Properties: posthog.Properties{"subject": follow.SubjectDid},
117
+
})
118
+
if err != nil {
119
+
log.Println("failed to enqueue posthog event:", err)
120
+
}
121
+
}
122
+
123
+
func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {
124
+
err := n.client.Enqueue(posthog.Capture{
125
+
DistinctId: profile.Did,
126
+
Event: "edit_profile",
127
+
})
128
+
if err != nil {
129
+
log.Println("failed to enqueue posthog event:", err)
130
+
}
131
+
}
+71
-42
appview/pulls/pulls.go
+71
-42
appview/pulls/pulls.go
···
14
"time"
15
16
"tangled.sh/tangled.sh/core/api/tangled"
17
-
"tangled.sh/tangled.sh/core/appview"
18
"tangled.sh/tangled.sh/core/appview/config"
19
"tangled.sh/tangled.sh/core/appview/db"
20
-
"tangled.sh/tangled.sh/core/appview/idresolver"
21
"tangled.sh/tangled.sh/core/appview/oauth"
22
"tangled.sh/tangled.sh/core/appview/pages"
23
"tangled.sh/tangled.sh/core/appview/reporesolver"
24
"tangled.sh/tangled.sh/core/knotclient"
25
"tangled.sh/tangled.sh/core/patchutil"
26
"tangled.sh/tangled.sh/core/types"
27
28
"github.com/bluekeyes/go-gitdiff/gitdiff"
···
31
lexutil "github.com/bluesky-social/indigo/lex/util"
32
"github.com/go-chi/chi/v5"
33
"github.com/google/uuid"
34
-
"github.com/posthog/posthog-go"
35
)
36
37
type Pulls struct {
···
41
idResolver *idresolver.Resolver
42
db *db.DB
43
config *config.Config
44
-
posthog posthog.Client
45
}
46
47
func New(
···
51
resolver *idresolver.Resolver,
52
db *db.DB,
53
config *config.Config,
54
-
posthog posthog.Client,
55
) *Pulls {
56
return &Pulls{
57
oauth: oauth,
···
60
idResolver: resolver,
61
db: db,
62
config: config,
63
-
posthog: posthog,
64
}
65
}
66
···
198
m[p.Sha] = p
199
}
200
201
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
202
LoggedInUser: user,
203
RepoInfo: repoInfo,
···
208
MergeCheck: mergeCheckResponse,
209
ResubmitCheck: resubmitResult,
210
Pipelines: m,
211
})
212
}
213
···
340
return
341
}
342
343
pull, ok := r.Context().Value("pull").(*db.Pull)
344
if !ok {
345
log.Println("failed to get pull")
···
380
Round: roundIdInt,
381
Submission: pull.Submissions[roundIdInt],
382
Diff: &diff,
383
})
384
385
}
···
393
return
394
}
395
396
pull, ok := r.Context().Value("pull").(*db.Pull)
397
if !ok {
398
log.Println("failed to get pull")
···
448
Round: roundIdInt,
449
DidHandleMap: didHandleMap,
450
Interdiff: interdiff,
451
})
452
-
return
453
}
454
455
func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
···
529
530
// we want to group all stacked PRs into just one list
531
stacks := make(map[string]db.Stack)
532
n := 0
533
for _, p := range pulls {
534
// this PR is stacked
535
if p.StackId != "" {
536
// we have already seen this PR stack
···
549
}
550
pulls = pulls[:n]
551
552
identsToResolve := make([]string, len(pulls))
553
for i, pull := range pulls {
554
identsToResolve[i] = pull.OwnerDid
···
570
DidHandleMap: didHandleMap,
571
FilteringBy: state,
572
Stacks: stacks,
573
})
574
-
return
575
}
576
577
func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
···
642
atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
643
Collection: tangled.RepoPullCommentNSID,
644
Repo: user.Did,
645
-
Rkey: appview.TID(),
646
Record: &lexutil.LexiconTypeDecoder{
647
Val: &tangled.RepoPullComment{
648
Repo: &atUri,
···
659
return
660
}
661
662
-
// Create the pull comment in the database with the commentAt field
663
-
commentId, err := db.NewPullComment(tx, &db.PullComment{
664
OwnerDid: user.Did,
665
RepoAt: f.RepoAt.String(),
666
PullId: pull.PullId,
667
Body: body,
668
CommentAt: atResp.Uri,
669
SubmissionId: pull.Submissions[roundNumber].ID,
670
-
})
671
if err != nil {
672
log.Println("failed to create pull comment", err)
673
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
···
681
return
682
}
683
684
-
if !s.config.Core.Dev {
685
-
err = s.posthog.Enqueue(posthog.Capture{
686
-
DistinctId: user.Did,
687
-
Event: "new_pull_comment",
688
-
Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pull.PullId},
689
-
})
690
-
if err != nil {
691
-
log.Println("failed to enqueue posthog event:", err)
692
-
}
693
-
}
694
695
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
696
return
···
1019
body = formatPatches[0].Body
1020
}
1021
1022
-
rkey := appview.TID()
1023
initialSubmission := db.PullSubmission{
1024
Patch: patch,
1025
SourceRev: sourceRev,
1026
}
1027
-
err = db.NewPull(tx, &db.Pull{
1028
Title: title,
1029
Body: body,
1030
TargetBranch: targetBranch,
···
1035
&initialSubmission,
1036
},
1037
PullSource: pullSource,
1038
-
})
1039
if err != nil {
1040
log.Println("failed to create pull request", err)
1041
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
···
1075
return
1076
}
1077
1078
-
if !s.config.Core.Dev {
1079
-
err = s.posthog.Enqueue(posthog.Capture{
1080
-
DistinctId: user.Did,
1081
-
Event: "new_pull",
1082
-
Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pullId},
1083
-
})
1084
-
if err != nil {
1085
-
log.Println("failed to enqueue posthog event:", err)
1086
-
}
1087
-
}
1088
1089
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
1090
}
···
1647
}
1648
1649
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1650
-
return
1651
}
1652
1653
func (s *Pulls) resubmitStackedPullHelper(
···
1891
}
1892
1893
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1894
-
return
1895
}
1896
1897
func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
···
2015
2016
// auth filter: only owner or collaborators can close
2017
roles := f.RolesInRepo(user)
2018
isCollaborator := roles.IsCollaborator()
2019
isPullAuthor := user.Did == pull.OwnerDid
2020
-
isCloseAllowed := isCollaborator || isPullAuthor
2021
if !isCloseAllowed {
2022
log.Println("failed to close pull")
2023
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
···
2061
}
2062
2063
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2064
-
return
2065
}
2066
2067
func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) {
···
2083
2084
// auth filter: only owner or collaborators can close
2085
roles := f.RolesInRepo(user)
2086
isCollaborator := roles.IsCollaborator()
2087
isPullAuthor := user.Did == pull.OwnerDid
2088
-
isCloseAllowed := isCollaborator || isPullAuthor
2089
if !isCloseAllowed {
2090
log.Println("failed to close pull")
2091
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
···
2129
}
2130
2131
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2132
-
return
2133
}
2134
2135
func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) {
···
2155
2156
title := fp.Title
2157
body := fp.Body
2158
-
rkey := appview.TID()
2159
2160
initialSubmission := db.PullSubmission{
2161
Patch: fp.Raw,
···
14
"time"
15
16
"tangled.sh/tangled.sh/core/api/tangled"
17
"tangled.sh/tangled.sh/core/appview/config"
18
"tangled.sh/tangled.sh/core/appview/db"
19
+
"tangled.sh/tangled.sh/core/appview/notify"
20
"tangled.sh/tangled.sh/core/appview/oauth"
21
"tangled.sh/tangled.sh/core/appview/pages"
22
"tangled.sh/tangled.sh/core/appview/reporesolver"
23
+
"tangled.sh/tangled.sh/core/idresolver"
24
"tangled.sh/tangled.sh/core/knotclient"
25
"tangled.sh/tangled.sh/core/patchutil"
26
+
"tangled.sh/tangled.sh/core/tid"
27
"tangled.sh/tangled.sh/core/types"
28
29
"github.com/bluekeyes/go-gitdiff/gitdiff"
···
32
lexutil "github.com/bluesky-social/indigo/lex/util"
33
"github.com/go-chi/chi/v5"
34
"github.com/google/uuid"
35
)
36
37
type Pulls struct {
···
41
idResolver *idresolver.Resolver
42
db *db.DB
43
config *config.Config
44
+
notifier notify.Notifier
45
}
46
47
func New(
···
51
resolver *idresolver.Resolver,
52
db *db.DB,
53
config *config.Config,
54
+
notifier notify.Notifier,
55
) *Pulls {
56
return &Pulls{
57
oauth: oauth,
···
60
idResolver: resolver,
61
db: db,
62
config: config,
63
+
notifier: notifier,
64
}
65
}
66
···
198
m[p.Sha] = p
199
}
200
201
+
reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt())
202
+
if err != nil {
203
+
log.Println("failed to get pull reactions")
204
+
s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.")
205
+
}
206
+
207
+
userReactions := map[db.ReactionKind]bool{}
208
+
if user != nil {
209
+
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
210
+
}
211
+
212
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
213
LoggedInUser: user,
214
RepoInfo: repoInfo,
···
219
MergeCheck: mergeCheckResponse,
220
ResubmitCheck: resubmitResult,
221
Pipelines: m,
222
+
223
+
OrderedReactionKinds: db.OrderedReactionKinds,
224
+
Reactions: reactionCountMap,
225
+
UserReacted: userReactions,
226
})
227
}
228
···
355
return
356
}
357
358
+
var diffOpts types.DiffOpts
359
+
if d := r.URL.Query().Get("diff"); d == "split" {
360
+
diffOpts.Split = true
361
+
}
362
+
363
pull, ok := r.Context().Value("pull").(*db.Pull)
364
if !ok {
365
log.Println("failed to get pull")
···
400
Round: roundIdInt,
401
Submission: pull.Submissions[roundIdInt],
402
Diff: &diff,
403
+
DiffOpts: diffOpts,
404
})
405
406
}
···
414
return
415
}
416
417
+
var diffOpts types.DiffOpts
418
+
if d := r.URL.Query().Get("diff"); d == "split" {
419
+
diffOpts.Split = true
420
+
}
421
+
422
pull, ok := r.Context().Value("pull").(*db.Pull)
423
if !ok {
424
log.Println("failed to get pull")
···
474
Round: roundIdInt,
475
DidHandleMap: didHandleMap,
476
Interdiff: interdiff,
477
+
DiffOpts: diffOpts,
478
})
479
}
480
481
func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
···
555
556
// we want to group all stacked PRs into just one list
557
stacks := make(map[string]db.Stack)
558
+
var shas []string
559
n := 0
560
for _, p := range pulls {
561
+
// store the sha for later
562
+
shas = append(shas, p.LatestSha())
563
// this PR is stacked
564
if p.StackId != "" {
565
// we have already seen this PR stack
···
578
}
579
pulls = pulls[:n]
580
581
+
repoInfo := f.RepoInfo(user)
582
+
ps, err := db.GetPipelineStatuses(
583
+
s.db,
584
+
db.FilterEq("repo_owner", repoInfo.OwnerDid),
585
+
db.FilterEq("repo_name", repoInfo.Name),
586
+
db.FilterEq("knot", repoInfo.Knot),
587
+
db.FilterIn("sha", shas),
588
+
)
589
+
if err != nil {
590
+
log.Printf("failed to fetch pipeline statuses: %s", err)
591
+
// non-fatal
592
+
}
593
+
m := make(map[string]db.Pipeline)
594
+
for _, p := range ps {
595
+
m[p.Sha] = p
596
+
}
597
+
598
identsToResolve := make([]string, len(pulls))
599
for i, pull := range pulls {
600
identsToResolve[i] = pull.OwnerDid
···
616
DidHandleMap: didHandleMap,
617
FilteringBy: state,
618
Stacks: stacks,
619
+
Pipelines: m,
620
})
621
}
622
623
func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
···
688
atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
689
Collection: tangled.RepoPullCommentNSID,
690
Repo: user.Did,
691
+
Rkey: tid.TID(),
692
Record: &lexutil.LexiconTypeDecoder{
693
Val: &tangled.RepoPullComment{
694
Repo: &atUri,
···
705
return
706
}
707
708
+
comment := &db.PullComment{
709
OwnerDid: user.Did,
710
RepoAt: f.RepoAt.String(),
711
PullId: pull.PullId,
712
Body: body,
713
CommentAt: atResp.Uri,
714
SubmissionId: pull.Submissions[roundNumber].ID,
715
+
}
716
+
717
+
// Create the pull comment in the database with the commentAt field
718
+
commentId, err := db.NewPullComment(tx, comment)
719
if err != nil {
720
log.Println("failed to create pull comment", err)
721
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
···
729
return
730
}
731
732
+
s.notifier.NewPullComment(r.Context(), comment)
733
734
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
735
return
···
1058
body = formatPatches[0].Body
1059
}
1060
1061
+
rkey := tid.TID()
1062
initialSubmission := db.PullSubmission{
1063
Patch: patch,
1064
SourceRev: sourceRev,
1065
}
1066
+
pull := &db.Pull{
1067
Title: title,
1068
Body: body,
1069
TargetBranch: targetBranch,
···
1074
&initialSubmission,
1075
},
1076
PullSource: pullSource,
1077
+
}
1078
+
err = db.NewPull(tx, pull)
1079
if err != nil {
1080
log.Println("failed to create pull request", err)
1081
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
···
1115
return
1116
}
1117
1118
+
s.notifier.NewPull(r.Context(), pull)
1119
1120
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
1121
}
···
1678
}
1679
1680
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1681
}
1682
1683
func (s *Pulls) resubmitStackedPullHelper(
···
1921
}
1922
1923
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1924
}
1925
1926
func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
···
2044
2045
// auth filter: only owner or collaborators can close
2046
roles := f.RolesInRepo(user)
2047
+
isOwner := roles.IsOwner()
2048
isCollaborator := roles.IsCollaborator()
2049
isPullAuthor := user.Did == pull.OwnerDid
2050
+
isCloseAllowed := isOwner || isCollaborator || isPullAuthor
2051
if !isCloseAllowed {
2052
log.Println("failed to close pull")
2053
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
···
2091
}
2092
2093
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2094
}
2095
2096
func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) {
···
2112
2113
// auth filter: only owner or collaborators can close
2114
roles := f.RolesInRepo(user)
2115
+
isOwner := roles.IsOwner()
2116
isCollaborator := roles.IsCollaborator()
2117
isPullAuthor := user.Did == pull.OwnerDid
2118
+
isCloseAllowed := isOwner || isCollaborator || isPullAuthor
2119
if !isCloseAllowed {
2120
log.Println("failed to close pull")
2121
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
···
2159
}
2160
2161
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2162
}
2163
2164
func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) {
···
2184
2185
title := fp.Title
2186
body := fp.Body
2187
+
rkey := tid.TID()
2188
2189
initialSubmission := db.PullSubmission{
2190
Patch: fp.Raw,
+2
appview/pulls/router.go
+2
appview/pulls/router.go
+2
-2
appview/repo/artifact.go
+2
-2
appview/repo/artifact.go
···
14
"github.com/go-git/go-git/v5/plumbing"
15
"github.com/ipfs/go-cid"
16
"tangled.sh/tangled.sh/core/api/tangled"
17
-
"tangled.sh/tangled.sh/core/appview"
18
"tangled.sh/tangled.sh/core/appview/db"
19
"tangled.sh/tangled.sh/core/appview/pages"
20
"tangled.sh/tangled.sh/core/appview/reporesolver"
21
"tangled.sh/tangled.sh/core/knotclient"
22
"tangled.sh/tangled.sh/core/types"
23
)
24
···
64
65
log.Println("uploaded blob", humanize.Bytes(uint64(uploadBlobResp.Blob.Size)), uploadBlobResp.Blob.Ref.String())
66
67
-
rkey := appview.TID()
68
createdAt := time.Now()
69
70
putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
···
14
"github.com/go-git/go-git/v5/plumbing"
15
"github.com/ipfs/go-cid"
16
"tangled.sh/tangled.sh/core/api/tangled"
17
"tangled.sh/tangled.sh/core/appview/db"
18
"tangled.sh/tangled.sh/core/appview/pages"
19
"tangled.sh/tangled.sh/core/appview/reporesolver"
20
"tangled.sh/tangled.sh/core/knotclient"
21
+
"tangled.sh/tangled.sh/core/tid"
22
"tangled.sh/tangled.sh/core/types"
23
)
24
···
64
65
log.Println("uploaded blob", humanize.Bytes(uint64(uploadBlobResp.Blob.Size)), uploadBlobResp.Blob.Ref.String())
66
67
+
rkey := tid.TID()
68
createdAt := time.Now()
69
70
putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+49
-25
appview/repo/index.go
+49
-25
appview/repo/index.go
···
58
tagMap[hash] = append(tagMap[hash], branch.Name)
59
}
60
61
slices.SortFunc(result.Branches, func(a, b types.Branch) int {
62
if a.Name == result.Ref {
63
return -1
···
123
}
124
}
125
126
-
languageInfo, err := getLanguageInfo(f, signedClient, ref)
127
if err != nil {
128
log.Printf("failed to compute language percentages: %s", err)
129
// non-fatal
···
153
Languages: languageInfo,
154
Pipelines: pipelines,
155
})
156
-
return
157
}
158
159
-
func getLanguageInfo(
160
f *reporesolver.ResolvedRepo,
161
signedClient *knotclient.SignedClient,
162
-
ref string,
163
) ([]types.RepoLanguageDetails, error) {
164
-
repoLanguages, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, ref)
165
-
if err != nil {
166
-
return []types.RepoLanguageDetails{}, err
167
-
}
168
-
if repoLanguages == nil {
169
-
repoLanguages = &types.RepoLanguageResponse{Languages: make(map[string]int64)}
170
-
}
171
-
172
-
var totalSize int64
173
-
for _, fileSize := range repoLanguages.Languages {
174
-
totalSize += fileSize
175
-
}
176
177
-
var languageStats []types.RepoLanguageDetails
178
-
var otherPercentage float32 = 0
179
180
-
for lang, size := range repoLanguages.Languages {
181
-
percentage := (float32(size) / float32(totalSize)) * 100
182
183
-
if percentage <= 0.5 {
184
-
otherPercentage += percentage
185
-
continue
186
}
187
188
-
color := enry.GetColor(lang)
189
190
-
languageStats = append(languageStats, types.RepoLanguageDetails{Name: lang, Percentage: percentage, Color: color})
191
}
192
193
sort.Slice(languageStats, func(i, j int) bool {
···
58
tagMap[hash] = append(tagMap[hash], branch.Name)
59
}
60
61
+
sortFiles(result.Files)
62
+
63
slices.SortFunc(result.Branches, func(a, b types.Branch) int {
64
if a.Name == result.Ref {
65
return -1
···
125
}
126
}
127
128
+
// TODO: a bit dirty
129
+
languageInfo, err := rp.getLanguageInfo(f, signedClient, chi.URLParam(r, "ref") == "")
130
if err != nil {
131
log.Printf("failed to compute language percentages: %s", err)
132
// non-fatal
···
156
Languages: languageInfo,
157
Pipelines: pipelines,
158
})
159
}
160
161
+
func (rp *Repo) getLanguageInfo(
162
f *reporesolver.ResolvedRepo,
163
signedClient *knotclient.SignedClient,
164
+
isDefaultRef bool,
165
) ([]types.RepoLanguageDetails, error) {
166
+
// first attempt to fetch from db
167
+
langs, err := db.GetRepoLanguages(
168
+
rp.db,
169
+
db.FilterEq("repo_at", f.RepoAt),
170
+
db.FilterEq("ref", f.Ref),
171
+
)
172
173
+
if err != nil || langs == nil {
174
+
// non-fatal, fetch langs from ks
175
+
ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, f.Ref)
176
+
if err != nil {
177
+
return nil, err
178
+
}
179
+
if ls == nil {
180
+
return nil, nil
181
+
}
182
183
+
for l, s := range ls.Languages {
184
+
langs = append(langs, db.RepoLanguage{
185
+
RepoAt: f.RepoAt,
186
+
Ref: f.Ref,
187
+
IsDefaultRef: isDefaultRef,
188
+
Language: l,
189
+
Bytes: s,
190
+
})
191
+
}
192
193
+
// update appview's cache
194
+
err = db.InsertRepoLanguages(rp.db, langs)
195
+
if err != nil {
196
+
// non-fatal
197
+
log.Println("failed to cache lang results", err)
198
}
199
+
}
200
201
+
var total int64
202
+
for _, l := range langs {
203
+
total += l.Bytes
204
+
}
205
206
+
var languageStats []types.RepoLanguageDetails
207
+
for _, l := range langs {
208
+
percentage := float32(l.Bytes) / float32(total) * 100
209
+
color := enry.GetColor(l.Language)
210
+
languageStats = append(languageStats, types.RepoLanguageDetails{
211
+
Name: l.Language,
212
+
Percentage: percentage,
213
+
Color: color,
214
+
})
215
}
216
217
sort.Slice(languageStats, func(i, j int) bool {
+441
-128
appview/repo/repo.go
+441
-128
appview/repo/repo.go
···
8
"fmt"
9
"io"
10
"log"
11
"net/http"
12
"net/url"
13
-
"path"
14
"slices"
15
-
"sort"
16
"strconv"
17
"strings"
18
"time"
19
20
"tangled.sh/tangled.sh/core/api/tangled"
21
-
"tangled.sh/tangled.sh/core/appview"
22
"tangled.sh/tangled.sh/core/appview/commitverify"
23
"tangled.sh/tangled.sh/core/appview/config"
24
"tangled.sh/tangled.sh/core/appview/db"
25
-
"tangled.sh/tangled.sh/core/appview/idresolver"
26
"tangled.sh/tangled.sh/core/appview/oauth"
27
"tangled.sh/tangled.sh/core/appview/pages"
28
"tangled.sh/tangled.sh/core/appview/pages/markup"
29
"tangled.sh/tangled.sh/core/appview/reporesolver"
30
"tangled.sh/tangled.sh/core/eventconsumer"
31
"tangled.sh/tangled.sh/core/knotclient"
32
"tangled.sh/tangled.sh/core/patchutil"
33
"tangled.sh/tangled.sh/core/rbac"
34
"tangled.sh/tangled.sh/core/types"
35
36
securejoin "github.com/cyphar/filepath-securejoin"
37
"github.com/go-chi/chi/v5"
38
"github.com/go-git/go-git/v5/plumbing"
39
-
"github.com/posthog/posthog-go"
40
41
comatproto "github.com/bluesky-social/indigo/api/atproto"
42
lexutil "github.com/bluesky-social/indigo/lex/util"
43
)
44
···
51
spindlestream *eventconsumer.Consumer
52
db *db.DB
53
enforcer *rbac.Enforcer
54
-
posthog posthog.Client
55
}
56
57
func New(
···
62
idResolver *idresolver.Resolver,
63
db *db.DB,
64
config *config.Config,
65
-
posthog posthog.Client,
66
enforcer *rbac.Enforcer,
67
) *Repo {
68
return &Repo{oauth: oauth,
69
repoResolver: repoResolver,
···
72
config: config,
73
spindlestream: spindlestream,
74
db: db,
75
-
posthog: posthog,
76
enforcer: enforcer,
77
}
78
}
79
···
106
return
107
}
108
109
-
result, err := us.Tags(f.OwnerDid(), f.RepoName)
110
if err != nil {
111
log.Println("failed to reach knotserver", err)
112
return
113
}
114
115
tagMap := make(map[string][]string)
116
-
for _, tag := range result.Tags {
117
hash := tag.Hash
118
if tag.Tag != nil {
119
hash = tag.Tag.Target.String()
···
121
tagMap[hash] = append(tagMap[hash], tag.Name)
122
}
123
124
user := rp.oauth.GetUser(r)
125
126
emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true)
···
154
VerifiedCommits: vc,
155
Pipelines: pipelines,
156
})
157
-
return
158
}
159
160
func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
···
169
rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
170
RepoInfo: f.RepoInfo(user),
171
})
172
-
return
173
}
174
175
func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) {
···
268
protocol = "https"
269
}
270
271
if !plumbing.IsHash(ref) {
272
rp.pages.Error404(w)
273
return
···
321
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
322
VerifiedCommit: vc,
323
Pipeline: pipeline,
324
})
325
-
return
326
}
327
328
func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) {
···
359
360
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
361
// so we can safely redirect to the "parent" (which is the same file).
362
-
if len(result.Files) == 0 && result.Parent == treePath {
363
http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound)
364
return
365
}
···
374
}
375
}
376
377
-
baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath)
378
-
baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath)
379
380
rp.pages.RepoTree(w, pages.RepoTreeParams{
381
LoggedInUser: user,
382
BreadCrumbs: breadcrumbs,
383
-
BaseTreeLink: baseTreeLink,
384
-
BaseBlobLink: baseBlobLink,
385
RepoInfo: f.RepoInfo(user),
386
RepoTreeResponse: result,
387
})
388
-
return
389
}
390
391
func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) {
···
443
ArtifactMap: artifactMap,
444
DanglingArtifacts: danglingArtifacts,
445
})
446
-
return
447
}
448
449
func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) {
···
465
return
466
}
467
468
-
slices.SortFunc(result.Branches, func(a, b types.Branch) int {
469
-
if a.IsDefault {
470
-
return -1
471
-
}
472
-
if b.IsDefault {
473
-
return 1
474
-
}
475
-
if a.Commit != nil && b.Commit != nil {
476
-
if a.Commit.Committer.When.Before(b.Commit.Committer.When) {
477
-
return 1
478
-
} else {
479
-
return -1
480
-
}
481
-
}
482
-
return strings.Compare(a.Name, b.Name) * -1
483
-
})
484
485
user := rp.oauth.GetUser(r)
486
rp.pages.RepoBranches(w, pages.RepoBranchesParams{
···
488
RepoInfo: f.RepoInfo(user),
489
RepoBranchesResponse: *result,
490
})
491
-
return
492
}
493
494
func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
···
539
showRendered = r.URL.Query().Get("code") != "true"
540
}
541
542
user := rp.oauth.GetUser(r)
543
rp.pages.RepoBlob(w, pages.RepoBlobParams{
544
LoggedInUser: user,
···
547
BreadCrumbs: breadcrumbs,
548
ShowRendered: showRendered,
549
RenderToggle: renderToggle,
550
})
551
-
return
552
}
553
554
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
555
f, err := rp.repoResolver.Resolve(r)
556
if err != nil {
557
log.Println("failed to get repo and knot", err)
558
return
559
}
560
···
565
if !rp.config.Core.Dev {
566
protocol = "https"
567
}
568
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
569
if err != nil {
570
-
log.Println("failed to reach knotserver", err)
571
return
572
}
573
574
-
body, err := io.ReadAll(resp.Body)
575
-
if err != nil {
576
-
log.Printf("Error reading response body: %v", err)
577
return
578
}
579
580
-
var result types.RepoBlobResponse
581
-
err = json.Unmarshal(body, &result)
582
if err != nil {
583
-
log.Println("failed to parse response:", err)
584
return
585
}
586
587
-
if result.IsBinary {
588
-
w.Header().Set("Content-Type", "application/octet-stream")
589
w.Write(body)
590
return
591
}
592
-
593
-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
594
-
w.Write([]byte(result.Contents))
595
-
return
596
}
597
598
// modify the spindle configured for this repo
599
func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
600
f, err := rp.repoResolver.Resolve(r)
601
if err != nil {
602
-
log.Println("failed to get repo and knot", err)
603
-
w.WriteHeader(http.StatusBadRequest)
604
return
605
}
606
607
repoAt := f.RepoAt
608
rkey := repoAt.RecordKey().String()
609
if rkey == "" {
610
-
log.Println("invalid aturi for repo", err)
611
-
w.WriteHeader(http.StatusInternalServerError)
612
return
613
}
614
615
-
user := rp.oauth.GetUser(r)
616
-
617
newSpindle := r.FormValue("spindle")
618
client, err := rp.oauth.AuthorizedClient(r)
619
if err != nil {
620
-
log.Println("failed to get client")
621
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
622
return
623
}
624
625
// ensure that this is a valid spindle for this user
626
validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
627
if err != nil {
628
-
log.Println("failed to get valid spindles")
629
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
630
return
631
}
632
633
if !slices.Contains(validSpindles, newSpindle) {
634
-
log.Println("newSpindle not present in validSpindles", "newSpindle", newSpindle, "validSpindles", validSpindles)
635
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
636
return
637
}
638
639
// optimistic update
640
err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle)
641
if err != nil {
642
-
log.Println("failed to perform update-spindle query", err)
643
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
644
return
645
}
646
647
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
648
if err != nil {
649
-
// failed to get record
650
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, no record found on PDS.")
651
return
652
}
653
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
···
668
})
669
670
if err != nil {
671
-
log.Println("failed to perform update-spindle query", err)
672
-
// failed to get record
673
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, unable to save to PDS.")
674
return
675
}
676
···
680
eventconsumer.NewSpindleSource(newSpindle),
681
)
682
683
-
w.Write(fmt.Append(nil, "spindle set to: ", newSpindle))
684
}
685
686
func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) {
687
f, err := rp.repoResolver.Resolve(r)
688
if err != nil {
689
-
log.Println("failed to get repo and knot", err)
690
return
691
}
692
693
collaborator := r.FormValue("collaborator")
694
if collaborator == "" {
695
-
http.Error(w, "malformed form", http.StatusBadRequest)
696
return
697
}
698
699
collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator)
700
if err != nil {
701
-
w.Write([]byte("failed to resolve collaborator did to a handle"))
702
return
703
}
704
-
log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot)
705
706
-
// TODO: create an atproto record for this
707
708
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
709
if err != nil {
710
-
log.Printf("no key found for domain %s: %s\n", f.Knot, err)
711
return
712
}
713
714
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
715
if err != nil {
716
-
log.Println("failed to create client to ", f.Knot)
717
return
718
}
719
720
ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String())
721
if err != nil {
722
-
log.Printf("failed to make request to %s: %s", f.Knot, err)
723
return
724
}
725
726
if ksResp.StatusCode != http.StatusNoContent {
727
-
w.Write(fmt.Append(nil, "knotserver failed to add collaborator: ", err))
728
return
729
}
730
731
tx, err := rp.db.BeginTx(r.Context(), nil)
732
if err != nil {
733
-
log.Println("failed to start tx")
734
-
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
735
return
736
}
737
defer func() {
738
tx.Rollback()
739
err = rp.enforcer.E.LoadPolicy()
740
if err != nil {
741
-
log.Println("failed to rollback policies")
742
}
743
}()
744
745
err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
746
if err != nil {
747
-
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
748
return
749
}
750
751
-
err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot)
752
if err != nil {
753
-
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
754
return
755
}
756
757
err = tx.Commit()
758
if err != nil {
759
-
log.Println("failed to commit changes", err)
760
-
http.Error(w, err.Error(), http.StatusInternalServerError)
761
return
762
}
763
764
err = rp.enforcer.E.SavePolicy()
765
if err != nil {
766
-
log.Println("failed to update ACLs", err)
767
-
http.Error(w, err.Error(), http.StatusInternalServerError)
768
return
769
}
770
771
-
w.Write(fmt.Append(nil, "added collaborator: ", collaboratorIdent.Handle.String()))
772
-
773
}
774
775
func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
···
921
w.Write(fmt.Append(nil, "default branch set to: ", branch))
922
}
923
924
-
func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
925
f, err := rp.repoResolver.Resolve(r)
926
if err != nil {
927
log.Println("failed to get repo and knot", err)
928
return
929
}
930
931
switch r.Method {
932
-
case http.MethodGet:
933
-
// for now, this is just pubkeys
934
-
user := rp.oauth.GetUser(r)
935
-
repoCollaborators, err := f.Collaborators(r.Context())
936
-
if err != nil {
937
-
log.Println("failed to get collaborators", err)
938
-
}
939
940
-
isCollaboratorInviteAllowed := false
941
-
if user != nil {
942
-
ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo())
943
-
if err == nil && ok {
944
-
isCollaboratorInviteAllowed = true
945
-
}
946
}
947
948
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
949
if err != nil {
950
-
log.Println("failed to create unsigned client", err)
951
return
952
}
953
954
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
955
if err != nil {
956
-
log.Println("failed to reach knotserver", err)
957
return
958
}
959
960
-
// all spindles that this user is a member of
961
-
spindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
962
-
if err != nil {
963
-
log.Println("failed to fetch spindles", err)
964
-
return
965
}
966
967
-
rp.pages.RepoSettings(w, pages.RepoSettingsParams{
968
-
LoggedInUser: user,
969
-
RepoInfo: f.RepoInfo(user),
970
-
Collaborators: repoCollaborators,
971
-
IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
972
-
Branches: result.Branches,
973
-
Spindles: spindles,
974
-
CurrentSpindle: f.Spindle,
975
})
976
}
977
}
978
979
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
···
1093
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1094
sourceAt := f.RepoAt.String()
1095
1096
-
rkey := appview.TID()
1097
repo := &db.Repo{
1098
Did: user.Did,
1099
Name: forkName,
···
1218
return
1219
}
1220
branches := result.Branches
1221
-
sort.Slice(branches, func(i int, j int) bool {
1222
-
return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When)
1223
-
})
1224
1225
var defaultBranch string
1226
for _, b := range branches {
···
1267
if err != nil {
1268
log.Println("failed to get repo and knot", err)
1269
return
1270
}
1271
1272
// if user is navigating to one of
···
1331
Base: base,
1332
Head: head,
1333
Diff: &diff,
1334
})
1335
1336
}
···
8
"fmt"
9
"io"
10
"log"
11
+
"log/slog"
12
"net/http"
13
"net/url"
14
+
"path/filepath"
15
"slices"
16
"strconv"
17
"strings"
18
"time"
19
20
"tangled.sh/tangled.sh/core/api/tangled"
21
"tangled.sh/tangled.sh/core/appview/commitverify"
22
"tangled.sh/tangled.sh/core/appview/config"
23
"tangled.sh/tangled.sh/core/appview/db"
24
+
"tangled.sh/tangled.sh/core/appview/notify"
25
"tangled.sh/tangled.sh/core/appview/oauth"
26
"tangled.sh/tangled.sh/core/appview/pages"
27
"tangled.sh/tangled.sh/core/appview/pages/markup"
28
"tangled.sh/tangled.sh/core/appview/reporesolver"
29
"tangled.sh/tangled.sh/core/eventconsumer"
30
+
"tangled.sh/tangled.sh/core/idresolver"
31
"tangled.sh/tangled.sh/core/knotclient"
32
"tangled.sh/tangled.sh/core/patchutil"
33
"tangled.sh/tangled.sh/core/rbac"
34
+
"tangled.sh/tangled.sh/core/tid"
35
"tangled.sh/tangled.sh/core/types"
36
37
securejoin "github.com/cyphar/filepath-securejoin"
38
"github.com/go-chi/chi/v5"
39
"github.com/go-git/go-git/v5/plumbing"
40
41
comatproto "github.com/bluesky-social/indigo/api/atproto"
42
+
"github.com/bluesky-social/indigo/atproto/syntax"
43
lexutil "github.com/bluesky-social/indigo/lex/util"
44
)
45
···
52
spindlestream *eventconsumer.Consumer
53
db *db.DB
54
enforcer *rbac.Enforcer
55
+
notifier notify.Notifier
56
+
logger *slog.Logger
57
}
58
59
func New(
···
64
idResolver *idresolver.Resolver,
65
db *db.DB,
66
config *config.Config,
67
+
notifier notify.Notifier,
68
enforcer *rbac.Enforcer,
69
+
logger *slog.Logger,
70
) *Repo {
71
return &Repo{oauth: oauth,
72
repoResolver: repoResolver,
···
75
config: config,
76
spindlestream: spindlestream,
77
db: db,
78
+
notifier: notifier,
79
enforcer: enforcer,
80
+
logger: logger,
81
}
82
}
83
···
110
return
111
}
112
113
+
tagResult, err := us.Tags(f.OwnerDid(), f.RepoName)
114
if err != nil {
115
log.Println("failed to reach knotserver", err)
116
return
117
}
118
119
tagMap := make(map[string][]string)
120
+
for _, tag := range tagResult.Tags {
121
hash := tag.Hash
122
if tag.Tag != nil {
123
hash = tag.Tag.Target.String()
···
125
tagMap[hash] = append(tagMap[hash], tag.Name)
126
}
127
128
+
branchResult, err := us.Branches(f.OwnerDid(), f.RepoName)
129
+
if err != nil {
130
+
log.Println("failed to reach knotserver", err)
131
+
return
132
+
}
133
+
134
+
for _, branch := range branchResult.Branches {
135
+
hash := branch.Hash
136
+
tagMap[hash] = append(tagMap[hash], branch.Name)
137
+
}
138
+
139
user := rp.oauth.GetUser(r)
140
141
emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true)
···
169
VerifiedCommits: vc,
170
Pipelines: pipelines,
171
})
172
}
173
174
func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
···
183
rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
184
RepoInfo: f.RepoInfo(user),
185
})
186
}
187
188
func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) {
···
281
protocol = "https"
282
}
283
284
+
var diffOpts types.DiffOpts
285
+
if d := r.URL.Query().Get("diff"); d == "split" {
286
+
diffOpts.Split = true
287
+
}
288
+
289
if !plumbing.IsHash(ref) {
290
rp.pages.Error404(w)
291
return
···
339
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
340
VerifiedCommit: vc,
341
Pipeline: pipeline,
342
+
DiffOpts: diffOpts,
343
})
344
}
345
346
func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) {
···
377
378
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
379
// so we can safely redirect to the "parent" (which is the same file).
380
+
unescapedTreePath, _ := url.PathUnescape(treePath)
381
+
if len(result.Files) == 0 && result.Parent == unescapedTreePath {
382
http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound)
383
return
384
}
···
393
}
394
}
395
396
+
sortFiles(result.Files)
397
398
rp.pages.RepoTree(w, pages.RepoTreeParams{
399
LoggedInUser: user,
400
BreadCrumbs: breadcrumbs,
401
+
TreePath: treePath,
402
RepoInfo: f.RepoInfo(user),
403
RepoTreeResponse: result,
404
})
405
}
406
407
func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) {
···
459
ArtifactMap: artifactMap,
460
DanglingArtifacts: danglingArtifacts,
461
})
462
}
463
464
func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) {
···
480
return
481
}
482
483
+
sortBranches(result.Branches)
484
485
user := rp.oauth.GetUser(r)
486
rp.pages.RepoBranches(w, pages.RepoBranchesParams{
···
488
RepoInfo: f.RepoInfo(user),
489
RepoBranchesResponse: *result,
490
})
491
}
492
493
func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
···
538
showRendered = r.URL.Query().Get("code") != "true"
539
}
540
541
+
var unsupported bool
542
+
var isImage bool
543
+
var isVideo bool
544
+
var contentSrc string
545
+
546
+
if result.IsBinary {
547
+
ext := strings.ToLower(filepath.Ext(result.Path))
548
+
switch ext {
549
+
case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
550
+
isImage = true
551
+
case ".mp4", ".webm", ".ogg", ".mov", ".avi":
552
+
isVideo = true
553
+
default:
554
+
unsupported = true
555
+
}
556
+
557
+
// fetch the actual binary content like in RepoBlobRaw
558
+
559
+
blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)
560
+
contentSrc = blobURL
561
+
if !rp.config.Core.Dev {
562
+
contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
563
+
}
564
+
}
565
+
566
user := rp.oauth.GetUser(r)
567
rp.pages.RepoBlob(w, pages.RepoBlobParams{
568
LoggedInUser: user,
···
571
BreadCrumbs: breadcrumbs,
572
ShowRendered: showRendered,
573
RenderToggle: renderToggle,
574
+
Unsupported: unsupported,
575
+
IsImage: isImage,
576
+
IsVideo: isVideo,
577
+
ContentSrc: contentSrc,
578
})
579
}
580
581
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
582
f, err := rp.repoResolver.Resolve(r)
583
if err != nil {
584
log.Println("failed to get repo and knot", err)
585
+
w.WriteHeader(http.StatusBadRequest)
586
return
587
}
588
···
593
if !rp.config.Core.Dev {
594
protocol = "https"
595
}
596
+
blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)
597
+
resp, err := http.Get(blobURL)
598
if err != nil {
599
+
log.Println("failed to reach knotserver:", err)
600
+
rp.pages.Error503(w)
601
return
602
}
603
+
defer resp.Body.Close()
604
605
+
if resp.StatusCode != http.StatusOK {
606
+
log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode)
607
+
w.WriteHeader(resp.StatusCode)
608
+
_, _ = io.Copy(w, resp.Body)
609
return
610
}
611
612
+
contentType := resp.Header.Get("Content-Type")
613
+
body, err := io.ReadAll(resp.Body)
614
if err != nil {
615
+
log.Printf("error reading response body from knotserver: %v", err)
616
+
w.WriteHeader(http.StatusInternalServerError)
617
return
618
}
619
620
+
if strings.Contains(contentType, "text/plain") {
621
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
622
+
w.Write(body)
623
+
} else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
624
+
w.Header().Set("Content-Type", contentType)
625
w.Write(body)
626
+
} else {
627
+
w.WriteHeader(http.StatusUnsupportedMediaType)
628
+
w.Write([]byte("unsupported content type"))
629
return
630
}
631
}
632
633
// modify the spindle configured for this repo
634
func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
635
+
user := rp.oauth.GetUser(r)
636
+
l := rp.logger.With("handler", "EditSpindle")
637
+
l = l.With("did", user.Did)
638
+
l = l.With("handle", user.Handle)
639
+
640
+
errorId := "operation-error"
641
+
fail := func(msg string, err error) {
642
+
l.Error(msg, "err", err)
643
+
rp.pages.Notice(w, errorId, msg)
644
+
}
645
+
646
f, err := rp.repoResolver.Resolve(r)
647
if err != nil {
648
+
fail("Failed to resolve repo. Try again later", err)
649
return
650
}
651
652
repoAt := f.RepoAt
653
rkey := repoAt.RecordKey().String()
654
if rkey == "" {
655
+
fail("Failed to resolve repo. Try again later", err)
656
return
657
}
658
659
newSpindle := r.FormValue("spindle")
660
client, err := rp.oauth.AuthorizedClient(r)
661
if err != nil {
662
+
fail("Failed to authorize. Try again later.", err)
663
return
664
}
665
666
// ensure that this is a valid spindle for this user
667
validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
668
if err != nil {
669
+
fail("Failed to find spindles. Try again later.", err)
670
return
671
}
672
673
if !slices.Contains(validSpindles, newSpindle) {
674
+
fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles))
675
return
676
}
677
678
// optimistic update
679
err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle)
680
if err != nil {
681
+
fail("Failed to update spindle. Try again later.", err)
682
return
683
}
684
685
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
686
if err != nil {
687
+
fail("Failed to update spindle, no record found on PDS.", err)
688
return
689
}
690
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
···
705
})
706
707
if err != nil {
708
+
fail("Failed to update spindle, unable to save to PDS.", err)
709
return
710
}
711
···
715
eventconsumer.NewSpindleSource(newSpindle),
716
)
717
718
+
rp.pages.HxRefresh(w)
719
}
720
721
func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) {
722
+
user := rp.oauth.GetUser(r)
723
+
l := rp.logger.With("handler", "AddCollaborator")
724
+
l = l.With("did", user.Did)
725
+
l = l.With("handle", user.Handle)
726
+
727
f, err := rp.repoResolver.Resolve(r)
728
if err != nil {
729
+
l.Error("failed to get repo and knot", "err", err)
730
return
731
}
732
733
+
errorId := "add-collaborator-error"
734
+
fail := func(msg string, err error) {
735
+
l.Error(msg, "err", err)
736
+
rp.pages.Notice(w, errorId, msg)
737
+
}
738
+
739
collaborator := r.FormValue("collaborator")
740
if collaborator == "" {
741
+
fail("Invalid form.", nil)
742
return
743
}
744
+
745
+
// remove a single leading `@`, to make @handle work with ResolveIdent
746
+
collaborator = strings.TrimPrefix(collaborator, "@")
747
748
collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator)
749
if err != nil {
750
+
fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err)
751
+
return
752
+
}
753
+
754
+
if collaboratorIdent.DID.String() == user.Did {
755
+
fail("You seem to be adding yourself as a collaborator.", nil)
756
return
757
}
758
+
l = l.With("collaborator", collaboratorIdent.Handle)
759
+
l = l.With("knot", f.Knot)
760
761
+
// announce this relation into the firehose, store into owners' pds
762
+
client, err := rp.oauth.AuthorizedClient(r)
763
+
if err != nil {
764
+
fail("Failed to write to PDS.", err)
765
+
return
766
+
}
767
768
+
// emit a record
769
+
currentUser := rp.oauth.GetUser(r)
770
+
rkey := tid.TID()
771
+
createdAt := time.Now()
772
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
773
+
Collection: tangled.RepoCollaboratorNSID,
774
+
Repo: currentUser.Did,
775
+
Rkey: rkey,
776
+
Record: &lexutil.LexiconTypeDecoder{
777
+
Val: &tangled.RepoCollaborator{
778
+
Subject: collaboratorIdent.DID.String(),
779
+
Repo: string(f.RepoAt),
780
+
CreatedAt: createdAt.Format(time.RFC3339),
781
+
}},
782
+
})
783
+
// invalid record
784
+
if err != nil {
785
+
fail("Failed to write record to PDS.", err)
786
+
return
787
+
}
788
+
l = l.With("at-uri", resp.Uri)
789
+
l.Info("wrote record to PDS")
790
+
791
+
l.Info("adding to knot")
792
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
793
if err != nil {
794
+
fail("Failed to add to knot.", err)
795
return
796
}
797
798
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
799
if err != nil {
800
+
fail("Failed to add to knot.", err)
801
return
802
}
803
804
ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String())
805
if err != nil {
806
+
fail("Knot was unreachable.", err)
807
return
808
}
809
810
if ksResp.StatusCode != http.StatusNoContent {
811
+
fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil)
812
return
813
}
814
815
tx, err := rp.db.BeginTx(r.Context(), nil)
816
if err != nil {
817
+
fail("Failed to add collaborator.", err)
818
return
819
}
820
defer func() {
821
tx.Rollback()
822
err = rp.enforcer.E.LoadPolicy()
823
if err != nil {
824
+
fail("Failed to add collaborator.", err)
825
}
826
}()
827
828
err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
829
if err != nil {
830
+
fail("Failed to add collaborator permissions.", err)
831
return
832
}
833
834
+
err = db.AddCollaborator(rp.db, db.Collaborator{
835
+
Did: syntax.DID(currentUser.Did),
836
+
Rkey: rkey,
837
+
SubjectDid: collaboratorIdent.DID,
838
+
RepoAt: f.RepoAt,
839
+
Created: createdAt,
840
+
})
841
if err != nil {
842
+
fail("Failed to add collaborator.", err)
843
return
844
}
845
846
err = tx.Commit()
847
if err != nil {
848
+
fail("Failed to add collaborator.", err)
849
return
850
}
851
852
err = rp.enforcer.E.SavePolicy()
853
if err != nil {
854
+
fail("Failed to update collaborator permissions.", err)
855
return
856
}
857
858
+
rp.pages.HxRefresh(w)
859
}
860
861
func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
···
1007
w.Write(fmt.Append(nil, "default branch set to: ", branch))
1008
}
1009
1010
+
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
1011
+
user := rp.oauth.GetUser(r)
1012
+
l := rp.logger.With("handler", "Secrets")
1013
+
l = l.With("handle", user.Handle)
1014
+
l = l.With("did", user.Did)
1015
+
1016
f, err := rp.repoResolver.Resolve(r)
1017
if err != nil {
1018
log.Println("failed to get repo and knot", err)
1019
return
1020
}
1021
1022
+
if f.Spindle == "" {
1023
+
log.Println("empty spindle cannot add/rm secret", err)
1024
+
return
1025
+
}
1026
+
1027
+
lxm := tangled.RepoAddSecretNSID
1028
+
if r.Method == http.MethodDelete {
1029
+
lxm = tangled.RepoRemoveSecretNSID
1030
+
}
1031
+
1032
+
spindleClient, err := rp.oauth.ServiceClient(
1033
+
r,
1034
+
oauth.WithService(f.Spindle),
1035
+
oauth.WithLxm(lxm),
1036
+
oauth.WithDev(rp.config.Core.Dev),
1037
+
)
1038
+
if err != nil {
1039
+
log.Println("failed to create spindle client", err)
1040
+
return
1041
+
}
1042
+
1043
+
key := r.FormValue("key")
1044
+
if key == "" {
1045
+
w.WriteHeader(http.StatusBadRequest)
1046
+
return
1047
+
}
1048
+
1049
switch r.Method {
1050
+
case http.MethodPut:
1051
+
errorId := "add-secret-error"
1052
1053
+
value := r.FormValue("value")
1054
+
if value == "" {
1055
+
w.WriteHeader(http.StatusBadRequest)
1056
+
return
1057
}
1058
1059
+
err = tangled.RepoAddSecret(
1060
+
r.Context(),
1061
+
spindleClient,
1062
+
&tangled.RepoAddSecret_Input{
1063
+
Repo: f.RepoAt.String(),
1064
+
Key: key,
1065
+
Value: value,
1066
+
},
1067
+
)
1068
if err != nil {
1069
+
l.Error("Failed to add secret.", "err", err)
1070
+
rp.pages.Notice(w, errorId, "Failed to add secret.")
1071
return
1072
}
1073
1074
+
case http.MethodDelete:
1075
+
errorId := "operation-error"
1076
+
1077
+
err = tangled.RepoRemoveSecret(
1078
+
r.Context(),
1079
+
spindleClient,
1080
+
&tangled.RepoRemoveSecret_Input{
1081
+
Repo: f.RepoAt.String(),
1082
+
Key: key,
1083
+
},
1084
+
)
1085
if err != nil {
1086
+
l.Error("Failed to delete secret.", "err", err)
1087
+
rp.pages.Notice(w, errorId, "Failed to delete secret.")
1088
return
1089
}
1090
+
}
1091
1092
+
rp.pages.HxRefresh(w)
1093
+
}
1094
+
1095
+
type tab = map[string]any
1096
+
1097
+
var (
1098
+
// would be great to have ordered maps right about now
1099
+
settingsTabs []tab = []tab{
1100
+
{"Name": "general", "Icon": "sliders-horizontal"},
1101
+
{"Name": "access", "Icon": "users"},
1102
+
{"Name": "pipelines", "Icon": "layers-2"},
1103
+
}
1104
+
)
1105
+
1106
+
func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
1107
+
tabVal := r.URL.Query().Get("tab")
1108
+
if tabVal == "" {
1109
+
tabVal = "general"
1110
+
}
1111
+
1112
+
switch tabVal {
1113
+
case "general":
1114
+
rp.generalSettings(w, r)
1115
+
1116
+
case "access":
1117
+
rp.accessSettings(w, r)
1118
+
1119
+
case "pipelines":
1120
+
rp.pipelineSettings(w, r)
1121
+
}
1122
+
1123
+
// user := rp.oauth.GetUser(r)
1124
+
// repoCollaborators, err := f.Collaborators(r.Context())
1125
+
// if err != nil {
1126
+
// log.Println("failed to get collaborators", err)
1127
+
// }
1128
+
1129
+
// isCollaboratorInviteAllowed := false
1130
+
// if user != nil {
1131
+
// ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo())
1132
+
// if err == nil && ok {
1133
+
// isCollaboratorInviteAllowed = true
1134
+
// }
1135
+
// }
1136
+
1137
+
// us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1138
+
// if err != nil {
1139
+
// log.Println("failed to create unsigned client", err)
1140
+
// return
1141
+
// }
1142
+
1143
+
// result, err := us.Branches(f.OwnerDid(), f.RepoName)
1144
+
// if err != nil {
1145
+
// log.Println("failed to reach knotserver", err)
1146
+
// return
1147
+
// }
1148
+
1149
+
// // all spindles that this user is a member of
1150
+
// spindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
1151
+
// if err != nil {
1152
+
// log.Println("failed to fetch spindles", err)
1153
+
// return
1154
+
// }
1155
+
1156
+
// var secrets []*tangled.RepoListSecrets_Secret
1157
+
// if f.Spindle != "" {
1158
+
// if spindleClient, err := rp.oauth.ServiceClient(
1159
+
// r,
1160
+
// oauth.WithService(f.Spindle),
1161
+
// oauth.WithLxm(tangled.RepoListSecretsNSID),
1162
+
// oauth.WithDev(rp.config.Core.Dev),
1163
+
// ); err != nil {
1164
+
// log.Println("failed to create spindle client", err)
1165
+
// } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil {
1166
+
// log.Println("failed to fetch secrets", err)
1167
+
// } else {
1168
+
// secrets = resp.Secrets
1169
+
// }
1170
+
// }
1171
+
1172
+
// rp.pages.RepoSettings(w, pages.RepoSettingsParams{
1173
+
// LoggedInUser: user,
1174
+
// RepoInfo: f.RepoInfo(user),
1175
+
// Collaborators: repoCollaborators,
1176
+
// IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
1177
+
// Branches: result.Branches,
1178
+
// Spindles: spindles,
1179
+
// CurrentSpindle: f.Spindle,
1180
+
// Secrets: secrets,
1181
+
// })
1182
+
}
1183
+
1184
+
func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
1185
+
f, err := rp.repoResolver.Resolve(r)
1186
+
user := rp.oauth.GetUser(r)
1187
+
1188
+
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1189
+
if err != nil {
1190
+
log.Println("failed to create unsigned client", err)
1191
+
return
1192
+
}
1193
+
1194
+
result, err := us.Branches(f.OwnerDid(), f.RepoName)
1195
+
if err != nil {
1196
+
log.Println("failed to reach knotserver", err)
1197
+
return
1198
+
}
1199
+
1200
+
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
1201
+
LoggedInUser: user,
1202
+
RepoInfo: f.RepoInfo(user),
1203
+
Branches: result.Branches,
1204
+
Tabs: settingsTabs,
1205
+
Tab: "general",
1206
+
})
1207
+
}
1208
+
1209
+
func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
1210
+
f, err := rp.repoResolver.Resolve(r)
1211
+
user := rp.oauth.GetUser(r)
1212
+
1213
+
repoCollaborators, err := f.Collaborators(r.Context())
1214
+
if err != nil {
1215
+
log.Println("failed to get collaborators", err)
1216
+
}
1217
+
1218
+
rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
1219
+
LoggedInUser: user,
1220
+
RepoInfo: f.RepoInfo(user),
1221
+
Tabs: settingsTabs,
1222
+
Tab: "access",
1223
+
Collaborators: repoCollaborators,
1224
+
})
1225
+
}
1226
+
1227
+
func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
1228
+
f, err := rp.repoResolver.Resolve(r)
1229
+
user := rp.oauth.GetUser(r)
1230
+
1231
+
// all spindles that the repo owner is a member of
1232
+
spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
1233
+
if err != nil {
1234
+
log.Println("failed to fetch spindles", err)
1235
+
return
1236
+
}
1237
+
1238
+
var secrets []*tangled.RepoListSecrets_Secret
1239
+
if f.Spindle != "" {
1240
+
if spindleClient, err := rp.oauth.ServiceClient(
1241
+
r,
1242
+
oauth.WithService(f.Spindle),
1243
+
oauth.WithLxm(tangled.RepoListSecretsNSID),
1244
+
oauth.WithDev(rp.config.Core.Dev),
1245
+
); err != nil {
1246
+
log.Println("failed to create spindle client", err)
1247
+
} else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil {
1248
+
log.Println("failed to fetch secrets", err)
1249
+
} else {
1250
+
secrets = resp.Secrets
1251
}
1252
+
}
1253
1254
+
slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
1255
+
return strings.Compare(a.Key, b.Key)
1256
+
})
1257
+
1258
+
var dids []string
1259
+
for _, s := range secrets {
1260
+
dids = append(dids, s.CreatedBy)
1261
+
}
1262
+
resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
1263
+
1264
+
// convert to a more manageable form
1265
+
var niceSecret []map[string]any
1266
+
for id, s := range secrets {
1267
+
when, _ := time.Parse(time.RFC3339, s.CreatedAt)
1268
+
niceSecret = append(niceSecret, map[string]any{
1269
+
"Id": id,
1270
+
"Key": s.Key,
1271
+
"CreatedAt": when,
1272
+
"CreatedBy": resolvedIdents[id].Handle.String(),
1273
})
1274
}
1275
+
1276
+
rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
1277
+
LoggedInUser: user,
1278
+
RepoInfo: f.RepoInfo(user),
1279
+
Tabs: settingsTabs,
1280
+
Tab: "pipelines",
1281
+
Spindles: spindles,
1282
+
CurrentSpindle: f.Spindle,
1283
+
Secrets: niceSecret,
1284
+
})
1285
}
1286
1287
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
···
1401
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1402
sourceAt := f.RepoAt.String()
1403
1404
+
rkey := tid.TID()
1405
repo := &db.Repo{
1406
Did: user.Did,
1407
Name: forkName,
···
1526
return
1527
}
1528
branches := result.Branches
1529
+
1530
+
sortBranches(branches)
1531
1532
var defaultBranch string
1533
for _, b := range branches {
···
1574
if err != nil {
1575
log.Println("failed to get repo and knot", err)
1576
return
1577
+
}
1578
+
1579
+
var diffOpts types.DiffOpts
1580
+
if d := r.URL.Query().Get("diff"); d == "split" {
1581
+
diffOpts.Split = true
1582
}
1583
1584
// if user is navigating to one of
···
1643
Base: base,
1644
Head: head,
1645
Diff: &diff,
1646
+
DiffOpts: diffOpts,
1647
})
1648
1649
}
+34
appview/repo/repo_util.go
+34
appview/repo/repo_util.go
···
5
"crypto/rand"
6
"fmt"
7
"math/big"
8
+
"slices"
9
+
"sort"
10
+
"strings"
11
12
"tangled.sh/tangled.sh/core/appview/db"
13
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
14
+
"tangled.sh/tangled.sh/core/types"
15
16
"github.com/go-git/go-git/v5/plumbing/object"
17
)
18
+
19
+
func sortFiles(files []types.NiceTree) {
20
+
sort.Slice(files, func(i, j int) bool {
21
+
iIsFile := files[i].IsFile
22
+
jIsFile := files[j].IsFile
23
+
if iIsFile != jIsFile {
24
+
return !iIsFile
25
+
}
26
+
return files[i].Name < files[j].Name
27
+
})
28
+
}
29
+
30
+
func sortBranches(branches []types.Branch) {
31
+
slices.SortFunc(branches, func(a, b types.Branch) int {
32
+
if a.IsDefault {
33
+
return -1
34
+
}
35
+
if b.IsDefault {
36
+
return 1
37
+
}
38
+
if a.Commit != nil && b.Commit != nil {
39
+
if a.Commit.Committer.When.Before(b.Commit.Committer.When) {
40
+
return 1
41
+
} else {
42
+
return -1
43
+
}
44
+
}
45
+
return strings.Compare(a.Name, b.Name)
46
+
})
47
+
}
48
49
func uniqueEmails(commits []*object.Commit) []string {
50
emails := make(map[string]struct{})
+2
appview/repo/router.go
+2
appview/repo/router.go
···
74
r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator)
75
r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo)
76
r.Put("/branches/default", rp.SetDefaultBranch)
77
+
r.Put("/secrets", rp.Secrets)
78
+
r.Delete("/secrets", rp.Secrets)
79
})
80
})
81
+5
-4
appview/reporesolver/resolver.go
+5
-4
appview/reporesolver/resolver.go
···
17
"github.com/go-chi/chi/v5"
18
"tangled.sh/tangled.sh/core/appview/config"
19
"tangled.sh/tangled.sh/core/appview/db"
20
-
"tangled.sh/tangled.sh/core/appview/idresolver"
21
"tangled.sh/tangled.sh/core/appview/oauth"
22
"tangled.sh/tangled.sh/core/appview/pages"
23
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
24
"tangled.sh/tangled.sh/core/knotclient"
25
"tangled.sh/tangled.sh/core/rbac"
26
)
···
149
for _, item := range repoCollaborators {
150
// currently only two roles: owner and member
151
var role string
152
-
if item[3] == "repo:owner" {
153
role = "owner"
154
-
} else if item[3] == "repo:collaborator" {
155
role = "collaborator"
156
-
} else {
157
continue
158
}
159
···
17
"github.com/go-chi/chi/v5"
18
"tangled.sh/tangled.sh/core/appview/config"
19
"tangled.sh/tangled.sh/core/appview/db"
20
"tangled.sh/tangled.sh/core/appview/oauth"
21
"tangled.sh/tangled.sh/core/appview/pages"
22
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
23
+
"tangled.sh/tangled.sh/core/idresolver"
24
"tangled.sh/tangled.sh/core/knotclient"
25
"tangled.sh/tangled.sh/core/rbac"
26
)
···
149
for _, item := range repoCollaborators {
150
// currently only two roles: owner and member
151
var role string
152
+
switch item[3] {
153
+
case "repo:owner":
154
role = "owner"
155
+
case "repo:collaborator":
156
role = "collaborator"
157
+
default:
158
continue
159
}
160
+2
-2
appview/settings/settings.go
+2
-2
appview/settings/settings.go
···
12
13
"github.com/go-chi/chi/v5"
14
"tangled.sh/tangled.sh/core/api/tangled"
15
-
"tangled.sh/tangled.sh/core/appview"
16
"tangled.sh/tangled.sh/core/appview/config"
17
"tangled.sh/tangled.sh/core/appview/db"
18
"tangled.sh/tangled.sh/core/appview/email"
19
"tangled.sh/tangled.sh/core/appview/middleware"
20
"tangled.sh/tangled.sh/core/appview/oauth"
21
"tangled.sh/tangled.sh/core/appview/pages"
22
23
comatproto "github.com/bluesky-social/indigo/api/atproto"
24
lexutil "github.com/bluesky-social/indigo/lex/util"
···
366
return
367
}
368
369
-
rkey := appview.TID()
370
371
tx, err := s.Db.Begin()
372
if err != nil {
···
12
13
"github.com/go-chi/chi/v5"
14
"tangled.sh/tangled.sh/core/api/tangled"
15
"tangled.sh/tangled.sh/core/appview/config"
16
"tangled.sh/tangled.sh/core/appview/db"
17
"tangled.sh/tangled.sh/core/appview/email"
18
"tangled.sh/tangled.sh/core/appview/middleware"
19
"tangled.sh/tangled.sh/core/appview/oauth"
20
"tangled.sh/tangled.sh/core/appview/pages"
21
+
"tangled.sh/tangled.sh/core/tid"
22
23
comatproto "github.com/bluesky-social/indigo/api/atproto"
24
lexutil "github.com/bluesky-social/indigo/lex/util"
···
366
return
367
}
368
369
+
rkey := tid.TID()
370
371
tx, err := s.Db.Begin()
372
if err != nil {
+104
appview/signup/requests.go
+104
appview/signup/requests.go
···
···
1
+
package signup
2
+
3
+
// We have this extra code here for now since the xrpcclient package
4
+
// only supports OAuth'd requests; these are unauthenticated or use PDS admin auth.
5
+
6
+
import (
7
+
"bytes"
8
+
"encoding/json"
9
+
"fmt"
10
+
"io"
11
+
"net/http"
12
+
"net/url"
13
+
)
14
+
15
+
// makePdsRequest is a helper method to make requests to the PDS service
16
+
func (s *Signup) makePdsRequest(method, endpoint string, body interface{}, useAuth bool) (*http.Response, error) {
17
+
jsonData, err := json.Marshal(body)
18
+
if err != nil {
19
+
return nil, err
20
+
}
21
+
22
+
url := fmt.Sprintf("%s/xrpc/%s", s.config.Pds.Host, endpoint)
23
+
req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonData))
24
+
if err != nil {
25
+
return nil, err
26
+
}
27
+
28
+
req.Header.Set("Content-Type", "application/json")
29
+
30
+
if useAuth {
31
+
req.SetBasicAuth("admin", s.config.Pds.AdminSecret)
32
+
}
33
+
34
+
return http.DefaultClient.Do(req)
35
+
}
36
+
37
+
// handlePdsError processes error responses from the PDS service
38
+
func (s *Signup) handlePdsError(resp *http.Response, action string) error {
39
+
var errorResp struct {
40
+
Error string `json:"error"`
41
+
Message string `json:"message"`
42
+
}
43
+
44
+
respBody, _ := io.ReadAll(resp.Body)
45
+
if err := json.Unmarshal(respBody, &errorResp); err == nil && errorResp.Message != "" {
46
+
return fmt.Errorf("Failed to %s: %s - %s.", action, errorResp.Error, errorResp.Message)
47
+
}
48
+
49
+
// Fallback if we couldn't parse the error
50
+
return fmt.Errorf("failed to %s, status code: %d", action, resp.StatusCode)
51
+
}
52
+
53
+
func (s *Signup) inviteCodeRequest() (string, error) {
54
+
body := map[string]any{"useCount": 1}
55
+
56
+
resp, err := s.makePdsRequest("POST", "com.atproto.server.createInviteCode", body, true)
57
+
if err != nil {
58
+
return "", err
59
+
}
60
+
defer resp.Body.Close()
61
+
62
+
if resp.StatusCode != http.StatusOK {
63
+
return "", s.handlePdsError(resp, "create invite code")
64
+
}
65
+
66
+
var result map[string]string
67
+
json.NewDecoder(resp.Body).Decode(&result)
68
+
return result["code"], nil
69
+
}
70
+
71
+
func (s *Signup) createAccountRequest(username, password, email, code string) (string, error) {
72
+
parsedURL, err := url.Parse(s.config.Pds.Host)
73
+
if err != nil {
74
+
return "", fmt.Errorf("invalid PDS host URL: %w", err)
75
+
}
76
+
77
+
pdsDomain := parsedURL.Hostname()
78
+
79
+
body := map[string]string{
80
+
"email": email,
81
+
"handle": fmt.Sprintf("%s.%s", username, pdsDomain),
82
+
"password": password,
83
+
"inviteCode": code,
84
+
}
85
+
86
+
resp, err := s.makePdsRequest("POST", "com.atproto.server.createAccount", body, false)
87
+
if err != nil {
88
+
return "", err
89
+
}
90
+
defer resp.Body.Close()
91
+
92
+
if resp.StatusCode != http.StatusOK {
93
+
return "", s.handlePdsError(resp, "create account")
94
+
}
95
+
96
+
var result struct {
97
+
DID string `json:"did"`
98
+
}
99
+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
100
+
return "", fmt.Errorf("failed to decode create account response: %w", err)
101
+
}
102
+
103
+
return result.DID, nil
104
+
}
+249
appview/signup/signup.go
+249
appview/signup/signup.go
···
···
1
+
package signup
2
+
3
+
import (
4
+
"bufio"
5
+
"fmt"
6
+
"log/slog"
7
+
"net/http"
8
+
"os"
9
+
"strings"
10
+
11
+
"github.com/go-chi/chi/v5"
12
+
"github.com/posthog/posthog-go"
13
+
"tangled.sh/tangled.sh/core/appview/config"
14
+
"tangled.sh/tangled.sh/core/appview/db"
15
+
"tangled.sh/tangled.sh/core/appview/dns"
16
+
"tangled.sh/tangled.sh/core/appview/email"
17
+
"tangled.sh/tangled.sh/core/appview/pages"
18
+
"tangled.sh/tangled.sh/core/appview/state/userutil"
19
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
20
+
"tangled.sh/tangled.sh/core/idresolver"
21
+
)
22
+
23
+
type Signup struct {
24
+
config *config.Config
25
+
db *db.DB
26
+
cf *dns.Cloudflare
27
+
posthog posthog.Client
28
+
xrpc *xrpcclient.Client
29
+
idResolver *idresolver.Resolver
30
+
pages *pages.Pages
31
+
l *slog.Logger
32
+
disallowedNicknames map[string]bool
33
+
}
34
+
35
+
func New(cfg *config.Config, database *db.DB, pc posthog.Client, idResolver *idresolver.Resolver, pages *pages.Pages, l *slog.Logger) *Signup {
36
+
var cf *dns.Cloudflare
37
+
if cfg.Cloudflare.ApiToken != "" && cfg.Cloudflare.ZoneId != "" {
38
+
var err error
39
+
cf, err = dns.NewCloudflare(cfg)
40
+
if err != nil {
41
+
l.Warn("failed to create cloudflare client, signup will be disabled", "error", err)
42
+
}
43
+
}
44
+
45
+
disallowedNicknames := loadDisallowedNicknames(cfg.Core.DisallowedNicknamesFile, l)
46
+
47
+
return &Signup{
48
+
config: cfg,
49
+
db: database,
50
+
posthog: pc,
51
+
idResolver: idResolver,
52
+
cf: cf,
53
+
pages: pages,
54
+
l: l,
55
+
disallowedNicknames: disallowedNicknames,
56
+
}
57
+
}
58
+
59
+
func loadDisallowedNicknames(filepath string, logger *slog.Logger) map[string]bool {
60
+
disallowed := make(map[string]bool)
61
+
62
+
if filepath == "" {
63
+
logger.Debug("no disallowed nicknames file configured")
64
+
return disallowed
65
+
}
66
+
67
+
file, err := os.Open(filepath)
68
+
if err != nil {
69
+
logger.Warn("failed to open disallowed nicknames file", "file", filepath, "error", err)
70
+
return disallowed
71
+
}
72
+
defer file.Close()
73
+
74
+
scanner := bufio.NewScanner(file)
75
+
lineNum := 0
76
+
for scanner.Scan() {
77
+
lineNum++
78
+
line := strings.TrimSpace(scanner.Text())
79
+
if line == "" || strings.HasPrefix(line, "#") {
80
+
continue // skip empty lines and comments
81
+
}
82
+
83
+
nickname := strings.ToLower(line)
84
+
if userutil.IsValidSubdomain(nickname) {
85
+
disallowed[nickname] = true
86
+
} else {
87
+
logger.Warn("invalid nickname format in disallowed nicknames file",
88
+
"file", filepath, "line", lineNum, "nickname", nickname)
89
+
}
90
+
}
91
+
92
+
if err := scanner.Err(); err != nil {
93
+
logger.Error("error reading disallowed nicknames file", "file", filepath, "error", err)
94
+
}
95
+
96
+
logger.Info("loaded disallowed nicknames", "count", len(disallowed), "file", filepath)
97
+
return disallowed
98
+
}
99
+
100
+
// isNicknameAllowed checks if a nickname is allowed (not in the disallowed list)
101
+
func (s *Signup) isNicknameAllowed(nickname string) bool {
102
+
return !s.disallowedNicknames[strings.ToLower(nickname)]
103
+
}
104
+
105
+
func (s *Signup) Router() http.Handler {
106
+
r := chi.NewRouter()
107
+
r.Post("/", s.signup)
108
+
r.Get("/complete", s.complete)
109
+
r.Post("/complete", s.complete)
110
+
111
+
return r
112
+
}
113
+
114
+
func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
115
+
if s.cf == nil {
116
+
http.Error(w, "signup is disabled", http.StatusFailedDependency)
117
+
}
118
+
emailId := r.FormValue("email")
119
+
120
+
if !email.IsValidEmail(emailId) {
121
+
s.pages.Notice(w, "login-msg", "Invalid email address.")
122
+
return
123
+
}
124
+
125
+
exists, err := db.CheckEmailExistsAtAll(s.db, emailId)
126
+
if err != nil {
127
+
s.l.Error("failed to check email existence", "error", err)
128
+
s.pages.Notice(w, "login-msg", "Failed to complete signup. Try again later.")
129
+
return
130
+
}
131
+
if exists {
132
+
s.pages.Notice(w, "login-msg", "Email already exists.")
133
+
return
134
+
}
135
+
136
+
code, err := s.inviteCodeRequest()
137
+
if err != nil {
138
+
s.l.Error("failed to create invite code", "error", err)
139
+
s.pages.Notice(w, "login-msg", "Failed to create invite code.")
140
+
return
141
+
}
142
+
143
+
em := email.Email{
144
+
APIKey: s.config.Resend.ApiKey,
145
+
From: s.config.Resend.SentFrom,
146
+
To: emailId,
147
+
Subject: "Verify your Tangled account",
148
+
Text: `Copy and paste this code below to verify your account on Tangled.
149
+
` + code,
150
+
Html: `<p>Copy and paste this code below to verify your account on Tangled.</p>
151
+
<p><code>` + code + `</code></p>`,
152
+
}
153
+
154
+
err = email.SendEmail(em)
155
+
if err != nil {
156
+
s.l.Error("failed to send email", "error", err)
157
+
s.pages.Notice(w, "login-msg", "Failed to send email.")
158
+
return
159
+
}
160
+
err = db.AddInflightSignup(s.db, db.InflightSignup{
161
+
Email: emailId,
162
+
InviteCode: code,
163
+
})
164
+
if err != nil {
165
+
s.l.Error("failed to add inflight signup", "error", err)
166
+
s.pages.Notice(w, "login-msg", "Failed to complete sign up. Try again later.")
167
+
return
168
+
}
169
+
170
+
s.pages.HxRedirect(w, "/signup/complete")
171
+
}
172
+
173
+
func (s *Signup) complete(w http.ResponseWriter, r *http.Request) {
174
+
switch r.Method {
175
+
case http.MethodGet:
176
+
s.pages.CompleteSignup(w, pages.SignupParams{})
177
+
case http.MethodPost:
178
+
username := r.FormValue("username")
179
+
password := r.FormValue("password")
180
+
code := r.FormValue("code")
181
+
182
+
if !userutil.IsValidSubdomain(username) {
183
+
s.pages.Notice(w, "signup-error", "Invalid username. Username must be 4โ63 characters, lowercase letters, digits, or hyphens, and can't start or end with a hyphen.")
184
+
return
185
+
}
186
+
187
+
if !s.isNicknameAllowed(username) {
188
+
s.pages.Notice(w, "signup-error", "This username is not available. Please choose a different one.")
189
+
return
190
+
}
191
+
192
+
email, err := db.GetEmailForCode(s.db, code)
193
+
if err != nil {
194
+
s.l.Error("failed to get email for code", "error", err)
195
+
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
196
+
return
197
+
}
198
+
199
+
did, err := s.createAccountRequest(username, password, email, code)
200
+
if err != nil {
201
+
s.l.Error("failed to create account", "error", err)
202
+
s.pages.Notice(w, "signup-error", err.Error())
203
+
return
204
+
}
205
+
206
+
if s.cf == nil {
207
+
s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration")
208
+
s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.")
209
+
return
210
+
}
211
+
212
+
err = s.cf.CreateDNSRecord(r.Context(), dns.Record{
213
+
Type: "TXT",
214
+
Name: "_atproto." + username,
215
+
Content: "did=" + did,
216
+
TTL: 6400,
217
+
Proxied: false,
218
+
})
219
+
if err != nil {
220
+
s.l.Error("failed to create DNS record", "error", err)
221
+
s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.")
222
+
return
223
+
}
224
+
225
+
err = db.AddEmail(s.db, db.Email{
226
+
Did: did,
227
+
Address: email,
228
+
Verified: true,
229
+
Primary: true,
230
+
})
231
+
if err != nil {
232
+
s.l.Error("failed to add email", "error", err)
233
+
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
234
+
return
235
+
}
236
+
237
+
s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now
238
+
<a class="underline text-black dark:text-white" href="/login">login</a>
239
+
with <code>%s.tngl.sh</code>.`, username))
240
+
241
+
go func() {
242
+
err := db.DeleteInflightSignup(s.db, email)
243
+
if err != nil {
244
+
s.l.Error("failed to delete inflight signup", "error", err)
245
+
}
246
+
}()
247
+
return
248
+
}
249
+
}
+26
-14
appview/spindles/spindles.go
+26
-14
appview/spindles/spindles.go
···
10
11
"github.com/go-chi/chi/v5"
12
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview"
14
"tangled.sh/tangled.sh/core/appview/config"
15
"tangled.sh/tangled.sh/core/appview/db"
16
-
"tangled.sh/tangled.sh/core/appview/idresolver"
17
"tangled.sh/tangled.sh/core/appview/middleware"
18
"tangled.sh/tangled.sh/core/appview/oauth"
19
"tangled.sh/tangled.sh/core/appview/pages"
20
verify "tangled.sh/tangled.sh/core/appview/spindleverify"
21
"tangled.sh/tangled.sh/core/rbac"
22
23
comatproto "github.com/bluesky-social/indigo/api/atproto"
24
"github.com/bluesky-social/indigo/atproto/syntax"
···
104
105
repos, err := db.GetRepos(
106
s.Db,
107
db.FilterEq("spindle", instance),
108
)
109
if err != nil {
···
113
}
114
115
identsToResolve := make([]string, len(members))
116
-
for i, member := range members {
117
-
identsToResolve[i] = member
118
-
}
119
resolvedIds := s.IdResolver.ResolveIdents(r.Context(), identsToResolve)
120
didHandleMap := make(map[string]string)
121
for _, identity := range resolvedIds {
···
257
258
// ok
259
s.Pages.HxRefresh(w)
260
-
return
261
}
262
263
func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) {
···
305
s.Enforcer.E.LoadPolicy()
306
}()
307
308
-
err = db.DeleteSpindle(
309
tx,
310
-
db.FilterEq("owner", user.Did),
311
db.FilterEq("instance", instance),
312
)
313
if err != nil {
314
-
l.Error("failed to delete spindle", "err", err)
315
fail()
316
return
317
}
318
319
-
err = s.Enforcer.RemoveSpindle(instance)
320
if err != nil {
321
-
l.Error("failed to update ACL", "err", err)
322
fail()
323
return
324
}
325
326
client, err := s.OAuth.AuthorizedClient(r)
327
if err != nil {
328
l.Error("failed to authorize client", "err", err)
···
520
s.Enforcer.E.LoadPolicy()
521
}()
522
523
-
rkey := appview.TID()
524
525
// add member to db
526
if err = db.AddSpindleMember(tx, db.SpindleMember{
···
579
l := s.Logger.With("handler", "removeMember")
580
581
noticeId := "operation-error"
582
-
defaultErr := "Failed to add member. Try again later."
583
fail := func() {
584
s.Pages.Notice(w, noticeId, defaultErr)
585
}
···
707
708
// ok
709
s.Pages.HxRefresh(w)
710
-
return
711
}
···
10
11
"github.com/go-chi/chi/v5"
12
"tangled.sh/tangled.sh/core/api/tangled"
13
"tangled.sh/tangled.sh/core/appview/config"
14
"tangled.sh/tangled.sh/core/appview/db"
15
"tangled.sh/tangled.sh/core/appview/middleware"
16
"tangled.sh/tangled.sh/core/appview/oauth"
17
"tangled.sh/tangled.sh/core/appview/pages"
18
verify "tangled.sh/tangled.sh/core/appview/spindleverify"
19
+
"tangled.sh/tangled.sh/core/idresolver"
20
"tangled.sh/tangled.sh/core/rbac"
21
+
"tangled.sh/tangled.sh/core/tid"
22
23
comatproto "github.com/bluesky-social/indigo/api/atproto"
24
"github.com/bluesky-social/indigo/atproto/syntax"
···
104
105
repos, err := db.GetRepos(
106
s.Db,
107
+
0,
108
db.FilterEq("spindle", instance),
109
)
110
if err != nil {
···
114
}
115
116
identsToResolve := make([]string, len(members))
117
+
copy(identsToResolve, members)
118
resolvedIds := s.IdResolver.ResolveIdents(r.Context(), identsToResolve)
119
didHandleMap := make(map[string]string)
120
for _, identity := range resolvedIds {
···
256
257
// ok
258
s.Pages.HxRefresh(w)
259
}
260
261
func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) {
···
303
s.Enforcer.E.LoadPolicy()
304
}()
305
306
+
// remove spindle members first
307
+
err = db.RemoveSpindleMember(
308
tx,
309
+
db.FilterEq("did", user.Did),
310
db.FilterEq("instance", instance),
311
)
312
if err != nil {
313
+
l.Error("failed to remove spindle members", "err", err)
314
fail()
315
return
316
}
317
318
+
err = db.DeleteSpindle(
319
+
tx,
320
+
db.FilterEq("owner", user.Did),
321
+
db.FilterEq("instance", instance),
322
+
)
323
if err != nil {
324
+
l.Error("failed to delete spindle", "err", err)
325
fail()
326
return
327
}
328
329
+
// delete from enforcer
330
+
if spindles[0].Verified != nil {
331
+
err = s.Enforcer.RemoveSpindle(instance)
332
+
if err != nil {
333
+
l.Error("failed to update ACL", "err", err)
334
+
fail()
335
+
return
336
+
}
337
+
}
338
+
339
client, err := s.OAuth.AuthorizedClient(r)
340
if err != nil {
341
l.Error("failed to authorize client", "err", err)
···
533
s.Enforcer.E.LoadPolicy()
534
}()
535
536
+
rkey := tid.TID()
537
538
// add member to db
539
if err = db.AddSpindleMember(tx, db.SpindleMember{
···
592
l := s.Logger.With("handler", "removeMember")
593
594
noticeId := "operation-error"
595
+
defaultErr := "Failed to remove member. Try again later."
596
fail := func() {
597
s.Pages.Notice(w, noticeId, defaultErr)
598
}
···
720
721
// ok
722
s.Pages.HxRefresh(w)
723
}
+13
-26
appview/state/follow.go
+13
-26
appview/state/follow.go
···
7
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
lexutil "github.com/bluesky-social/indigo/lex/util"
10
-
"github.com/posthog/posthog-go"
11
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/appview"
13
"tangled.sh/tangled.sh/core/appview/db"
14
"tangled.sh/tangled.sh/core/appview/pages"
15
)
16
17
func (s *State) Follow(w http.ResponseWriter, r *http.Request) {
···
42
switch r.Method {
43
case http.MethodPost:
44
createdAt := time.Now().Format(time.RFC3339)
45
-
rkey := appview.TID()
46
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
47
Collection: tangled.GraphFollowNSID,
48
Repo: currentUser.Did,
···
58
return
59
}
60
61
-
err = db.AddFollow(s.db, currentUser.Did, subjectIdent.DID.String(), rkey)
62
if err != nil {
63
log.Println("failed to follow", err)
64
return
65
}
66
67
-
log.Println("created atproto record: ", resp.Uri)
68
69
s.pages.FollowFragment(w, pages.FollowFragmentParams{
70
UserDid: subjectIdent.DID.String(),
71
FollowStatus: db.IsFollowing,
72
})
73
74
-
if !s.config.Core.Dev {
75
-
err = s.posthog.Enqueue(posthog.Capture{
76
-
DistinctId: currentUser.Did,
77
-
Event: "follow",
78
-
Properties: posthog.Properties{"subject": subjectIdent.DID.String()},
79
-
})
80
-
if err != nil {
81
-
log.Println("failed to enqueue posthog event:", err)
82
-
}
83
-
}
84
-
85
return
86
case http.MethodDelete:
87
// find the record in the db
···
113
FollowStatus: db.IsNotFollowing,
114
})
115
116
-
if !s.config.Core.Dev {
117
-
err = s.posthog.Enqueue(posthog.Capture{
118
-
DistinctId: currentUser.Did,
119
-
Event: "unfollow",
120
-
Properties: posthog.Properties{"subject": subjectIdent.DID.String()},
121
-
})
122
-
if err != nil {
123
-
log.Println("failed to enqueue posthog event:", err)
124
-
}
125
-
}
126
127
return
128
}
···
7
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
lexutil "github.com/bluesky-social/indigo/lex/util"
10
"tangled.sh/tangled.sh/core/api/tangled"
11
"tangled.sh/tangled.sh/core/appview/db"
12
"tangled.sh/tangled.sh/core/appview/pages"
13
+
"tangled.sh/tangled.sh/core/tid"
14
)
15
16
func (s *State) Follow(w http.ResponseWriter, r *http.Request) {
···
41
switch r.Method {
42
case http.MethodPost:
43
createdAt := time.Now().Format(time.RFC3339)
44
+
rkey := tid.TID()
45
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
46
Collection: tangled.GraphFollowNSID,
47
Repo: currentUser.Did,
···
57
return
58
}
59
60
+
log.Println("created atproto record: ", resp.Uri)
61
+
62
+
follow := &db.Follow{
63
+
UserDid: currentUser.Did,
64
+
SubjectDid: subjectIdent.DID.String(),
65
+
Rkey: rkey,
66
+
}
67
+
68
+
err = db.AddFollow(s.db, follow)
69
if err != nil {
70
log.Println("failed to follow", err)
71
return
72
}
73
74
+
s.notifier.NewFollow(r.Context(), follow)
75
76
s.pages.FollowFragment(w, pages.FollowFragmentParams{
77
UserDid: subjectIdent.DID.String(),
78
FollowStatus: db.IsFollowing,
79
})
80
81
return
82
case http.MethodDelete:
83
// find the record in the db
···
109
FollowStatus: db.IsNotFollowing,
110
})
111
112
+
s.notifier.DeleteFollow(r.Context(), follow)
113
114
return
115
}
+75
-12
appview/state/knotstream.go
+75
-12
appview/state/knotstream.go
···
3
import (
4
"context"
5
"encoding/json"
6
"fmt"
7
"slices"
8
"time"
···
18
"tangled.sh/tangled.sh/core/workflow"
19
20
"github.com/bluesky-social/indigo/atproto/syntax"
21
"github.com/posthog/posthog-go"
22
)
23
···
39
40
cfg := ec.ConsumerConfig{
41
Sources: srcs,
42
-
ProcessFunc: knotIngester(ctx, d, enforcer, posthog, c.Core.Dev),
43
RetryInterval: c.Knotstream.RetryInterval,
44
MaxRetryInterval: c.Knotstream.MaxRetryInterval,
45
ConnectionTimeout: c.Knotstream.ConnectionTimeout,
···
53
return ec.NewConsumer(cfg), nil
54
}
55
56
-
func knotIngester(ctx context.Context, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client, dev bool) ec.ProcessFunc {
57
return func(ctx context.Context, source ec.Source, msg ec.Message) error {
58
switch msg.Nsid {
59
case tangled.GitRefUpdateNSID:
···
81
return fmt.Errorf("%s does not belong to %s, something is fishy", record.CommitterDid, source.Key())
82
}
83
84
knownEmails, err := db.GetAllEmails(d, record.CommitterDid)
85
if err != nil {
86
return err
87
}
88
count := 0
89
for _, ke := range knownEmails {
90
if record.Meta == nil {
···
108
Date: time.Now(),
109
Count: count,
110
}
111
-
if err := db.AddPunch(d, punch); err != nil {
112
-
return err
113
}
114
115
-
if !dev {
116
-
err = pc.Enqueue(posthog.Capture{
117
-
DistinctId: record.CommitterDid,
118
-
Event: "git_ref_update",
119
})
120
-
if err != nil {
121
-
// non-fatal, TODO: log this
122
-
}
123
}
124
125
-
return nil
126
}
127
128
func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
···
138
139
if record.TriggerMetadata.Repo == nil {
140
return fmt.Errorf("empty repo: nsid %s, rkey %s", msg.Nsid, msg.Rkey)
141
}
142
143
// trigger info
···
3
import (
4
"context"
5
"encoding/json"
6
+
"errors"
7
"fmt"
8
"slices"
9
"time"
···
19
"tangled.sh/tangled.sh/core/workflow"
20
21
"github.com/bluesky-social/indigo/atproto/syntax"
22
+
"github.com/go-git/go-git/v5/plumbing"
23
"github.com/posthog/posthog-go"
24
)
25
···
41
42
cfg := ec.ConsumerConfig{
43
Sources: srcs,
44
+
ProcessFunc: knotIngester(d, enforcer, posthog, c.Core.Dev),
45
RetryInterval: c.Knotstream.RetryInterval,
46
MaxRetryInterval: c.Knotstream.MaxRetryInterval,
47
ConnectionTimeout: c.Knotstream.ConnectionTimeout,
···
55
return ec.NewConsumer(cfg), nil
56
}
57
58
+
func knotIngester(d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client, dev bool) ec.ProcessFunc {
59
return func(ctx context.Context, source ec.Source, msg ec.Message) error {
60
switch msg.Nsid {
61
case tangled.GitRefUpdateNSID:
···
83
return fmt.Errorf("%s does not belong to %s, something is fishy", record.CommitterDid, source.Key())
84
}
85
86
+
err1 := populatePunchcard(d, record)
87
+
err2 := updateRepoLanguages(d, record)
88
+
89
+
var err3 error
90
+
if !dev {
91
+
err3 = pc.Enqueue(posthog.Capture{
92
+
DistinctId: record.CommitterDid,
93
+
Event: "git_ref_update",
94
+
})
95
+
}
96
+
97
+
return errors.Join(err1, err2, err3)
98
+
}
99
+
100
+
func populatePunchcard(d *db.DB, record tangled.GitRefUpdate) error {
101
knownEmails, err := db.GetAllEmails(d, record.CommitterDid)
102
if err != nil {
103
return err
104
}
105
+
106
count := 0
107
for _, ke := range knownEmails {
108
if record.Meta == nil {
···
126
Date: time.Now(),
127
Count: count,
128
}
129
+
return db.AddPunch(d, punch)
130
+
}
131
+
132
+
func updateRepoLanguages(d *db.DB, record tangled.GitRefUpdate) error {
133
+
if record.Meta == nil || record.Meta.LangBreakdown == nil || record.Meta.LangBreakdown.Inputs == nil {
134
+
return fmt.Errorf("empty language data for repo: %s/%s", record.RepoDid, record.RepoName)
135
+
}
136
+
137
+
repos, err := db.GetRepos(
138
+
d,
139
+
0,
140
+
db.FilterEq("did", record.RepoDid),
141
+
db.FilterEq("name", record.RepoName),
142
+
)
143
+
if err != nil {
144
+
return fmt.Errorf("failed to look for repo in DB (%s/%s): %w", record.RepoDid, record.RepoName, err)
145
+
}
146
+
if len(repos) != 1 {
147
+
return fmt.Errorf("incorrect number of repos returned: %d (expected 1)", len(repos))
148
+
}
149
+
repo := repos[0]
150
+
151
+
ref := plumbing.ReferenceName(record.Ref)
152
+
if !ref.IsBranch() {
153
+
return fmt.Errorf("%s is not a valid reference name", ref)
154
}
155
156
+
var langs []db.RepoLanguage
157
+
for _, l := range record.Meta.LangBreakdown.Inputs {
158
+
if l == nil {
159
+
continue
160
+
}
161
+
162
+
langs = append(langs, db.RepoLanguage{
163
+
RepoAt: repo.RepoAt(),
164
+
Ref: ref.Short(),
165
+
IsDefaultRef: record.Meta.IsDefaultRef,
166
+
Language: l.Lang,
167
+
Bytes: l.Size,
168
})
169
}
170
171
+
return db.InsertRepoLanguages(d, langs)
172
}
173
174
func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
···
184
185
if record.TriggerMetadata.Repo == nil {
186
return fmt.Errorf("empty repo: nsid %s, rkey %s", msg.Nsid, msg.Rkey)
187
+
}
188
+
189
+
// does this repo have a spindle configured?
190
+
repos, err := db.GetRepos(
191
+
d,
192
+
0,
193
+
db.FilterEq("did", record.TriggerMetadata.Repo.Did),
194
+
db.FilterEq("name", record.TriggerMetadata.Repo.Repo),
195
+
)
196
+
if err != nil {
197
+
return fmt.Errorf("failed to look for repo in DB: nsid %s, rkey %s, %w", msg.Nsid, msg.Rkey, err)
198
+
}
199
+
if len(repos) != 1 {
200
+
return fmt.Errorf("incorrect number of repos returned: %d (expected 1)", len(repos))
201
+
}
202
+
if repos[0].Spindle == "" {
203
+
return fmt.Errorf("repo does not have a spindle configured yet: nsid %s, rkey %s", msg.Nsid, msg.Rkey)
204
}
205
206
// trigger info
+12
-15
appview/state/profile.go
+12
-15
appview/state/profile.go
···
16
"github.com/bluesky-social/indigo/atproto/syntax"
17
lexutil "github.com/bluesky-social/indigo/lex/util"
18
"github.com/go-chi/chi/v5"
19
-
"github.com/posthog/posthog-go"
20
"tangled.sh/tangled.sh/core/api/tangled"
21
"tangled.sh/tangled.sh/core/appview/db"
22
"tangled.sh/tangled.sh/core/appview/pages"
···
50
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
51
}
52
53
-
repos, err := db.GetAllReposByDid(s.db, ident.DID.String())
54
if err != nil {
55
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
56
}
···
171
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
172
}
173
174
-
repos, err := db.GetAllReposByDid(s.db, ident.DID.String())
175
if err != nil {
176
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
177
}
···
192
s.pages.ReposPage(w, pages.ReposPageParams{
193
LoggedInUser: loggedInUser,
194
Repos: repos,
195
Card: pages.ProfileCard{
196
UserDid: ident.DID.String(),
197
UserHandle: ident.Handle.String(),
···
257
}
258
259
s.updateProfile(profile, w, r)
260
-
return
261
}
262
263
func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) {
···
297
profile.PinnedRepos = pinnedRepos
298
299
s.updateProfile(profile, w, r)
300
-
return
301
}
302
303
func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) {
···
362
return
363
}
364
365
-
if !s.config.Core.Dev {
366
-
err = s.posthog.Enqueue(posthog.Capture{
367
-
DistinctId: user.Did,
368
-
Event: "edit_profile",
369
-
})
370
-
if err != nil {
371
-
log.Println("failed to enqueue posthog event:", err)
372
-
}
373
-
}
374
375
s.pages.HxRedirect(w, "/"+user.Did)
376
-
return
377
}
378
379
func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) {
···
16
"github.com/bluesky-social/indigo/atproto/syntax"
17
lexutil "github.com/bluesky-social/indigo/lex/util"
18
"github.com/go-chi/chi/v5"
19
"tangled.sh/tangled.sh/core/api/tangled"
20
"tangled.sh/tangled.sh/core/appview/db"
21
"tangled.sh/tangled.sh/core/appview/pages"
···
49
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
50
}
51
52
+
repos, err := db.GetRepos(
53
+
s.db,
54
+
0,
55
+
db.FilterEq("did", ident.DID.String()),
56
+
)
57
if err != nil {
58
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
59
}
···
174
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
175
}
176
177
+
repos, err := db.GetRepos(
178
+
s.db,
179
+
0,
180
+
db.FilterEq("did", ident.DID.String()),
181
+
)
182
if err != nil {
183
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
184
}
···
199
s.pages.ReposPage(w, pages.ReposPageParams{
200
LoggedInUser: loggedInUser,
201
Repos: repos,
202
+
DidHandleMap: map[string]string{ident.DID.String(): ident.Handle.String()},
203
Card: pages.ProfileCard{
204
UserDid: ident.DID.String(),
205
UserHandle: ident.Handle.String(),
···
265
}
266
267
s.updateProfile(profile, w, r)
268
}
269
270
func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) {
···
304
profile.PinnedRepos = pinnedRepos
305
306
s.updateProfile(profile, w, r)
307
}
308
309
func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) {
···
368
return
369
}
370
371
+
s.notifier.UpdateProfile(r.Context(), profile)
372
373
s.pages.HxRedirect(w, "/"+user.Did)
374
}
375
376
func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) {
+126
appview/state/reaction.go
+126
appview/state/reaction.go
···
···
1
+
package state
2
+
3
+
import (
4
+
"log"
5
+
"net/http"
6
+
"time"
7
+
8
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
11
+
lexutil "github.com/bluesky-social/indigo/lex/util"
12
+
"tangled.sh/tangled.sh/core/api/tangled"
13
+
"tangled.sh/tangled.sh/core/appview/db"
14
+
"tangled.sh/tangled.sh/core/appview/pages"
15
+
"tangled.sh/tangled.sh/core/tid"
16
+
)
17
+
18
+
func (s *State) React(w http.ResponseWriter, r *http.Request) {
19
+
currentUser := s.oauth.GetUser(r)
20
+
21
+
subject := r.URL.Query().Get("subject")
22
+
if subject == "" {
23
+
log.Println("invalid form")
24
+
return
25
+
}
26
+
27
+
subjectUri, err := syntax.ParseATURI(subject)
28
+
if err != nil {
29
+
log.Println("invalid form")
30
+
return
31
+
}
32
+
33
+
reactionKind, ok := db.ParseReactionKind(r.URL.Query().Get("kind"))
34
+
if !ok {
35
+
log.Println("invalid reaction kind")
36
+
return
37
+
}
38
+
39
+
client, err := s.oauth.AuthorizedClient(r)
40
+
if err != nil {
41
+
log.Println("failed to authorize client", err)
42
+
return
43
+
}
44
+
45
+
switch r.Method {
46
+
case http.MethodPost:
47
+
createdAt := time.Now().Format(time.RFC3339)
48
+
rkey := tid.TID()
49
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
50
+
Collection: tangled.FeedReactionNSID,
51
+
Repo: currentUser.Did,
52
+
Rkey: rkey,
53
+
Record: &lexutil.LexiconTypeDecoder{
54
+
Val: &tangled.FeedReaction{
55
+
Subject: subjectUri.String(),
56
+
Reaction: reactionKind.String(),
57
+
CreatedAt: createdAt,
58
+
},
59
+
},
60
+
})
61
+
if err != nil {
62
+
log.Println("failed to create atproto record", err)
63
+
return
64
+
}
65
+
66
+
err = db.AddReaction(s.db, currentUser.Did, subjectUri, reactionKind, rkey)
67
+
if err != nil {
68
+
log.Println("failed to react", err)
69
+
return
70
+
}
71
+
72
+
count, err := db.GetReactionCount(s.db, subjectUri, reactionKind)
73
+
if err != nil {
74
+
log.Println("failed to get reaction count for ", subjectUri)
75
+
}
76
+
77
+
log.Println("created atproto record: ", resp.Uri)
78
+
79
+
s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{
80
+
ThreadAt: subjectUri,
81
+
Kind: reactionKind,
82
+
Count: count,
83
+
IsReacted: true,
84
+
})
85
+
86
+
return
87
+
case http.MethodDelete:
88
+
reaction, err := db.GetReaction(s.db, currentUser.Did, subjectUri, reactionKind)
89
+
if err != nil {
90
+
log.Println("failed to get reaction relationship for", currentUser.Did, subjectUri)
91
+
return
92
+
}
93
+
94
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
95
+
Collection: tangled.FeedReactionNSID,
96
+
Repo: currentUser.Did,
97
+
Rkey: reaction.Rkey,
98
+
})
99
+
100
+
if err != nil {
101
+
log.Println("failed to remove reaction")
102
+
return
103
+
}
104
+
105
+
err = db.DeleteReactionByRkey(s.db, currentUser.Did, reaction.Rkey)
106
+
if err != nil {
107
+
log.Println("failed to delete reaction from DB")
108
+
// this is not an issue, the firehose event might have already done this
109
+
}
110
+
111
+
count, err := db.GetReactionCount(s.db, subjectUri, reactionKind)
112
+
if err != nil {
113
+
log.Println("failed to get reaction count for ", subjectUri)
114
+
return
115
+
}
116
+
117
+
s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{
118
+
ThreadAt: subjectUri,
119
+
Kind: reactionKind,
120
+
Count: count,
121
+
IsReacted: false,
122
+
})
123
+
124
+
return
125
+
}
126
+
}
+40
-22
appview/state/router.go
+40
-22
appview/state/router.go
···
7
"github.com/go-chi/chi/v5"
8
"github.com/gorilla/sessions"
9
"tangled.sh/tangled.sh/core/appview/issues"
10
"tangled.sh/tangled.sh/core/appview/middleware"
11
oauthhandler "tangled.sh/tangled.sh/core/appview/oauth/handler"
12
"tangled.sh/tangled.sh/core/appview/pipelines"
13
"tangled.sh/tangled.sh/core/appview/pulls"
14
"tangled.sh/tangled.sh/core/appview/repo"
15
"tangled.sh/tangled.sh/core/appview/settings"
16
"tangled.sh/tangled.sh/core/appview/spindles"
17
"tangled.sh/tangled.sh/core/appview/state/userutil"
18
"tangled.sh/tangled.sh/core/log"
···
101
102
r.Get("/", s.Timeline)
103
104
-
r.Route("/knots", func(r chi.Router) {
105
-
r.Use(middleware.AuthMiddleware(s.oauth))
106
-
r.Get("/", s.Knots)
107
-
r.Post("/key", s.RegistrationKey)
108
-
109
-
r.Route("/{domain}", func(r chi.Router) {
110
-
r.Post("/init", s.InitKnotServer)
111
-
r.Get("/", s.KnotServerInfo)
112
-
r.Route("/member", func(r chi.Router) {
113
-
r.Use(mw.KnotOwner())
114
-
r.Get("/", s.ListMembers)
115
-
r.Put("/", s.AddMember)
116
-
r.Delete("/", s.RemoveMember)
117
-
})
118
-
})
119
-
})
120
-
121
r.Route("/repo", func(r chi.Router) {
122
r.Route("/new", func(r chi.Router) {
123
r.Use(middleware.AuthMiddleware(s.oauth))
···
135
r.With(middleware.AuthMiddleware(s.oauth)).Route("/star", func(r chi.Router) {
136
r.Post("/", s.Star)
137
r.Delete("/", s.Star)
138
})
139
140
r.Route("/profile", func(r chi.Router) {
···
146
})
147
148
r.Mount("/settings", s.SettingsRouter())
149
r.Mount("/spindles", s.SpindlesRouter())
150
r.Mount("/", s.OAuthRouter())
151
152
r.Get("/keys/{user}", s.Keys)
153
154
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
155
s.pages.Error404(w)
···
190
return spindles.Router()
191
}
192
193
func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
194
-
issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.posthog)
195
return issues.Router(mw)
196
-
197
}
198
199
func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler {
200
-
pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.posthog)
201
return pulls.Router(mw)
202
}
203
204
func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler {
205
-
repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.posthog, s.enforcer)
206
return repo.Router(mw)
207
}
208
209
func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler {
210
-
pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.posthog, s.enforcer)
211
return pipes.Router(mw)
212
}
···
7
"github.com/go-chi/chi/v5"
8
"github.com/gorilla/sessions"
9
"tangled.sh/tangled.sh/core/appview/issues"
10
+
"tangled.sh/tangled.sh/core/appview/knots"
11
"tangled.sh/tangled.sh/core/appview/middleware"
12
oauthhandler "tangled.sh/tangled.sh/core/appview/oauth/handler"
13
"tangled.sh/tangled.sh/core/appview/pipelines"
14
"tangled.sh/tangled.sh/core/appview/pulls"
15
"tangled.sh/tangled.sh/core/appview/repo"
16
"tangled.sh/tangled.sh/core/appview/settings"
17
+
"tangled.sh/tangled.sh/core/appview/signup"
18
"tangled.sh/tangled.sh/core/appview/spindles"
19
"tangled.sh/tangled.sh/core/appview/state/userutil"
20
"tangled.sh/tangled.sh/core/log"
···
103
104
r.Get("/", s.Timeline)
105
106
r.Route("/repo", func(r chi.Router) {
107
r.Route("/new", func(r chi.Router) {
108
r.Use(middleware.AuthMiddleware(s.oauth))
···
120
r.With(middleware.AuthMiddleware(s.oauth)).Route("/star", func(r chi.Router) {
121
r.Post("/", s.Star)
122
r.Delete("/", s.Star)
123
+
})
124
+
125
+
r.With(middleware.AuthMiddleware(s.oauth)).Route("/react", func(r chi.Router) {
126
+
r.Post("/", s.React)
127
+
r.Delete("/", s.React)
128
})
129
130
r.Route("/profile", func(r chi.Router) {
···
136
})
137
138
r.Mount("/settings", s.SettingsRouter())
139
+
r.Mount("/knots", s.KnotsRouter(mw))
140
r.Mount("/spindles", s.SpindlesRouter())
141
+
r.Mount("/signup", s.SignupRouter())
142
r.Mount("/", s.OAuthRouter())
143
144
r.Get("/keys/{user}", s.Keys)
145
+
r.Get("/terms", s.TermsOfService)
146
+
r.Get("/privacy", s.PrivacyPolicy)
147
148
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
149
s.pages.Error404(w)
···
184
return spindles.Router()
185
}
186
187
+
func (s *State) KnotsRouter(mw *middleware.Middleware) http.Handler {
188
+
logger := log.New("knots")
189
+
190
+
knots := &knots.Knots{
191
+
Db: s.db,
192
+
OAuth: s.oauth,
193
+
Pages: s.pages,
194
+
Config: s.config,
195
+
Enforcer: s.enforcer,
196
+
IdResolver: s.idResolver,
197
+
Knotstream: s.knotstream,
198
+
Logger: logger,
199
+
}
200
+
201
+
return knots.Router(mw)
202
+
}
203
+
204
func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
205
+
issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier)
206
return issues.Router(mw)
207
}
208
209
func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler {
210
+
pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier)
211
return pulls.Router(mw)
212
}
213
214
func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler {
215
+
logger := log.New("repo")
216
+
repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger)
217
return repo.Router(mw)
218
}
219
220
func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler {
221
+
pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer)
222
return pipes.Router(mw)
223
}
224
+
225
+
func (s *State) SignupRouter() http.Handler {
226
+
logger := log.New("signup")
227
+
228
+
sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, logger)
229
+
return sig.Router()
230
+
}
+15
-29
appview/state/star.go
+15
-29
appview/state/star.go
···
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
lexutil "github.com/bluesky-social/indigo/lex/util"
11
-
"github.com/posthog/posthog-go"
12
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview"
14
"tangled.sh/tangled.sh/core/appview/db"
15
"tangled.sh/tangled.sh/core/appview/pages"
16
)
17
18
func (s *State) Star(w http.ResponseWriter, r *http.Request) {
···
39
switch r.Method {
40
case http.MethodPost:
41
createdAt := time.Now().Format(time.RFC3339)
42
-
rkey := appview.TID()
43
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
44
Collection: tangled.FeedStarNSID,
45
Repo: currentUser.Did,
···
54
log.Println("failed to create atproto record", err)
55
return
56
}
57
58
-
err = db.AddStar(s.db, currentUser.Did, subjectUri, rkey)
59
if err != nil {
60
log.Println("failed to star", err)
61
return
···
66
log.Println("failed to get star count for ", subjectUri)
67
}
68
69
-
log.Println("created atproto record: ", resp.Uri)
70
71
-
s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{
72
IsStarred: true,
73
RepoAt: subjectUri,
74
Stats: db.RepoStats{
···
76
},
77
})
78
79
-
if !s.config.Core.Dev {
80
-
err = s.posthog.Enqueue(posthog.Capture{
81
-
DistinctId: currentUser.Did,
82
-
Event: "star",
83
-
Properties: posthog.Properties{"repo_at": subjectUri.String()},
84
-
})
85
-
if err != nil {
86
-
log.Println("failed to enqueue posthog event:", err)
87
-
}
88
-
}
89
-
90
return
91
case http.MethodDelete:
92
// find the record in the db
···
119
return
120
}
121
122
-
s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{
123
IsStarred: false,
124
RepoAt: subjectUri,
125
Stats: db.RepoStats{
126
StarCount: starCount,
127
},
128
})
129
-
130
-
if !s.config.Core.Dev {
131
-
err = s.posthog.Enqueue(posthog.Capture{
132
-
DistinctId: currentUser.Did,
133
-
Event: "unstar",
134
-
Properties: posthog.Properties{"repo_at": subjectUri.String()},
135
-
})
136
-
if err != nil {
137
-
log.Println("failed to enqueue posthog event:", err)
138
-
}
139
-
}
140
141
return
142
}
···
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
lexutil "github.com/bluesky-social/indigo/lex/util"
11
"tangled.sh/tangled.sh/core/api/tangled"
12
"tangled.sh/tangled.sh/core/appview/db"
13
"tangled.sh/tangled.sh/core/appview/pages"
14
+
"tangled.sh/tangled.sh/core/tid"
15
)
16
17
func (s *State) Star(w http.ResponseWriter, r *http.Request) {
···
38
switch r.Method {
39
case http.MethodPost:
40
createdAt := time.Now().Format(time.RFC3339)
41
+
rkey := tid.TID()
42
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
43
Collection: tangled.FeedStarNSID,
44
Repo: currentUser.Did,
···
53
log.Println("failed to create atproto record", err)
54
return
55
}
56
+
log.Println("created atproto record: ", resp.Uri)
57
58
+
star := &db.Star{
59
+
StarredByDid: currentUser.Did,
60
+
RepoAt: subjectUri,
61
+
Rkey: rkey,
62
+
}
63
+
64
+
err = db.AddStar(s.db, star)
65
if err != nil {
66
log.Println("failed to star", err)
67
return
···
72
log.Println("failed to get star count for ", subjectUri)
73
}
74
75
+
s.notifier.NewStar(r.Context(), star)
76
77
+
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
78
IsStarred: true,
79
RepoAt: subjectUri,
80
Stats: db.RepoStats{
···
82
},
83
})
84
85
return
86
case http.MethodDelete:
87
// find the record in the db
···
114
return
115
}
116
117
+
s.notifier.DeleteStar(r.Context(), star)
118
+
119
+
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
120
IsStarred: false,
121
RepoAt: subjectUri,
122
Stats: db.RepoStats{
123
StarCount: starCount,
124
},
125
})
126
127
return
128
}
+27
-353
appview/state/state.go
+27
-353
appview/state/state.go
···
2
3
import (
4
"context"
5
-
"crypto/hmac"
6
-
"crypto/sha256"
7
-
"encoding/hex"
8
"fmt"
9
"log"
10
"log/slog"
···
13
"time"
14
15
comatproto "github.com/bluesky-social/indigo/api/atproto"
16
-
"github.com/bluesky-social/indigo/atproto/syntax"
17
lexutil "github.com/bluesky-social/indigo/lex/util"
18
securejoin "github.com/cyphar/filepath-securejoin"
19
"github.com/go-chi/chi/v5"
···
24
"tangled.sh/tangled.sh/core/appview/cache/session"
25
"tangled.sh/tangled.sh/core/appview/config"
26
"tangled.sh/tangled.sh/core/appview/db"
27
-
"tangled.sh/tangled.sh/core/appview/idresolver"
28
"tangled.sh/tangled.sh/core/appview/oauth"
29
"tangled.sh/tangled.sh/core/appview/pages"
30
"tangled.sh/tangled.sh/core/appview/reporesolver"
31
"tangled.sh/tangled.sh/core/eventconsumer"
32
"tangled.sh/tangled.sh/core/jetstream"
33
"tangled.sh/tangled.sh/core/knotclient"
34
tlog "tangled.sh/tangled.sh/core/log"
35
"tangled.sh/tangled.sh/core/rbac"
36
)
37
38
type State struct {
39
db *db.DB
40
oauth *oauth.OAuth
41
enforcer *rbac.Enforcer
42
-
tidClock syntax.TIDClock
43
pages *pages.Pages
44
sess *session.SessionStore
45
idResolver *idresolver.Resolver
···
62
return nil, fmt.Errorf("failed to create enforcer: %w", err)
63
}
64
65
-
clock := syntax.NewTIDClock(0)
66
-
67
pgs := pages.NewPages(config)
68
69
-
res, err := idresolver.RedisResolver(config.Redis)
70
if err != nil {
71
log.Printf("failed to create redis resolver: %v", err)
72
res = idresolver.DefaultResolver()
···
134
}
135
spindlestream.Start(ctx)
136
137
state := &State{
138
d,
139
oauth,
140
enforcer,
141
-
clock,
142
pgs,
143
sess,
144
res,
···
153
return state, nil
154
}
155
156
-
func TID(c *syntax.TIDClock) string {
157
-
return c.Next().String()
158
}
159
160
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
···
201
return
202
}
203
204
-
// requires auth
205
-
func (s *State) RegistrationKey(w http.ResponseWriter, r *http.Request) {
206
-
switch r.Method {
207
-
case http.MethodGet:
208
-
// list open registrations under this did
209
-
210
-
return
211
-
case http.MethodPost:
212
-
session, err := s.oauth.Stores().Get(r, oauth.SessionName)
213
-
if err != nil || session.IsNew {
214
-
log.Println("unauthorized attempt to generate registration key")
215
-
http.Error(w, "Forbidden", http.StatusUnauthorized)
216
-
return
217
-
}
218
-
219
-
did := session.Values[oauth.SessionDid].(string)
220
-
221
-
// check if domain is valid url, and strip extra bits down to just host
222
-
domain := r.FormValue("domain")
223
-
if domain == "" {
224
-
http.Error(w, "Invalid form", http.StatusBadRequest)
225
-
return
226
-
}
227
-
228
-
key, err := db.GenerateRegistrationKey(s.db, domain, did)
229
-
230
-
if err != nil {
231
-
log.Println(err)
232
-
http.Error(w, "unable to register this domain", http.StatusNotAcceptable)
233
-
return
234
-
}
235
-
236
-
w.Write([]byte(key))
237
-
}
238
-
}
239
-
240
func (s *State) Keys(w http.ResponseWriter, r *http.Request) {
241
user := chi.URLParam(r, "user")
242
user = strings.TrimPrefix(user, "@")
···
269
}
270
}
271
272
-
// create a signed request and check if a node responds to that
273
-
func (s *State) InitKnotServer(w http.ResponseWriter, r *http.Request) {
274
-
user := s.oauth.GetUser(r)
275
-
276
-
domain := chi.URLParam(r, "domain")
277
-
if domain == "" {
278
-
http.Error(w, "malformed url", http.StatusBadRequest)
279
-
return
280
-
}
281
-
log.Println("checking ", domain)
282
-
283
-
secret, err := db.GetRegistrationKey(s.db, domain)
284
-
if err != nil {
285
-
log.Printf("no key found for domain %s: %s\n", domain, err)
286
-
return
287
-
}
288
-
289
-
client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev)
290
-
if err != nil {
291
-
log.Println("failed to create client to ", domain)
292
-
}
293
-
294
-
resp, err := client.Init(user.Did)
295
-
if err != nil {
296
-
w.Write([]byte("no dice"))
297
-
log.Println("domain was unreachable after 5 seconds")
298
-
return
299
-
}
300
-
301
-
if resp.StatusCode == http.StatusConflict {
302
-
log.Println("status conflict", resp.StatusCode)
303
-
w.Write([]byte("already registered, sorry!"))
304
-
return
305
-
}
306
-
307
-
if resp.StatusCode != http.StatusNoContent {
308
-
log.Println("status nok", resp.StatusCode)
309
-
w.Write([]byte("no dice"))
310
-
return
311
-
}
312
-
313
-
// verify response mac
314
-
signature := resp.Header.Get("X-Signature")
315
-
signatureBytes, err := hex.DecodeString(signature)
316
-
if err != nil {
317
-
return
318
-
}
319
-
320
-
expectedMac := hmac.New(sha256.New, []byte(secret))
321
-
expectedMac.Write([]byte("ok"))
322
-
323
-
if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) {
324
-
log.Printf("response body signature mismatch: %x\n", signatureBytes)
325
-
return
326
-
}
327
-
328
-
tx, err := s.db.BeginTx(r.Context(), nil)
329
-
if err != nil {
330
-
log.Println("failed to start tx", err)
331
-
http.Error(w, err.Error(), http.StatusInternalServerError)
332
-
return
333
-
}
334
-
defer func() {
335
-
tx.Rollback()
336
-
err = s.enforcer.E.LoadPolicy()
337
-
if err != nil {
338
-
log.Println("failed to rollback policies")
339
-
}
340
-
}()
341
-
342
-
// mark as registered
343
-
err = db.Register(tx, domain)
344
-
if err != nil {
345
-
log.Println("failed to register domain", err)
346
-
http.Error(w, err.Error(), http.StatusInternalServerError)
347
-
return
348
-
}
349
-
350
-
// set permissions for this did as owner
351
-
reg, err := db.RegistrationByDomain(tx, domain)
352
-
if err != nil {
353
-
log.Println("failed to register domain", err)
354
-
http.Error(w, err.Error(), http.StatusInternalServerError)
355
-
return
356
-
}
357
-
358
-
// add basic acls for this domain
359
-
err = s.enforcer.AddKnot(domain)
360
-
if err != nil {
361
-
log.Println("failed to setup owner of domain", err)
362
-
http.Error(w, err.Error(), http.StatusInternalServerError)
363
-
return
364
-
}
365
-
366
-
// add this did as owner of this domain
367
-
err = s.enforcer.AddKnotOwner(domain, reg.ByDid)
368
-
if err != nil {
369
-
log.Println("failed to setup owner of domain", err)
370
-
http.Error(w, err.Error(), http.StatusInternalServerError)
371
-
return
372
-
}
373
-
374
-
err = tx.Commit()
375
-
if err != nil {
376
-
log.Println("failed to commit changes", err)
377
-
http.Error(w, err.Error(), http.StatusInternalServerError)
378
-
return
379
-
}
380
-
381
-
err = s.enforcer.E.SavePolicy()
382
-
if err != nil {
383
-
log.Println("failed to update ACLs", err)
384
-
http.Error(w, err.Error(), http.StatusInternalServerError)
385
-
return
386
-
}
387
-
388
-
// add this knot to knotstream
389
-
go s.knotstream.AddSource(
390
-
context.Background(),
391
-
eventconsumer.NewKnotSource(domain),
392
-
)
393
-
394
-
w.Write([]byte("check success"))
395
-
}
396
-
397
-
func (s *State) KnotServerInfo(w http.ResponseWriter, r *http.Request) {
398
-
domain := chi.URLParam(r, "domain")
399
-
if domain == "" {
400
-
http.Error(w, "malformed url", http.StatusBadRequest)
401
-
return
402
-
}
403
-
404
-
user := s.oauth.GetUser(r)
405
-
reg, err := db.RegistrationByDomain(s.db, domain)
406
-
if err != nil {
407
-
w.Write([]byte("failed to pull up registration info"))
408
-
return
409
-
}
410
-
411
-
var members []string
412
-
if reg.Registered != nil {
413
-
members, err = s.enforcer.GetUserByRole("server:member", domain)
414
-
if err != nil {
415
-
w.Write([]byte("failed to fetch member list"))
416
-
return
417
-
}
418
-
}
419
-
420
-
var didsToResolve []string
421
-
for _, m := range members {
422
-
didsToResolve = append(didsToResolve, m)
423
-
}
424
-
didsToResolve = append(didsToResolve, reg.ByDid)
425
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve)
426
-
didHandleMap := make(map[string]string)
427
-
for _, identity := range resolvedIds {
428
-
if !identity.Handle.IsInvalidHandle() {
429
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
430
-
} else {
431
-
didHandleMap[identity.DID.String()] = identity.DID.String()
432
-
}
433
-
}
434
-
435
-
ok, err := s.enforcer.IsKnotOwner(user.Did, domain)
436
-
isOwner := err == nil && ok
437
-
438
-
p := pages.KnotParams{
439
-
LoggedInUser: user,
440
-
DidHandleMap: didHandleMap,
441
-
Registration: reg,
442
-
Members: members,
443
-
IsOwner: isOwner,
444
-
}
445
-
446
-
s.pages.Knot(w, p)
447
-
}
448
-
449
-
// get knots registered by this user
450
-
func (s *State) Knots(w http.ResponseWriter, r *http.Request) {
451
-
// for now, this is just pubkeys
452
-
user := s.oauth.GetUser(r)
453
-
registrations, err := db.RegistrationsByDid(s.db, user.Did)
454
-
if err != nil {
455
-
log.Println(err)
456
-
}
457
-
458
-
s.pages.Knots(w, pages.KnotsParams{
459
-
LoggedInUser: user,
460
-
Registrations: registrations,
461
-
})
462
-
}
463
-
464
-
// list members of domain, requires auth and requires owner status
465
-
func (s *State) ListMembers(w http.ResponseWriter, r *http.Request) {
466
-
domain := chi.URLParam(r, "domain")
467
-
if domain == "" {
468
-
http.Error(w, "malformed url", http.StatusBadRequest)
469
-
return
470
-
}
471
-
472
-
// list all members for this domain
473
-
memberDids, err := s.enforcer.GetUserByRole("server:member", domain)
474
-
if err != nil {
475
-
w.Write([]byte("failed to fetch member list"))
476
-
return
477
-
}
478
-
479
-
w.Write([]byte(strings.Join(memberDids, "\n")))
480
-
return
481
-
}
482
-
483
-
// add member to domain, requires auth and requires invite access
484
-
func (s *State) AddMember(w http.ResponseWriter, r *http.Request) {
485
-
domain := chi.URLParam(r, "domain")
486
-
if domain == "" {
487
-
http.Error(w, "malformed url", http.StatusBadRequest)
488
-
return
489
-
}
490
-
491
-
subjectIdentifier := r.FormValue("subject")
492
-
if subjectIdentifier == "" {
493
-
http.Error(w, "malformed form", http.StatusBadRequest)
494
-
return
495
-
}
496
-
497
-
subjectIdentity, err := s.idResolver.ResolveIdent(r.Context(), subjectIdentifier)
498
-
if err != nil {
499
-
w.Write([]byte("failed to resolve member did to a handle"))
500
-
return
501
-
}
502
-
log.Printf("adding %s to %s\n", subjectIdentity.Handle.String(), domain)
503
-
504
-
// announce this relation into the firehose, store into owners' pds
505
-
client, err := s.oauth.AuthorizedClient(r)
506
-
if err != nil {
507
-
http.Error(w, "failed to authorize client", http.StatusInternalServerError)
508
-
return
509
-
}
510
-
currentUser := s.oauth.GetUser(r)
511
-
createdAt := time.Now().Format(time.RFC3339)
512
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
513
-
Collection: tangled.KnotMemberNSID,
514
-
Repo: currentUser.Did,
515
-
Rkey: appview.TID(),
516
-
Record: &lexutil.LexiconTypeDecoder{
517
-
Val: &tangled.KnotMember{
518
-
Subject: subjectIdentity.DID.String(),
519
-
Domain: domain,
520
-
CreatedAt: createdAt,
521
-
}},
522
-
})
523
-
524
-
// invalid record
525
-
if err != nil {
526
-
log.Printf("failed to create record: %s", err)
527
-
return
528
-
}
529
-
log.Println("created atproto record: ", resp.Uri)
530
-
531
-
secret, err := db.GetRegistrationKey(s.db, domain)
532
-
if err != nil {
533
-
log.Printf("no key found for domain %s: %s\n", domain, err)
534
-
return
535
-
}
536
-
537
-
ksClient, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev)
538
-
if err != nil {
539
-
log.Println("failed to create client to ", domain)
540
-
return
541
-
}
542
-
543
-
ksResp, err := ksClient.AddMember(subjectIdentity.DID.String())
544
-
if err != nil {
545
-
log.Printf("failed to make request to %s: %s", domain, err)
546
-
return
547
-
}
548
-
549
-
if ksResp.StatusCode != http.StatusNoContent {
550
-
w.Write([]byte(fmt.Sprint("knotserver failed to add member: ", err)))
551
-
return
552
-
}
553
-
554
-
err = s.enforcer.AddKnotMember(domain, subjectIdentity.DID.String())
555
-
if err != nil {
556
-
w.Write([]byte(fmt.Sprint("failed to add member: ", err)))
557
-
return
558
-
}
559
-
560
-
w.Write([]byte(fmt.Sprint("added member: ", subjectIdentity.Handle.String())))
561
-
}
562
-
563
-
func (s *State) RemoveMember(w http.ResponseWriter, r *http.Request) {
564
-
}
565
-
566
func validateRepoName(name string) error {
567
// check for path traversal attempts
568
if name == "." || name == ".." ||
···
661
return
662
}
663
664
-
rkey := appview.TID()
665
repo := &db.Repo{
666
Did: user.Did,
667
Name: repoName,
···
757
return
758
}
759
760
-
if !s.config.Core.Dev {
761
-
err = s.posthog.Enqueue(posthog.Capture{
762
-
DistinctId: user.Did,
763
-
Event: "new_repo",
764
-
Properties: posthog.Properties{"repo": repoName, "repo_at": repo.AtUri},
765
-
})
766
-
if err != nil {
767
-
log.Println("failed to enqueue posthog event:", err)
768
-
}
769
-
}
770
771
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
772
return
···
2
3
import (
4
"context"
5
"fmt"
6
"log"
7
"log/slog"
···
10
"time"
11
12
comatproto "github.com/bluesky-social/indigo/api/atproto"
13
lexutil "github.com/bluesky-social/indigo/lex/util"
14
securejoin "github.com/cyphar/filepath-securejoin"
15
"github.com/go-chi/chi/v5"
···
20
"tangled.sh/tangled.sh/core/appview/cache/session"
21
"tangled.sh/tangled.sh/core/appview/config"
22
"tangled.sh/tangled.sh/core/appview/db"
23
+
"tangled.sh/tangled.sh/core/appview/notify"
24
"tangled.sh/tangled.sh/core/appview/oauth"
25
"tangled.sh/tangled.sh/core/appview/pages"
26
+
posthogService "tangled.sh/tangled.sh/core/appview/posthog"
27
"tangled.sh/tangled.sh/core/appview/reporesolver"
28
"tangled.sh/tangled.sh/core/eventconsumer"
29
+
"tangled.sh/tangled.sh/core/idresolver"
30
"tangled.sh/tangled.sh/core/jetstream"
31
"tangled.sh/tangled.sh/core/knotclient"
32
tlog "tangled.sh/tangled.sh/core/log"
33
"tangled.sh/tangled.sh/core/rbac"
34
+
"tangled.sh/tangled.sh/core/tid"
35
)
36
37
type State struct {
38
db *db.DB
39
+
notifier notify.Notifier
40
oauth *oauth.OAuth
41
enforcer *rbac.Enforcer
42
pages *pages.Pages
43
sess *session.SessionStore
44
idResolver *idresolver.Resolver
···
61
return nil, fmt.Errorf("failed to create enforcer: %w", err)
62
}
63
64
pgs := pages.NewPages(config)
65
66
+
res, err := idresolver.RedisResolver(config.Redis.ToURL())
67
if err != nil {
68
log.Printf("failed to create redis resolver: %v", err)
69
res = idresolver.DefaultResolver()
···
131
}
132
spindlestream.Start(ctx)
133
134
+
var notifiers []notify.Notifier
135
+
if !config.Core.Dev {
136
+
notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog))
137
+
}
138
+
notifier := notify.NewMergedNotifier(notifiers...)
139
+
140
state := &State{
141
d,
142
+
notifier,
143
oauth,
144
enforcer,
145
pgs,
146
sess,
147
res,
···
156
return state, nil
157
}
158
159
+
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
160
+
user := s.oauth.GetUser(r)
161
+
s.pages.TermsOfService(w, pages.TermsOfServiceParams{
162
+
LoggedInUser: user,
163
+
})
164
+
}
165
+
166
+
func (s *State) PrivacyPolicy(w http.ResponseWriter, r *http.Request) {
167
+
user := s.oauth.GetUser(r)
168
+
s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{
169
+
LoggedInUser: user,
170
+
})
171
}
172
173
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
···
214
return
215
}
216
217
func (s *State) Keys(w http.ResponseWriter, r *http.Request) {
218
user := chi.URLParam(r, "user")
219
user = strings.TrimPrefix(user, "@")
···
246
}
247
}
248
249
func validateRepoName(name string) error {
250
// check for path traversal attempts
251
if name == "." || name == ".." ||
···
344
return
345
}
346
347
+
rkey := tid.TID()
348
repo := &db.Repo{
349
Did: user.Did,
350
Name: repoName,
···
440
return
441
}
442
443
+
s.notifier.NewRepo(r.Context(), repo)
444
445
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
446
return
+14
-6
appview/state/userutil/userutil.go
+14
-6
appview/state/userutil/userutil.go
···
5
"strings"
6
)
7
8
func IsHandleNoAt(s string) bool {
9
// ref: https://atproto.com/specs/handle
10
-
re := regexp.MustCompile(`^([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])?$`)
11
-
return re.MatchString(s)
12
}
13
14
func UnflattenDid(s string) string {
···
29
// Reconstruct as a standard DID format using Replace
30
// Example: "did-plc-xyz-abc" becomes "did:plc:xyz-abc"
31
reconstructed := strings.Replace(s, "-", ":", 2)
32
-
re := regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`)
33
34
-
return re.MatchString(reconstructed)
35
}
36
37
// FlattenDid converts a DID to a flattened format.
···
46
47
// IsDid checks if the given string is a standard DID.
48
func IsDid(s string) bool {
49
-
re := regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`)
50
-
return re.MatchString(s)
51
}
···
5
"strings"
6
)
7
8
+
var (
9
+
handleRegex = regexp.MustCompile(`^([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])?$`)
10
+
didRegex = regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`)
11
+
)
12
+
13
func IsHandleNoAt(s string) bool {
14
// ref: https://atproto.com/specs/handle
15
+
return handleRegex.MatchString(s)
16
}
17
18
func UnflattenDid(s string) string {
···
33
// Reconstruct as a standard DID format using Replace
34
// Example: "did-plc-xyz-abc" becomes "did:plc:xyz-abc"
35
reconstructed := strings.Replace(s, "-", ":", 2)
36
37
+
return didRegex.MatchString(reconstructed)
38
}
39
40
// FlattenDid converts a DID to a flattened format.
···
49
50
// IsDid checks if the given string is a standard DID.
51
func IsDid(s string) bool {
52
+
return didRegex.MatchString(s)
53
+
}
54
+
55
+
var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`)
56
+
57
+
func IsValidSubdomain(name string) bool {
58
+
return len(name) >= 4 && len(name) <= 63 && subdomainRegex.MatchString(name)
59
}
-11
appview/tid.go
-11
appview/tid.go
+15
appview/xrpcclient/xrpc.go
+15
appview/xrpcclient/xrpc.go
···
87
88
return &out, nil
89
}
90
+
91
+
func (c *Client) ServerGetServiceAuth(ctx context.Context, aud string, exp int64, lxm string) (*atproto.ServerGetServiceAuth_Output, error) {
92
+
var out atproto.ServerGetServiceAuth_Output
93
+
94
+
params := map[string]interface{}{
95
+
"aud": aud,
96
+
"exp": exp,
97
+
"lxm": lxm,
98
+
}
99
+
if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.server.getServiceAuth", params, nil, &out); err != nil {
100
+
return nil, err
101
+
}
102
+
103
+
return &out, nil
104
+
}
+33
-4
avatar/src/index.js
+33
-4
avatar/src/index.js
···
1
export default {
2
async fetch(request, env) {
3
const url = new URL(request.url);
4
const { pathname, searchParams } = url;
5
···
60
const profile = await profileResponse.json();
61
const avatar = profile.avatar;
62
63
-
if (!avatar) {
64
-
return new Response(`avatar not found for ${actor}.`, { status: 404 });
65
}
66
67
// Resize if requested
68
let avatarResponse;
69
if (resizeToTiny) {
70
-
avatarResponse = await fetch(avatar, {
71
cf: {
72
image: {
73
width: 32,
···
78
},
79
});
80
} else {
81
-
avatarResponse = await fetch(avatar);
82
}
83
84
if (!avatarResponse.ok) {
···
1
export default {
2
async fetch(request, env) {
3
+
// Helper function to generate a color from a string
4
+
const stringToColor = (str) => {
5
+
let hash = 0;
6
+
for (let i = 0; i < str.length; i++) {
7
+
hash = str.charCodeAt(i) + ((hash << 5) - hash);
8
+
}
9
+
let color = "#";
10
+
for (let i = 0; i < 3; i++) {
11
+
const value = (hash >> (i * 8)) & 0xff;
12
+
color += ("00" + value.toString(16)).substr(-2);
13
+
}
14
+
return color;
15
+
};
16
+
17
const url = new URL(request.url);
18
const { pathname, searchParams } = url;
19
···
74
const profile = await profileResponse.json();
75
const avatar = profile.avatar;
76
77
+
let avatarUrl = profile.avatar;
78
+
79
+
if (!avatarUrl) {
80
+
// Generate a random color based on the actor string
81
+
const bgColor = stringToColor(actor);
82
+
const size = resizeToTiny ? 32 : 128;
83
+
const svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg"><rect width="${size}" height="${size}" fill="${bgColor}"/></svg>`;
84
+
const svgData = new TextEncoder().encode(svg);
85
+
86
+
response = new Response(svgData, {
87
+
headers: {
88
+
"Content-Type": "image/svg+xml",
89
+
"Cache-Control": "public, max-age=43200",
90
+
},
91
+
});
92
+
await cache.put(cacheKey, response.clone());
93
+
return response;
94
}
95
96
// Resize if requested
97
let avatarResponse;
98
if (resizeToTiny) {
99
+
avatarResponse = await fetch(avatarUrl, {
100
cf: {
101
image: {
102
width: 32,
···
107
},
108
});
109
} else {
110
+
avatarResponse = await fetch(avatarUrl);
111
}
112
113
if (!avatarResponse.ok) {
+4
cmd/gen.go
+4
cmd/gen.go
···
15
"api/tangled/cbor_gen.go",
16
"tangled",
17
tangled.ActorProfile{},
18
tangled.FeedStar{},
19
tangled.GitRefUpdate{},
20
tangled.GitRefUpdate_Meta{},
21
tangled.GitRefUpdate_Meta_CommitCount{},
22
tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem{},
23
tangled.GraphFollow{},
24
tangled.KnotMember{},
25
tangled.Pipeline{},
···
37
tangled.PublicKey{},
38
tangled.Repo{},
39
tangled.RepoArtifact{},
40
tangled.RepoIssue{},
41
tangled.RepoIssueComment{},
42
tangled.RepoIssueState{},
···
15
"api/tangled/cbor_gen.go",
16
"tangled",
17
tangled.ActorProfile{},
18
+
tangled.FeedReaction{},
19
tangled.FeedStar{},
20
tangled.GitRefUpdate{},
21
tangled.GitRefUpdate_Meta{},
22
tangled.GitRefUpdate_Meta_CommitCount{},
23
tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem{},
24
+
tangled.GitRefUpdate_Meta_LangBreakdown{},
25
+
tangled.GitRefUpdate_Pair{},
26
tangled.GraphFollow{},
27
tangled.KnotMember{},
28
tangled.Pipeline{},
···
40
tangled.PublicKey{},
41
tangled.Repo{},
42
tangled.RepoArtifact{},
43
+
tangled.RepoCollaborator{},
44
tangled.RepoIssue{},
45
tangled.RepoIssueComment{},
46
tangled.RepoIssueState{},
+46
-3
docs/hacking.md
+46
-3
docs/hacking.md
···
32
nix run .#watch-tailwind
33
```
34
35
## running a knot
36
37
An end-to-end knot setup requires setting up a machine with
···
39
quite cumbersome. So the nix flake provides a
40
`nixosConfiguration` to do so.
41
42
-
To begin, head to `http://localhost:3000` in the browser and
43
-
generate a knot secret. Replace the existing secret in
44
-
`flake.nix` with the newly generated secret.
45
46
You can now start a lightweight NixOS VM using
47
`nixos-shell` like so:
···
71
git remote add local-dev git@nixos-shell:user/repo
72
git push local-dev main
73
```
···
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_JWKS
41
+
{"crv":"P-256","d":"tELKHYH-Dko6qo4ozYcVPE1ah6LvXHFV2wpcWpi8ab4","kid":"1753352226","kty":"EC","x":"mRzYpLzAGq74kJez9UbgGfV040DxgsXpMbaVsdy8RZs","y":"azqqXzUYywMlLb2Uc5AVG18nuLXyPnXr4kI4T39eeIc"}
42
+
43
+
# if not, you can set it up yourself:
44
+
go build -o genjwks.out ./cmd/genjwks
45
+
export TANGLED_OAUTH_JWKS="$(./genjwks.out)"
46
+
47
+
# run redis in at a new shell to store oauth sessions
48
+
redis-server
49
+
```
50
+
51
## running a knot
52
53
An end-to-end knot setup requires setting up a machine with
···
55
quite cumbersome. So the nix flake provides a
56
`nixosConfiguration` to do so.
57
58
+
To begin, head to `http://localhost:3000/knots` in the browser
59
+
and generate a knot secret. Replace the existing secret in
60
+
`nix/vm.nix` (`KNOT_SERVER_SECRET`) with the newly generated
61
+
secret.
62
63
You can now start a lightweight NixOS VM using
64
`nixos-shell` like so:
···
88
git remote add local-dev git@nixos-shell:user/repo
89
git push local-dev main
90
```
91
+
92
+
## running a spindle
93
+
94
+
Be sure to change the `owner` field for the spindle in
95
+
`nix/vm.nix` to your own DID. The above VM should already
96
+
be running a spindle on `localhost:6555`. You can head to
97
+
the spindle dashboard on `http://localhost:3000/spindles`,
98
+
and register a spindle with hostname `localhost:6555`. It
99
+
should instantly be verified. You can then configure each
100
+
repository to use this spindle and run CI jobs.
101
+
102
+
Of interest when debugging spindles:
103
+
104
+
```
105
+
# service logs from journald:
106
+
journalctl -xeu spindle
107
+
108
+
# CI job logs from disk:
109
+
ls /var/log/spindle
110
+
111
+
# debugging spindle db:
112
+
sqlite3 /var/lib/spindle/spindle.db
113
+
114
+
# litecli has a nicer REPL interface:
115
+
litecli /var/lib/spindle/spindle.db
116
+
```
+12
docs/knot-hosting.md
+12
docs/knot-hosting.md
···
191
```
192
193
Make sure to restart your SSH server!
194
+
195
+
#### MOTD (message of the day)
196
+
197
+
To configure the MOTD used ("Welcome to this knot!" by default), edit the
198
+
`/home/git/motd` file:
199
+
200
+
```
201
+
printf "Hi from this knot!\n" > /home/git/motd
202
+
```
203
+
204
+
Note that you should add a newline at the end if setting a non-empty message
205
+
since the knot won't do this for you.
+4
-3
docs/spindle/architecture.md
+4
-3
docs/spindle/architecture.md
···
13
14
### the engine
15
16
-
At present, the only supported backend is Docker. Spindle executes each step in
17
-
the pipeline in a fresh container, with state persisted across steps within the
18
-
`/tangled/workspace` directory.
19
20
The base image for the container is constructed on the fly using
21
[Nixery](https://nixery.dev), which is handy for caching layers for frequently
···
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
+8
-1
docs/spindle/hosting.md
+8
-1
docs/spindle/hosting.md
+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.sh/infra](https://tangled.sh/@tangled.sh/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 -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
+
```
+7
docs/spindle/pipeline.md
+7
docs/spindle/pipeline.md
···
57
depth: 50
58
submodules: true
59
```
60
+
61
+
## git push options
62
+
63
+
These are push options that can be used with the `--push-option (-o)` flag of git push:
64
+
65
+
- `verbose-ci`, `ci-verbose`: enables diagnostics reporting for the CI pipeline, allowing you to see any issues when you push.
66
+
- `skip-ci`, `ci-skip`: skips triggering the CI pipeline.
+58
-3
flake.lock
+58
-3
flake.lock
···
20
"type": "github"
21
}
22
},
23
"htmx-src": {
24
"flake": false,
25
"locked": {
···
101
},
102
"nixpkgs": {
103
"locked": {
104
-
"lastModified": 1746904237,
105
-
"narHash": "sha256-3e+AVBczosP5dCLQmMoMEogM57gmZ2qrVSrmq9aResQ=",
106
"owner": "nixos",
107
"repo": "nixpkgs",
108
-
"rev": "d89fc19e405cb2d55ce7cc114356846a0ee5e956",
109
"type": "github"
110
},
111
"original": {
···
118
"root": {
119
"inputs": {
120
"gitignore": "gitignore",
121
"htmx-src": "htmx-src",
122
"htmx-ws-src": "htmx-ws-src",
123
"ibm-plex-mono-src": "ibm-plex-mono-src",
···
139
"original": {
140
"type": "tarball",
141
"url": "https://sqlite.org/2024/sqlite-amalgamation-3450100.zip"
142
}
143
}
144
},
···
20
"type": "github"
21
}
22
},
23
+
"flake-utils": {
24
+
"inputs": {
25
+
"systems": "systems"
26
+
},
27
+
"locked": {
28
+
"lastModified": 1694529238,
29
+
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
30
+
"owner": "numtide",
31
+
"repo": "flake-utils",
32
+
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
33
+
"type": "github"
34
+
},
35
+
"original": {
36
+
"owner": "numtide",
37
+
"repo": "flake-utils",
38
+
"type": "github"
39
+
}
40
+
},
41
+
"gomod2nix": {
42
+
"inputs": {
43
+
"flake-utils": "flake-utils",
44
+
"nixpkgs": [
45
+
"nixpkgs"
46
+
]
47
+
},
48
+
"locked": {
49
+
"lastModified": 1751702058,
50
+
"narHash": "sha256-/GTdqFzFw/Y9DSNAfzvzyCMlKjUyRKMPO+apIuaTU4A=",
51
+
"owner": "nix-community",
52
+
"repo": "gomod2nix",
53
+
"rev": "664ad7a2df4623037e315e4094346bff5c44e9ee",
54
+
"type": "github"
55
+
},
56
+
"original": {
57
+
"owner": "nix-community",
58
+
"repo": "gomod2nix",
59
+
"type": "github"
60
+
}
61
+
},
62
"htmx-src": {
63
"flake": false,
64
"locked": {
···
140
},
141
"nixpkgs": {
142
"locked": {
143
+
"lastModified": 1751984180,
144
+
"narHash": "sha256-LwWRsENAZJKUdD3SpLluwDmdXY9F45ZEgCb0X+xgOL0=",
145
"owner": "nixos",
146
"repo": "nixpkgs",
147
+
"rev": "9807714d6944a957c2e036f84b0ff8caf9930bc0",
148
"type": "github"
149
},
150
"original": {
···
157
"root": {
158
"inputs": {
159
"gitignore": "gitignore",
160
+
"gomod2nix": "gomod2nix",
161
"htmx-src": "htmx-src",
162
"htmx-ws-src": "htmx-ws-src",
163
"ibm-plex-mono-src": "ibm-plex-mono-src",
···
179
"original": {
180
"type": "tarball",
181
"url": "https://sqlite.org/2024/sqlite-amalgamation-3450100.zip"
182
+
}
183
+
},
184
+
"systems": {
185
+
"locked": {
186
+
"lastModified": 1681028828,
187
+
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
188
+
"owner": "nix-systems",
189
+
"repo": "default",
190
+
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
191
+
"type": "github"
192
+
},
193
+
"original": {
194
+
"owner": "nix-systems",
195
+
"repo": "default",
196
+
"type": "github"
197
}
198
}
199
},
+65
-23
flake.nix
+65
-23
flake.nix
···
3
4
inputs = {
5
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
6
indigo = {
7
url = "github:oppiliappan/indigo";
8
flake = false;
···
42
outputs = {
43
self,
44
nixpkgs,
45
indigo,
46
htmx-src,
47
htmx-ws-src,
···
54
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"];
55
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
56
nixpkgsFor = forAllSystems (system: nixpkgs.legacyPackages.${system});
57
-
inherit (gitignore.lib) gitignoreSource;
58
-
mkPackageSet = pkgs: let
59
-
goModHash = "sha256-SLi+nALwCd/Lzn3aljwPqCo2UaM9hl/4OAjcHQLt2Bk=";
60
-
sqlite-lib = pkgs.callPackage ./nix/pkgs/sqlite-lib.nix {
61
-
inherit (pkgs) gcc;
62
-
inherit sqlite-lib-src;
63
-
};
64
-
genjwks = pkgs.callPackage ./nix/pkgs/genjwks.nix {inherit goModHash gitignoreSource;};
65
-
lexgen = pkgs.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
66
-
appview = pkgs.callPackage ./nix/pkgs/appview.nix {
67
-
inherit sqlite-lib htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src goModHash gitignoreSource;
68
-
};
69
-
spindle = pkgs.callPackage ./nix/pkgs/spindle.nix {inherit sqlite-lib goModHash gitignoreSource;};
70
-
knot-unwrapped = pkgs.callPackage ./nix/pkgs/knot-unwrapped.nix {inherit sqlite-lib goModHash gitignoreSource;};
71
-
knot = pkgs.callPackage ./nix/pkgs/knot.nix {inherit knot-unwrapped;};
72
-
in {
73
-
inherit lexgen appview spindle knot-unwrapped knot sqlite-lib genjwks;
74
-
};
75
in {
76
-
overlays.default = final: prev: mkPackageSet final;
77
78
packages = forAllSystems (system: let
79
pkgs = nixpkgsFor.${system};
···
142
''
143
${pkgs.air}/bin/air -c /dev/null \
144
-build.cmd "${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \
145
-
-build.bin "./out/${name}.out ${arg}" \
146
-build.stop_on_error "true" \
147
-build.include_ext "go"
148
'';
···
170
${pkgs.nixos-shell}/bin/nixos-shell --flake .#vm
171
'');
172
};
173
});
174
175
-
nixosModules.appview = import ./nix/modules/appview.nix {inherit self;};
176
-
nixosModules.knot = import ./nix/modules/knot.nix {inherit self;};
177
-
nixosModules.spindle = import ./nix/modules/spindle.nix {inherit self;};
178
nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;};
179
};
180
}
···
3
4
inputs = {
5
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
6
+
gomod2nix = {
7
+
url = "github:nix-community/gomod2nix";
8
+
inputs.nixpkgs.follows = "nixpkgs";
9
+
};
10
indigo = {
11
url = "github:oppiliappan/indigo";
12
flake = false;
···
46
outputs = {
47
self,
48
nixpkgs,
49
+
gomod2nix,
50
indigo,
51
htmx-src,
52
htmx-ws-src,
···
59
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"];
60
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
61
nixpkgsFor = forAllSystems (system: nixpkgs.legacyPackages.${system});
62
+
63
+
mkPackageSet = pkgs:
64
+
pkgs.lib.makeScope pkgs.newScope (self: {
65
+
inherit (gitignore.lib) gitignoreSource;
66
+
buildGoApplication =
67
+
(self.callPackage "${gomod2nix}/builder" {
68
+
gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix;
69
+
}).buildGoApplication;
70
+
modules = ./nix/gomod2nix.toml;
71
+
sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix {
72
+
inherit (pkgs) gcc;
73
+
inherit sqlite-lib-src;
74
+
};
75
+
genjwks = self.callPackage ./nix/pkgs/genjwks.nix {};
76
+
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
77
+
appview = self.callPackage ./nix/pkgs/appview.nix {
78
+
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src;
79
+
};
80
+
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
81
+
knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {};
82
+
knot = self.callPackage ./nix/pkgs/knot.nix {};
83
+
});
84
in {
85
+
overlays.default = final: prev: {
86
+
inherit (mkPackageSet final) lexgen sqlite-lib genjwks spindle knot-unwrapped knot appview;
87
+
};
88
89
packages = forAllSystems (system: let
90
pkgs = nixpkgsFor.${system};
···
153
''
154
${pkgs.air}/bin/air -c /dev/null \
155
-build.cmd "${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \
156
+
-build.bin "./out/${name}.out" \
157
+
-build.args_bin "${arg}" \
158
-build.stop_on_error "true" \
159
-build.include_ext "go"
160
'';
···
182
${pkgs.nixos-shell}/bin/nixos-shell --flake .#vm
183
'');
184
};
185
+
gomod2nix = {
186
+
type = "app";
187
+
program = toString (pkgs.writeShellScript "gomod2nix" ''
188
+
${gomod2nix.legacyPackages.${system}.gomod2nix}/bin/gomod2nix generate --outdir ./nix
189
+
'');
190
+
};
191
});
192
193
+
nixosModules.appview = {
194
+
lib,
195
+
pkgs,
196
+
...
197
+
}: {
198
+
imports = [./nix/modules/appview.nix];
199
+
200
+
services.tangled-appview.package = lib.mkDefault self.packages.${pkgs.system}.appview;
201
+
};
202
+
nixosModules.knot = {
203
+
lib,
204
+
pkgs,
205
+
...
206
+
}: {
207
+
imports = [./nix/modules/knot.nix];
208
+
209
+
services.tangled-knot.package = lib.mkDefault self.packages.${pkgs.system}.knot;
210
+
};
211
+
nixosModules.spindle = {
212
+
lib,
213
+
pkgs,
214
+
...
215
+
}: {
216
+
imports = [./nix/modules/spindle.nix];
217
+
218
+
services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
219
+
};
220
nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;};
221
};
222
}
+54
-34
go.mod
+54
-34
go.mod
···
1
module tangled.sh/tangled.sh/core
2
3
-
go 1.24.0
4
-
5
-
toolchain go1.24.3
6
7
require (
8
github.com/Blank-Xu/sql-adapter v1.1.1
9
github.com/alecthomas/chroma/v2 v2.15.0
10
github.com/bluekeyes/go-gitdiff v0.8.1
11
-
github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e
12
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
13
github.com/carlmjohnson/versioninfo v0.22.5
14
github.com/casbin/casbin/v2 v2.103.0
15
github.com/cyphar/filepath-securejoin v0.4.1
16
github.com/dgraph-io/ristretto v0.2.0
17
github.com/docker/docker v28.2.2+incompatible
···
22
github.com/go-git/go-git/v5 v5.14.0
23
github.com/google/uuid v1.6.0
24
github.com/gorilla/sessions v1.4.0
25
-
github.com/gorilla/websocket v1.5.3
26
github.com/hiddeco/sshsig v0.2.0
27
github.com/hpcloud/tail v1.0.0
28
github.com/ipfs/go-cid v0.5.0
29
github.com/lestrrat-go/jwx/v2 v2.1.6
30
github.com/mattn/go-sqlite3 v1.14.24
31
github.com/microcosm-cc/bluemonday v1.0.27
32
github.com/posthog/posthog-go v1.5.5
33
-
github.com/redis/go-redis/v9 v9.3.0
34
github.com/resend/resend-go/v2 v2.15.0
35
github.com/sethvargo/go-envconfig v1.1.0
36
github.com/stretchr/testify v1.10.0
37
github.com/urfave/cli/v3 v3.3.3
38
github.com/whyrusleeping/cbor-gen v0.3.1
39
github.com/yuin/goldmark v1.4.13
40
-
golang.org/x/crypto v0.38.0
41
-
golang.org/x/net v0.40.0
42
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
43
gopkg.in/yaml.v3 v3.0.1
44
-
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421
45
)
46
47
require (
48
dario.cat/mergo v1.0.1 // indirect
49
github.com/Microsoft/go-winio v0.6.2 // indirect
50
-
github.com/ProtonMail/go-crypto v1.2.0 // indirect
51
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
52
-
github.com/avast/retry-go/v4 v4.6.1 // indirect
53
github.com/aymerick/douceur v0.2.0 // indirect
54
github.com/beorn7/perks v1.0.1 // indirect
55
github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect
56
github.com/casbin/govaluate v1.3.0 // indirect
57
github.com/cespare/xxhash/v2 v2.3.0 // indirect
58
-
github.com/cloudflare/circl v1.6.0 // indirect
59
github.com/containerd/errdefs v1.0.0 // indirect
60
github.com/containerd/errdefs/pkg v0.3.0 // indirect
61
github.com/containerd/log v0.1.0 // indirect
···
68
github.com/docker/go-units v0.5.0 // indirect
69
github.com/emirpasic/gods v1.18.1 // indirect
70
github.com/felixge/httpsnoop v1.0.4 // indirect
71
github.com/go-enry/go-oniguruma v1.2.1 // indirect
72
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
73
github.com/go-git/go-billy/v5 v5.6.2 // indirect
74
-
github.com/go-logr/logr v1.4.2 // indirect
75
github.com/go-logr/stdr v1.2.2 // indirect
76
github.com/go-redis/cache/v9 v9.0.0 // indirect
77
github.com/goccy/go-json v0.10.5 // indirect
78
github.com/gogo/protobuf v1.3.2 // indirect
79
-
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
80
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
81
github.com/gorilla/css v1.0.1 // indirect
82
github.com/gorilla/securecookie v1.1.2 // indirect
83
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
84
-
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
85
github.com/hashicorp/golang-lru v1.0.2 // indirect
86
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
87
github.com/ipfs/bbloom v0.0.4 // indirect
88
-
github.com/ipfs/boxo v0.30.0 // indirect
89
-
github.com/ipfs/go-block-format v0.2.1 // indirect
90
github.com/ipfs/go-datastore v0.8.2 // indirect
91
github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect
92
github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect
93
-
github.com/ipfs/go-ipld-cbor v0.2.0 // indirect
94
-
github.com/ipfs/go-ipld-format v0.6.1 // indirect
95
github.com/ipfs/go-log v1.0.5 // indirect
96
github.com/ipfs/go-log/v2 v2.6.0 // indirect
97
github.com/ipfs/go-metrics-interface v0.3.0 // indirect
98
github.com/kevinburke/ssh_config v1.2.0 // indirect
99
github.com/klauspost/compress v1.18.0 // indirect
100
-
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
101
-
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
102
github.com/lestrrat-go/httpcc v1.0.1 // indirect
103
github.com/lestrrat-go/httprc v1.0.6 // indirect
104
github.com/lestrrat-go/iter v1.0.2 // indirect
105
github.com/lestrrat-go/option v1.0.1 // indirect
106
github.com/mattn/go-isatty v0.0.20 // indirect
107
github.com/minio/sha256-simd v1.0.1 // indirect
108
github.com/moby/docker-image-spec v1.3.1 // indirect
109
github.com/moby/sys/atomicwriter v0.1.0 // indirect
110
github.com/moby/term v0.5.2 // indirect
···
116
github.com/multiformats/go-multihash v0.2.3 // indirect
117
github.com/multiformats/go-varint v0.0.7 // indirect
118
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
119
github.com/opencontainers/go-digest v1.0.0 // indirect
120
github.com/opencontainers/image-spec v1.1.1 // indirect
121
-
github.com/opentracing/opentracing-go v1.2.0 // indirect
122
github.com/pjbgf/sha1cd v0.3.2 // indirect
123
github.com/pkg/errors v0.9.1 // indirect
124
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
125
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
126
github.com/prometheus/client_golang v1.22.0 // indirect
127
github.com/prometheus/client_model v0.6.2 // indirect
128
-
github.com/prometheus/common v0.63.0 // indirect
129
github.com/prometheus/procfs v0.16.1 // indirect
130
github.com/segmentio/asm v1.2.0 // indirect
131
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
132
github.com/spaolacci/murmur3 v1.1.0 // indirect
···
136
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
137
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
138
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
139
-
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
140
-
go.opentelemetry.io/otel v1.36.0 // indirect
141
-
go.opentelemetry.io/otel/metric v1.36.0 // indirect
142
-
go.opentelemetry.io/otel/trace v1.36.0 // indirect
143
go.opentelemetry.io/proto/otlp v1.6.0 // indirect
144
go.uber.org/atomic v1.11.0 // indirect
145
go.uber.org/multierr v1.11.0 // indirect
146
go.uber.org/zap v1.27.0 // indirect
147
-
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
148
-
golang.org/x/sync v0.14.0 // indirect
149
-
golang.org/x/sys v0.33.0 // indirect
150
-
golang.org/x/time v0.8.0 // indirect
151
-
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect
152
-
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect
153
-
google.golang.org/grpc v1.72.1 // indirect
154
google.golang.org/protobuf v1.36.6 // indirect
155
gopkg.in/fsnotify.v1 v1.4.7 // indirect
156
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
···
1
module tangled.sh/tangled.sh/core
2
3
+
go 1.24.4
4
5
require (
6
github.com/Blank-Xu/sql-adapter v1.1.1
7
+
github.com/alecthomas/assert/v2 v2.11.0
8
github.com/alecthomas/chroma/v2 v2.15.0
9
+
github.com/avast/retry-go/v4 v4.6.1
10
github.com/bluekeyes/go-gitdiff v0.8.1
11
+
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb
12
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
13
github.com/carlmjohnson/versioninfo v0.22.5
14
github.com/casbin/casbin/v2 v2.103.0
15
+
github.com/cloudflare/cloudflare-go v0.115.0
16
github.com/cyphar/filepath-securejoin v0.4.1
17
github.com/dgraph-io/ristretto v0.2.0
18
github.com/docker/docker v28.2.2+incompatible
···
23
github.com/go-git/go-git/v5 v5.14.0
24
github.com/google/uuid v1.6.0
25
github.com/gorilla/sessions v1.4.0
26
+
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
27
github.com/hiddeco/sshsig v0.2.0
28
github.com/hpcloud/tail v1.0.0
29
github.com/ipfs/go-cid v0.5.0
30
github.com/lestrrat-go/jwx/v2 v2.1.6
31
github.com/mattn/go-sqlite3 v1.14.24
32
github.com/microcosm-cc/bluemonday v1.0.27
33
+
github.com/openbao/openbao/api/v2 v2.3.0
34
github.com/posthog/posthog-go v1.5.5
35
+
github.com/redis/go-redis/v9 v9.7.3
36
github.com/resend/resend-go/v2 v2.15.0
37
github.com/sethvargo/go-envconfig v1.1.0
38
github.com/stretchr/testify v1.10.0
39
github.com/urfave/cli/v3 v3.3.3
40
github.com/whyrusleeping/cbor-gen v0.3.1
41
github.com/yuin/goldmark v1.4.13
42
+
golang.org/x/crypto v0.40.0
43
+
golang.org/x/net v0.42.0
44
+
golang.org/x/sync v0.16.0
45
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
46
gopkg.in/yaml.v3 v3.0.1
47
+
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1
48
)
49
50
require (
51
dario.cat/mergo v1.0.1 // indirect
52
github.com/Microsoft/go-winio v0.6.2 // indirect
53
+
github.com/ProtonMail/go-crypto v1.3.0 // indirect
54
+
github.com/alecthomas/repr v0.4.0 // indirect
55
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
56
github.com/aymerick/douceur v0.2.0 // indirect
57
github.com/beorn7/perks v1.0.1 // indirect
58
github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect
59
github.com/casbin/govaluate v1.3.0 // indirect
60
+
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
61
github.com/cespare/xxhash/v2 v2.3.0 // indirect
62
+
github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect
63
github.com/containerd/errdefs v1.0.0 // indirect
64
github.com/containerd/errdefs/pkg v0.3.0 // indirect
65
github.com/containerd/log v0.1.0 // indirect
···
72
github.com/docker/go-units v0.5.0 // indirect
73
github.com/emirpasic/gods v1.18.1 // indirect
74
github.com/felixge/httpsnoop v1.0.4 // indirect
75
+
github.com/fsnotify/fsnotify v1.6.0 // indirect
76
github.com/go-enry/go-oniguruma v1.2.1 // indirect
77
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
78
github.com/go-git/go-billy/v5 v5.6.2 // indirect
79
+
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
80
+
github.com/go-logr/logr v1.4.3 // indirect
81
github.com/go-logr/stdr v1.2.2 // indirect
82
github.com/go-redis/cache/v9 v9.0.0 // indirect
83
+
github.com/go-test/deep v1.1.1 // indirect
84
github.com/goccy/go-json v0.10.5 // indirect
85
github.com/gogo/protobuf v1.3.2 // indirect
86
+
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
87
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
88
+
github.com/golang/mock v1.6.0 // indirect
89
+
github.com/google/go-querystring v1.1.0 // indirect
90
github.com/gorilla/css v1.0.1 // indirect
91
github.com/gorilla/securecookie v1.1.2 // indirect
92
+
github.com/hashicorp/errwrap v1.1.0 // indirect
93
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
94
+
github.com/hashicorp/go-multierror v1.1.1 // indirect
95
+
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
96
+
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect
97
+
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
98
+
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
99
github.com/hashicorp/golang-lru v1.0.2 // indirect
100
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
101
+
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
102
+
github.com/hexops/gotextdiff v1.0.3 // indirect
103
github.com/ipfs/bbloom v0.0.4 // indirect
104
+
github.com/ipfs/boxo v0.33.0 // indirect
105
+
github.com/ipfs/go-block-format v0.2.2 // indirect
106
github.com/ipfs/go-datastore v0.8.2 // indirect
107
github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect
108
github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect
109
+
github.com/ipfs/go-ipld-cbor v0.2.1 // indirect
110
+
github.com/ipfs/go-ipld-format v0.6.2 // indirect
111
github.com/ipfs/go-log v1.0.5 // indirect
112
github.com/ipfs/go-log/v2 v2.6.0 // indirect
113
github.com/ipfs/go-metrics-interface v0.3.0 // indirect
114
github.com/kevinburke/ssh_config v1.2.0 // indirect
115
github.com/klauspost/compress v1.18.0 // indirect
116
+
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
117
+
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
118
github.com/lestrrat-go/httpcc v1.0.1 // indirect
119
github.com/lestrrat-go/httprc v1.0.6 // indirect
120
github.com/lestrrat-go/iter v1.0.2 // indirect
121
github.com/lestrrat-go/option v1.0.1 // indirect
122
github.com/mattn/go-isatty v0.0.20 // indirect
123
github.com/minio/sha256-simd v1.0.1 // indirect
124
+
github.com/mitchellh/mapstructure v1.5.0 // indirect
125
github.com/moby/docker-image-spec v1.3.1 // indirect
126
github.com/moby/sys/atomicwriter v0.1.0 // indirect
127
github.com/moby/term v0.5.2 // indirect
···
133
github.com/multiformats/go-multihash v0.2.3 // indirect
134
github.com/multiformats/go-varint v0.0.7 // indirect
135
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
136
+
github.com/onsi/gomega v1.37.0 // indirect
137
github.com/opencontainers/go-digest v1.0.0 // indirect
138
github.com/opencontainers/image-spec v1.1.1 // indirect
139
+
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
140
github.com/pjbgf/sha1cd v0.3.2 // indirect
141
github.com/pkg/errors v0.9.1 // indirect
142
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
143
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
144
github.com/prometheus/client_golang v1.22.0 // indirect
145
github.com/prometheus/client_model v0.6.2 // indirect
146
+
github.com/prometheus/common v0.64.0 // indirect
147
github.com/prometheus/procfs v0.16.1 // indirect
148
+
github.com/ryanuber/go-glob v1.0.0 // indirect
149
github.com/segmentio/asm v1.2.0 // indirect
150
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
151
github.com/spaolacci/murmur3 v1.1.0 // indirect
···
155
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
156
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
157
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
158
+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
159
+
go.opentelemetry.io/otel v1.37.0 // indirect
160
+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect
161
+
go.opentelemetry.io/otel/metric v1.37.0 // indirect
162
+
go.opentelemetry.io/otel/trace v1.37.0 // indirect
163
go.opentelemetry.io/proto/otlp v1.6.0 // indirect
164
go.uber.org/atomic v1.11.0 // indirect
165
go.uber.org/multierr v1.11.0 // indirect
166
go.uber.org/zap v1.27.0 // indirect
167
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
168
+
golang.org/x/sys v0.34.0 // indirect
169
+
golang.org/x/text v0.27.0 // indirect
170
+
golang.org/x/time v0.12.0 // indirect
171
+
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
172
+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
173
+
google.golang.org/grpc v1.73.0 // indirect
174
google.golang.org/protobuf v1.36.6 // indirect
175
gopkg.in/fsnotify.v1 v1.4.7 // indirect
176
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
+129
-87
go.sum
+129
-87
go.sum
···
7
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
8
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
9
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
10
-
github.com/ProtonMail/go-crypto v1.2.0 h1:+PhXXn4SPGd+qk76TlEePBfOfivE0zkWFenhGhFLzWs=
11
-
github.com/ProtonMail/go-crypto v1.2.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
12
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
13
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
14
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
···
23
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
24
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
25
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
26
-
github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e h1:DVD+HxQsDCVJtAkjfIKZVaBNc3kayHaU+A2TJZkFdp4=
27
-
github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e/go.mod h1:ovyxp8AMO1Hoe838vMJUbqHTZaAR8ABM3g3TXu+A5Ng=
28
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
29
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
30
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
···
51
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
52
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
53
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
54
-
github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk=
55
-
github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
56
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
57
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
58
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
···
91
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
92
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
93
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
94
-
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
95
-
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
96
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
97
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
98
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
99
-
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
100
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
101
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
102
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
103
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
···
114
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
115
github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03 h1:LumE+tQdnYW24a9RoO08w64LHTzkNkdUqBD/0QPtlEY=
116
github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM=
117
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
118
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
119
-
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
120
-
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
121
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
122
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
123
github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0=
124
github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI=
125
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
126
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
127
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
128
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
129
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
130
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
131
-
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
132
-
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
133
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
134
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
135
-
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
136
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
137
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
138
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
139
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
···
146
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
147
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
148
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
149
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
150
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
151
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
152
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
153
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
154
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
155
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
156
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
···
166
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
167
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
168
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
169
-
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
170
-
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
171
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
172
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
173
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
174
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
175
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
176
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
177
-
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
178
-
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
179
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
180
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
181
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
182
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
183
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
184
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
185
github.com/hiddeco/sshsig v0.2.0 h1:gMWllgKCITXdydVkDL+Zro0PU96QI55LwUwebSwNTSw=
···
189
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
190
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
191
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
192
-
github.com/ipfs/boxo v0.30.0 h1:7afsoxPGGqfoH7Dum/wOTGUB9M5fb8HyKPMlLfBvIEQ=
193
-
github.com/ipfs/boxo v0.30.0/go.mod h1:BPqgGGyHB9rZZcPSzah2Dc9C+5Or3U1aQe7EH1H7370=
194
-
github.com/ipfs/go-block-format v0.2.1 h1:96kW71XGNNa+mZw/MTzJrCpMhBWCrd9kBLoKm9Iip/Q=
195
-
github.com/ipfs/go-block-format v0.2.1/go.mod h1:frtvXHMQhM6zn7HvEQu+Qz5wSTj+04oEH/I+NjDgEjk=
196
github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg=
197
github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk=
198
github.com/ipfs/go-datastore v0.8.2 h1:Jy3wjqQR6sg/LhyY0NIePZC3Vux19nLtg7dx0TVqr6U=
···
205
github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo=
206
github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0=
207
github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs=
208
-
github.com/ipfs/go-ipld-cbor v0.2.0 h1:VHIW3HVIjcMd8m4ZLZbrYpwjzqlVUfjLM7oK4T5/YF0=
209
-
github.com/ipfs/go-ipld-cbor v0.2.0/go.mod h1:Cp8T7w1NKcu4AQJLqK0tWpd1nkgTxEVB5C6kVpLW6/0=
210
-
github.com/ipfs/go-ipld-format v0.6.1 h1:lQLmBM/HHbrXvjIkrydRXkn+gc0DE5xO5fqelsCKYOQ=
211
-
github.com/ipfs/go-ipld-format v0.6.1/go.mod h1:8TOH1Hj+LFyqM2PjSqI2/ZnyO0KlfhHbJLkbxFa61hs=
212
github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8=
213
github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo=
214
github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g=
···
216
github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8=
217
github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU=
218
github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY=
219
-
github.com/ipfs/go-test v0.2.1 h1:/D/a8xZ2JzkYqcVcV/7HYlCnc7bv/pKHQiX5TdClkPE=
220
-
github.com/ipfs/go-test v0.2.1/go.mod h1:dzu+KB9cmWjuJnXFDYJwC25T3j1GcN57byN+ixmK39M=
221
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
222
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
223
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
···
229
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
230
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
231
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
232
-
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
233
-
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
234
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
235
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
236
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
···
239
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
240
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
241
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
242
-
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
243
-
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
244
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
245
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
246
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
···
251
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
252
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
253
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
254
-
github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8=
255
-
github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg=
256
-
github.com/libp2p/go-libp2p v0.41.1 h1:8ecNQVT5ev/jqALTvisSJeVNvXYJyK4NhQx1nNRXQZE=
257
-
github.com/libp2p/go-libp2p v0.41.1/go.mod h1:DcGTovJzQl/I7HMrby5ZRjeD0kQkGiy+9w6aEkSZpRI=
258
-
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
259
-
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
260
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
261
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
262
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
···
265
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
266
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
267
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
268
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
269
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
270
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
···
281
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
282
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
283
github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4=
284
-
github.com/multiformats/go-multiaddr v0.15.0 h1:zB/HeaI/apcZiTDwhY5YqMvNVl/oQYvs3XySU+qeAVo=
285
-
github.com/multiformats/go-multiaddr v0.15.0/go.mod h1:JSVUmXDjsVFiW7RjIFMP7+Ev+h1DTbiJgVeTV/tcmP0=
286
github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g=
287
github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk=
288
-
github.com/multiformats/go-multicodec v0.9.0 h1:pb/dlPnzee/Sxv/j4PmkDRxCOi3hXTz3IbPKOXWJkmg=
289
-
github.com/multiformats/go-multicodec v0.9.0/go.mod h1:L3QTQvMIaVBkXOXXtVmYE+LI16i14xuaojr/H7Ai54k=
290
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
291
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
292
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
···
318
github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg=
319
github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM=
320
github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM=
321
-
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
322
-
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
323
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
324
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
325
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
326
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
327
-
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
328
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
329
github.com/oppiliappan/chroma/v2 v2.19.0 h1:PN7/pb+6JRKCva30NPTtRJMlrOyzgpPpIroNzy4ekHU=
330
github.com/oppiliappan/chroma/v2 v2.19.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
331
github.com/oppiliappan/go-git/v5 v5.17.0 h1:CuJnpcIDxr0oiNaSHMconovSWnowHznVDG+AhjGuSEo=
···
346
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
347
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
348
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
349
-
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
350
-
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
351
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
352
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
353
github.com/redis/go-redis/v9 v9.0.0-rc.4/go.mod h1:Vo3EsyWnicKnSKCA7HhgnvnyA74wOA69Cd2Meli5mmA=
354
-
github.com/redis/go-redis/v9 v9.3.0 h1:RiVDjmig62jIWp7Kk4XVLs0hzV6pI3PyTnnL0cnn0u0=
355
-
github.com/redis/go-redis/v9 v9.3.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
356
github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE=
357
github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
358
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
···
360
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
361
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
362
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
363
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
364
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
365
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
···
404
github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
405
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
406
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
407
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
408
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
409
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
···
413
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
414
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
415
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
416
-
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
417
-
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
418
-
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
419
-
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
420
-
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk=
421
-
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME=
422
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU=
423
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0/go.mod h1:2HpZxxQurfGxJlJDblybejHB6RX6pmExPNe517hREw4=
424
-
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
425
-
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
426
-
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
427
-
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
428
-
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
429
-
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
430
-
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
431
-
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
432
go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI=
433
go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc=
434
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
···
451
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
452
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
453
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
454
-
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
455
-
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
456
-
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
457
-
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
458
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
459
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
460
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
461
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
462
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
463
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
464
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
465
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
466
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
467
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
468
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
···
471
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
472
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
473
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
474
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
475
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
476
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
···
480
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
481
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
482
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
483
-
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
484
-
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
485
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
486
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
487
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
···
489
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
490
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
491
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
492
-
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
493
-
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
494
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
495
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
496
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
···
502
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
503
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
504
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
505
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
506
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
507
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
508
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
···
510
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
511
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
512
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
513
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
514
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
515
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
516
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
517
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
518
-
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
519
-
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
520
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
521
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
522
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
523
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
524
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
525
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
526
-
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
527
-
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
528
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
529
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
530
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
···
532
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
533
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
534
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
535
-
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
536
-
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
537
-
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
538
-
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
539
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
540
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
541
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
···
547
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
548
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
549
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
550
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
551
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
552
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
553
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
554
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
555
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
556
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
557
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
558
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
559
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
560
-
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0=
561
-
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto=
562
-
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34=
563
-
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
564
-
google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
565
-
google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
566
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
567
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
568
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
···
599
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
600
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
601
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
602
-
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421 h1:ZQNKE1HKWjfQBizxDb2XLhrJcgZqE0MLFIU1iggaF90=
603
-
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421/go.mod h1:Wad0H70uyyY4qZryU/1Ic+QZyw41YxN5QfzNAEBaXkQ=
604
tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc=
605
tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
···
7
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
8
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
9
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
10
+
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
11
+
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
12
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
13
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
14
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
···
23
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
24
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
25
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
26
+
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ=
27
+
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q=
28
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
29
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
30
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
···
51
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
52
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
53
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
54
+
github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d h1:IiIprFGH6SqstblP0Y9NIo3eaUJGkI/YDOFVSL64Uq4=
55
+
github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
56
+
github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM=
57
+
github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU=
58
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
59
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
60
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
···
93
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
94
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
95
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
96
+
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
97
+
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
98
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
99
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
100
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
101
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
102
+
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
103
+
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
104
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
105
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
106
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
···
117
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
118
github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03 h1:LumE+tQdnYW24a9RoO08w64LHTzkNkdUqBD/0QPtlEY=
119
github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM=
120
+
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
121
+
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
122
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
123
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
124
+
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
125
+
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
126
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
127
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
128
github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0=
129
github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI=
130
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
131
+
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
132
+
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
133
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
134
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
135
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
136
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
137
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
138
+
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
139
+
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
140
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
141
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
142
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
143
+
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
144
+
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
145
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
146
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
147
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
···
154
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
155
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
156
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
157
+
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
158
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
159
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
160
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
161
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
162
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
163
+
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
164
+
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
165
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
166
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
167
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
···
177
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
178
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
179
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
180
+
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
181
+
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
182
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
183
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
184
+
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
185
+
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
186
+
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
187
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
188
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
189
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
190
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
191
+
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
192
+
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
193
+
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
194
+
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
195
+
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM=
196
+
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0=
197
+
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
198
+
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
199
+
github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw=
200
+
github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw=
201
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
202
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
203
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
204
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
205
+
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
206
+
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
207
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
208
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
209
github.com/hiddeco/sshsig v0.2.0 h1:gMWllgKCITXdydVkDL+Zro0PU96QI55LwUwebSwNTSw=
···
213
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
214
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
215
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
216
+
github.com/ipfs/boxo v0.33.0 h1:9ow3chwkDzMj0Deq4AWRUEI7WnIIV7SZhPTzzG2mmfw=
217
+
github.com/ipfs/boxo v0.33.0/go.mod h1:3IPh7YFcCIcKp6o02mCHovrPntoT5Pctj/7j4syh/RM=
218
+
github.com/ipfs/go-block-format v0.2.2 h1:uecCTgRwDIXyZPgYspaLXoMiMmxQpSx2aq34eNc4YvQ=
219
+
github.com/ipfs/go-block-format v0.2.2/go.mod h1:vmuefuWU6b+9kIU0vZJgpiJt1yicQz9baHXE8qR+KB8=
220
github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg=
221
github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk=
222
github.com/ipfs/go-datastore v0.8.2 h1:Jy3wjqQR6sg/LhyY0NIePZC3Vux19nLtg7dx0TVqr6U=
···
229
github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo=
230
github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0=
231
github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs=
232
+
github.com/ipfs/go-ipld-cbor v0.2.1 h1:H05yEJbK/hxg0uf2AJhyerBDbjOuHX4yi+1U/ogRa7E=
233
+
github.com/ipfs/go-ipld-cbor v0.2.1/go.mod h1:x9Zbeq8CoE5R2WicYgBMcr/9mnkQ0lHddYWJP2sMV3A=
234
+
github.com/ipfs/go-ipld-format v0.6.2 h1:bPZQ+A05ol0b3lsJSl0bLvwbuQ+HQbSsdGTy4xtYUkU=
235
+
github.com/ipfs/go-ipld-format v0.6.2/go.mod h1:nni2xFdHKx5lxvXJ6brt/pndtGxKAE+FPR1rg4jTkyk=
236
github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8=
237
github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo=
238
github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g=
···
240
github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8=
241
github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU=
242
github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY=
243
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
244
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
245
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
···
251
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
252
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
253
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
254
+
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
255
+
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
256
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
257
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
258
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
···
261
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
262
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
263
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
264
+
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
265
+
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
266
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
267
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
268
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
···
273
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
274
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
275
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
276
+
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
277
+
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
278
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
279
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
280
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
···
283
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
284
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
285
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
286
+
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
287
+
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
288
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
289
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
290
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
···
301
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
302
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
303
github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4=
304
github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g=
305
github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk=
306
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
307
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
308
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
···
334
github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg=
335
github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM=
336
github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM=
337
+
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
338
+
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
339
+
github.com/openbao/openbao/api/v2 v2.3.0 h1:61FO3ILtpKoxbD9kTWeGaCq8pz1sdt4dv2cmTXsiaAc=
340
+
github.com/openbao/openbao/api/v2 v2.3.0/go.mod h1:T47WKHb7DqHa3Ms3xicQtl5EiPE+U8diKjb9888okWs=
341
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
342
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
343
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
344
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
345
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
346
+
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
347
+
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
348
github.com/oppiliappan/chroma/v2 v2.19.0 h1:PN7/pb+6JRKCva30NPTtRJMlrOyzgpPpIroNzy4ekHU=
349
github.com/oppiliappan/chroma/v2 v2.19.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
350
github.com/oppiliappan/go-git/v5 v5.17.0 h1:CuJnpcIDxr0oiNaSHMconovSWnowHznVDG+AhjGuSEo=
···
365
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
366
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
367
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
368
+
github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4=
369
+
github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
370
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
371
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
372
github.com/redis/go-redis/v9 v9.0.0-rc.4/go.mod h1:Vo3EsyWnicKnSKCA7HhgnvnyA74wOA69Cd2Meli5mmA=
373
+
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
374
+
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
375
github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE=
376
github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
377
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
···
379
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
380
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
381
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
382
+
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
383
+
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
384
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
385
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
386
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
···
425
github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
426
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
427
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
428
+
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
429
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
430
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
431
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
···
435
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
436
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
437
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
438
+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
439
+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
440
+
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
441
+
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
442
+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA=
443
+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI=
444
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU=
445
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0/go.mod h1:2HpZxxQurfGxJlJDblybejHB6RX6pmExPNe517hREw4=
446
+
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
447
+
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
448
+
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
449
+
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
450
+
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
451
+
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
452
+
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
453
+
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
454
go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI=
455
go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc=
456
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
···
473
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
474
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
475
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
476
+
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
477
+
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
478
+
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
479
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
480
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
481
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
482
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
483
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
484
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
485
+
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
486
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
487
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
488
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
489
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
490
+
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
491
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
492
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
493
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
···
496
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
497
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
498
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
499
+
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
500
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
501
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
502
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
···
506
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
507
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
508
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
509
+
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
510
+
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
511
+
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
512
+
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
513
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
514
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
515
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
···
517
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
518
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
519
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
520
+
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
521
+
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
522
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
523
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
524
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
···
530
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
531
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
532
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
533
+
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
534
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
535
+
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
536
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
537
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
538
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
···
540
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
541
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
542
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
543
+
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
544
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
545
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
546
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
547
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
548
+
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
549
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
550
+
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
551
+
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
552
+
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
553
+
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
554
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
555
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
556
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
557
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
558
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
559
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
560
+
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
561
+
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
562
+
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
563
+
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
564
+
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
565
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
566
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
567
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
···
569
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
570
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
571
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
572
+
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
573
+
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
574
+
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
575
+
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
576
+
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
577
+
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
578
+
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
579
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
580
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
581
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
···
587
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
588
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
589
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
590
+
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
591
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
592
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
593
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
594
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
595
+
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
596
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
597
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
598
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
599
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
600
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
601
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
602
+
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
603
+
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
604
+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
605
+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
606
+
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
607
+
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
608
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
609
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
610
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
···
641
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
642
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
643
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
644
+
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 h1:z1os1aRIqeo5e8d0Tx7hk+LH8OdZZeIOY0zw9VB/ZoU=
645
+
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1/go.mod h1:+oQi9S6IIDll0nxLZVhuzOPX8WKLCYEnE6M5kUKupDg=
646
tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc=
647
tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+20
-4
guard/guard.go
+20
-4
guard/guard.go
···
2
3
import (
4
"context"
5
"fmt"
6
"log/slog"
7
"net/http"
8
"net/url"
···
13
"github.com/bluesky-social/indigo/atproto/identity"
14
securejoin "github.com/cyphar/filepath-securejoin"
15
"github.com/urfave/cli/v3"
16
-
"tangled.sh/tangled.sh/core/appview/idresolver"
17
"tangled.sh/tangled.sh/core/log"
18
)
19
···
43
Usage: "internal API endpoint",
44
Value: "http://localhost:5444",
45
},
46
},
47
}
48
}
···
54
gitDir := cmd.String("git-dir")
55
logPath := cmd.String("log-path")
56
endpoint := cmd.String("internal-api")
57
58
logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
59
if err != nil {
···
149
"fullPath", fullPath,
150
"client", clientIP)
151
152
-
if gitCommand == "git-upload-pack" {
153
-
fmt.Fprintf(os.Stderr, "\x02%s\n", "Welcome to this knot!")
154
} else {
155
-
fmt.Fprintf(os.Stderr, "%s\n", "Welcome to this knot!")
156
}
157
158
gitCmd := exec.Command(gitCommand, fullPath)
159
gitCmd.Stdout = os.Stdout
···
2
3
import (
4
"context"
5
+
"errors"
6
"fmt"
7
+
"io"
8
"log/slog"
9
"net/http"
10
"net/url"
···
15
"github.com/bluesky-social/indigo/atproto/identity"
16
securejoin "github.com/cyphar/filepath-securejoin"
17
"github.com/urfave/cli/v3"
18
+
"tangled.sh/tangled.sh/core/idresolver"
19
"tangled.sh/tangled.sh/core/log"
20
)
21
···
45
Usage: "internal API endpoint",
46
Value: "http://localhost:5444",
47
},
48
+
&cli.StringFlag{
49
+
Name: "motd-file",
50
+
Usage: "path to message of the day file",
51
+
Value: "/home/git/motd",
52
+
},
53
},
54
}
55
}
···
61
gitDir := cmd.String("git-dir")
62
logPath := cmd.String("log-path")
63
endpoint := cmd.String("internal-api")
64
+
motdFile := cmd.String("motd-file")
65
66
logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
67
if err != nil {
···
157
"fullPath", fullPath,
158
"client", clientIP)
159
160
+
var motdReader io.Reader
161
+
if reader, err := os.Open(motdFile); err != nil {
162
+
if !errors.Is(err, os.ErrNotExist) {
163
+
l.Error("failed to read motd file", "error", err)
164
+
}
165
+
motdReader = strings.NewReader("Welcome to this knot!\n")
166
} else {
167
+
motdReader = reader
168
+
}
169
+
if gitCommand == "git-upload-pack" {
170
+
io.WriteString(os.Stderr, "\x02")
171
}
172
+
io.Copy(os.Stderr, motdReader)
173
174
gitCmd := exec.Command(gitCommand, fullPath)
175
gitCmd.Stdout = os.Stdout
+24
hook/hook.go
+24
hook/hook.go
···
3
import (
4
"bufio"
5
"context"
6
"fmt"
7
"net/http"
8
"os"
···
10
11
"github.com/urfave/cli/v3"
12
)
13
14
// The hook command is nested like so:
15
//
···
36
Usage: "endpoint for the internal API",
37
Value: "http://localhost:5444",
38
},
39
},
40
Commands: []*cli.Command{
41
{
···
52
userDid := cmd.String("user-did")
53
userHandle := cmd.String("user-handle")
54
endpoint := cmd.String("internal-api")
55
56
payloadReader := bufio.NewReader(os.Stdin)
57
payload, _ := payloadReader.ReadString('\n')
···
67
req.Header.Set("X-Git-Dir", gitDir)
68
req.Header.Set("X-Git-User-Did", userDid)
69
req.Header.Set("X-Git-User-Handle", userHandle)
70
71
resp, err := client.Do(req)
72
if err != nil {
···
76
77
if resp.StatusCode != http.StatusOK {
78
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
79
}
80
81
return nil
···
3
import (
4
"bufio"
5
"context"
6
+
"encoding/json"
7
"fmt"
8
"net/http"
9
"os"
···
11
12
"github.com/urfave/cli/v3"
13
)
14
+
15
+
type HookResponse struct {
16
+
Messages []string `json:"messages"`
17
+
}
18
19
// The hook command is nested like so:
20
//
···
41
Usage: "endpoint for the internal API",
42
Value: "http://localhost:5444",
43
},
44
+
&cli.StringSliceFlag{
45
+
Name: "push-option",
46
+
Usage: "any push option from git",
47
+
},
48
},
49
Commands: []*cli.Command{
50
{
···
61
userDid := cmd.String("user-did")
62
userHandle := cmd.String("user-handle")
63
endpoint := cmd.String("internal-api")
64
+
pushOptions := cmd.StringSlice("push-option")
65
66
payloadReader := bufio.NewReader(os.Stdin)
67
payload, _ := payloadReader.ReadString('\n')
···
77
req.Header.Set("X-Git-Dir", gitDir)
78
req.Header.Set("X-Git-User-Did", userDid)
79
req.Header.Set("X-Git-User-Handle", userHandle)
80
+
if pushOptions != nil {
81
+
for _, option := range pushOptions {
82
+
req.Header.Add("X-Git-Push-Option", option)
83
+
}
84
+
}
85
86
resp, err := client.Do(req)
87
if err != nil {
···
91
92
if resp.StatusCode != http.StatusOK {
93
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
94
+
}
95
+
96
+
var data HookResponse
97
+
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
98
+
return fmt.Errorf("failed to decode response: %w", err)
99
+
}
100
+
101
+
for _, message := range data.Messages {
102
+
fmt.Println(message)
103
}
104
105
return nil
+6
-1
hook/setup.go
+6
-1
hook/setup.go
···
133
134
hookContent := fmt.Sprintf(`#!/usr/bin/env bash
135
# AUTO GENERATED BY KNOT, DO NOT MODIFY
136
-
%s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" post-recieve
137
`, executablePath, config.internalApi)
138
139
return os.WriteFile(hookPath, []byte(hookContent), 0755)
···
133
134
hookContent := fmt.Sprintf(`#!/usr/bin/env bash
135
# AUTO GENERATED BY KNOT, DO NOT MODIFY
136
+
push_options=()
137
+
for ((i=0; i<GIT_PUSH_OPTION_COUNT; i++)); do
138
+
option_var="GIT_PUSH_OPTION_$i"
139
+
push_options+=(-push-option "${!option_var}")
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
142
`, executablePath, config.internalApi)
143
144
return os.WriteFile(hookPath, []byte(hookContent), 0755)
+116
idresolver/resolver.go
+116
idresolver/resolver.go
···
···
1
+
package idresolver
2
+
3
+
import (
4
+
"context"
5
+
"net"
6
+
"net/http"
7
+
"sync"
8
+
"time"
9
+
10
+
"github.com/bluesky-social/indigo/atproto/identity"
11
+
"github.com/bluesky-social/indigo/atproto/identity/redisdir"
12
+
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
"github.com/carlmjohnson/versioninfo"
14
+
)
15
+
16
+
type Resolver struct {
17
+
directory identity.Directory
18
+
}
19
+
20
+
func BaseDirectory() identity.Directory {
21
+
base := identity.BaseDirectory{
22
+
PLCURL: identity.DefaultPLCURL,
23
+
HTTPClient: http.Client{
24
+
Timeout: time.Second * 10,
25
+
Transport: &http.Transport{
26
+
// would want this around 100ms for services doing lots of handle resolution. Impacts PLC connections as well, but not too bad.
27
+
IdleConnTimeout: time.Millisecond * 1000,
28
+
MaxIdleConns: 100,
29
+
},
30
+
},
31
+
Resolver: net.Resolver{
32
+
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
33
+
d := net.Dialer{Timeout: time.Second * 3}
34
+
return d.DialContext(ctx, network, address)
35
+
},
36
+
},
37
+
TryAuthoritativeDNS: true,
38
+
// primary Bluesky PDS instance only supports HTTP resolution method
39
+
SkipDNSDomainSuffixes: []string{".bsky.social"},
40
+
UserAgent: "indigo-identity/" + versioninfo.Short(),
41
+
}
42
+
return &base
43
+
}
44
+
45
+
func RedisDirectory(url string) (identity.Directory, error) {
46
+
hitTTL := time.Hour * 24
47
+
errTTL := time.Second * 30
48
+
invalidHandleTTL := time.Minute * 5
49
+
return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000)
50
+
}
51
+
52
+
func DefaultResolver() *Resolver {
53
+
return &Resolver{
54
+
directory: identity.DefaultDirectory(),
55
+
}
56
+
}
57
+
58
+
func RedisResolver(redisUrl string) (*Resolver, error) {
59
+
directory, err := RedisDirectory(redisUrl)
60
+
if err != nil {
61
+
return nil, err
62
+
}
63
+
return &Resolver{
64
+
directory: directory,
65
+
}, nil
66
+
}
67
+
68
+
func (r *Resolver) ResolveIdent(ctx context.Context, arg string) (*identity.Identity, error) {
69
+
id, err := syntax.ParseAtIdentifier(arg)
70
+
if err != nil {
71
+
return nil, err
72
+
}
73
+
74
+
return r.directory.Lookup(ctx, *id)
75
+
}
76
+
77
+
func (r *Resolver) ResolveIdents(ctx context.Context, idents []string) []*identity.Identity {
78
+
results := make([]*identity.Identity, len(idents))
79
+
var wg sync.WaitGroup
80
+
81
+
done := make(chan struct{})
82
+
defer close(done)
83
+
84
+
for idx, ident := range idents {
85
+
wg.Add(1)
86
+
go func(index int, id string) {
87
+
defer wg.Done()
88
+
89
+
select {
90
+
case <-ctx.Done():
91
+
results[index] = nil
92
+
case <-done:
93
+
results[index] = nil
94
+
default:
95
+
identity, _ := r.ResolveIdent(ctx, id)
96
+
results[index] = identity
97
+
}
98
+
}(idx, ident)
99
+
}
100
+
101
+
wg.Wait()
102
+
return results
103
+
}
104
+
105
+
func (r *Resolver) InvalidateIdent(ctx context.Context, arg string) error {
106
+
id, err := syntax.ParseAtIdentifier(arg)
107
+
if err != nil {
108
+
return err
109
+
}
110
+
111
+
return r.directory.Purge(ctx, *id)
112
+
}
113
+
114
+
func (r *Resolver) Directory() identity.Directory {
115
+
return r.directory
116
+
}
+1
-2
input.css
+1
-2
input.css
+13
jetstream/jetstream.go
+13
jetstream/jetstream.go
···
52
j.mu.Unlock()
53
}
54
55
+
func (j *JetstreamClient) RemoveDid(did string) {
56
+
if did == "" {
57
+
return
58
+
}
59
+
60
+
if j.logDids {
61
+
j.l.Info("removing did from in-memory filter", "did", did)
62
+
}
63
+
j.mu.Lock()
64
+
delete(j.wantedDids, did)
65
+
j.mu.Unlock()
66
+
}
67
+
68
type processor func(context.Context, *models.Event) error
69
70
func (j *JetstreamClient) withDidFilter(processFunc processor) processor {
+6
knotserver/config/config.go
+6
knotserver/config/config.go
···
2
3
import (
4
"context"
5
+
"fmt"
6
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
8
"github.com/sethvargo/go-envconfig"
9
)
10
···
25
26
// This disables signature verification so use with caution.
27
Dev bool `env:"DEV, default=false"`
28
+
}
29
+
30
+
func (s Server) Did() syntax.DID {
31
+
return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname))
32
}
33
34
type Config struct {
+112
knotserver/git/branch.go
+112
knotserver/git/branch.go
···
···
1
+
package git
2
+
3
+
import (
4
+
"fmt"
5
+
"slices"
6
+
"strconv"
7
+
"strings"
8
+
"time"
9
+
10
+
"github.com/go-git/go-git/v5/plumbing"
11
+
"github.com/go-git/go-git/v5/plumbing/object"
12
+
"tangled.sh/tangled.sh/core/types"
13
+
)
14
+
15
+
func (g *GitRepo) Branches() ([]types.Branch, error) {
16
+
fields := []string{
17
+
"refname:short",
18
+
"objectname",
19
+
"authorname",
20
+
"authoremail",
21
+
"authordate:unix",
22
+
"committername",
23
+
"committeremail",
24
+
"committerdate:unix",
25
+
"tree",
26
+
"parent",
27
+
"contents",
28
+
}
29
+
30
+
var outFormat strings.Builder
31
+
outFormat.WriteString("--format=")
32
+
for i, f := range fields {
33
+
if i != 0 {
34
+
outFormat.WriteString(fieldSeparator)
35
+
}
36
+
outFormat.WriteString(fmt.Sprintf("%%(%s)", f))
37
+
}
38
+
outFormat.WriteString("")
39
+
outFormat.WriteString(recordSeparator)
40
+
41
+
output, err := g.forEachRef(outFormat.String(), "refs/heads")
42
+
if err != nil {
43
+
return nil, fmt.Errorf("failed to get branches: %w", err)
44
+
}
45
+
46
+
records := strings.Split(strings.TrimSpace(string(output)), recordSeparator)
47
+
if len(records) == 1 && records[0] == "" {
48
+
return nil, nil
49
+
}
50
+
51
+
branches := make([]types.Branch, 0, len(records))
52
+
53
+
// ignore errors here
54
+
defaultBranch, _ := g.FindMainBranch()
55
+
56
+
for _, line := range records {
57
+
parts := strings.SplitN(strings.TrimSpace(line), fieldSeparator, len(fields))
58
+
if len(parts) < 6 {
59
+
continue
60
+
}
61
+
62
+
branchName := parts[0]
63
+
commitHash := plumbing.NewHash(parts[1])
64
+
authorName := parts[2]
65
+
authorEmail := strings.TrimSuffix(strings.TrimPrefix(parts[3], "<"), ">")
66
+
authorDate := parts[4]
67
+
committerName := parts[5]
68
+
committerEmail := strings.TrimSuffix(strings.TrimPrefix(parts[6], "<"), ">")
69
+
committerDate := parts[7]
70
+
treeHash := plumbing.NewHash(parts[8])
71
+
parentHash := plumbing.NewHash(parts[9])
72
+
message := parts[10]
73
+
74
+
// parse creation time
75
+
var authoredAt, committedAt time.Time
76
+
if unix, err := strconv.ParseInt(authorDate, 10, 64); err == nil {
77
+
authoredAt = time.Unix(unix, 0)
78
+
}
79
+
if unix, err := strconv.ParseInt(committerDate, 10, 64); err == nil {
80
+
committedAt = time.Unix(unix, 0)
81
+
}
82
+
83
+
branch := types.Branch{
84
+
IsDefault: branchName == defaultBranch,
85
+
Reference: types.Reference{
86
+
Name: branchName,
87
+
Hash: commitHash.String(),
88
+
},
89
+
Commit: &object.Commit{
90
+
Hash: commitHash,
91
+
Author: object.Signature{
92
+
Name: authorName,
93
+
Email: authorEmail,
94
+
When: authoredAt,
95
+
},
96
+
Committer: object.Signature{
97
+
Name: committerName,
98
+
Email: committerEmail,
99
+
When: committedAt,
100
+
},
101
+
TreeHash: treeHash,
102
+
ParentHashes: []plumbing.Hash{parentHash},
103
+
Message: message,
104
+
},
105
+
}
106
+
107
+
branches = append(branches, branch)
108
+
}
109
+
110
+
slices.Reverse(branches)
111
+
return branches, nil
112
+
}
+42
knotserver/git/cmd.go
+42
knotserver/git/cmd.go
···
···
1
+
package git
2
+
3
+
import (
4
+
"fmt"
5
+
"os/exec"
6
+
)
7
+
8
+
const (
9
+
fieldSeparator = "\x1f" // ASCII Unit Separator
10
+
recordSeparator = "\x1e" // ASCII Record Separator
11
+
)
12
+
13
+
func (g *GitRepo) runGitCmd(command string, extraArgs ...string) ([]byte, error) {
14
+
var args []string
15
+
args = append(args, command)
16
+
args = append(args, extraArgs...)
17
+
18
+
cmd := exec.Command("git", args...)
19
+
cmd.Dir = g.path
20
+
21
+
out, err := cmd.Output()
22
+
if err != nil {
23
+
if exitErr, ok := err.(*exec.ExitError); ok {
24
+
return nil, fmt.Errorf("%w, stderr: %s", err, string(exitErr.Stderr))
25
+
}
26
+
return nil, err
27
+
}
28
+
29
+
return out, nil
30
+
}
31
+
32
+
func (g *GitRepo) revList(extraArgs ...string) ([]byte, error) {
33
+
return g.runGitCmd("rev-list", extraArgs...)
34
+
}
35
+
36
+
func (g *GitRepo) forEachRef(extraArgs ...string) ([]byte, error) {
37
+
return g.runGitCmd("for-each-ref", extraArgs...)
38
+
}
39
+
40
+
func (g *GitRepo) revParse(extraArgs ...string) ([]byte, error) {
41
+
return g.runGitCmd("rev-parse", extraArgs...)
42
+
}
+3
-94
knotserver/git/git.go
+3
-94
knotserver/git/git.go
···
6
"fmt"
7
"io"
8
"io/fs"
9
-
"os/exec"
10
"path"
11
-
"sort"
12
"strconv"
13
"strings"
14
"time"
···
16
"github.com/go-git/go-git/v5"
17
"github.com/go-git/go-git/v5/plumbing"
18
"github.com/go-git/go-git/v5/plumbing/object"
19
-
"tangled.sh/tangled.sh/core/types"
20
)
21
22
var (
···
170
return count, nil
171
}
172
173
-
func (g *GitRepo) revList(extraArgs ...string) ([]byte, error) {
174
-
var args []string
175
-
args = append(args, "rev-list")
176
-
args = append(args, extraArgs...)
177
-
178
-
cmd := exec.Command("git", args...)
179
-
cmd.Dir = g.path
180
-
181
-
out, err := cmd.Output()
182
-
if err != nil {
183
-
if exitErr, ok := err.(*exec.ExitError); ok {
184
-
return nil, fmt.Errorf("%w, stderr: %s", err, string(exitErr.Stderr))
185
-
}
186
-
return nil, err
187
-
}
188
-
189
-
return out, nil
190
-
}
191
-
192
func (g *GitRepo) Commit(h plumbing.Hash) (*object.Commit, error) {
193
return g.r.CommitObject(h)
194
}
···
285
return io.ReadAll(reader)
286
}
287
288
-
func (g *GitRepo) Tags() ([]*TagReference, error) {
289
-
iter, err := g.r.Tags()
290
-
if err != nil {
291
-
return nil, fmt.Errorf("tag objects: %w", err)
292
-
}
293
-
294
-
tags := make([]*TagReference, 0)
295
-
296
-
if err := iter.ForEach(func(ref *plumbing.Reference) error {
297
-
obj, err := g.r.TagObject(ref.Hash())
298
-
switch err {
299
-
case nil:
300
-
tags = append(tags, &TagReference{
301
-
ref: ref,
302
-
tag: obj,
303
-
})
304
-
case plumbing.ErrObjectNotFound:
305
-
tags = append(tags, &TagReference{
306
-
ref: ref,
307
-
})
308
-
default:
309
-
return err
310
-
}
311
-
return nil
312
-
}); err != nil {
313
-
return nil, err
314
-
}
315
-
316
-
tagList := &TagList{r: g.r, refs: tags}
317
-
sort.Sort(tagList)
318
-
return tags, nil
319
-
}
320
-
321
-
func (g *GitRepo) Branches() ([]types.Branch, error) {
322
-
bi, err := g.r.Branches()
323
-
if err != nil {
324
-
return nil, fmt.Errorf("branchs: %w", err)
325
-
}
326
-
327
-
branches := []types.Branch{}
328
-
329
-
defaultBranch, err := g.FindMainBranch()
330
-
331
-
_ = bi.ForEach(func(ref *plumbing.Reference) error {
332
-
b := types.Branch{}
333
-
b.Hash = ref.Hash().String()
334
-
b.Name = ref.Name().Short()
335
-
336
-
// resolve commit that this branch points to
337
-
commit, _ := g.Commit(ref.Hash())
338
-
if commit != nil {
339
-
b.Commit = commit
340
-
}
341
-
342
-
if defaultBranch != "" && defaultBranch == b.Name {
343
-
b.IsDefault = true
344
-
}
345
-
346
-
branches = append(branches, b)
347
-
348
-
return nil
349
-
})
350
-
351
-
return branches, nil
352
-
}
353
-
354
func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) {
355
ref, err := g.r.Reference(plumbing.NewBranchReferenceName(name), false)
356
if err != nil {
···
370
}
371
372
func (g *GitRepo) FindMainBranch() (string, error) {
373
-
ref, err := g.r.Head()
374
if err != nil {
375
-
return "", fmt.Errorf("unable to find main branch: %w", err)
376
-
}
377
-
if ref.Name().IsBranch() {
378
-
return strings.TrimPrefix(string(ref.Name()), "refs/heads/"), nil
379
}
380
381
-
return "", fmt.Errorf("unable to find main branch: %w", err)
382
}
383
384
// WriteTar writes itself from a tree into a binary tar file format.
···
6
"fmt"
7
"io"
8
"io/fs"
9
"path"
10
"strconv"
11
"strings"
12
"time"
···
14
"github.com/go-git/go-git/v5"
15
"github.com/go-git/go-git/v5/plumbing"
16
"github.com/go-git/go-git/v5/plumbing/object"
17
)
18
19
var (
···
167
return count, nil
168
}
169
170
func (g *GitRepo) Commit(h plumbing.Hash) (*object.Commit, error) {
171
return g.r.CommitObject(h)
172
}
···
263
return io.ReadAll(reader)
264
}
265
266
func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) {
267
ref, err := g.r.Reference(plumbing.NewBranchReferenceName(name), false)
268
if err != nil {
···
282
}
283
284
func (g *GitRepo) FindMainBranch() (string, error) {
285
+
output, err := g.revParse("--abbrev-ref", "HEAD")
286
if err != nil {
287
+
return "", fmt.Errorf("failed to find main branch: %w", err)
288
}
289
290
+
return strings.TrimSpace(string(output)), nil
291
}
292
293
// WriteTar writes itself from a tree into a binary tar file format.
+66
knotserver/git/language.go
+66
knotserver/git/language.go
···
···
1
+
package git
2
+
3
+
import (
4
+
"context"
5
+
"path"
6
+
7
+
"github.com/go-enry/go-enry/v2"
8
+
"github.com/go-git/go-git/v5/plumbing/object"
9
+
)
10
+
11
+
type LangBreakdown map[string]int64
12
+
13
+
func (g *GitRepo) AnalyzeLanguages(ctx context.Context) (LangBreakdown, error) {
14
+
sizes := make(map[string]int64)
15
+
err := g.Walk(ctx, "", func(node object.TreeEntry, parent *object.Tree, root string) error {
16
+
filepath := path.Join(root, node.Name)
17
+
18
+
content, err := g.FileContentN(filepath, 16*1024) // 16KB
19
+
if err != nil {
20
+
return nil
21
+
}
22
+
23
+
if enry.IsGenerated(filepath, content) {
24
+
return nil
25
+
}
26
+
27
+
language := analyzeLanguage(node, content)
28
+
if group := enry.GetLanguageGroup(language); group != "" {
29
+
language = group
30
+
}
31
+
32
+
langType := enry.GetLanguageType(language)
33
+
if langType != enry.Programming && langType != enry.Markup && langType != enry.Unknown {
34
+
return nil
35
+
}
36
+
37
+
sz, _ := parent.Size(node.Name)
38
+
sizes[language] += sz
39
+
40
+
return nil
41
+
})
42
+
43
+
if err != nil {
44
+
return nil, err
45
+
}
46
+
47
+
return sizes, nil
48
+
}
49
+
50
+
func analyzeLanguage(node object.TreeEntry, content []byte) string {
51
+
language, ok := enry.GetLanguageByExtension(node.Name)
52
+
if ok {
53
+
return language
54
+
}
55
+
56
+
language, ok = enry.GetLanguageByFilename(node.Name)
57
+
if ok {
58
+
return language
59
+
}
60
+
61
+
if len(content) == 0 {
62
+
return enry.OtherLanguage
63
+
}
64
+
65
+
return enry.GetLanguage(node.Name, content)
66
+
}
+57
-25
knotserver/git/post_receive.go
+57
-25
knotserver/git/post_receive.go
···
2
3
import (
4
"bufio"
5
"fmt"
6
"io"
7
"strings"
8
9
"tangled.sh/tangled.sh/core/api/tangled"
10
···
46
}
47
48
type RefUpdateMeta struct {
49
-
CommitCount CommitCount
50
-
IsDefaultRef bool
51
}
52
53
type CommitCount struct {
···
57
func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) RefUpdateMeta {
58
commitCount, err := g.newCommitCount(line)
59
if err != nil {
60
-
// TODO: non-fatal, log this
61
}
62
63
isDefaultRef, err := g.isDefaultBranch(line)
64
if err != nil {
65
-
// TODO: non-fatal, log this
66
}
67
68
return RefUpdateMeta{
69
-
CommitCount: commitCount,
70
-
IsDefaultRef: isDefaultRef,
71
}
72
}
73
···
77
ByEmail: byEmail,
78
}
79
80
-
if !line.NewSha.IsZero() {
81
-
output, err := g.revList(
82
-
fmt.Sprintf("--max-count=%d", 100),
83
-
fmt.Sprintf("%s..%s", line.OldSha.String(), line.NewSha.String()),
84
-
)
85
-
if err != nil {
86
-
return commitCount, fmt.Errorf("failed to run rev-list: %w", err)
87
-
}
88
89
-
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
90
-
if len(lines) == 1 && lines[0] == "" {
91
-
return commitCount, nil
92
-
}
93
94
-
for _, item := range lines {
95
-
obj, err := g.r.CommitObject(plumbing.NewHash(item))
96
-
if err != nil {
97
-
continue
98
-
}
99
-
commitCount.ByEmail[obj.Author.Email] += 1
100
}
101
}
102
103
return commitCount, nil
···
126
})
127
}
128
129
return tangled.GitRefUpdate_Meta{
130
CommitCount: &tangled.GitRefUpdate_Meta_CommitCount{
131
ByEmail: byEmail,
132
},
133
-
IsDefaultRef: m.IsDefaultRef,
134
}
135
}
···
2
3
import (
4
"bufio"
5
+
"context"
6
"fmt"
7
"io"
8
"strings"
9
+
"time"
10
11
"tangled.sh/tangled.sh/core/api/tangled"
12
···
48
}
49
50
type RefUpdateMeta struct {
51
+
CommitCount CommitCount
52
+
IsDefaultRef bool
53
+
LangBreakdown LangBreakdown
54
}
55
56
type CommitCount struct {
···
60
func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) RefUpdateMeta {
61
commitCount, err := g.newCommitCount(line)
62
if err != nil {
63
+
// TODO: log this
64
}
65
66
isDefaultRef, err := g.isDefaultBranch(line)
67
if err != nil {
68
+
// TODO: log this
69
+
}
70
+
71
+
ctx, cancel := context.WithTimeout(context.Background(), time.Second*2)
72
+
defer cancel()
73
+
breakdown, err := g.AnalyzeLanguages(ctx)
74
+
if err != nil {
75
+
// TODO: log this
76
}
77
78
return RefUpdateMeta{
79
+
CommitCount: commitCount,
80
+
IsDefaultRef: isDefaultRef,
81
+
LangBreakdown: breakdown,
82
}
83
}
84
···
88
ByEmail: byEmail,
89
}
90
91
+
if line.NewSha.IsZero() {
92
+
return commitCount, nil
93
+
}
94
95
+
args := []string{fmt.Sprintf("--max-count=%d", 100)}
96
97
+
if line.OldSha.IsZero() {
98
+
// just git rev-list <newsha>
99
+
args = append(args, line.NewSha.String())
100
+
} else {
101
+
// git rev-list <oldsha>..<newsha>
102
+
args = append(args, fmt.Sprintf("%s..%s", line.OldSha.String(), line.NewSha.String()))
103
+
}
104
+
105
+
output, err := g.revList(args...)
106
+
if err != nil {
107
+
return commitCount, fmt.Errorf("failed to run rev-list: %w", err)
108
+
}
109
+
110
+
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
111
+
if len(lines) == 1 && lines[0] == "" {
112
+
return commitCount, nil
113
+
}
114
+
115
+
for _, item := range lines {
116
+
obj, err := g.r.CommitObject(plumbing.NewHash(item))
117
+
if err != nil {
118
+
continue
119
}
120
+
commitCount.ByEmail[obj.Author.Email] += 1
121
}
122
123
return commitCount, nil
···
146
})
147
}
148
149
+
var langs []*tangled.GitRefUpdate_Pair
150
+
for lang, size := range m.LangBreakdown {
151
+
langs = append(langs, &tangled.GitRefUpdate_Pair{
152
+
Lang: lang,
153
+
Size: size,
154
+
})
155
+
}
156
+
langBreakdown := &tangled.GitRefUpdate_Meta_LangBreakdown{
157
+
Inputs: langs,
158
+
}
159
+
160
return tangled.GitRefUpdate_Meta{
161
CommitCount: &tangled.GitRefUpdate_Meta_CommitCount{
162
ByEmail: byEmail,
163
},
164
+
IsDefaultRef: m.IsDefaultRef,
165
+
LangBreakdown: langBreakdown,
166
}
167
}
+99
knotserver/git/tag.go
+99
knotserver/git/tag.go
···
···
1
+
package git
2
+
3
+
import (
4
+
"fmt"
5
+
"slices"
6
+
"strconv"
7
+
"strings"
8
+
"time"
9
+
10
+
"github.com/go-git/go-git/v5/plumbing"
11
+
"github.com/go-git/go-git/v5/plumbing/object"
12
+
)
13
+
14
+
func (g *GitRepo) Tags() ([]object.Tag, error) {
15
+
fields := []string{
16
+
"refname:short",
17
+
"objectname",
18
+
"objecttype",
19
+
"*objectname",
20
+
"*objecttype",
21
+
"taggername",
22
+
"taggeremail",
23
+
"taggerdate:unix",
24
+
"contents",
25
+
}
26
+
27
+
var outFormat strings.Builder
28
+
outFormat.WriteString("--format=")
29
+
for i, f := range fields {
30
+
if i != 0 {
31
+
outFormat.WriteString(fieldSeparator)
32
+
}
33
+
outFormat.WriteString(fmt.Sprintf("%%(%s)", f))
34
+
}
35
+
outFormat.WriteString("")
36
+
outFormat.WriteString(recordSeparator)
37
+
38
+
output, err := g.forEachRef(outFormat.String(), "refs/tags")
39
+
if err != nil {
40
+
return nil, fmt.Errorf("failed to get tags: %w", err)
41
+
}
42
+
43
+
records := strings.Split(strings.TrimSpace(string(output)), recordSeparator)
44
+
if len(records) == 1 && records[0] == "" {
45
+
return nil, nil
46
+
}
47
+
48
+
tags := make([]object.Tag, 0, len(records))
49
+
50
+
for _, line := range records {
51
+
parts := strings.SplitN(strings.TrimSpace(line), fieldSeparator, len(fields))
52
+
if len(parts) < 6 {
53
+
continue
54
+
}
55
+
56
+
tagName := parts[0]
57
+
objectHash := parts[1]
58
+
objectType := parts[2]
59
+
targetHash := parts[3] // dereferenced object hash (empty for lightweight tags)
60
+
// targetType := parts[4] // dereferenced object type (empty for lightweight tags)
61
+
taggerName := parts[5]
62
+
taggerEmail := parts[6]
63
+
taggerDate := parts[7]
64
+
message := parts[8]
65
+
66
+
// parse creation time
67
+
var createdAt time.Time
68
+
if unix, err := strconv.ParseInt(taggerDate, 10, 64); err == nil {
69
+
createdAt = time.Unix(unix, 0)
70
+
}
71
+
72
+
// parse object type
73
+
typ, err := plumbing.ParseObjectType(objectType)
74
+
if err != nil {
75
+
return nil, err
76
+
}
77
+
78
+
// strip email separators
79
+
taggerEmail = strings.TrimSuffix(strings.TrimPrefix(taggerEmail, "<"), ">")
80
+
81
+
tag := object.Tag{
82
+
Hash: plumbing.NewHash(objectHash),
83
+
Name: tagName,
84
+
Tagger: object.Signature{
85
+
Name: taggerName,
86
+
Email: taggerEmail,
87
+
When: createdAt,
88
+
},
89
+
Message: message,
90
+
TargetType: typ,
91
+
Target: plumbing.NewHash(targetHash),
92
+
}
93
+
94
+
tags = append(tags, tag)
95
+
}
96
+
97
+
slices.Reverse(tags)
98
+
return tags, nil
99
+
}
+37
-18
knotserver/handler.go
+37
-18
knotserver/handler.go
···
8
"runtime/debug"
9
10
"github.com/go-chi/chi/v5"
11
"tangled.sh/tangled.sh/core/jetstream"
12
"tangled.sh/tangled.sh/core/knotserver/config"
13
"tangled.sh/tangled.sh/core/knotserver/db"
14
"tangled.sh/tangled.sh/core/notifier"
15
"tangled.sh/tangled.sh/core/rbac"
16
-
)
17
-
18
-
const (
19
-
ThisServer = "thisserver" // resource identifier for rbac enforcement
20
)
21
22
type Handle struct {
23
-
c *config.Config
24
-
db *db.DB
25
-
jc *jetstream.JetstreamClient
26
-
e *rbac.Enforcer
27
-
l *slog.Logger
28
-
n *notifier.Notifier
29
30
// init is a channel that is closed when the knot has been initailized
31
// i.e. when the first user (knot owner) has been added.
···
37
r := chi.NewRouter()
38
39
h := Handle{
40
-
c: c,
41
-
db: db,
42
-
e: e,
43
-
l: l,
44
-
jc: jc,
45
-
n: n,
46
-
init: make(chan struct{}),
47
}
48
49
-
err := e.AddKnot(ThisServer)
50
if err != nil {
51
return nil, fmt.Errorf("failed to setup enforcer: %w", err)
52
}
···
131
})
132
})
133
134
// Create a new repository.
135
r.Route("/repo", func(r chi.Router) {
136
r.Use(h.VerifySignature)
···
161
r.Get("/keys", h.Keys)
162
163
return r, nil
164
}
165
166
// version is set during build time.
···
8
"runtime/debug"
9
10
"github.com/go-chi/chi/v5"
11
+
"tangled.sh/tangled.sh/core/idresolver"
12
"tangled.sh/tangled.sh/core/jetstream"
13
"tangled.sh/tangled.sh/core/knotserver/config"
14
"tangled.sh/tangled.sh/core/knotserver/db"
15
+
"tangled.sh/tangled.sh/core/knotserver/xrpc"
16
+
tlog "tangled.sh/tangled.sh/core/log"
17
"tangled.sh/tangled.sh/core/notifier"
18
"tangled.sh/tangled.sh/core/rbac"
19
)
20
21
type Handle struct {
22
+
c *config.Config
23
+
db *db.DB
24
+
jc *jetstream.JetstreamClient
25
+
e *rbac.Enforcer
26
+
l *slog.Logger
27
+
n *notifier.Notifier
28
+
resolver *idresolver.Resolver
29
30
// init is a channel that is closed when the knot has been initailized
31
// i.e. when the first user (knot owner) has been added.
···
37
r := chi.NewRouter()
38
39
h := Handle{
40
+
c: c,
41
+
db: db,
42
+
e: e,
43
+
l: l,
44
+
jc: jc,
45
+
n: n,
46
+
resolver: idresolver.DefaultResolver(),
47
+
init: make(chan struct{}),
48
}
49
50
+
err := e.AddKnot(rbac.ThisServer)
51
if err != nil {
52
return nil, fmt.Errorf("failed to setup enforcer: %w", err)
53
}
···
132
})
133
})
134
135
+
// xrpc apis
136
+
r.Mount("/xrpc", h.XrpcRouter())
137
+
138
// Create a new repository.
139
r.Route("/repo", func(r chi.Router) {
140
r.Use(h.VerifySignature)
···
165
r.Get("/keys", h.Keys)
166
167
return r, nil
168
+
}
169
+
170
+
func (h *Handle) XrpcRouter() http.Handler {
171
+
logger := tlog.New("knots")
172
+
173
+
xrpc := &xrpc.Xrpc{
174
+
Config: h.c,
175
+
Db: h.db,
176
+
Ingester: h.jc,
177
+
Enforcer: h.e,
178
+
Logger: logger,
179
+
Notifier: h.n,
180
+
Resolver: h.resolver,
181
+
}
182
+
return xrpc.Router()
183
}
184
185
// version is set during build time.
+65
-4
knotserver/ingester.go
+65
-4
knotserver/ingester.go
···
17
"github.com/bluesky-social/jetstream/pkg/models"
18
securejoin "github.com/cyphar/filepath-securejoin"
19
"tangled.sh/tangled.sh/core/api/tangled"
20
-
"tangled.sh/tangled.sh/core/appview/idresolver"
21
"tangled.sh/tangled.sh/core/knotserver/db"
22
"tangled.sh/tangled.sh/core/knotserver/git"
23
"tangled.sh/tangled.sh/core/log"
24
"tangled.sh/tangled.sh/core/workflow"
25
)
26
···
46
return fmt.Errorf("domain mismatch: %s != %s", record.Domain, h.c.Server.Hostname)
47
}
48
49
-
ok, err := h.e.E.Enforce(did, ThisServer, ThisServer, "server:invite")
50
if err != nil || !ok {
51
l.Error("failed to add member", "did", did)
52
return fmt.Errorf("failed to enforce permissions: %w", err)
53
}
54
55
-
if err := h.e.AddKnotMember(ThisServer, record.Subject); err != nil {
56
l.Error("failed to add member", "error", err)
57
return fmt.Errorf("failed to add member: %w", err)
58
}
···
212
return h.db.InsertEvent(event, h.n)
213
}
214
215
func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error {
216
l := log.FromContext(ctx)
217
···
265
defer func() {
266
eventTime := event.TimeUS
267
lastTimeUs := eventTime + 1
268
-
fmt.Println("lastTimeUs", lastTimeUs)
269
if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil {
270
err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
271
}
···
291
if err := h.processKnotMember(ctx, did, record); err != nil {
292
return fmt.Errorf("failed to process knot member: %w", err)
293
}
294
case tangled.RepoPullNSID:
295
var record tangled.RepoPull
296
if err := json.Unmarshal(raw, &record); err != nil {
···
299
if err := h.processPull(ctx, did, record); err != nil {
300
return fmt.Errorf("failed to process knot member: %w", err)
301
}
302
}
303
304
return err
···
17
"github.com/bluesky-social/jetstream/pkg/models"
18
securejoin "github.com/cyphar/filepath-securejoin"
19
"tangled.sh/tangled.sh/core/api/tangled"
20
+
"tangled.sh/tangled.sh/core/idresolver"
21
"tangled.sh/tangled.sh/core/knotserver/db"
22
"tangled.sh/tangled.sh/core/knotserver/git"
23
"tangled.sh/tangled.sh/core/log"
24
+
"tangled.sh/tangled.sh/core/rbac"
25
"tangled.sh/tangled.sh/core/workflow"
26
)
27
···
47
return fmt.Errorf("domain mismatch: %s != %s", record.Domain, h.c.Server.Hostname)
48
}
49
50
+
ok, err := h.e.E.Enforce(did, rbac.ThisServer, rbac.ThisServer, "server:invite")
51
if err != nil || !ok {
52
l.Error("failed to add member", "did", did)
53
return fmt.Errorf("failed to enforce permissions: %w", err)
54
}
55
56
+
if err := h.e.AddKnotMember(rbac.ThisServer, record.Subject); err != nil {
57
l.Error("failed to add member", "error", err)
58
return fmt.Errorf("failed to add member: %w", err)
59
}
···
213
return h.db.InsertEvent(event, h.n)
214
}
215
216
+
// duplicated from add collaborator
217
+
func (h *Handle) processCollaborator(ctx context.Context, did string, record tangled.RepoCollaborator) error {
218
+
repoAt, err := syntax.ParseATURI(record.Repo)
219
+
if err != nil {
220
+
return err
221
+
}
222
+
223
+
resolver := idresolver.DefaultResolver()
224
+
225
+
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
226
+
if err != nil || subjectId.Handle.IsInvalidHandle() {
227
+
return err
228
+
}
229
+
230
+
// TODO: fix this for good, we need to fetch the record here unfortunately
231
+
// resolve this aturi to extract the repo record
232
+
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
233
+
if err != nil || owner.Handle.IsInvalidHandle() {
234
+
return fmt.Errorf("failed to resolve handle: %w", err)
235
+
}
236
+
237
+
xrpcc := xrpc.Client{
238
+
Host: owner.PDSEndpoint(),
239
+
}
240
+
241
+
resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
242
+
if err != nil {
243
+
return err
244
+
}
245
+
246
+
repo := resp.Value.Val.(*tangled.Repo)
247
+
didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name)
248
+
249
+
// check perms for this user
250
+
if ok, err := h.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil {
251
+
return fmt.Errorf("insufficient permissions: %w", err)
252
+
}
253
+
254
+
if err := h.db.AddDid(subjectId.DID.String()); err != nil {
255
+
return err
256
+
}
257
+
h.jc.AddDid(subjectId.DID.String())
258
+
259
+
if err := h.e.AddCollaborator(subjectId.DID.String(), rbac.ThisServer, didSlashRepo); err != nil {
260
+
return err
261
+
}
262
+
263
+
return h.fetchAndAddKeys(ctx, subjectId.DID.String())
264
+
}
265
+
266
func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error {
267
l := log.FromContext(ctx)
268
···
316
defer func() {
317
eventTime := event.TimeUS
318
lastTimeUs := eventTime + 1
319
if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil {
320
err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
321
}
···
341
if err := h.processKnotMember(ctx, did, record); err != nil {
342
return fmt.Errorf("failed to process knot member: %w", err)
343
}
344
+
345
case tangled.RepoPullNSID:
346
var record tangled.RepoPull
347
if err := json.Unmarshal(raw, &record); err != nil {
···
350
if err := h.processPull(ctx, did, record); err != nil {
351
return fmt.Errorf("failed to process knot member: %w", err)
352
}
353
+
354
+
case tangled.RepoCollaboratorNSID:
355
+
var record tangled.RepoCollaborator
356
+
if err := json.Unmarshal(raw, &record); err != nil {
357
+
return fmt.Errorf("failed to unmarshal record: %w", err)
358
+
}
359
+
if err := h.processCollaborator(ctx, did, record); err != nil {
360
+
return fmt.Errorf("failed to process knot member: %w", err)
361
+
}
362
+
363
}
364
365
return err
+66
-7
knotserver/internal.go
+66
-7
knotserver/internal.go
···
3
import (
4
"context"
5
"encoding/json"
6
"log/slog"
7
"net/http"
8
"path/filepath"
···
12
"github.com/go-chi/chi/v5"
13
"github.com/go-chi/chi/v5/middleware"
14
"tangled.sh/tangled.sh/core/api/tangled"
15
"tangled.sh/tangled.sh/core/knotserver/config"
16
"tangled.sh/tangled.sh/core/knotserver/db"
17
"tangled.sh/tangled.sh/core/knotserver/git"
···
37
return
38
}
39
40
-
ok, err := h.e.IsPushAllowed(user, ThisServer, repo)
41
if err != nil || !ok {
42
w.WriteHeader(http.StatusForbidden)
43
return
···
63
return
64
}
65
66
func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) {
67
l := h.l.With("handler", "PostReceiveHook")
68
···
89
// non-fatal
90
}
91
92
for _, line := range lines {
93
err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName)
94
if err != nil {
···
96
// non-fatal
97
}
98
99
-
err = h.triggerPipeline(line, gitUserDid, repoDid, repoName)
100
if err != nil {
101
l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
102
// non-fatal
103
}
104
}
105
}
106
107
func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
···
115
return err
116
}
117
118
-
gr, err := git.PlainOpen(repoPath)
119
if err != nil {
120
-
return err
121
}
122
123
meta := gr.RefUpdateMeta(line)
124
metaRecord := meta.AsRecord()
125
126
refUpdate := tangled.GitRefUpdate{
···
146
return h.db.InsertEvent(event, h.n)
147
}
148
149
-
func (h *InternalHandle) triggerPipeline(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
150
didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
151
if err != nil {
152
return err
···
166
if err != nil {
167
return err
168
}
169
170
var pipeline workflow.Pipeline
171
for _, e := range workflowDir {
···
181
182
wf, err := workflow.FromFile(e.Name, contents)
183
if err != nil {
184
-
// TODO: log here, respond to client that is pushing
185
h.l.Error("failed to parse workflow", "err", err, "path", fpath)
186
continue
187
}
188
···
207
},
208
}
209
210
-
// TODO: send the diagnostics back to the user here via stderr
211
cp := compiler.Compile(pipeline)
212
eventJson, err := json.Marshal(cp)
213
if err != nil {
214
return err
215
}
216
217
// do not run empty pipelines
···
3
import (
4
"context"
5
"encoding/json"
6
+
"fmt"
7
"log/slog"
8
"net/http"
9
"path/filepath"
···
13
"github.com/go-chi/chi/v5"
14
"github.com/go-chi/chi/v5/middleware"
15
"tangled.sh/tangled.sh/core/api/tangled"
16
+
"tangled.sh/tangled.sh/core/hook"
17
"tangled.sh/tangled.sh/core/knotserver/config"
18
"tangled.sh/tangled.sh/core/knotserver/db"
19
"tangled.sh/tangled.sh/core/knotserver/git"
···
39
return
40
}
41
42
+
ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo)
43
if err != nil || !ok {
44
w.WriteHeader(http.StatusForbidden)
45
return
···
65
return
66
}
67
68
+
type PushOptions struct {
69
+
skipCi bool
70
+
verboseCi bool
71
+
}
72
+
73
func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) {
74
l := h.l.With("handler", "PostReceiveHook")
75
···
96
// non-fatal
97
}
98
99
+
// extract any push options
100
+
pushOptionsRaw := r.Header.Values("X-Git-Push-Option")
101
+
pushOptions := PushOptions{}
102
+
for _, option := range pushOptionsRaw {
103
+
if option == "skip-ci" || option == "ci-skip" {
104
+
pushOptions.skipCi = true
105
+
}
106
+
if option == "verbose-ci" || option == "ci-verbose" {
107
+
pushOptions.verboseCi = true
108
+
}
109
+
}
110
+
111
+
resp := hook.HookResponse{
112
+
Messages: make([]string, 0),
113
+
}
114
+
115
for _, line := range lines {
116
err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName)
117
if err != nil {
···
119
// non-fatal
120
}
121
122
+
err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
123
if err != nil {
124
l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
125
// non-fatal
126
}
127
}
128
+
129
+
writeJSON(w, resp)
130
}
131
132
func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
···
140
return err
141
}
142
143
+
gr, err := git.Open(repoPath, line.Ref)
144
if err != nil {
145
+
return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err)
146
}
147
148
meta := gr.RefUpdateMeta(line)
149
+
150
metaRecord := meta.AsRecord()
151
152
refUpdate := tangled.GitRefUpdate{
···
172
return h.db.InsertEvent(event, h.n)
173
}
174
175
+
func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error {
176
+
if pushOptions.skipCi {
177
+
return nil
178
+
}
179
+
180
didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
181
if err != nil {
182
return err
···
196
if err != nil {
197
return err
198
}
199
+
200
+
pipelineParseErrors := []string{}
201
202
var pipeline workflow.Pipeline
203
for _, e := range workflowDir {
···
213
214
wf, err := workflow.FromFile(e.Name, contents)
215
if err != nil {
216
h.l.Error("failed to parse workflow", "err", err, "path", fpath)
217
+
pipelineParseErrors = append(pipelineParseErrors, fmt.Sprintf("- at %s: %s\n", fpath, err))
218
continue
219
}
220
···
239
},
240
}
241
242
cp := compiler.Compile(pipeline)
243
eventJson, err := json.Marshal(cp)
244
if err != nil {
245
return err
246
+
}
247
+
248
+
if pushOptions.verboseCi {
249
+
hasDiagnostics := false
250
+
if len(pipelineParseErrors) > 0 {
251
+
hasDiagnostics = true
252
+
*clientMsgs = append(*clientMsgs, "error: failed to parse workflow(s):")
253
+
for _, error := range pipelineParseErrors {
254
+
*clientMsgs = append(*clientMsgs, error)
255
+
}
256
+
}
257
+
if len(compiler.Diagnostics.Errors) > 0 {
258
+
hasDiagnostics = true
259
+
*clientMsgs = append(*clientMsgs, "error(s) on pipeline:")
260
+
for _, error := range compiler.Diagnostics.Errors {
261
+
*clientMsgs = append(*clientMsgs, fmt.Sprintf("- %s:", error))
262
+
}
263
+
}
264
+
if len(compiler.Diagnostics.Warnings) > 0 {
265
+
hasDiagnostics = true
266
+
*clientMsgs = append(*clientMsgs, "warning(s) on pipeline:")
267
+
for _, warning := range compiler.Diagnostics.Warnings {
268
+
*clientMsgs = append(*clientMsgs, fmt.Sprintf("- at %s: %s: %s", warning.Path, warning.Type, warning.Reason))
269
+
}
270
+
}
271
+
if !hasDiagnostics {
272
+
*clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics")
273
+
}
274
}
275
276
// do not run empty pipelines
+38
-70
knotserver/routes.go
+38
-70
knotserver/routes.go
···
13
"net/http"
14
"net/url"
15
"os"
16
-
"path"
17
"path/filepath"
18
"strconv"
19
"strings"
···
23
securejoin "github.com/cyphar/filepath-securejoin"
24
"github.com/gliderlabs/ssh"
25
"github.com/go-chi/chi/v5"
26
-
"github.com/go-enry/go-enry/v2"
27
gogit "github.com/go-git/go-git/v5"
28
"github.com/go-git/go-git/v5/plumbing"
29
"github.com/go-git/go-git/v5/plumbing/object"
···
31
"tangled.sh/tangled.sh/core/knotserver/db"
32
"tangled.sh/tangled.sh/core/knotserver/git"
33
"tangled.sh/tangled.sh/core/patchutil"
34
"tangled.sh/tangled.sh/core/types"
35
)
36
···
96
total int
97
branches []types.Branch
98
files []types.NiceTree
99
-
tags []*git.TagReference
100
)
101
102
var wg sync.WaitGroup
···
169
170
rtags := []*types.TagReference{}
171
for _, tag := range tags {
172
tr := types.TagReference{
173
-
Tag: tag.TagObject(),
174
}
175
176
tr.Reference = types.Reference{
177
-
Name: tag.Name(),
178
-
Hash: tag.Hash().String(),
179
}
180
181
-
if tag.Message() != "" {
182
-
tr.Message = tag.Message()
183
}
184
185
rtags = append(rtags, &tr)
···
283
mimeType = "image/svg+xml"
284
}
285
286
-
if !strings.HasPrefix(mimeType, "image/") && !strings.HasPrefix(mimeType, "video/") {
287
-
l.Error("attempted to serve non-image/video file", "mimetype", mimeType)
288
-
writeError(w, "only image and video files can be accessed directly", http.StatusForbidden)
289
return
290
}
291
···
488
489
rtags := []*types.TagReference{}
490
for _, tag := range tags {
491
tr := types.TagReference{
492
-
Tag: tag.TagObject(),
493
}
494
495
tr.Reference = types.Reference{
496
-
Name: tag.Name(),
497
-
Hash: tag.Hash().String(),
498
}
499
500
-
if tag.Message() != "" {
501
-
tr.Message = tag.Message()
502
}
503
504
rtags = append(rtags, &tr)
···
668
}
669
670
// add perms for this user to access the repo
671
-
err = h.e.AddRepo(did, ThisServer, relativeRepoPath)
672
if err != nil {
673
l.Error("adding repo permissions", "error", err.Error())
674
writeError(w, err.Error(), http.StatusInternalServerError)
···
777
return
778
}
779
780
-
sizes := make(map[string]int64)
781
-
782
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
783
defer cancel()
784
785
-
err = gr.Walk(ctx, "", func(node object.TreeEntry, parent *object.Tree, root string) error {
786
-
filepath := path.Join(root, node.Name)
787
-
788
-
content, err := gr.FileContentN(filepath, 16*1024) // 16KB
789
-
if err != nil {
790
-
return nil
791
-
}
792
-
793
-
if enry.IsGenerated(filepath, content) {
794
-
return nil
795
-
}
796
-
797
-
language := analyzeLanguage(node, content)
798
-
if group := enry.GetLanguageGroup(language); group != "" {
799
-
language = group
800
-
}
801
-
802
-
langType := enry.GetLanguageType(language)
803
-
if langType != enry.Programming && langType != enry.Markup && langType != enry.Unknown {
804
-
return nil
805
-
}
806
-
807
-
sz, _ := parent.Size(node.Name)
808
-
sizes[language] += sz
809
-
810
-
return nil
811
-
})
812
if err != nil {
813
-
l.Error("failed to recurse file tree", "error", err.Error())
814
writeError(w, err.Error(), http.StatusNoContent)
815
return
816
}
···
818
resp := types.RepoLanguageResponse{Languages: sizes}
819
820
writeJSON(w, resp)
821
-
return
822
-
}
823
-
824
-
func analyzeLanguage(node object.TreeEntry, content []byte) string {
825
-
language, ok := enry.GetLanguageByExtension(node.Name)
826
-
if ok {
827
-
return language
828
-
}
829
-
830
-
language, ok = enry.GetLanguageByFilename(node.Name)
831
-
if ok {
832
-
return language
833
-
}
834
-
835
-
if len(content) == 0 {
836
-
return enry.OtherLanguage
837
-
}
838
-
839
-
return enry.GetLanguage(node.Name, content)
840
}
841
842
func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) {
···
933
}
934
935
// add perms for this user to access the repo
936
-
err = h.e.AddRepo(did, ThisServer, relativeRepoPath)
937
if err != nil {
938
l.Error("adding repo permissions", "error", err.Error())
939
writeError(w, err.Error(), http.StatusInternalServerError)
···
1187
}
1188
h.jc.AddDid(did)
1189
1190
-
if err := h.e.AddKnotMember(ThisServer, did); err != nil {
1191
l.Error("adding member", "error", err.Error())
1192
writeError(w, err.Error(), http.StatusInternalServerError)
1193
return
···
1225
h.jc.AddDid(data.Did)
1226
1227
repoName, _ := securejoin.SecureJoin(ownerDid, repo)
1228
-
if err := h.e.AddCollaborator(data.Did, ThisServer, repoName); err != nil {
1229
l.Error("adding repo collaborator", "error", err.Error())
1230
writeError(w, err.Error(), http.StatusInternalServerError)
1231
return
···
1322
}
1323
h.jc.AddDid(data.Did)
1324
1325
-
if err := h.e.AddKnotOwner(ThisServer, data.Did); err != nil {
1326
l.Error("adding owner", "error", err.Error())
1327
writeError(w, err.Error(), http.StatusInternalServerError)
1328
return
···
13
"net/http"
14
"net/url"
15
"os"
16
"path/filepath"
17
"strconv"
18
"strings"
···
22
securejoin "github.com/cyphar/filepath-securejoin"
23
"github.com/gliderlabs/ssh"
24
"github.com/go-chi/chi/v5"
25
gogit "github.com/go-git/go-git/v5"
26
"github.com/go-git/go-git/v5/plumbing"
27
"github.com/go-git/go-git/v5/plumbing/object"
···
29
"tangled.sh/tangled.sh/core/knotserver/db"
30
"tangled.sh/tangled.sh/core/knotserver/git"
31
"tangled.sh/tangled.sh/core/patchutil"
32
+
"tangled.sh/tangled.sh/core/rbac"
33
"tangled.sh/tangled.sh/core/types"
34
)
35
···
95
total int
96
branches []types.Branch
97
files []types.NiceTree
98
+
tags []object.Tag
99
)
100
101
var wg sync.WaitGroup
···
168
169
rtags := []*types.TagReference{}
170
for _, tag := range tags {
171
+
var target *object.Tag
172
+
if tag.Target != plumbing.ZeroHash {
173
+
target = &tag
174
+
}
175
tr := types.TagReference{
176
+
Tag: target,
177
}
178
179
tr.Reference = types.Reference{
180
+
Name: tag.Name,
181
+
Hash: tag.Hash.String(),
182
}
183
184
+
if tag.Message != "" {
185
+
tr.Message = tag.Message
186
}
187
188
rtags = append(rtags, &tr)
···
286
mimeType = "image/svg+xml"
287
}
288
289
+
// allow image, video, and text/plain files to be served directly
290
+
switch {
291
+
case strings.HasPrefix(mimeType, "image/"):
292
+
// allowed
293
+
case strings.HasPrefix(mimeType, "video/"):
294
+
// allowed
295
+
case strings.HasPrefix(mimeType, "text/plain"):
296
+
// allowed
297
+
default:
298
+
l.Error("attempted to serve disallowed file type", "mimetype", mimeType)
299
+
writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden)
300
return
301
}
302
···
499
500
rtags := []*types.TagReference{}
501
for _, tag := range tags {
502
+
var target *object.Tag
503
+
if tag.Target != plumbing.ZeroHash {
504
+
target = &tag
505
+
}
506
tr := types.TagReference{
507
+
Tag: target,
508
}
509
510
tr.Reference = types.Reference{
511
+
Name: tag.Name,
512
+
Hash: tag.Hash.String(),
513
}
514
515
+
if tag.Message != "" {
516
+
tr.Message = tag.Message
517
}
518
519
rtags = append(rtags, &tr)
···
683
}
684
685
// add perms for this user to access the repo
686
+
err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath)
687
if err != nil {
688
l.Error("adding repo permissions", "error", err.Error())
689
writeError(w, err.Error(), http.StatusInternalServerError)
···
792
return
793
}
794
795
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
796
defer cancel()
797
798
+
sizes, err := gr.AnalyzeLanguages(ctx)
799
if err != nil {
800
+
l.Error("failed to analyze languages", "error", err.Error())
801
writeError(w, err.Error(), http.StatusNoContent)
802
return
803
}
···
805
resp := types.RepoLanguageResponse{Languages: sizes}
806
807
writeJSON(w, resp)
808
}
809
810
func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) {
···
901
}
902
903
// add perms for this user to access the repo
904
+
err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath)
905
if err != nil {
906
l.Error("adding repo permissions", "error", err.Error())
907
writeError(w, err.Error(), http.StatusInternalServerError)
···
1155
}
1156
h.jc.AddDid(did)
1157
1158
+
if err := h.e.AddKnotMember(rbac.ThisServer, did); err != nil {
1159
l.Error("adding member", "error", err.Error())
1160
writeError(w, err.Error(), http.StatusInternalServerError)
1161
return
···
1193
h.jc.AddDid(data.Did)
1194
1195
repoName, _ := securejoin.SecureJoin(ownerDid, repo)
1196
+
if err := h.e.AddCollaborator(data.Did, rbac.ThisServer, repoName); err != nil {
1197
l.Error("adding repo collaborator", "error", err.Error())
1198
writeError(w, err.Error(), http.StatusInternalServerError)
1199
return
···
1290
}
1291
h.jc.AddDid(data.Did)
1292
1293
+
if err := h.e.AddKnotOwner(rbac.ThisServer, data.Did); err != nil {
1294
l.Error("adding owner", "error", err.Error())
1295
writeError(w, err.Error(), http.StatusInternalServerError)
1296
return
+1
knotserver/server.go
+1
knotserver/server.go
-5
knotserver/util.go
-5
knotserver/util.go
···
8
"github.com/bluesky-social/indigo/atproto/syntax"
9
securejoin "github.com/cyphar/filepath-securejoin"
10
"github.com/go-chi/chi/v5"
11
-
"github.com/microcosm-cc/bluemonday"
12
)
13
-
14
-
func sanitize(content []byte) []byte {
15
-
return bluemonday.UGCPolicy().SanitizeBytes([]byte(content))
16
-
}
17
18
func didPath(r *http.Request) string {
19
did := chi.URLParam(r, "did")
+149
knotserver/xrpc/router.go
+149
knotserver/xrpc/router.go
···
···
1
+
package xrpc
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"fmt"
7
+
"log/slog"
8
+
"net/http"
9
+
"strings"
10
+
11
+
"tangled.sh/tangled.sh/core/api/tangled"
12
+
"tangled.sh/tangled.sh/core/idresolver"
13
+
"tangled.sh/tangled.sh/core/jetstream"
14
+
"tangled.sh/tangled.sh/core/knotserver/config"
15
+
"tangled.sh/tangled.sh/core/knotserver/db"
16
+
"tangled.sh/tangled.sh/core/notifier"
17
+
"tangled.sh/tangled.sh/core/rbac"
18
+
19
+
"github.com/bluesky-social/indigo/atproto/auth"
20
+
"github.com/go-chi/chi/v5"
21
+
)
22
+
23
+
type Xrpc struct {
24
+
Config *config.Config
25
+
Db *db.DB
26
+
Ingester *jetstream.JetstreamClient
27
+
Enforcer *rbac.Enforcer
28
+
Logger *slog.Logger
29
+
Notifier *notifier.Notifier
30
+
Resolver *idresolver.Resolver
31
+
}
32
+
33
+
func (x *Xrpc) Router() http.Handler {
34
+
r := chi.NewRouter()
35
+
36
+
r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch)
37
+
38
+
return r
39
+
}
40
+
41
+
func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler {
42
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
43
+
l := x.Logger.With("url", r.URL)
44
+
45
+
token := r.Header.Get("Authorization")
46
+
token = strings.TrimPrefix(token, "Bearer ")
47
+
48
+
s := auth.ServiceAuthValidator{
49
+
Audience: x.Config.Server.Did().String(),
50
+
Dir: x.Resolver.Directory(),
51
+
}
52
+
53
+
did, err := s.Validate(r.Context(), token, nil)
54
+
if err != nil {
55
+
l.Error("signature verification failed", "err", err)
56
+
writeError(w, AuthError(err), http.StatusForbidden)
57
+
return
58
+
}
59
+
60
+
r = r.WithContext(
61
+
context.WithValue(r.Context(), ActorDid, did),
62
+
)
63
+
64
+
next.ServeHTTP(w, r)
65
+
})
66
+
}
67
+
68
+
type XrpcError struct {
69
+
Tag string `json:"error"`
70
+
Message string `json:"message"`
71
+
}
72
+
73
+
func NewXrpcError(opts ...ErrOpt) XrpcError {
74
+
x := XrpcError{}
75
+
for _, o := range opts {
76
+
o(&x)
77
+
}
78
+
79
+
return x
80
+
}
81
+
82
+
type ErrOpt = func(xerr *XrpcError)
83
+
84
+
func WithTag(tag string) ErrOpt {
85
+
return func(xerr *XrpcError) {
86
+
xerr.Tag = tag
87
+
}
88
+
}
89
+
90
+
func WithMessage[S ~string](s S) ErrOpt {
91
+
return func(xerr *XrpcError) {
92
+
xerr.Message = string(s)
93
+
}
94
+
}
95
+
96
+
func WithError(e error) ErrOpt {
97
+
return func(xerr *XrpcError) {
98
+
xerr.Message = e.Error()
99
+
}
100
+
}
101
+
102
+
var MissingActorDidError = NewXrpcError(
103
+
WithTag("MissingActorDid"),
104
+
WithMessage("actor DID not supplied"),
105
+
)
106
+
107
+
var AuthError = func(err error) XrpcError {
108
+
return NewXrpcError(
109
+
WithTag("Auth"),
110
+
WithError(fmt.Errorf("signature verification failed: %w", err)),
111
+
)
112
+
}
113
+
114
+
var InvalidRepoError = func(r string) XrpcError {
115
+
return NewXrpcError(
116
+
WithTag("InvalidRepo"),
117
+
WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)),
118
+
)
119
+
}
120
+
121
+
var AccessControlError = func(d string) XrpcError {
122
+
return NewXrpcError(
123
+
WithTag("AccessControl"),
124
+
WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)),
125
+
)
126
+
}
127
+
128
+
var GitError = func(e error) XrpcError {
129
+
return NewXrpcError(
130
+
WithTag("Git"),
131
+
WithError(fmt.Errorf("git error: %w", e)),
132
+
)
133
+
}
134
+
135
+
func GenericError(err error) XrpcError {
136
+
return NewXrpcError(
137
+
WithTag("Generic"),
138
+
WithError(err),
139
+
)
140
+
}
141
+
142
+
// this is slightly different from http_util::write_error to follow the spec:
143
+
//
144
+
// the json object returned must include an "error" and a "message"
145
+
func writeError(w http.ResponseWriter, e XrpcError, status int) {
146
+
w.Header().Set("Content-Type", "application/json")
147
+
w.WriteHeader(status)
148
+
json.NewEncoder(w).Encode(e)
149
+
}
+87
knotserver/xrpc/set_default_branch.go
+87
knotserver/xrpc/set_default_branch.go
···
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
8
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
"github.com/bluesky-social/indigo/xrpc"
11
+
securejoin "github.com/cyphar/filepath-securejoin"
12
+
"tangled.sh/tangled.sh/core/api/tangled"
13
+
"tangled.sh/tangled.sh/core/knotserver/git"
14
+
"tangled.sh/tangled.sh/core/rbac"
15
+
)
16
+
17
+
const ActorDid string = "ActorDid"
18
+
19
+
func (x *Xrpc) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
20
+
l := x.Logger
21
+
fail := func(e XrpcError) {
22
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
23
+
writeError(w, e, http.StatusBadRequest)
24
+
}
25
+
26
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
27
+
if !ok {
28
+
fail(MissingActorDidError)
29
+
return
30
+
}
31
+
32
+
var data tangled.RepoSetDefaultBranch_Input
33
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
34
+
fail(GenericError(err))
35
+
return
36
+
}
37
+
38
+
// unfortunately we have to resolve repo-at here
39
+
repoAt, err := syntax.ParseATURI(data.Repo)
40
+
if err != nil {
41
+
fail(InvalidRepoError(data.Repo))
42
+
return
43
+
}
44
+
45
+
// resolve this aturi to extract the repo record
46
+
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
47
+
if err != nil || ident.Handle.IsInvalidHandle() {
48
+
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
49
+
return
50
+
}
51
+
52
+
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
53
+
resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
54
+
if err != nil {
55
+
fail(GenericError(err))
56
+
return
57
+
}
58
+
59
+
repo := resp.Value.Val.(*tangled.Repo)
60
+
didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name)
61
+
if err != nil {
62
+
fail(GenericError(err))
63
+
return
64
+
}
65
+
66
+
if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
67
+
l.Error("insufficent permissions", "did", actorDid.String())
68
+
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
69
+
return
70
+
}
71
+
72
+
path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath)
73
+
gr, err := git.PlainOpen(path)
74
+
if err != nil {
75
+
fail(InvalidRepoError(data.Repo))
76
+
return
77
+
}
78
+
79
+
err = gr.SetDefaultBranch(data.DefaultBranch)
80
+
if err != nil {
81
+
l.Error("setting default branch", "error", err.Error())
82
+
writeError(w, GitError(err), http.StatusInternalServerError)
83
+
return
84
+
}
85
+
86
+
w.WriteHeader(http.StatusOK)
87
+
}
-52
lexicons/artifact.json
-52
lexicons/artifact.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo.artifact",
4
-
"needsCbor": true,
5
-
"needsType": true,
6
-
"defs": {
7
-
"main": {
8
-
"type": "record",
9
-
"key": "tid",
10
-
"record": {
11
-
"type": "object",
12
-
"required": [
13
-
"name",
14
-
"repo",
15
-
"tag",
16
-
"createdAt",
17
-
"artifact"
18
-
],
19
-
"properties": {
20
-
"name": {
21
-
"type": "string",
22
-
"description": "name of the artifact"
23
-
},
24
-
"repo": {
25
-
"type": "string",
26
-
"format": "at-uri",
27
-
"description": "repo that this artifact is being uploaded to"
28
-
},
29
-
"tag": {
30
-
"type": "bytes",
31
-
"description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)",
32
-
"minLength": 20,
33
-
"maxLength": 20
34
-
},
35
-
"createdAt": {
36
-
"type": "string",
37
-
"format": "datetime",
38
-
"description": "time of creation of this artifact"
39
-
},
40
-
"artifact": {
41
-
"type": "blob",
42
-
"description": "the artifact",
43
-
"accept": [
44
-
"*/*"
45
-
],
46
-
"maxSize": 52428800
47
-
}
48
-
}
49
-
}
50
-
}
51
-
}
52
-
}
···
+34
lexicons/feed/reaction.json
+34
lexicons/feed/reaction.json
···
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.feed.reaction",
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
+
"reaction",
15
+
"createdAt"
16
+
],
17
+
"properties": {
18
+
"subject": {
19
+
"type": "string",
20
+
"format": "at-uri"
21
+
},
22
+
"reaction": {
23
+
"type": "string",
24
+
"enum": [ "๐", "๐", "๐", "๐", "๐ซค", "โค๏ธ", "๐", "๐" ]
25
+
},
26
+
"createdAt": {
27
+
"type": "string",
28
+
"format": "datetime"
29
+
}
30
+
}
31
+
}
32
+
}
33
+
}
34
+
}
+27
lexicons/git/refUpdate.json
+27
lexicons/git/refUpdate.json
···
61
"type": "boolean",
62
"default": "false"
63
},
64
+
"langBreakdown": {
65
+
"type": "object",
66
+
"properties": {
67
+
"inputs": {
68
+
"type": "array",
69
+
"items": {
70
+
"type": "ref",
71
+
"ref": "#pair"
72
+
}
73
+
}
74
+
}
75
+
},
76
"commitCount": {
77
"type": "object",
78
"required": [],
···
99
}
100
}
101
}
102
+
}
103
+
}
104
+
},
105
+
"pair": {
106
+
"type": "object",
107
+
"required": [
108
+
"lang",
109
+
"size"
110
+
],
111
+
"properties": {
112
+
"lang": {
113
+
"type": "string"
114
+
},
115
+
"size": {
116
+
"type": "integer"
117
}
118
}
119
}
+263
lexicons/pipeline/pipeline.json
+263
lexicons/pipeline/pipeline.json
···
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.pipeline",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"triggerMetadata",
14
+
"workflows"
15
+
],
16
+
"properties": {
17
+
"triggerMetadata": {
18
+
"type": "ref",
19
+
"ref": "#triggerMetadata"
20
+
},
21
+
"workflows": {
22
+
"type": "array",
23
+
"items": {
24
+
"type": "ref",
25
+
"ref": "#workflow"
26
+
}
27
+
}
28
+
}
29
+
}
30
+
},
31
+
"triggerMetadata": {
32
+
"type": "object",
33
+
"required": [
34
+
"kind",
35
+
"repo"
36
+
],
37
+
"properties": {
38
+
"kind": {
39
+
"type": "string",
40
+
"enum": [
41
+
"push",
42
+
"pull_request",
43
+
"manual"
44
+
]
45
+
},
46
+
"repo": {
47
+
"type": "ref",
48
+
"ref": "#triggerRepo"
49
+
},
50
+
"push": {
51
+
"type": "ref",
52
+
"ref": "#pushTriggerData"
53
+
},
54
+
"pullRequest": {
55
+
"type": "ref",
56
+
"ref": "#pullRequestTriggerData"
57
+
},
58
+
"manual": {
59
+
"type": "ref",
60
+
"ref": "#manualTriggerData"
61
+
}
62
+
}
63
+
},
64
+
"triggerRepo": {
65
+
"type": "object",
66
+
"required": [
67
+
"knot",
68
+
"did",
69
+
"repo",
70
+
"defaultBranch"
71
+
],
72
+
"properties": {
73
+
"knot": {
74
+
"type": "string"
75
+
},
76
+
"did": {
77
+
"type": "string",
78
+
"format": "did"
79
+
},
80
+
"repo": {
81
+
"type": "string"
82
+
},
83
+
"defaultBranch": {
84
+
"type": "string"
85
+
}
86
+
}
87
+
},
88
+
"pushTriggerData": {
89
+
"type": "object",
90
+
"required": [
91
+
"ref",
92
+
"newSha",
93
+
"oldSha"
94
+
],
95
+
"properties": {
96
+
"ref": {
97
+
"type": "string"
98
+
},
99
+
"newSha": {
100
+
"type": "string",
101
+
"minLength": 40,
102
+
"maxLength": 40
103
+
},
104
+
"oldSha": {
105
+
"type": "string",
106
+
"minLength": 40,
107
+
"maxLength": 40
108
+
}
109
+
}
110
+
},
111
+
"pullRequestTriggerData": {
112
+
"type": "object",
113
+
"required": [
114
+
"sourceBranch",
115
+
"targetBranch",
116
+
"sourceSha",
117
+
"action"
118
+
],
119
+
"properties": {
120
+
"sourceBranch": {
121
+
"type": "string"
122
+
},
123
+
"targetBranch": {
124
+
"type": "string"
125
+
},
126
+
"sourceSha": {
127
+
"type": "string",
128
+
"minLength": 40,
129
+
"maxLength": 40
130
+
},
131
+
"action": {
132
+
"type": "string"
133
+
}
134
+
}
135
+
},
136
+
"manualTriggerData": {
137
+
"type": "object",
138
+
"properties": {
139
+
"inputs": {
140
+
"type": "array",
141
+
"items": {
142
+
"type": "ref",
143
+
"ref": "#pair"
144
+
}
145
+
}
146
+
}
147
+
},
148
+
"workflow": {
149
+
"type": "object",
150
+
"required": [
151
+
"name",
152
+
"dependencies",
153
+
"steps",
154
+
"environment",
155
+
"clone"
156
+
],
157
+
"properties": {
158
+
"name": {
159
+
"type": "string"
160
+
},
161
+
"dependencies": {
162
+
"type": "array",
163
+
"items": {
164
+
"type": "ref",
165
+
"ref": "#dependency"
166
+
}
167
+
},
168
+
"steps": {
169
+
"type": "array",
170
+
"items": {
171
+
"type": "ref",
172
+
"ref": "#step"
173
+
}
174
+
},
175
+
"environment": {
176
+
"type": "array",
177
+
"items": {
178
+
"type": "ref",
179
+
"ref": "#pair"
180
+
}
181
+
},
182
+
"clone": {
183
+
"type": "ref",
184
+
"ref": "#cloneOpts"
185
+
}
186
+
}
187
+
},
188
+
"dependency": {
189
+
"type": "object",
190
+
"required": [
191
+
"registry",
192
+
"packages"
193
+
],
194
+
"properties": {
195
+
"registry": {
196
+
"type": "string"
197
+
},
198
+
"packages": {
199
+
"type": "array",
200
+
"items": {
201
+
"type": "string"
202
+
}
203
+
}
204
+
}
205
+
},
206
+
"cloneOpts": {
207
+
"type": "object",
208
+
"required": [
209
+
"skip",
210
+
"depth",
211
+
"submodules"
212
+
],
213
+
"properties": {
214
+
"skip": {
215
+
"type": "boolean"
216
+
},
217
+
"depth": {
218
+
"type": "integer"
219
+
},
220
+
"submodules": {
221
+
"type": "boolean"
222
+
}
223
+
}
224
+
},
225
+
"step": {
226
+
"type": "object",
227
+
"required": [
228
+
"name",
229
+
"command"
230
+
],
231
+
"properties": {
232
+
"name": {
233
+
"type": "string"
234
+
},
235
+
"command": {
236
+
"type": "string"
237
+
},
238
+
"environment": {
239
+
"type": "array",
240
+
"items": {
241
+
"type": "ref",
242
+
"ref": "#pair"
243
+
}
244
+
}
245
+
}
246
+
},
247
+
"pair": {
248
+
"type": "object",
249
+
"required": [
250
+
"key",
251
+
"value"
252
+
],
253
+
"properties": {
254
+
"key": {
255
+
"type": "string"
256
+
},
257
+
"value": {
258
+
"type": "string"
259
+
}
260
+
}
261
+
}
262
+
}
263
+
}
-263
lexicons/pipeline.json
-263
lexicons/pipeline.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.pipeline",
4
-
"needsCbor": true,
5
-
"needsType": true,
6
-
"defs": {
7
-
"main": {
8
-
"type": "record",
9
-
"key": "tid",
10
-
"record": {
11
-
"type": "object",
12
-
"required": [
13
-
"triggerMetadata",
14
-
"workflows"
15
-
],
16
-
"properties": {
17
-
"triggerMetadata": {
18
-
"type": "ref",
19
-
"ref": "#triggerMetadata"
20
-
},
21
-
"workflows": {
22
-
"type": "array",
23
-
"items": {
24
-
"type": "ref",
25
-
"ref": "#workflow"
26
-
}
27
-
}
28
-
}
29
-
}
30
-
},
31
-
"triggerMetadata": {
32
-
"type": "object",
33
-
"required": [
34
-
"kind",
35
-
"repo"
36
-
],
37
-
"properties": {
38
-
"kind": {
39
-
"type": "string",
40
-
"enum": [
41
-
"push",
42
-
"pull_request",
43
-
"manual"
44
-
]
45
-
},
46
-
"repo": {
47
-
"type": "ref",
48
-
"ref": "#triggerRepo"
49
-
},
50
-
"push": {
51
-
"type": "ref",
52
-
"ref": "#pushTriggerData"
53
-
},
54
-
"pullRequest": {
55
-
"type": "ref",
56
-
"ref": "#pullRequestTriggerData"
57
-
},
58
-
"manual": {
59
-
"type": "ref",
60
-
"ref": "#manualTriggerData"
61
-
}
62
-
}
63
-
},
64
-
"triggerRepo": {
65
-
"type": "object",
66
-
"required": [
67
-
"knot",
68
-
"did",
69
-
"repo",
70
-
"defaultBranch"
71
-
],
72
-
"properties": {
73
-
"knot": {
74
-
"type": "string"
75
-
},
76
-
"did": {
77
-
"type": "string",
78
-
"format": "did"
79
-
},
80
-
"repo": {
81
-
"type": "string"
82
-
},
83
-
"defaultBranch": {
84
-
"type": "string"
85
-
}
86
-
}
87
-
},
88
-
"pushTriggerData": {
89
-
"type": "object",
90
-
"required": [
91
-
"ref",
92
-
"newSha",
93
-
"oldSha"
94
-
],
95
-
"properties": {
96
-
"ref": {
97
-
"type": "string"
98
-
},
99
-
"newSha": {
100
-
"type": "string",
101
-
"minLength": 40,
102
-
"maxLength": 40
103
-
},
104
-
"oldSha": {
105
-
"type": "string",
106
-
"minLength": 40,
107
-
"maxLength": 40
108
-
}
109
-
}
110
-
},
111
-
"pullRequestTriggerData": {
112
-
"type": "object",
113
-
"required": [
114
-
"sourceBranch",
115
-
"targetBranch",
116
-
"sourceSha",
117
-
"action"
118
-
],
119
-
"properties": {
120
-
"sourceBranch": {
121
-
"type": "string"
122
-
},
123
-
"targetBranch": {
124
-
"type": "string"
125
-
},
126
-
"sourceSha": {
127
-
"type": "string",
128
-
"minLength": 40,
129
-
"maxLength": 40
130
-
},
131
-
"action": {
132
-
"type": "string"
133
-
}
134
-
}
135
-
},
136
-
"manualTriggerData": {
137
-
"type": "object",
138
-
"properties": {
139
-
"inputs": {
140
-
"type": "array",
141
-
"items": {
142
-
"type": "ref",
143
-
"ref": "#pair"
144
-
}
145
-
}
146
-
}
147
-
},
148
-
"workflow": {
149
-
"type": "object",
150
-
"required": [
151
-
"name",
152
-
"dependencies",
153
-
"steps",
154
-
"environment",
155
-
"clone"
156
-
],
157
-
"properties": {
158
-
"name": {
159
-
"type": "string"
160
-
},
161
-
"dependencies": {
162
-
"type": "array",
163
-
"items": {
164
-
"type": "ref",
165
-
"ref": "#dependency"
166
-
}
167
-
},
168
-
"steps": {
169
-
"type": "array",
170
-
"items": {
171
-
"type": "ref",
172
-
"ref": "#step"
173
-
}
174
-
},
175
-
"environment": {
176
-
"type": "array",
177
-
"items": {
178
-
"type": "ref",
179
-
"ref": "#pair"
180
-
}
181
-
},
182
-
"clone": {
183
-
"type": "ref",
184
-
"ref": "#cloneOpts"
185
-
}
186
-
}
187
-
},
188
-
"dependency": {
189
-
"type": "object",
190
-
"required": [
191
-
"registry",
192
-
"packages"
193
-
],
194
-
"properties": {
195
-
"registry": {
196
-
"type": "string"
197
-
},
198
-
"packages": {
199
-
"type": "array",
200
-
"items": {
201
-
"type": "string"
202
-
}
203
-
}
204
-
}
205
-
},
206
-
"cloneOpts": {
207
-
"type": "object",
208
-
"required": [
209
-
"skip",
210
-
"depth",
211
-
"submodules"
212
-
],
213
-
"properties": {
214
-
"skip": {
215
-
"type": "boolean"
216
-
},
217
-
"depth": {
218
-
"type": "integer"
219
-
},
220
-
"submodules": {
221
-
"type": "boolean"
222
-
}
223
-
}
224
-
},
225
-
"step": {
226
-
"type": "object",
227
-
"required": [
228
-
"name",
229
-
"command"
230
-
],
231
-
"properties": {
232
-
"name": {
233
-
"type": "string"
234
-
},
235
-
"command": {
236
-
"type": "string"
237
-
},
238
-
"environment": {
239
-
"type": "array",
240
-
"items": {
241
-
"type": "ref",
242
-
"ref": "#pair"
243
-
}
244
-
}
245
-
}
246
-
},
247
-
"pair": {
248
-
"type": "object",
249
-
"required": [
250
-
"key",
251
-
"value"
252
-
],
253
-
"properties": {
254
-
"key": {
255
-
"type": "string"
256
-
},
257
-
"value": {
258
-
"type": "string"
259
-
}
260
-
}
261
-
}
262
-
}
263
-
}
···
+37
lexicons/repo/addSecret.json
+37
lexicons/repo/addSecret.json
···
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.addSecret",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Add a CI secret",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"repo",
14
+
"key",
15
+
"value"
16
+
],
17
+
"properties": {
18
+
"repo": {
19
+
"type": "string",
20
+
"format": "at-uri"
21
+
},
22
+
"key": {
23
+
"type": "string",
24
+
"maxLength": 50,
25
+
"minLength": 1
26
+
},
27
+
"value": {
28
+
"type": "string",
29
+
"maxLength": 200,
30
+
"minLength": 1
31
+
}
32
+
}
33
+
}
34
+
}
35
+
}
36
+
}
37
+
}
+52
lexicons/repo/artifact.json
+52
lexicons/repo/artifact.json
···
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.artifact",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"name",
14
+
"repo",
15
+
"tag",
16
+
"createdAt",
17
+
"artifact"
18
+
],
19
+
"properties": {
20
+
"name": {
21
+
"type": "string",
22
+
"description": "name of the artifact"
23
+
},
24
+
"repo": {
25
+
"type": "string",
26
+
"format": "at-uri",
27
+
"description": "repo that this artifact is being uploaded to"
28
+
},
29
+
"tag": {
30
+
"type": "bytes",
31
+
"description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)",
32
+
"minLength": 20,
33
+
"maxLength": 20
34
+
},
35
+
"createdAt": {
36
+
"type": "string",
37
+
"format": "datetime",
38
+
"description": "time of creation of this artifact"
39
+
},
40
+
"artifact": {
41
+
"type": "blob",
42
+
"description": "the artifact",
43
+
"accept": [
44
+
"*/*"
45
+
],
46
+
"maxSize": 52428800
47
+
}
48
+
}
49
+
}
50
+
}
51
+
}
52
+
}
+36
lexicons/repo/collaborator.json
+36
lexicons/repo/collaborator.json
···
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.collaborator",
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
+
"repo",
15
+
"createdAt"
16
+
],
17
+
"properties": {
18
+
"subject": {
19
+
"type": "string",
20
+
"format": "did"
21
+
},
22
+
"repo": {
23
+
"type": "string",
24
+
"description": "repo to add this user to",
25
+
"format": "at-uri"
26
+
},
27
+
"createdAt": {
28
+
"type": "string",
29
+
"format": "datetime"
30
+
}
31
+
}
32
+
}
33
+
}
34
+
}
35
+
}
36
+
+29
lexicons/repo/defaultBranch.json
+29
lexicons/repo/defaultBranch.json
···
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.setDefaultBranch",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Set the default branch for a repository",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"repo",
14
+
"defaultBranch"
15
+
],
16
+
"properties": {
17
+
"repo": {
18
+
"type": "string",
19
+
"format": "at-uri"
20
+
},
21
+
"defaultBranch": {
22
+
"type": "string"
23
+
}
24
+
}
25
+
}
26
+
}
27
+
}
28
+
}
29
+
}
+67
lexicons/repo/listSecrets.json
+67
lexicons/repo/listSecrets.json
···
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.listSecrets",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": [
10
+
"repo"
11
+
],
12
+
"properties": {
13
+
"repo": {
14
+
"type": "string",
15
+
"format": "at-uri"
16
+
}
17
+
}
18
+
},
19
+
"output": {
20
+
"encoding": "application/json",
21
+
"schema": {
22
+
"type": "object",
23
+
"required": [
24
+
"secrets"
25
+
],
26
+
"properties": {
27
+
"secrets": {
28
+
"type": "array",
29
+
"items": {
30
+
"type": "ref",
31
+
"ref": "#secret"
32
+
}
33
+
}
34
+
}
35
+
}
36
+
}
37
+
},
38
+
"secret": {
39
+
"type": "object",
40
+
"required": [
41
+
"repo",
42
+
"key",
43
+
"createdAt",
44
+
"createdBy"
45
+
],
46
+
"properties": {
47
+
"repo": {
48
+
"type": "string",
49
+
"format": "at-uri"
50
+
},
51
+
"key": {
52
+
"type": "string",
53
+
"maxLength": 50,
54
+
"minLength": 1
55
+
},
56
+
"createdAt": {
57
+
"type": "string",
58
+
"format": "datetime"
59
+
},
60
+
"createdBy": {
61
+
"type": "string",
62
+
"format": "did"
63
+
}
64
+
}
65
+
}
66
+
}
67
+
}
+31
lexicons/repo/removeSecret.json
+31
lexicons/repo/removeSecret.json
···
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.removeSecret",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Remove a CI secret",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"repo",
14
+
"key"
15
+
],
16
+
"properties": {
17
+
"repo": {
18
+
"type": "string",
19
+
"format": "at-uri"
20
+
},
21
+
"key": {
22
+
"type": "string",
23
+
"maxLength": 50,
24
+
"minLength": 1
25
+
}
26
+
}
27
+
}
28
+
}
29
+
}
30
+
}
31
+
}
+54
lexicons/repo/repo.json
+54
lexicons/repo/repo.json
···
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"name",
14
+
"knot",
15
+
"owner",
16
+
"createdAt"
17
+
],
18
+
"properties": {
19
+
"name": {
20
+
"type": "string",
21
+
"description": "name of the repo"
22
+
},
23
+
"owner": {
24
+
"type": "string",
25
+
"format": "did"
26
+
},
27
+
"knot": {
28
+
"type": "string",
29
+
"description": "knot where the repo was created"
30
+
},
31
+
"spindle": {
32
+
"type": "string",
33
+
"description": "CI runner to send jobs to and receive results from"
34
+
},
35
+
"description": {
36
+
"type": "string",
37
+
"format": "datetime",
38
+
"minGraphemes": 1,
39
+
"maxGraphemes": 140
40
+
},
41
+
"source": {
42
+
"type": "string",
43
+
"format": "uri",
44
+
"description": "source of the repo"
45
+
},
46
+
"createdAt": {
47
+
"type": "string",
48
+
"format": "datetime"
49
+
}
50
+
}
51
+
}
52
+
}
53
+
}
54
+
}
-54
lexicons/repo.json
-54
lexicons/repo.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo",
4
-
"needsCbor": true,
5
-
"needsType": true,
6
-
"defs": {
7
-
"main": {
8
-
"type": "record",
9
-
"key": "tid",
10
-
"record": {
11
-
"type": "object",
12
-
"required": [
13
-
"name",
14
-
"knot",
15
-
"owner",
16
-
"createdAt"
17
-
],
18
-
"properties": {
19
-
"name": {
20
-
"type": "string",
21
-
"description": "name of the repo"
22
-
},
23
-
"owner": {
24
-
"type": "string",
25
-
"format": "did"
26
-
},
27
-
"knot": {
28
-
"type": "string",
29
-
"description": "knot where the repo was created"
30
-
},
31
-
"spindle": {
32
-
"type": "string",
33
-
"description": "CI runner to send jobs to and receive results from"
34
-
},
35
-
"description": {
36
-
"type": "string",
37
-
"format": "datetime",
38
-
"minGraphemes": 1,
39
-
"maxGraphemes": 140
40
-
},
41
-
"source": {
42
-
"type": "string",
43
-
"format": "uri",
44
-
"description": "source of the repo"
45
-
},
46
-
"createdAt": {
47
-
"type": "string",
48
-
"format": "datetime"
49
-
}
50
-
}
51
-
}
52
-
}
53
-
}
54
-
}
···
+25
lexicons/spindle/spindle.json
+25
lexicons/spindle/spindle.json
···
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.spindle",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "any",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"createdAt"
14
+
],
15
+
"properties": {
16
+
"createdAt": {
17
+
"type": "string",
18
+
"format": "datetime"
19
+
}
20
+
}
21
+
}
22
+
}
23
+
}
24
+
}
25
+
-25
lexicons/spindle.json
-25
lexicons/spindle.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.spindle",
4
-
"needsCbor": true,
5
-
"needsType": true,
6
-
"defs": {
7
-
"main": {
8
-
"type": "record",
9
-
"key": "any",
10
-
"record": {
11
-
"type": "object",
12
-
"required": [
13
-
"createdAt"
14
-
],
15
-
"properties": {
16
-
"createdAt": {
17
-
"type": "string",
18
-
"format": "datetime"
19
-
}
20
-
}
21
-
}
22
-
}
23
-
}
24
-
}
25
-
···
+514
nix/gomod2nix.toml
+514
nix/gomod2nix.toml
···
···
1
+
schema = 3
2
+
3
+
[mod]
4
+
[mod."dario.cat/mergo"]
5
+
version = "v1.0.1"
6
+
hash = "sha256-wcG6+x0k6KzOSlaPA+1RFxa06/RIAePJTAjjuhLbImw="
7
+
[mod."github.com/Blank-Xu/sql-adapter"]
8
+
version = "v1.1.1"
9
+
hash = "sha256-9AiQhXoNPCiViV+p5aa3qGFkYU4rJNbADvNdYGq4GA4="
10
+
[mod."github.com/Microsoft/go-winio"]
11
+
version = "v0.6.2"
12
+
hash = "sha256-tVNWDUMILZbJvarcl/E7tpSnkn7urqgSHa2Eaka5vSU="
13
+
[mod."github.com/ProtonMail/go-crypto"]
14
+
version = "v1.3.0"
15
+
hash = "sha256-TUG+C4MyeWglOmiwiW2/NUVurFHXLgEPRd3X9uQ1NGI="
16
+
[mod."github.com/alecthomas/assert/v2"]
17
+
version = "v2.11.0"
18
+
hash = "sha256-tDJCDKZ0R4qNA7hgMKWrpDyogt1802LCJDBCExxdqaU="
19
+
[mod."github.com/alecthomas/chroma/v2"]
20
+
version = "v2.19.0"
21
+
hash = "sha256-dxsu43a+PvHg2jYR0Tfys6a8x6IVR+9oCGAh+fvL3SM="
22
+
replaced = "github.com/oppiliappan/chroma/v2"
23
+
[mod."github.com/alecthomas/repr"]
24
+
version = "v0.4.0"
25
+
hash = "sha256-CyAzMSTfLGHDtfGXi91y7XMVpPUDNOKjsznb+osl9dU="
26
+
[mod."github.com/anmitsu/go-shlex"]
27
+
version = "v0.0.0-20200514113438-38f4b401e2be"
28
+
hash = "sha256-L3Ak4X2z7WXq7vMKuiHCOJ29nlpajUQ08Sfb9T0yP54="
29
+
[mod."github.com/avast/retry-go/v4"]
30
+
version = "v4.6.1"
31
+
hash = "sha256-PeZc8k4rDV64+k8nZt/oy1YNVbLevltXP3ZD1jf6Z6k="
32
+
[mod."github.com/aymerick/douceur"]
33
+
version = "v0.2.0"
34
+
hash = "sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE="
35
+
[mod."github.com/beorn7/perks"]
36
+
version = "v1.0.1"
37
+
hash = "sha256-h75GUqfwJKngCJQVE5Ao5wnO3cfKD9lSIteoLp/3xJ4="
38
+
[mod."github.com/bluekeyes/go-gitdiff"]
39
+
version = "v0.8.2"
40
+
hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ="
41
+
replaced = "tangled.sh/oppi.li/go-gitdiff"
42
+
[mod."github.com/bluesky-social/indigo"]
43
+
version = "v0.0.0-20250724221105-5827c8fb61bb"
44
+
hash = "sha256-uDYmzP4/mT7xP62LIL4QIOlkaKWS/IT1uho+udyVOAI="
45
+
[mod."github.com/bluesky-social/jetstream"]
46
+
version = "v0.0.0-20241210005130-ea96859b93d1"
47
+
hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0="
48
+
[mod."github.com/bmatcuk/doublestar/v4"]
49
+
version = "v4.7.1"
50
+
hash = "sha256-idO38nWZtmxvE0CgdziepwFM+Xc70Isr6NiKEZjHOjA="
51
+
[mod."github.com/carlmjohnson/versioninfo"]
52
+
version = "v0.22.5"
53
+
hash = "sha256-tf7yKVFTUPmGKBLK43bjyIRQUboCYduh3I5HXE5+LPw="
54
+
[mod."github.com/casbin/casbin/v2"]
55
+
version = "v2.103.0"
56
+
hash = "sha256-adYds8Arni/ioPM9J0F+wAlJqhLLtCV9epv7d7tDvAQ="
57
+
[mod."github.com/casbin/govaluate"]
58
+
version = "v1.3.0"
59
+
hash = "sha256-vDUFEGt8oL4n/PHwlMZPjmaLvcpGTN4HEIRGl2FPxUA="
60
+
[mod."github.com/cenkalti/backoff/v4"]
61
+
version = "v4.3.0"
62
+
hash = "sha256-wfVjNZsGG1WoNC5aL+kdcy6QXPgZo4THAevZ1787md8="
63
+
[mod."github.com/cespare/xxhash/v2"]
64
+
version = "v2.3.0"
65
+
hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY="
66
+
[mod."github.com/cloudflare/circl"]
67
+
version = "v1.6.2-0.20250618153321-aa837fd1539d"
68
+
hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y="
69
+
[mod."github.com/containerd/errdefs"]
70
+
version = "v1.0.0"
71
+
hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI="
72
+
[mod."github.com/containerd/errdefs/pkg"]
73
+
version = "v0.3.0"
74
+
hash = "sha256-BILJ0Be4cc8xfvLPylc/Pvwwa+w88+Hd0njzetUCeTg="
75
+
[mod."github.com/containerd/log"]
76
+
version = "v0.1.0"
77
+
hash = "sha256-vuE6Mie2gSxiN3jTKTZovjcbdBd1YEExb7IBe3GM+9s="
78
+
[mod."github.com/cyphar/filepath-securejoin"]
79
+
version = "v0.4.1"
80
+
hash = "sha256-NOV6MfbkcQbfhNmfADQw2SJmZ6q1nw0wwg8Pm2tf2DM="
81
+
[mod."github.com/davecgh/go-spew"]
82
+
version = "v1.1.2-0.20180830191138-d8f796af33cc"
83
+
hash = "sha256-fV9oI51xjHdOmEx6+dlq7Ku2Ag+m/bmbzPo6A4Y74qc="
84
+
[mod."github.com/decred/dcrd/dcrec/secp256k1/v4"]
85
+
version = "v4.4.0"
86
+
hash = "sha256-qrhEIwhDll3cxoVpMbm1NQ9/HTI42S7ms8Buzlo5HCg="
87
+
[mod."github.com/dgraph-io/ristretto"]
88
+
version = "v0.2.0"
89
+
hash = "sha256-bnpxX+oO/Qf7IJevA0gsbloVoqRx+5bh7RQ9d9eLNYw="
90
+
[mod."github.com/dgryski/go-rendezvous"]
91
+
version = "v0.0.0-20200823014737-9f7001d12a5f"
92
+
hash = "sha256-n/7xo5CQqo4yLaWMSzSN1Muk/oqK6O5dgDOFWapeDUI="
93
+
[mod."github.com/distribution/reference"]
94
+
version = "v0.6.0"
95
+
hash = "sha256-gr4tL+qz4jKyAtl8LINcxMSanztdt+pybj1T+2ulQv4="
96
+
[mod."github.com/dlclark/regexp2"]
97
+
version = "v1.11.5"
98
+
hash = "sha256-jN5+2ED+YbIoPIuyJ4Ou5pqJb2w1uNKzp5yTjKY6rEQ="
99
+
[mod."github.com/docker/docker"]
100
+
version = "v28.2.2+incompatible"
101
+
hash = "sha256-5FnlTcygdxpHyFB0/7EsYocFhADUAjC/Dku0Xn4W8so="
102
+
[mod."github.com/docker/go-connections"]
103
+
version = "v0.5.0"
104
+
hash = "sha256-aGbMRrguh98DupIHgcpLkVUZpwycx1noQXbtTl5Sbms="
105
+
[mod."github.com/docker/go-units"]
106
+
version = "v0.5.0"
107
+
hash = "sha256-iK/V/jJc+borzqMeqLY+38Qcts2KhywpsTk95++hImE="
108
+
[mod."github.com/dustin/go-humanize"]
109
+
version = "v1.0.1"
110
+
hash = "sha256-yuvxYYngpfVkUg9yAmG99IUVmADTQA0tMbBXe0Fq0Mc="
111
+
[mod."github.com/emirpasic/gods"]
112
+
version = "v1.18.1"
113
+
hash = "sha256-hGDKddjLj+5dn2woHtXKUdd49/3xdsqnhx7VEdCu1m4="
114
+
[mod."github.com/felixge/httpsnoop"]
115
+
version = "v1.0.4"
116
+
hash = "sha256-c1JKoRSndwwOyOxq9ddCe+8qn7mG9uRq2o/822x5O/c="
117
+
[mod."github.com/fsnotify/fsnotify"]
118
+
version = "v1.6.0"
119
+
hash = "sha256-DQesOCweQPEwmAn6s7DCP/Dwy8IypC+osbpfsvpkdP0="
120
+
[mod."github.com/gliderlabs/ssh"]
121
+
version = "v0.3.8"
122
+
hash = "sha256-FW+91qCB3rfTm0I1VmqfwA7o+2kDys2JHOudKKyxWwc="
123
+
[mod."github.com/go-chi/chi/v5"]
124
+
version = "v5.2.0"
125
+
hash = "sha256-rCZ2W5BdWwjtv7SSpHOgpYEHf9ketzdPX+r2500JL8A="
126
+
[mod."github.com/go-enry/go-enry/v2"]
127
+
version = "v2.9.2"
128
+
hash = "sha256-LkCSW+4+DkTok1JcOQR0rt3UKNKVn4KPaiDeatdQhCU="
129
+
[mod."github.com/go-enry/go-oniguruma"]
130
+
version = "v1.2.1"
131
+
hash = "sha256-DoCNyX75CuCgFnfSZs63VB4+HAIMDBgwcQglXXHRj/I="
132
+
[mod."github.com/go-git/gcfg"]
133
+
version = "v1.5.1-0.20230307220236-3a3c6141e376"
134
+
hash = "sha256-f4k0gSYuo0/q3WOoTxl2eFaj7WZpdz29ih6CKc8Ude8="
135
+
[mod."github.com/go-git/go-billy/v5"]
136
+
version = "v5.6.2"
137
+
hash = "sha256-VgbxcLkHjiSyRIfKS7E9Sn8OynCrMGUDkwFz6K2TVL4="
138
+
[mod."github.com/go-git/go-git/v5"]
139
+
version = "v5.17.0"
140
+
hash = "sha256-gya68abB6GtejUqr60DyU7NIGtNzHQVCAeDTYKk1evQ="
141
+
replaced = "github.com/oppiliappan/go-git/v5"
142
+
[mod."github.com/go-jose/go-jose/v3"]
143
+
version = "v3.0.4"
144
+
hash = "sha256-RrLHCu9l6k0XVobdZQJ9Sx/VTQcWjrdLR5BEG7yXTEQ="
145
+
[mod."github.com/go-logr/logr"]
146
+
version = "v1.4.3"
147
+
hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA="
148
+
[mod."github.com/go-logr/stdr"]
149
+
version = "v1.2.2"
150
+
hash = "sha256-rRweAP7XIb4egtT1f2gkz4sYOu7LDHmcJ5iNsJUd0sE="
151
+
[mod."github.com/go-redis/cache/v9"]
152
+
version = "v9.0.0"
153
+
hash = "sha256-b4S3K4KoZhF0otw6FRIOq/PTdHGrb/LumB4GKo4khsY="
154
+
[mod."github.com/go-test/deep"]
155
+
version = "v1.1.1"
156
+
hash = "sha256-WvPrTvUPmbQb4R6DrvSB9O3zm0IOk+n14YpnSl2deR8="
157
+
[mod."github.com/goccy/go-json"]
158
+
version = "v0.10.5"
159
+
hash = "sha256-/EtlGihP0/7oInzMC5E0InZ4b5Ad3s4xOpqotloi3xw="
160
+
[mod."github.com/gogo/protobuf"]
161
+
version = "v1.3.2"
162
+
hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c="
163
+
[mod."github.com/golang-jwt/jwt/v5"]
164
+
version = "v5.2.3"
165
+
hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo="
166
+
[mod."github.com/golang/groupcache"]
167
+
version = "v0.0.0-20241129210726-2c02b8208cf8"
168
+
hash = "sha256-AdLZ3dJLe/yduoNvZiXugZxNfmwJjNQyQGsIdzYzH74="
169
+
[mod."github.com/golang/mock"]
170
+
version = "v1.6.0"
171
+
hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno="
172
+
[mod."github.com/google/uuid"]
173
+
version = "v1.6.0"
174
+
hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw="
175
+
[mod."github.com/gorilla/css"]
176
+
version = "v1.0.1"
177
+
hash = "sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A="
178
+
[mod."github.com/gorilla/securecookie"]
179
+
version = "v1.1.2"
180
+
hash = "sha256-KeMHNM9emxX+N0WYiZsTii7n8sNsmjWwbnQ9SaJfTKE="
181
+
[mod."github.com/gorilla/sessions"]
182
+
version = "v1.4.0"
183
+
hash = "sha256-cLK2z1uOEz7Wah/LclF65ptYMqzuvaRnfIGYqtn3b7g="
184
+
[mod."github.com/gorilla/websocket"]
185
+
version = "v1.5.4-0.20250319132907-e064f32e3674"
186
+
hash = "sha256-a8n6oe20JDpwThClgAyVhJDi6QVaS0qzT4PvRxlQ9to="
187
+
[mod."github.com/hashicorp/errwrap"]
188
+
version = "v1.1.0"
189
+
hash = "sha256-6lwuMQOfBq+McrViN3maJTIeh4f8jbEqvLy2c9FvvFw="
190
+
[mod."github.com/hashicorp/go-cleanhttp"]
191
+
version = "v0.5.2"
192
+
hash = "sha256-N9GOKYo7tK6XQUFhvhImtL7PZW/mr4C4Manx/yPVvcQ="
193
+
[mod."github.com/hashicorp/go-multierror"]
194
+
version = "v1.1.1"
195
+
hash = "sha256-ANzPEUJIZIlToxR89Mn7Db73d9LGI51ssy7eNnUgmlA="
196
+
[mod."github.com/hashicorp/go-retryablehttp"]
197
+
version = "v0.7.8"
198
+
hash = "sha256-4LZwKaFBbpKi9lSq5y6lOlYHU6WMnQdGNMxTd33rN80="
199
+
[mod."github.com/hashicorp/go-secure-stdlib/parseutil"]
200
+
version = "v0.2.0"
201
+
hash = "sha256-mb27ZKw5VDTmNj1QJvxHVR0GyY7UdacLJ0jWDV3nQd8="
202
+
[mod."github.com/hashicorp/go-secure-stdlib/strutil"]
203
+
version = "v0.1.2"
204
+
hash = "sha256-UmCMzjamCW1d9KNvNzELqKf1ElHOXPz+ZtdJkI+DV0A="
205
+
[mod."github.com/hashicorp/go-sockaddr"]
206
+
version = "v1.0.7"
207
+
hash = "sha256-p6eDOrGzN1jMmT/F/f/VJMq0cKNFhUcEuVVwTE6vSrs="
208
+
[mod."github.com/hashicorp/golang-lru"]
209
+
version = "v1.0.2"
210
+
hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48="
211
+
[mod."github.com/hashicorp/golang-lru/v2"]
212
+
version = "v2.0.7"
213
+
hash = "sha256-t1bcXLgrQNOYUVyYEZ0knxcXpsTk4IuJZDjKvyJX75g="
214
+
[mod."github.com/hashicorp/hcl"]
215
+
version = "v1.0.1-vault-7"
216
+
hash = "sha256-xqYtjCJQVsg04Yj2Uy2Q5bi6X6cDRYhJD/SUEWaHMDM="
217
+
[mod."github.com/hexops/gotextdiff"]
218
+
version = "v1.0.3"
219
+
hash = "sha256-wVs5uJs2KHU1HnDCDdSe0vIgNZylvs8oNidDxwA3+O0="
220
+
[mod."github.com/hiddeco/sshsig"]
221
+
version = "v0.2.0"
222
+
hash = "sha256-Yc8Ip4XxrL5plb7Lq0ziYFznteVDZnskoyOZDIMsWOU="
223
+
[mod."github.com/hpcloud/tail"]
224
+
version = "v1.0.0"
225
+
hash = "sha256-7ByBr/RcOwIsGPCiCUpfNwUSvU18QAY+HMnCJr8uU1w="
226
+
[mod."github.com/ipfs/bbloom"]
227
+
version = "v0.0.4"
228
+
hash = "sha256-4k778kBlNul2Rc4xuNQ9WA4kT0V7x5X9odZrT+2xjTU="
229
+
[mod."github.com/ipfs/boxo"]
230
+
version = "v0.33.0"
231
+
hash = "sha256-C85D/TjyoWNKvRFvl2A9hBjpDPhC//hGB2jRoDmXM38="
232
+
[mod."github.com/ipfs/go-block-format"]
233
+
version = "v0.2.2"
234
+
hash = "sha256-kz87tlGneqEARGnjA1Lb0K5CqD1lgxhBD68rfmzZZGU="
235
+
[mod."github.com/ipfs/go-cid"]
236
+
version = "v0.5.0"
237
+
hash = "sha256-BuZKkcBXrnx7mM1c9SP4LdzZoaAoai9U49vITGrYJQk="
238
+
[mod."github.com/ipfs/go-datastore"]
239
+
version = "v0.8.2"
240
+
hash = "sha256-9Q7+bi04srAE3AcXzWSGs/HP6DWnE1Edtx3NnjMQi8U="
241
+
[mod."github.com/ipfs/go-ipfs-blockstore"]
242
+
version = "v1.3.1"
243
+
hash = "sha256-NFlKr8bdJcM5FLlkc51sKt4AnMMlHS4wbdKiiaoDaqg="
244
+
[mod."github.com/ipfs/go-ipfs-ds-help"]
245
+
version = "v1.1.1"
246
+
hash = "sha256-cpEohOsf4afYRGTdsWh84TCVGIDzJo2hSjWy7NtNtvY="
247
+
[mod."github.com/ipfs/go-ipld-cbor"]
248
+
version = "v0.2.1"
249
+
hash = "sha256-ONBX/YO/knnmp+12fC13KsKVeo/vdWOI3SDyqCBxRE4="
250
+
[mod."github.com/ipfs/go-ipld-format"]
251
+
version = "v0.6.2"
252
+
hash = "sha256-nGLM/n/hy+0q1VIzQUvF4D3aKvL238ALIEziQQhVMkU="
253
+
[mod."github.com/ipfs/go-log"]
254
+
version = "v1.0.5"
255
+
hash = "sha256-WQarHZo2y/rH6ixLsOlN5fFZeLUqsOTMnvdxszP2Qj4="
256
+
[mod."github.com/ipfs/go-log/v2"]
257
+
version = "v2.6.0"
258
+
hash = "sha256-cZ+rsx7LIROoNITyu/s0B6hq8lNQsUC1ynvx2f2o4Gk="
259
+
[mod."github.com/ipfs/go-metrics-interface"]
260
+
version = "v0.3.0"
261
+
hash = "sha256-b3tp3jxecLmJEGx2kW7MiKGlAKPEWg/LJ7hXylSC8jQ="
262
+
[mod."github.com/kevinburke/ssh_config"]
263
+
version = "v1.2.0"
264
+
hash = "sha256-Ta7ZOmyX8gG5tzWbY2oES70EJPfI90U7CIJS9EAce0s="
265
+
[mod."github.com/klauspost/compress"]
266
+
version = "v1.18.0"
267
+
hash = "sha256-jc5pMU/HCBFOShMcngVwNMhz9wolxjOb579868LtOuk="
268
+
[mod."github.com/klauspost/cpuid/v2"]
269
+
version = "v2.3.0"
270
+
hash = "sha256-50JhbQyT67BK38HIdJihPtjV7orYp96HknI2VP7A9Yc="
271
+
[mod."github.com/lestrrat-go/blackmagic"]
272
+
version = "v1.0.4"
273
+
hash = "sha256-HmWOpwoPDNMwLdOi7onNn3Sb+ZsAa3Ai3gVBbXmQ0e8="
274
+
[mod."github.com/lestrrat-go/httpcc"]
275
+
version = "v1.0.1"
276
+
hash = "sha256-SMRSwJpqDIs/xL0l2e8vP0W65qtCHX2wigcOeqPJmos="
277
+
[mod."github.com/lestrrat-go/httprc"]
278
+
version = "v1.0.6"
279
+
hash = "sha256-mfZzePEhrmyyu/avEBd2MsDXyto8dq5+fyu5lA8GUWM="
280
+
[mod."github.com/lestrrat-go/iter"]
281
+
version = "v1.0.2"
282
+
hash = "sha256-30tErRf7Qu/NOAt1YURXY/XJSA6sCr6hYQfO8QqHrtw="
283
+
[mod."github.com/lestrrat-go/jwx/v2"]
284
+
version = "v2.1.6"
285
+
hash = "sha256-0LszXRZIba+X8AOrs3T4uanAUafBdlVB8/MpUNEFpbc="
286
+
[mod."github.com/lestrrat-go/option"]
287
+
version = "v1.0.1"
288
+
hash = "sha256-jVcIYYVsxElIS/l2akEw32vdEPR8+anR6oeT1FoYULI="
289
+
[mod."github.com/mattn/go-isatty"]
290
+
version = "v0.0.20"
291
+
hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ="
292
+
[mod."github.com/mattn/go-sqlite3"]
293
+
version = "v1.14.24"
294
+
hash = "sha256-taGKFZFQlR5++5b2oZ1dYS3RERKv6yh1gniNWhb4egg="
295
+
[mod."github.com/microcosm-cc/bluemonday"]
296
+
version = "v1.0.27"
297
+
hash = "sha256-EZSya9FLPQ83CL7N2cZy21fdS35hViTkiMK5f3op8Es="
298
+
[mod."github.com/minio/sha256-simd"]
299
+
version = "v1.0.1"
300
+
hash = "sha256-4hfGDIQaWq8fvtGzHDhoK9v2IocXnJY7OAL6saMJbmA="
301
+
[mod."github.com/mitchellh/mapstructure"]
302
+
version = "v1.5.0"
303
+
hash = "sha256-ztVhGQXs67MF8UadVvG72G3ly0ypQW0IRDdOOkjYwoE="
304
+
[mod."github.com/moby/docker-image-spec"]
305
+
version = "v1.3.1"
306
+
hash = "sha256-xwSNLmMagzywdGJIuhrWl1r7cIWBYCOMNYbuDDT6Jhs="
307
+
[mod."github.com/moby/sys/atomicwriter"]
308
+
version = "v0.1.0"
309
+
hash = "sha256-i46GNrsICnJ0AYkN+ocbVZ2GNTQVEsrVX5WcjKzjtBM="
310
+
[mod."github.com/moby/term"]
311
+
version = "v0.5.2"
312
+
hash = "sha256-/G20jUZKx36ktmPU/nEw/gX7kRTl1Dbu7zvNBYNt4xU="
313
+
[mod."github.com/morikuni/aec"]
314
+
version = "v1.0.0"
315
+
hash = "sha256-5zYgLeGr3K+uhGKlN3xv0PO67V+2Zw+cezjzNCmAWOE="
316
+
[mod."github.com/mr-tron/base58"]
317
+
version = "v1.2.0"
318
+
hash = "sha256-8FzMu3kHUbBX10pUdtGf59Ag7BNupx8ZHeUaodR1/Vk="
319
+
[mod."github.com/multiformats/go-base32"]
320
+
version = "v0.1.0"
321
+
hash = "sha256-O2IM7FB+Y9MkDdZztyQL5F8oEnmON2Yew7XkotQziio="
322
+
[mod."github.com/multiformats/go-base36"]
323
+
version = "v0.2.0"
324
+
hash = "sha256-GKNnAGA0Lb39BDGYBm1ieKdXmho8Pu7ouyfVPXvV0PE="
325
+
[mod."github.com/multiformats/go-multibase"]
326
+
version = "v0.2.0"
327
+
hash = "sha256-w+hp6u5bWyd34qe0CX+bq487ADqq6SgRR/JuqRB578s="
328
+
[mod."github.com/multiformats/go-multihash"]
329
+
version = "v0.2.3"
330
+
hash = "sha256-zqIIE5jMFzm+qhUrouSF+WdXGeHUEYIQvVnKWWU6mRs="
331
+
[mod."github.com/multiformats/go-varint"]
332
+
version = "v0.0.7"
333
+
hash = "sha256-To3Uuv7uSUJEr5OTwxE1LEIpA62xY3M/KKMNlscHmlA="
334
+
[mod."github.com/munnerz/goautoneg"]
335
+
version = "v0.0.0-20191010083416-a7dc8b61c822"
336
+
hash = "sha256-79URDDFenmGc9JZu+5AXHToMrtTREHb3BC84b/gym9Q="
337
+
[mod."github.com/onsi/gomega"]
338
+
version = "v1.37.0"
339
+
hash = "sha256-PfHFYp365MwBo+CUZs+mN5QEk3Kqe9xrBX+twWfIc9o="
340
+
[mod."github.com/openbao/openbao/api/v2"]
341
+
version = "v2.3.0"
342
+
hash = "sha256-1bIyvL3GdzPUfsM+gxuKMaH5jKxMaucZQgL6/DfbmDM="
343
+
[mod."github.com/opencontainers/go-digest"]
344
+
version = "v1.0.0"
345
+
hash = "sha256-cfVDjHyWItmUGZ2dzQhCHgmOmou8v7N+itDkLZVkqkQ="
346
+
[mod."github.com/opencontainers/image-spec"]
347
+
version = "v1.1.1"
348
+
hash = "sha256-bxBjtl+6846Ed3QHwdssOrNvlHV6b+Dn17zPISSQGP8="
349
+
[mod."github.com/opentracing/opentracing-go"]
350
+
version = "v1.2.1-0.20220228012449-10b1cf09e00b"
351
+
hash = "sha256-77oWcDviIoGWHVAotbgmGRpLGpH5AUy+pM15pl3vRrw="
352
+
[mod."github.com/pjbgf/sha1cd"]
353
+
version = "v0.3.2"
354
+
hash = "sha256-jdbiRhU8xc1C5c8m7BSCj71PUXHY3f7TWFfxDKKpUMk="
355
+
[mod."github.com/pkg/errors"]
356
+
version = "v0.9.1"
357
+
hash = "sha256-mNfQtcrQmu3sNg/7IwiieKWOgFQOVVe2yXgKBpe/wZw="
358
+
[mod."github.com/pmezard/go-difflib"]
359
+
version = "v1.0.1-0.20181226105442-5d4384ee4fb2"
360
+
hash = "sha256-XA4Oj1gdmdV/F/+8kMI+DBxKPthZ768hbKsO3d9Gx90="
361
+
[mod."github.com/polydawn/refmt"]
362
+
version = "v0.89.1-0.20221221234430-40501e09de1f"
363
+
hash = "sha256-wBdFROClTHNPYU4IjeKbBXaG7F6j5hZe15gMxiqKvi4="
364
+
[mod."github.com/posthog/posthog-go"]
365
+
version = "v1.5.5"
366
+
hash = "sha256-ouhfDUCXsfpcgaCLfJE9oYprAQHuV61OJzb/aEhT0j8="
367
+
[mod."github.com/prometheus/client_golang"]
368
+
version = "v1.22.0"
369
+
hash = "sha256-OJ/9rlWG1DIPQJAZUTzjykkX0o+f+4IKLvW8YityaMQ="
370
+
[mod."github.com/prometheus/client_model"]
371
+
version = "v0.6.2"
372
+
hash = "sha256-q6Fh6v8iNJN9ypD47LjWmx66YITa3FyRjZMRsuRTFeQ="
373
+
[mod."github.com/prometheus/common"]
374
+
version = "v0.64.0"
375
+
hash = "sha256-uy3KO60F2Cvhamz3fWQALGSsy13JiTk3NfpXgRuwqtI="
376
+
[mod."github.com/prometheus/procfs"]
377
+
version = "v0.16.1"
378
+
hash = "sha256-OBCvKlLW2obct35p0L9Q+1ZrxZjpTmbgHMP2rng9hpo="
379
+
[mod."github.com/redis/go-redis/v9"]
380
+
version = "v9.7.3"
381
+
hash = "sha256-7ip5Ns/NEnFmVLr5iN8m3gS4RrzVAYJ7pmJeeaTmjjo="
382
+
[mod."github.com/resend/resend-go/v2"]
383
+
version = "v2.15.0"
384
+
hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg="
385
+
[mod."github.com/ryanuber/go-glob"]
386
+
version = "v1.0.0"
387
+
hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY="
388
+
[mod."github.com/segmentio/asm"]
389
+
version = "v1.2.0"
390
+
hash = "sha256-zbNuKxNrUDUc6IlmRQNuJQzVe5Ol/mqp7srDg9IMMqs="
391
+
[mod."github.com/sergi/go-diff"]
392
+
version = "v1.1.0"
393
+
hash = "sha256-8NJMabldpf40uwQN20T6QXx5KORDibCBJL02KD661xY="
394
+
replaced = "github.com/sergi/go-diff"
395
+
[mod."github.com/sethvargo/go-envconfig"]
396
+
version = "v1.1.0"
397
+
hash = "sha256-WelRHfyZG9hrA4fbQcfBawb2ZXBQNT1ourEYHzQdZ4w="
398
+
[mod."github.com/spaolacci/murmur3"]
399
+
version = "v1.1.0"
400
+
hash = "sha256-RWD4PPrlAsZZ8Xy356MBxpj+/NZI7w2XOU14Ob7/Y9M="
401
+
[mod."github.com/stretchr/testify"]
402
+
version = "v1.10.0"
403
+
hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI="
404
+
[mod."github.com/urfave/cli/v3"]
405
+
version = "v3.3.3"
406
+
hash = "sha256-FdPiu7koY1qBinkfca4A05zCrX+Vu4eRz8wlRDZJyGg="
407
+
[mod."github.com/vmihailenco/go-tinylfu"]
408
+
version = "v0.2.2"
409
+
hash = "sha256-ZHr4g7DJAV6rLcfrEWZwo9wJSeZcXB9KSP38UIOFfaM="
410
+
[mod."github.com/vmihailenco/msgpack/v5"]
411
+
version = "v5.4.1"
412
+
hash = "sha256-pDplX6xU6UpNLcFbO1pRREW5vCnSPvSU+ojAwFDv3Hk="
413
+
[mod."github.com/vmihailenco/tagparser/v2"]
414
+
version = "v2.0.0"
415
+
hash = "sha256-M9QyaKhSmmYwsJk7gkjtqu9PuiqZHSmTkous8VWkWY0="
416
+
[mod."github.com/whyrusleeping/cbor-gen"]
417
+
version = "v0.3.1"
418
+
hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc="
419
+
[mod."github.com/yuin/goldmark"]
420
+
version = "v1.4.13"
421
+
hash = "sha256-GVwFKZY6moIS6I0ZGuio/WtDif+lkZRfqWS6b4AAJyI="
422
+
[mod."gitlab.com/yawning/secp256k1-voi"]
423
+
version = "v0.0.0-20230925100816-f2616030848b"
424
+
hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA="
425
+
[mod."gitlab.com/yawning/tuplehash"]
426
+
version = "v0.0.0-20230713102510-df83abbf9a02"
427
+
hash = "sha256-pehQduoaJRLchebhgvMYacVvbuNIBA++XkiqCuqdato="
428
+
[mod."go.opentelemetry.io/auto/sdk"]
429
+
version = "v1.1.0"
430
+
hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo="
431
+
[mod."go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"]
432
+
version = "v0.62.0"
433
+
hash = "sha256-WcDogpsvFxGOVqc+/ljlZ10nrOU2q0rPteukGyWWmfc="
434
+
[mod."go.opentelemetry.io/otel"]
435
+
version = "v1.37.0"
436
+
hash = "sha256-zWpyp9K8/Te86uhNjamchZctTdAnmHhoVw9m4ACfSoo="
437
+
[mod."go.opentelemetry.io/otel/exporters/otlp/otlptrace"]
438
+
version = "v1.33.0"
439
+
hash = "sha256-D5BMzmtN1d3pRnxIcvDOyQrjerK1JoavtYjJLhPKv/I="
440
+
[mod."go.opentelemetry.io/otel/metric"]
441
+
version = "v1.37.0"
442
+
hash = "sha256-BWnkdldA3xzGhnaConzMAuQzOnugytIvrP6GjkZVAYg="
443
+
[mod."go.opentelemetry.io/otel/trace"]
444
+
version = "v1.37.0"
445
+
hash = "sha256-FBeLOb5qmIiE9VmbgCf1l/xpndBqHkRiaPt1PvoKrVY="
446
+
[mod."go.opentelemetry.io/proto/otlp"]
447
+
version = "v1.6.0"
448
+
hash = "sha256-1kjkJ9cqkvx3ib6ytLcw+Adp4xqD3ShF97lpzt/BeCg="
449
+
[mod."go.uber.org/atomic"]
450
+
version = "v1.11.0"
451
+
hash = "sha256-TyYws/cSPVqYNffFX0gbDml1bD4bBGcysrUWU7mHPIY="
452
+
[mod."go.uber.org/multierr"]
453
+
version = "v1.11.0"
454
+
hash = "sha256-Lb6rHHfR62Ozg2j2JZy3MKOMKdsfzd1IYTR57r3Mhp0="
455
+
[mod."go.uber.org/zap"]
456
+
version = "v1.27.0"
457
+
hash = "sha256-8655KDrulc4Das3VRduO9MjCn8ZYD5WkULjCvruaYsU="
458
+
[mod."golang.org/x/crypto"]
459
+
version = "v0.40.0"
460
+
hash = "sha256-I6p2fqvz63P9MwAuoQrljI7IUbfZQvCem0ii4Q2zZng="
461
+
[mod."golang.org/x/exp"]
462
+
version = "v0.0.0-20250620022241-b7579e27df2b"
463
+
hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig="
464
+
[mod."golang.org/x/net"]
465
+
version = "v0.42.0"
466
+
hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s="
467
+
[mod."golang.org/x/sync"]
468
+
version = "v0.16.0"
469
+
hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks="
470
+
[mod."golang.org/x/sys"]
471
+
version = "v0.34.0"
472
+
hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50="
473
+
[mod."golang.org/x/text"]
474
+
version = "v0.27.0"
475
+
hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8="
476
+
[mod."golang.org/x/time"]
477
+
version = "v0.12.0"
478
+
hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw="
479
+
[mod."golang.org/x/xerrors"]
480
+
version = "v0.0.0-20240903120638-7835f813f4da"
481
+
hash = "sha256-bE7CcrnAvryNvM26ieJGXqbAtuLwHaGcmtVMsVnksqo="
482
+
[mod."google.golang.org/genproto/googleapis/api"]
483
+
version = "v0.0.0-20250603155806-513f23925822"
484
+
hash = "sha256-0CS432v9zVhkVLqFpZtxBX8rvVqP67lb7qQ3es7RqIU="
485
+
[mod."google.golang.org/genproto/googleapis/rpc"]
486
+
version = "v0.0.0-20250603155806-513f23925822"
487
+
hash = "sha256-WK7iDtAhH19NPe3TywTQlGjDawNaDKWnxhFL9PgVUwM="
488
+
[mod."google.golang.org/grpc"]
489
+
version = "v1.73.0"
490
+
hash = "sha256-LfVlwip++q2DX70RU6CxoXglx1+r5l48DwlFD05G11c="
491
+
[mod."google.golang.org/protobuf"]
492
+
version = "v1.36.6"
493
+
hash = "sha256-lT5qnefI5FDJnowz9PEkAGylH3+fE+A3DJDkAyy9RMc="
494
+
[mod."gopkg.in/fsnotify.v1"]
495
+
version = "v1.4.7"
496
+
hash = "sha256-j/Ts92oXa3k1MFU7Yd8/AqafRTsFn7V2pDKCyDJLah8="
497
+
[mod."gopkg.in/tomb.v1"]
498
+
version = "v1.0.0-20141024135613-dd632973f1e7"
499
+
hash = "sha256-W/4wBAvuaBFHhowB67SZZfXCRDp5tzbYG4vo81TAFdM="
500
+
[mod."gopkg.in/warnings.v0"]
501
+
version = "v0.1.2"
502
+
hash = "sha256-ATVL9yEmgYbkJ1DkltDGRn/auGAjqGOfjQyBYyUo8s8="
503
+
[mod."gopkg.in/yaml.v3"]
504
+
version = "v3.0.1"
505
+
hash = "sha256-FqL9TKYJ0XkNwJFnq9j0VvJ5ZUU1RvH/52h/f5bkYAU="
506
+
[mod."gotest.tools/v3"]
507
+
version = "v3.5.2"
508
+
hash = "sha256-eAxnRrF2bQugeFYzGLOr+4sLyCPOpaTWpoZsIKNP1WE="
509
+
[mod."lukechampine.com/blake3"]
510
+
version = "v1.4.1"
511
+
hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc="
512
+
[mod."tangled.sh/icyphox.sh/atproto-oauth"]
513
+
version = "v0.0.0-20250724194903-28e660378cb1"
514
+
hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
+40
-35
nix/modules/appview.nix
+40
-35
nix/modules/appview.nix
···
1
-
{self}: {
2
config,
3
-
pkgs,
4
lib,
5
...
6
-
}:
7
-
with lib; {
8
-
options = {
9
-
services.tangled-appview = {
10
-
enable = mkOption {
11
-
type = types.bool;
12
-
default = false;
13
-
description = "Enable tangled appview";
14
-
};
15
-
port = mkOption {
16
-
type = types.int;
17
-
default = 3000;
18
-
description = "Port to run the appview on";
19
-
};
20
-
cookie_secret = mkOption {
21
-
type = types.str;
22
-
default = "00000000000000000000000000000000";
23
-
description = "Cookie secret";
24
};
25
};
26
-
};
27
28
-
config = mkIf config.services.tangled-appview.enable {
29
-
systemd.services.tangled-appview = {
30
-
description = "tangled appview service";
31
-
wantedBy = ["multi-user.target"];
32
33
-
serviceConfig = {
34
-
ListenStream = "0.0.0.0:${toString config.services.tangled-appview.port}";
35
-
ExecStart = "${self.packages.${pkgs.system}.appview}/bin/appview";
36
-
Restart = "always";
37
-
};
38
39
-
environment = {
40
-
TANGLED_DB_PATH = "appview.db";
41
-
TANGLED_COOKIE_SECRET = config.services.tangled-appview.cookie_secret;
42
};
43
};
44
-
};
45
-
}
···
1
+
{
2
config,
3
lib,
4
...
5
+
}: let
6
+
cfg = config.services.tangled-appview;
7
+
in
8
+
with lib; {
9
+
options = {
10
+
services.tangled-appview = {
11
+
enable = mkOption {
12
+
type = types.bool;
13
+
default = false;
14
+
description = "Enable tangled appview";
15
+
};
16
+
package = mkOption {
17
+
type = types.package;
18
+
description = "Package to use for the appview";
19
+
};
20
+
port = mkOption {
21
+
type = types.int;
22
+
default = 3000;
23
+
description = "Port to run the appview on";
24
+
};
25
+
cookie_secret = mkOption {
26
+
type = types.str;
27
+
default = "00000000000000000000000000000000";
28
+
description = "Cookie secret";
29
+
};
30
};
31
};
32
33
+
config = mkIf cfg.enable {
34
+
systemd.services.tangled-appview = {
35
+
description = "tangled appview service";
36
+
wantedBy = ["multi-user.target"];
37
38
+
serviceConfig = {
39
+
ListenStream = "0.0.0.0:${toString cfg.port}";
40
+
ExecStart = "${cfg.package}/bin/appview";
41
+
Restart = "always";
42
+
};
43
44
+
environment = {
45
+
TANGLED_DB_PATH = "appview.db";
46
+
TANGLED_COOKIE_SECRET = cfg.cookie_secret;
47
+
};
48
};
49
};
50
+
}
+45
-7
nix/modules/knot.nix
+45
-7
nix/modules/knot.nix
···
1
-
{self}: {
2
config,
3
pkgs,
4
lib,
···
13
type = types.bool;
14
default = false;
15
description = "Enable a tangled knot";
16
};
17
18
appviewEndpoint = mkOption {
···
53
};
54
};
55
56
server = {
57
listenAddr = mkOption {
58
type = types.str;
···
94
};
95
96
config = mkIf cfg.enable {
97
-
environment.systemPackages = with pkgs; [
98
-
git
99
-
self.packages."${pkgs.system}".knot
100
];
101
102
-
system.activationScripts.gitConfig = ''
103
mkdir -p "${cfg.repo.scanPath}"
104
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}"
105
···
108
[user]
109
name = Git User
110
email = git@example.com
111
EOF
112
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
113
'';
114
···
135
mode = "0555";
136
text = ''
137
#!${pkgs.stdenv.shell}
138
-
${self.packages.${pkgs.system}.knot}/bin/knot keys \
139
-output authorized-keys \
140
-internal-api "http://${cfg.server.internalListenAddr}" \
141
-git-dir "${cfg.repo.scanPath}" \
···
160
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
161
];
162
EnvironmentFile = cfg.server.secretFile;
163
-
ExecStart = "${self.packages.${pkgs.system}.knot}/bin/knot server";
164
Restart = "always";
165
};
166
};
···
1
+
{
2
config,
3
pkgs,
4
lib,
···
13
type = types.bool;
14
default = false;
15
description = "Enable a tangled knot";
16
+
};
17
+
18
+
package = mkOption {
19
+
type = types.package;
20
+
description = "Package to use for the knot";
21
};
22
23
appviewEndpoint = mkOption {
···
58
};
59
};
60
61
+
motd = mkOption {
62
+
type = types.nullOr types.str;
63
+
default = null;
64
+
description = ''
65
+
Message of the day
66
+
67
+
The contents are shown as-is; eg. you will want to add a newline if
68
+
setting a non-empty message since the knot won't do this for you.
69
+
'';
70
+
};
71
+
72
+
motdFile = mkOption {
73
+
type = types.nullOr types.path;
74
+
default = null;
75
+
description = ''
76
+
File containing message of the day
77
+
78
+
The contents are shown as-is; eg. you will want to add a newline if
79
+
setting a non-empty message since the knot won't do this for you.
80
+
'';
81
+
};
82
+
83
server = {
84
listenAddr = mkOption {
85
type = types.str;
···
121
};
122
123
config = mkIf cfg.enable {
124
+
environment.systemPackages = [
125
+
pkgs.git
126
+
cfg.package
127
];
128
129
+
system.activationScripts.gitConfig = let
130
+
setMotd =
131
+
if cfg.motdFile != null && cfg.motd != null
132
+
then throw "motdFile and motd cannot be both set"
133
+
else ''
134
+
${optionalString (cfg.motdFile != null) "cat ${cfg.motdFile} > ${cfg.stateDir}/motd"}
135
+
${optionalString (cfg.motd != null) ''printf "${cfg.motd}" > ${cfg.stateDir}/motd''}
136
+
'';
137
+
in ''
138
mkdir -p "${cfg.repo.scanPath}"
139
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}"
140
···
143
[user]
144
name = Git User
145
email = git@example.com
146
+
[receive]
147
+
advertisePushOptions = true
148
EOF
149
+
${setMotd}
150
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
151
'';
152
···
173
mode = "0555";
174
text = ''
175
#!${pkgs.stdenv.shell}
176
+
${cfg.package}/bin/knot keys \
177
-output authorized-keys \
178
-internal-api "http://${cfg.server.internalListenAddr}" \
179
-git-dir "${cfg.repo.scanPath}" \
···
198
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
199
];
200
EnvironmentFile = cfg.server.secretFile;
201
+
ExecStart = "${cfg.package}/bin/knot server";
202
Restart = "always";
203
};
204
};
+8
-5
nix/modules/spindle.nix
+8
-5
nix/modules/spindle.nix
···
1
-
{self}: {
2
config,
3
-
pkgs,
4
lib,
5
...
6
}: let
···
13
type = types.bool;
14
default = false;
15
description = "Enable a tangled spindle";
16
};
17
18
server = {
···
60
description = "Nixery instance to use";
61
};
62
63
-
stepTimeout = mkOption {
64
type = types.str;
65
default = "5m";
66
description = "Timeout for each step of a pipeline";
···
87
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
88
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
89
"SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}"
90
-
"SPINDLE_PIPELINES_STEP_TIMEOUT=${cfg.pipelines.stepTimeout}"
91
];
92
-
ExecStart = "${self.packages.${pkgs.system}.spindle}/bin/spindle";
93
Restart = "always";
94
};
95
};
···
1
+
{
2
config,
3
lib,
4
...
5
}: let
···
12
type = types.bool;
13
default = false;
14
description = "Enable a tangled spindle";
15
+
};
16
+
package = mkOption {
17
+
type = types.package;
18
+
description = "Package to use for the spindle";
19
};
20
21
server = {
···
63
description = "Nixery instance to use";
64
};
65
66
+
workflowTimeout = mkOption {
67
type = types.str;
68
default = "5m";
69
description = "Timeout for each step of a pipeline";
···
90
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
91
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
92
"SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}"
93
+
"SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}"
94
];
95
+
ExecStart = "${cfg.package}/bin/spindle";
96
Restart = "always";
97
};
98
};
+6
-9
nix/pkgs/appview.nix
+6
-9
nix/pkgs/appview.nix
···
1
{
2
-
buildGoModule,
3
-
stdenv,
4
htmx-src,
5
htmx-ws-src,
6
lucide-src,
···
8
ibm-plex-mono-src,
9
tailwindcss,
10
sqlite-lib,
11
-
goModHash,
12
gitignoreSource,
13
}:
14
-
buildGoModule {
15
-
inherit stdenv;
16
-
17
pname = "appview";
18
version = "0.1.0";
19
src = gitignoreSource ../..;
20
21
postUnpack = ''
22
pushd source
···
33
34
doCheck = false;
35
subPackages = ["cmd/appview"];
36
-
vendorHash = goModHash;
37
38
-
tags = "libsqlite3";
39
env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
40
env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib";
41
-
env.CGO_ENABLED = 1;
42
}
···
1
{
2
+
buildGoApplication,
3
+
modules,
4
htmx-src,
5
htmx-ws-src,
6
lucide-src,
···
8
ibm-plex-mono-src,
9
tailwindcss,
10
sqlite-lib,
11
gitignoreSource,
12
}:
13
+
buildGoApplication {
14
pname = "appview";
15
version = "0.1.0";
16
src = gitignoreSource ../..;
17
+
inherit modules;
18
19
postUnpack = ''
20
pushd source
···
31
32
doCheck = false;
33
subPackages = ["cmd/appview"];
34
35
+
tags = ["libsqlite3"];
36
env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
37
env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib";
38
+
CGO_ENABLED = 1;
39
}
+5
-5
nix/pkgs/genjwks.nix
+5
-5
nix/pkgs/genjwks.nix
+6
-7
nix/pkgs/knot-unwrapped.nix
+6
-7
nix/pkgs/knot-unwrapped.nix
···
1
{
2
-
buildGoModule,
3
-
stdenv,
4
sqlite-lib,
5
-
goModHash,
6
gitignoreSource,
7
}:
8
-
buildGoModule {
9
pname = "knot";
10
version = "0.1.0";
11
src = gitignoreSource ../..;
12
13
doCheck = false;
14
15
subPackages = ["cmd/knot"];
16
-
vendorHash = goModHash;
17
-
tags = "libsqlite3";
18
19
env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
20
env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib";
21
-
env.CGO_ENABLED = 1;
22
}
···
1
{
2
+
buildGoApplication,
3
+
modules,
4
sqlite-lib,
5
gitignoreSource,
6
}:
7
+
buildGoApplication {
8
pname = "knot";
9
version = "0.1.0";
10
src = gitignoreSource ../..;
11
+
inherit modules;
12
13
doCheck = false;
14
15
subPackages = ["cmd/knot"];
16
+
tags = ["libsqlite3"];
17
18
env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
19
env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib";
20
+
CGO_ENABLED = 1;
21
}
+6
-7
nix/pkgs/spindle.nix
+6
-7
nix/pkgs/spindle.nix
···
1
{
2
-
buildGoModule,
3
-
stdenv,
4
sqlite-lib,
5
-
goModHash,
6
gitignoreSource,
7
}:
8
-
buildGoModule {
9
pname = "spindle";
10
version = "0.1.0";
11
src = gitignoreSource ../..;
12
13
doCheck = false;
14
15
subPackages = ["cmd/spindle"];
16
-
vendorHash = goModHash;
17
-
tags = "libsqlite3";
18
19
env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
20
env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib";
21
-
env.CGO_ENABLED = 1;
22
}
···
1
{
2
+
buildGoApplication,
3
+
modules,
4
sqlite-lib,
5
gitignoreSource,
6
}:
7
+
buildGoApplication {
8
pname = "spindle";
9
version = "0.1.0";
10
src = gitignoreSource ../..;
11
+
inherit modules;
12
13
doCheck = false;
14
15
subPackages = ["cmd/spindle"];
16
+
tags = ["libsqlite3"];
17
18
env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
19
env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib";
20
+
CGO_ENABLED = 1;
21
}
+1
nix/vm.nix
+1
nix/vm.nix
+25
patchutil/interdiff.go
+25
patchutil/interdiff.go
···
5
"strings"
6
7
"github.com/bluekeyes/go-gitdiff/gitdiff"
8
+
"tangled.sh/tangled.sh/core/types"
9
)
10
11
type InterdiffResult struct {
···
34
*gitdiff.File
35
Name string
36
Status InterdiffFileStatus
37
+
}
38
+
39
+
func (s *InterdiffFile) Split() *types.SplitDiff {
40
+
fragments := make([]types.SplitFragment, len(s.TextFragments))
41
+
42
+
for i, fragment := range s.TextFragments {
43
+
leftLines, rightLines := types.SeparateLines(fragment)
44
+
45
+
fragments[i] = types.SplitFragment{
46
+
Header: fragment.Header(),
47
+
LeftLines: leftLines,
48
+
RightLines: rightLines,
49
+
}
50
+
}
51
+
52
+
return &types.SplitDiff{
53
+
Name: s.Id(),
54
+
TextFragments: fragments,
55
+
}
56
+
}
57
+
58
+
// used by html elements as a unique ID for hrefs
59
+
func (s *InterdiffFile) Id() string {
60
+
return s.Name
61
}
62
63
func (s *InterdiffFile) String() string {
+4
rbac/rbac.go
+4
rbac/rbac.go
+23
-6
spindle/config/config.go
+23
-6
spindle/config/config.go
···
2
3
import (
4
"context"
5
6
"github.com/sethvargo/go-envconfig"
7
)
8
9
type Server struct {
10
-
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"`
11
-
DBPath string `env:"DB_PATH, default=spindle.db"`
12
-
Hostname string `env:"HOSTNAME, required"`
13
-
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
14
-
Dev bool `env:"DEV, default=false"`
15
-
Owner string `env:"OWNER, required"`
16
}
17
18
type Pipelines struct {
···
2
3
import (
4
"context"
5
+
"fmt"
6
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
8
"github.com/sethvargo/go-envconfig"
9
)
10
11
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
+
Dev bool `env:"DEV, default=false"`
17
+
Owner string `env:"OWNER, required"`
18
+
Secrets Secrets `env:",prefix=SECRETS_"`
19
+
}
20
+
21
+
func (s Server) Did() syntax.DID {
22
+
return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname))
23
+
}
24
+
25
+
type Secrets struct {
26
+
Provider string `env:"PROVIDER, default=sqlite"`
27
+
OpenBao OpenBaoConfig `env:",prefix=OPENBAO_"`
28
+
}
29
+
30
+
type OpenBaoConfig struct {
31
+
ProxyAddr string `env:"PROXY_ADDR, default=http://127.0.0.1:8200"`
32
+
Mount string `env:"MOUNT, default=spindle"`
33
}
34
35
type Pipelines struct {
+42
-19
spindle/engine/engine.go
+42
-19
spindle/engine/engine.go
···
11
"sync"
12
"time"
13
14
"github.com/docker/docker/api/types/container"
15
"github.com/docker/docker/api/types/image"
16
"github.com/docker/docker/api/types/mount"
···
18
"github.com/docker/docker/api/types/volume"
19
"github.com/docker/docker/client"
20
"github.com/docker/docker/pkg/stdcopy"
21
"tangled.sh/tangled.sh/core/log"
22
"tangled.sh/tangled.sh/core/notifier"
23
"tangled.sh/tangled.sh/core/spindle/config"
24
"tangled.sh/tangled.sh/core/spindle/db"
25
"tangled.sh/tangled.sh/core/spindle/models"
26
)
27
28
const (
···
37
db *db.DB
38
n *notifier.Notifier
39
cfg *config.Config
40
41
cleanupMu sync.Mutex
42
cleanup map[string][]cleanupFunc
43
}
44
45
-
func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier) (*Engine, error) {
46
dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
47
if err != nil {
48
return nil, err
···
56
db: db,
57
n: n,
58
cfg: cfg,
59
}
60
61
e.cleanup = make(map[string][]cleanupFunc)
···
66
func (e *Engine) StartWorkflows(ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) {
67
e.l.Info("starting all workflows in parallel", "pipeline", pipelineId)
68
69
-
wg := sync.WaitGroup{}
70
for _, w := range pipeline.Workflows {
71
-
wg.Add(1)
72
-
go func() error {
73
-
defer wg.Done()
74
wid := models.WorkflowId{
75
PipelineId: pipelineId,
76
Name: w.Name,
···
102
defer reader.Close()
103
io.Copy(os.Stdout, reader)
104
105
-
workflowTimeoutStr := e.cfg.Pipelines.WorkflowTimeout
106
-
workflowTimeout, err := time.ParseDuration(workflowTimeoutStr)
107
-
if err != nil {
108
-
e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr)
109
-
workflowTimeout = 5 * time.Minute
110
-
}
111
-
e.l.Info("using workflow timeout", "timeout", workflowTimeout)
112
ctx, cancel := context.WithTimeout(ctx, workflowTimeout)
113
defer cancel()
114
115
-
err = e.StartSteps(ctx, w.Steps, wid, w.Image)
116
if err != nil {
117
if errors.Is(err, ErrTimedOut) {
118
dbErr := e.db.StatusTimeout(wid, e.n)
···
135
}
136
137
return nil
138
-
}()
139
}
140
141
-
wg.Wait()
142
}
143
144
// SetupWorkflow sets up a new network for the workflow and volumes for
···
186
// ONLY marks pipeline as failed if container's exit code is non-zero.
187
// All other errors are bubbled up.
188
// Fixed version of the step execution logic
189
-
func (e *Engine) StartSteps(ctx context.Context, steps []models.Step, wid models.WorkflowId, image string) error {
190
191
-
for stepIdx, step := range steps {
192
select {
193
case <-ctx.Done():
194
return ctx.Err()
195
default:
196
}
197
198
-
envs := ConstructEnvs(step.Environment)
199
envs.AddEnv("HOME", workspaceDir)
200
e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice())
201
202
hostConfig := hostConfig(wid)
203
resp, err := e.docker.ContainerCreate(ctx, &container.Config{
204
-
Image: image,
205
Cmd: []string{"bash", "-c", step.Command},
206
WorkingDir: workspaceDir,
207
Tty: false,
···
11
"sync"
12
"time"
13
14
+
securejoin "github.com/cyphar/filepath-securejoin"
15
"github.com/docker/docker/api/types/container"
16
"github.com/docker/docker/api/types/image"
17
"github.com/docker/docker/api/types/mount"
···
19
"github.com/docker/docker/api/types/volume"
20
"github.com/docker/docker/client"
21
"github.com/docker/docker/pkg/stdcopy"
22
+
"golang.org/x/sync/errgroup"
23
"tangled.sh/tangled.sh/core/log"
24
"tangled.sh/tangled.sh/core/notifier"
25
"tangled.sh/tangled.sh/core/spindle/config"
26
"tangled.sh/tangled.sh/core/spindle/db"
27
"tangled.sh/tangled.sh/core/spindle/models"
28
+
"tangled.sh/tangled.sh/core/spindle/secrets"
29
)
30
31
const (
···
40
db *db.DB
41
n *notifier.Notifier
42
cfg *config.Config
43
+
vault secrets.Manager
44
45
cleanupMu sync.Mutex
46
cleanup map[string][]cleanupFunc
47
}
48
49
+
func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier, vault secrets.Manager) (*Engine, error) {
50
dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
51
if err != nil {
52
return nil, err
···
60
db: db,
61
n: n,
62
cfg: cfg,
63
+
vault: vault,
64
}
65
66
e.cleanup = make(map[string][]cleanupFunc)
···
71
func (e *Engine) StartWorkflows(ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) {
72
e.l.Info("starting all workflows in parallel", "pipeline", pipelineId)
73
74
+
// extract secrets
75
+
var allSecrets []secrets.UnlockedSecret
76
+
if didSlashRepo, err := securejoin.SecureJoin(pipeline.RepoOwner, pipeline.RepoName); err == nil {
77
+
if res, err := e.vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)); err == nil {
78
+
allSecrets = res
79
+
}
80
+
}
81
+
82
+
workflowTimeoutStr := e.cfg.Pipelines.WorkflowTimeout
83
+
workflowTimeout, err := time.ParseDuration(workflowTimeoutStr)
84
+
if err != nil {
85
+
e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr)
86
+
workflowTimeout = 5 * time.Minute
87
+
}
88
+
e.l.Info("using workflow timeout", "timeout", workflowTimeout)
89
+
90
+
eg, ctx := errgroup.WithContext(ctx)
91
for _, w := range pipeline.Workflows {
92
+
eg.Go(func() error {
93
wid := models.WorkflowId{
94
PipelineId: pipelineId,
95
Name: w.Name,
···
121
defer reader.Close()
122
io.Copy(os.Stdout, reader)
123
124
ctx, cancel := context.WithTimeout(ctx, workflowTimeout)
125
defer cancel()
126
127
+
err = e.StartSteps(ctx, wid, w, allSecrets)
128
if err != nil {
129
if errors.Is(err, ErrTimedOut) {
130
dbErr := e.db.StatusTimeout(wid, e.n)
···
147
}
148
149
return nil
150
+
})
151
}
152
153
+
if err = eg.Wait(); err != nil {
154
+
e.l.Error("failed to run one or more workflows", "err", err)
155
+
} else {
156
+
e.l.Error("successfully ran full pipeline")
157
+
}
158
}
159
160
// SetupWorkflow sets up a new network for the workflow and volumes for
···
202
// ONLY marks pipeline as failed if container's exit code is non-zero.
203
// All other errors are bubbled up.
204
// Fixed version of the step execution logic
205
+
func (e *Engine) StartSteps(ctx context.Context, wid models.WorkflowId, w models.Workflow, secrets []secrets.UnlockedSecret) error {
206
+
workflowEnvs := ConstructEnvs(w.Environment)
207
+
for _, s := range secrets {
208
+
workflowEnvs.AddEnv(s.Key, s.Value)
209
+
}
210
211
+
for stepIdx, step := range w.Steps {
212
select {
213
case <-ctx.Done():
214
return ctx.Err()
215
default:
216
}
217
218
+
envs := append(EnvVars(nil), workflowEnvs...)
219
+
for k, v := range step.Environment {
220
+
envs.AddEnv(k, v)
221
+
}
222
envs.AddEnv("HOME", workspaceDir)
223
e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice())
224
225
hostConfig := hostConfig(wid)
226
resp, err := e.docker.ContainerCreate(ctx, &container.Config{
227
+
Image: w.Image,
228
Cmd: []string{"bash", "-c", step.Command},
229
WorkingDir: workspaceDir,
230
Tty: false,
+129
-2
spindle/ingester.go
+129
-2
spindle/ingester.go
···
3
import (
4
"context"
5
"encoding/json"
6
"fmt"
7
8
"tangled.sh/tangled.sh/core/api/tangled"
9
"tangled.sh/tangled.sh/core/eventconsumer"
10
11
"github.com/bluesky-social/jetstream/pkg/models"
12
)
13
14
type Ingester func(ctx context.Context, e *models.Event) error
···
33
s.ingestMember(ctx, e)
34
case tangled.RepoNSID:
35
s.ingestRepo(ctx, e)
36
}
37
38
return err
···
72
return fmt.Errorf("failed to enforce permissions: %w", err)
73
}
74
75
-
if err := s.e.AddKnotMember(rbacDomain, record.Subject); err != nil {
76
l.Error("failed to add member", "error", err)
77
return fmt.Errorf("failed to add member: %w", err)
78
}
···
90
return nil
91
}
92
93
-
func (s *Spindle) ingestRepo(_ context.Context, e *models.Event) error {
94
var err error
95
96
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
97
···
127
return fmt.Errorf("failed to add repo: %w", err)
128
}
129
130
// add this knot to the event consumer
131
src := eventconsumer.NewKnotSource(record.Knot)
132
s.ks.AddSource(context.Background(), src)
···
136
}
137
return nil
138
}
···
3
import (
4
"context"
5
"encoding/json"
6
+
"errors"
7
"fmt"
8
9
"tangled.sh/tangled.sh/core/api/tangled"
10
"tangled.sh/tangled.sh/core/eventconsumer"
11
+
"tangled.sh/tangled.sh/core/idresolver"
12
+
"tangled.sh/tangled.sh/core/rbac"
13
14
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
15
+
"github.com/bluesky-social/indigo/atproto/identity"
16
+
"github.com/bluesky-social/indigo/atproto/syntax"
17
+
"github.com/bluesky-social/indigo/xrpc"
18
"github.com/bluesky-social/jetstream/pkg/models"
19
+
securejoin "github.com/cyphar/filepath-securejoin"
20
)
21
22
type Ingester func(ctx context.Context, e *models.Event) error
···
41
s.ingestMember(ctx, e)
42
case tangled.RepoNSID:
43
s.ingestRepo(ctx, e)
44
+
case tangled.RepoCollaboratorNSID:
45
+
s.ingestCollaborator(ctx, e)
46
}
47
48
return err
···
82
return fmt.Errorf("failed to enforce permissions: %w", err)
83
}
84
85
+
if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil {
86
l.Error("failed to add member", "error", err)
87
return fmt.Errorf("failed to add member: %w", err)
88
}
···
100
return nil
101
}
102
103
+
func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error {
104
var err error
105
+
did := e.Did
106
+
resolver := idresolver.DefaultResolver()
107
108
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
109
···
139
return fmt.Errorf("failed to add repo: %w", err)
140
}
141
142
+
didSlashRepo, err := securejoin.SecureJoin(record.Owner, record.Name)
143
+
if err != nil {
144
+
return err
145
+
}
146
+
147
+
// add repo to rbac
148
+
if err := s.e.AddRepo(record.Owner, rbac.ThisServer, didSlashRepo); err != nil {
149
+
l.Error("failed to add repo to enforcer", "error", err)
150
+
return fmt.Errorf("failed to add repo: %w", err)
151
+
}
152
+
153
+
// add collaborators to rbac
154
+
owner, err := resolver.ResolveIdent(ctx, did)
155
+
if err != nil || owner.Handle.IsInvalidHandle() {
156
+
return err
157
+
}
158
+
if err := s.fetchAndAddCollaborators(ctx, owner, didSlashRepo); err != nil {
159
+
return err
160
+
}
161
+
162
// add this knot to the event consumer
163
src := eventconsumer.NewKnotSource(record.Knot)
164
s.ks.AddSource(context.Background(), src)
···
168
}
169
return nil
170
}
171
+
172
+
func (s *Spindle) ingestCollaborator(ctx context.Context, e *models.Event) error {
173
+
var err error
174
+
175
+
l := s.l.With("component", "ingester", "record", tangled.RepoCollaboratorNSID, "did", e.Did)
176
+
177
+
l.Info("ingesting collaborator record")
178
+
179
+
switch e.Commit.Operation {
180
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
181
+
raw := e.Commit.Record
182
+
record := tangled.RepoCollaborator{}
183
+
err = json.Unmarshal(raw, &record)
184
+
if err != nil {
185
+
l.Error("invalid record", "error", err)
186
+
return err
187
+
}
188
+
189
+
resolver := idresolver.DefaultResolver()
190
+
191
+
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
192
+
if err != nil || subjectId.Handle.IsInvalidHandle() {
193
+
return err
194
+
}
195
+
196
+
repoAt, err := syntax.ParseATURI(record.Repo)
197
+
if err != nil {
198
+
l.Info("rejecting record, invalid repoAt", "repoAt", record.Repo)
199
+
return nil
200
+
}
201
+
202
+
// TODO: get rid of this entirely
203
+
// resolve this aturi to extract the repo record
204
+
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
205
+
if err != nil || owner.Handle.IsInvalidHandle() {
206
+
return fmt.Errorf("failed to resolve handle: %w", err)
207
+
}
208
+
209
+
xrpcc := xrpc.Client{
210
+
Host: owner.PDSEndpoint(),
211
+
}
212
+
213
+
resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
214
+
if err != nil {
215
+
return err
216
+
}
217
+
218
+
repo := resp.Value.Val.(*tangled.Repo)
219
+
didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name)
220
+
221
+
// check perms for this user
222
+
if ok, err := s.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil {
223
+
return fmt.Errorf("insufficient permissions: %w", err)
224
+
}
225
+
226
+
// add collaborator to rbac
227
+
if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil {
228
+
l.Error("failed to add repo to enforcer", "error", err)
229
+
return fmt.Errorf("failed to add repo: %w", err)
230
+
}
231
+
232
+
return nil
233
+
}
234
+
return nil
235
+
}
236
+
237
+
func (s *Spindle) fetchAndAddCollaborators(ctx context.Context, owner *identity.Identity, didSlashRepo string) error {
238
+
l := s.l.With("component", "ingester", "handler", "fetchAndAddCollaborators")
239
+
240
+
l.Info("fetching and adding existing collaborators")
241
+
242
+
xrpcc := xrpc.Client{
243
+
Host: owner.PDSEndpoint(),
244
+
}
245
+
246
+
resp, err := comatproto.RepoListRecords(ctx, &xrpcc, tangled.RepoCollaboratorNSID, "", 50, owner.DID.String(), false)
247
+
if err != nil {
248
+
return err
249
+
}
250
+
251
+
var errs error
252
+
for _, r := range resp.Records {
253
+
if r == nil {
254
+
continue
255
+
}
256
+
record := r.Value.Val.(*tangled.RepoCollaborator)
257
+
258
+
if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil {
259
+
l.Error("failed to add repo to enforcer", "error", err)
260
+
errors.Join(errs, fmt.Errorf("failed to add repo: %w", err))
261
+
}
262
+
}
263
+
264
+
return errs
265
+
}
+9
-12
spindle/models/pipeline.go
+9
-12
spindle/models/pipeline.go
···
8
)
9
10
type Pipeline struct {
11
Workflows []Workflow
12
}
13
···
63
swf.Environment = workflowEnvToMap(twf.Environment)
64
swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery)
65
66
-
swf.addNixProfileToPath()
67
-
swf.setGlobalEnvs()
68
setup := &setupSteps{}
69
70
setup.addStep(nixConfStep())
···
79
80
workflows = append(workflows, *swf)
81
}
82
-
return &Pipeline{Workflows: workflows}
83
}
84
85
func workflowEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string {
···
115
116
return path.Join(nixery, dependencies)
117
}
118
-
119
-
func (wf *Workflow) addNixProfileToPath() {
120
-
wf.Environment["PATH"] = "$PATH:/.nix-profile/bin"
121
-
}
122
-
123
-
func (wf *Workflow) setGlobalEnvs() {
124
-
wf.Environment["NIX_CONFIG"] = "experimental-features = nix-command flakes"
125
-
wf.Environment["HOME"] = "/tangled/workspace"
126
-
}
···
8
)
9
10
type Pipeline struct {
11
+
RepoOwner string
12
+
RepoName string
13
Workflows []Workflow
14
}
15
···
65
swf.Environment = workflowEnvToMap(twf.Environment)
66
swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery)
67
68
setup := &setupSteps{}
69
70
setup.addStep(nixConfStep())
···
79
80
workflows = append(workflows, *swf)
81
}
82
+
repoOwner := pl.TriggerMetadata.Repo.Did
83
+
repoName := pl.TriggerMetadata.Repo.Repo
84
+
return &Pipeline{
85
+
RepoOwner: repoOwner,
86
+
RepoName: repoName,
87
+
Workflows: workflows,
88
+
}
89
}
90
91
func workflowEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string {
···
121
122
return path.Join(nixery, dependencies)
123
}
+3
spindle/models/setup_steps.go
+3
spindle/models/setup_steps.go
+25
spindle/motd
+25
spindle/motd
···
···
1
+
****
2
+
*** ***
3
+
*** ** ****** **
4
+
** * *****
5
+
* ** **
6
+
* * * ***************
7
+
** ** *# **
8
+
* ** ** *** **
9
+
* * ** ** * ******
10
+
* ** ** * ** * *
11
+
** ** *** ** ** *
12
+
** ** * ** * *
13
+
** **** ** * *
14
+
** *** ** ** **
15
+
*** ** *****
16
+
********************
17
+
**
18
+
*
19
+
#**************
20
+
**
21
+
********
22
+
23
+
This is a spindle server. More info at https://tangled.sh/@tangled.sh/core/tree/master/docs/spindle
24
+
25
+
Most API routes are under /xrpc/
+70
spindle/secrets/manager.go
+70
spindle/secrets/manager.go
···
···
1
+
package secrets
2
+
3
+
import (
4
+
"context"
5
+
"errors"
6
+
"regexp"
7
+
"time"
8
+
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
)
11
+
12
+
type DidSlashRepo string
13
+
14
+
type Secret[T any] struct {
15
+
Key string
16
+
Value T
17
+
Repo DidSlashRepo
18
+
CreatedAt time.Time
19
+
CreatedBy syntax.DID
20
+
}
21
+
22
+
// the secret is not present
23
+
type LockedSecret = Secret[struct{}]
24
+
25
+
// the secret is present in plaintext, never expose this publicly,
26
+
// only use in the workflow engine
27
+
type UnlockedSecret = Secret[string]
28
+
29
+
type Manager interface {
30
+
AddSecret(ctx context.Context, secret UnlockedSecret) error
31
+
RemoveSecret(ctx context.Context, secret Secret[any]) error
32
+
GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error)
33
+
GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error)
34
+
}
35
+
36
+
// stopper interface for managers that need cleanup
37
+
type Stopper interface {
38
+
Stop()
39
+
}
40
+
41
+
var ErrKeyAlreadyPresent = errors.New("key already present")
42
+
var ErrInvalidKeyIdent = errors.New("key is not a valid identifier")
43
+
var ErrKeyNotFound = errors.New("key not found")
44
+
45
+
// ensure that we are satisfying the interface
46
+
var (
47
+
_ = []Manager{
48
+
&SqliteManager{},
49
+
&OpenBaoManager{},
50
+
}
51
+
)
52
+
53
+
var (
54
+
// bash identifier syntax
55
+
keyIdent = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
56
+
)
57
+
58
+
func isValidKey(key string) bool {
59
+
if key == "" {
60
+
return false
61
+
}
62
+
return keyIdent.MatchString(key)
63
+
}
64
+
65
+
func ValidateKey(key string) error {
66
+
if !isValidKey(key) {
67
+
return ErrInvalidKeyIdent
68
+
}
69
+
return nil
70
+
}
+313
spindle/secrets/openbao.go
+313
spindle/secrets/openbao.go
···
···
1
+
package secrets
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"log/slog"
7
+
"path"
8
+
"strings"
9
+
"time"
10
+
11
+
"github.com/bluesky-social/indigo/atproto/syntax"
12
+
vault "github.com/openbao/openbao/api/v2"
13
+
)
14
+
15
+
type OpenBaoManager struct {
16
+
client *vault.Client
17
+
mountPath string
18
+
logger *slog.Logger
19
+
}
20
+
21
+
type OpenBaoManagerOpt func(*OpenBaoManager)
22
+
23
+
func WithMountPath(mountPath string) OpenBaoManagerOpt {
24
+
return func(v *OpenBaoManager) {
25
+
v.mountPath = mountPath
26
+
}
27
+
}
28
+
29
+
// NewOpenBaoManager creates a new OpenBao manager that connects to a Bao Proxy
30
+
// The proxyAddress should point to the local Bao Proxy (e.g., "http://127.0.0.1:8200")
31
+
// The proxy handles all authentication automatically via Auto-Auth
32
+
func NewOpenBaoManager(proxyAddress string, logger *slog.Logger, opts ...OpenBaoManagerOpt) (*OpenBaoManager, error) {
33
+
if proxyAddress == "" {
34
+
return nil, fmt.Errorf("proxy address cannot be empty")
35
+
}
36
+
37
+
config := vault.DefaultConfig()
38
+
config.Address = proxyAddress
39
+
40
+
client, err := vault.NewClient(config)
41
+
if err != nil {
42
+
return nil, fmt.Errorf("failed to create openbao client: %w", err)
43
+
}
44
+
45
+
manager := &OpenBaoManager{
46
+
client: client,
47
+
mountPath: "spindle", // default KV v2 mount path
48
+
logger: logger,
49
+
}
50
+
51
+
for _, opt := range opts {
52
+
opt(manager)
53
+
}
54
+
55
+
if err := manager.testConnection(); err != nil {
56
+
return nil, fmt.Errorf("failed to connect to bao proxy: %w", err)
57
+
}
58
+
59
+
logger.Info("successfully connected to bao proxy", "address", proxyAddress)
60
+
return manager, nil
61
+
}
62
+
63
+
// testConnection verifies that we can connect to the proxy
64
+
func (v *OpenBaoManager) testConnection() error {
65
+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
66
+
defer cancel()
67
+
68
+
// try token self-lookup as a quick way to verify proxy works
69
+
// and is authenticated
70
+
_, err := v.client.Auth().Token().LookupSelfWithContext(ctx)
71
+
if err != nil {
72
+
return fmt.Errorf("proxy connection test failed: %w", err)
73
+
}
74
+
75
+
return nil
76
+
}
77
+
78
+
func (v *OpenBaoManager) AddSecret(ctx context.Context, secret UnlockedSecret) error {
79
+
if err := ValidateKey(secret.Key); err != nil {
80
+
return err
81
+
}
82
+
83
+
secretPath := v.buildSecretPath(secret.Repo, secret.Key)
84
+
v.logger.Debug("adding secret", "repo", secret.Repo, "key", secret.Key, "path", secretPath)
85
+
86
+
// Check if secret already exists
87
+
existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
88
+
if err == nil && existing != nil {
89
+
v.logger.Debug("secret already exists", "path", secretPath)
90
+
return ErrKeyAlreadyPresent
91
+
}
92
+
93
+
secretData := map[string]interface{}{
94
+
"value": secret.Value,
95
+
"repo": string(secret.Repo),
96
+
"key": secret.Key,
97
+
"created_at": secret.CreatedAt.Format(time.RFC3339),
98
+
"created_by": secret.CreatedBy.String(),
99
+
}
100
+
101
+
v.logger.Debug("writing secret to openbao", "path", secretPath, "mount", v.mountPath)
102
+
resp, err := v.client.KVv2(v.mountPath).Put(ctx, secretPath, secretData)
103
+
if err != nil {
104
+
v.logger.Error("failed to write secret", "path", secretPath, "error", err)
105
+
return fmt.Errorf("failed to store secret in openbao: %w", err)
106
+
}
107
+
108
+
v.logger.Debug("secret write response", "version", resp.VersionMetadata.Version, "created_time", resp.VersionMetadata.CreatedTime)
109
+
110
+
v.logger.Debug("verifying secret was written", "path", secretPath)
111
+
readBack, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
112
+
if err != nil {
113
+
v.logger.Error("failed to verify secret after write", "path", secretPath, "error", err)
114
+
return fmt.Errorf("secret not found after writing to %s/%s: %w", v.mountPath, secretPath, err)
115
+
}
116
+
117
+
if readBack == nil || readBack.Data == nil {
118
+
v.logger.Error("secret verification returned empty data", "path", secretPath)
119
+
return fmt.Errorf("secret verification failed: empty data returned for %s/%s", v.mountPath, secretPath)
120
+
}
121
+
122
+
v.logger.Info("secret added and verified successfully", "repo", secret.Repo, "key", secret.Key, "version", readBack.VersionMetadata.Version)
123
+
return nil
124
+
}
125
+
126
+
func (v *OpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error {
127
+
secretPath := v.buildSecretPath(secret.Repo, secret.Key)
128
+
129
+
// check if secret exists
130
+
existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
131
+
if err != nil || existing == nil {
132
+
return ErrKeyNotFound
133
+
}
134
+
135
+
err = v.client.KVv2(v.mountPath).Delete(ctx, secretPath)
136
+
if err != nil {
137
+
return fmt.Errorf("failed to delete secret from openbao: %w", err)
138
+
}
139
+
140
+
v.logger.Debug("secret removed successfully", "repo", secret.Repo, "key", secret.Key)
141
+
return nil
142
+
}
143
+
144
+
func (v *OpenBaoManager) GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) {
145
+
repoPath := v.buildRepoPath(repo)
146
+
147
+
secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath))
148
+
if err != nil {
149
+
if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") {
150
+
return []LockedSecret{}, nil
151
+
}
152
+
return nil, fmt.Errorf("failed to list secrets: %w", err)
153
+
}
154
+
155
+
if secretsList == nil || secretsList.Data == nil {
156
+
return []LockedSecret{}, nil
157
+
}
158
+
159
+
keys, ok := secretsList.Data["keys"].([]interface{})
160
+
if !ok {
161
+
return []LockedSecret{}, nil
162
+
}
163
+
164
+
var secrets []LockedSecret
165
+
166
+
for _, keyInterface := range keys {
167
+
key, ok := keyInterface.(string)
168
+
if !ok {
169
+
continue
170
+
}
171
+
172
+
secretPath := fmt.Sprintf("%s/%s", repoPath, key)
173
+
secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
174
+
if err != nil {
175
+
v.logger.Warn("failed to read secret metadata", "path", secretPath, "error", err)
176
+
continue
177
+
}
178
+
179
+
if secretData == nil || secretData.Data == nil {
180
+
continue
181
+
}
182
+
183
+
data := secretData.Data
184
+
185
+
createdAtStr, ok := data["created_at"].(string)
186
+
if !ok {
187
+
createdAtStr = time.Now().Format(time.RFC3339)
188
+
}
189
+
190
+
createdAt, err := time.Parse(time.RFC3339, createdAtStr)
191
+
if err != nil {
192
+
createdAt = time.Now()
193
+
}
194
+
195
+
createdByStr, ok := data["created_by"].(string)
196
+
if !ok {
197
+
createdByStr = ""
198
+
}
199
+
200
+
keyStr, ok := data["key"].(string)
201
+
if !ok {
202
+
keyStr = key
203
+
}
204
+
205
+
secret := LockedSecret{
206
+
Key: keyStr,
207
+
Repo: repo,
208
+
CreatedAt: createdAt,
209
+
CreatedBy: syntax.DID(createdByStr),
210
+
}
211
+
212
+
secrets = append(secrets, secret)
213
+
}
214
+
215
+
v.logger.Debug("retrieved locked secrets", "repo", repo, "count", len(secrets))
216
+
return secrets, nil
217
+
}
218
+
219
+
func (v *OpenBaoManager) GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) {
220
+
repoPath := v.buildRepoPath(repo)
221
+
222
+
secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath))
223
+
if err != nil {
224
+
if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") {
225
+
return []UnlockedSecret{}, nil
226
+
}
227
+
return nil, fmt.Errorf("failed to list secrets: %w", err)
228
+
}
229
+
230
+
if secretsList == nil || secretsList.Data == nil {
231
+
return []UnlockedSecret{}, nil
232
+
}
233
+
234
+
keys, ok := secretsList.Data["keys"].([]interface{})
235
+
if !ok {
236
+
return []UnlockedSecret{}, nil
237
+
}
238
+
239
+
var secrets []UnlockedSecret
240
+
241
+
for _, keyInterface := range keys {
242
+
key, ok := keyInterface.(string)
243
+
if !ok {
244
+
continue
245
+
}
246
+
247
+
secretPath := fmt.Sprintf("%s/%s", repoPath, key)
248
+
secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
249
+
if err != nil {
250
+
v.logger.Warn("failed to read secret", "path", secretPath, "error", err)
251
+
continue
252
+
}
253
+
254
+
if secretData == nil || secretData.Data == nil {
255
+
continue
256
+
}
257
+
258
+
data := secretData.Data
259
+
260
+
valueStr, ok := data["value"].(string)
261
+
if !ok {
262
+
v.logger.Warn("secret missing value", "path", secretPath)
263
+
continue
264
+
}
265
+
266
+
createdAtStr, ok := data["created_at"].(string)
267
+
if !ok {
268
+
createdAtStr = time.Now().Format(time.RFC3339)
269
+
}
270
+
271
+
createdAt, err := time.Parse(time.RFC3339, createdAtStr)
272
+
if err != nil {
273
+
createdAt = time.Now()
274
+
}
275
+
276
+
createdByStr, ok := data["created_by"].(string)
277
+
if !ok {
278
+
createdByStr = ""
279
+
}
280
+
281
+
keyStr, ok := data["key"].(string)
282
+
if !ok {
283
+
keyStr = key
284
+
}
285
+
286
+
secret := UnlockedSecret{
287
+
Key: keyStr,
288
+
Value: valueStr,
289
+
Repo: repo,
290
+
CreatedAt: createdAt,
291
+
CreatedBy: syntax.DID(createdByStr),
292
+
}
293
+
294
+
secrets = append(secrets, secret)
295
+
}
296
+
297
+
v.logger.Debug("retrieved unlocked secrets", "repo", repo, "count", len(secrets))
298
+
return secrets, nil
299
+
}
300
+
301
+
// buildRepoPath creates a safe path for a repository
302
+
func (v *OpenBaoManager) buildRepoPath(repo DidSlashRepo) string {
303
+
// convert DidSlashRepo to a safe path by replacing special characters
304
+
repoPath := strings.ReplaceAll(string(repo), "/", "_")
305
+
repoPath = strings.ReplaceAll(repoPath, ":", "_")
306
+
repoPath = strings.ReplaceAll(repoPath, ".", "_")
307
+
return fmt.Sprintf("repos/%s", repoPath)
308
+
}
309
+
310
+
// buildSecretPath creates a path for a specific secret
311
+
func (v *OpenBaoManager) buildSecretPath(repo DidSlashRepo, key string) string {
312
+
return path.Join(v.buildRepoPath(repo), key)
313
+
}
+605
spindle/secrets/openbao_test.go
+605
spindle/secrets/openbao_test.go
···
···
1
+
package secrets
2
+
3
+
import (
4
+
"context"
5
+
"log/slog"
6
+
"os"
7
+
"testing"
8
+
"time"
9
+
10
+
"github.com/bluesky-social/indigo/atproto/syntax"
11
+
"github.com/stretchr/testify/assert"
12
+
)
13
+
14
+
// MockOpenBaoManager is a mock implementation of Manager interface for testing
15
+
type MockOpenBaoManager struct {
16
+
secrets map[string]UnlockedSecret // key: repo_key format
17
+
shouldError bool
18
+
errorToReturn error
19
+
}
20
+
21
+
func NewMockOpenBaoManager() *MockOpenBaoManager {
22
+
return &MockOpenBaoManager{secrets: make(map[string]UnlockedSecret)}
23
+
}
24
+
25
+
func (m *MockOpenBaoManager) SetError(err error) {
26
+
m.shouldError = true
27
+
m.errorToReturn = err
28
+
}
29
+
30
+
func (m *MockOpenBaoManager) ClearError() {
31
+
m.shouldError = false
32
+
m.errorToReturn = nil
33
+
}
34
+
35
+
func (m *MockOpenBaoManager) buildKey(repo DidSlashRepo, key string) string {
36
+
return string(repo) + "_" + key
37
+
}
38
+
39
+
func (m *MockOpenBaoManager) AddSecret(ctx context.Context, secret UnlockedSecret) error {
40
+
if m.shouldError {
41
+
return m.errorToReturn
42
+
}
43
+
44
+
key := m.buildKey(secret.Repo, secret.Key)
45
+
if _, exists := m.secrets[key]; exists {
46
+
return ErrKeyAlreadyPresent
47
+
}
48
+
49
+
m.secrets[key] = secret
50
+
return nil
51
+
}
52
+
53
+
func (m *MockOpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error {
54
+
if m.shouldError {
55
+
return m.errorToReturn
56
+
}
57
+
58
+
key := m.buildKey(secret.Repo, secret.Key)
59
+
if _, exists := m.secrets[key]; !exists {
60
+
return ErrKeyNotFound
61
+
}
62
+
63
+
delete(m.secrets, key)
64
+
return nil
65
+
}
66
+
67
+
func (m *MockOpenBaoManager) GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) {
68
+
if m.shouldError {
69
+
return nil, m.errorToReturn
70
+
}
71
+
72
+
var result []LockedSecret
73
+
for _, secret := range m.secrets {
74
+
if secret.Repo == repo {
75
+
result = append(result, LockedSecret{
76
+
Key: secret.Key,
77
+
Repo: secret.Repo,
78
+
CreatedAt: secret.CreatedAt,
79
+
CreatedBy: secret.CreatedBy,
80
+
})
81
+
}
82
+
}
83
+
84
+
return result, nil
85
+
}
86
+
87
+
func (m *MockOpenBaoManager) GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) {
88
+
if m.shouldError {
89
+
return nil, m.errorToReturn
90
+
}
91
+
92
+
var result []UnlockedSecret
93
+
for _, secret := range m.secrets {
94
+
if secret.Repo == repo {
95
+
result = append(result, secret)
96
+
}
97
+
}
98
+
99
+
return result, nil
100
+
}
101
+
102
+
func createTestSecretForOpenBao(repo, key, value, createdBy string) UnlockedSecret {
103
+
return UnlockedSecret{
104
+
Key: key,
105
+
Value: value,
106
+
Repo: DidSlashRepo(repo),
107
+
CreatedAt: time.Now(),
108
+
CreatedBy: syntax.DID(createdBy),
109
+
}
110
+
}
111
+
112
+
// Test MockOpenBaoManager interface compliance
113
+
func TestMockOpenBaoManagerInterface(t *testing.T) {
114
+
var _ Manager = (*MockOpenBaoManager)(nil)
115
+
}
116
+
117
+
func TestOpenBaoManagerInterface(t *testing.T) {
118
+
var _ Manager = (*OpenBaoManager)(nil)
119
+
}
120
+
121
+
func TestNewOpenBaoManager(t *testing.T) {
122
+
tests := []struct {
123
+
name string
124
+
proxyAddr string
125
+
opts []OpenBaoManagerOpt
126
+
expectError bool
127
+
errorContains string
128
+
}{
129
+
{
130
+
name: "empty proxy address",
131
+
proxyAddr: "",
132
+
opts: nil,
133
+
expectError: true,
134
+
errorContains: "proxy address cannot be empty",
135
+
},
136
+
{
137
+
name: "valid proxy address",
138
+
proxyAddr: "http://localhost:8200",
139
+
opts: nil,
140
+
expectError: true, // Will fail because no real proxy is running
141
+
errorContains: "failed to connect to bao proxy",
142
+
},
143
+
{
144
+
name: "with mount path option",
145
+
proxyAddr: "http://localhost:8200",
146
+
opts: []OpenBaoManagerOpt{WithMountPath("custom-mount")},
147
+
expectError: true, // Will fail because no real proxy is running
148
+
errorContains: "failed to connect to bao proxy",
149
+
},
150
+
}
151
+
152
+
for _, tt := range tests {
153
+
t.Run(tt.name, func(t *testing.T) {
154
+
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
155
+
manager, err := NewOpenBaoManager(tt.proxyAddr, logger, tt.opts...)
156
+
157
+
if tt.expectError {
158
+
assert.Error(t, err)
159
+
assert.Nil(t, manager)
160
+
assert.Contains(t, err.Error(), tt.errorContains)
161
+
} else {
162
+
assert.NoError(t, err)
163
+
assert.NotNil(t, manager)
164
+
}
165
+
})
166
+
}
167
+
}
168
+
169
+
func TestOpenBaoManager_PathBuilding(t *testing.T) {
170
+
manager := &OpenBaoManager{mountPath: "secret"}
171
+
172
+
tests := []struct {
173
+
name string
174
+
repo DidSlashRepo
175
+
key string
176
+
expected string
177
+
}{
178
+
{
179
+
name: "simple repo path",
180
+
repo: DidSlashRepo("did:plc:foo/repo"),
181
+
key: "api_key",
182
+
expected: "repos/did_plc_foo_repo/api_key",
183
+
},
184
+
{
185
+
name: "complex repo path with dots",
186
+
repo: DidSlashRepo("did:web:example.com/my-repo"),
187
+
key: "secret_key",
188
+
expected: "repos/did_web_example_com_my-repo/secret_key",
189
+
},
190
+
}
191
+
192
+
for _, tt := range tests {
193
+
t.Run(tt.name, func(t *testing.T) {
194
+
result := manager.buildSecretPath(tt.repo, tt.key)
195
+
assert.Equal(t, tt.expected, result)
196
+
})
197
+
}
198
+
}
199
+
200
+
func TestOpenBaoManager_buildRepoPath(t *testing.T) {
201
+
manager := &OpenBaoManager{mountPath: "test"}
202
+
203
+
tests := []struct {
204
+
name string
205
+
repo DidSlashRepo
206
+
expected string
207
+
}{
208
+
{
209
+
name: "simple repo",
210
+
repo: "did:plc:test/myrepo",
211
+
expected: "repos/did_plc_test_myrepo",
212
+
},
213
+
{
214
+
name: "repo with dots",
215
+
repo: "did:plc:example.com/my.repo",
216
+
expected: "repos/did_plc_example_com_my_repo",
217
+
},
218
+
{
219
+
name: "complex repo",
220
+
repo: "did:web:example.com:8080/path/to/repo",
221
+
expected: "repos/did_web_example_com_8080_path_to_repo",
222
+
},
223
+
}
224
+
225
+
for _, tt := range tests {
226
+
t.Run(tt.name, func(t *testing.T) {
227
+
result := manager.buildRepoPath(tt.repo)
228
+
assert.Equal(t, tt.expected, result)
229
+
})
230
+
}
231
+
}
232
+
233
+
func TestWithMountPath(t *testing.T) {
234
+
manager := &OpenBaoManager{mountPath: "default"}
235
+
236
+
opt := WithMountPath("custom-mount")
237
+
opt(manager)
238
+
239
+
assert.Equal(t, "custom-mount", manager.mountPath)
240
+
}
241
+
242
+
func TestMockOpenBaoManager_AddSecret(t *testing.T) {
243
+
tests := []struct {
244
+
name string
245
+
secrets []UnlockedSecret
246
+
expectError bool
247
+
}{
248
+
{
249
+
name: "add single secret",
250
+
secrets: []UnlockedSecret{
251
+
createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"),
252
+
},
253
+
expectError: false,
254
+
},
255
+
{
256
+
name: "add multiple secrets",
257
+
secrets: []UnlockedSecret{
258
+
createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"),
259
+
createTestSecretForOpenBao("did:plc:test/repo1", "DB_PASSWORD", "dbpass456", "did:plc:creator"),
260
+
},
261
+
expectError: false,
262
+
},
263
+
{
264
+
name: "add duplicate secret",
265
+
secrets: []UnlockedSecret{
266
+
createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"),
267
+
createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "newsecret", "did:plc:creator"),
268
+
},
269
+
expectError: true,
270
+
},
271
+
}
272
+
273
+
for _, tt := range tests {
274
+
t.Run(tt.name, func(t *testing.T) {
275
+
mock := NewMockOpenBaoManager()
276
+
ctx := context.Background()
277
+
var err error
278
+
279
+
for i, secret := range tt.secrets {
280
+
err = mock.AddSecret(ctx, secret)
281
+
if tt.expectError && i == 1 { // Second secret should fail for duplicate test
282
+
assert.Equal(t, ErrKeyAlreadyPresent, err)
283
+
return
284
+
}
285
+
if !tt.expectError {
286
+
assert.NoError(t, err)
287
+
}
288
+
}
289
+
290
+
if !tt.expectError {
291
+
assert.NoError(t, err)
292
+
}
293
+
})
294
+
}
295
+
}
296
+
297
+
func TestMockOpenBaoManager_RemoveSecret(t *testing.T) {
298
+
tests := []struct {
299
+
name string
300
+
setupSecrets []UnlockedSecret
301
+
removeSecret Secret[any]
302
+
expectError bool
303
+
}{
304
+
{
305
+
name: "remove existing secret",
306
+
setupSecrets: []UnlockedSecret{
307
+
createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"),
308
+
},
309
+
removeSecret: Secret[any]{
310
+
Key: "API_KEY",
311
+
Repo: DidSlashRepo("did:plc:test/repo1"),
312
+
},
313
+
expectError: false,
314
+
},
315
+
{
316
+
name: "remove non-existent secret",
317
+
setupSecrets: []UnlockedSecret{},
318
+
removeSecret: Secret[any]{
319
+
Key: "API_KEY",
320
+
Repo: DidSlashRepo("did:plc:test/repo1"),
321
+
},
322
+
expectError: true,
323
+
},
324
+
}
325
+
326
+
for _, tt := range tests {
327
+
t.Run(tt.name, func(t *testing.T) {
328
+
mock := NewMockOpenBaoManager()
329
+
ctx := context.Background()
330
+
331
+
// Setup secrets
332
+
for _, secret := range tt.setupSecrets {
333
+
err := mock.AddSecret(ctx, secret)
334
+
assert.NoError(t, err)
335
+
}
336
+
337
+
// Remove secret
338
+
err := mock.RemoveSecret(ctx, tt.removeSecret)
339
+
340
+
if tt.expectError {
341
+
assert.Equal(t, ErrKeyNotFound, err)
342
+
} else {
343
+
assert.NoError(t, err)
344
+
}
345
+
})
346
+
}
347
+
}
348
+
349
+
func TestMockOpenBaoManager_GetSecretsLocked(t *testing.T) {
350
+
tests := []struct {
351
+
name string
352
+
setupSecrets []UnlockedSecret
353
+
queryRepo DidSlashRepo
354
+
expectedCount int
355
+
expectedKeys []string
356
+
expectError bool
357
+
}{
358
+
{
359
+
name: "get secrets from repo with secrets",
360
+
setupSecrets: []UnlockedSecret{
361
+
createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"),
362
+
createTestSecretForOpenBao("did:plc:test/repo1", "DB_PASSWORD", "dbpass456", "did:plc:creator"),
363
+
createTestSecretForOpenBao("did:plc:test/repo2", "OTHER_KEY", "other789", "did:plc:creator"),
364
+
},
365
+
queryRepo: DidSlashRepo("did:plc:test/repo1"),
366
+
expectedCount: 2,
367
+
expectedKeys: []string{"API_KEY", "DB_PASSWORD"},
368
+
expectError: false,
369
+
},
370
+
{
371
+
name: "get secrets from empty repo",
372
+
setupSecrets: []UnlockedSecret{},
373
+
queryRepo: DidSlashRepo("did:plc:test/empty"),
374
+
expectedCount: 0,
375
+
expectedKeys: []string{},
376
+
expectError: false,
377
+
},
378
+
}
379
+
380
+
for _, tt := range tests {
381
+
t.Run(tt.name, func(t *testing.T) {
382
+
mock := NewMockOpenBaoManager()
383
+
ctx := context.Background()
384
+
385
+
// Setup
386
+
for _, secret := range tt.setupSecrets {
387
+
err := mock.AddSecret(ctx, secret)
388
+
assert.NoError(t, err)
389
+
}
390
+
391
+
// Test
392
+
secrets, err := mock.GetSecretsLocked(ctx, tt.queryRepo)
393
+
394
+
if tt.expectError {
395
+
assert.Error(t, err)
396
+
} else {
397
+
assert.NoError(t, err)
398
+
assert.Len(t, secrets, tt.expectedCount)
399
+
400
+
// Check keys
401
+
actualKeys := make([]string, len(secrets))
402
+
for i, secret := range secrets {
403
+
actualKeys[i] = secret.Key
404
+
}
405
+
406
+
for _, expectedKey := range tt.expectedKeys {
407
+
assert.Contains(t, actualKeys, expectedKey)
408
+
}
409
+
}
410
+
})
411
+
}
412
+
}
413
+
414
+
func TestMockOpenBaoManager_GetSecretsUnlocked(t *testing.T) {
415
+
tests := []struct {
416
+
name string
417
+
setupSecrets []UnlockedSecret
418
+
queryRepo DidSlashRepo
419
+
expectedCount int
420
+
expectedSecrets map[string]string // key -> value
421
+
expectError bool
422
+
}{
423
+
{
424
+
name: "get unlocked secrets from repo",
425
+
setupSecrets: []UnlockedSecret{
426
+
createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"),
427
+
createTestSecretForOpenBao("did:plc:test/repo1", "DB_PASSWORD", "dbpass456", "did:plc:creator"),
428
+
createTestSecretForOpenBao("did:plc:test/repo2", "OTHER_KEY", "other789", "did:plc:creator"),
429
+
},
430
+
queryRepo: DidSlashRepo("did:plc:test/repo1"),
431
+
expectedCount: 2,
432
+
expectedSecrets: map[string]string{
433
+
"API_KEY": "secret123",
434
+
"DB_PASSWORD": "dbpass456",
435
+
},
436
+
expectError: false,
437
+
},
438
+
{
439
+
name: "get secrets from empty repo",
440
+
setupSecrets: []UnlockedSecret{},
441
+
queryRepo: DidSlashRepo("did:plc:test/empty"),
442
+
expectedCount: 0,
443
+
expectedSecrets: map[string]string{},
444
+
expectError: false,
445
+
},
446
+
}
447
+
448
+
for _, tt := range tests {
449
+
t.Run(tt.name, func(t *testing.T) {
450
+
mock := NewMockOpenBaoManager()
451
+
ctx := context.Background()
452
+
453
+
// Setup
454
+
for _, secret := range tt.setupSecrets {
455
+
err := mock.AddSecret(ctx, secret)
456
+
assert.NoError(t, err)
457
+
}
458
+
459
+
// Test
460
+
secrets, err := mock.GetSecretsUnlocked(ctx, tt.queryRepo)
461
+
462
+
if tt.expectError {
463
+
assert.Error(t, err)
464
+
} else {
465
+
assert.NoError(t, err)
466
+
assert.Len(t, secrets, tt.expectedCount)
467
+
468
+
// Check key-value pairs
469
+
actualSecrets := make(map[string]string)
470
+
for _, secret := range secrets {
471
+
actualSecrets[secret.Key] = secret.Value
472
+
}
473
+
474
+
for expectedKey, expectedValue := range tt.expectedSecrets {
475
+
actualValue, exists := actualSecrets[expectedKey]
476
+
assert.True(t, exists, "Expected key %s not found", expectedKey)
477
+
assert.Equal(t, expectedValue, actualValue)
478
+
}
479
+
}
480
+
})
481
+
}
482
+
}
483
+
484
+
func TestMockOpenBaoManager_ErrorHandling(t *testing.T) {
485
+
mock := NewMockOpenBaoManager()
486
+
ctx := context.Background()
487
+
testError := assert.AnError
488
+
489
+
// Test error injection
490
+
mock.SetError(testError)
491
+
492
+
secret := createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator")
493
+
494
+
// All operations should return the injected error
495
+
err := mock.AddSecret(ctx, secret)
496
+
assert.Equal(t, testError, err)
497
+
498
+
_, err = mock.GetSecretsLocked(ctx, "did:plc:test/repo1")
499
+
assert.Equal(t, testError, err)
500
+
501
+
_, err = mock.GetSecretsUnlocked(ctx, "did:plc:test/repo1")
502
+
assert.Equal(t, testError, err)
503
+
504
+
err = mock.RemoveSecret(ctx, Secret[any]{Key: "API_KEY", Repo: "did:plc:test/repo1"})
505
+
assert.Equal(t, testError, err)
506
+
507
+
// Clear error and test normal operation
508
+
mock.ClearError()
509
+
err = mock.AddSecret(ctx, secret)
510
+
assert.NoError(t, err)
511
+
}
512
+
513
+
func TestMockOpenBaoManager_Integration(t *testing.T) {
514
+
tests := []struct {
515
+
name string
516
+
scenario func(t *testing.T, mock *MockOpenBaoManager)
517
+
}{
518
+
{
519
+
name: "complete workflow",
520
+
scenario: func(t *testing.T, mock *MockOpenBaoManager) {
521
+
ctx := context.Background()
522
+
repo := DidSlashRepo("did:plc:test/integration")
523
+
524
+
// Start with empty repo
525
+
secrets, err := mock.GetSecretsLocked(ctx, repo)
526
+
assert.NoError(t, err)
527
+
assert.Empty(t, secrets)
528
+
529
+
// Add some secrets
530
+
secret1 := createTestSecretForOpenBao(string(repo), "API_KEY", "secret123", "did:plc:creator")
531
+
secret2 := createTestSecretForOpenBao(string(repo), "DB_PASSWORD", "dbpass456", "did:plc:creator")
532
+
533
+
err = mock.AddSecret(ctx, secret1)
534
+
assert.NoError(t, err)
535
+
536
+
err = mock.AddSecret(ctx, secret2)
537
+
assert.NoError(t, err)
538
+
539
+
// Verify secrets exist
540
+
secrets, err = mock.GetSecretsLocked(ctx, repo)
541
+
assert.NoError(t, err)
542
+
assert.Len(t, secrets, 2)
543
+
544
+
unlockedSecrets, err := mock.GetSecretsUnlocked(ctx, repo)
545
+
assert.NoError(t, err)
546
+
assert.Len(t, unlockedSecrets, 2)
547
+
548
+
// Remove one secret
549
+
err = mock.RemoveSecret(ctx, Secret[any]{Key: "API_KEY", Repo: repo})
550
+
assert.NoError(t, err)
551
+
552
+
// Verify only one secret remains
553
+
secrets, err = mock.GetSecretsLocked(ctx, repo)
554
+
assert.NoError(t, err)
555
+
assert.Len(t, secrets, 1)
556
+
assert.Equal(t, "DB_PASSWORD", secrets[0].Key)
557
+
},
558
+
},
559
+
}
560
+
561
+
for _, tt := range tests {
562
+
t.Run(tt.name, func(t *testing.T) {
563
+
mock := NewMockOpenBaoManager()
564
+
tt.scenario(t, mock)
565
+
})
566
+
}
567
+
}
568
+
569
+
func TestOpenBaoManager_ProxyConfiguration(t *testing.T) {
570
+
tests := []struct {
571
+
name string
572
+
proxyAddr string
573
+
description string
574
+
}{
575
+
{
576
+
name: "default_localhost",
577
+
proxyAddr: "http://127.0.0.1:8200",
578
+
description: "Should connect to default localhost proxy",
579
+
},
580
+
{
581
+
name: "custom_host",
582
+
proxyAddr: "http://bao-proxy:8200",
583
+
description: "Should connect to custom proxy host",
584
+
},
585
+
{
586
+
name: "https_proxy",
587
+
proxyAddr: "https://127.0.0.1:8200",
588
+
description: "Should connect to HTTPS proxy",
589
+
},
590
+
}
591
+
592
+
for _, tt := range tests {
593
+
t.Run(tt.name, func(t *testing.T) {
594
+
t.Log("Testing scenario:", tt.description)
595
+
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
596
+
597
+
// All these will fail because no real proxy is running
598
+
// but we can test that the configuration is properly accepted
599
+
manager, err := NewOpenBaoManager(tt.proxyAddr, logger)
600
+
assert.Error(t, err) // Expected because no real proxy
601
+
assert.Nil(t, manager)
602
+
assert.Contains(t, err.Error(), "failed to connect to bao proxy")
603
+
})
604
+
}
605
+
}
+22
spindle/secrets/policy.hcl
+22
spindle/secrets/policy.hcl
···
···
1
+
# Allow full access to the spindle KV mount
2
+
path "spindle/*" {
3
+
capabilities = ["create", "read", "update", "delete", "list"]
4
+
}
5
+
6
+
path "spindle/data/*" {
7
+
capabilities = ["create", "read", "update", "delete"]
8
+
}
9
+
10
+
path "spindle/metadata/*" {
11
+
capabilities = ["list", "read", "delete"]
12
+
}
13
+
14
+
# Allow listing mounts (for connection testing)
15
+
path "sys/mounts" {
16
+
capabilities = ["read"]
17
+
}
18
+
19
+
# Allow token self-lookup (for health checks)
20
+
path "auth/token/lookup-self" {
21
+
capabilities = ["read"]
22
+
}
+172
spindle/secrets/sqlite.go
+172
spindle/secrets/sqlite.go
···
···
1
+
// an sqlite3 backed secret manager
2
+
package secrets
3
+
4
+
import (
5
+
"context"
6
+
"database/sql"
7
+
"fmt"
8
+
"time"
9
+
10
+
_ "github.com/mattn/go-sqlite3"
11
+
)
12
+
13
+
type SqliteManager struct {
14
+
db *sql.DB
15
+
tableName string
16
+
}
17
+
18
+
type SqliteManagerOpt func(*SqliteManager)
19
+
20
+
func WithTableName(name string) SqliteManagerOpt {
21
+
return func(s *SqliteManager) {
22
+
s.tableName = name
23
+
}
24
+
}
25
+
26
+
func NewSQLiteManager(dbPath string, opts ...SqliteManagerOpt) (*SqliteManager, error) {
27
+
db, err := sql.Open("sqlite3", dbPath)
28
+
if err != nil {
29
+
return nil, fmt.Errorf("failed to open sqlite database: %w", err)
30
+
}
31
+
32
+
manager := &SqliteManager{
33
+
db: db,
34
+
tableName: "secrets",
35
+
}
36
+
37
+
for _, o := range opts {
38
+
o(manager)
39
+
}
40
+
41
+
if err := manager.init(); err != nil {
42
+
return nil, err
43
+
}
44
+
45
+
return manager, nil
46
+
}
47
+
48
+
// creates a table and sets up the schema, migrations if any can go here
49
+
func (s *SqliteManager) init() error {
50
+
createTable :=
51
+
`create table if not exists ` + s.tableName + `(
52
+
id integer primary key autoincrement,
53
+
repo text not null,
54
+
key text not null,
55
+
value text not null,
56
+
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
57
+
created_by text not null,
58
+
59
+
unique(repo, key)
60
+
);`
61
+
_, err := s.db.Exec(createTable)
62
+
return err
63
+
}
64
+
65
+
func (s *SqliteManager) AddSecret(ctx context.Context, secret UnlockedSecret) error {
66
+
query := fmt.Sprintf(`
67
+
insert or ignore into %s (repo, key, value, created_by)
68
+
values (?, ?, ?, ?);
69
+
`, s.tableName)
70
+
71
+
res, err := s.db.ExecContext(ctx, query, secret.Repo, secret.Key, secret.Value, secret.CreatedBy)
72
+
if err != nil {
73
+
return err
74
+
}
75
+
76
+
num, err := res.RowsAffected()
77
+
if err != nil {
78
+
return err
79
+
}
80
+
81
+
if num == 0 {
82
+
return ErrKeyAlreadyPresent
83
+
}
84
+
85
+
return nil
86
+
}
87
+
88
+
func (s *SqliteManager) RemoveSecret(ctx context.Context, secret Secret[any]) error {
89
+
query := fmt.Sprintf(`
90
+
delete from %s where repo = ? and key = ?;
91
+
`, s.tableName)
92
+
93
+
res, err := s.db.ExecContext(ctx, query, secret.Repo, secret.Key)
94
+
if err != nil {
95
+
return err
96
+
}
97
+
98
+
num, err := res.RowsAffected()
99
+
if err != nil {
100
+
return err
101
+
}
102
+
103
+
if num == 0 {
104
+
return ErrKeyNotFound
105
+
}
106
+
107
+
return nil
108
+
}
109
+
110
+
func (s *SqliteManager) GetSecretsLocked(ctx context.Context, didSlashRepo DidSlashRepo) ([]LockedSecret, error) {
111
+
query := fmt.Sprintf(`
112
+
select repo, key, created_at, created_by from %s where repo = ?;
113
+
`, s.tableName)
114
+
115
+
rows, err := s.db.QueryContext(ctx, query, didSlashRepo)
116
+
if err != nil {
117
+
return nil, err
118
+
}
119
+
120
+
var ls []LockedSecret
121
+
for rows.Next() {
122
+
var l LockedSecret
123
+
var createdAt string
124
+
if err = rows.Scan(&l.Repo, &l.Key, &createdAt, &l.CreatedBy); err != nil {
125
+
return nil, err
126
+
}
127
+
128
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
129
+
l.CreatedAt = t
130
+
}
131
+
132
+
ls = append(ls, l)
133
+
}
134
+
135
+
if err = rows.Err(); err != nil {
136
+
return nil, err
137
+
}
138
+
139
+
return ls, nil
140
+
}
141
+
142
+
func (s *SqliteManager) GetSecretsUnlocked(ctx context.Context, didSlashRepo DidSlashRepo) ([]UnlockedSecret, error) {
143
+
query := fmt.Sprintf(`
144
+
select repo, key, value, created_at, created_by from %s where repo = ?;
145
+
`, s.tableName)
146
+
147
+
rows, err := s.db.QueryContext(ctx, query, didSlashRepo)
148
+
if err != nil {
149
+
return nil, err
150
+
}
151
+
152
+
var ls []UnlockedSecret
153
+
for rows.Next() {
154
+
var l UnlockedSecret
155
+
var createdAt string
156
+
if err = rows.Scan(&l.Repo, &l.Key, &l.Value, &createdAt, &l.CreatedBy); err != nil {
157
+
return nil, err
158
+
}
159
+
160
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
161
+
l.CreatedAt = t
162
+
}
163
+
164
+
ls = append(ls, l)
165
+
}
166
+
167
+
if err = rows.Err(); err != nil {
168
+
return nil, err
169
+
}
170
+
171
+
return ls, nil
172
+
}
+590
spindle/secrets/sqlite_test.go
+590
spindle/secrets/sqlite_test.go
···
···
1
+
package secrets
2
+
3
+
import (
4
+
"context"
5
+
"testing"
6
+
"time"
7
+
8
+
"github.com/alecthomas/assert/v2"
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
)
11
+
12
+
func createInMemoryDB(t *testing.T) *SqliteManager {
13
+
t.Helper()
14
+
manager, err := NewSQLiteManager(":memory:")
15
+
if err != nil {
16
+
t.Fatalf("Failed to create in-memory manager: %v", err)
17
+
}
18
+
return manager
19
+
}
20
+
21
+
func createTestSecret(repo, key, value, createdBy string) UnlockedSecret {
22
+
return UnlockedSecret{
23
+
Key: key,
24
+
Value: value,
25
+
Repo: DidSlashRepo(repo),
26
+
CreatedAt: time.Now(),
27
+
CreatedBy: syntax.DID(createdBy),
28
+
}
29
+
}
30
+
31
+
// ensure that interface is satisfied
32
+
func TestManagerInterface(t *testing.T) {
33
+
var _ Manager = (*SqliteManager)(nil)
34
+
}
35
+
36
+
func TestNewSQLiteManager(t *testing.T) {
37
+
tests := []struct {
38
+
name string
39
+
dbPath string
40
+
opts []SqliteManagerOpt
41
+
expectError bool
42
+
expectTable string
43
+
}{
44
+
{
45
+
name: "default table name",
46
+
dbPath: ":memory:",
47
+
opts: nil,
48
+
expectError: false,
49
+
expectTable: "secrets",
50
+
},
51
+
{
52
+
name: "custom table name",
53
+
dbPath: ":memory:",
54
+
opts: []SqliteManagerOpt{WithTableName("custom_secrets")},
55
+
expectError: false,
56
+
expectTable: "custom_secrets",
57
+
},
58
+
{
59
+
name: "invalid database path",
60
+
dbPath: "/invalid/path/to/database.db",
61
+
opts: nil,
62
+
expectError: true,
63
+
expectTable: "",
64
+
},
65
+
}
66
+
67
+
for _, tt := range tests {
68
+
t.Run(tt.name, func(t *testing.T) {
69
+
manager, err := NewSQLiteManager(tt.dbPath, tt.opts...)
70
+
if tt.expectError {
71
+
if err == nil {
72
+
t.Error("Expected error but got none")
73
+
}
74
+
return
75
+
}
76
+
77
+
if err != nil {
78
+
t.Fatalf("Unexpected error: %v", err)
79
+
}
80
+
defer manager.db.Close()
81
+
82
+
if manager.tableName != tt.expectTable {
83
+
t.Errorf("Expected table name %s, got %s", tt.expectTable, manager.tableName)
84
+
}
85
+
})
86
+
}
87
+
}
88
+
89
+
func TestSqliteManager_AddSecret(t *testing.T) {
90
+
tests := []struct {
91
+
name string
92
+
secrets []UnlockedSecret
93
+
expectError []error
94
+
}{
95
+
{
96
+
name: "add single secret",
97
+
secrets: []UnlockedSecret{
98
+
createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"),
99
+
},
100
+
expectError: []error{nil},
101
+
},
102
+
{
103
+
name: "add multiple unique secrets",
104
+
secrets: []UnlockedSecret{
105
+
createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"),
106
+
createTestSecret("did:plc:foo/repo", "db_password", "password_456", "did:plc:example123"),
107
+
createTestSecret("other.com/repo", "api_key", "other_secret", "did:plc:other"),
108
+
},
109
+
expectError: []error{nil, nil, nil},
110
+
},
111
+
{
112
+
name: "add duplicate secret",
113
+
secrets: []UnlockedSecret{
114
+
createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"),
115
+
createTestSecret("did:plc:foo/repo", "api_key", "different_value", "did:plc:example123"),
116
+
},
117
+
expectError: []error{nil, ErrKeyAlreadyPresent},
118
+
},
119
+
}
120
+
121
+
for _, tt := range tests {
122
+
t.Run(tt.name, func(t *testing.T) {
123
+
manager := createInMemoryDB(t)
124
+
defer manager.db.Close()
125
+
126
+
for i, secret := range tt.secrets {
127
+
err := manager.AddSecret(context.Background(), secret)
128
+
if err != tt.expectError[i] {
129
+
t.Errorf("Secret %d: expected error %v, got %v", i, tt.expectError[i], err)
130
+
}
131
+
}
132
+
})
133
+
}
134
+
}
135
+
136
+
func TestSqliteManager_RemoveSecret(t *testing.T) {
137
+
tests := []struct {
138
+
name string
139
+
setupSecrets []UnlockedSecret
140
+
removeSecret Secret[any]
141
+
expectError error
142
+
}{
143
+
{
144
+
name: "remove existing secret",
145
+
setupSecrets: []UnlockedSecret{
146
+
createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"),
147
+
},
148
+
removeSecret: Secret[any]{
149
+
Key: "api_key",
150
+
Repo: DidSlashRepo("did:plc:foo/repo"),
151
+
},
152
+
expectError: nil,
153
+
},
154
+
{
155
+
name: "remove non-existent secret",
156
+
setupSecrets: []UnlockedSecret{
157
+
createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"),
158
+
},
159
+
removeSecret: Secret[any]{
160
+
Key: "non_existent_key",
161
+
Repo: DidSlashRepo("did:plc:foo/repo"),
162
+
},
163
+
expectError: ErrKeyNotFound,
164
+
},
165
+
{
166
+
name: "remove from empty database",
167
+
setupSecrets: []UnlockedSecret{},
168
+
removeSecret: Secret[any]{
169
+
Key: "any_key",
170
+
Repo: DidSlashRepo("did:plc:foo/repo"),
171
+
},
172
+
expectError: ErrKeyNotFound,
173
+
},
174
+
{
175
+
name: "remove secret from wrong repo",
176
+
setupSecrets: []UnlockedSecret{
177
+
createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"),
178
+
},
179
+
removeSecret: Secret[any]{
180
+
Key: "api_key",
181
+
Repo: DidSlashRepo("other.com/repo"),
182
+
},
183
+
expectError: ErrKeyNotFound,
184
+
},
185
+
}
186
+
187
+
for _, tt := range tests {
188
+
t.Run(tt.name, func(t *testing.T) {
189
+
manager := createInMemoryDB(t)
190
+
defer manager.db.Close()
191
+
192
+
// Setup secrets
193
+
for _, secret := range tt.setupSecrets {
194
+
if err := manager.AddSecret(context.Background(), secret); err != nil {
195
+
t.Fatalf("Failed to setup secret: %v", err)
196
+
}
197
+
}
198
+
199
+
// Test removal
200
+
err := manager.RemoveSecret(context.Background(), tt.removeSecret)
201
+
if err != tt.expectError {
202
+
t.Errorf("Expected error %v, got %v", tt.expectError, err)
203
+
}
204
+
})
205
+
}
206
+
}
207
+
208
+
func TestSqliteManager_GetSecretsLocked(t *testing.T) {
209
+
tests := []struct {
210
+
name string
211
+
setupSecrets []UnlockedSecret
212
+
queryRepo DidSlashRepo
213
+
expectedCount int
214
+
expectedKeys []string
215
+
expectError bool
216
+
}{
217
+
{
218
+
name: "get secrets for repo with multiple secrets",
219
+
setupSecrets: []UnlockedSecret{
220
+
createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"),
221
+
createTestSecret("did:plc:foo/repo", "key2", "value2", "did:plc:user2"),
222
+
createTestSecret("other.com/repo", "key3", "value3", "did:plc:user3"),
223
+
},
224
+
queryRepo: DidSlashRepo("did:plc:foo/repo"),
225
+
expectedCount: 2,
226
+
expectedKeys: []string{"key1", "key2"},
227
+
expectError: false,
228
+
},
229
+
{
230
+
name: "get secrets for repo with single secret",
231
+
setupSecrets: []UnlockedSecret{
232
+
createTestSecret("did:plc:foo/repo", "single_key", "single_value", "did:plc:user1"),
233
+
createTestSecret("other.com/repo", "other_key", "other_value", "did:plc:user2"),
234
+
},
235
+
queryRepo: DidSlashRepo("did:plc:foo/repo"),
236
+
expectedCount: 1,
237
+
expectedKeys: []string{"single_key"},
238
+
expectError: false,
239
+
},
240
+
{
241
+
name: "get secrets for non-existent repo",
242
+
setupSecrets: []UnlockedSecret{
243
+
createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"),
244
+
},
245
+
queryRepo: DidSlashRepo("nonexistent.com/repo"),
246
+
expectedCount: 0,
247
+
expectedKeys: []string{},
248
+
expectError: false,
249
+
},
250
+
{
251
+
name: "get secrets from empty database",
252
+
setupSecrets: []UnlockedSecret{},
253
+
queryRepo: DidSlashRepo("did:plc:foo/repo"),
254
+
expectedCount: 0,
255
+
expectedKeys: []string{},
256
+
expectError: false,
257
+
},
258
+
}
259
+
260
+
for _, tt := range tests {
261
+
t.Run(tt.name, func(t *testing.T) {
262
+
manager := createInMemoryDB(t)
263
+
defer manager.db.Close()
264
+
265
+
// Setup secrets
266
+
for _, secret := range tt.setupSecrets {
267
+
if err := manager.AddSecret(context.Background(), secret); err != nil {
268
+
t.Fatalf("Failed to setup secret: %v", err)
269
+
}
270
+
}
271
+
272
+
// Test getting locked secrets
273
+
lockedSecrets, err := manager.GetSecretsLocked(context.Background(), tt.queryRepo)
274
+
if tt.expectError && err == nil {
275
+
t.Error("Expected error but got none")
276
+
return
277
+
}
278
+
if !tt.expectError && err != nil {
279
+
t.Fatalf("Unexpected error: %v", err)
280
+
}
281
+
282
+
if len(lockedSecrets) != tt.expectedCount {
283
+
t.Errorf("Expected %d secrets, got %d", tt.expectedCount, len(lockedSecrets))
284
+
}
285
+
286
+
// Verify keys and that values are not present (locked)
287
+
foundKeys := make(map[string]bool)
288
+
for _, ls := range lockedSecrets {
289
+
foundKeys[ls.Key] = true
290
+
if ls.Repo != tt.queryRepo {
291
+
t.Errorf("Expected repo %s, got %s", tt.queryRepo, ls.Repo)
292
+
}
293
+
if ls.CreatedBy == "" {
294
+
t.Error("Expected CreatedBy to be present")
295
+
}
296
+
if ls.CreatedAt.IsZero() {
297
+
t.Error("Expected CreatedAt to be set")
298
+
}
299
+
}
300
+
301
+
for _, expectedKey := range tt.expectedKeys {
302
+
if !foundKeys[expectedKey] {
303
+
t.Errorf("Expected key %s not found", expectedKey)
304
+
}
305
+
}
306
+
})
307
+
}
308
+
}
309
+
310
+
func TestSqliteManager_GetSecretsUnlocked(t *testing.T) {
311
+
tests := []struct {
312
+
name string
313
+
setupSecrets []UnlockedSecret
314
+
queryRepo DidSlashRepo
315
+
expectedCount int
316
+
expectedSecrets map[string]string // key -> value
317
+
expectError bool
318
+
}{
319
+
{
320
+
name: "get unlocked secrets for repo with multiple secrets",
321
+
setupSecrets: []UnlockedSecret{
322
+
createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"),
323
+
createTestSecret("did:plc:foo/repo", "key2", "value2", "did:plc:user2"),
324
+
createTestSecret("other.com/repo", "key3", "value3", "did:plc:user3"),
325
+
},
326
+
queryRepo: DidSlashRepo("did:plc:foo/repo"),
327
+
expectedCount: 2,
328
+
expectedSecrets: map[string]string{
329
+
"key1": "value1",
330
+
"key2": "value2",
331
+
},
332
+
expectError: false,
333
+
},
334
+
{
335
+
name: "get unlocked secrets for repo with single secret",
336
+
setupSecrets: []UnlockedSecret{
337
+
createTestSecret("did:plc:foo/repo", "single_key", "single_value", "did:plc:user1"),
338
+
createTestSecret("other.com/repo", "other_key", "other_value", "did:plc:user2"),
339
+
},
340
+
queryRepo: DidSlashRepo("did:plc:foo/repo"),
341
+
expectedCount: 1,
342
+
expectedSecrets: map[string]string{
343
+
"single_key": "single_value",
344
+
},
345
+
expectError: false,
346
+
},
347
+
{
348
+
name: "get unlocked secrets for non-existent repo",
349
+
setupSecrets: []UnlockedSecret{
350
+
createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"),
351
+
},
352
+
queryRepo: DidSlashRepo("nonexistent.com/repo"),
353
+
expectedCount: 0,
354
+
expectedSecrets: map[string]string{},
355
+
expectError: false,
356
+
},
357
+
{
358
+
name: "get unlocked secrets from empty database",
359
+
setupSecrets: []UnlockedSecret{},
360
+
queryRepo: DidSlashRepo("did:plc:foo/repo"),
361
+
expectedCount: 0,
362
+
expectedSecrets: map[string]string{},
363
+
expectError: false,
364
+
},
365
+
}
366
+
367
+
for _, tt := range tests {
368
+
t.Run(tt.name, func(t *testing.T) {
369
+
manager := createInMemoryDB(t)
370
+
defer manager.db.Close()
371
+
372
+
// Setup secrets
373
+
for _, secret := range tt.setupSecrets {
374
+
if err := manager.AddSecret(context.Background(), secret); err != nil {
375
+
t.Fatalf("Failed to setup secret: %v", err)
376
+
}
377
+
}
378
+
379
+
// Test getting unlocked secrets
380
+
unlockedSecrets, err := manager.GetSecretsUnlocked(context.Background(), tt.queryRepo)
381
+
if tt.expectError && err == nil {
382
+
t.Error("Expected error but got none")
383
+
return
384
+
}
385
+
if !tt.expectError && err != nil {
386
+
t.Fatalf("Unexpected error: %v", err)
387
+
}
388
+
389
+
if len(unlockedSecrets) != tt.expectedCount {
390
+
t.Errorf("Expected %d secrets, got %d", tt.expectedCount, len(unlockedSecrets))
391
+
}
392
+
393
+
// Verify keys, values, and metadata
394
+
for _, us := range unlockedSecrets {
395
+
expectedValue, exists := tt.expectedSecrets[us.Key]
396
+
if !exists {
397
+
t.Errorf("Unexpected key: %s", us.Key)
398
+
continue
399
+
}
400
+
if us.Value != expectedValue {
401
+
t.Errorf("Expected value %s for key %s, got %s", expectedValue, us.Key, us.Value)
402
+
}
403
+
if us.Repo != tt.queryRepo {
404
+
t.Errorf("Expected repo %s, got %s", tt.queryRepo, us.Repo)
405
+
}
406
+
if us.CreatedBy == "" {
407
+
t.Error("Expected CreatedBy to be present")
408
+
}
409
+
if us.CreatedAt.IsZero() {
410
+
t.Error("Expected CreatedAt to be set")
411
+
}
412
+
}
413
+
})
414
+
}
415
+
}
416
+
417
+
// Test that demonstrates interface usage with table-driven tests
418
+
func TestManagerInterface_Usage(t *testing.T) {
419
+
tests := []struct {
420
+
name string
421
+
operations []func(Manager) error
422
+
expectError bool
423
+
}{
424
+
{
425
+
name: "successful workflow",
426
+
operations: []func(Manager) error{
427
+
func(m Manager) error {
428
+
secret := createTestSecret("interface.test/repo", "test_key", "test_value", "did:plc:user")
429
+
return m.AddSecret(context.Background(), secret)
430
+
},
431
+
func(m Manager) error {
432
+
_, err := m.GetSecretsLocked(context.Background(), DidSlashRepo("interface.test/repo"))
433
+
return err
434
+
},
435
+
func(m Manager) error {
436
+
_, err := m.GetSecretsUnlocked(context.Background(), DidSlashRepo("interface.test/repo"))
437
+
return err
438
+
},
439
+
func(m Manager) error {
440
+
secret := Secret[any]{
441
+
Key: "test_key",
442
+
Repo: DidSlashRepo("interface.test/repo"),
443
+
}
444
+
return m.RemoveSecret(context.Background(), secret)
445
+
},
446
+
},
447
+
expectError: false,
448
+
},
449
+
{
450
+
name: "error on duplicate key",
451
+
operations: []func(Manager) error{
452
+
func(m Manager) error {
453
+
secret := createTestSecret("interface.test/repo", "dup_key", "value1", "did:plc:user")
454
+
return m.AddSecret(context.Background(), secret)
455
+
},
456
+
func(m Manager) error {
457
+
secret := createTestSecret("interface.test/repo", "dup_key", "value2", "did:plc:user")
458
+
return m.AddSecret(context.Background(), secret) // Should return ErrKeyAlreadyPresent
459
+
},
460
+
},
461
+
expectError: true,
462
+
},
463
+
}
464
+
465
+
for _, tt := range tests {
466
+
t.Run(tt.name, func(t *testing.T) {
467
+
var manager Manager = createInMemoryDB(t)
468
+
defer func() {
469
+
if sqliteManager, ok := manager.(*SqliteManager); ok {
470
+
sqliteManager.db.Close()
471
+
}
472
+
}()
473
+
474
+
var finalErr error
475
+
for i, operation := range tt.operations {
476
+
if err := operation(manager); err != nil {
477
+
finalErr = err
478
+
t.Logf("Operation %d returned error: %v", i, err)
479
+
}
480
+
}
481
+
482
+
if tt.expectError && finalErr == nil {
483
+
t.Error("Expected error but got none")
484
+
}
485
+
if !tt.expectError && finalErr != nil {
486
+
t.Errorf("Unexpected error: %v", finalErr)
487
+
}
488
+
})
489
+
}
490
+
}
491
+
492
+
// Integration test with table-driven scenarios
493
+
func TestSqliteManager_Integration(t *testing.T) {
494
+
tests := []struct {
495
+
name string
496
+
scenario func(*testing.T, *SqliteManager)
497
+
}{
498
+
{
499
+
name: "multi-repo secret management",
500
+
scenario: func(t *testing.T, manager *SqliteManager) {
501
+
repo1 := DidSlashRepo("example1.com/repo")
502
+
repo2 := DidSlashRepo("example2.com/repo")
503
+
504
+
secrets := []UnlockedSecret{
505
+
createTestSecret(string(repo1), "db_password", "super_secret_123", "did:plc:admin"),
506
+
createTestSecret(string(repo1), "api_key", "api_key_456", "did:plc:user1"),
507
+
createTestSecret(string(repo2), "token", "bearer_token_789", "did:plc:user2"),
508
+
}
509
+
510
+
// Add all secrets
511
+
for _, secret := range secrets {
512
+
if err := manager.AddSecret(context.Background(), secret); err != nil {
513
+
t.Fatalf("Failed to add secret %s: %v", secret.Key, err)
514
+
}
515
+
}
516
+
517
+
// Verify counts
518
+
locked1, _ := manager.GetSecretsLocked(context.Background(), repo1)
519
+
locked2, _ := manager.GetSecretsLocked(context.Background(), repo2)
520
+
521
+
if len(locked1) != 2 {
522
+
t.Errorf("Expected 2 secrets for repo1, got %d", len(locked1))
523
+
}
524
+
if len(locked2) != 1 {
525
+
t.Errorf("Expected 1 secret for repo2, got %d", len(locked2))
526
+
}
527
+
528
+
// Remove and verify
529
+
secretToRemove := Secret[any]{Key: "db_password", Repo: repo1}
530
+
if err := manager.RemoveSecret(context.Background(), secretToRemove); err != nil {
531
+
t.Fatalf("Failed to remove secret: %v", err)
532
+
}
533
+
534
+
locked1After, _ := manager.GetSecretsLocked(context.Background(), repo1)
535
+
if len(locked1After) != 1 {
536
+
t.Errorf("Expected 1 secret for repo1 after removal, got %d", len(locked1After))
537
+
}
538
+
if locked1After[0].Key != "api_key" {
539
+
t.Errorf("Expected remaining secret to be 'api_key', got %s", locked1After[0].Key)
540
+
}
541
+
},
542
+
},
543
+
{
544
+
name: "empty database operations",
545
+
scenario: func(t *testing.T, manager *SqliteManager) {
546
+
repo := DidSlashRepo("empty.test/repo")
547
+
548
+
// Operations on empty database should not error
549
+
locked, err := manager.GetSecretsLocked(context.Background(), repo)
550
+
if err != nil {
551
+
t.Errorf("GetSecretsLocked on empty DB failed: %v", err)
552
+
}
553
+
if len(locked) != 0 {
554
+
t.Errorf("Expected 0 secrets, got %d", len(locked))
555
+
}
556
+
557
+
unlocked, err := manager.GetSecretsUnlocked(context.Background(), repo)
558
+
if err != nil {
559
+
t.Errorf("GetSecretsUnlocked on empty DB failed: %v", err)
560
+
}
561
+
if len(unlocked) != 0 {
562
+
t.Errorf("Expected 0 secrets, got %d", len(unlocked))
563
+
}
564
+
565
+
// Remove from empty should return ErrKeyNotFound
566
+
nonExistent := Secret[any]{Key: "none", Repo: repo}
567
+
err = manager.RemoveSecret(context.Background(), nonExistent)
568
+
if err != ErrKeyNotFound {
569
+
t.Errorf("Expected ErrKeyNotFound, got %v", err)
570
+
}
571
+
},
572
+
},
573
+
}
574
+
575
+
for _, tt := range tests {
576
+
t.Run(tt.name, func(t *testing.T) {
577
+
manager := createInMemoryDB(t)
578
+
defer manager.db.Close()
579
+
tt.scenario(t, manager)
580
+
})
581
+
}
582
+
}
583
+
584
+
func TestSqliteManager_StopperInterface(t *testing.T) {
585
+
manager := &SqliteManager{}
586
+
587
+
// Verify that SqliteManager does NOT implement the Stopper interface
588
+
_, ok := interface{}(manager).(Stopper)
589
+
assert.False(t, ok, "SqliteManager should NOT implement Stopper interface")
590
+
}
+81
-42
spindle/server.go
+81
-42
spindle/server.go
···
2
3
import (
4
"context"
5
"encoding/json"
6
"fmt"
7
"log/slog"
···
11
"tangled.sh/tangled.sh/core/api/tangled"
12
"tangled.sh/tangled.sh/core/eventconsumer"
13
"tangled.sh/tangled.sh/core/eventconsumer/cursor"
14
"tangled.sh/tangled.sh/core/jetstream"
15
"tangled.sh/tangled.sh/core/log"
16
"tangled.sh/tangled.sh/core/notifier"
···
20
"tangled.sh/tangled.sh/core/spindle/engine"
21
"tangled.sh/tangled.sh/core/spindle/models"
22
"tangled.sh/tangled.sh/core/spindle/queue"
23
)
24
25
const (
26
rbacDomain = "thisserver"
27
)
28
29
type Spindle struct {
30
-
jc *jetstream.JetstreamClient
31
-
db *db.DB
32
-
e *rbac.Enforcer
33
-
l *slog.Logger
34
-
n *notifier.Notifier
35
-
eng *engine.Engine
36
-
jq *queue.Queue
37
-
cfg *config.Config
38
-
ks *eventconsumer.Consumer
39
}
40
41
func Run(ctx context.Context) error {
···
59
60
n := notifier.New()
61
62
-
eng, err := engine.New(ctx, cfg, d, &n)
63
if err != nil {
64
return err
65
}
···
69
collections := []string{
70
tangled.SpindleMemberNSID,
71
tangled.RepoNSID,
72
}
73
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true)
74
if err != nil {
···
76
}
77
jc.AddDid(cfg.Server.Owner)
78
79
spindle := Spindle{
80
-
jc: jc,
81
-
e: e,
82
-
db: d,
83
-
l: logger,
84
-
n: &n,
85
-
eng: eng,
86
-
jq: jq,
87
-
cfg: cfg,
88
}
89
90
err = e.AddSpindle(rbacDomain)
···
100
// starts a job queue runner in the background
101
jq.Start()
102
defer jq.Stop()
103
104
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
105
if err != nil {
···
144
mux := chi.NewRouter()
145
146
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
147
-
w.Write([]byte(
148
-
` ****
149
-
*** ***
150
-
*** ** ****** **
151
-
** * *****
152
-
* ** **
153
-
* * * ***************
154
-
** ** *# **
155
-
* ** ** *** **
156
-
* * ** ** * ******
157
-
* ** ** * ** * *
158
-
** ** *** ** ** *
159
-
** ** * ** * *
160
-
** **** ** * *
161
-
** *** ** ** **
162
-
*** ** *****
163
-
********************
164
-
**
165
-
*
166
-
#**************
167
-
**
168
-
********
169
-
170
-
This is a spindle server. More info at https://tangled.sh/@tangled.sh/core/tree/master/docs/spindle`))
171
})
172
mux.HandleFunc("/events", s.Events)
173
mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) {
174
w.Write([]byte(s.cfg.Server.Owner))
175
})
176
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
177
return mux
178
}
179
180
func (s *Spindle) processPipeline(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error {
···
2
3
import (
4
"context"
5
+
_ "embed"
6
"encoding/json"
7
"fmt"
8
"log/slog"
···
12
"tangled.sh/tangled.sh/core/api/tangled"
13
"tangled.sh/tangled.sh/core/eventconsumer"
14
"tangled.sh/tangled.sh/core/eventconsumer/cursor"
15
+
"tangled.sh/tangled.sh/core/idresolver"
16
"tangled.sh/tangled.sh/core/jetstream"
17
"tangled.sh/tangled.sh/core/log"
18
"tangled.sh/tangled.sh/core/notifier"
···
22
"tangled.sh/tangled.sh/core/spindle/engine"
23
"tangled.sh/tangled.sh/core/spindle/models"
24
"tangled.sh/tangled.sh/core/spindle/queue"
25
+
"tangled.sh/tangled.sh/core/spindle/secrets"
26
+
"tangled.sh/tangled.sh/core/spindle/xrpc"
27
)
28
29
+
//go:embed motd
30
+
var motd []byte
31
+
32
const (
33
rbacDomain = "thisserver"
34
)
35
36
type Spindle struct {
37
+
jc *jetstream.JetstreamClient
38
+
db *db.DB
39
+
e *rbac.Enforcer
40
+
l *slog.Logger
41
+
n *notifier.Notifier
42
+
eng *engine.Engine
43
+
jq *queue.Queue
44
+
cfg *config.Config
45
+
ks *eventconsumer.Consumer
46
+
res *idresolver.Resolver
47
+
vault secrets.Manager
48
}
49
50
func Run(ctx context.Context) error {
···
68
69
n := notifier.New()
70
71
+
var vault secrets.Manager
72
+
switch cfg.Server.Secrets.Provider {
73
+
case "openbao":
74
+
if cfg.Server.Secrets.OpenBao.ProxyAddr == "" {
75
+
return fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
76
+
}
77
+
vault, err = secrets.NewOpenBaoManager(
78
+
cfg.Server.Secrets.OpenBao.ProxyAddr,
79
+
logger,
80
+
secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount),
81
+
)
82
+
if err != nil {
83
+
return fmt.Errorf("failed to setup openbao secrets provider: %w", err)
84
+
}
85
+
logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount)
86
+
case "sqlite", "":
87
+
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
88
+
if err != nil {
89
+
return fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
90
+
}
91
+
logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath)
92
+
default:
93
+
return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
94
+
}
95
+
96
+
eng, err := engine.New(ctx, cfg, d, &n, vault)
97
if err != nil {
98
return err
99
}
···
103
collections := []string{
104
tangled.SpindleMemberNSID,
105
tangled.RepoNSID,
106
+
tangled.RepoCollaboratorNSID,
107
}
108
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true)
109
if err != nil {
···
111
}
112
jc.AddDid(cfg.Server.Owner)
113
114
+
resolver := idresolver.DefaultResolver()
115
+
116
spindle := Spindle{
117
+
jc: jc,
118
+
e: e,
119
+
db: d,
120
+
l: logger,
121
+
n: &n,
122
+
eng: eng,
123
+
jq: jq,
124
+
cfg: cfg,
125
+
res: resolver,
126
+
vault: vault,
127
}
128
129
err = e.AddSpindle(rbacDomain)
···
139
// starts a job queue runner in the background
140
jq.Start()
141
defer jq.Stop()
142
+
143
+
// Stop vault token renewal if it implements Stopper
144
+
if stopper, ok := vault.(secrets.Stopper); ok {
145
+
defer stopper.Stop()
146
+
}
147
148
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
149
if err != nil {
···
188
mux := chi.NewRouter()
189
190
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
191
+
w.Write(motd)
192
})
193
mux.HandleFunc("/events", s.Events)
194
mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) {
195
w.Write([]byte(s.cfg.Server.Owner))
196
})
197
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
198
+
199
+
mux.Mount("/xrpc", s.XrpcRouter())
200
return mux
201
+
}
202
+
203
+
func (s *Spindle) XrpcRouter() http.Handler {
204
+
logger := s.l.With("route", "xrpc")
205
+
206
+
x := xrpc.Xrpc{
207
+
Logger: logger,
208
+
Db: s.db,
209
+
Enforcer: s.e,
210
+
Engine: s.eng,
211
+
Config: s.cfg,
212
+
Resolver: s.res,
213
+
Vault: s.vault,
214
+
}
215
+
216
+
return x.Router()
217
}
218
219
func (s *Spindle) processPipeline(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error {
+91
spindle/xrpc/add_secret.go
+91
spindle/xrpc/add_secret.go
···
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
"time"
8
+
9
+
"github.com/bluesky-social/indigo/api/atproto"
10
+
"github.com/bluesky-social/indigo/atproto/syntax"
11
+
"github.com/bluesky-social/indigo/xrpc"
12
+
securejoin "github.com/cyphar/filepath-securejoin"
13
+
"tangled.sh/tangled.sh/core/api/tangled"
14
+
"tangled.sh/tangled.sh/core/rbac"
15
+
"tangled.sh/tangled.sh/core/spindle/secrets"
16
+
)
17
+
18
+
func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) {
19
+
l := x.Logger
20
+
fail := func(e XrpcError) {
21
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
22
+
writeError(w, e, http.StatusBadRequest)
23
+
}
24
+
25
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
26
+
if !ok {
27
+
fail(MissingActorDidError)
28
+
return
29
+
}
30
+
31
+
var data tangled.RepoAddSecret_Input
32
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
33
+
fail(GenericError(err))
34
+
return
35
+
}
36
+
37
+
if err := secrets.ValidateKey(data.Key); err != nil {
38
+
fail(GenericError(err))
39
+
return
40
+
}
41
+
42
+
// unfortunately we have to resolve repo-at here
43
+
repoAt, err := syntax.ParseATURI(data.Repo)
44
+
if err != nil {
45
+
fail(InvalidRepoError(data.Repo))
46
+
return
47
+
}
48
+
49
+
// resolve this aturi to extract the repo record
50
+
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
51
+
if err != nil || ident.Handle.IsInvalidHandle() {
52
+
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
53
+
return
54
+
}
55
+
56
+
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
57
+
resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
58
+
if err != nil {
59
+
fail(GenericError(err))
60
+
return
61
+
}
62
+
63
+
repo := resp.Value.Val.(*tangled.Repo)
64
+
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
65
+
if err != nil {
66
+
fail(GenericError(err))
67
+
return
68
+
}
69
+
70
+
if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
71
+
l.Error("insufficent permissions", "did", actorDid.String())
72
+
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
73
+
return
74
+
}
75
+
76
+
secret := secrets.UnlockedSecret{
77
+
Repo: secrets.DidSlashRepo(didPath),
78
+
Key: data.Key,
79
+
Value: data.Value,
80
+
CreatedAt: time.Now(),
81
+
CreatedBy: actorDid,
82
+
}
83
+
err = x.Vault.AddSecret(r.Context(), secret)
84
+
if err != nil {
85
+
l.Error("failed to add secret to vault", "did", actorDid.String(), "err", err)
86
+
writeError(w, GenericError(err), http.StatusInternalServerError)
87
+
return
88
+
}
89
+
90
+
w.WriteHeader(http.StatusOK)
91
+
}
+91
spindle/xrpc/list_secrets.go
+91
spindle/xrpc/list_secrets.go
···
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
"time"
8
+
9
+
"github.com/bluesky-social/indigo/api/atproto"
10
+
"github.com/bluesky-social/indigo/atproto/syntax"
11
+
"github.com/bluesky-social/indigo/xrpc"
12
+
securejoin "github.com/cyphar/filepath-securejoin"
13
+
"tangled.sh/tangled.sh/core/api/tangled"
14
+
"tangled.sh/tangled.sh/core/rbac"
15
+
"tangled.sh/tangled.sh/core/spindle/secrets"
16
+
)
17
+
18
+
func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) {
19
+
l := x.Logger
20
+
fail := func(e XrpcError) {
21
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
22
+
writeError(w, e, http.StatusBadRequest)
23
+
}
24
+
25
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
26
+
if !ok {
27
+
fail(MissingActorDidError)
28
+
return
29
+
}
30
+
31
+
repoParam := r.URL.Query().Get("repo")
32
+
if repoParam == "" {
33
+
fail(GenericError(fmt.Errorf("empty params")))
34
+
return
35
+
}
36
+
37
+
// unfortunately we have to resolve repo-at here
38
+
repoAt, err := syntax.ParseATURI(repoParam)
39
+
if err != nil {
40
+
fail(InvalidRepoError(repoParam))
41
+
return
42
+
}
43
+
44
+
// resolve this aturi to extract the repo record
45
+
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
46
+
if err != nil || ident.Handle.IsInvalidHandle() {
47
+
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
48
+
return
49
+
}
50
+
51
+
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
52
+
resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
53
+
if err != nil {
54
+
fail(GenericError(err))
55
+
return
56
+
}
57
+
58
+
repo := resp.Value.Val.(*tangled.Repo)
59
+
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
60
+
if err != nil {
61
+
fail(GenericError(err))
62
+
return
63
+
}
64
+
65
+
if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
66
+
l.Error("insufficent permissions", "did", actorDid.String())
67
+
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
68
+
return
69
+
}
70
+
71
+
ls, err := x.Vault.GetSecretsLocked(r.Context(), secrets.DidSlashRepo(didPath))
72
+
if err != nil {
73
+
l.Error("failed to get secret from vault", "did", actorDid.String(), "err", err)
74
+
writeError(w, GenericError(err), http.StatusInternalServerError)
75
+
return
76
+
}
77
+
78
+
var out tangled.RepoListSecrets_Output
79
+
for _, l := range ls {
80
+
out.Secrets = append(out.Secrets, &tangled.RepoListSecrets_Secret{
81
+
Repo: repoAt.String(),
82
+
Key: l.Key,
83
+
CreatedAt: l.CreatedAt.Format(time.RFC3339),
84
+
CreatedBy: l.CreatedBy.String(),
85
+
})
86
+
}
87
+
88
+
w.Header().Set("Content-Type", "application/json")
89
+
w.WriteHeader(http.StatusOK)
90
+
json.NewEncoder(w).Encode(out)
91
+
}
+82
spindle/xrpc/remove_secret.go
+82
spindle/xrpc/remove_secret.go
···
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
8
+
"github.com/bluesky-social/indigo/api/atproto"
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
"github.com/bluesky-social/indigo/xrpc"
11
+
securejoin "github.com/cyphar/filepath-securejoin"
12
+
"tangled.sh/tangled.sh/core/api/tangled"
13
+
"tangled.sh/tangled.sh/core/rbac"
14
+
"tangled.sh/tangled.sh/core/spindle/secrets"
15
+
)
16
+
17
+
func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) {
18
+
l := x.Logger
19
+
fail := func(e XrpcError) {
20
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
21
+
writeError(w, e, http.StatusBadRequest)
22
+
}
23
+
24
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
25
+
if !ok {
26
+
fail(MissingActorDidError)
27
+
return
28
+
}
29
+
30
+
var data tangled.RepoRemoveSecret_Input
31
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
32
+
fail(GenericError(err))
33
+
return
34
+
}
35
+
36
+
// unfortunately we have to resolve repo-at here
37
+
repoAt, err := syntax.ParseATURI(data.Repo)
38
+
if err != nil {
39
+
fail(InvalidRepoError(data.Repo))
40
+
return
41
+
}
42
+
43
+
// resolve this aturi to extract the repo record
44
+
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
45
+
if err != nil || ident.Handle.IsInvalidHandle() {
46
+
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
47
+
return
48
+
}
49
+
50
+
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
51
+
resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
52
+
if err != nil {
53
+
fail(GenericError(err))
54
+
return
55
+
}
56
+
57
+
repo := resp.Value.Val.(*tangled.Repo)
58
+
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
59
+
if err != nil {
60
+
fail(GenericError(err))
61
+
return
62
+
}
63
+
64
+
if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
65
+
l.Error("insufficent permissions", "did", actorDid.String())
66
+
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
67
+
return
68
+
}
69
+
70
+
secret := secrets.Secret[any]{
71
+
Repo: secrets.DidSlashRepo(didPath),
72
+
Key: data.Key,
73
+
}
74
+
err = x.Vault.RemoveSecret(r.Context(), secret)
75
+
if err != nil {
76
+
l.Error("failed to remove secret from vault", "did", actorDid.String(), "err", err)
77
+
writeError(w, GenericError(err), http.StatusInternalServerError)
78
+
return
79
+
}
80
+
81
+
w.WriteHeader(http.StatusOK)
82
+
}
+147
spindle/xrpc/xrpc.go
+147
spindle/xrpc/xrpc.go
···
···
1
+
package xrpc
2
+
3
+
import (
4
+
"context"
5
+
_ "embed"
6
+
"encoding/json"
7
+
"fmt"
8
+
"log/slog"
9
+
"net/http"
10
+
"strings"
11
+
12
+
"github.com/bluesky-social/indigo/atproto/auth"
13
+
"github.com/go-chi/chi/v5"
14
+
15
+
"tangled.sh/tangled.sh/core/api/tangled"
16
+
"tangled.sh/tangled.sh/core/idresolver"
17
+
"tangled.sh/tangled.sh/core/rbac"
18
+
"tangled.sh/tangled.sh/core/spindle/config"
19
+
"tangled.sh/tangled.sh/core/spindle/db"
20
+
"tangled.sh/tangled.sh/core/spindle/engine"
21
+
"tangled.sh/tangled.sh/core/spindle/secrets"
22
+
)
23
+
24
+
const ActorDid string = "ActorDid"
25
+
26
+
type Xrpc struct {
27
+
Logger *slog.Logger
28
+
Db *db.DB
29
+
Enforcer *rbac.Enforcer
30
+
Engine *engine.Engine
31
+
Config *config.Config
32
+
Resolver *idresolver.Resolver
33
+
Vault secrets.Manager
34
+
}
35
+
36
+
func (x *Xrpc) Router() http.Handler {
37
+
r := chi.NewRouter()
38
+
39
+
r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret)
40
+
r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret)
41
+
r.With(x.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets)
42
+
43
+
return r
44
+
}
45
+
46
+
func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler {
47
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
48
+
l := x.Logger.With("url", r.URL)
49
+
50
+
token := r.Header.Get("Authorization")
51
+
token = strings.TrimPrefix(token, "Bearer ")
52
+
53
+
s := auth.ServiceAuthValidator{
54
+
Audience: x.Config.Server.Did().String(),
55
+
Dir: x.Resolver.Directory(),
56
+
}
57
+
58
+
did, err := s.Validate(r.Context(), token, nil)
59
+
if err != nil {
60
+
l.Error("signature verification failed", "err", err)
61
+
writeError(w, AuthError(err), http.StatusForbidden)
62
+
return
63
+
}
64
+
65
+
r = r.WithContext(
66
+
context.WithValue(r.Context(), ActorDid, did),
67
+
)
68
+
69
+
next.ServeHTTP(w, r)
70
+
})
71
+
}
72
+
73
+
type XrpcError struct {
74
+
Tag string `json:"error"`
75
+
Message string `json:"message"`
76
+
}
77
+
78
+
func NewXrpcError(opts ...ErrOpt) XrpcError {
79
+
x := XrpcError{}
80
+
for _, o := range opts {
81
+
o(&x)
82
+
}
83
+
84
+
return x
85
+
}
86
+
87
+
type ErrOpt = func(xerr *XrpcError)
88
+
89
+
func WithTag(tag string) ErrOpt {
90
+
return func(xerr *XrpcError) {
91
+
xerr.Tag = tag
92
+
}
93
+
}
94
+
95
+
func WithMessage[S ~string](s S) ErrOpt {
96
+
return func(xerr *XrpcError) {
97
+
xerr.Message = string(s)
98
+
}
99
+
}
100
+
101
+
func WithError(e error) ErrOpt {
102
+
return func(xerr *XrpcError) {
103
+
xerr.Message = e.Error()
104
+
}
105
+
}
106
+
107
+
var MissingActorDidError = NewXrpcError(
108
+
WithTag("MissingActorDid"),
109
+
WithMessage("actor DID not supplied"),
110
+
)
111
+
112
+
var AuthError = func(err error) XrpcError {
113
+
return NewXrpcError(
114
+
WithTag("Auth"),
115
+
WithError(fmt.Errorf("signature verification failed: %w", err)),
116
+
)
117
+
}
118
+
119
+
var InvalidRepoError = func(r string) XrpcError {
120
+
return NewXrpcError(
121
+
WithTag("InvalidRepo"),
122
+
WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)),
123
+
)
124
+
}
125
+
126
+
func GenericError(err error) XrpcError {
127
+
return NewXrpcError(
128
+
WithTag("Generic"),
129
+
WithError(err),
130
+
)
131
+
}
132
+
133
+
var AccessControlError = func(d string) XrpcError {
134
+
return NewXrpcError(
135
+
WithTag("AccessControl"),
136
+
WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)),
137
+
)
138
+
}
139
+
140
+
// this is slightly different from http_util::write_error to follow the spec:
141
+
//
142
+
// the json object returned must include an "error" and a "message"
143
+
func writeError(w http.ResponseWriter, e XrpcError, status int) {
144
+
w.Header().Set("Content-Type", "application/json")
145
+
w.WriteHeader(status)
146
+
json.NewEncoder(w).Encode(e)
147
+
}
+26
types/diff.go
+26
types/diff.go
···
5
"github.com/go-git/go-git/v5/plumbing/object"
6
)
7
8
+
type DiffOpts struct {
9
+
Split bool `json:"split"`
10
+
}
11
+
12
type TextFragment struct {
13
Header string `json:"comment"`
14
Lines []gitdiff.Line `json:"lines"`
···
81
82
return files
83
}
84
+
85
+
// used by html elements as a unique ID for hrefs
86
+
func (d *Diff) Id() string {
87
+
return d.Name.New
88
+
}
89
+
90
+
func (d *Diff) Split() *SplitDiff {
91
+
fragments := make([]SplitFragment, len(d.TextFragments))
92
+
for i, fragment := range d.TextFragments {
93
+
leftLines, rightLines := SeparateLines(&fragment)
94
+
fragments[i] = SplitFragment{
95
+
Header: fragment.Header(),
96
+
LeftLines: leftLines,
97
+
RightLines: rightLines,
98
+
}
99
+
}
100
+
101
+
return &SplitDiff{
102
+
Name: d.Id(),
103
+
TextFragments: fragments,
104
+
}
105
+
}
+131
types/split.go
+131
types/split.go
···
···
1
+
package types
2
+
3
+
import (
4
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
5
+
)
6
+
7
+
type SplitLine struct {
8
+
LineNumber int `json:"line_number,omitempty"`
9
+
Content string `json:"content"`
10
+
Op gitdiff.LineOp `json:"op"`
11
+
IsEmpty bool `json:"is_empty"`
12
+
}
13
+
14
+
type SplitFragment struct {
15
+
Header string `json:"header"`
16
+
LeftLines []SplitLine `json:"left_lines"`
17
+
RightLines []SplitLine `json:"right_lines"`
18
+
}
19
+
20
+
type SplitDiff struct {
21
+
Name string `json:"name"`
22
+
TextFragments []SplitFragment `json:"fragments"`
23
+
}
24
+
25
+
// used by html elements as a unique ID for hrefs
26
+
func (d *SplitDiff) Id() string {
27
+
return d.Name
28
+
}
29
+
30
+
// separate lines into left and right, this includes additional logic to
31
+
// group consecutive runs of additions and deletions in order to align them
32
+
// properly in the final output
33
+
//
34
+
// TODO: move all diff stuff to a single package, we are spread across patchutil and types right now
35
+
func SeparateLines(fragment *gitdiff.TextFragment) ([]SplitLine, []SplitLine) {
36
+
lines := fragment.Lines
37
+
var leftLines, rightLines []SplitLine
38
+
oldLineNum := fragment.OldPosition
39
+
newLineNum := fragment.NewPosition
40
+
41
+
// process deletions and additions in groups for better alignment
42
+
i := 0
43
+
for i < len(lines) {
44
+
line := lines[i]
45
+
46
+
switch line.Op {
47
+
case gitdiff.OpContext:
48
+
leftLines = append(leftLines, SplitLine{
49
+
LineNumber: int(oldLineNum),
50
+
Content: line.Line,
51
+
Op: gitdiff.OpContext,
52
+
IsEmpty: false,
53
+
})
54
+
rightLines = append(rightLines, SplitLine{
55
+
LineNumber: int(newLineNum),
56
+
Content: line.Line,
57
+
Op: gitdiff.OpContext,
58
+
IsEmpty: false,
59
+
})
60
+
oldLineNum++
61
+
newLineNum++
62
+
i++
63
+
64
+
case gitdiff.OpDelete:
65
+
deletionCount := 0
66
+
for j := i; j < len(lines) && lines[j].Op == gitdiff.OpDelete; j++ {
67
+
leftLines = append(leftLines, SplitLine{
68
+
LineNumber: int(oldLineNum),
69
+
Content: lines[j].Line,
70
+
Op: gitdiff.OpDelete,
71
+
IsEmpty: false,
72
+
})
73
+
oldLineNum++
74
+
deletionCount++
75
+
}
76
+
i += deletionCount
77
+
78
+
additionCount := 0
79
+
for j := i; j < len(lines) && lines[j].Op == gitdiff.OpAdd; j++ {
80
+
rightLines = append(rightLines, SplitLine{
81
+
LineNumber: int(newLineNum),
82
+
Content: lines[j].Line,
83
+
Op: gitdiff.OpAdd,
84
+
IsEmpty: false,
85
+
})
86
+
newLineNum++
87
+
additionCount++
88
+
}
89
+
i += additionCount
90
+
91
+
// add empty lines to balance the sides
92
+
if deletionCount > additionCount {
93
+
// more deletions than additions - pad right side
94
+
for k := 0; k < deletionCount-additionCount; k++ {
95
+
rightLines = append(rightLines, SplitLine{
96
+
Content: "",
97
+
Op: gitdiff.OpContext,
98
+
IsEmpty: true,
99
+
})
100
+
}
101
+
} else if additionCount > deletionCount {
102
+
// more additions than deletions - pad left side
103
+
for k := 0; k < additionCount-deletionCount; k++ {
104
+
leftLines = append(leftLines, SplitLine{
105
+
Content: "",
106
+
Op: gitdiff.OpContext,
107
+
IsEmpty: true,
108
+
})
109
+
}
110
+
}
111
+
112
+
case gitdiff.OpAdd:
113
+
// standalone addition (not preceded by deletion)
114
+
leftLines = append(leftLines, SplitLine{
115
+
Content: "",
116
+
Op: gitdiff.OpContext,
117
+
IsEmpty: true,
118
+
})
119
+
rightLines = append(rightLines, SplitLine{
120
+
LineNumber: int(newLineNum),
121
+
Content: line.Line,
122
+
Op: gitdiff.OpAdd,
123
+
IsEmpty: false,
124
+
})
125
+
newLineNum++
126
+
i++
127
+
}
128
+
}
129
+
130
+
return leftLines, rightLines
131
+
}