+5
-1
.tangled/workflows/build.yml
+5
-1
.tangled/workflows/build.yml
+3
-2
.tangled/workflows/fmt.yml
+3
-2
.tangled/workflows/fmt.yml
+2
-2
.tangled/workflows/test.yml
+2
-2
.tangled/workflows/test.yml
+740
-2
api/tangled/cbor_gen.go
+740
-2
api/tangled/cbor_gen.go
···
504
504
505
505
return nil
506
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
+
}
507
705
func (t *FeedStar) MarshalCBOR(w io.Writer) error {
508
706
if t == nil {
509
707
_, err := w.Write(cbg.CborNull)
···
1011
1209
}
1012
1210
1013
1211
cw := cbg.NewCborWriter(w)
1212
+
fieldCount := 3
1014
1213
1015
-
if _, err := cw.Write([]byte{162}); err != nil {
1214
+
if t.LangBreakdown == nil {
1215
+
fieldCount--
1216
+
}
1217
+
1218
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
1016
1219
return err
1017
1220
}
1018
1221
···
1047
1250
if err := cbg.WriteBool(w, t.IsDefaultRef); err != nil {
1048
1251
return err
1049
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
+
}
1050
1272
return nil
1051
1273
}
1052
1274
···
1075
1297
1076
1298
n := extra
1077
1299
1078
-
nameBuf := make([]byte, 12)
1300
+
nameBuf := make([]byte, 13)
1079
1301
for i := uint64(0); i < n; i++ {
1080
1302
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
1081
1303
if err != nil {
···
1128
1350
t.IsDefaultRef = true
1129
1351
default:
1130
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
+
1131
1373
}
1132
1374
1133
1375
default:
···
1425
1667
}
1426
1668
1427
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)
1428
1968
}
1429
1969
1430
1970
default:
···
5291
5831
}
5292
5832
}
5293
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)
5294
6032
}
5295
6033
// t.CreatedAt (string) (string)
5296
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
34
}
35
35
36
36
type GitRefUpdate_Meta struct {
37
-
CommitCount *GitRefUpdate_Meta_CommitCount `json:"commitCount" cborgen:"commitCount"`
38
-
IsDefaultRef bool `json:"isDefaultRef" cborgen:"isDefaultRef"`
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"`
39
40
}
40
41
41
42
type GitRefUpdate_Meta_CommitCount struct {
···
46
47
Count int64 `json:"count" cborgen:"count"`
47
48
Email string `json:"email" cborgen:"email"`
48
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
10
)
11
11
12
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"`
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"`
18
19
}
19
20
20
21
type OAuthConfig struct {
···
59
60
DB int `env:"DB, default=0"`
60
61
}
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
+
62
73
func (cfg RedisConfig) ToURL() string {
63
74
u := &url.URL{
64
75
Scheme: "redis",
···
84
95
Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"`
85
96
OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"`
86
97
Redis RedisConfig `env:",prefix=TANGLED_REDIS_"`
98
+
Pds PdsConfig `env:",prefix=TANGLED_PDS_"`
99
+
Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"`
87
100
}
88
101
89
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
199
unique(starred_by_did, repo_at)
200
200
);
201
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
+
202
212
create table if not exists emails (
203
213
id integer primary key autoincrement,
204
214
did text not null,
···
345
355
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
346
356
347
357
-- constraints
348
-
foreign key (did, instance) references spindles(owner, instance) on delete cascade,
349
358
unique (did, instance, subject)
350
359
);
351
360
···
411
420
on delete cascade
412
421
);
413
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
+
414
446
create table if not exists migrations (
415
447
id integer primary key autoincrement,
416
448
name text unique
···
553
585
return nil
554
586
})
555
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
+
556
643
return &DB{db}, nil
557
644
}
558
645
···
628
715
// if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)`
629
716
if kind == reflect.Slice || kind == reflect.Array {
630
717
if rv.Len() == 0 {
631
-
panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key))
718
+
// always false
719
+
return "1 = 0"
632
720
}
633
721
634
722
placeholders := make([]string, rv.Len())
···
647
735
kind := rv.Kind()
648
736
if kind == reflect.Slice || kind == reflect.Array {
649
737
if rv.Len() == 0 {
650
-
panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key))
738
+
return nil
651
739
}
652
740
653
741
out := make([]any, rv.Len())
+16
-2
appview/db/email.go
+16
-2
appview/db/email.go
···
103
103
query := `
104
104
select email, did
105
105
from emails
106
-
where
107
-
verified = ?
106
+
where
107
+
verified = ?
108
108
and email in (` + strings.Join(placeholders, ",") + `)
109
109
`
110
110
···
153
153
`
154
154
var count int
155
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)
156
170
if err != nil {
157
171
return false, err
158
172
}
+2
-2
appview/db/follow.go
+2
-2
appview/db/follow.go
···
12
12
Rkey string
13
13
}
14
14
15
-
func AddFollow(e Execer, userDid, subjectDid, rkey string) error {
15
+
func AddFollow(e Execer, follow *Follow) error {
16
16
query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)`
17
-
_, err := e.Exec(query, userDid, subjectDid, rkey)
17
+
_, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey)
18
18
return err
19
19
}
20
20
+17
-12
appview/db/issues.go
+17
-12
appview/db/issues.go
···
9
9
)
10
10
11
11
type Issue struct {
12
+
ID int64
12
13
RepoAt syntax.ATURI
13
14
OwnerDid string
14
15
IssueId int
···
65
66
66
67
issue.IssueId = nextId
67
68
68
-
_, err = tx.Exec(`
69
+
res, err := tx.Exec(`
69
70
insert into issues (repo_at, owner_did, issue_id, title, body)
70
71
values (?, ?, ?, ?, ?)
71
72
`, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body)
72
73
if err != nil {
73
74
return err
74
75
}
76
+
77
+
lastID, err := res.LastInsertId()
78
+
if err != nil {
79
+
return err
80
+
}
81
+
issue.ID = lastID
75
82
76
83
if err := tx.Commit(); err != nil {
77
84
return err
···
89
96
var issueAt string
90
97
err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt)
91
98
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
}
99
100
100
101
func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
···
114
115
`
115
116
with numbered_issue as (
116
117
select
118
+
i.id,
117
119
i.owner_did,
118
120
i.issue_id,
119
121
i.created,
···
132
134
i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
133
135
)
134
136
select
137
+
id,
135
138
owner_did,
136
139
issue_id,
137
140
created,
···
153
156
var issue Issue
154
157
var createdAt string
155
158
var metadata IssueMetadata
156
-
err := rows.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount)
159
+
err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount)
157
160
if err != nil {
158
161
return nil, err
159
162
}
···
182
185
183
186
rows, err := e.Query(
184
187
`select
188
+
i.id,
185
189
i.owner_did,
186
190
i.repo_at,
187
191
i.issue_id,
···
213
217
var issueCreatedAt, repoCreatedAt string
214
218
var repo Repo
215
219
err := rows.Scan(
220
+
&issue.ID,
216
221
&issue.OwnerDid,
217
222
&issue.RepoAt,
218
223
&issue.IssueId,
···
257
262
}
258
263
259
264
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 = ?`
265
+
query := `select id, owner_did, created, title, body, open from issues where repo_at = ? and issue_id = ?`
261
266
row := e.QueryRow(query, repoAt, issueId)
262
267
263
268
var issue Issue
264
269
var createdAt string
265
-
err := row.Scan(&issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open)
270
+
err := row.Scan(&issue.ID, &issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open)
266
271
if err != nil {
267
272
return nil, err
268
273
}
···
277
282
}
278
283
279
284
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 = ?`
285
+
query := `select id, owner_did, issue_id, created, title, body, open, issue_at from issues where repo_at = ? and issue_id = ?`
281
286
row := e.QueryRow(query, repoAt, issueId)
282
287
283
288
var issue Issue
284
289
var createdAt string
285
-
err := row.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open)
290
+
err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt)
286
291
if err != nil {
287
292
return nil, nil, err
288
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
348
return tx.Commit()
349
349
}
350
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
+
351
459
func GetProfile(e Execer, did string) (*Profile, error) {
352
460
var profile Profile
353
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
10
)
11
11
12
12
type Registration struct {
13
+
Id int64
13
14
Domain string
14
15
ByDid string
15
16
Created *time.Time
···
36
37
var registrations []Registration
37
38
38
39
rows, err := e.Query(`
39
-
select domain, did, created, registered from registrations
40
+
select id, domain, did, created, registered from registrations
40
41
where did = ?
41
42
`, did)
42
43
if err != nil {
···
47
48
var createdAt *string
48
49
var registeredAt *string
49
50
var registration Registration
50
-
err = rows.Scan(®istration.Domain, ®istration.ByDid, &createdAt, ®isteredAt)
51
+
err = rows.Scan(®istration.Id, ®istration.Domain, ®istration.ByDid, &createdAt, ®isteredAt)
51
52
52
53
if err != nil {
53
54
log.Println(err)
···
75
76
var registration Registration
76
77
77
78
err := e.QueryRow(`
78
-
select domain, did, created, registered from registrations
79
+
select id, domain, did, created, registered from registrations
79
80
where domain = ?
80
-
`, domain).Scan(®istration.Domain, ®istration.ByDid, &createdAt, ®isteredAt)
81
+
`, domain).Scan(®istration.Id, ®istration.Domain, ®istration.ByDid, &createdAt, ®isteredAt)
81
82
82
83
if err != nil {
83
84
if err == sql.ErrNoRows {
+68
-70
appview/db/repos.go
+68
-70
appview/db/repos.go
···
3
3
import (
4
4
"database/sql"
5
5
"fmt"
6
+
"log"
7
+
"slices"
6
8
"strings"
7
9
"time"
8
10
···
71
73
return repos, nil
72
74
}
73
75
74
-
func GetRepos(e Execer, filters ...filter) ([]Repo, error) {
75
-
repoMap := make(map[syntax.ATURI]Repo)
76
+
func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) {
77
+
repoMap := make(map[syntax.ATURI]*Repo)
76
78
77
79
var conditions []string
78
80
var args []any
···
86
88
whereClause = " where " + strings.Join(conditions, " and ")
87
89
}
88
90
91
+
limitClause := ""
92
+
if limit != 0 {
93
+
limitClause = fmt.Sprintf(" limit %d", limit)
94
+
}
95
+
89
96
repoQuery := fmt.Sprintf(
90
97
`select
91
98
did,
···
98
105
spindle
99
106
from
100
107
repos r
108
+
%s
109
+
order by created desc
101
110
%s`,
102
111
whereClause,
112
+
limitClause,
103
113
)
104
114
rows, err := e.Query(repoQuery, args...)
105
115
···
139
149
repo.Spindle = spindle.String
140
150
}
141
151
142
-
repoMap[repo.RepoAt()] = repo
152
+
repo.RepoStats = &RepoStats{}
153
+
repoMap[repo.RepoAt()] = &repo
143
154
}
144
155
145
156
if err = rows.Err(); err != nil {
···
148
159
149
160
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(repoMap)), ", ")
150
161
args = make([]any, len(repoMap))
162
+
163
+
i := 0
151
164
for _, r := range repoMap {
152
-
args = append(args, r.RepoAt())
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)
153
205
}
154
206
155
207
starCountQuery := fmt.Sprintf(
···
168
220
var repoat string
169
221
var count int
170
222
if err := rows.Scan(&repoat, &count); err != nil {
223
+
log.Println("err", "err", err)
171
224
continue
172
225
}
173
226
if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
···
196
249
var repoat string
197
250
var open, closed int
198
251
if err := rows.Scan(&repoat, &open, &closed); err != nil {
252
+
log.Println("err", "err", err)
199
253
continue
200
254
}
201
255
if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
···
236
290
var repoat string
237
291
var open, merged, closed, deleted int
238
292
if err := rows.Scan(&repoat, &open, &merged, &closed, &deleted); err != nil {
293
+
log.Println("err", "err", err)
239
294
continue
240
295
}
241
296
if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
···
251
306
252
307
var repos []Repo
253
308
for _, r := range repoMap {
254
-
repos = append(repos, r)
309
+
repos = append(repos, *r)
255
310
}
256
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
+
257
319
return repos, nil
258
320
}
259
321
···
488
550
return &repo, nil
489
551
}
490
552
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
553
func UpdateDescription(e Execer, repoAt, newDescription string) error {
500
554
_, err := e.Exec(
501
555
`update repos set description = ? where at_uri = ?`, newDescription, repoAt)
···
508
562
return err
509
563
}
510
564
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
565
type RepoStats struct {
566
+
Language string
569
567
StarCount int
570
568
IssueCount IssueCount
571
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
1
package db
2
2
3
3
import (
4
+
"fmt"
4
5
"log"
6
+
"strings"
5
7
"time"
6
8
7
9
"github.com/bluesky-social/indigo/atproto/syntax"
···
31
33
return nil
32
34
}
33
35
34
-
func AddStar(e Execer, starredByDid string, repoAt syntax.ATURI, rkey string) error {
36
+
func AddStar(e Execer, star *Star) error {
35
37
query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)`
36
-
_, err := e.Exec(query, starredByDid, repoAt, rkey)
38
+
_, err := e.Exec(
39
+
query,
40
+
star.StarredByDid,
41
+
star.RepoAt.String(),
42
+
star.Rkey,
43
+
)
37
44
return err
38
45
}
39
46
···
91
98
} else {
92
99
return true
93
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
94
184
}
95
185
96
186
func GetAllStars(e Execer, limit int) ([]Star, error) {
+136
-27
appview/db/timeline.go
+136
-27
appview/db/timeline.go
···
14
14
15
15
// optional: populate only if Repo is a fork
16
16
Source *Repo
17
+
18
+
// optional: populate only if event is Follow
19
+
*Profile
20
+
*FollowStats
17
21
}
22
+
23
+
type FollowStats struct {
24
+
Followers int
25
+
Following int
26
+
}
27
+
28
+
const Limit = 50
18
29
19
30
// TODO: this gathers heterogenous events from different sources and aggregates
20
31
// them in code; if we did this entirely in sql, we could order and limit and paginate easily
21
32
func MakeTimeline(e Execer) ([]TimelineEvent, error) {
22
33
var events []TimelineEvent
23
-
limit := 50
24
34
25
-
repos, err := GetAllRepos(e, limit)
35
+
repos, err := getTimelineRepos(e)
26
36
if err != nil {
27
37
return nil, err
28
38
}
29
39
30
-
follows, err := GetAllFollows(e, limit)
40
+
stars, err := getTimelineStars(e)
31
41
if err != nil {
32
42
return nil, err
33
43
}
34
44
35
-
stars, err := GetAllStars(e, limit)
45
+
follows, err := getTimelineFollows(e)
36
46
if err != nil {
37
47
return nil, err
38
48
}
39
49
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
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
46
99
}
47
100
}
48
101
49
102
events = append(events, TimelineEvent{
50
-
Repo: &repo,
51
-
EventAt: repo.Created,
52
-
Source: sourceRepo,
103
+
Repo: &r,
104
+
EventAt: r.Created,
105
+
Source: source,
53
106
})
54
107
}
55
108
56
-
for _, follow := range follows {
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 {
57
130
events = append(events, TimelineEvent{
58
-
Follow: &follow,
59
-
EventAt: follow.FollowedAt,
131
+
Star: &s,
132
+
EventAt: s.Created,
60
133
})
61
134
}
62
135
63
-
for _, star := range stars {
64
-
events = append(events, TimelineEvent{
65
-
Star: &star,
66
-
EventAt: star.Created,
67
-
})
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
68
161
}
69
162
70
-
sort.Slice(events, func(i, j int) bool {
71
-
return events[i].EventAt.After(events[j].EventAt)
72
-
})
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
+
}
73
174
74
-
// Limit the slice to 100 events
75
-
if len(events) > limit {
76
-
events = events[:limit]
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
+
})
77
186
}
78
187
79
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
+
}
-104
appview/idresolver/resolver.go
-104
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
-
}
+52
-26
appview/ingester.go
+52
-26
appview/ingester.go
···
14
14
"tangled.sh/tangled.sh/core/api/tangled"
15
15
"tangled.sh/tangled.sh/core/appview/config"
16
16
"tangled.sh/tangled.sh/core/appview/db"
17
-
"tangled.sh/tangled.sh/core/appview/idresolver"
18
17
"tangled.sh/tangled.sh/core/appview/spindleverify"
18
+
"tangled.sh/tangled.sh/core/idresolver"
19
19
"tangled.sh/tangled.sh/core/rbac"
20
20
)
21
21
···
40
40
}
41
41
}()
42
42
43
-
if e.Kind != models.EventKindCommit {
44
-
return nil
45
-
}
46
-
47
-
switch e.Commit.Collection {
48
-
case tangled.GraphFollowNSID:
49
-
err = i.ingestFollow(e)
50
-
case tangled.FeedStarNSID:
51
-
err = i.ingestStar(e)
52
-
case tangled.PublicKeyNSID:
53
-
err = i.ingestPublicKey(e)
54
-
case tangled.RepoArtifactNSID:
55
-
err = i.ingestArtifact(e)
56
-
case tangled.ActorProfileNSID:
57
-
err = i.ingestProfile(e)
58
-
case tangled.SpindleMemberNSID:
59
-
err = i.ingestSpindleMember(e)
60
-
case tangled.SpindleNSID:
61
-
err = i.ingestSpindle(e)
43
+
l := i.Logger.With("kind", e.Kind)
44
+
switch e.Kind {
45
+
case models.EventKindAccount:
46
+
if !e.Account.Active && *e.Account.Status == "deactivated" {
47
+
err = i.IdResolver.InvalidateIdent(ctx, e.Account.Did)
48
+
}
49
+
case models.EventKindIdentity:
50
+
err = i.IdResolver.InvalidateIdent(ctx, e.Identity.Did)
51
+
case models.EventKindCommit:
52
+
switch e.Commit.Collection {
53
+
case tangled.GraphFollowNSID:
54
+
err = i.ingestFollow(e)
55
+
case tangled.FeedStarNSID:
56
+
err = i.ingestStar(e)
57
+
case tangled.PublicKeyNSID:
58
+
err = i.ingestPublicKey(e)
59
+
case tangled.RepoArtifactNSID:
60
+
err = i.ingestArtifact(e)
61
+
case tangled.ActorProfileNSID:
62
+
err = i.ingestProfile(e)
63
+
case tangled.SpindleMemberNSID:
64
+
err = i.ingestSpindleMember(e)
65
+
case tangled.SpindleNSID:
66
+
err = i.ingestSpindle(e)
67
+
}
68
+
l = i.Logger.With("nsid", e.Commit.Collection)
62
69
}
63
70
64
71
if err != nil {
65
-
l := i.Logger.With("nsid", e.Commit.Collection)
66
72
l.Error("error ingesting record", "err", err)
67
73
}
68
74
···
94
100
l.Error("invalid record", "err", err)
95
101
return err
96
102
}
97
-
err = db.AddStar(i.Db, did, subjectUri, e.Commit.RKey)
103
+
err = db.AddStar(i.Db, &db.Star{
104
+
StarredByDid: did,
105
+
RepoAt: subjectUri,
106
+
Rkey: e.Commit.RKey,
107
+
})
98
108
case models.CommitOperationDelete:
99
109
err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
100
110
}
···
123
133
return err
124
134
}
125
135
126
-
subjectDid := record.Subject
127
-
err = db.AddFollow(i.Db, did, subjectDid, e.Commit.RKey)
136
+
err = db.AddFollow(i.Db, &db.Follow{
137
+
UserDid: did,
138
+
SubjectDid: record.Subject,
139
+
Rkey: e.Commit.RKey,
140
+
})
128
141
case models.CommitOperationDelete:
129
142
err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey)
130
143
}
···
486
499
if err != nil || len(spindles) != 1 {
487
500
return fmt.Errorf("failed to get spindles: %w, len(spindles) = %d", err, len(spindles))
488
501
}
502
+
spindle := spindles[0]
489
503
490
504
tx, err := ddb.Begin()
491
505
if err != nil {
···
496
510
i.Enforcer.E.LoadPolicy()
497
511
}()
498
512
499
-
err = db.DeleteSpindle(
513
+
// remove spindle members first
514
+
err = db.RemoveSpindleMember(
500
515
tx,
501
516
db.FilterEq("owner", did),
502
517
db.FilterEq("instance", instance),
···
505
520
return err
506
521
}
507
522
508
-
err = i.Enforcer.RemoveSpindle(instance)
523
+
err = db.DeleteSpindle(
524
+
tx,
525
+
db.FilterEq("owner", did),
526
+
db.FilterEq("instance", instance),
527
+
)
509
528
if err != nil {
510
529
return err
530
+
}
531
+
532
+
if spindle.Verified != nil {
533
+
err = i.Enforcer.RemoveSpindle(instance)
534
+
if err != nil {
535
+
return err
536
+
}
511
537
}
512
538
513
539
err = tx.Commit()
+32
-31
appview/issues/issues.go
+32
-31
appview/issues/issues.go
···
11
11
12
12
comatproto "github.com/bluesky-social/indigo/api/atproto"
13
13
"github.com/bluesky-social/indigo/atproto/data"
14
+
"github.com/bluesky-social/indigo/atproto/syntax"
14
15
lexutil "github.com/bluesky-social/indigo/lex/util"
15
16
"github.com/go-chi/chi/v5"
16
-
"github.com/posthog/posthog-go"
17
17
18
18
"tangled.sh/tangled.sh/core/api/tangled"
19
-
"tangled.sh/tangled.sh/core/appview"
20
19
"tangled.sh/tangled.sh/core/appview/config"
21
20
"tangled.sh/tangled.sh/core/appview/db"
22
-
"tangled.sh/tangled.sh/core/appview/idresolver"
21
+
"tangled.sh/tangled.sh/core/appview/notify"
23
22
"tangled.sh/tangled.sh/core/appview/oauth"
24
23
"tangled.sh/tangled.sh/core/appview/pages"
25
24
"tangled.sh/tangled.sh/core/appview/pagination"
26
25
"tangled.sh/tangled.sh/core/appview/reporesolver"
26
+
"tangled.sh/tangled.sh/core/idresolver"
27
+
"tangled.sh/tangled.sh/core/tid"
27
28
)
28
29
29
30
type Issues struct {
···
33
34
idResolver *idresolver.Resolver
34
35
db *db.DB
35
36
config *config.Config
36
-
posthog posthog.Client
37
+
notifier notify.Notifier
37
38
}
38
39
39
40
func New(
···
43
44
idResolver *idresolver.Resolver,
44
45
db *db.DB,
45
46
config *config.Config,
46
-
posthog posthog.Client,
47
+
notifier notify.Notifier,
47
48
) *Issues {
48
49
return &Issues{
49
50
oauth: oauth,
···
52
53
idResolver: idResolver,
53
54
db: db,
54
55
config: config,
55
-
posthog: posthog,
56
+
notifier: notifier,
56
57
}
57
58
}
58
59
···
79
80
return
80
81
}
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
+
82
94
issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid)
83
95
if err != nil {
84
96
log.Println("failed to resolve issue owner", err)
···
106
118
107
119
IssueOwnerHandle: issueOwnerIdent.Handle.String(),
108
120
DidHandleMap: didHandleMap,
121
+
122
+
OrderedReactionKinds: db.OrderedReactionKinds,
123
+
Reactions: reactionCountMap,
124
+
UserReacted: userReactions,
109
125
})
110
126
111
127
}
···
155
171
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
156
172
Collection: tangled.RepoIssueStateNSID,
157
173
Repo: user.Did,
158
-
Rkey: appview.TID(),
174
+
Rkey: tid.TID(),
159
175
Record: &lexutil.LexiconTypeDecoder{
160
176
Val: &tangled.RepoIssueState{
161
177
Issue: issue.IssueAt,
···
259
275
}
260
276
261
277
commentId := mathrand.IntN(1000000)
262
-
rkey := appview.TID()
278
+
rkey := tid.TID()
263
279
264
280
err := db.NewIssueComment(rp.db, &db.Comment{
265
281
OwnerDid: user.Did,
···
687
703
return
688
704
}
689
705
690
-
err = db.NewIssue(tx, &db.Issue{
706
+
issue := &db.Issue{
691
707
RepoAt: f.RepoAt,
692
708
Title: title,
693
709
Body: body,
694
710
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
711
}
701
-
702
-
issueId, err := db.GetIssueId(rp.db, f.RepoAt)
712
+
err = db.NewIssue(tx, issue)
703
713
if err != nil {
704
-
log.Println("failed to get issue id", err)
714
+
log.Println("failed to create issue", err)
705
715
rp.pages.Notice(w, "issues", "Failed to create issue.")
706
716
return
707
717
}
···
716
726
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
717
727
Collection: tangled.RepoIssueNSID,
718
728
Repo: user.Did,
719
-
Rkey: appview.TID(),
729
+
Rkey: tid.TID(),
720
730
Record: &lexutil.LexiconTypeDecoder{
721
731
Val: &tangled.RepoIssue{
722
732
Repo: atUri,
723
733
Title: title,
724
734
Body: &body,
725
735
Owner: user.Did,
726
-
IssueId: int64(issueId),
736
+
IssueId: int64(issue.IssueId),
727
737
},
728
738
},
729
739
})
···
733
743
return
734
744
}
735
745
736
-
err = db.SetIssueAt(rp.db, f.RepoAt, issueId, resp.Uri)
746
+
err = db.SetIssueAt(rp.db, f.RepoAt, issue.IssueId, resp.Uri)
737
747
if err != nil {
738
748
log.Println("failed to set issue at", err)
739
749
rp.pages.Notice(w, "issues", "Failed to create issue.")
740
750
return
741
751
}
742
752
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
+
rp.notifier.NewIssue(r.Context(), issue)
753
754
754
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
755
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
755
756
return
756
757
}
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
13
"github.com/bluesky-social/indigo/atproto/identity"
14
14
"github.com/go-chi/chi/v5"
15
15
"tangled.sh/tangled.sh/core/appview/db"
16
-
"tangled.sh/tangled.sh/core/appview/idresolver"
17
16
"tangled.sh/tangled.sh/core/appview/oauth"
18
17
"tangled.sh/tangled.sh/core/appview/pages"
19
18
"tangled.sh/tangled.sh/core/appview/pagination"
20
19
"tangled.sh/tangled.sh/core/appview/reporesolver"
20
+
"tangled.sh/tangled.sh/core/idresolver"
21
21
"tangled.sh/tangled.sh/core/rbac"
22
22
)
23
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
16
sessioncache "tangled.sh/tangled.sh/core/appview/cache/session"
17
17
"tangled.sh/tangled.sh/core/appview/config"
18
18
"tangled.sh/tangled.sh/core/appview/db"
19
-
"tangled.sh/tangled.sh/core/appview/idresolver"
20
19
"tangled.sh/tangled.sh/core/appview/middleware"
21
20
"tangled.sh/tangled.sh/core/appview/oauth"
22
21
"tangled.sh/tangled.sh/core/appview/oauth/client"
23
22
"tangled.sh/tangled.sh/core/appview/pages"
23
+
"tangled.sh/tangled.sh/core/idresolver"
24
24
"tangled.sh/tangled.sh/core/knotclient"
25
25
"tangled.sh/tangled.sh/core/rbac"
26
26
)
+73
appview/oauth/oauth.go
+73
appview/oauth/oauth.go
···
7
7
"net/url"
8
8
"time"
9
9
10
+
indigo_xrpc "github.com/bluesky-social/indigo/xrpc"
10
11
"github.com/gorilla/sessions"
11
12
oauth "tangled.sh/icyphox.sh/atproto-oauth"
12
13
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
···
204
205
})
205
206
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
207
280
}
208
281
209
282
type ClientMetadata struct {
+67
-31
appview/pages/funcmap.go
+67
-31
appview/pages/funcmap.go
···
17
17
"time"
18
18
19
19
"github.com/dustin/go-humanize"
20
+
"github.com/go-enry/go-enry/v2"
20
21
"github.com/microcosm-cc/bluemonday"
21
22
"tangled.sh/tangled.sh/core/appview/filetree"
22
23
"tangled.sh/tangled.sh/core/appview/pages/markup"
···
105
106
s = append(s, values...)
106
107
return s
107
108
},
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 {
109
+
"commaFmt": humanize.Comma,
110
+
"relTimeFmt": humanize.Time,
111
+
"shortRelTimeFmt": func(t time.Time) string {
114
112
return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{
115
113
{time.Second, "now", time.Second},
116
114
{2 * time.Second, "1s %s", 1},
···
129
127
{math.MaxInt64, "a long while %s", 1},
130
128
})
131
129
},
132
-
"durationFmt": func(duration time.Duration) string {
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 {
133
137
days := int64(duration.Hours() / 24)
134
138
hours := int64(math.Mod(duration.Hours(), 24))
135
139
minutes := int64(math.Mod(duration.Minutes(), 60))
136
140
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, " ")
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"})
157
148
},
158
149
"byteFmt": humanize.Bytes,
159
150
"length": func(slice any) int {
···
200
191
if v.Len() == 0 {
201
192
return nil
202
193
}
203
-
return v.Slice(0, min(n, v.Len()-1)).Interface()
194
+
return v.Slice(0, min(n, v.Len())).Interface()
204
195
},
205
196
206
197
"markdown": func(text string) template.HTML {
···
250
241
return u
251
242
},
252
243
253
-
"tinyAvatar": p.tinyAvatar,
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
+
},
254
257
}
255
258
}
256
259
257
-
func (p *Pages) tinyAvatar(handle string) string {
260
+
func (p *Pages) avatarUri(handle, size string) string {
258
261
handle = strings.TrimPrefix(handle, "@")
262
+
259
263
secret := p.avatar.SharedSecret
260
264
h := hmac.New(sha256.New, []byte(secret))
261
265
h.Write([]byte(handle))
262
266
signature := hex.EncodeToString(h.Sum(nil))
263
-
return fmt.Sprintf("%s/%s/%s?size=tiny", p.avatar.Host, signature, handle)
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)
264
273
}
265
274
266
275
func icon(name string, classes []string) (template.HTML, error) {
···
288
297
modifiedSVG := svgStr[:svgTagEnd] + classTag + svgStr[svgTagEnd:]
289
298
return template.HTML(modifiedSVG), nil
290
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
9
"github.com/yuin/goldmark/ast"
10
10
)
11
11
12
-
func generateCamoURL(baseURL, secret, imageURL string) string {
12
+
func GenerateCamoURL(baseURL, secret, imageURL string) string {
13
13
h := hmac.New(sha256.New, []byte(secret))
14
14
h.Write([]byte(imageURL))
15
15
signature := hex.EncodeToString(h.Sum(nil))
···
24
24
}
25
25
26
26
if rctx.CamoUrl != "" && rctx.CamoSecret != "" {
27
-
return generateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst)
27
+
return GenerateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst)
28
28
}
29
29
30
30
return dst
+157
-26
appview/pages/pages.go
+157
-26
appview/pages/pages.go
···
14
14
"os"
15
15
"path/filepath"
16
16
"strings"
17
+
"sync"
17
18
19
+
"tangled.sh/tangled.sh/core/api/tangled"
18
20
"tangled.sh/tangled.sh/core/appview/commitverify"
19
21
"tangled.sh/tangled.sh/core/appview/config"
20
22
"tangled.sh/tangled.sh/core/appview/db"
···
32
34
"github.com/bluesky-social/indigo/atproto/syntax"
33
35
"github.com/go-git/go-git/v5/plumbing"
34
36
"github.com/go-git/go-git/v5/plumbing/object"
35
-
"github.com/microcosm-cc/bluemonday"
36
37
)
37
38
38
39
//go:embed templates/* static
39
40
var Files embed.FS
40
41
41
42
type Pages struct {
42
-
t map[string]*template.Template
43
+
mu sync.RWMutex
44
+
t map[string]*template.Template
45
+
43
46
avatar config.AvatarConfig
44
47
dev bool
45
48
embedFS embed.FS
···
56
59
}
57
60
58
61
p := &Pages{
62
+
mu: sync.RWMutex{},
59
63
t: make(map[string]*template.Template),
60
64
dev: config.Core.Dev,
61
65
avatar: config.Avatar,
···
147
151
}
148
152
149
153
log.Printf("total templates loaded: %d", len(templates))
154
+
p.mu.Lock()
155
+
defer p.mu.Unlock()
150
156
p.t = templates
151
157
}
152
158
···
207
213
}
208
214
209
215
// Update the template in the map
216
+
p.mu.Lock()
217
+
defer p.mu.Unlock()
210
218
p.t[name] = tmpl
211
219
log.Printf("template reloaded from disk: %s", name)
212
220
return nil
···
221
229
}
222
230
}
223
231
232
+
p.mu.RLock()
233
+
defer p.mu.RUnlock()
224
234
tmpl, exists := p.t[templateName]
225
235
if !exists {
226
236
return fmt.Errorf("template not found: %s", templateName)
···
252
262
return p.executePlain("user/login", w, params)
253
263
}
254
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
+
255
287
type TimelineParams struct {
256
288
LoggedInUser *oauth.User
257
289
Timeline []db.TimelineEvent
···
278
310
}
279
311
280
312
func (p *Pages) Knots(w io.Writer, params KnotsParams) error {
281
-
return p.execute("knots", w, params)
313
+
return p.execute("knots/index", w, params)
282
314
}
283
315
284
316
type KnotParams struct {
···
286
318
DidHandleMap map[string]string
287
319
Registration *db.Registration
288
320
Members []string
321
+
Repos map[string][]db.Repo
289
322
IsOwner bool
290
323
}
291
324
292
325
func (p *Pages) Knot(w io.Writer, params KnotParams) error {
293
-
return p.execute("knot", w, params)
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)
294
351
}
295
352
296
353
type SpindlesParams struct {
···
413
470
return p.executePlain("user/fragments/editPins", w, params)
414
471
}
415
472
416
-
type RepoActionsFragmentParams struct {
473
+
type RepoStarFragmentParams struct {
417
474
IsStarred bool
418
475
RepoAt syntax.ATURI
419
476
Stats db.RepoStats
420
477
}
421
478
422
-
func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error {
423
-
return p.executePlain("repo/fragments/repoActions", w, params)
479
+
func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error {
480
+
return p.executePlain("repo/fragments/repoStar", w, params)
424
481
}
425
482
426
483
type RepoDescriptionParams struct {
···
467
524
ext := filepath.Ext(params.ReadmeFileName)
468
525
switch ext {
469
526
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
527
+
htmlString = p.rctx.Sanitize(htmlString)
470
528
htmlString = p.rctx.RenderMarkdown(params.Readme)
471
529
params.Raw = false
472
-
params.HTMLReadme = template.HTML(p.rctx.Sanitize(htmlString))
530
+
params.HTMLReadme = template.HTML(htmlString)
473
531
default:
474
-
htmlString = string(params.Readme)
475
532
params.Raw = true
476
-
params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString))
477
533
}
478
534
}
479
535
···
502
558
Active string
503
559
EmailToDidOrHandle map[string]string
504
560
Pipeline *db.Pipeline
561
+
DiffOpts types.DiffOpts
505
562
506
563
// singular because it's always going to be just one
507
564
VerifiedCommit commitverify.VerifiedCommits
···
519
576
RepoInfo repoinfo.RepoInfo
520
577
Active string
521
578
BreadCrumbs [][]string
522
-
BaseTreeLink string
523
-
BaseBlobLink string
579
+
TreePath string
524
580
types.RepoTreeResponse
525
581
}
526
582
···
590
646
LoggedInUser *oauth.User
591
647
RepoInfo repoinfo.RepoInfo
592
648
Active string
649
+
Unsupported bool
650
+
IsImage bool
651
+
IsVideo bool
652
+
ContentSrc string
593
653
BreadCrumbs [][]string
594
654
ShowRendered bool
595
655
RenderToggle bool
···
657
717
Branches []types.Branch
658
718
Spindles []string
659
719
CurrentSpindle string
720
+
Secrets []*tangled.RepoListSecrets_Secret
721
+
660
722
// TODO: use repoinfo.roles
661
723
IsCollaboratorInviteAllowed bool
662
724
}
···
666
728
return p.executeRepo("repo/settings", w, params)
667
729
}
668
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
+
669
775
type RepoIssuesParams struct {
670
776
LoggedInUser *oauth.User
671
777
RepoInfo repoinfo.RepoInfo
···
690
796
IssueOwnerHandle string
691
797
DidHandleMap map[string]string
692
798
799
+
OrderedReactionKinds []db.ReactionKind
800
+
Reactions map[db.ReactionKind]int
801
+
UserReacted map[db.ReactionKind]bool
802
+
693
803
State string
694
804
}
695
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
+
696
817
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
697
818
params.Active = "issues"
698
819
if params.Issue.Open {
···
762
883
DidHandleMap map[string]string
763
884
FilteringBy db.PullState
764
885
Stacks map[string]db.Stack
886
+
Pipelines map[string]db.Pipeline
765
887
}
766
888
767
889
func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
···
798
920
MergeCheck types.MergeCheckResponse
799
921
ResubmitCheck ResubmitResult
800
922
Pipelines map[string]db.Pipeline
923
+
924
+
OrderedReactionKinds []db.ReactionKind
925
+
Reactions map[db.ReactionKind]int
926
+
UserReacted map[db.ReactionKind]bool
801
927
}
802
928
803
929
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
806
932
}
807
933
808
934
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
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
817
945
}
818
946
819
947
// this name is a mouthful
···
822
950
}
823
951
824
952
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
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
831
961
}
832
962
833
963
// this name is a mouthful
···
918
1048
Base string
919
1049
Head string
920
1050
Diff *types.NiceDiff
1051
+
DiffOpts types.DiffOpts
921
1052
922
1053
Active string
923
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
14
<title>{{ block "title" . }}{{ end }} ยท tangled</title>
15
15
{{ block "extrameta" . }}{{ end }}
16
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" . }}
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;">
21
20
{{ template "layouts/topbar" . }}
22
-
{{ end }}
23
21
</header>
24
-
<main class="content grow">{{ block "content" . }}{{ end }}</main>
25
-
<footer class="mt-16">
26
-
{{ block "footer" . }}
27
-
{{ template "layouts/footer" . }}
28
-
{{ end }}
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" . }}
29
55
</footer>
30
-
</div>
56
+
{{ end }}
31
57
</body>
32
58
</html>
33
59
{{ end }}
+26
-4
appview/pages/templates/layouts/repobase.html
+26
-4
appview/pages/templates/layouts/repobase.html
···
19
19
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
20
20
</div>
21
21
22
-
{{ template "repo/fragments/repoActions" .RepoInfo }}
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>
23
45
</div>
24
46
{{ template "repo/fragments/repoDescription" . }}
25
47
</section>
26
48
27
49
<section
28
-
class="min-h-screen w-full flex flex-col drop-shadow-sm"
50
+
class="w-full flex flex-col drop-shadow-sm"
29
51
>
30
52
<nav class="w-full pl-4 overflow-auto">
31
53
<div class="flex z-60">
···
47
69
{{ if eq $.Active $key }}
48
70
{{ $activeTabStyles }}
49
71
{{ else }}
50
-
group-hover:bg-gray-200 dark:group-hover:bg-gray-700
72
+
group-hover:bg-gray-100/25 group-hover:dark:bg-gray-700/25
51
73
{{ end }}
52
74
"
53
75
>
···
64
86
</div>
65
87
</nav>
66
88
<section
67
-
class="bg-white dark:bg-gray-800 p-6 rounded relative w-full drop-shadow-sm dark:text-white"
89
+
class="bg-white dark:bg-gray-800 p-6 rounded relative w-full dark:text-white"
68
90
>
69
91
{{ block "repoContent" . }}{{ end }}
70
92
</section>
+7
-17
appview/pages/templates/layouts/topbar.html
+7
-17
appview/pages/templates/layouts/topbar.html
···
1
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">
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
4
<div id="left-items">
5
5
<a href="/" hx-boost="true" class="flex gap-2 font-semibold italic">
6
6
tangled<sub>alpha</sub>
7
7
</a>
8
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
9
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
10
<div id="right-items" class="flex items-center gap-4">
23
11
{{ with .LoggedInUser }}
24
12
<a href="/repo/new" hx-boost="true" class="btn-create hover:no-underline hover:text-white">
···
36
24
{{ define "dropDown" }}
37
25
<details class="relative inline-block text-left">
38
26
<summary
39
-
class="cursor-pointer list-none"
27
+
class="cursor-pointer list-none flex items-center"
40
28
>
41
-
{{ didOrHandle .Did .Handle }}
29
+
{{ $user := didOrHandle .Did .Handle }}
30
+
{{ template "user/fragments/picHandle" $user }}
42
31
</summary>
43
32
<div
44
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"
45
34
>
46
-
<a href="/{{ didOrHandle .Did .Handle }}">profile</a>
35
+
<a href="/{{ $user }}">profile</a>
36
+
<a href="/{{ $user }}?tab=repos">repositories</a>
47
37
<a href="/knots">knots</a>
48
38
<a href="/spindles">spindles</a>
49
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
5
6
6
{{ $title := printf "%s at %s · %s" .Path .Ref .RepoInfo.FullName }}
7
7
{{ $url := printf "https://tangled.sh/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }}
8
-
8
+
9
9
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10
-
10
+
11
11
{{ end }}
12
12
13
13
{{ define "repoContent" }}
···
44
44
<a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a>
45
45
{{ if .RenderToggle }}
46
46
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
47
-
<a
48
-
href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}"
47
+
<a
48
+
href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}"
49
49
hx-boost="true"
50
50
>view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a>
51
51
{{ end }}
52
52
</div>
53
53
</div>
54
54
</div>
55
-
{{ if .IsBinary }}
55
+
{{ if and .IsBinary .Unsupported }}
56
56
<p class="text-center text-gray-400 dark:text-gray-500">
57
-
This is a binary file and will not be displayed.
57
+
Previews are not supported for this file type.
58
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>
59
72
{{ else }}
60
73
<div class="overflow-auto relative">
61
74
{{ if .ShowRendered }}
+2
-2
appview/pages/templates/repo/branches.html
+2
-2
appview/pages/templates/repo/branches.html
···
59
59
</td>
60
60
<td class="py-3 whitespace-nowrap text-gray-500 dark:text-gray-400">
61
61
{{ if .Commit }}
62
-
{{ .Commit.Committer.When | timeFmt }}
62
+
{{ template "repo/fragments/time" .Commit.Committer.When }}
63
63
{{ end }}
64
64
</td>
65
65
</tr>
···
98
98
</a>
99
99
</span>
100
100
<div class="inline-block px-1 select-none after:content-['ยท']"></div>
101
-
<span>{{ .Commit.Committer.When | timeFmt }}</span>
101
+
{{ template "repo/fragments/time" .Commit.Committer.When }}
102
102
</div>
103
103
{{ end }}
104
104
</div>
+43
-6
appview/pages/templates/repo/commit.html
+43
-6
appview/pages/templates/repo/commit.html
···
34
34
<a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a>
35
35
{{ end }}
36
36
<span class="px-1 select-none before:content-['\00B7']"></span>
37
-
{{ timeFmt $commit.Author.When }}
37
+
{{ template "repo/fragments/time" $commit.Author.When }}
38
38
<span class="px-1 select-none before:content-['\00B7']"></span>
39
39
</p>
40
40
···
59
59
<div class="flex items-center gap-2 my-2">
60
60
{{ i "user" "w-4 h-4" }}
61
61
{{ $committerDidOrHandle := index $.EmailToDidOrHandle $commit.Committer.Email }}
62
-
<a href="/{{ $committerDidOrHandle }}">{{ $committerDidOrHandle }}</a>
62
+
<a href="/{{ $committerDidOrHandle }}">{{ template "user/fragments/picHandleLink" $committerDidOrHandle }}</a>
63
63
</div>
64
64
<div class="my-1 pt-2 text-xs border-t">
65
65
<div class="text-gray-600 dark:text-gray-300">SSH Key Fingerprint:</div>
···
77
77
</div>
78
78
79
79
</section>
80
+
{{end}}
80
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) }}
81
115
{{end}}
82
116
83
-
{{ define "repoAfter" }}
84
-
<div class="-z-[9999]">
85
-
{{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }}
86
-
</div>
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>
87
124
{{end}}
+42
-2
appview/pages/templates/repo/compare/compare.html
+42
-2
appview/pages/templates/repo/compare/compare.html
···
10
10
{{ end }}
11
11
{{ end }}
12
12
13
-
{{ define "repoAfter" }}
14
-
{{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }}
13
+
{{ define "topbarLayout" }}
14
+
<header class="px-1 col-span-full" style="z-index: 20;">
15
+
{{ template "layouts/topbar" . }}
16
+
</header>
15
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
19
<a href="/{{ $.RepoInfo.FullName }}/compare?head={{ $br.Name | urlquery }}" class="no-underline hover:no-underline">
20
20
<div class="flex items-center justify-between p-2">
21
21
{{ $br.Name }}
22
-
<time class="text-gray-500 dark:text-gray-400">{{ timeFmt $br.Commit.Committer.When }}</time>
22
+
<span class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" $br.Commit.Committer.When }}</span>
23
23
</div>
24
24
</a>
25
25
{{ end }}
+16
-4
appview/pages/templates/repo/empty.html
+16
-4
appview/pages/templates/repo/empty.html
···
17
17
<a href="/{{ $.RepoInfo.FullName }}/tree/{{$br.Name | urlquery }}" class="no-underline hover:no-underline">
18
18
<div class="flex items-center justify-between p-2">
19
19
{{ $br.Name }}
20
-
<time class="text-gray-500 dark:text-gray-400">{{ timeFmt $br.Commit.Committer.When }}</time>
20
+
<span class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" $br.Commit.Committer.When }}</span>
21
21
</div>
22
22
</a>
23
23
{{ end }}
24
24
</div>
25
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>
26
40
{{ 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>
41
+
<p class="text-gray-400 dark:text-gray-500 py-6 text-center">This is an empty repository.</p>
30
42
{{ end }}
31
43
</main>
32
44
{{ end }}
+2
-2
appview/pages/templates/repo/fragments/artifact.html
+2
-2
appview/pages/templates/repo/fragments/artifact.html
···
10
10
</div>
11
11
12
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>
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
15
16
16
<span class="select-none after:content-['ยท'] hidden md:inline"></span>
17
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
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 }}
2
+
{{ $repo := index . 0 }}
3
+
{{ $diff := index . 1 }}
4
+
{{ $opts := index . 2 }}
8
5
6
+
{{ $commit := $diff.Commit }}
7
+
{{ $diff := $diff.Diff }}
8
+
{{ $isSplit := $opts.Split }}
9
9
{{ $this := $commit.This }}
10
10
{{ $parent := $commit.Parent }}
11
+
{{ $last := sub (len $diff) 1 }}
11
12
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>
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 }}
21
36
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 }}
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>
45
74
46
-
{{ block "statPill" .Stats }} {{ end }}
47
75
</div>
76
+
</summary>
48
77
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>
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 -}}
62
94
{{ 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>
95
+
{{- template "repo/fragments/unifiedDiff" . -}}
66
96
{{ end }}
67
-
</div>
97
+
{{- end -}}
68
98
</div>
69
99
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 }}
100
+
</details>
77
101
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
102
</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>
103
+
</div>
104
+
</section>
105
+
{{ end }}
161
106
{{ end }}
162
107
</div>
163
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
1
{{ define "repo/fragments/interdiff" }}
2
2
{{ $repo := index . 0 }}
3
3
{{ $x := index . 1 }}
4
+
{{ $opts := index . 2 }}
4
5
{{ $fileTree := fileTree $x.AffectedFiles }}
5
6
{{ $diff := $x.Files }}
7
+
{{ $last := sub (len $diff) 1 }}
8
+
{{ $isSplit := $opts.Split }}
6
9
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 }}
10
+
<div class="flex flex-col gap-4">
17
11
{{ 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 }}
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>
41
42
</div>
42
43
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>
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 }}
47
56
</div>
57
+
48
58
</div>
59
+
</summary>
49
60
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>
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" . -}}
56
79
{{ 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
-
80
+
{{- end -}}
64
81
</div>
65
-
</summary>
66
82
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>
83
+
</details>
122
84
123
-
</details>
124
-
85
+
</div>
125
86
</div>
126
-
</div>
127
-
</section>
128
-
{{ end }}
87
+
</section>
88
+
{{ end }}
129
89
{{ end }}
90
+
</div>
130
91
{{ end }}
131
92
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 }}
+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
127
{{ end }}
128
128
129
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" }}
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" }}
136
132
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>
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" }}
150
139
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>
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>
173
151
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>
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>
184
160
{{ end }}
185
161
186
162
{{ define "rightInfo" }}
···
194
170
{{ define "commitLog" }}
195
171
<div id="commit-log" class="md:col-span-1 px-2 pb-4">
196
172
<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>
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>
204
176
</a>
205
177
</div>
206
178
<div class="flex flex-col gap-6">
···
266
238
{{ end }}"
267
239
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
268
240
>{{ if $didOrHandle }}
269
-
{{ $didOrHandle }}
241
+
{{ template "user/fragments/picHandleLink" $didOrHandle }}
270
242
{{ else }}
271
243
{{ .Author.Name }}
272
244
{{ end }}</a
273
245
>
274
246
</span>
275
247
<div class="inline-block px-1 select-none after:content-['ยท']"></div>
276
-
<span>{{ timeFmt .Committer.When }}</span>
248
+
{{ template "repo/fragments/time" .Committer.When }}
277
249
278
250
<!-- tags/branches -->
279
251
{{ $tagsForCommit := index $.TagMap .Hash.String }}
···
302
274
{{ define "branchList" }}
303
275
{{ if gt (len .BranchesTrunc) 0 }}
304
276
<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>
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>
312
280
</a>
313
281
<div class="flex flex-col gap-1">
314
282
{{ range .BranchesTrunc }}
···
320
288
</a>
321
289
{{ if .Commit }}
322
290
<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>
291
+
<span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .Commit.Committer.When }}</span>
324
292
{{ end }}
325
293
{{ if .IsDefault }}
326
294
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>
···
345
313
{{ if gt (len .TagsTrunc) 0 }}
346
314
<div id="tags" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700">
347
315
<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>
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>
355
319
</a>
356
320
</div>
357
321
<div class="flex flex-col gap-1">
···
366
330
</div>
367
331
<div>
368
332
{{ with .Tag }}
369
-
<time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .Tagger.When }}</time>
333
+
<span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .Tagger.When }}</span>
370
334
{{ end }}
371
335
{{ if eq $idx 0 }}
372
336
{{ with .Tag }}<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>{{ end }}
···
382
346
{{ end }}
383
347
384
348
{{ define "repoAfter" }}
385
-
{{- if .HTMLReadme -}}
349
+
{{- if or .HTMLReadme .Readme -}}
386
350
<section
387
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 }}
388
352
prose dark:prose-invert dark:[&_pre]:bg-gray-900
···
390
354
dark:[&_pre]:border dark:[&_pre]:border-gray-700
391
355
{{ end }}"
392
356
>
393
-
<article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-scroll">
394
-
{{- .HTMLReadme -}}
357
+
<article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto">
358
+
{{- .Readme -}}
395
359
</pre>
396
360
{{- else -}}
397
361
{{ .HTMLReadme }}
+3
-5
appview/pages/templates/repo/issues/fragments/editIssueComment.html
+3
-5
appview/pages/templates/repo/issues/fragments/editIssueComment.html
···
1
1
{{ define "repo/issues/fragments/editIssueComment" }}
2
2
{{ with .Comment }}
3
3
<div id="comment-container-{{.CommentId}}">
4
-
<div class="flex items-center gap-2 mb-2 text-gray-500 text-sm">
4
+
<div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap">
5
5
{{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }}
6
6
<a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a>
7
7
···
9
9
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
10
10
{{ if $isIssueAuthor }}
11
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
12
author
14
-
</span>
15
13
{{ end }}
16
14
17
15
<span class="before:content-['ยท']"></span>
18
16
<a
19
17
href="#{{ .CommentId }}"
20
-
class="text-gray-500 hover:text-gray-500 hover:underline no-underline"
18
+
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
21
19
id="{{ .CommentId }}">
22
-
{{ .Created | timeFmt }}
20
+
{{ template "repo/fragments/time" .Created }}
23
21
</a>
24
22
25
23
<button
+15
-16
appview/pages/templates/repo/issues/fragments/issueComment.html
+15
-16
appview/pages/templates/repo/issues/fragments/issueComment.html
···
1
1
{{ define "repo/issues/fragments/issueComment" }}
2
2
{{ with .Comment }}
3
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">
4
+
<div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap">
5
5
{{ $owner := index $.DidHandleMap .OwnerDid }}
6
-
<a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a>
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 }}
7
14
8
15
<span class="before:content-['ยท']"></span>
9
16
<a
···
11
18
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
12
19
id="{{ .CommentId }}">
13
20
{{ if .Deleted }}
14
-
deleted {{ .Deleted | timeFmt }}
21
+
deleted {{ template "repo/fragments/time" .Deleted }}
15
22
{{ else if .Edited }}
16
-
edited {{ .Edited | timeFmt }}
23
+
edited {{ template "repo/fragments/time" .Edited }}
17
24
{{ else }}
18
-
{{ .Created | timeFmt }}
25
+
{{ template "repo/fragments/time" .Created }}
19
26
{{ end }}
20
27
</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
28
30
29
{{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }}
31
30
{{ if and $isCommentOwner (not .Deleted) }}
32
-
<button
33
-
class="btn px-2 py-1 text-sm"
31
+
<button
32
+
class="btn px-2 py-1 text-sm"
34
33
hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
35
34
hx-swap="outerHTML"
36
35
hx-target="#comment-container-{{.CommentId}}"
37
36
>
38
37
{{ i "pencil" "w-4 h-4" }}
39
38
</button>
40
-
<button
39
+
<button
41
40
class="btn px-2 py-1 text-sm text-red-500 flex gap-2 items-center group"
42
41
hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/"
43
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
33
<span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1">
34
34
opened by
35
35
{{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }}
36
-
{{ template "user/fragments/picHandle" $owner }}
36
+
{{ template "user/fragments/picHandleLink" $owner }}
37
37
<span class="select-none before:content-['\00B7']"></span>
38
-
<time title="{{ .Issue.Created | longTimeFmt }}">
39
-
{{ .Issue.Created | timeFmt }}
40
-
</time>
38
+
{{ template "repo/fragments/time" .Issue.Created }}
41
39
</span>
42
40
</div>
43
41
···
46
44
{{ .Issue.Body | markdown }}
47
45
</article>
48
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>
49
61
</section>
50
62
{{ end }}
51
63
···
76
88
>
77
89
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full md:w-3/5">
78
90
<div class="text-sm pb-2 text-gray-500 dark:text-gray-400">
79
-
{{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }}
91
+
{{ template "user/fragments/picHandleLink" (didOrHandle .LoggedInUser.Did .LoggedInUser.Handle) }}
80
92
</div>
81
93
<textarea
82
94
id="comment-textarea"
+2
-4
appview/pages/templates/repo/issues/issues.html
+2
-4
appview/pages/templates/repo/issues/issues.html
···
66
66
67
67
<span class="ml-1">
68
68
{{ $owner := index $.DidHandleMap .OwnerDid }}
69
-
{{ template "user/fragments/picHandle" $owner }}
69
+
{{ template "user/fragments/picHandleLink" $owner }}
70
70
</span>
71
71
72
72
<span class="before:content-['ยท']">
73
-
<time>
74
-
{{ .Created | timeFmt }}
75
-
</time>
73
+
{{ template "repo/fragments/time" .Created }}
76
74
</span>
77
75
78
76
<span class="before:content-['ยท']">
+76
-79
appview/pages/templates/repo/log.html
+76
-79
appview/pages/templates/repo/log.html
···
14
14
</h2>
15
15
16
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>
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>
61
59
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 }}
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 }}
69
67
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>
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>
78
76
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>
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>
95
92
96
93
<!-- mobile view (visible only on small screens) -->
97
94
<div class="md:hidden">
···
102
99
<div class="text-base cursor-pointer">
103
100
<div class="flex items-center justify-between">
104
101
<div class="flex-1">
105
-
<div class="inline-flex items-end">
102
+
<div>
106
103
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}"
107
104
class="inline no-underline hover:underline dark:text-white">
108
105
{{ index $messageParts 0 }}
109
106
</a>
110
107
{{ if gt (len $messageParts) 1 }}
111
108
<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"
109
+
class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600"
113
110
hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">
114
111
{{ i "ellipsis" "w-3 h-3" }}
115
112
</button>
···
159
156
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
160
157
<a href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}"
161
158
class="text-gray-500 dark:text-gray-400 no-underline hover:underline">
162
-
{{ if $didOrHandle }}{{ $didOrHandle }}{{ else }}{{ $commit.Author.Name }}{{ end }}
159
+
{{ if $didOrHandle }}{{ template "user/fragments/picHandleLink" $didOrHandle }}{{ else }}{{ $commit.Author.Name }}{{ end }}
163
160
</a>
164
161
</span>
165
162
<div class="inline-block px-1 select-none after:content-['ยท']"></div>
166
-
<span>{{ shortTimeFmt $commit.Committer.When }}</span>
163
+
<span>{{ template "repo/fragments/shortTime" $commit.Committer.When }}</span>
167
164
168
165
<!-- ci status -->
169
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
1
{{ define "repo/pipelines/fragments/logBlock" }}
2
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">
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
5
<div class="group-open:hidden flex items-center gap-1">
6
6
{{ i "chevron-right" "w-4 h-4" }} {{ .Name }}
7
7
</div>
···
9
9
{{ i "chevron-down" "w-4 h-4" }} {{ .Name }}
10
10
</div>
11
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>
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
13
</details>
14
14
</div>
15
15
{{ end }}
+5
-9
appview/pages/templates/repo/pipelines/fragments/tooltip.html
+5
-9
appview/pages/templates/repo/pipelines/fragments/tooltip.html
···
10
10
{{ $lastStatus := $all.Latest }}
11
11
{{ $kind := $lastStatus.Status.String }}
12
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
13
<div id="left" class="flex items-center gap-2 flex-shrink-0">
22
14
{{ template "repo/pipelines/fragments/workflowSymbol" $all }}
23
15
{{ $name }}
24
16
</div>
25
17
<div id="right" class="flex items-center gap-2 flex-shrink-0">
26
18
<span class="font-bold">{{ $kind }}</span>
27
-
<time>{{ $time }}</time>
19
+
{{ if .TimeTaken }}
20
+
{{ template "repo/fragments/duration" .TimeTaken }}
21
+
{{ else }}
22
+
{{ template "repo/fragments/shortTimeAgo" $pipeline.Created }}
23
+
{{ end }}
28
24
</div>
29
25
</div>
30
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
17
</section>
18
18
{{ end }}
19
19
20
-
{{ define "repoAfter" }}
21
-
{{ end }}
22
-
23
20
{{ define "sidebar" }}
24
21
{{ $active := .Workflow }}
25
22
{{ with .Pipeline }}
···
32
29
{{ $lastStatus := $all.Latest }}
33
30
{{ $kind := $lastStatus.Status.String }}
34
31
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
32
<div id="left" class="flex items-center gap-2 flex-shrink-0">
45
33
{{ template "repo/pipelines/fragments/workflowSymbol" $all }}
46
34
{{ $name }}
47
35
</div>
48
36
<div id="right" class="flex items-center gap-2 flex-shrink-0">
49
37
<span class="font-bold">{{ $kind }}</span>
50
-
<time>{{ $time }}</time>
38
+
{{ if .TimeTaken }}
39
+
{{ template "repo/fragments/duration" .TimeTaken }}
40
+
{{ else }}
41
+
{{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }}
42
+
{{ end }}
51
43
</div>
52
44
</div>
53
45
</a>
+18
-2
appview/pages/templates/repo/pulls/fragments/pullHeader.html
+18
-2
appview/pages/templates/repo/pulls/fragments/pullHeader.html
···
29
29
<span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1">
30
30
opened by
31
31
{{ $owner := index $.DidHandleMap .Pull.OwnerDid }}
32
-
{{ template "user/fragments/picHandle" $owner }}
32
+
{{ template "user/fragments/picHandleLink" $owner }}
33
33
<span class="select-none before:content-['\00B7']"></span>
34
-
<time>{{ .Pull.Created | timeFmt }}</time>
34
+
{{ template "repo/fragments/time" .Pull.Created }}
35
35
36
36
<span class="select-none before:content-['\00B7']"></span>
37
37
<span>
···
60
60
<article id="body" class="mt-8 prose dark:prose-invert">
61
61
{{ .Pull.Body | markdown }}
62
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>
63
79
{{ end }}
64
80
</section>
65
81
+2
-3
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
+2
-3
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
···
1
1
{{ define "repo/pulls/fragments/pullNewComment" }}
2
-
<div
3
-
id="pull-comment-card-{{ .RoundNumber }}"
2
+
<div
3
+
id="pull-comment-card-{{ .RoundNumber }}"
4
4
class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2">
5
5
<div class="text-sm text-gray-500 dark:text-gray-400">
6
6
{{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }}
···
38
38
</form>
39
39
</div>
40
40
{{ end }}
41
-
-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
13
</span>
14
14
</div>
15
15
16
-
<div class="flex-shrink-0 flex items-center">
16
+
<div class="flex-shrink-0 flex items-center gap-2">
17
17
{{ $latestRound := .LastRoundNumber }}
18
18
{{ $lastSubmission := index .Submissions $latestRound }}
19
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>
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>
25
23
{{ end }}
26
24
<span>
27
-
<div class="inline-flex items-center gap-2">
25
+
<div class="inline-flex items-center gap-1">
28
26
{{ i "message-square" "w-3 h-3 md:hidden" }}
29
27
{{ $commentCount }}
30
28
<span class="hidden md:inline">comment{{if ne $commentCount 1}}s{{end}}</span>
31
29
</div>
32
30
</span>
33
-
<span class="mx-2 before:content-['ยท'] before:select-none"></span>
31
+
<span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span>
34
32
<span>
35
33
<span class="hidden md:inline">round</span>
36
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
26
</header>
27
27
</section>
28
28
29
-
<section>
30
-
{{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff) }}
31
-
</section>
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>
32
54
{{ end }}
33
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
31
<div class="border-t border-gray-200 dark:border-gray-700 my-2"></div>
32
32
{{ template "repo/pulls/fragments/pullHeader" . }}
33
33
</section>
34
-
{{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }}
35
34
</section>
36
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
5
{{ define "extrameta" }}
6
6
{{ $title := printf "%s · pull #%d · %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }}
7
7
{{ $url := printf "https://tangled.sh/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }}
8
-
8
+
9
9
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10
10
{{ end }}
11
11
···
46
46
</div>
47
47
<!-- round summary -->
48
48
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
49
-
<span>
49
+
<span class="gap-1 flex items-center">
50
50
{{ $owner := index $.DidHandleMap $.Pull.OwnerDid }}
51
51
{{ $re := "re" }}
52
52
{{ if eq .RoundNumber 0 }}
53
53
{{ $re = "" }}
54
54
{{ end }}
55
55
<span class="hidden md:inline">{{$re}}submitted</span>
56
-
by <a href="/{{ $owner }}">{{ $owner }}</a>
56
+
by {{ template "user/fragments/picHandleLink" $owner }}
57
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>
58
+
<a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}">{{ template "repo/fragments/shortTime" .Created }}</a>
59
59
<span class="select-none before:content-['ยท']"></span>
60
60
{{ $s := "s" }}
61
61
{{ if eq (len .Comments) 1 }}
···
68
68
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
69
69
hx-boost="true"
70
70
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}">
71
-
{{ i "file-diff" "w-4 h-4" }}
71
+
{{ i "file-diff" "w-4 h-4" }}
72
72
<span class="hidden md:inline">diff</span>
73
73
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
74
74
</a>
···
150
150
{{ if gt $cidx 0 }}
151
151
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
152
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>
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
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>
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
158
</div>
159
159
<div class="prose dark:prose-invert">
160
160
{{ $c.Body | markdown }}
···
179
179
{{ end }}
180
180
</div>
181
181
</details>
182
-
<hr class="md:hidden border-t border-gray-300 dark:border-gray-600"/>
183
182
{{ end }}
184
183
{{ end }}
185
184
{{ end }}
···
277
276
{{ $lastStatus := $all.Latest }}
278
277
{{ $kind := $lastStatus.Status.String }}
279
278
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
279
<div id="left" class="flex items-center gap-2 flex-shrink-0">
290
280
{{ template "repo/pipelines/fragments/workflowSymbol" $all }}
291
281
{{ $name }}
292
282
</div>
293
283
<div id="right" class="flex items-center gap-2 flex-shrink-0">
294
284
<span class="font-bold">{{ $kind }}</span>
295
-
<time>{{ $time }}</time>
285
+
{{ if .TimeTaken }}
286
+
{{ template "repo/fragments/duration" .TimeTaken }}
287
+
{{ else }}
288
+
{{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }}
289
+
{{ end }}
296
290
</div>
297
291
</div>
298
292
</a>
+46
-57
appview/pages/templates/repo/pulls/pulls.html
+46
-57
appview/pages/templates/repo/pulls/pulls.html
···
54
54
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
55
55
</a>
56
56
</div>
57
-
<p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
57
+
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
58
58
{{ $owner := index $.DidHandleMap .OwnerDid }}
59
59
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
60
60
{{ $icon := "ban" }}
···
76
76
</span>
77
77
78
78
<span class="ml-1">
79
-
{{ template "user/fragments/picHandle" $owner }}
79
+
{{ template "user/fragments/picHandleLink" $owner }}
80
80
</span>
81
81
82
-
<span>
83
-
<time>
84
-
{{ .Created | timeFmt }}
85
-
</time>
82
+
<span class="before:content-['ยท']">
83
+
{{ template "repo/fragments/time" .Created }}
86
84
</span>
85
+
86
+
87
+
{{ $latestRound := .LastRoundNumber }}
88
+
{{ $lastSubmission := index .Submissions $latestRound }}
87
89
88
90
<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>
91
+
{{ $commentCount := len $lastSubmission.Comments }}
92
+
{{ $s := "s" }}
93
+
{{ if eq $commentCount 1 }}
94
+
{{ $s = "" }}
95
+
{{ end }}
96
+
97
+
{{ len $lastSubmission.Comments}} comment{{$s}}
93
98
</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 -}}
99
+
100
+
<span class="before:content-['ยท']">
101
+
round
102
+
<span class="font-mono">
103
+
#{{ .LastRoundNumber }}
104
+
</span>
105
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 }}
106
111
{{ 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>
112
+
</div>
127
113
</div>
128
114
{{ if .StackId }}
129
115
{{ $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>
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 }}
145
133
{{ end }}
146
134
</div>
147
135
{{ end }}
···
153
141
{{ $root := index . 1 }}
154
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">
155
143
{{ range $pull := $list }}
144
+
{{ $pipeline := index $root.Pipelines $pull.LatestSha }}
156
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">
157
146
<div class="flex gap-2 items-center px-6">
158
147
<div class="flex-grow min-w-0 w-full py-2">
159
-
{{ template "repo/pulls/fragments/summarizedHeader" (list $pull 0) }}
148
+
{{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }}
160
149
</div>
161
150
</div>
162
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
1
{{ define "title" }}settings · {{ .RepoInfo.FullName }}{{ end }}
2
+
2
3
{{ define "repoContent" }}
3
-
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
4
-
Collaborators
5
-
</header>
4
+
{{ template "collaboratorSettings" . }}
5
+
{{ template "branchSettings" . }}
6
+
{{ template "dangerZone" . }}
7
+
{{ template "spindleSelector" . }}
8
+
{{ template "spindleSecrets" . }}
9
+
{{ end }}
6
10
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>
11
+
{{ define "collaboratorSettings" }}
12
+
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
13
+
Collaborators
14
+
</header>
24
15
25
-
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
26
-
<form
27
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"
28
-
class="group"
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"
29
22
>
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>
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>
49
31
{{ end }}
32
+
</div>
50
33
34
+
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
51
35
<form
52
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default"
53
-
class="mt-6 group"
36
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"
37
+
class="group"
54
38
>
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>
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>
115
53
</form>
116
-
{{ end }}
54
+
{{ end }}
55
+
{{ end }}
117
56
118
-
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
57
+
{{ define "dangerZone" }}
58
+
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
119
59
<form
120
60
hx-confirm="Are you sure you want to delete this repository?"
121
61
hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete"
122
62
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>
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>
135
74
</form>
136
-
{{ end }}
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" />
137
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 }}
138
168
{{ end }}
+28
-30
appview/pages/templates/repo/tree.html
+28
-30
appview/pages/templates/repo/tree.html
···
11
11
{{ template "repo/fragments/meta" . }}
12
12
{{ $title := printf "%s at %s · %s" $path .Ref .RepoInfo.FullName }}
13
13
{{ $url := printf "https://tangled.sh/%s/tree/%s%s" .RepoInfo.FullName .Ref $path }}
14
-
14
+
15
15
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
16
16
{{ end }}
17
17
···
19
19
{{define "repoContent"}}
20
20
<main>
21
21
<div class="tree">
22
-
{{ $containerstyle := "py-1" }}
23
22
{{ $linkstyle := "no-underline hover:underline" }}
24
23
25
24
<div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
···
54
53
</div>
55
54
56
55
{{ 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 }}
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>
68
71
</div>
69
-
</div>
70
-
{{ end }}
71
-
{{ end }}
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>
72
78
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 }}
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 }}
85
83
</div>
86
-
</div>
84
+
</div>
87
85
{{ end }}
88
-
{{ end }}
86
+
89
87
</div>
90
88
</main>
91
89
{{end}}
+2
-2
appview/pages/templates/settings.html
+2
-2
appview/pages/templates/settings.html
···
39
39
{{ i "key" "w-3 h-3 dark:text-gray-300" }}
40
40
<p class="font-bold dark:text-white">{{ .Name }}</p>
41
41
</div>
42
-
<p class="text-sm text-gray-500 dark:text-gray-400">added {{ .Created | timeFmt }}</p>
42
+
<p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .Created }}</p>
43
43
<div class="overflow-x-auto whitespace-nowrap flex-1 max-w-full">
44
44
<code class="text-sm text-gray-500 dark:text-gray-400">{{ .Key }}</code>
45
45
</div>
···
112
112
{{ end }}
113
113
</div>
114
114
</div>
115
-
<p class="text-sm text-gray-500 dark:text-gray-400">added {{ .CreatedAt | timeFmt }}</p>
115
+
<p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .CreatedAt }}</p>
116
116
</div>
117
117
<div class="flex gap-2 items-center">
118
118
{{ if not .Verified }}
+1
-1
appview/pages/templates/spindles/fragments/addMemberModal.html
+1
-1
appview/pages/templates/spindles/fragments/addMemberModal.html
···
13
13
<div
14
14
id="add-member-{{ .Instance }}"
15
15
popover
16
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded drop-shadow dark:text-white">
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
17
{{ block "addMemberPopover" . }} {{ end }}
18
18
</div>
19
19
{{ end }}
+2
-2
appview/pages/templates/spindles/fragments/spindleListing.html
+2
-2
appview/pages/templates/spindles/fragments/spindleListing.html
···
11
11
{{ i "hard-drive" "w-4 h-4" }}
12
12
{{ .Instance }}
13
13
<span class="text-gray-500">
14
-
{{ .Created | shortTimeFmt }} ago
14
+
{{ template "repo/fragments/shortTimeAgo" .Created }}
15
15
</span>
16
16
</a>
17
17
{{ else }}
···
19
19
{{ i "hard-drive" "w-4 h-4" }}
20
20
{{ .Instance }}
21
21
<span class="text-gray-500">
22
-
{{ .Created | shortTimeFmt }} ago
22
+
{{ template "repo/fragments/shortTimeAgo" .Created }}
23
23
</span>
24
24
</div>
25
25
{{ end }}
+14
-2
appview/pages/templates/spindles/index.html
+14
-2
appview/pages/templates/spindles/index.html
···
7
7
8
8
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
9
9
<div class="flex flex-col gap-6">
10
-
{{ block "all" . }} {{ end }}
10
+
{{ block "about" . }} {{ end }}
11
+
{{ block "list" . }} {{ end }}
11
12
{{ block "register" . }} {{ end }}
12
13
</div>
13
14
</section>
14
15
{{ end }}
15
16
16
-
{{ define "all" }}
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" }}
17
29
<section class="rounded w-full flex flex-col gap-2">
18
30
<h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your spindles</h2>
19
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
49
<p class="text-xl font-bold dark:text-white">Timeline</p>
50
50
</div>
51
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 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 }}
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 }}
126
67
</div>
127
-
{{ end }}
68
+
{{ end }}
69
+
</div>
70
+
{{ end }}
128
71
</div>
129
72
</div>
130
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
···
1
1
{{ define "user/fragments/picHandle" }}
2
-
<a href="/{{ . }}" class="flex items-center">
3
-
<img
4
-
src="{{ tinyAvatar . }}"
5
-
alt="{{ . }}"
6
-
class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700"
7
-
/>
8
-
{{ . | truncateAt30 }}
9
-
</a>
2
+
<img
3
+
src="{{ tinyAvatar . }}"
4
+
alt="{{ . }}"
5
+
class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700"
6
+
/>
7
+
{{ . | truncateAt30 }}
10
8
{{ end }}
+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
17
/>
18
18
<meta
19
19
property="og:description"
20
-
content="login to tangled"
20
+
content="login to or sign up for tangled"
21
21
/>
22
22
<script src="/static/htmx.min.js"></script>
23
23
<link
···
25
25
href="/static/tw.css?{{ cssContentHash }}"
26
26
type="text/css"
27
27
/>
28
-
<title>login · tangled</title>
28
+
<title>login or sign up · tangled</title>
29
29
</head>
30
30
<body class="flex items-center justify-center min-h-screen">
31
31
<main class="max-w-md px-6 -mt-4">
···
51
51
name="handle"
52
52
tabindex="1"
53
53
required
54
+
placeholder="foo.tngl.sh"
54
55
/>
55
56
<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.
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
60
</span>
61
61
</div>
62
62
···
69
69
<span>login</span>
70
70
</button>
71
71
</form>
72
-
<p class="text-sm text-gray-500">
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">
73
120
Join our <a href="https://chat.tangled.sh">Discord</a> or
74
121
IRC channel:
75
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
8
{{ end }}
9
9
10
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">
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
13
<div class="grid grid-cols-1 gap-4">
14
14
{{ template "user/fragments/profileCard" .Card }}
15
15
{{ block "punchcard" .Punchcard }} {{ end }}
16
16
</div>
17
17
</div>
18
-
<div id="all-repos" class="md:col-span-3 order-2 md:order-2">
18
+
<div id="all-repos" class="md:col-span-4 order-2 md:order-2">
19
19
<div class="grid grid-cols-1 gap-4">
20
20
{{ block "ownRepos" . }}{{ end }}
21
21
{{ block "collaboratingRepos" . }}{{ end }}
22
22
</div>
23
23
</div>
24
-
<div class="md:col-span-3 order-3 md:order-3">
24
+
<div class="md:col-span-4 order-3 md:order-3">
25
25
{{ block "profileTimeline" . }}{{ end }}
26
26
</div>
27
27
</div>
···
258
258
</button>
259
259
{{ end }}
260
260
</div>
261
-
<div id="repos" class="grid grid-cols-1 gap-4">
261
+
<div id="repos" class="grid grid-cols-1 gap-4 items-stretch">
262
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>
263
+
{{ template "user/fragments/repoCard" (list $ . false) }}
285
264
{{ else }}
286
265
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
287
266
{{ end }}
···
295
274
<p class="text-sm font-bold p-2 dark:text-white">COLLABORATING ON</p>
296
275
<div id="collaborating" class="grid grid-cols-1 gap-4">
297
276
{{ 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>
277
+
{{ template "user/fragments/repoCard" (list $ . true) }}
320
278
{{ else }}
321
279
<p class="px-6 dark:text-white">This user is not collaborating.</p>
322
280
{{ end }}
+4
-25
appview/pages/templates/user/repos.html
+4
-25
appview/pages/templates/user/repos.html
···
8
8
{{ end }}
9
9
10
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">
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
13
{{ template "user/fragments/profileCard" .Card }}
14
14
</div>
15
-
<div id="all-repos" class="md:col-span-6 order-2 md:order-2">
15
+
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
16
16
{{ block "ownRepos" . }}{{ end }}
17
17
</div>
18
18
</div>
···
22
22
<p class="text-sm font-bold p-2 dark:text-white">ALL REPOSITORIES</p>
23
23
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
24
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>
25
+
{{ template "user/fragments/repoCard" (list $ . false) }}
47
26
{{ else }}
48
27
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
49
28
{{ end }}
+1
-5
appview/pipelines/pipelines.go
+1
-5
appview/pipelines/pipelines.go
···
11
11
12
12
"tangled.sh/tangled.sh/core/appview/config"
13
13
"tangled.sh/tangled.sh/core/appview/db"
14
-
"tangled.sh/tangled.sh/core/appview/idresolver"
15
14
"tangled.sh/tangled.sh/core/appview/oauth"
16
15
"tangled.sh/tangled.sh/core/appview/pages"
17
16
"tangled.sh/tangled.sh/core/appview/reporesolver"
18
17
"tangled.sh/tangled.sh/core/eventconsumer"
18
+
"tangled.sh/tangled.sh/core/idresolver"
19
19
"tangled.sh/tangled.sh/core/log"
20
20
"tangled.sh/tangled.sh/core/rbac"
21
21
spindlemodel "tangled.sh/tangled.sh/core/spindle/models"
22
22
23
23
"github.com/go-chi/chi/v5"
24
24
"github.com/gorilla/websocket"
25
-
"github.com/posthog/posthog-go"
26
25
)
27
26
28
27
type Pipelines struct {
···
34
33
spindlestream *eventconsumer.Consumer
35
34
db *db.DB
36
35
enforcer *rbac.Enforcer
37
-
posthog posthog.Client
38
36
logger *slog.Logger
39
37
}
40
38
···
46
44
idResolver *idresolver.Resolver,
47
45
db *db.DB,
48
46
config *config.Config,
49
-
posthog posthog.Client,
50
47
enforcer *rbac.Enforcer,
51
48
) *Pipelines {
52
49
logger := log.New("pipelines")
···
58
55
config: config,
59
56
spindlestream: spindlestream,
60
57
db: db,
61
-
posthog: posthog,
62
58
enforcer: enforcer,
63
59
logger: logger,
64
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
14
"time"
15
15
16
16
"tangled.sh/tangled.sh/core/api/tangled"
17
-
"tangled.sh/tangled.sh/core/appview"
18
17
"tangled.sh/tangled.sh/core/appview/config"
19
18
"tangled.sh/tangled.sh/core/appview/db"
20
-
"tangled.sh/tangled.sh/core/appview/idresolver"
19
+
"tangled.sh/tangled.sh/core/appview/notify"
21
20
"tangled.sh/tangled.sh/core/appview/oauth"
22
21
"tangled.sh/tangled.sh/core/appview/pages"
23
22
"tangled.sh/tangled.sh/core/appview/reporesolver"
23
+
"tangled.sh/tangled.sh/core/idresolver"
24
24
"tangled.sh/tangled.sh/core/knotclient"
25
25
"tangled.sh/tangled.sh/core/patchutil"
26
+
"tangled.sh/tangled.sh/core/tid"
26
27
"tangled.sh/tangled.sh/core/types"
27
28
28
29
"github.com/bluekeyes/go-gitdiff/gitdiff"
···
31
32
lexutil "github.com/bluesky-social/indigo/lex/util"
32
33
"github.com/go-chi/chi/v5"
33
34
"github.com/google/uuid"
34
-
"github.com/posthog/posthog-go"
35
35
)
36
36
37
37
type Pulls struct {
···
41
41
idResolver *idresolver.Resolver
42
42
db *db.DB
43
43
config *config.Config
44
-
posthog posthog.Client
44
+
notifier notify.Notifier
45
45
}
46
46
47
47
func New(
···
51
51
resolver *idresolver.Resolver,
52
52
db *db.DB,
53
53
config *config.Config,
54
-
posthog posthog.Client,
54
+
notifier notify.Notifier,
55
55
) *Pulls {
56
56
return &Pulls{
57
57
oauth: oauth,
···
60
60
idResolver: resolver,
61
61
db: db,
62
62
config: config,
63
-
posthog: posthog,
63
+
notifier: notifier,
64
64
}
65
65
}
66
66
···
198
198
m[p.Sha] = p
199
199
}
200
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
+
201
212
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
202
213
LoggedInUser: user,
203
214
RepoInfo: repoInfo,
···
208
219
MergeCheck: mergeCheckResponse,
209
220
ResubmitCheck: resubmitResult,
210
221
Pipelines: m,
222
+
223
+
OrderedReactionKinds: db.OrderedReactionKinds,
224
+
Reactions: reactionCountMap,
225
+
UserReacted: userReactions,
211
226
})
212
227
}
213
228
···
340
355
return
341
356
}
342
357
358
+
var diffOpts types.DiffOpts
359
+
if d := r.URL.Query().Get("diff"); d == "split" {
360
+
diffOpts.Split = true
361
+
}
362
+
343
363
pull, ok := r.Context().Value("pull").(*db.Pull)
344
364
if !ok {
345
365
log.Println("failed to get pull")
···
380
400
Round: roundIdInt,
381
401
Submission: pull.Submissions[roundIdInt],
382
402
Diff: &diff,
403
+
DiffOpts: diffOpts,
383
404
})
384
405
385
406
}
···
393
414
return
394
415
}
395
416
417
+
var diffOpts types.DiffOpts
418
+
if d := r.URL.Query().Get("diff"); d == "split" {
419
+
diffOpts.Split = true
420
+
}
421
+
396
422
pull, ok := r.Context().Value("pull").(*db.Pull)
397
423
if !ok {
398
424
log.Println("failed to get pull")
···
448
474
Round: roundIdInt,
449
475
DidHandleMap: didHandleMap,
450
476
Interdiff: interdiff,
477
+
DiffOpts: diffOpts,
451
478
})
452
-
return
453
479
}
454
480
455
481
func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
···
529
555
530
556
// we want to group all stacked PRs into just one list
531
557
stacks := make(map[string]db.Stack)
558
+
var shas []string
532
559
n := 0
533
560
for _, p := range pulls {
561
+
// store the sha for later
562
+
shas = append(shas, p.LatestSha())
534
563
// this PR is stacked
535
564
if p.StackId != "" {
536
565
// we have already seen this PR stack
···
549
578
}
550
579
pulls = pulls[:n]
551
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
+
552
598
identsToResolve := make([]string, len(pulls))
553
599
for i, pull := range pulls {
554
600
identsToResolve[i] = pull.OwnerDid
···
570
616
DidHandleMap: didHandleMap,
571
617
FilteringBy: state,
572
618
Stacks: stacks,
619
+
Pipelines: m,
573
620
})
574
-
return
575
621
}
576
622
577
623
func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
···
642
688
atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
643
689
Collection: tangled.RepoPullCommentNSID,
644
690
Repo: user.Did,
645
-
Rkey: appview.TID(),
691
+
Rkey: tid.TID(),
646
692
Record: &lexutil.LexiconTypeDecoder{
647
693
Val: &tangled.RepoPullComment{
648
694
Repo: &atUri,
···
659
705
return
660
706
}
661
707
662
-
// Create the pull comment in the database with the commentAt field
663
-
commentId, err := db.NewPullComment(tx, &db.PullComment{
708
+
comment := &db.PullComment{
664
709
OwnerDid: user.Did,
665
710
RepoAt: f.RepoAt.String(),
666
711
PullId: pull.PullId,
667
712
Body: body,
668
713
CommentAt: atResp.Uri,
669
714
SubmissionId: pull.Submissions[roundNumber].ID,
670
-
})
715
+
}
716
+
717
+
// Create the pull comment in the database with the commentAt field
718
+
commentId, err := db.NewPullComment(tx, comment)
671
719
if err != nil {
672
720
log.Println("failed to create pull comment", err)
673
721
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
···
681
729
return
682
730
}
683
731
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
-
}
732
+
s.notifier.NewPullComment(r.Context(), comment)
694
733
695
734
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
696
735
return
···
1019
1058
body = formatPatches[0].Body
1020
1059
}
1021
1060
1022
-
rkey := appview.TID()
1061
+
rkey := tid.TID()
1023
1062
initialSubmission := db.PullSubmission{
1024
1063
Patch: patch,
1025
1064
SourceRev: sourceRev,
1026
1065
}
1027
-
err = db.NewPull(tx, &db.Pull{
1066
+
pull := &db.Pull{
1028
1067
Title: title,
1029
1068
Body: body,
1030
1069
TargetBranch: targetBranch,
···
1035
1074
&initialSubmission,
1036
1075
},
1037
1076
PullSource: pullSource,
1038
-
})
1077
+
}
1078
+
err = db.NewPull(tx, pull)
1039
1079
if err != nil {
1040
1080
log.Println("failed to create pull request", err)
1041
1081
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
···
1075
1115
return
1076
1116
}
1077
1117
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
-
}
1118
+
s.notifier.NewPull(r.Context(), pull)
1088
1119
1089
1120
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
1090
1121
}
···
1647
1678
}
1648
1679
1649
1680
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1650
-
return
1651
1681
}
1652
1682
1653
1683
func (s *Pulls) resubmitStackedPullHelper(
···
1891
1921
}
1892
1922
1893
1923
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1894
-
return
1895
1924
}
1896
1925
1897
1926
func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
···
2015
2044
2016
2045
// auth filter: only owner or collaborators can close
2017
2046
roles := f.RolesInRepo(user)
2047
+
isOwner := roles.IsOwner()
2018
2048
isCollaborator := roles.IsCollaborator()
2019
2049
isPullAuthor := user.Did == pull.OwnerDid
2020
-
isCloseAllowed := isCollaborator || isPullAuthor
2050
+
isCloseAllowed := isOwner || isCollaborator || isPullAuthor
2021
2051
if !isCloseAllowed {
2022
2052
log.Println("failed to close pull")
2023
2053
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
···
2061
2091
}
2062
2092
2063
2093
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2064
-
return
2065
2094
}
2066
2095
2067
2096
func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) {
···
2083
2112
2084
2113
// auth filter: only owner or collaborators can close
2085
2114
roles := f.RolesInRepo(user)
2115
+
isOwner := roles.IsOwner()
2086
2116
isCollaborator := roles.IsCollaborator()
2087
2117
isPullAuthor := user.Did == pull.OwnerDid
2088
-
isCloseAllowed := isCollaborator || isPullAuthor
2118
+
isCloseAllowed := isOwner || isCollaborator || isPullAuthor
2089
2119
if !isCloseAllowed {
2090
2120
log.Println("failed to close pull")
2091
2121
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
···
2129
2159
}
2130
2160
2131
2161
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2132
-
return
2133
2162
}
2134
2163
2135
2164
func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) {
···
2155
2184
2156
2185
title := fp.Title
2157
2186
body := fp.Body
2158
-
rkey := appview.TID()
2187
+
rkey := tid.TID()
2159
2188
2160
2189
initialSubmission := db.PullSubmission{
2161
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
14
"github.com/go-git/go-git/v5/plumbing"
15
15
"github.com/ipfs/go-cid"
16
16
"tangled.sh/tangled.sh/core/api/tangled"
17
-
"tangled.sh/tangled.sh/core/appview"
18
17
"tangled.sh/tangled.sh/core/appview/db"
19
18
"tangled.sh/tangled.sh/core/appview/pages"
20
19
"tangled.sh/tangled.sh/core/appview/reporesolver"
21
20
"tangled.sh/tangled.sh/core/knotclient"
21
+
"tangled.sh/tangled.sh/core/tid"
22
22
"tangled.sh/tangled.sh/core/types"
23
23
)
24
24
···
64
64
65
65
log.Println("uploaded blob", humanize.Bytes(uint64(uploadBlobResp.Blob.Size)), uploadBlobResp.Blob.Ref.String())
66
66
67
-
rkey := appview.TID()
67
+
rkey := tid.TID()
68
68
createdAt := time.Now()
69
69
70
70
putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+49
-25
appview/repo/index.go
+49
-25
appview/repo/index.go
···
58
58
tagMap[hash] = append(tagMap[hash], branch.Name)
59
59
}
60
60
61
+
sortFiles(result.Files)
62
+
61
63
slices.SortFunc(result.Branches, func(a, b types.Branch) int {
62
64
if a.Name == result.Ref {
63
65
return -1
···
123
125
}
124
126
}
125
127
126
-
languageInfo, err := getLanguageInfo(f, signedClient, ref)
128
+
// TODO: a bit dirty
129
+
languageInfo, err := rp.getLanguageInfo(f, signedClient, chi.URLParam(r, "ref") == "")
127
130
if err != nil {
128
131
log.Printf("failed to compute language percentages: %s", err)
129
132
// non-fatal
···
153
156
Languages: languageInfo,
154
157
Pipelines: pipelines,
155
158
})
156
-
return
157
159
}
158
160
159
-
func getLanguageInfo(
161
+
func (rp *Repo) getLanguageInfo(
160
162
f *reporesolver.ResolvedRepo,
161
163
signedClient *knotclient.SignedClient,
162
-
ref string,
164
+
isDefaultRef bool,
163
165
) ([]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
-
}
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
+
)
176
172
177
-
var languageStats []types.RepoLanguageDetails
178
-
var otherPercentage float32 = 0
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
+
}
179
182
180
-
for lang, size := range repoLanguages.Languages {
181
-
percentage := (float32(size) / float32(totalSize)) * 100
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
+
}
182
192
183
-
if percentage <= 0.5 {
184
-
otherPercentage += percentage
185
-
continue
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)
186
198
}
199
+
}
187
200
188
-
color := enry.GetColor(lang)
201
+
var total int64
202
+
for _, l := range langs {
203
+
total += l.Bytes
204
+
}
189
205
190
-
languageStats = append(languageStats, types.RepoLanguageDetails{Name: lang, Percentage: percentage, Color: color})
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
+
})
191
215
}
192
216
193
217
sort.Slice(languageStats, func(i, j int) bool {
+441
-128
appview/repo/repo.go
+441
-128
appview/repo/repo.go
···
8
8
"fmt"
9
9
"io"
10
10
"log"
11
+
"log/slog"
11
12
"net/http"
12
13
"net/url"
13
-
"path"
14
+
"path/filepath"
14
15
"slices"
15
-
"sort"
16
16
"strconv"
17
17
"strings"
18
18
"time"
19
19
20
20
"tangled.sh/tangled.sh/core/api/tangled"
21
-
"tangled.sh/tangled.sh/core/appview"
22
21
"tangled.sh/tangled.sh/core/appview/commitverify"
23
22
"tangled.sh/tangled.sh/core/appview/config"
24
23
"tangled.sh/tangled.sh/core/appview/db"
25
-
"tangled.sh/tangled.sh/core/appview/idresolver"
24
+
"tangled.sh/tangled.sh/core/appview/notify"
26
25
"tangled.sh/tangled.sh/core/appview/oauth"
27
26
"tangled.sh/tangled.sh/core/appview/pages"
28
27
"tangled.sh/tangled.sh/core/appview/pages/markup"
29
28
"tangled.sh/tangled.sh/core/appview/reporesolver"
30
29
"tangled.sh/tangled.sh/core/eventconsumer"
30
+
"tangled.sh/tangled.sh/core/idresolver"
31
31
"tangled.sh/tangled.sh/core/knotclient"
32
32
"tangled.sh/tangled.sh/core/patchutil"
33
33
"tangled.sh/tangled.sh/core/rbac"
34
+
"tangled.sh/tangled.sh/core/tid"
34
35
"tangled.sh/tangled.sh/core/types"
35
36
36
37
securejoin "github.com/cyphar/filepath-securejoin"
37
38
"github.com/go-chi/chi/v5"
38
39
"github.com/go-git/go-git/v5/plumbing"
39
-
"github.com/posthog/posthog-go"
40
40
41
41
comatproto "github.com/bluesky-social/indigo/api/atproto"
42
+
"github.com/bluesky-social/indigo/atproto/syntax"
42
43
lexutil "github.com/bluesky-social/indigo/lex/util"
43
44
)
44
45
···
51
52
spindlestream *eventconsumer.Consumer
52
53
db *db.DB
53
54
enforcer *rbac.Enforcer
54
-
posthog posthog.Client
55
+
notifier notify.Notifier
56
+
logger *slog.Logger
55
57
}
56
58
57
59
func New(
···
62
64
idResolver *idresolver.Resolver,
63
65
db *db.DB,
64
66
config *config.Config,
65
-
posthog posthog.Client,
67
+
notifier notify.Notifier,
66
68
enforcer *rbac.Enforcer,
69
+
logger *slog.Logger,
67
70
) *Repo {
68
71
return &Repo{oauth: oauth,
69
72
repoResolver: repoResolver,
···
72
75
config: config,
73
76
spindlestream: spindlestream,
74
77
db: db,
75
-
posthog: posthog,
78
+
notifier: notifier,
76
79
enforcer: enforcer,
80
+
logger: logger,
77
81
}
78
82
}
79
83
···
106
110
return
107
111
}
108
112
109
-
result, err := us.Tags(f.OwnerDid(), f.RepoName)
113
+
tagResult, err := us.Tags(f.OwnerDid(), f.RepoName)
110
114
if err != nil {
111
115
log.Println("failed to reach knotserver", err)
112
116
return
113
117
}
114
118
115
119
tagMap := make(map[string][]string)
116
-
for _, tag := range result.Tags {
120
+
for _, tag := range tagResult.Tags {
117
121
hash := tag.Hash
118
122
if tag.Tag != nil {
119
123
hash = tag.Tag.Target.String()
···
121
125
tagMap[hash] = append(tagMap[hash], tag.Name)
122
126
}
123
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
+
124
139
user := rp.oauth.GetUser(r)
125
140
126
141
emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true)
···
154
169
VerifiedCommits: vc,
155
170
Pipelines: pipelines,
156
171
})
157
-
return
158
172
}
159
173
160
174
func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
···
169
183
rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
170
184
RepoInfo: f.RepoInfo(user),
171
185
})
172
-
return
173
186
}
174
187
175
188
func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) {
···
268
281
protocol = "https"
269
282
}
270
283
284
+
var diffOpts types.DiffOpts
285
+
if d := r.URL.Query().Get("diff"); d == "split" {
286
+
diffOpts.Split = true
287
+
}
288
+
271
289
if !plumbing.IsHash(ref) {
272
290
rp.pages.Error404(w)
273
291
return
···
321
339
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
322
340
VerifiedCommit: vc,
323
341
Pipeline: pipeline,
342
+
DiffOpts: diffOpts,
324
343
})
325
-
return
326
344
}
327
345
328
346
func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) {
···
359
377
360
378
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
361
379
// so we can safely redirect to the "parent" (which is the same file).
362
-
if len(result.Files) == 0 && result.Parent == treePath {
380
+
unescapedTreePath, _ := url.PathUnescape(treePath)
381
+
if len(result.Files) == 0 && result.Parent == unescapedTreePath {
363
382
http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound)
364
383
return
365
384
}
···
374
393
}
375
394
}
376
395
377
-
baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath)
378
-
baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath)
396
+
sortFiles(result.Files)
379
397
380
398
rp.pages.RepoTree(w, pages.RepoTreeParams{
381
399
LoggedInUser: user,
382
400
BreadCrumbs: breadcrumbs,
383
-
BaseTreeLink: baseTreeLink,
384
-
BaseBlobLink: baseBlobLink,
401
+
TreePath: treePath,
385
402
RepoInfo: f.RepoInfo(user),
386
403
RepoTreeResponse: result,
387
404
})
388
-
return
389
405
}
390
406
391
407
func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) {
···
443
459
ArtifactMap: artifactMap,
444
460
DanglingArtifacts: danglingArtifacts,
445
461
})
446
-
return
447
462
}
448
463
449
464
func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) {
···
465
480
return
466
481
}
467
482
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
-
})
483
+
sortBranches(result.Branches)
484
484
485
485
user := rp.oauth.GetUser(r)
486
486
rp.pages.RepoBranches(w, pages.RepoBranchesParams{
···
488
488
RepoInfo: f.RepoInfo(user),
489
489
RepoBranchesResponse: *result,
490
490
})
491
-
return
492
491
}
493
492
494
493
func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
···
539
538
showRendered = r.URL.Query().Get("code") != "true"
540
539
}
541
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
+
542
566
user := rp.oauth.GetUser(r)
543
567
rp.pages.RepoBlob(w, pages.RepoBlobParams{
544
568
LoggedInUser: user,
···
547
571
BreadCrumbs: breadcrumbs,
548
572
ShowRendered: showRendered,
549
573
RenderToggle: renderToggle,
574
+
Unsupported: unsupported,
575
+
IsImage: isImage,
576
+
IsVideo: isVideo,
577
+
ContentSrc: contentSrc,
550
578
})
551
-
return
552
579
}
553
580
554
581
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
555
582
f, err := rp.repoResolver.Resolve(r)
556
583
if err != nil {
557
584
log.Println("failed to get repo and knot", err)
585
+
w.WriteHeader(http.StatusBadRequest)
558
586
return
559
587
}
560
588
···
565
593
if !rp.config.Core.Dev {
566
594
protocol = "https"
567
595
}
568
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
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)
569
598
if err != nil {
570
-
log.Println("failed to reach knotserver", err)
599
+
log.Println("failed to reach knotserver:", err)
600
+
rp.pages.Error503(w)
571
601
return
572
602
}
603
+
defer resp.Body.Close()
573
604
574
-
body, err := io.ReadAll(resp.Body)
575
-
if err != nil {
576
-
log.Printf("Error reading response body: %v", err)
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)
577
609
return
578
610
}
579
611
580
-
var result types.RepoBlobResponse
581
-
err = json.Unmarshal(body, &result)
612
+
contentType := resp.Header.Get("Content-Type")
613
+
body, err := io.ReadAll(resp.Body)
582
614
if err != nil {
583
-
log.Println("failed to parse response:", err)
615
+
log.Printf("error reading response body from knotserver: %v", err)
616
+
w.WriteHeader(http.StatusInternalServerError)
584
617
return
585
618
}
586
619
587
-
if result.IsBinary {
588
-
w.Header().Set("Content-Type", "application/octet-stream")
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)
589
625
w.Write(body)
626
+
} else {
627
+
w.WriteHeader(http.StatusUnsupportedMediaType)
628
+
w.Write([]byte("unsupported content type"))
590
629
return
591
630
}
592
-
593
-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
594
-
w.Write([]byte(result.Contents))
595
-
return
596
631
}
597
632
598
633
// modify the spindle configured for this repo
599
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
+
600
646
f, err := rp.repoResolver.Resolve(r)
601
647
if err != nil {
602
-
log.Println("failed to get repo and knot", err)
603
-
w.WriteHeader(http.StatusBadRequest)
648
+
fail("Failed to resolve repo. Try again later", err)
604
649
return
605
650
}
606
651
607
652
repoAt := f.RepoAt
608
653
rkey := repoAt.RecordKey().String()
609
654
if rkey == "" {
610
-
log.Println("invalid aturi for repo", err)
611
-
w.WriteHeader(http.StatusInternalServerError)
655
+
fail("Failed to resolve repo. Try again later", err)
612
656
return
613
657
}
614
658
615
-
user := rp.oauth.GetUser(r)
616
-
617
659
newSpindle := r.FormValue("spindle")
618
660
client, err := rp.oauth.AuthorizedClient(r)
619
661
if err != nil {
620
-
log.Println("failed to get client")
621
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
662
+
fail("Failed to authorize. Try again later.", err)
622
663
return
623
664
}
624
665
625
666
// ensure that this is a valid spindle for this user
626
667
validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
627
668
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.")
669
+
fail("Failed to find spindles. Try again later.", err)
630
670
return
631
671
}
632
672
633
673
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.")
674
+
fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles))
636
675
return
637
676
}
638
677
639
678
// optimistic update
640
679
err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle)
641
680
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.")
681
+
fail("Failed to update spindle. Try again later.", err)
644
682
return
645
683
}
646
684
647
685
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
648
686
if err != nil {
649
-
// failed to get record
650
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, no record found on PDS.")
687
+
fail("Failed to update spindle, no record found on PDS.", err)
651
688
return
652
689
}
653
690
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
···
668
705
})
669
706
670
707
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.")
708
+
fail("Failed to update spindle, unable to save to PDS.", err)
674
709
return
675
710
}
676
711
···
680
715
eventconsumer.NewSpindleSource(newSpindle),
681
716
)
682
717
683
-
w.Write(fmt.Append(nil, "spindle set to: ", newSpindle))
718
+
rp.pages.HxRefresh(w)
684
719
}
685
720
686
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
+
687
727
f, err := rp.repoResolver.Resolve(r)
688
728
if err != nil {
689
-
log.Println("failed to get repo and knot", err)
729
+
l.Error("failed to get repo and knot", "err", err)
690
730
return
691
731
}
692
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
+
693
739
collaborator := r.FormValue("collaborator")
694
740
if collaborator == "" {
695
-
http.Error(w, "malformed form", http.StatusBadRequest)
741
+
fail("Invalid form.", nil)
696
742
return
697
743
}
744
+
745
+
// remove a single leading `@`, to make @handle work with ResolveIdent
746
+
collaborator = strings.TrimPrefix(collaborator, "@")
698
747
699
748
collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator)
700
749
if err != nil {
701
-
w.Write([]byte("failed to resolve collaborator did to a handle"))
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)
702
756
return
703
757
}
704
-
log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot)
758
+
l = l.With("collaborator", collaboratorIdent.Handle)
759
+
l = l.With("knot", f.Knot)
705
760
706
-
// TODO: create an atproto record for this
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
+
}
707
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")
708
792
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
709
793
if err != nil {
710
-
log.Printf("no key found for domain %s: %s\n", f.Knot, err)
794
+
fail("Failed to add to knot.", err)
711
795
return
712
796
}
713
797
714
798
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
715
799
if err != nil {
716
-
log.Println("failed to create client to ", f.Knot)
800
+
fail("Failed to add to knot.", err)
717
801
return
718
802
}
719
803
720
804
ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String())
721
805
if err != nil {
722
-
log.Printf("failed to make request to %s: %s", f.Knot, err)
806
+
fail("Knot was unreachable.", err)
723
807
return
724
808
}
725
809
726
810
if ksResp.StatusCode != http.StatusNoContent {
727
-
w.Write(fmt.Append(nil, "knotserver failed to add collaborator: ", err))
811
+
fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil)
728
812
return
729
813
}
730
814
731
815
tx, err := rp.db.BeginTx(r.Context(), nil)
732
816
if err != nil {
733
-
log.Println("failed to start tx")
734
-
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
817
+
fail("Failed to add collaborator.", err)
735
818
return
736
819
}
737
820
defer func() {
738
821
tx.Rollback()
739
822
err = rp.enforcer.E.LoadPolicy()
740
823
if err != nil {
741
-
log.Println("failed to rollback policies")
824
+
fail("Failed to add collaborator.", err)
742
825
}
743
826
}()
744
827
745
828
err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
746
829
if err != nil {
747
-
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
830
+
fail("Failed to add collaborator permissions.", err)
748
831
return
749
832
}
750
833
751
-
err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot)
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
+
})
752
841
if err != nil {
753
-
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
842
+
fail("Failed to add collaborator.", err)
754
843
return
755
844
}
756
845
757
846
err = tx.Commit()
758
847
if err != nil {
759
-
log.Println("failed to commit changes", err)
760
-
http.Error(w, err.Error(), http.StatusInternalServerError)
848
+
fail("Failed to add collaborator.", err)
761
849
return
762
850
}
763
851
764
852
err = rp.enforcer.E.SavePolicy()
765
853
if err != nil {
766
-
log.Println("failed to update ACLs", err)
767
-
http.Error(w, err.Error(), http.StatusInternalServerError)
854
+
fail("Failed to update collaborator permissions.", err)
768
855
return
769
856
}
770
857
771
-
w.Write(fmt.Append(nil, "added collaborator: ", collaboratorIdent.Handle.String()))
772
-
858
+
rp.pages.HxRefresh(w)
773
859
}
774
860
775
861
func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
···
921
1007
w.Write(fmt.Append(nil, "default branch set to: ", branch))
922
1008
}
923
1009
924
-
func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
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
+
925
1016
f, err := rp.repoResolver.Resolve(r)
926
1017
if err != nil {
927
1018
log.Println("failed to get repo and knot", err)
928
1019
return
929
1020
}
930
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
+
931
1049
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
-
}
1050
+
case http.MethodPut:
1051
+
errorId := "add-secret-error"
939
1052
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
-
}
1053
+
value := r.FormValue("value")
1054
+
if value == "" {
1055
+
w.WriteHeader(http.StatusBadRequest)
1056
+
return
946
1057
}
947
1058
948
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
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
+
)
949
1068
if err != nil {
950
-
log.Println("failed to create unsigned client", err)
1069
+
l.Error("Failed to add secret.", "err", err)
1070
+
rp.pages.Notice(w, errorId, "Failed to add secret.")
951
1071
return
952
1072
}
953
1073
954
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
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
+
)
955
1085
if err != nil {
956
-
log.Println("failed to reach knotserver", err)
1086
+
l.Error("Failed to delete secret.", "err", err)
1087
+
rp.pages.Notice(w, errorId, "Failed to delete secret.")
957
1088
return
958
1089
}
1090
+
}
959
1091
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
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
965
1251
}
1252
+
}
966
1253
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,
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(),
975
1273
})
976
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
+
})
977
1285
}
978
1286
979
1287
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
···
1093
1401
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1094
1402
sourceAt := f.RepoAt.String()
1095
1403
1096
-
rkey := appview.TID()
1404
+
rkey := tid.TID()
1097
1405
repo := &db.Repo{
1098
1406
Did: user.Did,
1099
1407
Name: forkName,
···
1218
1526
return
1219
1527
}
1220
1528
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
-
})
1529
+
1530
+
sortBranches(branches)
1224
1531
1225
1532
var defaultBranch string
1226
1533
for _, b := range branches {
···
1267
1574
if err != nil {
1268
1575
log.Println("failed to get repo and knot", err)
1269
1576
return
1577
+
}
1578
+
1579
+
var diffOpts types.DiffOpts
1580
+
if d := r.URL.Query().Get("diff"); d == "split" {
1581
+
diffOpts.Split = true
1270
1582
}
1271
1583
1272
1584
// if user is navigating to one of
···
1331
1643
Base: base,
1332
1644
Head: head,
1333
1645
Diff: &diff,
1646
+
DiffOpts: diffOpts,
1334
1647
})
1335
1648
1336
1649
}
+34
appview/repo/repo_util.go
+34
appview/repo/repo_util.go
···
5
5
"crypto/rand"
6
6
"fmt"
7
7
"math/big"
8
+
"slices"
9
+
"sort"
10
+
"strings"
8
11
9
12
"tangled.sh/tangled.sh/core/appview/db"
10
13
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
14
+
"tangled.sh/tangled.sh/core/types"
11
15
12
16
"github.com/go-git/go-git/v5/plumbing/object"
13
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
+
}
14
48
15
49
func uniqueEmails(commits []*object.Commit) []string {
16
50
emails := make(map[string]struct{})
+2
appview/repo/router.go
+2
appview/repo/router.go
···
74
74
r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator)
75
75
r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo)
76
76
r.Put("/branches/default", rp.SetDefaultBranch)
77
+
r.Put("/secrets", rp.Secrets)
78
+
r.Delete("/secrets", rp.Secrets)
77
79
})
78
80
})
79
81
+5
-4
appview/reporesolver/resolver.go
+5
-4
appview/reporesolver/resolver.go
···
17
17
"github.com/go-chi/chi/v5"
18
18
"tangled.sh/tangled.sh/core/appview/config"
19
19
"tangled.sh/tangled.sh/core/appview/db"
20
-
"tangled.sh/tangled.sh/core/appview/idresolver"
21
20
"tangled.sh/tangled.sh/core/appview/oauth"
22
21
"tangled.sh/tangled.sh/core/appview/pages"
23
22
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
23
+
"tangled.sh/tangled.sh/core/idresolver"
24
24
"tangled.sh/tangled.sh/core/knotclient"
25
25
"tangled.sh/tangled.sh/core/rbac"
26
26
)
···
149
149
for _, item := range repoCollaborators {
150
150
// currently only two roles: owner and member
151
151
var role string
152
-
if item[3] == "repo:owner" {
152
+
switch item[3] {
153
+
case "repo:owner":
153
154
role = "owner"
154
-
} else if item[3] == "repo:collaborator" {
155
+
case "repo:collaborator":
155
156
role = "collaborator"
156
-
} else {
157
+
default:
157
158
continue
158
159
}
159
160
+2
-2
appview/settings/settings.go
+2
-2
appview/settings/settings.go
···
12
12
13
13
"github.com/go-chi/chi/v5"
14
14
"tangled.sh/tangled.sh/core/api/tangled"
15
-
"tangled.sh/tangled.sh/core/appview"
16
15
"tangled.sh/tangled.sh/core/appview/config"
17
16
"tangled.sh/tangled.sh/core/appview/db"
18
17
"tangled.sh/tangled.sh/core/appview/email"
19
18
"tangled.sh/tangled.sh/core/appview/middleware"
20
19
"tangled.sh/tangled.sh/core/appview/oauth"
21
20
"tangled.sh/tangled.sh/core/appview/pages"
21
+
"tangled.sh/tangled.sh/core/tid"
22
22
23
23
comatproto "github.com/bluesky-social/indigo/api/atproto"
24
24
lexutil "github.com/bluesky-social/indigo/lex/util"
···
366
366
return
367
367
}
368
368
369
-
rkey := appview.TID()
369
+
rkey := tid.TID()
370
370
371
371
tx, err := s.Db.Begin()
372
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
10
11
11
"github.com/go-chi/chi/v5"
12
12
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview"
14
13
"tangled.sh/tangled.sh/core/appview/config"
15
14
"tangled.sh/tangled.sh/core/appview/db"
16
-
"tangled.sh/tangled.sh/core/appview/idresolver"
17
15
"tangled.sh/tangled.sh/core/appview/middleware"
18
16
"tangled.sh/tangled.sh/core/appview/oauth"
19
17
"tangled.sh/tangled.sh/core/appview/pages"
20
18
verify "tangled.sh/tangled.sh/core/appview/spindleverify"
19
+
"tangled.sh/tangled.sh/core/idresolver"
21
20
"tangled.sh/tangled.sh/core/rbac"
21
+
"tangled.sh/tangled.sh/core/tid"
22
22
23
23
comatproto "github.com/bluesky-social/indigo/api/atproto"
24
24
"github.com/bluesky-social/indigo/atproto/syntax"
···
104
104
105
105
repos, err := db.GetRepos(
106
106
s.Db,
107
+
0,
107
108
db.FilterEq("spindle", instance),
108
109
)
109
110
if err != nil {
···
113
114
}
114
115
115
116
identsToResolve := make([]string, len(members))
116
-
for i, member := range members {
117
-
identsToResolve[i] = member
118
-
}
117
+
copy(identsToResolve, members)
119
118
resolvedIds := s.IdResolver.ResolveIdents(r.Context(), identsToResolve)
120
119
didHandleMap := make(map[string]string)
121
120
for _, identity := range resolvedIds {
···
257
256
258
257
// ok
259
258
s.Pages.HxRefresh(w)
260
-
return
261
259
}
262
260
263
261
func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) {
···
305
303
s.Enforcer.E.LoadPolicy()
306
304
}()
307
305
308
-
err = db.DeleteSpindle(
306
+
// remove spindle members first
307
+
err = db.RemoveSpindleMember(
309
308
tx,
310
-
db.FilterEq("owner", user.Did),
309
+
db.FilterEq("did", user.Did),
311
310
db.FilterEq("instance", instance),
312
311
)
313
312
if err != nil {
314
-
l.Error("failed to delete spindle", "err", err)
313
+
l.Error("failed to remove spindle members", "err", err)
315
314
fail()
316
315
return
317
316
}
318
317
319
-
err = s.Enforcer.RemoveSpindle(instance)
318
+
err = db.DeleteSpindle(
319
+
tx,
320
+
db.FilterEq("owner", user.Did),
321
+
db.FilterEq("instance", instance),
322
+
)
320
323
if err != nil {
321
-
l.Error("failed to update ACL", "err", err)
324
+
l.Error("failed to delete spindle", "err", err)
322
325
fail()
323
326
return
324
327
}
325
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
+
326
339
client, err := s.OAuth.AuthorizedClient(r)
327
340
if err != nil {
328
341
l.Error("failed to authorize client", "err", err)
···
520
533
s.Enforcer.E.LoadPolicy()
521
534
}()
522
535
523
-
rkey := appview.TID()
536
+
rkey := tid.TID()
524
537
525
538
// add member to db
526
539
if err = db.AddSpindleMember(tx, db.SpindleMember{
···
579
592
l := s.Logger.With("handler", "removeMember")
580
593
581
594
noticeId := "operation-error"
582
-
defaultErr := "Failed to add member. Try again later."
595
+
defaultErr := "Failed to remove member. Try again later."
583
596
fail := func() {
584
597
s.Pages.Notice(w, noticeId, defaultErr)
585
598
}
···
707
720
708
721
// ok
709
722
s.Pages.HxRefresh(w)
710
-
return
711
723
}
+13
-26
appview/state/follow.go
+13
-26
appview/state/follow.go
···
7
7
8
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
9
lexutil "github.com/bluesky-social/indigo/lex/util"
10
-
"github.com/posthog/posthog-go"
11
10
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/appview"
13
11
"tangled.sh/tangled.sh/core/appview/db"
14
12
"tangled.sh/tangled.sh/core/appview/pages"
13
+
"tangled.sh/tangled.sh/core/tid"
15
14
)
16
15
17
16
func (s *State) Follow(w http.ResponseWriter, r *http.Request) {
···
42
41
switch r.Method {
43
42
case http.MethodPost:
44
43
createdAt := time.Now().Format(time.RFC3339)
45
-
rkey := appview.TID()
44
+
rkey := tid.TID()
46
45
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
47
46
Collection: tangled.GraphFollowNSID,
48
47
Repo: currentUser.Did,
···
58
57
return
59
58
}
60
59
61
-
err = db.AddFollow(s.db, currentUser.Did, subjectIdent.DID.String(), rkey)
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)
62
69
if err != nil {
63
70
log.Println("failed to follow", err)
64
71
return
65
72
}
66
73
67
-
log.Println("created atproto record: ", resp.Uri)
74
+
s.notifier.NewFollow(r.Context(), follow)
68
75
69
76
s.pages.FollowFragment(w, pages.FollowFragmentParams{
70
77
UserDid: subjectIdent.DID.String(),
71
78
FollowStatus: db.IsFollowing,
72
79
})
73
80
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
81
return
86
82
case http.MethodDelete:
87
83
// find the record in the db
···
113
109
FollowStatus: db.IsNotFollowing,
114
110
})
115
111
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
-
}
112
+
s.notifier.DeleteFollow(r.Context(), follow)
126
113
127
114
return
128
115
}
+75
-12
appview/state/knotstream.go
+75
-12
appview/state/knotstream.go
···
3
3
import (
4
4
"context"
5
5
"encoding/json"
6
+
"errors"
6
7
"fmt"
7
8
"slices"
8
9
"time"
···
18
19
"tangled.sh/tangled.sh/core/workflow"
19
20
20
21
"github.com/bluesky-social/indigo/atproto/syntax"
22
+
"github.com/go-git/go-git/v5/plumbing"
21
23
"github.com/posthog/posthog-go"
22
24
)
23
25
···
39
41
40
42
cfg := ec.ConsumerConfig{
41
43
Sources: srcs,
42
-
ProcessFunc: knotIngester(ctx, d, enforcer, posthog, c.Core.Dev),
44
+
ProcessFunc: knotIngester(d, enforcer, posthog, c.Core.Dev),
43
45
RetryInterval: c.Knotstream.RetryInterval,
44
46
MaxRetryInterval: c.Knotstream.MaxRetryInterval,
45
47
ConnectionTimeout: c.Knotstream.ConnectionTimeout,
···
53
55
return ec.NewConsumer(cfg), nil
54
56
}
55
57
56
-
func knotIngester(ctx context.Context, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client, dev bool) ec.ProcessFunc {
58
+
func knotIngester(d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client, dev bool) ec.ProcessFunc {
57
59
return func(ctx context.Context, source ec.Source, msg ec.Message) error {
58
60
switch msg.Nsid {
59
61
case tangled.GitRefUpdateNSID:
···
81
83
return fmt.Errorf("%s does not belong to %s, something is fishy", record.CommitterDid, source.Key())
82
84
}
83
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 {
84
101
knownEmails, err := db.GetAllEmails(d, record.CommitterDid)
85
102
if err != nil {
86
103
return err
87
104
}
105
+
88
106
count := 0
89
107
for _, ke := range knownEmails {
90
108
if record.Meta == nil {
···
108
126
Date: time.Now(),
109
127
Count: count,
110
128
}
111
-
if err := db.AddPunch(d, punch); err != nil {
112
-
return err
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)
113
154
}
114
155
115
-
if !dev {
116
-
err = pc.Enqueue(posthog.Capture{
117
-
DistinctId: record.CommitterDid,
118
-
Event: "git_ref_update",
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,
119
168
})
120
-
if err != nil {
121
-
// non-fatal, TODO: log this
122
-
}
123
169
}
124
170
125
-
return nil
171
+
return db.InsertRepoLanguages(d, langs)
126
172
}
127
173
128
174
func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
···
138
184
139
185
if record.TriggerMetadata.Repo == nil {
140
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)
141
204
}
142
205
143
206
// trigger info
+12
-15
appview/state/profile.go
+12
-15
appview/state/profile.go
···
16
16
"github.com/bluesky-social/indigo/atproto/syntax"
17
17
lexutil "github.com/bluesky-social/indigo/lex/util"
18
18
"github.com/go-chi/chi/v5"
19
-
"github.com/posthog/posthog-go"
20
19
"tangled.sh/tangled.sh/core/api/tangled"
21
20
"tangled.sh/tangled.sh/core/appview/db"
22
21
"tangled.sh/tangled.sh/core/appview/pages"
···
50
49
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
51
50
}
52
51
53
-
repos, err := db.GetAllReposByDid(s.db, ident.DID.String())
52
+
repos, err := db.GetRepos(
53
+
s.db,
54
+
0,
55
+
db.FilterEq("did", ident.DID.String()),
56
+
)
54
57
if err != nil {
55
58
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
56
59
}
···
171
174
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
172
175
}
173
176
174
-
repos, err := db.GetAllReposByDid(s.db, ident.DID.String())
177
+
repos, err := db.GetRepos(
178
+
s.db,
179
+
0,
180
+
db.FilterEq("did", ident.DID.String()),
181
+
)
175
182
if err != nil {
176
183
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
177
184
}
···
192
199
s.pages.ReposPage(w, pages.ReposPageParams{
193
200
LoggedInUser: loggedInUser,
194
201
Repos: repos,
202
+
DidHandleMap: map[string]string{ident.DID.String(): ident.Handle.String()},
195
203
Card: pages.ProfileCard{
196
204
UserDid: ident.DID.String(),
197
205
UserHandle: ident.Handle.String(),
···
257
265
}
258
266
259
267
s.updateProfile(profile, w, r)
260
-
return
261
268
}
262
269
263
270
func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) {
···
297
304
profile.PinnedRepos = pinnedRepos
298
305
299
306
s.updateProfile(profile, w, r)
300
-
return
301
307
}
302
308
303
309
func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) {
···
362
368
return
363
369
}
364
370
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
-
}
371
+
s.notifier.UpdateProfile(r.Context(), profile)
374
372
375
373
s.pages.HxRedirect(w, "/"+user.Did)
376
-
return
377
374
}
378
375
379
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
7
"github.com/go-chi/chi/v5"
8
8
"github.com/gorilla/sessions"
9
9
"tangled.sh/tangled.sh/core/appview/issues"
10
+
"tangled.sh/tangled.sh/core/appview/knots"
10
11
"tangled.sh/tangled.sh/core/appview/middleware"
11
12
oauthhandler "tangled.sh/tangled.sh/core/appview/oauth/handler"
12
13
"tangled.sh/tangled.sh/core/appview/pipelines"
13
14
"tangled.sh/tangled.sh/core/appview/pulls"
14
15
"tangled.sh/tangled.sh/core/appview/repo"
15
16
"tangled.sh/tangled.sh/core/appview/settings"
17
+
"tangled.sh/tangled.sh/core/appview/signup"
16
18
"tangled.sh/tangled.sh/core/appview/spindles"
17
19
"tangled.sh/tangled.sh/core/appview/state/userutil"
18
20
"tangled.sh/tangled.sh/core/log"
···
101
103
102
104
r.Get("/", s.Timeline)
103
105
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
106
r.Route("/repo", func(r chi.Router) {
122
107
r.Route("/new", func(r chi.Router) {
123
108
r.Use(middleware.AuthMiddleware(s.oauth))
···
135
120
r.With(middleware.AuthMiddleware(s.oauth)).Route("/star", func(r chi.Router) {
136
121
r.Post("/", s.Star)
137
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)
138
128
})
139
129
140
130
r.Route("/profile", func(r chi.Router) {
···
146
136
})
147
137
148
138
r.Mount("/settings", s.SettingsRouter())
139
+
r.Mount("/knots", s.KnotsRouter(mw))
149
140
r.Mount("/spindles", s.SpindlesRouter())
141
+
r.Mount("/signup", s.SignupRouter())
150
142
r.Mount("/", s.OAuthRouter())
151
143
152
144
r.Get("/keys/{user}", s.Keys)
145
+
r.Get("/terms", s.TermsOfService)
146
+
r.Get("/privacy", s.PrivacyPolicy)
153
147
154
148
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
155
149
s.pages.Error404(w)
···
190
184
return spindles.Router()
191
185
}
192
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
+
193
204
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)
205
+
issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier)
195
206
return issues.Router(mw)
196
-
197
207
}
198
208
199
209
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)
210
+
pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier)
201
211
return pulls.Router(mw)
202
212
}
203
213
204
214
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)
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)
206
217
return repo.Router(mw)
207
218
}
208
219
209
220
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)
221
+
pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer)
211
222
return pipes.Router(mw)
212
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
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
10
lexutil "github.com/bluesky-social/indigo/lex/util"
11
-
"github.com/posthog/posthog-go"
12
11
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview"
14
12
"tangled.sh/tangled.sh/core/appview/db"
15
13
"tangled.sh/tangled.sh/core/appview/pages"
14
+
"tangled.sh/tangled.sh/core/tid"
16
15
)
17
16
18
17
func (s *State) Star(w http.ResponseWriter, r *http.Request) {
···
39
38
switch r.Method {
40
39
case http.MethodPost:
41
40
createdAt := time.Now().Format(time.RFC3339)
42
-
rkey := appview.TID()
41
+
rkey := tid.TID()
43
42
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
44
43
Collection: tangled.FeedStarNSID,
45
44
Repo: currentUser.Did,
···
54
53
log.Println("failed to create atproto record", err)
55
54
return
56
55
}
56
+
log.Println("created atproto record: ", resp.Uri)
57
57
58
-
err = db.AddStar(s.db, currentUser.Did, subjectUri, rkey)
58
+
star := &db.Star{
59
+
StarredByDid: currentUser.Did,
60
+
RepoAt: subjectUri,
61
+
Rkey: rkey,
62
+
}
63
+
64
+
err = db.AddStar(s.db, star)
59
65
if err != nil {
60
66
log.Println("failed to star", err)
61
67
return
···
66
72
log.Println("failed to get star count for ", subjectUri)
67
73
}
68
74
69
-
log.Println("created atproto record: ", resp.Uri)
75
+
s.notifier.NewStar(r.Context(), star)
70
76
71
-
s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{
77
+
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
72
78
IsStarred: true,
73
79
RepoAt: subjectUri,
74
80
Stats: db.RepoStats{
···
76
82
},
77
83
})
78
84
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
85
return
91
86
case http.MethodDelete:
92
87
// find the record in the db
···
119
114
return
120
115
}
121
116
122
-
s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{
117
+
s.notifier.DeleteStar(r.Context(), star)
118
+
119
+
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
123
120
IsStarred: false,
124
121
RepoAt: subjectUri,
125
122
Stats: db.RepoStats{
126
123
StarCount: starCount,
127
124
},
128
125
})
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
126
141
127
return
142
128
}
+27
-353
appview/state/state.go
+27
-353
appview/state/state.go
···
2
2
3
3
import (
4
4
"context"
5
-
"crypto/hmac"
6
-
"crypto/sha256"
7
-
"encoding/hex"
8
5
"fmt"
9
6
"log"
10
7
"log/slog"
···
13
10
"time"
14
11
15
12
comatproto "github.com/bluesky-social/indigo/api/atproto"
16
-
"github.com/bluesky-social/indigo/atproto/syntax"
17
13
lexutil "github.com/bluesky-social/indigo/lex/util"
18
14
securejoin "github.com/cyphar/filepath-securejoin"
19
15
"github.com/go-chi/chi/v5"
···
24
20
"tangled.sh/tangled.sh/core/appview/cache/session"
25
21
"tangled.sh/tangled.sh/core/appview/config"
26
22
"tangled.sh/tangled.sh/core/appview/db"
27
-
"tangled.sh/tangled.sh/core/appview/idresolver"
23
+
"tangled.sh/tangled.sh/core/appview/notify"
28
24
"tangled.sh/tangled.sh/core/appview/oauth"
29
25
"tangled.sh/tangled.sh/core/appview/pages"
26
+
posthogService "tangled.sh/tangled.sh/core/appview/posthog"
30
27
"tangled.sh/tangled.sh/core/appview/reporesolver"
31
28
"tangled.sh/tangled.sh/core/eventconsumer"
29
+
"tangled.sh/tangled.sh/core/idresolver"
32
30
"tangled.sh/tangled.sh/core/jetstream"
33
31
"tangled.sh/tangled.sh/core/knotclient"
34
32
tlog "tangled.sh/tangled.sh/core/log"
35
33
"tangled.sh/tangled.sh/core/rbac"
34
+
"tangled.sh/tangled.sh/core/tid"
36
35
)
37
36
38
37
type State struct {
39
38
db *db.DB
39
+
notifier notify.Notifier
40
40
oauth *oauth.OAuth
41
41
enforcer *rbac.Enforcer
42
-
tidClock syntax.TIDClock
43
42
pages *pages.Pages
44
43
sess *session.SessionStore
45
44
idResolver *idresolver.Resolver
···
62
61
return nil, fmt.Errorf("failed to create enforcer: %w", err)
63
62
}
64
63
65
-
clock := syntax.NewTIDClock(0)
66
-
67
64
pgs := pages.NewPages(config)
68
65
69
-
res, err := idresolver.RedisResolver(config.Redis)
66
+
res, err := idresolver.RedisResolver(config.Redis.ToURL())
70
67
if err != nil {
71
68
log.Printf("failed to create redis resolver: %v", err)
72
69
res = idresolver.DefaultResolver()
···
134
131
}
135
132
spindlestream.Start(ctx)
136
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
+
137
140
state := &State{
138
141
d,
142
+
notifier,
139
143
oauth,
140
144
enforcer,
141
-
clock,
142
145
pgs,
143
146
sess,
144
147
res,
···
153
156
return state, nil
154
157
}
155
158
156
-
func TID(c *syntax.TIDClock) string {
157
-
return c.Next().String()
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
+
})
158
171
}
159
172
160
173
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
···
201
214
return
202
215
}
203
216
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
217
func (s *State) Keys(w http.ResponseWriter, r *http.Request) {
241
218
user := chi.URLParam(r, "user")
242
219
user = strings.TrimPrefix(user, "@")
···
269
246
}
270
247
}
271
248
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
249
func validateRepoName(name string) error {
567
250
// check for path traversal attempts
568
251
if name == "." || name == ".." ||
···
661
344
return
662
345
}
663
346
664
-
rkey := appview.TID()
347
+
rkey := tid.TID()
665
348
repo := &db.Repo{
666
349
Did: user.Did,
667
350
Name: repoName,
···
757
440
return
758
441
}
759
442
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
-
}
443
+
s.notifier.NewRepo(r.Context(), repo)
770
444
771
445
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
772
446
return
+14
-6
appview/state/userutil/userutil.go
+14
-6
appview/state/userutil/userutil.go
···
5
5
"strings"
6
6
)
7
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
+
8
13
func IsHandleNoAt(s string) bool {
9
14
// 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)
15
+
return handleRegex.MatchString(s)
12
16
}
13
17
14
18
func UnflattenDid(s string) string {
···
29
33
// Reconstruct as a standard DID format using Replace
30
34
// Example: "did-plc-xyz-abc" becomes "did:plc:xyz-abc"
31
35
reconstructed := strings.Replace(s, "-", ":", 2)
32
-
re := regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`)
33
36
34
-
return re.MatchString(reconstructed)
37
+
return didRegex.MatchString(reconstructed)
35
38
}
36
39
37
40
// FlattenDid converts a DID to a flattened format.
···
46
49
47
50
// IsDid checks if the given string is a standard DID.
48
51
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)
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)
51
59
}
-11
appview/tid.go
-11
appview/tid.go
+15
appview/xrpcclient/xrpc.go
+15
appview/xrpcclient/xrpc.go
···
87
87
88
88
return &out, nil
89
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
1
export default {
2
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
+
3
17
const url = new URL(request.url);
4
18
const { pathname, searchParams } = url;
5
19
···
60
74
const profile = await profileResponse.json();
61
75
const avatar = profile.avatar;
62
76
63
-
if (!avatar) {
64
-
return new Response(`avatar not found for ${actor}.`, { status: 404 });
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;
65
94
}
66
95
67
96
// Resize if requested
68
97
let avatarResponse;
69
98
if (resizeToTiny) {
70
-
avatarResponse = await fetch(avatar, {
99
+
avatarResponse = await fetch(avatarUrl, {
71
100
cf: {
72
101
image: {
73
102
width: 32,
···
78
107
},
79
108
});
80
109
} else {
81
-
avatarResponse = await fetch(avatar);
110
+
avatarResponse = await fetch(avatarUrl);
82
111
}
83
112
84
113
if (!avatarResponse.ok) {
+4
cmd/gen.go
+4
cmd/gen.go
···
15
15
"api/tangled/cbor_gen.go",
16
16
"tangled",
17
17
tangled.ActorProfile{},
18
+
tangled.FeedReaction{},
18
19
tangled.FeedStar{},
19
20
tangled.GitRefUpdate{},
20
21
tangled.GitRefUpdate_Meta{},
21
22
tangled.GitRefUpdate_Meta_CommitCount{},
22
23
tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem{},
24
+
tangled.GitRefUpdate_Meta_LangBreakdown{},
25
+
tangled.GitRefUpdate_Pair{},
23
26
tangled.GraphFollow{},
24
27
tangled.KnotMember{},
25
28
tangled.Pipeline{},
···
37
40
tangled.PublicKey{},
38
41
tangled.Repo{},
39
42
tangled.RepoArtifact{},
43
+
tangled.RepoCollaborator{},
40
44
tangled.RepoIssue{},
41
45
tangled.RepoIssueComment{},
42
46
tangled.RepoIssueState{},
+51
-7
docs/hacking.md
+51
-7
docs/hacking.md
···
32
32
nix run .#watch-tailwind
33
33
```
34
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
+
35
51
## running a knot
36
52
37
53
An end-to-end knot setup requires setting up a machine with
···
39
55
quite cumbersome. So the nix flake provides a
40
56
`nixosConfiguration` to do so.
41
57
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.
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.
45
62
46
63
You can now start a lightweight NixOS VM using
47
64
`nixos-shell` like so:
48
65
49
66
```bash
50
-
QEMU_NET_OPTS="hostfwd=tcp::6000-:6000,hostfwd=tcp::2222-:22" nixos-shell --flake .#knotVM
67
+
nix run .#vm
68
+
# or nixos-shell --flake .#vm
51
69
52
70
# hit Ctrl-a + c + q to exit the VM
53
71
```
54
72
55
-
This starts a knot on port 6000 with `ssh` exposed on port
56
-
2222. You can push repositories to this VM with this ssh
57
-
config block on your main machine:
73
+
This starts a knot on port 6000, a spindle on port 6555
74
+
with `ssh` exposed on port 2222. You can push repositories
75
+
to this VM with this ssh config block on your main machine:
58
76
59
77
```bash
60
78
Host nixos-shell
···
70
88
git remote add local-dev git@nixos-shell:user/repo
71
89
git push local-dev main
72
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
191
```
192
192
193
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
13
14
14
### the engine
15
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.
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.
19
20
20
21
The base image for the container is constructed on the fly using
21
22
[Nixery](https://nixery.dev), which is handy for caching layers for frequently
+12
-3
docs/spindle/hosting.md
+12
-3
docs/spindle/hosting.md
···
31
31
2. **Build the Spindle binary.**
32
32
33
33
```shell
34
-
go build -o spindle core/spindle/server.go
34
+
cd core
35
+
go mod download
36
+
go build -o cmd/spindle/spindle cmd/spindle/main.go
37
+
```
38
+
39
+
3. **Create the log directory.**
40
+
41
+
```shell
42
+
sudo mkdir -p /var/log/spindle
43
+
sudo chown $USER:$USER -R /var/log/spindle
35
44
```
36
45
37
-
3. **Run the Spindle binary.**
46
+
4. **Run the Spindle binary.**
38
47
39
48
```shell
40
-
./spindle
49
+
./cmd/spindle/spindle
41
50
```
42
51
43
52
Spindle will now start, connect to the Jetstream server, and begin processing pipelines.
+285
docs/spindle/openbao.md
+285
docs/spindle/openbao.md
···
1
+
# spindle secrets with openbao
2
+
3
+
This document covers setting up Spindle to use OpenBao for secrets
4
+
management via OpenBao Proxy instead of the default SQLite backend.
5
+
6
+
## overview
7
+
8
+
Spindle now uses OpenBao Proxy for secrets management. The proxy handles
9
+
authentication automatically using AppRole credentials, while Spindle
10
+
connects to the local proxy instead of directly to the OpenBao server.
11
+
12
+
This approach provides better security, automatic token renewal, and
13
+
simplified application code.
14
+
15
+
## installation
16
+
17
+
Install OpenBao from nixpkgs:
18
+
19
+
```bash
20
+
nix shell nixpkgs#openbao # for a local server
21
+
```
22
+
23
+
## setup
24
+
25
+
The setup process can is documented for both local development and production.
26
+
27
+
### local development
28
+
29
+
Start OpenBao in dev mode:
30
+
31
+
```bash
32
+
bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201
33
+
```
34
+
35
+
This starts OpenBao on `http://localhost:8201` with a root token.
36
+
37
+
Set up environment for bao CLI:
38
+
39
+
```bash
40
+
export BAO_ADDR=http://localhost:8200
41
+
export BAO_TOKEN=root
42
+
```
43
+
44
+
### production
45
+
46
+
You would typically use a systemd service with a configuration file. Refer to
47
+
[@tangled.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
57
depth: 50
58
58
submodules: true
59
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
20
"type": "github"
21
21
}
22
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
+
},
23
62
"htmx-src": {
24
63
"flake": false,
25
64
"locked": {
···
101
140
},
102
141
"nixpkgs": {
103
142
"locked": {
104
-
"lastModified": 1746904237,
105
-
"narHash": "sha256-3e+AVBczosP5dCLQmMoMEogM57gmZ2qrVSrmq9aResQ=",
143
+
"lastModified": 1751984180,
144
+
"narHash": "sha256-LwWRsENAZJKUdD3SpLluwDmdXY9F45ZEgCb0X+xgOL0=",
106
145
"owner": "nixos",
107
146
"repo": "nixpkgs",
108
-
"rev": "d89fc19e405cb2d55ce7cc114356846a0ee5e956",
147
+
"rev": "9807714d6944a957c2e036f84b0ff8caf9930bc0",
109
148
"type": "github"
110
149
},
111
150
"original": {
···
118
157
"root": {
119
158
"inputs": {
120
159
"gitignore": "gitignore",
160
+
"gomod2nix": "gomod2nix",
121
161
"htmx-src": "htmx-src",
122
162
"htmx-ws-src": "htmx-ws-src",
123
163
"ibm-plex-mono-src": "ibm-plex-mono-src",
···
139
179
"original": {
140
180
"type": "tarball",
141
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"
142
197
}
143
198
}
144
199
},
+94
-55
flake.nix
+94
-55
flake.nix
···
3
3
4
4
inputs = {
5
5
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
6
+
gomod2nix = {
7
+
url = "github:nix-community/gomod2nix";
8
+
inputs.nixpkgs.follows = "nixpkgs";
9
+
};
6
10
indigo = {
7
11
url = "github:oppiliappan/indigo";
8
12
flake = false;
···
42
46
outputs = {
43
47
self,
44
48
nixpkgs,
49
+
gomod2nix,
45
50
indigo,
46
51
htmx-src,
47
52
htmx-ws-src,
···
53
58
}: let
54
59
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"];
55
60
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
56
-
nixpkgsFor = forAllSystems (system:
57
-
import nixpkgs {
58
-
inherit system;
59
-
overlays = [self.overlays.default];
60
-
});
61
-
inherit (gitignore.lib) gitignoreSource;
62
-
in {
63
-
overlays.default = final: prev: let
64
-
goModHash = "sha256-SLi+nALwCd/Lzn3aljwPqCo2UaM9hl/4OAjcHQLt2Bk=";
65
-
appviewDeps = {
66
-
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src goModHash gitignoreSource;
67
-
};
68
-
knotDeps = {
69
-
inherit goModHash gitignoreSource;
70
-
};
71
-
spindleDeps = {
72
-
inherit goModHash gitignoreSource;
73
-
};
74
-
mkPackageSet = pkgs: {
75
-
lexgen = pkgs.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
76
-
appview = pkgs.callPackage ./nix/pkgs/appview.nix appviewDeps;
77
-
knot = pkgs.callPackage ./nix/pkgs/knot.nix {};
78
-
spindle = pkgs.callPackage ./nix/pkgs/spindle.nix spindleDeps;
79
-
knot-unwrapped = pkgs.callPackage ./nix/pkgs/knot-unwrapped.nix knotDeps;
80
-
sqlite-lib = pkgs.callPackage ./nix/pkgs/sqlite-lib.nix {
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 {
81
72
inherit (pkgs) gcc;
82
73
inherit sqlite-lib-src;
83
74
};
84
-
genjwks = pkgs.callPackage ./nix/pkgs/genjwks.nix {inherit goModHash gitignoreSource;};
85
-
};
86
-
in
87
-
mkPackageSet final;
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
88
89
89
packages = forAllSystems (system: let
90
90
pkgs = nixpkgsFor.${system};
91
-
staticPkgs = pkgs.pkgsStatic;
92
-
crossPkgs = pkgs.pkgsCross.gnu64.pkgsStatic;
91
+
packages = mkPackageSet pkgs;
92
+
staticPackages = mkPackageSet pkgs.pkgsStatic;
93
+
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
93
94
in {
94
-
appview = pkgs.appview;
95
-
lexgen = pkgs.lexgen;
96
-
knot = pkgs.knot;
97
-
knot-unwrapped = pkgs.knot-unwrapped;
98
-
spindle = pkgs.spindle;
99
-
genjwks = pkgs.genjwks;
100
-
sqlite-lib = pkgs.sqlite-lib;
95
+
appview = packages.appview;
96
+
lexgen = packages.lexgen;
97
+
knot = packages.knot;
98
+
knot-unwrapped = packages.knot-unwrapped;
99
+
spindle = packages.spindle;
100
+
genjwks = packages.genjwks;
101
+
sqlite-lib = packages.sqlite-lib;
101
102
102
-
pkgsStatic-appview = staticPkgs.appview;
103
-
pkgsStatic-knot = staticPkgs.knot;
104
-
pkgsStatic-knot-unwrapped = staticPkgs.knot-unwrapped;
105
-
pkgsStatic-spindle = staticPkgs.spindle;
106
-
pkgsStatic-sqlite-lib = staticPkgs.sqlite-lib;
103
+
pkgsStatic-appview = staticPackages.appview;
104
+
pkgsStatic-knot = staticPackages.knot;
105
+
pkgsStatic-knot-unwrapped = staticPackages.knot-unwrapped;
106
+
pkgsStatic-spindle = staticPackages.spindle;
107
+
pkgsStatic-sqlite-lib = staticPackages.sqlite-lib;
107
108
108
-
pkgsCross-gnu64-pkgsStatic-appview = crossPkgs.appview;
109
-
pkgsCross-gnu64-pkgsStatic-knot = crossPkgs.knot;
110
-
pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPkgs.knot-unwrapped;
111
-
pkgsCross-gnu64-pkgsStatic-spindle = crossPkgs.spindle;
109
+
pkgsCross-gnu64-pkgsStatic-appview = crossPackages.appview;
110
+
pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot;
111
+
pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped;
112
+
pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle;
112
113
});
113
-
defaultPackage = forAllSystems (system: nixpkgsFor.${system}.appview);
114
-
formatter = forAllSystems (system: nixpkgsFor."${system}".alejandra);
114
+
defaultPackage = forAllSystems (system: self.packages.${system}.appview);
115
+
formatter = forAllSystems (system: nixpkgsFor.${system}.alejandra);
115
116
devShells = forAllSystems (system: let
116
117
pkgs = nixpkgsFor.${system};
118
+
packages' = self.packages.${system};
117
119
staticShell = pkgs.mkShell.override {
118
120
stdenv = pkgs.pkgsStatic.stdenv;
119
121
};
···
124
126
pkgs.air
125
127
pkgs.gopls
126
128
pkgs.httpie
127
-
pkgs.lexgen
128
129
pkgs.litecli
129
130
pkgs.websocat
130
131
pkgs.tailwindcss
131
132
pkgs.nixos-shell
132
133
pkgs.redis
134
+
packages'.lexgen
133
135
];
134
136
shellHook = ''
135
137
mkdir -p appview/pages/static/{fonts,icons}
···
139
141
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/
140
142
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
141
143
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/
142
-
export TANGLED_OAUTH_JWKS="$(${pkgs.genjwks}/bin/genjwks)"
144
+
export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)"
143
145
'';
144
146
env.CGO_ENABLED = 1;
145
147
};
···
151
153
''
152
154
${pkgs.air}/bin/air -c /dev/null \
153
155
-build.cmd "${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \
154
-
-build.bin "./out/${name}.out ${arg}" \
156
+
-build.bin "./out/${name}.out" \
157
+
-build.args_bin "${arg}" \
155
158
-build.stop_on_error "true" \
156
159
-build.include_ext "go"
157
160
'';
···
173
176
type = "app";
174
177
program = ''${tailwind-watcher}/bin/run'';
175
178
};
179
+
vm = {
180
+
type = "app";
181
+
program = toString (pkgs.writeShellScript "vm" ''
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
+
};
176
191
});
177
192
178
-
nixosModules.appview = import ./nix/modules/appview.nix {inherit self;};
179
-
nixosModules.knot = import ./nix/modules/knot.nix {inherit self;};
180
-
nixosModules.spindle = import ./nix/modules/spindle.nix {inherit self;};
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
+
};
181
220
nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;};
182
221
};
183
222
}
+54
-34
go.mod
+54
-34
go.mod
···
1
1
module tangled.sh/tangled.sh/core
2
2
3
-
go 1.24.0
4
-
5
-
toolchain go1.24.3
3
+
go 1.24.4
6
4
7
5
require (
8
6
github.com/Blank-Xu/sql-adapter v1.1.1
7
+
github.com/alecthomas/assert/v2 v2.11.0
9
8
github.com/alecthomas/chroma/v2 v2.15.0
9
+
github.com/avast/retry-go/v4 v4.6.1
10
10
github.com/bluekeyes/go-gitdiff v0.8.1
11
-
github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e
11
+
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb
12
12
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
13
13
github.com/carlmjohnson/versioninfo v0.22.5
14
14
github.com/casbin/casbin/v2 v2.103.0
15
+
github.com/cloudflare/cloudflare-go v0.115.0
15
16
github.com/cyphar/filepath-securejoin v0.4.1
16
17
github.com/dgraph-io/ristretto v0.2.0
17
18
github.com/docker/docker v28.2.2+incompatible
···
22
23
github.com/go-git/go-git/v5 v5.14.0
23
24
github.com/google/uuid v1.6.0
24
25
github.com/gorilla/sessions v1.4.0
25
-
github.com/gorilla/websocket v1.5.3
26
+
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
26
27
github.com/hiddeco/sshsig v0.2.0
27
28
github.com/hpcloud/tail v1.0.0
28
29
github.com/ipfs/go-cid v0.5.0
29
30
github.com/lestrrat-go/jwx/v2 v2.1.6
30
31
github.com/mattn/go-sqlite3 v1.14.24
31
32
github.com/microcosm-cc/bluemonday v1.0.27
33
+
github.com/openbao/openbao/api/v2 v2.3.0
32
34
github.com/posthog/posthog-go v1.5.5
33
-
github.com/redis/go-redis/v9 v9.3.0
35
+
github.com/redis/go-redis/v9 v9.7.3
34
36
github.com/resend/resend-go/v2 v2.15.0
35
37
github.com/sethvargo/go-envconfig v1.1.0
36
38
github.com/stretchr/testify v1.10.0
37
39
github.com/urfave/cli/v3 v3.3.3
38
40
github.com/whyrusleeping/cbor-gen v0.3.1
39
41
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/crypto v0.40.0
43
+
golang.org/x/net v0.42.0
44
+
golang.org/x/sync v0.16.0
42
45
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
43
46
gopkg.in/yaml.v3 v3.0.1
44
-
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421
47
+
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1
45
48
)
46
49
47
50
require (
48
51
dario.cat/mergo v1.0.1 // indirect
49
52
github.com/Microsoft/go-winio v0.6.2 // indirect
50
-
github.com/ProtonMail/go-crypto v1.2.0 // indirect
53
+
github.com/ProtonMail/go-crypto v1.3.0 // indirect
54
+
github.com/alecthomas/repr v0.4.0 // indirect
51
55
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
52
-
github.com/avast/retry-go/v4 v4.6.1 // indirect
53
56
github.com/aymerick/douceur v0.2.0 // indirect
54
57
github.com/beorn7/perks v1.0.1 // indirect
55
58
github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect
56
59
github.com/casbin/govaluate v1.3.0 // indirect
60
+
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
57
61
github.com/cespare/xxhash/v2 v2.3.0 // indirect
58
-
github.com/cloudflare/circl v1.6.0 // indirect
62
+
github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect
59
63
github.com/containerd/errdefs v1.0.0 // indirect
60
64
github.com/containerd/errdefs/pkg v0.3.0 // indirect
61
65
github.com/containerd/log v0.1.0 // indirect
···
68
72
github.com/docker/go-units v0.5.0 // indirect
69
73
github.com/emirpasic/gods v1.18.1 // indirect
70
74
github.com/felixge/httpsnoop v1.0.4 // indirect
75
+
github.com/fsnotify/fsnotify v1.6.0 // indirect
71
76
github.com/go-enry/go-oniguruma v1.2.1 // indirect
72
77
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
73
78
github.com/go-git/go-billy/v5 v5.6.2 // indirect
74
-
github.com/go-logr/logr v1.4.2 // indirect
79
+
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
80
+
github.com/go-logr/logr v1.4.3 // indirect
75
81
github.com/go-logr/stdr v1.2.2 // indirect
76
82
github.com/go-redis/cache/v9 v9.0.0 // indirect
83
+
github.com/go-test/deep v1.1.1 // indirect
77
84
github.com/goccy/go-json v0.10.5 // indirect
78
85
github.com/gogo/protobuf v1.3.2 // indirect
79
-
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
86
+
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
80
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
81
90
github.com/gorilla/css v1.0.1 // indirect
82
91
github.com/gorilla/securecookie v1.1.2 // indirect
92
+
github.com/hashicorp/errwrap v1.1.0 // indirect
83
93
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
84
-
github.com/hashicorp/go-retryablehttp v0.7.7 // 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
85
99
github.com/hashicorp/golang-lru v1.0.2 // indirect
86
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
87
103
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
104
+
github.com/ipfs/boxo v0.33.0 // indirect
105
+
github.com/ipfs/go-block-format v0.2.2 // indirect
90
106
github.com/ipfs/go-datastore v0.8.2 // indirect
91
107
github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect
92
108
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
109
+
github.com/ipfs/go-ipld-cbor v0.2.1 // indirect
110
+
github.com/ipfs/go-ipld-format v0.6.2 // indirect
95
111
github.com/ipfs/go-log v1.0.5 // indirect
96
112
github.com/ipfs/go-log/v2 v2.6.0 // indirect
97
113
github.com/ipfs/go-metrics-interface v0.3.0 // indirect
98
114
github.com/kevinburke/ssh_config v1.2.0 // indirect
99
115
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
116
+
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
117
+
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
102
118
github.com/lestrrat-go/httpcc v1.0.1 // indirect
103
119
github.com/lestrrat-go/httprc v1.0.6 // indirect
104
120
github.com/lestrrat-go/iter v1.0.2 // indirect
105
121
github.com/lestrrat-go/option v1.0.1 // indirect
106
122
github.com/mattn/go-isatty v0.0.20 // indirect
107
123
github.com/minio/sha256-simd v1.0.1 // indirect
124
+
github.com/mitchellh/mapstructure v1.5.0 // indirect
108
125
github.com/moby/docker-image-spec v1.3.1 // indirect
109
126
github.com/moby/sys/atomicwriter v0.1.0 // indirect
110
127
github.com/moby/term v0.5.2 // indirect
···
116
133
github.com/multiformats/go-multihash v0.2.3 // indirect
117
134
github.com/multiformats/go-varint v0.0.7 // indirect
118
135
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
136
+
github.com/onsi/gomega v1.37.0 // indirect
119
137
github.com/opencontainers/go-digest v1.0.0 // indirect
120
138
github.com/opencontainers/image-spec v1.1.1 // indirect
121
-
github.com/opentracing/opentracing-go v1.2.0 // indirect
139
+
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
122
140
github.com/pjbgf/sha1cd v0.3.2 // indirect
123
141
github.com/pkg/errors v0.9.1 // indirect
124
142
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
125
143
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
126
144
github.com/prometheus/client_golang v1.22.0 // indirect
127
145
github.com/prometheus/client_model v0.6.2 // indirect
128
-
github.com/prometheus/common v0.63.0 // indirect
146
+
github.com/prometheus/common v0.64.0 // indirect
129
147
github.com/prometheus/procfs v0.16.1 // indirect
148
+
github.com/ryanuber/go-glob v1.0.0 // indirect
130
149
github.com/segmentio/asm v1.2.0 // indirect
131
150
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
132
151
github.com/spaolacci/murmur3 v1.1.0 // indirect
···
136
155
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
137
156
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
138
157
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
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
143
163
go.opentelemetry.io/proto/otlp v1.6.0 // indirect
144
164
go.uber.org/atomic v1.11.0 // indirect
145
165
go.uber.org/multierr v1.11.0 // indirect
146
166
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
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
154
174
google.golang.org/protobuf v1.36.6 // indirect
155
175
gopkg.in/fsnotify.v1 v1.4.7 // indirect
156
176
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
+129
-87
go.sum
+129
-87
go.sum
···
7
7
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
8
8
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
9
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=
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
12
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
13
13
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
14
14
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
···
23
23
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
24
24
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
25
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=
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
28
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
29
29
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
30
30
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
···
51
51
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
52
52
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
53
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=
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=
56
58
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
57
59
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
58
60
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
···
91
93
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
92
94
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
93
95
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/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
97
+
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
96
98
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
97
99
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
98
100
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
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=
101
104
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
102
105
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
103
106
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
···
114
117
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
115
118
github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03 h1:LumE+tQdnYW24a9RoO08w64LHTzkNkdUqBD/0QPtlEY=
116
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=
117
122
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
118
123
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=
124
+
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
125
+
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
121
126
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
122
127
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
123
128
github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0=
124
129
github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI=
125
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=
126
133
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
127
134
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
128
135
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
129
136
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
130
137
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=
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=
133
140
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
134
141
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
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=
137
145
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
138
146
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
139
147
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
···
146
154
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
147
155
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
148
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=
149
158
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
150
159
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
151
160
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
152
161
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
153
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=
154
165
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
155
166
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
156
167
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
···
166
177
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
167
178
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
168
179
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=
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=
171
182
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
172
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=
173
187
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
174
188
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
175
189
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
176
190
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=
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=
179
201
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
180
202
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
181
203
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
182
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=
183
207
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
184
208
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
185
209
github.com/hiddeco/sshsig v0.2.0 h1:gMWllgKCITXdydVkDL+Zro0PU96QI55LwUwebSwNTSw=
···
189
213
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
190
214
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
191
215
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=
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=
196
220
github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg=
197
221
github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk=
198
222
github.com/ipfs/go-datastore v0.8.2 h1:Jy3wjqQR6sg/LhyY0NIePZC3Vux19nLtg7dx0TVqr6U=
···
205
229
github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo=
206
230
github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0=
207
231
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=
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=
212
236
github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8=
213
237
github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo=
214
238
github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g=
···
216
240
github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8=
217
241
github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU=
218
242
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
243
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
222
244
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
223
245
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
···
229
251
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
230
252
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
231
253
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=
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=
234
256
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
235
257
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
236
258
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
···
239
261
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
240
262
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
241
263
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=
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=
244
266
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
245
267
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
246
268
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
···
251
273
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
252
274
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
253
275
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=
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=
260
278
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
261
279
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
262
280
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
···
265
283
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
266
284
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
267
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=
268
288
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
269
289
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
270
290
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
···
281
301
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
282
302
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
283
303
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
304
github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g=
287
305
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
306
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
291
307
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
292
308
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
···
318
334
github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg=
319
335
github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM=
320
336
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=
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=
323
341
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
324
342
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
325
343
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
326
344
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
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=
329
348
github.com/oppiliappan/chroma/v2 v2.19.0 h1:PN7/pb+6JRKCva30NPTtRJMlrOyzgpPpIroNzy4ekHU=
330
349
github.com/oppiliappan/chroma/v2 v2.19.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
331
350
github.com/oppiliappan/go-git/v5 v5.17.0 h1:CuJnpcIDxr0oiNaSHMconovSWnowHznVDG+AhjGuSEo=
···
346
365
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
347
366
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
348
367
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=
368
+
github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4=
369
+
github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
351
370
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
352
371
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
353
372
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=
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=
356
375
github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE=
357
376
github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
358
377
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
···
360
379
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
361
380
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
362
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=
363
384
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
364
385
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
365
386
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
···
404
425
github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
405
426
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
406
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=
407
429
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
408
430
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
409
431
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
···
413
435
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
414
436
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
415
437
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=
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=
422
444
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU=
423
445
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=
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=
432
454
go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI=
433
455
go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc=
434
456
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
···
451
473
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
452
474
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
453
475
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=
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=
458
481
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
459
482
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
460
483
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
461
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=
462
486
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
463
487
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
464
488
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
465
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=
466
491
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
467
492
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
468
493
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
···
471
496
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
472
497
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
473
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=
474
500
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
475
501
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
476
502
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
···
480
506
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
481
507
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
482
508
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=
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=
485
513
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
486
514
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
487
515
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
···
489
517
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
490
518
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
491
519
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=
520
+
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
521
+
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
494
522
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
495
523
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
496
524
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
···
502
530
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
503
531
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
504
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=
505
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=
506
536
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
507
537
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
508
538
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
···
510
540
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
511
541
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
512
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=
513
544
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
514
545
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
515
546
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
516
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=
517
549
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=
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=
520
554
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
521
555
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
522
556
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
523
557
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
524
558
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
525
559
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=
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=
528
565
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
529
566
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
530
567
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
···
532
569
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
533
570
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
534
571
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=
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=
539
579
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
540
580
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
541
581
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
···
547
587
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
548
588
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
549
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=
550
591
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
551
592
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
552
593
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
553
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=
554
596
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
555
597
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
556
598
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
557
599
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
558
600
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
559
601
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=
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=
566
608
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
567
609
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
568
610
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
···
599
641
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
600
642
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
601
643
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=
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=
604
646
tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc=
605
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
2
3
3
import (
4
4
"context"
5
+
"errors"
5
6
"fmt"
7
+
"io"
6
8
"log/slog"
7
9
"net/http"
8
10
"net/url"
···
13
15
"github.com/bluesky-social/indigo/atproto/identity"
14
16
securejoin "github.com/cyphar/filepath-securejoin"
15
17
"github.com/urfave/cli/v3"
16
-
"tangled.sh/tangled.sh/core/appview/idresolver"
18
+
"tangled.sh/tangled.sh/core/idresolver"
17
19
"tangled.sh/tangled.sh/core/log"
18
20
)
19
21
···
43
45
Usage: "internal API endpoint",
44
46
Value: "http://localhost:5444",
45
47
},
48
+
&cli.StringFlag{
49
+
Name: "motd-file",
50
+
Usage: "path to message of the day file",
51
+
Value: "/home/git/motd",
52
+
},
46
53
},
47
54
}
48
55
}
···
54
61
gitDir := cmd.String("git-dir")
55
62
logPath := cmd.String("log-path")
56
63
endpoint := cmd.String("internal-api")
64
+
motdFile := cmd.String("motd-file")
57
65
58
66
logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
59
67
if err != nil {
···
149
157
"fullPath", fullPath,
150
158
"client", clientIP)
151
159
152
-
if gitCommand == "git-upload-pack" {
153
-
fmt.Fprintf(os.Stderr, "\x02%s\n", "Welcome to this knot!")
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")
154
166
} else {
155
-
fmt.Fprintf(os.Stderr, "%s\n", "Welcome to this knot!")
167
+
motdReader = reader
168
+
}
169
+
if gitCommand == "git-upload-pack" {
170
+
io.WriteString(os.Stderr, "\x02")
156
171
}
172
+
io.Copy(os.Stderr, motdReader)
157
173
158
174
gitCmd := exec.Command(gitCommand, fullPath)
159
175
gitCmd.Stdout = os.Stdout
+24
hook/hook.go
+24
hook/hook.go
···
3
3
import (
4
4
"bufio"
5
5
"context"
6
+
"encoding/json"
6
7
"fmt"
7
8
"net/http"
8
9
"os"
···
10
11
11
12
"github.com/urfave/cli/v3"
12
13
)
14
+
15
+
type HookResponse struct {
16
+
Messages []string `json:"messages"`
17
+
}
13
18
14
19
// The hook command is nested like so:
15
20
//
···
36
41
Usage: "endpoint for the internal API",
37
42
Value: "http://localhost:5444",
38
43
},
44
+
&cli.StringSliceFlag{
45
+
Name: "push-option",
46
+
Usage: "any push option from git",
47
+
},
39
48
},
40
49
Commands: []*cli.Command{
41
50
{
···
52
61
userDid := cmd.String("user-did")
53
62
userHandle := cmd.String("user-handle")
54
63
endpoint := cmd.String("internal-api")
64
+
pushOptions := cmd.StringSlice("push-option")
55
65
56
66
payloadReader := bufio.NewReader(os.Stdin)
57
67
payload, _ := payloadReader.ReadString('\n')
···
67
77
req.Header.Set("X-Git-Dir", gitDir)
68
78
req.Header.Set("X-Git-User-Did", userDid)
69
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
+
}
70
85
71
86
resp, err := client.Do(req)
72
87
if err != nil {
···
76
91
77
92
if resp.StatusCode != http.StatusOK {
78
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)
79
103
}
80
104
81
105
return nil
+6
-1
hook/setup.go
+6
-1
hook/setup.go
···
133
133
134
134
hookContent := fmt.Sprintf(`#!/usr/bin/env bash
135
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
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
137
142
`, executablePath, config.internalApi)
138
143
139
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
52
j.mu.Unlock()
53
53
}
54
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
+
55
68
type processor func(context.Context, *models.Event) error
56
69
57
70
func (j *JetstreamClient) withDidFilter(processFunc processor) processor {
+6
knotserver/config/config.go
+6
knotserver/config/config.go
···
2
2
3
3
import (
4
4
"context"
5
+
"fmt"
5
6
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
6
8
"github.com/sethvargo/go-envconfig"
7
9
)
8
10
···
23
25
24
26
// This disables signature verification so use with caution.
25
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))
26
32
}
27
33
28
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
6
"fmt"
7
7
"io"
8
8
"io/fs"
9
-
"os/exec"
10
9
"path"
11
-
"sort"
12
10
"strconv"
13
11
"strings"
14
12
"time"
···
16
14
"github.com/go-git/go-git/v5"
17
15
"github.com/go-git/go-git/v5/plumbing"
18
16
"github.com/go-git/go-git/v5/plumbing/object"
19
-
"tangled.sh/tangled.sh/core/types"
20
17
)
21
18
22
19
var (
···
170
167
return count, nil
171
168
}
172
169
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
170
func (g *GitRepo) Commit(h plumbing.Hash) (*object.Commit, error) {
193
171
return g.r.CommitObject(h)
194
172
}
···
285
263
return io.ReadAll(reader)
286
264
}
287
265
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
266
func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) {
355
267
ref, err := g.r.Reference(plumbing.NewBranchReferenceName(name), false)
356
268
if err != nil {
···
370
282
}
371
283
372
284
func (g *GitRepo) FindMainBranch() (string, error) {
373
-
ref, err := g.r.Head()
285
+
output, err := g.revParse("--abbrev-ref", "HEAD")
374
286
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
287
+
return "", fmt.Errorf("failed to find main branch: %w", err)
379
288
}
380
289
381
-
return "", fmt.Errorf("unable to find main branch: %w", err)
290
+
return strings.TrimSpace(string(output)), nil
382
291
}
383
292
384
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
2
3
3
import (
4
4
"bufio"
5
+
"context"
5
6
"fmt"
6
7
"io"
7
8
"strings"
9
+
"time"
8
10
9
11
"tangled.sh/tangled.sh/core/api/tangled"
10
12
···
46
48
}
47
49
48
50
type RefUpdateMeta struct {
49
-
CommitCount CommitCount
50
-
IsDefaultRef bool
51
+
CommitCount CommitCount
52
+
IsDefaultRef bool
53
+
LangBreakdown LangBreakdown
51
54
}
52
55
53
56
type CommitCount struct {
···
57
60
func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) RefUpdateMeta {
58
61
commitCount, err := g.newCommitCount(line)
59
62
if err != nil {
60
-
// TODO: non-fatal, log this
63
+
// TODO: log this
61
64
}
62
65
63
66
isDefaultRef, err := g.isDefaultBranch(line)
64
67
if err != nil {
65
-
// TODO: non-fatal, log this
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
66
76
}
67
77
68
78
return RefUpdateMeta{
69
-
CommitCount: commitCount,
70
-
IsDefaultRef: isDefaultRef,
79
+
CommitCount: commitCount,
80
+
IsDefaultRef: isDefaultRef,
81
+
LangBreakdown: breakdown,
71
82
}
72
83
}
73
84
···
77
88
ByEmail: byEmail,
78
89
}
79
90
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
-
}
91
+
if line.NewSha.IsZero() {
92
+
return commitCount, nil
93
+
}
88
94
89
-
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
90
-
if len(lines) == 1 && lines[0] == "" {
91
-
return commitCount, nil
92
-
}
95
+
args := []string{fmt.Sprintf("--max-count=%d", 100)}
93
96
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
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
100
119
}
120
+
commitCount.ByEmail[obj.Author.Email] += 1
101
121
}
102
122
103
123
return commitCount, nil
···
126
146
})
127
147
}
128
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
+
129
160
return tangled.GitRefUpdate_Meta{
130
161
CommitCount: &tangled.GitRefUpdate_Meta_CommitCount{
131
162
ByEmail: byEmail,
132
163
},
133
-
IsDefaultRef: m.IsDefaultRef,
164
+
IsDefaultRef: m.IsDefaultRef,
165
+
LangBreakdown: langBreakdown,
134
166
}
135
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
8
"runtime/debug"
9
9
10
10
"github.com/go-chi/chi/v5"
11
+
"tangled.sh/tangled.sh/core/idresolver"
11
12
"tangled.sh/tangled.sh/core/jetstream"
12
13
"tangled.sh/tangled.sh/core/knotserver/config"
13
14
"tangled.sh/tangled.sh/core/knotserver/db"
15
+
"tangled.sh/tangled.sh/core/knotserver/xrpc"
16
+
tlog "tangled.sh/tangled.sh/core/log"
14
17
"tangled.sh/tangled.sh/core/notifier"
15
18
"tangled.sh/tangled.sh/core/rbac"
16
-
)
17
-
18
-
const (
19
-
ThisServer = "thisserver" // resource identifier for rbac enforcement
20
19
)
21
20
22
21
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
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
29
30
30
// init is a channel that is closed when the knot has been initailized
31
31
// i.e. when the first user (knot owner) has been added.
···
37
37
r := chi.NewRouter()
38
38
39
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{}),
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{}),
47
48
}
48
49
49
-
err := e.AddKnot(ThisServer)
50
+
err := e.AddKnot(rbac.ThisServer)
50
51
if err != nil {
51
52
return nil, fmt.Errorf("failed to setup enforcer: %w", err)
52
53
}
···
131
132
})
132
133
})
133
134
135
+
// xrpc apis
136
+
r.Mount("/xrpc", h.XrpcRouter())
137
+
134
138
// Create a new repository.
135
139
r.Route("/repo", func(r chi.Router) {
136
140
r.Use(h.VerifySignature)
···
161
165
r.Get("/keys", h.Keys)
162
166
163
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()
164
183
}
165
184
166
185
// version is set during build time.
+65
-4
knotserver/ingester.go
+65
-4
knotserver/ingester.go
···
17
17
"github.com/bluesky-social/jetstream/pkg/models"
18
18
securejoin "github.com/cyphar/filepath-securejoin"
19
19
"tangled.sh/tangled.sh/core/api/tangled"
20
-
"tangled.sh/tangled.sh/core/appview/idresolver"
20
+
"tangled.sh/tangled.sh/core/idresolver"
21
21
"tangled.sh/tangled.sh/core/knotserver/db"
22
22
"tangled.sh/tangled.sh/core/knotserver/git"
23
23
"tangled.sh/tangled.sh/core/log"
24
+
"tangled.sh/tangled.sh/core/rbac"
24
25
"tangled.sh/tangled.sh/core/workflow"
25
26
)
26
27
···
46
47
return fmt.Errorf("domain mismatch: %s != %s", record.Domain, h.c.Server.Hostname)
47
48
}
48
49
49
-
ok, err := h.e.E.Enforce(did, ThisServer, ThisServer, "server:invite")
50
+
ok, err := h.e.E.Enforce(did, rbac.ThisServer, rbac.ThisServer, "server:invite")
50
51
if err != nil || !ok {
51
52
l.Error("failed to add member", "did", did)
52
53
return fmt.Errorf("failed to enforce permissions: %w", err)
53
54
}
54
55
55
-
if err := h.e.AddKnotMember(ThisServer, record.Subject); err != nil {
56
+
if err := h.e.AddKnotMember(rbac.ThisServer, record.Subject); err != nil {
56
57
l.Error("failed to add member", "error", err)
57
58
return fmt.Errorf("failed to add member: %w", err)
58
59
}
···
212
213
return h.db.InsertEvent(event, h.n)
213
214
}
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
+
215
266
func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error {
216
267
l := log.FromContext(ctx)
217
268
···
265
316
defer func() {
266
317
eventTime := event.TimeUS
267
318
lastTimeUs := eventTime + 1
268
-
fmt.Println("lastTimeUs", lastTimeUs)
269
319
if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil {
270
320
err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
271
321
}
···
291
341
if err := h.processKnotMember(ctx, did, record); err != nil {
292
342
return fmt.Errorf("failed to process knot member: %w", err)
293
343
}
344
+
294
345
case tangled.RepoPullNSID:
295
346
var record tangled.RepoPull
296
347
if err := json.Unmarshal(raw, &record); err != nil {
···
299
350
if err := h.processPull(ctx, did, record); err != nil {
300
351
return fmt.Errorf("failed to process knot member: %w", err)
301
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
+
302
363
}
303
364
304
365
return err
+66
-7
knotserver/internal.go
+66
-7
knotserver/internal.go
···
3
3
import (
4
4
"context"
5
5
"encoding/json"
6
+
"fmt"
6
7
"log/slog"
7
8
"net/http"
8
9
"path/filepath"
···
12
13
"github.com/go-chi/chi/v5"
13
14
"github.com/go-chi/chi/v5/middleware"
14
15
"tangled.sh/tangled.sh/core/api/tangled"
16
+
"tangled.sh/tangled.sh/core/hook"
15
17
"tangled.sh/tangled.sh/core/knotserver/config"
16
18
"tangled.sh/tangled.sh/core/knotserver/db"
17
19
"tangled.sh/tangled.sh/core/knotserver/git"
···
37
39
return
38
40
}
39
41
40
-
ok, err := h.e.IsPushAllowed(user, ThisServer, repo)
42
+
ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo)
41
43
if err != nil || !ok {
42
44
w.WriteHeader(http.StatusForbidden)
43
45
return
···
63
65
return
64
66
}
65
67
68
+
type PushOptions struct {
69
+
skipCi bool
70
+
verboseCi bool
71
+
}
72
+
66
73
func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) {
67
74
l := h.l.With("handler", "PostReceiveHook")
68
75
···
89
96
// non-fatal
90
97
}
91
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
+
92
115
for _, line := range lines {
93
116
err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName)
94
117
if err != nil {
···
96
119
// non-fatal
97
120
}
98
121
99
-
err = h.triggerPipeline(line, gitUserDid, repoDid, repoName)
122
+
err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
100
123
if err != nil {
101
124
l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
102
125
// non-fatal
103
126
}
104
127
}
128
+
129
+
writeJSON(w, resp)
105
130
}
106
131
107
132
func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
···
115
140
return err
116
141
}
117
142
118
-
gr, err := git.PlainOpen(repoPath)
143
+
gr, err := git.Open(repoPath, line.Ref)
119
144
if err != nil {
120
-
return err
145
+
return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err)
121
146
}
122
147
123
148
meta := gr.RefUpdateMeta(line)
149
+
124
150
metaRecord := meta.AsRecord()
125
151
126
152
refUpdate := tangled.GitRefUpdate{
···
146
172
return h.db.InsertEvent(event, h.n)
147
173
}
148
174
149
-
func (h *InternalHandle) triggerPipeline(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
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
+
150
180
didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
151
181
if err != nil {
152
182
return err
···
166
196
if err != nil {
167
197
return err
168
198
}
199
+
200
+
pipelineParseErrors := []string{}
169
201
170
202
var pipeline workflow.Pipeline
171
203
for _, e := range workflowDir {
···
181
213
182
214
wf, err := workflow.FromFile(e.Name, contents)
183
215
if err != nil {
184
-
// TODO: log here, respond to client that is pushing
185
216
h.l.Error("failed to parse workflow", "err", err, "path", fpath)
217
+
pipelineParseErrors = append(pipelineParseErrors, fmt.Sprintf("- at %s: %s\n", fpath, err))
186
218
continue
187
219
}
188
220
···
207
239
},
208
240
}
209
241
210
-
// TODO: send the diagnostics back to the user here via stderr
211
242
cp := compiler.Compile(pipeline)
212
243
eventJson, err := json.Marshal(cp)
213
244
if err != nil {
214
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
+
}
215
274
}
216
275
217
276
// do not run empty pipelines
+38
-70
knotserver/routes.go
+38
-70
knotserver/routes.go
···
13
13
"net/http"
14
14
"net/url"
15
15
"os"
16
-
"path"
17
16
"path/filepath"
18
17
"strconv"
19
18
"strings"
···
23
22
securejoin "github.com/cyphar/filepath-securejoin"
24
23
"github.com/gliderlabs/ssh"
25
24
"github.com/go-chi/chi/v5"
26
-
"github.com/go-enry/go-enry/v2"
27
25
gogit "github.com/go-git/go-git/v5"
28
26
"github.com/go-git/go-git/v5/plumbing"
29
27
"github.com/go-git/go-git/v5/plumbing/object"
···
31
29
"tangled.sh/tangled.sh/core/knotserver/db"
32
30
"tangled.sh/tangled.sh/core/knotserver/git"
33
31
"tangled.sh/tangled.sh/core/patchutil"
32
+
"tangled.sh/tangled.sh/core/rbac"
34
33
"tangled.sh/tangled.sh/core/types"
35
34
)
36
35
···
96
95
total int
97
96
branches []types.Branch
98
97
files []types.NiceTree
99
-
tags []*git.TagReference
98
+
tags []object.Tag
100
99
)
101
100
102
101
var wg sync.WaitGroup
···
169
168
170
169
rtags := []*types.TagReference{}
171
170
for _, tag := range tags {
171
+
var target *object.Tag
172
+
if tag.Target != plumbing.ZeroHash {
173
+
target = &tag
174
+
}
172
175
tr := types.TagReference{
173
-
Tag: tag.TagObject(),
176
+
Tag: target,
174
177
}
175
178
176
179
tr.Reference = types.Reference{
177
-
Name: tag.Name(),
178
-
Hash: tag.Hash().String(),
180
+
Name: tag.Name,
181
+
Hash: tag.Hash.String(),
179
182
}
180
183
181
-
if tag.Message() != "" {
182
-
tr.Message = tag.Message()
184
+
if tag.Message != "" {
185
+
tr.Message = tag.Message
183
186
}
184
187
185
188
rtags = append(rtags, &tr)
···
283
286
mimeType = "image/svg+xml"
284
287
}
285
288
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
+
// 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)
289
300
return
290
301
}
291
302
···
488
499
489
500
rtags := []*types.TagReference{}
490
501
for _, tag := range tags {
502
+
var target *object.Tag
503
+
if tag.Target != plumbing.ZeroHash {
504
+
target = &tag
505
+
}
491
506
tr := types.TagReference{
492
-
Tag: tag.TagObject(),
507
+
Tag: target,
493
508
}
494
509
495
510
tr.Reference = types.Reference{
496
-
Name: tag.Name(),
497
-
Hash: tag.Hash().String(),
511
+
Name: tag.Name,
512
+
Hash: tag.Hash.String(),
498
513
}
499
514
500
-
if tag.Message() != "" {
501
-
tr.Message = tag.Message()
515
+
if tag.Message != "" {
516
+
tr.Message = tag.Message
502
517
}
503
518
504
519
rtags = append(rtags, &tr)
···
668
683
}
669
684
670
685
// add perms for this user to access the repo
671
-
err = h.e.AddRepo(did, ThisServer, relativeRepoPath)
686
+
err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath)
672
687
if err != nil {
673
688
l.Error("adding repo permissions", "error", err.Error())
674
689
writeError(w, err.Error(), http.StatusInternalServerError)
···
777
792
return
778
793
}
779
794
780
-
sizes := make(map[string]int64)
781
-
782
795
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
783
796
defer cancel()
784
797
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
-
})
798
+
sizes, err := gr.AnalyzeLanguages(ctx)
812
799
if err != nil {
813
-
l.Error("failed to recurse file tree", "error", err.Error())
800
+
l.Error("failed to analyze languages", "error", err.Error())
814
801
writeError(w, err.Error(), http.StatusNoContent)
815
802
return
816
803
}
···
818
805
resp := types.RepoLanguageResponse{Languages: sizes}
819
806
820
807
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
808
}
841
809
842
810
func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) {
···
933
901
}
934
902
935
903
// add perms for this user to access the repo
936
-
err = h.e.AddRepo(did, ThisServer, relativeRepoPath)
904
+
err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath)
937
905
if err != nil {
938
906
l.Error("adding repo permissions", "error", err.Error())
939
907
writeError(w, err.Error(), http.StatusInternalServerError)
···
1187
1155
}
1188
1156
h.jc.AddDid(did)
1189
1157
1190
-
if err := h.e.AddKnotMember(ThisServer, did); err != nil {
1158
+
if err := h.e.AddKnotMember(rbac.ThisServer, did); err != nil {
1191
1159
l.Error("adding member", "error", err.Error())
1192
1160
writeError(w, err.Error(), http.StatusInternalServerError)
1193
1161
return
···
1225
1193
h.jc.AddDid(data.Did)
1226
1194
1227
1195
repoName, _ := securejoin.SecureJoin(ownerDid, repo)
1228
-
if err := h.e.AddCollaborator(data.Did, ThisServer, repoName); err != nil {
1196
+
if err := h.e.AddCollaborator(data.Did, rbac.ThisServer, repoName); err != nil {
1229
1197
l.Error("adding repo collaborator", "error", err.Error())
1230
1198
writeError(w, err.Error(), http.StatusInternalServerError)
1231
1199
return
···
1322
1290
}
1323
1291
h.jc.AddDid(data.Did)
1324
1292
1325
-
if err := h.e.AddKnotOwner(ThisServer, data.Did); err != nil {
1293
+
if err := h.e.AddKnotOwner(rbac.ThisServer, data.Did); err != nil {
1326
1294
l.Error("adding owner", "error", err.Error())
1327
1295
writeError(w, err.Error(), http.StatusInternalServerError)
1328
1296
return
+1
knotserver/server.go
+1
knotserver/server.go
-5
knotserver/util.go
-5
knotserver/util.go
···
8
8
"github.com/bluesky-social/indigo/atproto/syntax"
9
9
securejoin "github.com/cyphar/filepath-securejoin"
10
10
"github.com/go-chi/chi/v5"
11
-
"github.com/microcosm-cc/bluemonday"
12
11
)
13
-
14
-
func sanitize(content []byte) []byte {
15
-
return bluemonday.UGCPolicy().SanitizeBytes([]byte(content))
16
-
}
17
12
18
13
func didPath(r *http.Request) string {
19
14
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
61
"type": "boolean",
62
62
"default": "false"
63
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
+
},
64
76
"commitCount": {
65
77
"type": "object",
66
78
"required": [],
···
87
99
}
88
100
}
89
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"
90
117
}
91
118
}
92
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}: {
1
+
{
2
2
config,
3
-
pkgs,
4
3
lib,
5
4
...
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";
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
+
};
24
30
};
25
31
};
26
-
};
27
32
28
-
config = mkIf config.services.tangled-appview.enable {
29
-
systemd.services.tangled-appview = {
30
-
description = "tangled appview service";
31
-
wantedBy = ["multi-user.target"];
33
+
config = mkIf cfg.enable {
34
+
systemd.services.tangled-appview = {
35
+
description = "tangled appview service";
36
+
wantedBy = ["multi-user.target"];
32
37
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
+
serviceConfig = {
39
+
ListenStream = "0.0.0.0:${toString cfg.port}";
40
+
ExecStart = "${cfg.package}/bin/appview";
41
+
Restart = "always";
42
+
};
38
43
39
-
environment = {
40
-
TANGLED_DB_PATH = "appview.db";
41
-
TANGLED_COOKIE_SECRET = config.services.tangled-appview.cookie_secret;
44
+
environment = {
45
+
TANGLED_DB_PATH = "appview.db";
46
+
TANGLED_COOKIE_SECRET = cfg.cookie_secret;
47
+
};
42
48
};
43
49
};
44
-
};
45
-
}
50
+
}
+45
-7
nix/modules/knot.nix
+45
-7
nix/modules/knot.nix
···
1
-
{self}: {
1
+
{
2
2
config,
3
3
pkgs,
4
4
lib,
···
13
13
type = types.bool;
14
14
default = false;
15
15
description = "Enable a tangled knot";
16
+
};
17
+
18
+
package = mkOption {
19
+
type = types.package;
20
+
description = "Package to use for the knot";
16
21
};
17
22
18
23
appviewEndpoint = mkOption {
···
53
58
};
54
59
};
55
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
+
56
83
server = {
57
84
listenAddr = mkOption {
58
85
type = types.str;
···
94
121
};
95
122
96
123
config = mkIf cfg.enable {
97
-
environment.systemPackages = with pkgs; [
98
-
git
99
-
self.packages."${pkgs.system}".knot
124
+
environment.systemPackages = [
125
+
pkgs.git
126
+
cfg.package
100
127
];
101
128
102
-
system.activationScripts.gitConfig = ''
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 ''
103
138
mkdir -p "${cfg.repo.scanPath}"
104
139
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}"
105
140
···
108
143
[user]
109
144
name = Git User
110
145
email = git@example.com
146
+
[receive]
147
+
advertisePushOptions = true
111
148
EOF
149
+
${setMotd}
112
150
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
113
151
'';
114
152
···
135
173
mode = "0555";
136
174
text = ''
137
175
#!${pkgs.stdenv.shell}
138
-
${self.packages.${pkgs.system}.knot}/bin/knot keys \
176
+
${cfg.package}/bin/knot keys \
139
177
-output authorized-keys \
140
178
-internal-api "http://${cfg.server.internalListenAddr}" \
141
179
-git-dir "${cfg.repo.scanPath}" \
···
160
198
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
161
199
];
162
200
EnvironmentFile = cfg.server.secretFile;
163
-
ExecStart = "${self.packages.${pkgs.system}.knot}/bin/knot server";
201
+
ExecStart = "${cfg.package}/bin/knot server";
164
202
Restart = "always";
165
203
};
166
204
};
+8
-5
nix/modules/spindle.nix
+8
-5
nix/modules/spindle.nix
···
1
-
{self}: {
1
+
{
2
2
config,
3
-
pkgs,
4
3
lib,
5
4
...
6
5
}: let
···
13
12
type = types.bool;
14
13
default = false;
15
14
description = "Enable a tangled spindle";
15
+
};
16
+
package = mkOption {
17
+
type = types.package;
18
+
description = "Package to use for the spindle";
16
19
};
17
20
18
21
server = {
···
60
63
description = "Nixery instance to use";
61
64
};
62
65
63
-
stepTimeout = mkOption {
66
+
workflowTimeout = mkOption {
64
67
type = types.str;
65
68
default = "5m";
66
69
description = "Timeout for each step of a pipeline";
···
87
90
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
88
91
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
89
92
"SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}"
90
-
"SPINDLE_PIPELINES_STEP_TIMEOUT=${cfg.pipelines.stepTimeout}"
93
+
"SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}"
91
94
];
92
-
ExecStart = "${self.packages.${pkgs.system}.spindle}/bin/spindle";
95
+
ExecStart = "${cfg.package}/bin/spindle";
93
96
Restart = "always";
94
97
};
95
98
};
+6
-9
nix/pkgs/appview.nix
+6
-9
nix/pkgs/appview.nix
···
1
1
{
2
-
buildGoModule,
3
-
stdenv,
2
+
buildGoApplication,
3
+
modules,
4
4
htmx-src,
5
5
htmx-ws-src,
6
6
lucide-src,
···
8
8
ibm-plex-mono-src,
9
9
tailwindcss,
10
10
sqlite-lib,
11
-
goModHash,
12
11
gitignoreSource,
13
12
}:
14
-
buildGoModule {
15
-
inherit stdenv;
16
-
13
+
buildGoApplication {
17
14
pname = "appview";
18
15
version = "0.1.0";
19
16
src = gitignoreSource ../..;
17
+
inherit modules;
20
18
21
19
postUnpack = ''
22
20
pushd source
···
33
31
34
32
doCheck = false;
35
33
subPackages = ["cmd/appview"];
36
-
vendorHash = goModHash;
37
34
38
-
tags = "libsqlite3";
35
+
tags = ["libsqlite3"];
39
36
env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
40
37
env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib";
41
-
env.CGO_ENABLED = 1;
38
+
CGO_ENABLED = 1;
42
39
}
+5
-5
nix/pkgs/genjwks.nix
+5
-5
nix/pkgs/genjwks.nix
···
1
1
{
2
-
buildGoModule,
3
-
goModHash,
4
2
gitignoreSource,
3
+
buildGoApplication,
4
+
modules,
5
5
}:
6
-
buildGoModule {
6
+
buildGoApplication {
7
7
pname = "genjwks";
8
8
version = "0.1.0";
9
9
src = gitignoreSource ../..;
10
+
inherit modules;
10
11
subPackages = ["cmd/genjwks"];
11
-
vendorHash = goModHash;
12
12
doCheck = false;
13
-
env.CGO_ENABLED = 0;
13
+
CGO_ENABLED = 0;
14
14
}
+6
-7
nix/pkgs/knot-unwrapped.nix
+6
-7
nix/pkgs/knot-unwrapped.nix
···
1
1
{
2
-
buildGoModule,
3
-
stdenv,
2
+
buildGoApplication,
3
+
modules,
4
4
sqlite-lib,
5
-
goModHash,
6
5
gitignoreSource,
7
6
}:
8
-
buildGoModule {
7
+
buildGoApplication {
9
8
pname = "knot";
10
9
version = "0.1.0";
11
10
src = gitignoreSource ../..;
11
+
inherit modules;
12
12
13
13
doCheck = false;
14
14
15
15
subPackages = ["cmd/knot"];
16
-
vendorHash = goModHash;
17
-
tags = "libsqlite3";
16
+
tags = ["libsqlite3"];
18
17
19
18
env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
20
19
env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib";
21
-
env.CGO_ENABLED = 1;
20
+
CGO_ENABLED = 1;
22
21
}
+6
-7
nix/pkgs/spindle.nix
+6
-7
nix/pkgs/spindle.nix
···
1
1
{
2
-
buildGoModule,
3
-
stdenv,
2
+
buildGoApplication,
3
+
modules,
4
4
sqlite-lib,
5
-
goModHash,
6
5
gitignoreSource,
7
6
}:
8
-
buildGoModule {
7
+
buildGoApplication {
9
8
pname = "spindle";
10
9
version = "0.1.0";
11
10
src = gitignoreSource ../..;
11
+
inherit modules;
12
12
13
13
doCheck = false;
14
14
15
15
subPackages = ["cmd/spindle"];
16
-
vendorHash = goModHash;
17
-
tags = "libsqlite3";
16
+
tags = ["libsqlite3"];
18
17
19
18
env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
20
19
env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib";
21
-
env.CGO_ENABLED = 1;
20
+
CGO_ENABLED = 1;
22
21
}
+1
nix/vm.nix
+1
nix/vm.nix
+25
patchutil/interdiff.go
+25
patchutil/interdiff.go
···
5
5
"strings"
6
6
7
7
"github.com/bluekeyes/go-gitdiff/gitdiff"
8
+
"tangled.sh/tangled.sh/core/types"
8
9
)
9
10
10
11
type InterdiffResult struct {
···
33
34
*gitdiff.File
34
35
Name string
35
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
36
61
}
37
62
38
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
2
3
3
import (
4
4
"context"
5
+
"fmt"
5
6
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
6
8
"github.com/sethvargo/go-envconfig"
7
9
)
8
10
9
11
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"`
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"`
16
33
}
17
34
18
35
type Pipelines struct {
+43
-20
spindle/engine/engine.go
+43
-20
spindle/engine/engine.go
···
11
11
"sync"
12
12
"time"
13
13
14
+
securejoin "github.com/cyphar/filepath-securejoin"
14
15
"github.com/docker/docker/api/types/container"
15
16
"github.com/docker/docker/api/types/image"
16
17
"github.com/docker/docker/api/types/mount"
···
18
19
"github.com/docker/docker/api/types/volume"
19
20
"github.com/docker/docker/client"
20
21
"github.com/docker/docker/pkg/stdcopy"
22
+
"golang.org/x/sync/errgroup"
21
23
"tangled.sh/tangled.sh/core/log"
22
24
"tangled.sh/tangled.sh/core/notifier"
23
25
"tangled.sh/tangled.sh/core/spindle/config"
24
26
"tangled.sh/tangled.sh/core/spindle/db"
25
27
"tangled.sh/tangled.sh/core/spindle/models"
28
+
"tangled.sh/tangled.sh/core/spindle/secrets"
26
29
)
27
30
28
31
const (
···
37
40
db *db.DB
38
41
n *notifier.Notifier
39
42
cfg *config.Config
43
+
vault secrets.Manager
40
44
41
45
cleanupMu sync.Mutex
42
46
cleanup map[string][]cleanupFunc
43
47
}
44
48
45
-
func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier) (*Engine, error) {
49
+
func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier, vault secrets.Manager) (*Engine, error) {
46
50
dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
47
51
if err != nil {
48
52
return nil, err
···
56
60
db: db,
57
61
n: n,
58
62
cfg: cfg,
63
+
vault: vault,
59
64
}
60
65
61
66
e.cleanup = make(map[string][]cleanupFunc)
···
66
71
func (e *Engine) StartWorkflows(ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) {
67
72
e.l.Info("starting all workflows in parallel", "pipeline", pipelineId)
68
73
69
-
wg := sync.WaitGroup{}
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)
70
91
for _, w := range pipeline.Workflows {
71
-
wg.Add(1)
72
-
go func() error {
73
-
defer wg.Done()
92
+
eg.Go(func() error {
74
93
wid := models.WorkflowId{
75
94
PipelineId: pipelineId,
76
95
Name: w.Name,
···
90
109
91
110
reader, err := e.docker.ImagePull(ctx, w.Image, image.PullOptions{})
92
111
if err != nil {
93
-
e.l.Error("pipeline failed!", "workflowId", wid, "error", err.Error())
112
+
e.l.Error("pipeline image pull failed!", "image", w.Image, "workflowId", wid, "error", err.Error())
94
113
95
114
err := e.db.StatusFailed(wid, err.Error(), -1, e.n)
96
115
if err != nil {
···
102
121
defer reader.Close()
103
122
io.Copy(os.Stdout, reader)
104
123
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
124
ctx, cancel := context.WithTimeout(ctx, workflowTimeout)
113
125
defer cancel()
114
126
115
-
err = e.StartSteps(ctx, w.Steps, wid, w.Image)
127
+
err = e.StartSteps(ctx, wid, w, allSecrets)
116
128
if err != nil {
117
129
if errors.Is(err, ErrTimedOut) {
118
130
dbErr := e.db.StatusTimeout(wid, e.n)
···
135
147
}
136
148
137
149
return nil
138
-
}()
150
+
})
139
151
}
140
152
141
-
wg.Wait()
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
+
}
142
158
}
143
159
144
160
// SetupWorkflow sets up a new network for the workflow and volumes for
···
186
202
// ONLY marks pipeline as failed if container's exit code is non-zero.
187
203
// All other errors are bubbled up.
188
204
// Fixed version of the step execution logic
189
-
func (e *Engine) StartSteps(ctx context.Context, steps []models.Step, wid models.WorkflowId, image string) error {
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
+
}
190
210
191
-
for stepIdx, step := range steps {
211
+
for stepIdx, step := range w.Steps {
192
212
select {
193
213
case <-ctx.Done():
194
214
return ctx.Err()
195
215
default:
196
216
}
197
217
198
-
envs := ConstructEnvs(step.Environment)
218
+
envs := append(EnvVars(nil), workflowEnvs...)
219
+
for k, v := range step.Environment {
220
+
envs.AddEnv(k, v)
221
+
}
199
222
envs.AddEnv("HOME", workspaceDir)
200
223
e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice())
201
224
202
225
hostConfig := hostConfig(wid)
203
226
resp, err := e.docker.ContainerCreate(ctx, &container.Config{
204
-
Image: image,
227
+
Image: w.Image,
205
228
Cmd: []string{"bash", "-c", step.Command},
206
229
WorkingDir: workspaceDir,
207
230
Tty: false,
+1
-1
spindle/engine/envs_test.go
+1
-1
spindle/engine/envs_test.go
+129
-2
spindle/ingester.go
+129
-2
spindle/ingester.go
···
3
3
import (
4
4
"context"
5
5
"encoding/json"
6
+
"errors"
6
7
"fmt"
7
8
8
9
"tangled.sh/tangled.sh/core/api/tangled"
9
10
"tangled.sh/tangled.sh/core/eventconsumer"
11
+
"tangled.sh/tangled.sh/core/idresolver"
12
+
"tangled.sh/tangled.sh/core/rbac"
10
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"
11
18
"github.com/bluesky-social/jetstream/pkg/models"
19
+
securejoin "github.com/cyphar/filepath-securejoin"
12
20
)
13
21
14
22
type Ingester func(ctx context.Context, e *models.Event) error
···
33
41
s.ingestMember(ctx, e)
34
42
case tangled.RepoNSID:
35
43
s.ingestRepo(ctx, e)
44
+
case tangled.RepoCollaboratorNSID:
45
+
s.ingestCollaborator(ctx, e)
36
46
}
37
47
38
48
return err
···
72
82
return fmt.Errorf("failed to enforce permissions: %w", err)
73
83
}
74
84
75
-
if err := s.e.AddKnotMember(rbacDomain, record.Subject); err != nil {
85
+
if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil {
76
86
l.Error("failed to add member", "error", err)
77
87
return fmt.Errorf("failed to add member: %w", err)
78
88
}
···
90
100
return nil
91
101
}
92
102
93
-
func (s *Spindle) ingestRepo(_ context.Context, e *models.Event) error {
103
+
func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error {
94
104
var err error
105
+
did := e.Did
106
+
resolver := idresolver.DefaultResolver()
95
107
96
108
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
97
109
···
127
139
return fmt.Errorf("failed to add repo: %w", err)
128
140
}
129
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
+
130
162
// add this knot to the event consumer
131
163
src := eventconsumer.NewKnotSource(record.Knot)
132
164
s.ks.AddSource(context.Background(), src)
···
136
168
}
137
169
return nil
138
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
8
)
9
9
10
10
type Pipeline struct {
11
+
RepoOwner string
12
+
RepoName string
11
13
Workflows []Workflow
12
14
}
13
15
···
63
65
swf.Environment = workflowEnvToMap(twf.Environment)
64
66
swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery)
65
67
66
-
swf.addNixProfileToPath()
67
-
swf.setGlobalEnvs()
68
68
setup := &setupSteps{}
69
69
70
70
setup.addStep(nixConfStep())
···
79
79
80
80
workflows = append(workflows, *swf)
81
81
}
82
-
return &Pipeline{Workflows: workflows}
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
+
}
83
89
}
84
90
85
91
func workflowEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string {
···
115
121
116
122
return path.Join(nixery, dependencies)
117
123
}
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
-
}
+3
spindle/models/setup_steps.go
+3
spindle/models/setup_steps.go
···
102
102
continue
103
103
}
104
104
105
+
if len(packages) == 0 {
106
+
customPackages = append(customPackages, registry)
107
+
}
105
108
// collect packages from custom registries
106
109
for _, pkg := range packages {
107
110
customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg))
+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
2
3
3
import (
4
4
"context"
5
+
_ "embed"
5
6
"encoding/json"
6
7
"fmt"
7
8
"log/slog"
···
11
12
"tangled.sh/tangled.sh/core/api/tangled"
12
13
"tangled.sh/tangled.sh/core/eventconsumer"
13
14
"tangled.sh/tangled.sh/core/eventconsumer/cursor"
15
+
"tangled.sh/tangled.sh/core/idresolver"
14
16
"tangled.sh/tangled.sh/core/jetstream"
15
17
"tangled.sh/tangled.sh/core/log"
16
18
"tangled.sh/tangled.sh/core/notifier"
···
20
22
"tangled.sh/tangled.sh/core/spindle/engine"
21
23
"tangled.sh/tangled.sh/core/spindle/models"
22
24
"tangled.sh/tangled.sh/core/spindle/queue"
25
+
"tangled.sh/tangled.sh/core/spindle/secrets"
26
+
"tangled.sh/tangled.sh/core/spindle/xrpc"
23
27
)
24
28
29
+
//go:embed motd
30
+
var motd []byte
31
+
25
32
const (
26
33
rbacDomain = "thisserver"
27
34
)
28
35
29
36
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
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
39
48
}
40
49
41
50
func Run(ctx context.Context) error {
···
59
68
60
69
n := notifier.New()
61
70
62
-
eng, err := engine.New(ctx, cfg, d, &n)
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)
63
97
if err != nil {
64
98
return err
65
99
}
···
69
103
collections := []string{
70
104
tangled.SpindleMemberNSID,
71
105
tangled.RepoNSID,
106
+
tangled.RepoCollaboratorNSID,
72
107
}
73
108
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true)
74
109
if err != nil {
···
76
111
}
77
112
jc.AddDid(cfg.Server.Owner)
78
113
114
+
resolver := idresolver.DefaultResolver()
115
+
79
116
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,
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,
88
127
}
89
128
90
129
err = e.AddSpindle(rbacDomain)
···
100
139
// starts a job queue runner in the background
101
140
jq.Start()
102
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
+
}
103
147
104
148
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
105
149
if err != nil {
···
144
188
mux := chi.NewRouter()
145
189
146
190
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`))
191
+
w.Write(motd)
171
192
})
172
193
mux.HandleFunc("/events", s.Events)
173
194
mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) {
174
195
w.Write([]byte(s.cfg.Server.Owner))
175
196
})
176
197
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
198
+
199
+
mux.Mount("/xrpc", s.XrpcRouter())
177
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()
178
217
}
179
218
180
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
5
"github.com/go-git/go-git/v5/plumbing/object"
6
6
)
7
7
8
+
type DiffOpts struct {
9
+
Split bool `json:"split"`
10
+
}
11
+
8
12
type TextFragment struct {
9
13
Header string `json:"comment"`
10
14
Lines []gitdiff.Line `json:"lines"`
···
77
81
78
82
return files
79
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
+
}