+1
-1
.air/appview.toml
+1
-1
.air/appview.toml
+4
.gitignore
+4
.gitignore
+12
.prettierrc.json
+12
.prettierrc.json
+7
-1
.tangled/workflows/build.yml
+7
-1
.tangled/workflows/build.yml
···
1
1
when:
2
-
- event: ["push"]
2
+
- event: ["push", "pull_request"]
3
3
branch: ["master"]
4
+
5
+
engine: nixery
4
6
5
7
dependencies:
6
8
nixpkgs:
···
22
24
- name: build knot
23
25
command: |
24
26
go build -o knot.out ./cmd/knot
27
+
28
+
- name: build spindle
29
+
command: |
30
+
go build -o spindle.out ./cmd/spindle
+4
-12
.tangled/workflows/fmt.yml
+4
-12
.tangled/workflows/fmt.yml
···
1
1
when:
2
-
- event: ["push"]
2
+
- event: ["push", "pull_request"]
3
3
branch: ["master"]
4
4
5
-
dependencies:
6
-
nixpkgs:
7
-
- go
8
-
- alejandra
5
+
engine: nixery
9
6
10
7
steps:
11
-
- name: "nix fmt"
12
-
command: |
13
-
alejandra -c nix/**/*.nix flake.nix
14
-
15
-
- name: "go fmt"
8
+
- name: "Check formatting"
16
9
command: |
17
-
gofmt -l .
18
-
10
+
nix run .#fmt -- --ci
+4
-2
.tangled/workflows/test.yml
+4
-2
.tangled/workflows/test.yml
-16
.zed/settings.json
-16
.zed/settings.json
···
1
-
// Folder-specific settings
2
-
//
3
-
// For a full list of overridable settings, and general information on folder-specific settings,
4
-
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
5
-
{
6
-
"languages": {
7
-
"HTML": {
8
-
"prettier": {
9
-
"format_on_save": false,
10
-
"allowed": true,
11
-
"parser": "go-template",
12
-
"plugins": ["prettier-plugin-go-template"]
13
-
}
14
-
}
15
-
}
16
-
}
+1027
-724
api/tangled/cbor_gen.go
+1027
-724
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:
···
1437
1679
1438
1680
return nil
1439
1681
}
1682
+
func (t *GitRefUpdate_Meta_LangBreakdown) MarshalCBOR(w io.Writer) error {
1683
+
if t == nil {
1684
+
_, err := w.Write(cbg.CborNull)
1685
+
return err
1686
+
}
1687
+
1688
+
cw := cbg.NewCborWriter(w)
1689
+
fieldCount := 1
1690
+
1691
+
if t.Inputs == nil {
1692
+
fieldCount--
1693
+
}
1694
+
1695
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
1696
+
return err
1697
+
}
1698
+
1699
+
// t.Inputs ([]*tangled.GitRefUpdate_Pair) (slice)
1700
+
if t.Inputs != nil {
1701
+
1702
+
if len("inputs") > 1000000 {
1703
+
return xerrors.Errorf("Value in field \"inputs\" was too long")
1704
+
}
1705
+
1706
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("inputs"))); err != nil {
1707
+
return err
1708
+
}
1709
+
if _, err := cw.WriteString(string("inputs")); err != nil {
1710
+
return err
1711
+
}
1712
+
1713
+
if len(t.Inputs) > 8192 {
1714
+
return xerrors.Errorf("Slice value in field t.Inputs was too long")
1715
+
}
1716
+
1717
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Inputs))); err != nil {
1718
+
return err
1719
+
}
1720
+
for _, v := range t.Inputs {
1721
+
if err := v.MarshalCBOR(cw); err != nil {
1722
+
return err
1723
+
}
1724
+
1725
+
}
1726
+
}
1727
+
return nil
1728
+
}
1729
+
1730
+
func (t *GitRefUpdate_Meta_LangBreakdown) UnmarshalCBOR(r io.Reader) (err error) {
1731
+
*t = GitRefUpdate_Meta_LangBreakdown{}
1732
+
1733
+
cr := cbg.NewCborReader(r)
1734
+
1735
+
maj, extra, err := cr.ReadHeader()
1736
+
if err != nil {
1737
+
return err
1738
+
}
1739
+
defer func() {
1740
+
if err == io.EOF {
1741
+
err = io.ErrUnexpectedEOF
1742
+
}
1743
+
}()
1744
+
1745
+
if maj != cbg.MajMap {
1746
+
return fmt.Errorf("cbor input should be of type map")
1747
+
}
1748
+
1749
+
if extra > cbg.MaxLength {
1750
+
return fmt.Errorf("GitRefUpdate_Meta_LangBreakdown: map struct too large (%d)", extra)
1751
+
}
1752
+
1753
+
n := extra
1754
+
1755
+
nameBuf := make([]byte, 6)
1756
+
for i := uint64(0); i < n; i++ {
1757
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
1758
+
if err != nil {
1759
+
return err
1760
+
}
1761
+
1762
+
if !ok {
1763
+
// Field doesn't exist on this type, so ignore it
1764
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
1765
+
return err
1766
+
}
1767
+
continue
1768
+
}
1769
+
1770
+
switch string(nameBuf[:nameLen]) {
1771
+
// t.Inputs ([]*tangled.GitRefUpdate_Pair) (slice)
1772
+
case "inputs":
1773
+
1774
+
maj, extra, err = cr.ReadHeader()
1775
+
if err != nil {
1776
+
return err
1777
+
}
1778
+
1779
+
if extra > 8192 {
1780
+
return fmt.Errorf("t.Inputs: array too large (%d)", extra)
1781
+
}
1782
+
1783
+
if maj != cbg.MajArray {
1784
+
return fmt.Errorf("expected cbor array")
1785
+
}
1786
+
1787
+
if extra > 0 {
1788
+
t.Inputs = make([]*GitRefUpdate_Pair, extra)
1789
+
}
1790
+
1791
+
for i := 0; i < int(extra); i++ {
1792
+
{
1793
+
var maj byte
1794
+
var extra uint64
1795
+
var err error
1796
+
_ = maj
1797
+
_ = extra
1798
+
_ = err
1799
+
1800
+
{
1801
+
1802
+
b, err := cr.ReadByte()
1803
+
if err != nil {
1804
+
return err
1805
+
}
1806
+
if b != cbg.CborNull[0] {
1807
+
if err := cr.UnreadByte(); err != nil {
1808
+
return err
1809
+
}
1810
+
t.Inputs[i] = new(GitRefUpdate_Pair)
1811
+
if err := t.Inputs[i].UnmarshalCBOR(cr); err != nil {
1812
+
return xerrors.Errorf("unmarshaling t.Inputs[i] pointer: %w", err)
1813
+
}
1814
+
}
1815
+
1816
+
}
1817
+
1818
+
}
1819
+
}
1820
+
1821
+
default:
1822
+
// Field doesn't exist on this type, so ignore it
1823
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
1824
+
return err
1825
+
}
1826
+
}
1827
+
}
1828
+
1829
+
return nil
1830
+
}
1831
+
func (t *GitRefUpdate_Pair) MarshalCBOR(w io.Writer) error {
1832
+
if t == nil {
1833
+
_, err := w.Write(cbg.CborNull)
1834
+
return err
1835
+
}
1836
+
1837
+
cw := cbg.NewCborWriter(w)
1838
+
1839
+
if _, err := cw.Write([]byte{162}); err != nil {
1840
+
return err
1841
+
}
1842
+
1843
+
// t.Lang (string) (string)
1844
+
if len("lang") > 1000000 {
1845
+
return xerrors.Errorf("Value in field \"lang\" was too long")
1846
+
}
1847
+
1848
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("lang"))); err != nil {
1849
+
return err
1850
+
}
1851
+
if _, err := cw.WriteString(string("lang")); err != nil {
1852
+
return err
1853
+
}
1854
+
1855
+
if len(t.Lang) > 1000000 {
1856
+
return xerrors.Errorf("Value in field t.Lang was too long")
1857
+
}
1858
+
1859
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Lang))); err != nil {
1860
+
return err
1861
+
}
1862
+
if _, err := cw.WriteString(string(t.Lang)); err != nil {
1863
+
return err
1864
+
}
1865
+
1866
+
// t.Size (int64) (int64)
1867
+
if len("size") > 1000000 {
1868
+
return xerrors.Errorf("Value in field \"size\" was too long")
1869
+
}
1870
+
1871
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("size"))); err != nil {
1872
+
return err
1873
+
}
1874
+
if _, err := cw.WriteString(string("size")); err != nil {
1875
+
return err
1876
+
}
1877
+
1878
+
if t.Size >= 0 {
1879
+
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Size)); err != nil {
1880
+
return err
1881
+
}
1882
+
} else {
1883
+
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Size-1)); err != nil {
1884
+
return err
1885
+
}
1886
+
}
1887
+
1888
+
return nil
1889
+
}
1890
+
1891
+
func (t *GitRefUpdate_Pair) UnmarshalCBOR(r io.Reader) (err error) {
1892
+
*t = GitRefUpdate_Pair{}
1893
+
1894
+
cr := cbg.NewCborReader(r)
1895
+
1896
+
maj, extra, err := cr.ReadHeader()
1897
+
if err != nil {
1898
+
return err
1899
+
}
1900
+
defer func() {
1901
+
if err == io.EOF {
1902
+
err = io.ErrUnexpectedEOF
1903
+
}
1904
+
}()
1905
+
1906
+
if maj != cbg.MajMap {
1907
+
return fmt.Errorf("cbor input should be of type map")
1908
+
}
1909
+
1910
+
if extra > cbg.MaxLength {
1911
+
return fmt.Errorf("GitRefUpdate_Pair: map struct too large (%d)", extra)
1912
+
}
1913
+
1914
+
n := extra
1915
+
1916
+
nameBuf := make([]byte, 4)
1917
+
for i := uint64(0); i < n; i++ {
1918
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
1919
+
if err != nil {
1920
+
return err
1921
+
}
1922
+
1923
+
if !ok {
1924
+
// Field doesn't exist on this type, so ignore it
1925
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
1926
+
return err
1927
+
}
1928
+
continue
1929
+
}
1930
+
1931
+
switch string(nameBuf[:nameLen]) {
1932
+
// t.Lang (string) (string)
1933
+
case "lang":
1934
+
1935
+
{
1936
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
1937
+
if err != nil {
1938
+
return err
1939
+
}
1940
+
1941
+
t.Lang = string(sval)
1942
+
}
1943
+
// t.Size (int64) (int64)
1944
+
case "size":
1945
+
{
1946
+
maj, extra, err := cr.ReadHeader()
1947
+
if err != nil {
1948
+
return err
1949
+
}
1950
+
var extraI int64
1951
+
switch maj {
1952
+
case cbg.MajUnsignedInt:
1953
+
extraI = int64(extra)
1954
+
if extraI < 0 {
1955
+
return fmt.Errorf("int64 positive overflow")
1956
+
}
1957
+
case cbg.MajNegativeInt:
1958
+
extraI = int64(extra)
1959
+
if extraI < 0 {
1960
+
return fmt.Errorf("int64 negative overflow")
1961
+
}
1962
+
extraI = -1 - extraI
1963
+
default:
1964
+
return fmt.Errorf("wrong type for int64 field: %d", maj)
1965
+
}
1966
+
1967
+
t.Size = int64(extraI)
1968
+
}
1969
+
1970
+
default:
1971
+
// Field doesn't exist on this type, so ignore it
1972
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
1973
+
return err
1974
+
}
1975
+
}
1976
+
}
1977
+
1978
+
return nil
1979
+
}
1440
1980
func (t *GraphFollow) MarshalCBOR(w io.Writer) error {
1441
1981
if t == nil {
1442
1982
_, err := w.Write(cbg.CborNull)
···
2188
2728
2189
2729
return nil
2190
2730
}
2191
-
func (t *Pipeline_Dependency) MarshalCBOR(w io.Writer) error {
2192
-
if t == nil {
2193
-
_, err := w.Write(cbg.CborNull)
2194
-
return err
2195
-
}
2196
-
2197
-
cw := cbg.NewCborWriter(w)
2198
-
2199
-
if _, err := cw.Write([]byte{162}); err != nil {
2200
-
return err
2201
-
}
2202
-
2203
-
// t.Packages ([]string) (slice)
2204
-
if len("packages") > 1000000 {
2205
-
return xerrors.Errorf("Value in field \"packages\" was too long")
2206
-
}
2207
-
2208
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("packages"))); err != nil {
2209
-
return err
2210
-
}
2211
-
if _, err := cw.WriteString(string("packages")); err != nil {
2212
-
return err
2213
-
}
2214
-
2215
-
if len(t.Packages) > 8192 {
2216
-
return xerrors.Errorf("Slice value in field t.Packages was too long")
2217
-
}
2218
-
2219
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Packages))); err != nil {
2220
-
return err
2221
-
}
2222
-
for _, v := range t.Packages {
2223
-
if len(v) > 1000000 {
2224
-
return xerrors.Errorf("Value in field v was too long")
2225
-
}
2226
-
2227
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
2228
-
return err
2229
-
}
2230
-
if _, err := cw.WriteString(string(v)); err != nil {
2231
-
return err
2232
-
}
2233
-
2234
-
}
2235
-
2236
-
// t.Registry (string) (string)
2237
-
if len("registry") > 1000000 {
2238
-
return xerrors.Errorf("Value in field \"registry\" was too long")
2239
-
}
2240
-
2241
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("registry"))); err != nil {
2242
-
return err
2243
-
}
2244
-
if _, err := cw.WriteString(string("registry")); err != nil {
2245
-
return err
2246
-
}
2247
-
2248
-
if len(t.Registry) > 1000000 {
2249
-
return xerrors.Errorf("Value in field t.Registry was too long")
2250
-
}
2251
-
2252
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Registry))); err != nil {
2253
-
return err
2254
-
}
2255
-
if _, err := cw.WriteString(string(t.Registry)); err != nil {
2256
-
return err
2257
-
}
2258
-
return nil
2259
-
}
2260
-
2261
-
func (t *Pipeline_Dependency) UnmarshalCBOR(r io.Reader) (err error) {
2262
-
*t = Pipeline_Dependency{}
2263
-
2264
-
cr := cbg.NewCborReader(r)
2265
-
2266
-
maj, extra, err := cr.ReadHeader()
2267
-
if err != nil {
2268
-
return err
2269
-
}
2270
-
defer func() {
2271
-
if err == io.EOF {
2272
-
err = io.ErrUnexpectedEOF
2273
-
}
2274
-
}()
2275
-
2276
-
if maj != cbg.MajMap {
2277
-
return fmt.Errorf("cbor input should be of type map")
2278
-
}
2279
-
2280
-
if extra > cbg.MaxLength {
2281
-
return fmt.Errorf("Pipeline_Dependency: map struct too large (%d)", extra)
2282
-
}
2283
-
2284
-
n := extra
2285
-
2286
-
nameBuf := make([]byte, 8)
2287
-
for i := uint64(0); i < n; i++ {
2288
-
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
2289
-
if err != nil {
2290
-
return err
2291
-
}
2292
-
2293
-
if !ok {
2294
-
// Field doesn't exist on this type, so ignore it
2295
-
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
2296
-
return err
2297
-
}
2298
-
continue
2299
-
}
2300
-
2301
-
switch string(nameBuf[:nameLen]) {
2302
-
// t.Packages ([]string) (slice)
2303
-
case "packages":
2304
-
2305
-
maj, extra, err = cr.ReadHeader()
2306
-
if err != nil {
2307
-
return err
2308
-
}
2309
-
2310
-
if extra > 8192 {
2311
-
return fmt.Errorf("t.Packages: array too large (%d)", extra)
2312
-
}
2313
-
2314
-
if maj != cbg.MajArray {
2315
-
return fmt.Errorf("expected cbor array")
2316
-
}
2317
-
2318
-
if extra > 0 {
2319
-
t.Packages = make([]string, extra)
2320
-
}
2321
-
2322
-
for i := 0; i < int(extra); i++ {
2323
-
{
2324
-
var maj byte
2325
-
var extra uint64
2326
-
var err error
2327
-
_ = maj
2328
-
_ = extra
2329
-
_ = err
2330
-
2331
-
{
2332
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2333
-
if err != nil {
2334
-
return err
2335
-
}
2336
-
2337
-
t.Packages[i] = string(sval)
2338
-
}
2339
-
2340
-
}
2341
-
}
2342
-
// t.Registry (string) (string)
2343
-
case "registry":
2344
-
2345
-
{
2346
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2347
-
if err != nil {
2348
-
return err
2349
-
}
2350
-
2351
-
t.Registry = string(sval)
2352
-
}
2353
-
2354
-
default:
2355
-
// Field doesn't exist on this type, so ignore it
2356
-
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
2357
-
return err
2358
-
}
2359
-
}
2360
-
}
2361
-
2362
-
return nil
2363
-
}
2364
2731
func (t *Pipeline_ManualTriggerData) MarshalCBOR(w io.Writer) error {
2365
2732
if t == nil {
2366
2733
_, err := w.Write(cbg.CborNull)
···
3376
3743
3377
3744
return nil
3378
3745
}
3379
-
func (t *Pipeline_Step) MarshalCBOR(w io.Writer) error {
3380
-
if t == nil {
3381
-
_, err := w.Write(cbg.CborNull)
3382
-
return err
3383
-
}
3384
-
3385
-
cw := cbg.NewCborWriter(w)
3386
-
fieldCount := 3
3387
-
3388
-
if t.Environment == nil {
3389
-
fieldCount--
3390
-
}
3391
-
3392
-
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
3393
-
return err
3394
-
}
3395
-
3396
-
// t.Name (string) (string)
3397
-
if len("name") > 1000000 {
3398
-
return xerrors.Errorf("Value in field \"name\" was too long")
3399
-
}
3400
-
3401
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("name"))); err != nil {
3402
-
return err
3403
-
}
3404
-
if _, err := cw.WriteString(string("name")); err != nil {
3405
-
return err
3406
-
}
3407
-
3408
-
if len(t.Name) > 1000000 {
3409
-
return xerrors.Errorf("Value in field t.Name was too long")
3410
-
}
3411
-
3412
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Name))); err != nil {
3413
-
return err
3414
-
}
3415
-
if _, err := cw.WriteString(string(t.Name)); err != nil {
3416
-
return err
3417
-
}
3418
-
3419
-
// t.Command (string) (string)
3420
-
if len("command") > 1000000 {
3421
-
return xerrors.Errorf("Value in field \"command\" was too long")
3422
-
}
3423
-
3424
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("command"))); err != nil {
3425
-
return err
3426
-
}
3427
-
if _, err := cw.WriteString(string("command")); err != nil {
3428
-
return err
3429
-
}
3430
-
3431
-
if len(t.Command) > 1000000 {
3432
-
return xerrors.Errorf("Value in field t.Command was too long")
3433
-
}
3434
-
3435
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Command))); err != nil {
3436
-
return err
3437
-
}
3438
-
if _, err := cw.WriteString(string(t.Command)); err != nil {
3439
-
return err
3440
-
}
3441
-
3442
-
// t.Environment ([]*tangled.Pipeline_Pair) (slice)
3443
-
if t.Environment != nil {
3444
-
3445
-
if len("environment") > 1000000 {
3446
-
return xerrors.Errorf("Value in field \"environment\" was too long")
3447
-
}
3448
-
3449
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("environment"))); err != nil {
3450
-
return err
3451
-
}
3452
-
if _, err := cw.WriteString(string("environment")); err != nil {
3453
-
return err
3454
-
}
3455
-
3456
-
if len(t.Environment) > 8192 {
3457
-
return xerrors.Errorf("Slice value in field t.Environment was too long")
3458
-
}
3459
-
3460
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Environment))); err != nil {
3461
-
return err
3462
-
}
3463
-
for _, v := range t.Environment {
3464
-
if err := v.MarshalCBOR(cw); err != nil {
3465
-
return err
3466
-
}
3467
-
3468
-
}
3469
-
}
3470
-
return nil
3471
-
}
3472
-
3473
-
func (t *Pipeline_Step) UnmarshalCBOR(r io.Reader) (err error) {
3474
-
*t = Pipeline_Step{}
3475
-
3476
-
cr := cbg.NewCborReader(r)
3477
-
3478
-
maj, extra, err := cr.ReadHeader()
3479
-
if err != nil {
3480
-
return err
3481
-
}
3482
-
defer func() {
3483
-
if err == io.EOF {
3484
-
err = io.ErrUnexpectedEOF
3485
-
}
3486
-
}()
3487
-
3488
-
if maj != cbg.MajMap {
3489
-
return fmt.Errorf("cbor input should be of type map")
3490
-
}
3491
-
3492
-
if extra > cbg.MaxLength {
3493
-
return fmt.Errorf("Pipeline_Step: map struct too large (%d)", extra)
3494
-
}
3495
-
3496
-
n := extra
3497
-
3498
-
nameBuf := make([]byte, 11)
3499
-
for i := uint64(0); i < n; i++ {
3500
-
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
3501
-
if err != nil {
3502
-
return err
3503
-
}
3504
-
3505
-
if !ok {
3506
-
// Field doesn't exist on this type, so ignore it
3507
-
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
3508
-
return err
3509
-
}
3510
-
continue
3511
-
}
3512
-
3513
-
switch string(nameBuf[:nameLen]) {
3514
-
// t.Name (string) (string)
3515
-
case "name":
3516
-
3517
-
{
3518
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3519
-
if err != nil {
3520
-
return err
3521
-
}
3522
-
3523
-
t.Name = string(sval)
3524
-
}
3525
-
// t.Command (string) (string)
3526
-
case "command":
3527
-
3528
-
{
3529
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3530
-
if err != nil {
3531
-
return err
3532
-
}
3533
-
3534
-
t.Command = string(sval)
3535
-
}
3536
-
// t.Environment ([]*tangled.Pipeline_Pair) (slice)
3537
-
case "environment":
3538
-
3539
-
maj, extra, err = cr.ReadHeader()
3540
-
if err != nil {
3541
-
return err
3542
-
}
3543
-
3544
-
if extra > 8192 {
3545
-
return fmt.Errorf("t.Environment: array too large (%d)", extra)
3546
-
}
3547
-
3548
-
if maj != cbg.MajArray {
3549
-
return fmt.Errorf("expected cbor array")
3550
-
}
3551
-
3552
-
if extra > 0 {
3553
-
t.Environment = make([]*Pipeline_Pair, extra)
3554
-
}
3555
-
3556
-
for i := 0; i < int(extra); i++ {
3557
-
{
3558
-
var maj byte
3559
-
var extra uint64
3560
-
var err error
3561
-
_ = maj
3562
-
_ = extra
3563
-
_ = err
3564
-
3565
-
{
3566
-
3567
-
b, err := cr.ReadByte()
3568
-
if err != nil {
3569
-
return err
3570
-
}
3571
-
if b != cbg.CborNull[0] {
3572
-
if err := cr.UnreadByte(); err != nil {
3573
-
return err
3574
-
}
3575
-
t.Environment[i] = new(Pipeline_Pair)
3576
-
if err := t.Environment[i].UnmarshalCBOR(cr); err != nil {
3577
-
return xerrors.Errorf("unmarshaling t.Environment[i] pointer: %w", err)
3578
-
}
3579
-
}
3580
-
3581
-
}
3582
-
3583
-
}
3584
-
}
3585
-
3586
-
default:
3587
-
// Field doesn't exist on this type, so ignore it
3588
-
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
3589
-
return err
3590
-
}
3591
-
}
3592
-
}
3593
-
3594
-
return nil
3595
-
}
3596
3746
func (t *Pipeline_TriggerMetadata) MarshalCBOR(w io.Writer) error {
3597
3747
if t == nil {
3598
3748
_, err := w.Write(cbg.CborNull)
···
4069
4219
4070
4220
cw := cbg.NewCborWriter(w)
4071
4221
4072
-
if _, err := cw.Write([]byte{165}); err != nil {
4222
+
if _, err := cw.Write([]byte{164}); err != nil {
4223
+
return err
4224
+
}
4225
+
4226
+
// t.Raw (string) (string)
4227
+
if len("raw") > 1000000 {
4228
+
return xerrors.Errorf("Value in field \"raw\" was too long")
4229
+
}
4230
+
4231
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("raw"))); err != nil {
4232
+
return err
4233
+
}
4234
+
if _, err := cw.WriteString(string("raw")); err != nil {
4235
+
return err
4236
+
}
4237
+
4238
+
if len(t.Raw) > 1000000 {
4239
+
return xerrors.Errorf("Value in field t.Raw was too long")
4240
+
}
4241
+
4242
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Raw))); err != nil {
4243
+
return err
4244
+
}
4245
+
if _, err := cw.WriteString(string(t.Raw)); err != nil {
4073
4246
return err
4074
4247
}
4075
4248
···
4112
4285
return err
4113
4286
}
4114
4287
4115
-
// t.Steps ([]*tangled.Pipeline_Step) (slice)
4116
-
if len("steps") > 1000000 {
4117
-
return xerrors.Errorf("Value in field \"steps\" was too long")
4288
+
// t.Engine (string) (string)
4289
+
if len("engine") > 1000000 {
4290
+
return xerrors.Errorf("Value in field \"engine\" was too long")
4118
4291
}
4119
4292
4120
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("steps"))); err != nil {
4121
-
return err
4122
-
}
4123
-
if _, err := cw.WriteString(string("steps")); err != nil {
4293
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("engine"))); err != nil {
4124
4294
return err
4125
4295
}
4126
-
4127
-
if len(t.Steps) > 8192 {
4128
-
return xerrors.Errorf("Slice value in field t.Steps was too long")
4129
-
}
4130
-
4131
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Steps))); err != nil {
4132
-
return err
4133
-
}
4134
-
for _, v := range t.Steps {
4135
-
if err := v.MarshalCBOR(cw); err != nil {
4136
-
return err
4137
-
}
4138
-
4139
-
}
4140
-
4141
-
// t.Environment ([]*tangled.Pipeline_Pair) (slice)
4142
-
if len("environment") > 1000000 {
4143
-
return xerrors.Errorf("Value in field \"environment\" was too long")
4144
-
}
4145
-
4146
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("environment"))); err != nil {
4147
-
return err
4148
-
}
4149
-
if _, err := cw.WriteString(string("environment")); err != nil {
4150
-
return err
4151
-
}
4152
-
4153
-
if len(t.Environment) > 8192 {
4154
-
return xerrors.Errorf("Slice value in field t.Environment was too long")
4155
-
}
4156
-
4157
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Environment))); err != nil {
4296
+
if _, err := cw.WriteString(string("engine")); err != nil {
4158
4297
return err
4159
4298
}
4160
-
for _, v := range t.Environment {
4161
-
if err := v.MarshalCBOR(cw); err != nil {
4162
-
return err
4163
-
}
4164
4299
4300
+
if len(t.Engine) > 1000000 {
4301
+
return xerrors.Errorf("Value in field t.Engine was too long")
4165
4302
}
4166
4303
4167
-
// t.Dependencies ([]*tangled.Pipeline_Dependency) (slice)
4168
-
if len("dependencies") > 1000000 {
4169
-
return xerrors.Errorf("Value in field \"dependencies\" was too long")
4170
-
}
4171
-
4172
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("dependencies"))); err != nil {
4304
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Engine))); err != nil {
4173
4305
return err
4174
4306
}
4175
-
if _, err := cw.WriteString(string("dependencies")); err != nil {
4307
+
if _, err := cw.WriteString(string(t.Engine)); err != nil {
4176
4308
return err
4177
-
}
4178
-
4179
-
if len(t.Dependencies) > 8192 {
4180
-
return xerrors.Errorf("Slice value in field t.Dependencies was too long")
4181
-
}
4182
-
4183
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Dependencies))); err != nil {
4184
-
return err
4185
-
}
4186
-
for _, v := range t.Dependencies {
4187
-
if err := v.MarshalCBOR(cw); err != nil {
4188
-
return err
4189
-
}
4190
-
4191
4309
}
4192
4310
return nil
4193
4311
}
···
4217
4335
4218
4336
n := extra
4219
4337
4220
-
nameBuf := make([]byte, 12)
4338
+
nameBuf := make([]byte, 6)
4221
4339
for i := uint64(0); i < n; i++ {
4222
4340
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
4223
4341
if err != nil {
···
4233
4351
}
4234
4352
4235
4353
switch string(nameBuf[:nameLen]) {
4236
-
// t.Name (string) (string)
4354
+
// t.Raw (string) (string)
4355
+
case "raw":
4356
+
4357
+
{
4358
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
4359
+
if err != nil {
4360
+
return err
4361
+
}
4362
+
4363
+
t.Raw = string(sval)
4364
+
}
4365
+
// t.Name (string) (string)
4237
4366
case "name":
4238
4367
4239
4368
{
···
4264
4393
}
4265
4394
4266
4395
}
4267
-
// t.Steps ([]*tangled.Pipeline_Step) (slice)
4268
-
case "steps":
4396
+
// t.Engine (string) (string)
4397
+
case "engine":
4269
4398
4270
-
maj, extra, err = cr.ReadHeader()
4271
-
if err != nil {
4272
-
return err
4273
-
}
4274
-
4275
-
if extra > 8192 {
4276
-
return fmt.Errorf("t.Steps: array too large (%d)", extra)
4277
-
}
4278
-
4279
-
if maj != cbg.MajArray {
4280
-
return fmt.Errorf("expected cbor array")
4281
-
}
4282
-
4283
-
if extra > 0 {
4284
-
t.Steps = make([]*Pipeline_Step, extra)
4285
-
}
4286
-
4287
-
for i := 0; i < int(extra); i++ {
4288
-
{
4289
-
var maj byte
4290
-
var extra uint64
4291
-
var err error
4292
-
_ = maj
4293
-
_ = extra
4294
-
_ = err
4295
-
4296
-
{
4297
-
4298
-
b, err := cr.ReadByte()
4299
-
if err != nil {
4300
-
return err
4301
-
}
4302
-
if b != cbg.CborNull[0] {
4303
-
if err := cr.UnreadByte(); err != nil {
4304
-
return err
4305
-
}
4306
-
t.Steps[i] = new(Pipeline_Step)
4307
-
if err := t.Steps[i].UnmarshalCBOR(cr); err != nil {
4308
-
return xerrors.Errorf("unmarshaling t.Steps[i] pointer: %w", err)
4309
-
}
4310
-
}
4311
-
4312
-
}
4313
-
4399
+
{
4400
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
4401
+
if err != nil {
4402
+
return err
4314
4403
}
4315
-
}
4316
-
// t.Environment ([]*tangled.Pipeline_Pair) (slice)
4317
-
case "environment":
4318
4404
4319
-
maj, extra, err = cr.ReadHeader()
4320
-
if err != nil {
4321
-
return err
4322
-
}
4323
-
4324
-
if extra > 8192 {
4325
-
return fmt.Errorf("t.Environment: array too large (%d)", extra)
4326
-
}
4327
-
4328
-
if maj != cbg.MajArray {
4329
-
return fmt.Errorf("expected cbor array")
4330
-
}
4331
-
4332
-
if extra > 0 {
4333
-
t.Environment = make([]*Pipeline_Pair, extra)
4334
-
}
4335
-
4336
-
for i := 0; i < int(extra); i++ {
4337
-
{
4338
-
var maj byte
4339
-
var extra uint64
4340
-
var err error
4341
-
_ = maj
4342
-
_ = extra
4343
-
_ = err
4344
-
4345
-
{
4346
-
4347
-
b, err := cr.ReadByte()
4348
-
if err != nil {
4349
-
return err
4350
-
}
4351
-
if b != cbg.CborNull[0] {
4352
-
if err := cr.UnreadByte(); err != nil {
4353
-
return err
4354
-
}
4355
-
t.Environment[i] = new(Pipeline_Pair)
4356
-
if err := t.Environment[i].UnmarshalCBOR(cr); err != nil {
4357
-
return xerrors.Errorf("unmarshaling t.Environment[i] pointer: %w", err)
4358
-
}
4359
-
}
4360
-
4361
-
}
4362
-
4363
-
}
4364
-
}
4365
-
// t.Dependencies ([]*tangled.Pipeline_Dependency) (slice)
4366
-
case "dependencies":
4367
-
4368
-
maj, extra, err = cr.ReadHeader()
4369
-
if err != nil {
4370
-
return err
4371
-
}
4372
-
4373
-
if extra > 8192 {
4374
-
return fmt.Errorf("t.Dependencies: array too large (%d)", extra)
4375
-
}
4376
-
4377
-
if maj != cbg.MajArray {
4378
-
return fmt.Errorf("expected cbor array")
4379
-
}
4380
-
4381
-
if extra > 0 {
4382
-
t.Dependencies = make([]*Pipeline_Dependency, extra)
4383
-
}
4384
-
4385
-
for i := 0; i < int(extra); i++ {
4386
-
{
4387
-
var maj byte
4388
-
var extra uint64
4389
-
var err error
4390
-
_ = maj
4391
-
_ = extra
4392
-
_ = err
4393
-
4394
-
{
4395
-
4396
-
b, err := cr.ReadByte()
4397
-
if err != nil {
4398
-
return err
4399
-
}
4400
-
if b != cbg.CborNull[0] {
4401
-
if err := cr.UnreadByte(); err != nil {
4402
-
return err
4403
-
}
4404
-
t.Dependencies[i] = new(Pipeline_Dependency)
4405
-
if err := t.Dependencies[i].UnmarshalCBOR(cr); err != nil {
4406
-
return xerrors.Errorf("unmarshaling t.Dependencies[i] pointer: %w", err)
4407
-
}
4408
-
}
4409
-
4410
-
}
4411
-
4412
-
}
4405
+
t.Engine = string(sval)
4413
4406
}
4414
4407
4415
4408
default:
···
5314
5307
5315
5308
return nil
5316
5309
}
5310
+
func (t *RepoCollaborator) MarshalCBOR(w io.Writer) error {
5311
+
if t == nil {
5312
+
_, err := w.Write(cbg.CborNull)
5313
+
return err
5314
+
}
5315
+
5316
+
cw := cbg.NewCborWriter(w)
5317
+
5318
+
if _, err := cw.Write([]byte{164}); err != nil {
5319
+
return err
5320
+
}
5321
+
5322
+
// t.Repo (string) (string)
5323
+
if len("repo") > 1000000 {
5324
+
return xerrors.Errorf("Value in field \"repo\" was too long")
5325
+
}
5326
+
5327
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil {
5328
+
return err
5329
+
}
5330
+
if _, err := cw.WriteString(string("repo")); err != nil {
5331
+
return err
5332
+
}
5333
+
5334
+
if len(t.Repo) > 1000000 {
5335
+
return xerrors.Errorf("Value in field t.Repo was too long")
5336
+
}
5337
+
5338
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil {
5339
+
return err
5340
+
}
5341
+
if _, err := cw.WriteString(string(t.Repo)); err != nil {
5342
+
return err
5343
+
}
5344
+
5345
+
// t.LexiconTypeID (string) (string)
5346
+
if len("$type") > 1000000 {
5347
+
return xerrors.Errorf("Value in field \"$type\" was too long")
5348
+
}
5349
+
5350
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
5351
+
return err
5352
+
}
5353
+
if _, err := cw.WriteString(string("$type")); err != nil {
5354
+
return err
5355
+
}
5356
+
5357
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.repo.collaborator"))); err != nil {
5358
+
return err
5359
+
}
5360
+
if _, err := cw.WriteString(string("sh.tangled.repo.collaborator")); err != nil {
5361
+
return err
5362
+
}
5363
+
5364
+
// t.Subject (string) (string)
5365
+
if len("subject") > 1000000 {
5366
+
return xerrors.Errorf("Value in field \"subject\" was too long")
5367
+
}
5368
+
5369
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil {
5370
+
return err
5371
+
}
5372
+
if _, err := cw.WriteString(string("subject")); err != nil {
5373
+
return err
5374
+
}
5375
+
5376
+
if len(t.Subject) > 1000000 {
5377
+
return xerrors.Errorf("Value in field t.Subject was too long")
5378
+
}
5379
+
5380
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil {
5381
+
return err
5382
+
}
5383
+
if _, err := cw.WriteString(string(t.Subject)); err != nil {
5384
+
return err
5385
+
}
5386
+
5387
+
// t.CreatedAt (string) (string)
5388
+
if len("createdAt") > 1000000 {
5389
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
5390
+
}
5391
+
5392
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
5393
+
return err
5394
+
}
5395
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
5396
+
return err
5397
+
}
5398
+
5399
+
if len(t.CreatedAt) > 1000000 {
5400
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
5401
+
}
5402
+
5403
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
5404
+
return err
5405
+
}
5406
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
5407
+
return err
5408
+
}
5409
+
return nil
5410
+
}
5411
+
5412
+
func (t *RepoCollaborator) UnmarshalCBOR(r io.Reader) (err error) {
5413
+
*t = RepoCollaborator{}
5414
+
5415
+
cr := cbg.NewCborReader(r)
5416
+
5417
+
maj, extra, err := cr.ReadHeader()
5418
+
if err != nil {
5419
+
return err
5420
+
}
5421
+
defer func() {
5422
+
if err == io.EOF {
5423
+
err = io.ErrUnexpectedEOF
5424
+
}
5425
+
}()
5426
+
5427
+
if maj != cbg.MajMap {
5428
+
return fmt.Errorf("cbor input should be of type map")
5429
+
}
5430
+
5431
+
if extra > cbg.MaxLength {
5432
+
return fmt.Errorf("RepoCollaborator: map struct too large (%d)", extra)
5433
+
}
5434
+
5435
+
n := extra
5436
+
5437
+
nameBuf := make([]byte, 9)
5438
+
for i := uint64(0); i < n; i++ {
5439
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
5440
+
if err != nil {
5441
+
return err
5442
+
}
5443
+
5444
+
if !ok {
5445
+
// Field doesn't exist on this type, so ignore it
5446
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
5447
+
return err
5448
+
}
5449
+
continue
5450
+
}
5451
+
5452
+
switch string(nameBuf[:nameLen]) {
5453
+
// t.Repo (string) (string)
5454
+
case "repo":
5455
+
5456
+
{
5457
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
5458
+
if err != nil {
5459
+
return err
5460
+
}
5461
+
5462
+
t.Repo = string(sval)
5463
+
}
5464
+
// t.LexiconTypeID (string) (string)
5465
+
case "$type":
5466
+
5467
+
{
5468
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
5469
+
if err != nil {
5470
+
return err
5471
+
}
5472
+
5473
+
t.LexiconTypeID = string(sval)
5474
+
}
5475
+
// t.Subject (string) (string)
5476
+
case "subject":
5477
+
5478
+
{
5479
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
5480
+
if err != nil {
5481
+
return err
5482
+
}
5483
+
5484
+
t.Subject = string(sval)
5485
+
}
5486
+
// t.CreatedAt (string) (string)
5487
+
case "createdAt":
5488
+
5489
+
{
5490
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
5491
+
if err != nil {
5492
+
return err
5493
+
}
5494
+
5495
+
t.CreatedAt = string(sval)
5496
+
}
5497
+
5498
+
default:
5499
+
// Field doesn't exist on this type, so ignore it
5500
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
5501
+
return err
5502
+
}
5503
+
}
5504
+
}
5505
+
5506
+
return nil
5507
+
}
5317
5508
func (t *RepoIssue) MarshalCBOR(w io.Writer) error {
5318
5509
if t == nil {
5319
5510
_, err := w.Write(cbg.CborNull)
···
5321
5512
}
5322
5513
5323
5514
cw := cbg.NewCborWriter(w)
5324
-
fieldCount := 7
5515
+
fieldCount := 6
5325
5516
5326
5517
if t.Body == nil {
5327
5518
fieldCount--
···
5451
5642
return err
5452
5643
}
5453
5644
5454
-
// t.IssueId (int64) (int64)
5455
-
if len("issueId") > 1000000 {
5456
-
return xerrors.Errorf("Value in field \"issueId\" was too long")
5457
-
}
5458
-
5459
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("issueId"))); err != nil {
5460
-
return err
5461
-
}
5462
-
if _, err := cw.WriteString(string("issueId")); err != nil {
5463
-
return err
5464
-
}
5465
-
5466
-
if t.IssueId >= 0 {
5467
-
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.IssueId)); err != nil {
5468
-
return err
5469
-
}
5470
-
} else {
5471
-
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.IssueId-1)); err != nil {
5472
-
return err
5473
-
}
5474
-
}
5475
-
5476
5645
// t.CreatedAt (string) (string)
5477
5646
if len("createdAt") > 1000000 {
5478
5647
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
5604
5773
5605
5774
t.Title = string(sval)
5606
5775
}
5607
-
// t.IssueId (int64) (int64)
5608
-
case "issueId":
5609
-
{
5610
-
maj, extra, err := cr.ReadHeader()
5611
-
if err != nil {
5612
-
return err
5613
-
}
5614
-
var extraI int64
5615
-
switch maj {
5616
-
case cbg.MajUnsignedInt:
5617
-
extraI = int64(extra)
5618
-
if extraI < 0 {
5619
-
return fmt.Errorf("int64 positive overflow")
5620
-
}
5621
-
case cbg.MajNegativeInt:
5622
-
extraI = int64(extra)
5623
-
if extraI < 0 {
5624
-
return fmt.Errorf("int64 negative overflow")
5625
-
}
5626
-
extraI = -1 - extraI
5627
-
default:
5628
-
return fmt.Errorf("wrong type for int64 field: %d", maj)
5629
-
}
5630
-
5631
-
t.IssueId = int64(extraI)
5632
-
}
5633
5776
// t.CreatedAt (string) (string)
5634
5777
case "createdAt":
5635
5778
···
5659
5802
}
5660
5803
5661
5804
cw := cbg.NewCborWriter(w)
5662
-
fieldCount := 7
5663
-
5664
-
if t.CommentId == nil {
5665
-
fieldCount--
5666
-
}
5805
+
fieldCount := 6
5667
5806
5668
5807
if t.Owner == nil {
5669
5808
fieldCount--
···
5806
5945
}
5807
5946
}
5808
5947
5809
-
// t.CommentId (int64) (int64)
5810
-
if t.CommentId != nil {
5811
-
5812
-
if len("commentId") > 1000000 {
5813
-
return xerrors.Errorf("Value in field \"commentId\" was too long")
5814
-
}
5815
-
5816
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commentId"))); err != nil {
5817
-
return err
5818
-
}
5819
-
if _, err := cw.WriteString(string("commentId")); err != nil {
5820
-
return err
5821
-
}
5822
-
5823
-
if t.CommentId == nil {
5824
-
if _, err := cw.Write(cbg.CborNull); err != nil {
5825
-
return err
5826
-
}
5827
-
} else {
5828
-
if *t.CommentId >= 0 {
5829
-
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.CommentId)); err != nil {
5830
-
return err
5831
-
}
5832
-
} else {
5833
-
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.CommentId-1)); err != nil {
5834
-
return err
5835
-
}
5836
-
}
5837
-
}
5838
-
5839
-
}
5840
-
5841
5948
// t.CreatedAt (string) (string)
5842
5949
if len("createdAt") > 1000000 {
5843
5950
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
5977
6084
}
5978
6085
5979
6086
t.Owner = (*string)(&sval)
5980
-
}
5981
-
}
5982
-
// t.CommentId (int64) (int64)
5983
-
case "commentId":
5984
-
{
5985
-
5986
-
b, err := cr.ReadByte()
5987
-
if err != nil {
5988
-
return err
5989
-
}
5990
-
if b != cbg.CborNull[0] {
5991
-
if err := cr.UnreadByte(); err != nil {
5992
-
return err
5993
-
}
5994
-
maj, extra, err := cr.ReadHeader()
5995
-
if err != nil {
5996
-
return err
5997
-
}
5998
-
var extraI int64
5999
-
switch maj {
6000
-
case cbg.MajUnsignedInt:
6001
-
extraI = int64(extra)
6002
-
if extraI < 0 {
6003
-
return fmt.Errorf("int64 positive overflow")
6004
-
}
6005
-
case cbg.MajNegativeInt:
6006
-
extraI = int64(extra)
6007
-
if extraI < 0 {
6008
-
return fmt.Errorf("int64 negative overflow")
6009
-
}
6010
-
extraI = -1 - extraI
6011
-
default:
6012
-
return fmt.Errorf("wrong type for int64 field: %d", maj)
6013
-
}
6014
-
6015
-
t.CommentId = (*int64)(&extraI)
6016
6087
}
6017
6088
}
6018
6089
// t.CreatedAt (string) (string)
···
7685
7756
7686
7757
return nil
7687
7758
}
7759
+
func (t *String) MarshalCBOR(w io.Writer) error {
7760
+
if t == nil {
7761
+
_, err := w.Write(cbg.CborNull)
7762
+
return err
7763
+
}
7764
+
7765
+
cw := cbg.NewCborWriter(w)
7766
+
7767
+
if _, err := cw.Write([]byte{165}); err != nil {
7768
+
return err
7769
+
}
7770
+
7771
+
// t.LexiconTypeID (string) (string)
7772
+
if len("$type") > 1000000 {
7773
+
return xerrors.Errorf("Value in field \"$type\" was too long")
7774
+
}
7775
+
7776
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
7777
+
return err
7778
+
}
7779
+
if _, err := cw.WriteString(string("$type")); err != nil {
7780
+
return err
7781
+
}
7782
+
7783
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.string"))); err != nil {
7784
+
return err
7785
+
}
7786
+
if _, err := cw.WriteString(string("sh.tangled.string")); err != nil {
7787
+
return err
7788
+
}
7789
+
7790
+
// t.Contents (string) (string)
7791
+
if len("contents") > 1000000 {
7792
+
return xerrors.Errorf("Value in field \"contents\" was too long")
7793
+
}
7794
+
7795
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("contents"))); err != nil {
7796
+
return err
7797
+
}
7798
+
if _, err := cw.WriteString(string("contents")); err != nil {
7799
+
return err
7800
+
}
7801
+
7802
+
if len(t.Contents) > 1000000 {
7803
+
return xerrors.Errorf("Value in field t.Contents was too long")
7804
+
}
7805
+
7806
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Contents))); err != nil {
7807
+
return err
7808
+
}
7809
+
if _, err := cw.WriteString(string(t.Contents)); err != nil {
7810
+
return err
7811
+
}
7812
+
7813
+
// t.Filename (string) (string)
7814
+
if len("filename") > 1000000 {
7815
+
return xerrors.Errorf("Value in field \"filename\" was too long")
7816
+
}
7817
+
7818
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("filename"))); err != nil {
7819
+
return err
7820
+
}
7821
+
if _, err := cw.WriteString(string("filename")); err != nil {
7822
+
return err
7823
+
}
7824
+
7825
+
if len(t.Filename) > 1000000 {
7826
+
return xerrors.Errorf("Value in field t.Filename was too long")
7827
+
}
7828
+
7829
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Filename))); err != nil {
7830
+
return err
7831
+
}
7832
+
if _, err := cw.WriteString(string(t.Filename)); err != nil {
7833
+
return err
7834
+
}
7835
+
7836
+
// t.CreatedAt (string) (string)
7837
+
if len("createdAt") > 1000000 {
7838
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
7839
+
}
7840
+
7841
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
7842
+
return err
7843
+
}
7844
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
7845
+
return err
7846
+
}
7847
+
7848
+
if len(t.CreatedAt) > 1000000 {
7849
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
7850
+
}
7851
+
7852
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
7853
+
return err
7854
+
}
7855
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
7856
+
return err
7857
+
}
7858
+
7859
+
// t.Description (string) (string)
7860
+
if len("description") > 1000000 {
7861
+
return xerrors.Errorf("Value in field \"description\" was too long")
7862
+
}
7863
+
7864
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil {
7865
+
return err
7866
+
}
7867
+
if _, err := cw.WriteString(string("description")); err != nil {
7868
+
return err
7869
+
}
7870
+
7871
+
if len(t.Description) > 1000000 {
7872
+
return xerrors.Errorf("Value in field t.Description was too long")
7873
+
}
7874
+
7875
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Description))); err != nil {
7876
+
return err
7877
+
}
7878
+
if _, err := cw.WriteString(string(t.Description)); err != nil {
7879
+
return err
7880
+
}
7881
+
return nil
7882
+
}
7883
+
7884
+
func (t *String) UnmarshalCBOR(r io.Reader) (err error) {
7885
+
*t = String{}
7886
+
7887
+
cr := cbg.NewCborReader(r)
7888
+
7889
+
maj, extra, err := cr.ReadHeader()
7890
+
if err != nil {
7891
+
return err
7892
+
}
7893
+
defer func() {
7894
+
if err == io.EOF {
7895
+
err = io.ErrUnexpectedEOF
7896
+
}
7897
+
}()
7898
+
7899
+
if maj != cbg.MajMap {
7900
+
return fmt.Errorf("cbor input should be of type map")
7901
+
}
7902
+
7903
+
if extra > cbg.MaxLength {
7904
+
return fmt.Errorf("String: map struct too large (%d)", extra)
7905
+
}
7906
+
7907
+
n := extra
7908
+
7909
+
nameBuf := make([]byte, 11)
7910
+
for i := uint64(0); i < n; i++ {
7911
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
7912
+
if err != nil {
7913
+
return err
7914
+
}
7915
+
7916
+
if !ok {
7917
+
// Field doesn't exist on this type, so ignore it
7918
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
7919
+
return err
7920
+
}
7921
+
continue
7922
+
}
7923
+
7924
+
switch string(nameBuf[:nameLen]) {
7925
+
// t.LexiconTypeID (string) (string)
7926
+
case "$type":
7927
+
7928
+
{
7929
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7930
+
if err != nil {
7931
+
return err
7932
+
}
7933
+
7934
+
t.LexiconTypeID = string(sval)
7935
+
}
7936
+
// t.Contents (string) (string)
7937
+
case "contents":
7938
+
7939
+
{
7940
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7941
+
if err != nil {
7942
+
return err
7943
+
}
7944
+
7945
+
t.Contents = string(sval)
7946
+
}
7947
+
// t.Filename (string) (string)
7948
+
case "filename":
7949
+
7950
+
{
7951
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7952
+
if err != nil {
7953
+
return err
7954
+
}
7955
+
7956
+
t.Filename = string(sval)
7957
+
}
7958
+
// t.CreatedAt (string) (string)
7959
+
case "createdAt":
7960
+
7961
+
{
7962
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7963
+
if err != nil {
7964
+
return err
7965
+
}
7966
+
7967
+
t.CreatedAt = string(sval)
7968
+
}
7969
+
// t.Description (string) (string)
7970
+
case "description":
7971
+
7972
+
{
7973
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7974
+
if err != nil {
7975
+
return err
7976
+
}
7977
+
7978
+
t.Description = string(sval)
7979
+
}
7980
+
7981
+
default:
7982
+
// Field doesn't exist on this type, so ignore it
7983
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
7984
+
return err
7985
+
}
7986
+
}
7987
+
}
7988
+
7989
+
return nil
7990
+
}
+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
+
}
-1
api/tangled/issuecomment.go
-1
api/tangled/issuecomment.go
···
19
19
type RepoIssueComment struct {
20
20
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"`
21
21
Body string `json:"body" cborgen:"body"`
22
-
CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"`
23
22
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
24
23
Issue string `json:"issue" cborgen:"issue"`
25
24
Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"`
+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
+
}
-1
api/tangled/repoissue.go
-1
api/tangled/repoissue.go
···
20
20
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"`
21
21
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
22
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
-
IssueId int64 `json:"issueId" cborgen:"issueId"`
24
23
Owner string `json:"owner" cborgen:"owner"`
25
24
Repo string `json:"repo" cborgen:"repo"`
26
25
Title string `json:"title" cborgen:"title"`
+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
+4
-18
api/tangled/tangledpipeline.go
+4
-18
api/tangled/tangledpipeline.go
···
29
29
Submodules bool `json:"submodules" cborgen:"submodules"`
30
30
}
31
31
32
-
// Pipeline_Dependency is a "dependency" in the sh.tangled.pipeline schema.
33
-
type Pipeline_Dependency struct {
34
-
Packages []string `json:"packages" cborgen:"packages"`
35
-
Registry string `json:"registry" cborgen:"registry"`
36
-
}
37
-
38
32
// Pipeline_ManualTriggerData is a "manualTriggerData" in the sh.tangled.pipeline schema.
39
33
type Pipeline_ManualTriggerData struct {
40
34
Inputs []*Pipeline_Pair `json:"inputs,omitempty" cborgen:"inputs,omitempty"`
···
61
55
Ref string `json:"ref" cborgen:"ref"`
62
56
}
63
57
64
-
// Pipeline_Step is a "step" in the sh.tangled.pipeline schema.
65
-
type Pipeline_Step struct {
66
-
Command string `json:"command" cborgen:"command"`
67
-
Environment []*Pipeline_Pair `json:"environment,omitempty" cborgen:"environment,omitempty"`
68
-
Name string `json:"name" cborgen:"name"`
69
-
}
70
-
71
58
// Pipeline_TriggerMetadata is a "triggerMetadata" in the sh.tangled.pipeline schema.
72
59
type Pipeline_TriggerMetadata struct {
73
60
Kind string `json:"kind" cborgen:"kind"`
···
87
74
88
75
// Pipeline_Workflow is a "workflow" in the sh.tangled.pipeline schema.
89
76
type Pipeline_Workflow struct {
90
-
Clone *Pipeline_CloneOpts `json:"clone" cborgen:"clone"`
91
-
Dependencies []*Pipeline_Dependency `json:"dependencies" cborgen:"dependencies"`
92
-
Environment []*Pipeline_Pair `json:"environment" cborgen:"environment"`
93
-
Name string `json:"name" cborgen:"name"`
94
-
Steps []*Pipeline_Step `json:"steps" cborgen:"steps"`
77
+
Clone *Pipeline_CloneOpts `json:"clone" cborgen:"clone"`
78
+
Engine string `json:"engine" cborgen:"engine"`
79
+
Name string `json:"name" cborgen:"name"`
80
+
Raw string `json:"raw" cborgen:"raw"`
95
81
}
+25
api/tangled/tangledstring.go
+25
api/tangled/tangledstring.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.string
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
StringNSID = "sh.tangled.string"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.string", &String{})
17
+
} //
18
+
// RECORDTYPE: String
19
+
type String struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.string" cborgen:"$type,const=sh.tangled.string"`
21
+
Contents string `json:"contents" cborgen:"contents"`
22
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
+
Description string `json:"description" cborgen:"description"`
24
+
Filename string `json:"filename" cborgen:"filename"`
25
+
}
+1
appview/cache/session/store.go
+1
appview/cache/session/store.go
+21
-5
appview/config/config.go
+21
-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"`
19
+
20
+
// temporarily, to add users to default spindle
21
+
AppPassword string `env:"APP_PASSWORD"`
18
22
}
19
23
20
24
type OAuthConfig struct {
···
59
63
DB int `env:"DB, default=0"`
60
64
}
61
65
66
+
type PdsConfig struct {
67
+
Host string `env:"HOST, default=https://tngl.sh"`
68
+
AdminSecret string `env:"ADMIN_SECRET"`
69
+
}
70
+
71
+
type Cloudflare struct {
72
+
ApiToken string `env:"API_TOKEN"`
73
+
ZoneId string `env:"ZONE_ID"`
74
+
}
75
+
62
76
func (cfg RedisConfig) ToURL() string {
63
77
u := &url.URL{
64
78
Scheme: "redis",
···
84
98
Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"`
85
99
OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"`
86
100
Redis RedisConfig `env:",prefix=TANGLED_REDIS_"`
101
+
Pds PdsConfig `env:",prefix=TANGLED_PDS_"`
102
+
Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"`
87
103
}
88
104
89
105
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
+
}
+153
-27
appview/db/db.go
+153
-27
appview/db/db.go
···
27
27
}
28
28
29
29
func Make(dbPath string) (*DB, error) {
30
-
db, err := sql.Open("sqlite3", dbPath)
30
+
// https://github.com/mattn/go-sqlite3#connection-string
31
+
opts := []string{
32
+
"_foreign_keys=1",
33
+
"_journal_mode=WAL",
34
+
"_synchronous=NORMAL",
35
+
"_auto_vacuum=incremental",
36
+
}
37
+
38
+
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
39
+
if err != nil {
40
+
return nil, err
41
+
}
42
+
43
+
ctx := context.Background()
44
+
45
+
conn, err := db.Conn(ctx)
31
46
if err != nil {
32
47
return nil, err
33
48
}
34
-
_, err = db.Exec(`
35
-
pragma journal_mode = WAL;
36
-
pragma synchronous = normal;
37
-
pragma foreign_keys = on;
38
-
pragma temp_store = memory;
39
-
pragma mmap_size = 30000000000;
40
-
pragma page_size = 32768;
41
-
pragma auto_vacuum = incremental;
42
-
pragma busy_timeout = 5000;
49
+
defer conn.Close()
43
50
51
+
_, err = conn.ExecContext(ctx, `
44
52
create table if not exists registrations (
45
53
id integer primary key autoincrement,
46
54
domain text not null unique,
···
199
207
unique(starred_by_did, repo_at)
200
208
);
201
209
210
+
create table if not exists reactions (
211
+
id integer primary key autoincrement,
212
+
reacted_by_did text not null,
213
+
thread_at text not null,
214
+
kind text not null,
215
+
rkey text not null,
216
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
217
+
unique(reacted_by_did, thread_at, kind)
218
+
);
219
+
202
220
create table if not exists emails (
203
221
id integer primary key autoincrement,
204
222
did text not null,
···
345
363
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
346
364
347
365
-- constraints
348
-
foreign key (did, instance) references spindles(owner, instance) on delete cascade,
349
366
unique (did, instance, subject)
350
367
);
351
368
···
411
428
on delete cascade
412
429
);
413
430
431
+
create table if not exists repo_languages (
432
+
-- identifiers
433
+
id integer primary key autoincrement,
434
+
435
+
-- repo identifiers
436
+
repo_at text not null,
437
+
ref text not null,
438
+
is_default_ref integer not null default 0,
439
+
440
+
-- language breakdown
441
+
language text not null,
442
+
bytes integer not null check (bytes >= 0),
443
+
444
+
unique(repo_at, ref, language)
445
+
);
446
+
447
+
create table if not exists signups_inflight (
448
+
id integer primary key autoincrement,
449
+
email text not null unique,
450
+
invite_code text not null,
451
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
452
+
);
453
+
454
+
create table if not exists strings (
455
+
-- identifiers
456
+
did text not null,
457
+
rkey text not null,
458
+
459
+
-- content
460
+
filename text not null,
461
+
description text,
462
+
content text not null,
463
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
464
+
edited text,
465
+
466
+
primary key (did, rkey)
467
+
);
468
+
414
469
create table if not exists migrations (
415
470
id integer primary key autoincrement,
416
471
name text unique
417
472
);
473
+
474
+
-- indexes for better star query performance
475
+
create index if not exists idx_stars_created on stars(created);
476
+
create index if not exists idx_stars_repo_at_created on stars(repo_at, created);
418
477
`)
419
478
if err != nil {
420
479
return nil, err
421
480
}
422
481
423
482
// run migrations
424
-
runMigration(db, "add-description-to-repos", func(tx *sql.Tx) error {
483
+
runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error {
425
484
tx.Exec(`
426
485
alter table repos add column description text check (length(description) <= 200);
427
486
`)
428
487
return nil
429
488
})
430
489
431
-
runMigration(db, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
490
+
runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
432
491
// add unconstrained column
433
492
_, err := tx.Exec(`
434
493
alter table public_keys
···
451
510
return nil
452
511
})
453
512
454
-
runMigration(db, "add-rkey-to-comments", func(tx *sql.Tx) error {
513
+
runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error {
455
514
_, err := tx.Exec(`
456
515
alter table comments drop column comment_at;
457
516
alter table comments add column rkey text;
···
459
518
return err
460
519
})
461
520
462
-
runMigration(db, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
521
+
runMigration(conn, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
463
522
_, err := tx.Exec(`
464
523
alter table comments add column deleted text; -- timestamp
465
524
alter table comments add column edited text; -- timestamp
···
467
526
return err
468
527
})
469
528
470
-
runMigration(db, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
529
+
runMigration(conn, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
471
530
_, err := tx.Exec(`
472
531
alter table pulls add column source_branch text;
473
532
alter table pulls add column source_repo_at text;
···
476
535
return err
477
536
})
478
537
479
-
runMigration(db, "add-source-to-repos", func(tx *sql.Tx) error {
538
+
runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error {
480
539
_, err := tx.Exec(`
481
540
alter table repos add column source text;
482
541
`)
···
487
546
// NOTE: this cannot be done in a transaction, so it is run outside [0]
488
547
//
489
548
// [0]: https://sqlite.org/pragma.html#pragma_foreign_keys
490
-
db.Exec("pragma foreign_keys = off;")
491
-
runMigration(db, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error {
549
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
550
+
runMigration(conn, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error {
492
551
_, err := tx.Exec(`
493
552
create table pulls_new (
494
553
-- identifiers
···
543
602
`)
544
603
return err
545
604
})
546
-
db.Exec("pragma foreign_keys = on;")
605
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
547
606
548
607
// run migrations
549
-
runMigration(db, "add-spindle-to-repos", func(tx *sql.Tx) error {
608
+
runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error {
550
609
tx.Exec(`
551
610
alter table repos add column spindle text;
552
611
`)
553
612
return nil
554
613
})
555
614
615
+
// recreate and add rkey + created columns with default constraint
616
+
runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error {
617
+
// create new table
618
+
// - repo_at instead of repo integer
619
+
// - rkey field
620
+
// - created field
621
+
_, err := tx.Exec(`
622
+
create table collaborators_new (
623
+
-- identifiers for the record
624
+
id integer primary key autoincrement,
625
+
did text not null,
626
+
rkey text,
627
+
628
+
-- content
629
+
subject_did text not null,
630
+
repo_at text not null,
631
+
632
+
-- meta
633
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
634
+
635
+
-- constraints
636
+
foreign key (repo_at) references repos(at_uri) on delete cascade
637
+
)
638
+
`)
639
+
if err != nil {
640
+
return err
641
+
}
642
+
643
+
// copy data
644
+
_, err = tx.Exec(`
645
+
insert into collaborators_new (id, did, rkey, subject_did, repo_at)
646
+
select
647
+
c.id,
648
+
r.did,
649
+
'',
650
+
c.did,
651
+
r.at_uri
652
+
from collaborators c
653
+
join repos r on c.repo = r.id
654
+
`)
655
+
if err != nil {
656
+
return err
657
+
}
658
+
659
+
// drop old table
660
+
_, err = tx.Exec(`drop table collaborators`)
661
+
if err != nil {
662
+
return err
663
+
}
664
+
665
+
// rename new table
666
+
_, err = tx.Exec(`alter table collaborators_new rename to collaborators`)
667
+
return err
668
+
})
669
+
670
+
runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error {
671
+
_, err := tx.Exec(`
672
+
alter table issues add column rkey text not null default '';
673
+
674
+
-- get last url section from issue_at and save to rkey column
675
+
update issues
676
+
set rkey = replace(issue_at, rtrim(issue_at, replace(issue_at, '/', '')), '');
677
+
`)
678
+
return err
679
+
})
680
+
556
681
return &DB{db}, nil
557
682
}
558
683
559
684
type migrationFn = func(*sql.Tx) error
560
685
561
-
func runMigration(d *sql.DB, name string, migrationFn migrationFn) error {
562
-
tx, err := d.Begin()
686
+
func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error {
687
+
tx, err := c.BeginTx(context.Background(), nil)
563
688
if err != nil {
564
689
return err
565
690
}
···
626
751
kind := rv.Kind()
627
752
628
753
// if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)`
629
-
if kind == reflect.Slice || kind == reflect.Array {
754
+
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
630
755
if rv.Len() == 0 {
631
-
panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key))
756
+
// always false
757
+
return "1 = 0"
632
758
}
633
759
634
760
placeholders := make([]string, rv.Len())
···
645
771
func (f filter) Arg() []any {
646
772
rv := reflect.ValueOf(f.arg)
647
773
kind := rv.Kind()
648
-
if kind == reflect.Slice || kind == reflect.Array {
774
+
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
649
775
if rv.Len() == 0 {
650
-
panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key))
776
+
return nil
651
777
}
652
778
653
779
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
}
+3
-3
appview/db/follow.go
+3
-3
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
···
53
53
return err
54
54
}
55
55
56
-
func GetFollowerFollowing(e Execer, did string) (int, int, error) {
56
+
func GetFollowerFollowingCount(e Execer, did string) (int, int, error) {
57
57
followers, following := 0, 0
58
58
err := e.QueryRow(
59
59
`SELECT
+220
-24
appview/db/issues.go
+220
-24
appview/db/issues.go
···
2
2
3
3
import (
4
4
"database/sql"
5
+
"fmt"
6
+
mathrand "math/rand/v2"
7
+
"strings"
5
8
"time"
6
9
7
10
"github.com/bluesky-social/indigo/atproto/syntax"
11
+
"tangled.sh/tangled.sh/core/api/tangled"
8
12
"tangled.sh/tangled.sh/core/appview/pagination"
9
13
)
10
14
11
15
type Issue struct {
16
+
ID int64
12
17
RepoAt syntax.ATURI
13
18
OwnerDid string
14
19
IssueId int
15
-
IssueAt string
20
+
Rkey string
16
21
Created time.Time
17
22
Title string
18
23
Body string
···
41
46
Edited *time.Time
42
47
}
43
48
49
+
func (i *Issue) AtUri() syntax.ATURI {
50
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.OwnerDid, tangled.RepoIssueNSID, i.Rkey))
51
+
}
52
+
53
+
func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue {
54
+
created, err := time.Parse(time.RFC3339, record.CreatedAt)
55
+
if err != nil {
56
+
created = time.Now()
57
+
}
58
+
59
+
body := ""
60
+
if record.Body != nil {
61
+
body = *record.Body
62
+
}
63
+
64
+
return Issue{
65
+
RepoAt: syntax.ATURI(record.Repo),
66
+
OwnerDid: record.Owner,
67
+
Rkey: rkey,
68
+
Created: created,
69
+
Title: record.Title,
70
+
Body: body,
71
+
Open: true, // new issues are open by default
72
+
}
73
+
}
74
+
75
+
func ResolveIssueFromAtUri(e Execer, issueUri syntax.ATURI) (syntax.ATURI, int, error) {
76
+
ownerDid := issueUri.Authority().String()
77
+
issueRkey := issueUri.RecordKey().String()
78
+
79
+
var repoAt string
80
+
var issueId int
81
+
82
+
query := `select repo_at, issue_id from issues where owner_did = ? and rkey = ?`
83
+
err := e.QueryRow(query, ownerDid, issueRkey).Scan(&repoAt, &issueId)
84
+
if err != nil {
85
+
return "", 0, err
86
+
}
87
+
88
+
return syntax.ATURI(repoAt), issueId, nil
89
+
}
90
+
91
+
func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (Comment, error) {
92
+
created, err := time.Parse(time.RFC3339, record.CreatedAt)
93
+
if err != nil {
94
+
created = time.Now()
95
+
}
96
+
97
+
ownerDid := did
98
+
if record.Owner != nil {
99
+
ownerDid = *record.Owner
100
+
}
101
+
102
+
issueUri, err := syntax.ParseATURI(record.Issue)
103
+
if err != nil {
104
+
return Comment{}, err
105
+
}
106
+
107
+
repoAt, issueId, err := ResolveIssueFromAtUri(e, issueUri)
108
+
if err != nil {
109
+
return Comment{}, err
110
+
}
111
+
112
+
comment := Comment{
113
+
OwnerDid: ownerDid,
114
+
RepoAt: repoAt,
115
+
Rkey: rkey,
116
+
Body: record.Body,
117
+
Issue: issueId,
118
+
CommentId: mathrand.IntN(1000000),
119
+
Created: &created,
120
+
}
121
+
122
+
return comment, nil
123
+
}
124
+
44
125
func NewIssue(tx *sql.Tx, issue *Issue) error {
45
126
defer tx.Rollback()
46
127
···
65
146
66
147
issue.IssueId = nextId
67
148
68
-
_, err = tx.Exec(`
69
-
insert into issues (repo_at, owner_did, issue_id, title, body)
70
-
values (?, ?, ?, ?, ?)
71
-
`, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body)
149
+
res, err := tx.Exec(`
150
+
insert into issues (repo_at, owner_did, rkey, issue_at, issue_id, title, body)
151
+
values (?, ?, ?, ?, ?, ?, ?)
152
+
`, issue.RepoAt, issue.OwnerDid, issue.Rkey, issue.AtUri(), issue.IssueId, issue.Title, issue.Body)
153
+
if err != nil {
154
+
return err
155
+
}
156
+
157
+
lastID, err := res.LastInsertId()
72
158
if err != nil {
73
159
return err
74
160
}
161
+
issue.ID = lastID
75
162
76
163
if err := tx.Commit(); err != nil {
77
164
return err
···
80
167
return nil
81
168
}
82
169
83
-
func SetIssueAt(e Execer, repoAt syntax.ATURI, issueId int, issueAt string) error {
84
-
_, err := e.Exec(`update issues set issue_at = ? where repo_at = ? and issue_id = ?`, issueAt, repoAt, issueId)
85
-
return err
86
-
}
87
-
88
170
func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
89
171
var issueAt string
90
172
err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt)
91
173
return issueAt, err
92
174
}
93
175
94
-
func GetIssueId(e Execer, repoAt syntax.ATURI) (int, error) {
95
-
var issueId int
96
-
err := e.QueryRow(`select next_issue_id from repo_issue_seqs where repo_at = ?`, repoAt).Scan(&issueId)
97
-
return issueId - 1, err
98
-
}
99
-
100
176
func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
101
177
var ownerDid string
102
178
err := e.QueryRow(`select owner_did from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&ownerDid)
103
179
return ownerDid, err
104
180
}
105
181
106
-
func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) {
182
+
func GetIssuesPaginated(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) {
107
183
var issues []Issue
108
184
openValue := 0
109
185
if isOpen {
···
114
190
`
115
191
with numbered_issue as (
116
192
select
193
+
i.id,
117
194
i.owner_did,
195
+
i.rkey,
118
196
i.issue_id,
119
197
i.created,
120
198
i.title,
···
132
210
i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
133
211
)
134
212
select
213
+
id,
135
214
owner_did,
215
+
rkey,
136
216
issue_id,
137
217
created,
138
218
title,
139
219
body,
140
220
open,
141
221
comment_count
142
-
from
222
+
from
143
223
numbered_issue
144
-
where
224
+
where
145
225
row_num between ? and ?`,
146
226
repoAt, openValue, page.Offset+1, page.Offset+page.Limit)
147
227
if err != nil {
···
153
233
var issue Issue
154
234
var createdAt string
155
235
var metadata IssueMetadata
156
-
err := rows.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount)
236
+
err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount)
157
237
if err != nil {
158
238
return nil, err
159
239
}
···
175
255
return issues, nil
176
256
}
177
257
258
+
func GetIssuesWithLimit(e Execer, limit int, filters ...filter) ([]Issue, error) {
259
+
issues := make([]Issue, 0, limit)
260
+
261
+
var conditions []string
262
+
var args []any
263
+
for _, filter := range filters {
264
+
conditions = append(conditions, filter.Condition())
265
+
args = append(args, filter.Arg()...)
266
+
}
267
+
268
+
whereClause := ""
269
+
if conditions != nil {
270
+
whereClause = " where " + strings.Join(conditions, " and ")
271
+
}
272
+
limitClause := ""
273
+
if limit != 0 {
274
+
limitClause = fmt.Sprintf(" limit %d ", limit)
275
+
}
276
+
277
+
query := fmt.Sprintf(
278
+
`select
279
+
i.id,
280
+
i.owner_did,
281
+
i.repo_at,
282
+
i.issue_id,
283
+
i.created,
284
+
i.title,
285
+
i.body,
286
+
i.open
287
+
from
288
+
issues i
289
+
%s
290
+
order by
291
+
i.created desc
292
+
%s`,
293
+
whereClause, limitClause)
294
+
295
+
rows, err := e.Query(query, args...)
296
+
if err != nil {
297
+
return nil, err
298
+
}
299
+
defer rows.Close()
300
+
301
+
for rows.Next() {
302
+
var issue Issue
303
+
var issueCreatedAt string
304
+
err := rows.Scan(
305
+
&issue.ID,
306
+
&issue.OwnerDid,
307
+
&issue.RepoAt,
308
+
&issue.IssueId,
309
+
&issueCreatedAt,
310
+
&issue.Title,
311
+
&issue.Body,
312
+
&issue.Open,
313
+
)
314
+
if err != nil {
315
+
return nil, err
316
+
}
317
+
318
+
issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
319
+
if err != nil {
320
+
return nil, err
321
+
}
322
+
issue.Created = issueCreatedTime
323
+
324
+
issues = append(issues, issue)
325
+
}
326
+
327
+
if err := rows.Err(); err != nil {
328
+
return nil, err
329
+
}
330
+
331
+
return issues, nil
332
+
}
333
+
334
+
func GetIssues(e Execer, filters ...filter) ([]Issue, error) {
335
+
return GetIssuesWithLimit(e, 0, filters...)
336
+
}
337
+
178
338
// timeframe here is directly passed into the sql query filter, and any
179
339
// timeframe in the past should be negative; e.g.: "-3 months"
180
340
func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) {
···
182
342
183
343
rows, err := e.Query(
184
344
`select
345
+
i.id,
185
346
i.owner_did,
347
+
i.rkey,
186
348
i.repo_at,
187
349
i.issue_id,
188
350
i.created,
···
213
375
var issueCreatedAt, repoCreatedAt string
214
376
var repo Repo
215
377
err := rows.Scan(
378
+
&issue.ID,
216
379
&issue.OwnerDid,
380
+
&issue.Rkey,
217
381
&issue.RepoAt,
218
382
&issue.IssueId,
219
383
&issueCreatedAt,
···
257
421
}
258
422
259
423
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 = ?`
424
+
query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?`
261
425
row := e.QueryRow(query, repoAt, issueId)
262
426
263
427
var issue Issue
264
428
var createdAt string
265
-
err := row.Scan(&issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open)
429
+
err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
266
430
if err != nil {
267
431
return nil, err
268
432
}
···
277
441
}
278
442
279
443
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 = ?`
444
+
query := `select id, owner_did, rkey, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?`
281
445
row := e.QueryRow(query, repoAt, issueId)
282
446
283
447
var issue Issue
284
448
var createdAt string
285
-
err := row.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open)
449
+
err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open)
286
450
if err != nil {
287
451
return nil, nil, err
288
452
}
···
459
623
deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
460
624
where repo_at = ? and issue_id = ? and comment_id = ?
461
625
`, repoAt, issueId, commentId)
626
+
return err
627
+
}
628
+
629
+
func UpdateCommentByRkey(e Execer, ownerDid, rkey, newBody string) error {
630
+
_, err := e.Exec(
631
+
`
632
+
update comments
633
+
set body = ?,
634
+
edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
635
+
where owner_did = ? and rkey = ?
636
+
`, newBody, ownerDid, rkey)
637
+
return err
638
+
}
639
+
640
+
func DeleteCommentByRkey(e Execer, ownerDid, rkey string) error {
641
+
_, err := e.Exec(
642
+
`
643
+
update comments
644
+
set body = "",
645
+
deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
646
+
where owner_did = ? and rkey = ?
647
+
`, ownerDid, rkey)
648
+
return err
649
+
}
650
+
651
+
func UpdateIssueByRkey(e Execer, ownerDid, rkey, title, body string) error {
652
+
_, err := e.Exec(`update issues set title = ?, body = ? where owner_did = ? and rkey = ?`, title, body, ownerDid, rkey)
653
+
return err
654
+
}
655
+
656
+
func DeleteIssueByRkey(e Execer, ownerDid, rkey string) error {
657
+
_, err := e.Exec(`delete from issues where owner_did = ? and rkey = ?`, ownerDid, rkey)
462
658
return err
463
659
}
464
660
+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
+
}
-62
appview/db/migrations/20250305_113405.sql
-62
appview/db/migrations/20250305_113405.sql
···
1
-
-- Simplified SQLite Database Migration Script for Issues and Comments
2
-
3
-
-- Migration for issues table
4
-
CREATE TABLE issues_new (
5
-
id integer primary key autoincrement,
6
-
owner_did text not null,
7
-
repo_at text not null,
8
-
issue_id integer not null,
9
-
title text not null,
10
-
body text not null,
11
-
open integer not null default 1,
12
-
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
13
-
issue_at text,
14
-
unique(repo_at, issue_id),
15
-
foreign key (repo_at) references repos(at_uri) on delete cascade
16
-
);
17
-
18
-
-- Migrate data to new issues table
19
-
INSERT INTO issues_new (
20
-
id, owner_did, repo_at, issue_id,
21
-
title, body, open, created, issue_at
22
-
)
23
-
SELECT
24
-
id, owner_did, repo_at, issue_id,
25
-
title, body, open, created, issue_at
26
-
FROM issues;
27
-
28
-
-- Drop old issues table
29
-
DROP TABLE issues;
30
-
31
-
-- Rename new issues table
32
-
ALTER TABLE issues_new RENAME TO issues;
33
-
34
-
-- Migration for comments table
35
-
CREATE TABLE comments_new (
36
-
id integer primary key autoincrement,
37
-
owner_did text not null,
38
-
issue_id integer not null,
39
-
repo_at text not null,
40
-
comment_id integer not null,
41
-
comment_at text not null,
42
-
body text not null,
43
-
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
44
-
unique(issue_id, comment_id),
45
-
foreign key (repo_at, issue_id) references issues(repo_at, issue_id) on delete cascade
46
-
);
47
-
48
-
-- Migrate data to new comments table
49
-
INSERT INTO comments_new (
50
-
id, owner_did, issue_id, repo_at,
51
-
comment_id, comment_at, body, created
52
-
)
53
-
SELECT
54
-
id, owner_did, issue_id, repo_at,
55
-
comment_id, comment_at, body, created
56
-
FROM comments;
57
-
58
-
-- Drop old comments table
59
-
DROP TABLE comments;
60
-
61
-
-- Rename new comments table
62
-
ALTER TABLE comments_new RENAME TO comments;
-66
appview/db/migrations/validate.sql
-66
appview/db/migrations/validate.sql
···
1
-
-- Validation Queries for Database Migration
2
-
3
-
-- 1. Verify Issues Table Structure
4
-
PRAGMA table_info(issues);
5
-
6
-
-- 2. Verify Comments Table Structure
7
-
PRAGMA table_info(comments);
8
-
9
-
-- 3. Check Total Row Count Consistency
10
-
SELECT
11
-
'Issues Row Count' AS check_type,
12
-
(SELECT COUNT(*) FROM issues) AS row_count
13
-
UNION ALL
14
-
SELECT
15
-
'Comments Row Count' AS check_type,
16
-
(SELECT COUNT(*) FROM comments) AS row_count;
17
-
18
-
-- 4. Verify Unique Constraint on Issues
19
-
SELECT
20
-
repo_at,
21
-
issue_id,
22
-
COUNT(*) as duplicate_count
23
-
FROM issues
24
-
GROUP BY repo_at, issue_id
25
-
HAVING duplicate_count > 1;
26
-
27
-
-- 5. Verify Foreign Key Integrity for Comments
28
-
SELECT
29
-
'Orphaned Comments' AS check_type,
30
-
COUNT(*) AS orphaned_count
31
-
FROM comments c
32
-
LEFT JOIN issues i ON c.repo_at = i.repo_at AND c.issue_id = i.issue_id
33
-
WHERE i.id IS NULL;
34
-
35
-
-- 6. Check Foreign Key Constraint
36
-
PRAGMA foreign_key_list(comments);
37
-
38
-
-- 7. Sample Data Integrity Check
39
-
SELECT
40
-
'Sample Issues' AS check_type,
41
-
repo_at,
42
-
issue_id,
43
-
title,
44
-
created
45
-
FROM issues
46
-
LIMIT 5;
47
-
48
-
-- 8. Sample Comments Data Integrity Check
49
-
SELECT
50
-
'Sample Comments' AS check_type,
51
-
repo_at,
52
-
issue_id,
53
-
comment_id,
54
-
body,
55
-
created
56
-
FROM comments
57
-
LIMIT 5;
58
-
59
-
-- 9. Verify Constraint on Comments (Issue ID and Comment ID Uniqueness)
60
-
SELECT
61
-
issue_id,
62
-
comment_id,
63
-
COUNT(*) as duplicate_count
64
-
FROM comments
65
-
GROUP BY issue_id, comment_id
66
-
HAVING duplicate_count > 1;
+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
+22
-3
appview/db/pulls.go
+22
-3
appview/db/pulls.go
···
310
310
return pullId - 1, err
311
311
}
312
312
313
-
func GetPulls(e Execer, filters ...filter) ([]*Pull, error) {
313
+
func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*Pull, error) {
314
314
pulls := make(map[int]*Pull)
315
315
316
316
var conditions []string
···
323
323
whereClause := ""
324
324
if conditions != nil {
325
325
whereClause = " where " + strings.Join(conditions, " and ")
326
+
}
327
+
limitClause := ""
328
+
if limit != 0 {
329
+
limitClause = fmt.Sprintf(" limit %d ", limit)
326
330
}
327
331
328
332
query := fmt.Sprintf(`
···
344
348
from
345
349
pulls
346
350
%s
347
-
`, whereClause)
351
+
order by
352
+
created desc
353
+
%s
354
+
`, whereClause, limitClause)
348
355
349
356
rows, err := e.Query(query, args...)
350
357
if err != nil {
···
412
419
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
413
420
submissionsQuery := fmt.Sprintf(`
414
421
select
415
-
id, pull_id, round_number, patch, source_rev
422
+
id, pull_id, round_number, patch, created, source_rev
416
423
from
417
424
pull_submissions
418
425
where
···
438
445
for submissionsRows.Next() {
439
446
var s PullSubmission
440
447
var sourceRev sql.NullString
448
+
var createdAt string
441
449
err := submissionsRows.Scan(
442
450
&s.ID,
443
451
&s.PullId,
444
452
&s.RoundNumber,
445
453
&s.Patch,
454
+
&createdAt,
446
455
&sourceRev,
447
456
)
448
457
if err != nil {
449
458
return nil, err
450
459
}
460
+
461
+
createdTime, err := time.Parse(time.RFC3339, createdAt)
462
+
if err != nil {
463
+
return nil, err
464
+
}
465
+
s.Created = createdTime
451
466
452
467
if sourceRev.Valid {
453
468
s.SourceRev = sourceRev.String
···
511
526
})
512
527
513
528
return orderedByPullId, nil
529
+
}
530
+
531
+
func GetPulls(e Execer, filters ...filter) ([]*Pull, error) {
532
+
return GetPullsWithLimit(e, 0, filters...)
514
533
}
515
534
516
535
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
+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 ReactionKind = "๐"
15
+
Laugh ReactionKind = "๐"
16
+
Celebration ReactionKind = "๐"
17
+
Confused ReactionKind = "๐ซค"
18
+
Heart ReactionKind = "โค๏ธ"
19
+
Rocket ReactionKind = "๐"
20
+
Eyes ReactionKind = "๐"
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 {
+78
-81
appview/db/repos.go
+78
-81
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
···
17
19
Knot string
18
20
Rkey string
19
21
Created time.Time
20
-
AtUri string
21
22
Description string
22
23
Spindle string
23
24
···
71
72
return repos, nil
72
73
}
73
74
74
-
func GetRepos(e Execer, filters ...filter) ([]Repo, error) {
75
-
repoMap := make(map[syntax.ATURI]Repo)
75
+
func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) {
76
+
repoMap := make(map[syntax.ATURI]*Repo)
76
77
77
78
var conditions []string
78
79
var args []any
···
86
87
whereClause = " where " + strings.Join(conditions, " and ")
87
88
}
88
89
90
+
limitClause := ""
91
+
if limit != 0 {
92
+
limitClause = fmt.Sprintf(" limit %d", limit)
93
+
}
94
+
89
95
repoQuery := fmt.Sprintf(
90
96
`select
91
97
did,
···
98
104
spindle
99
105
from
100
106
repos r
107
+
%s
108
+
order by created desc
101
109
%s`,
102
110
whereClause,
111
+
limitClause,
103
112
)
104
113
rows, err := e.Query(repoQuery, args...)
105
114
···
139
148
repo.Spindle = spindle.String
140
149
}
141
150
142
-
repoMap[repo.RepoAt()] = repo
151
+
repo.RepoStats = &RepoStats{}
152
+
repoMap[repo.RepoAt()] = &repo
143
153
}
144
154
145
155
if err = rows.Err(); err != nil {
···
148
158
149
159
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(repoMap)), ", ")
150
160
args = make([]any, len(repoMap))
161
+
162
+
i := 0
151
163
for _, r := range repoMap {
152
-
args = append(args, r.RepoAt())
164
+
args[i] = r.RepoAt()
165
+
i++
166
+
}
167
+
168
+
languageQuery := fmt.Sprintf(
169
+
`
170
+
select
171
+
repo_at, language
172
+
from
173
+
repo_languages r1
174
+
where
175
+
repo_at IN (%s)
176
+
and is_default_ref = 1
177
+
and id = (
178
+
select id
179
+
from repo_languages r2
180
+
where r2.repo_at = r1.repo_at
181
+
and r2.is_default_ref = 1
182
+
order by bytes desc
183
+
limit 1
184
+
);
185
+
`,
186
+
inClause,
187
+
)
188
+
rows, err = e.Query(languageQuery, args...)
189
+
if err != nil {
190
+
return nil, fmt.Errorf("failed to execute lang query: %w ", err)
191
+
}
192
+
for rows.Next() {
193
+
var repoat, lang string
194
+
if err := rows.Scan(&repoat, &lang); err != nil {
195
+
log.Println("err", "err", err)
196
+
continue
197
+
}
198
+
if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
199
+
r.RepoStats.Language = lang
200
+
}
201
+
}
202
+
if err = rows.Err(); err != nil {
203
+
return nil, fmt.Errorf("failed to execute lang query: %w ", err)
153
204
}
154
205
155
206
starCountQuery := fmt.Sprintf(
···
168
219
var repoat string
169
220
var count int
170
221
if err := rows.Scan(&repoat, &count); err != nil {
222
+
log.Println("err", "err", err)
171
223
continue
172
224
}
173
225
if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
···
196
248
var repoat string
197
249
var open, closed int
198
250
if err := rows.Scan(&repoat, &open, &closed); err != nil {
251
+
log.Println("err", "err", err)
199
252
continue
200
253
}
201
254
if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
···
236
289
var repoat string
237
290
var open, merged, closed, deleted int
238
291
if err := rows.Scan(&repoat, &open, &merged, &closed, &deleted); err != nil {
292
+
log.Println("err", "err", err)
239
293
continue
240
294
}
241
295
if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
···
251
305
252
306
var repos []Repo
253
307
for _, r := range repoMap {
254
-
repos = append(repos, r)
308
+
repos = append(repos, *r)
255
309
}
256
310
311
+
slices.SortFunc(repos, func(a, b Repo) int {
312
+
if a.Created.After(b.Created) {
313
+
return 1
314
+
}
315
+
return -1
316
+
})
317
+
257
318
return repos, nil
258
319
}
259
320
···
329
390
var description, spindle sql.NullString
330
391
331
392
row := e.QueryRow(`
332
-
select did, name, knot, created, at_uri, description, spindle
393
+
select did, name, knot, created, description, spindle, rkey
333
394
from repos
334
395
where did = ? and name = ?
335
396
`,
···
338
399
)
339
400
340
401
var createdAt string
341
-
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &description, &spindle); err != nil {
402
+
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &description, &spindle, &repo.Rkey); err != nil {
342
403
return nil, err
343
404
}
344
405
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
359
420
var repo Repo
360
421
var nullableDescription sql.NullString
361
422
362
-
row := e.QueryRow(`select did, name, knot, created, at_uri, description from repos where at_uri = ?`, atUri)
423
+
row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
363
424
364
425
var createdAt string
365
-
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &nullableDescription); err != nil {
426
+
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
366
427
return nil, err
367
428
}
368
429
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
382
443
`insert into repos
383
444
(did, name, knot, rkey, at_uri, description, source)
384
445
values (?, ?, ?, ?, ?, ?, ?)`,
385
-
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description, repo.Source,
446
+
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source,
386
447
)
387
448
return err
388
449
}
···
405
466
var repos []Repo
406
467
407
468
rows, err := e.Query(
408
-
`select did, name, knot, rkey, description, created, at_uri, source
469
+
`select did, name, knot, rkey, description, created, source
409
470
from repos
410
471
where did = ? and source is not null and source != ''
411
472
order by created desc`,
···
422
483
var nullableDescription sql.NullString
423
484
var nullableSource sql.NullString
424
485
425
-
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource)
486
+
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
426
487
if err != nil {
427
488
return nil, err
428
489
}
···
459
520
var nullableSource sql.NullString
460
521
461
522
row := e.QueryRow(
462
-
`select did, name, knot, rkey, description, created, at_uri, source
523
+
`select did, name, knot, rkey, description, created, source
463
524
from repos
464
525
where did = ? and name = ? and source is not null and source != ''`,
465
526
did, name,
466
527
)
467
528
468
-
err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource)
529
+
err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
469
530
if err != nil {
470
531
return nil, err
471
532
}
···
488
549
return &repo, nil
489
550
}
490
551
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
552
func UpdateDescription(e Execer, repoAt, newDescription string) error {
500
553
_, err := e.Exec(
501
554
`update repos set description = ? where at_uri = ?`, newDescription, repoAt)
502
555
return err
503
556
}
504
557
505
-
func UpdateSpindle(e Execer, repoAt, spindle string) error {
558
+
func UpdateSpindle(e Execer, repoAt string, spindle *string) error {
506
559
_, err := e.Exec(
507
560
`update repos set spindle = ? where at_uri = ?`, spindle, repoAt)
508
561
return err
509
562
}
510
563
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
564
type RepoStats struct {
565
+
Language string
569
566
StarCount int
570
567
IssueCount IssueCount
571
568
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
+
}
+164
-7
appview/db/star.go
+164
-7
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
40
47
// Get a star record
41
48
func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) {
42
49
query := `
43
-
select starred_by_did, repo_at, created, rkey
50
+
select starred_by_did, repo_at, created, rkey
44
51
from stars
45
52
where starred_by_did = ? and repo_at = ?`
46
53
row := e.QueryRow(query, starredByDid, repoAt)
···
93
100
}
94
101
}
95
102
103
+
func GetStars(e Execer, limit int, filters ...filter) ([]Star, error) {
104
+
var conditions []string
105
+
var args []any
106
+
for _, filter := range filters {
107
+
conditions = append(conditions, filter.Condition())
108
+
args = append(args, filter.Arg()...)
109
+
}
110
+
111
+
whereClause := ""
112
+
if conditions != nil {
113
+
whereClause = " where " + strings.Join(conditions, " and ")
114
+
}
115
+
116
+
limitClause := ""
117
+
if limit != 0 {
118
+
limitClause = fmt.Sprintf(" limit %d", limit)
119
+
}
120
+
121
+
repoQuery := fmt.Sprintf(
122
+
`select starred_by_did, repo_at, created, rkey
123
+
from stars
124
+
%s
125
+
order by created desc
126
+
%s`,
127
+
whereClause,
128
+
limitClause,
129
+
)
130
+
rows, err := e.Query(repoQuery, args...)
131
+
if err != nil {
132
+
return nil, err
133
+
}
134
+
135
+
starMap := make(map[string][]Star)
136
+
for rows.Next() {
137
+
var star Star
138
+
var created string
139
+
err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
140
+
if err != nil {
141
+
return nil, err
142
+
}
143
+
144
+
star.Created = time.Now()
145
+
if t, err := time.Parse(time.RFC3339, created); err == nil {
146
+
star.Created = t
147
+
}
148
+
149
+
repoAt := string(star.RepoAt)
150
+
starMap[repoAt] = append(starMap[repoAt], star)
151
+
}
152
+
153
+
// populate *Repo in each star
154
+
args = make([]any, len(starMap))
155
+
i := 0
156
+
for r := range starMap {
157
+
args[i] = r
158
+
i++
159
+
}
160
+
161
+
if len(args) == 0 {
162
+
return nil, nil
163
+
}
164
+
165
+
repos, err := GetRepos(e, 0, FilterIn("at_uri", args))
166
+
if err != nil {
167
+
return nil, err
168
+
}
169
+
170
+
for _, r := range repos {
171
+
if stars, ok := starMap[string(r.RepoAt())]; ok {
172
+
for i := range stars {
173
+
stars[i].Repo = &r
174
+
}
175
+
}
176
+
}
177
+
178
+
var stars []Star
179
+
for _, s := range starMap {
180
+
stars = append(stars, s...)
181
+
}
182
+
183
+
return stars, nil
184
+
}
185
+
96
186
func GetAllStars(e Execer, limit int) ([]Star, error) {
97
187
var stars []Star
98
188
99
189
rows, err := e.Query(`
100
-
select
190
+
select
101
191
s.starred_by_did,
102
192
s.repo_at,
103
193
s.rkey,
···
106
196
r.name,
107
197
r.knot,
108
198
r.rkey,
109
-
r.created,
110
-
r.at_uri
199
+
r.created
111
200
from stars s
112
201
join repos r on s.repo_at = r.at_uri
113
202
`)
···
132
221
&repo.Knot,
133
222
&repo.Rkey,
134
223
&repoCreatedAt,
135
-
&repo.AtUri,
136
224
); err != nil {
137
225
return nil, err
138
226
}
···
156
244
157
245
return stars, nil
158
246
}
247
+
248
+
// GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week
249
+
func GetTopStarredReposLastWeek(e Execer) ([]Repo, error) {
250
+
// first, get the top repo URIs by star count from the last week
251
+
query := `
252
+
with recent_starred_repos as (
253
+
select distinct repo_at
254
+
from stars
255
+
where created >= datetime('now', '-7 days')
256
+
),
257
+
repo_star_counts as (
258
+
select
259
+
s.repo_at,
260
+
count(*) as star_count
261
+
from stars s
262
+
join recent_starred_repos rsr on s.repo_at = rsr.repo_at
263
+
group by s.repo_at
264
+
)
265
+
select rsc.repo_at
266
+
from repo_star_counts rsc
267
+
order by rsc.star_count desc
268
+
limit 8
269
+
`
270
+
271
+
rows, err := e.Query(query)
272
+
if err != nil {
273
+
return nil, err
274
+
}
275
+
defer rows.Close()
276
+
277
+
var repoUris []string
278
+
for rows.Next() {
279
+
var repoUri string
280
+
err := rows.Scan(&repoUri)
281
+
if err != nil {
282
+
return nil, err
283
+
}
284
+
repoUris = append(repoUris, repoUri)
285
+
}
286
+
287
+
if err := rows.Err(); err != nil {
288
+
return nil, err
289
+
}
290
+
291
+
if len(repoUris) == 0 {
292
+
return []Repo{}, nil
293
+
}
294
+
295
+
// get full repo data
296
+
repos, err := GetRepos(e, 0, FilterIn("at_uri", repoUris))
297
+
if err != nil {
298
+
return nil, err
299
+
}
300
+
301
+
// sort repos by the original trending order
302
+
repoMap := make(map[string]Repo)
303
+
for _, repo := range repos {
304
+
repoMap[repo.RepoAt().String()] = repo
305
+
}
306
+
307
+
orderedRepos := make([]Repo, 0, len(repoUris))
308
+
for _, uri := range repoUris {
309
+
if repo, exists := repoMap[uri]; exists {
310
+
orderedRepos = append(orderedRepos, repo)
311
+
}
312
+
}
313
+
314
+
return orderedRepos, nil
315
+
}
+252
appview/db/strings.go
+252
appview/db/strings.go
···
1
+
package db
2
+
3
+
import (
4
+
"bytes"
5
+
"database/sql"
6
+
"errors"
7
+
"fmt"
8
+
"io"
9
+
"strings"
10
+
"time"
11
+
"unicode/utf8"
12
+
13
+
"github.com/bluesky-social/indigo/atproto/syntax"
14
+
"tangled.sh/tangled.sh/core/api/tangled"
15
+
)
16
+
17
+
type String struct {
18
+
Did syntax.DID
19
+
Rkey string
20
+
21
+
Filename string
22
+
Description string
23
+
Contents string
24
+
Created time.Time
25
+
Edited *time.Time
26
+
}
27
+
28
+
func (s *String) StringAt() syntax.ATURI {
29
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey))
30
+
}
31
+
32
+
type StringStats struct {
33
+
LineCount uint64
34
+
ByteCount uint64
35
+
}
36
+
37
+
func (s String) Stats() StringStats {
38
+
lineCount, err := countLines(strings.NewReader(s.Contents))
39
+
if err != nil {
40
+
// non-fatal
41
+
// TODO: log this?
42
+
}
43
+
44
+
return StringStats{
45
+
LineCount: uint64(lineCount),
46
+
ByteCount: uint64(len(s.Contents)),
47
+
}
48
+
}
49
+
50
+
func (s String) Validate() error {
51
+
var err error
52
+
53
+
if utf8.RuneCountInString(s.Filename) > 140 {
54
+
err = errors.Join(err, fmt.Errorf("filename too long"))
55
+
}
56
+
57
+
if utf8.RuneCountInString(s.Description) > 280 {
58
+
err = errors.Join(err, fmt.Errorf("description too long"))
59
+
}
60
+
61
+
if len(s.Contents) == 0 {
62
+
err = errors.Join(err, fmt.Errorf("contents is empty"))
63
+
}
64
+
65
+
return err
66
+
}
67
+
68
+
func (s *String) AsRecord() tangled.String {
69
+
return tangled.String{
70
+
Filename: s.Filename,
71
+
Description: s.Description,
72
+
Contents: s.Contents,
73
+
CreatedAt: s.Created.Format(time.RFC3339),
74
+
}
75
+
}
76
+
77
+
func StringFromRecord(did, rkey string, record tangled.String) String {
78
+
created, err := time.Parse(record.CreatedAt, time.RFC3339)
79
+
if err != nil {
80
+
created = time.Now()
81
+
}
82
+
return String{
83
+
Did: syntax.DID(did),
84
+
Rkey: rkey,
85
+
Filename: record.Filename,
86
+
Description: record.Description,
87
+
Contents: record.Contents,
88
+
Created: created,
89
+
}
90
+
}
91
+
92
+
func AddString(e Execer, s String) error {
93
+
_, err := e.Exec(
94
+
`insert into strings (
95
+
did,
96
+
rkey,
97
+
filename,
98
+
description,
99
+
content,
100
+
created,
101
+
edited
102
+
)
103
+
values (?, ?, ?, ?, ?, ?, null)
104
+
on conflict(did, rkey) do update set
105
+
filename = excluded.filename,
106
+
description = excluded.description,
107
+
content = excluded.content,
108
+
edited = case
109
+
when
110
+
strings.content != excluded.content
111
+
or strings.filename != excluded.filename
112
+
or strings.description != excluded.description then ?
113
+
else strings.edited
114
+
end`,
115
+
s.Did,
116
+
s.Rkey,
117
+
s.Filename,
118
+
s.Description,
119
+
s.Contents,
120
+
s.Created.Format(time.RFC3339),
121
+
time.Now().Format(time.RFC3339),
122
+
)
123
+
return err
124
+
}
125
+
126
+
func GetStrings(e Execer, limit int, filters ...filter) ([]String, error) {
127
+
var all []String
128
+
129
+
var conditions []string
130
+
var args []any
131
+
for _, filter := range filters {
132
+
conditions = append(conditions, filter.Condition())
133
+
args = append(args, filter.Arg()...)
134
+
}
135
+
136
+
whereClause := ""
137
+
if conditions != nil {
138
+
whereClause = " where " + strings.Join(conditions, " and ")
139
+
}
140
+
141
+
limitClause := ""
142
+
if limit != 0 {
143
+
limitClause = fmt.Sprintf(" limit %d ", limit)
144
+
}
145
+
146
+
query := fmt.Sprintf(`select
147
+
did,
148
+
rkey,
149
+
filename,
150
+
description,
151
+
content,
152
+
created,
153
+
edited
154
+
from strings
155
+
%s
156
+
order by created desc
157
+
%s`,
158
+
whereClause,
159
+
limitClause,
160
+
)
161
+
162
+
rows, err := e.Query(query, args...)
163
+
164
+
if err != nil {
165
+
return nil, err
166
+
}
167
+
defer rows.Close()
168
+
169
+
for rows.Next() {
170
+
var s String
171
+
var createdAt string
172
+
var editedAt sql.NullString
173
+
174
+
if err := rows.Scan(
175
+
&s.Did,
176
+
&s.Rkey,
177
+
&s.Filename,
178
+
&s.Description,
179
+
&s.Contents,
180
+
&createdAt,
181
+
&editedAt,
182
+
); err != nil {
183
+
return nil, err
184
+
}
185
+
186
+
s.Created, err = time.Parse(time.RFC3339, createdAt)
187
+
if err != nil {
188
+
s.Created = time.Now()
189
+
}
190
+
191
+
if editedAt.Valid {
192
+
e, err := time.Parse(time.RFC3339, editedAt.String)
193
+
if err != nil {
194
+
e = time.Now()
195
+
}
196
+
s.Edited = &e
197
+
}
198
+
199
+
all = append(all, s)
200
+
}
201
+
202
+
if err := rows.Err(); err != nil {
203
+
return nil, err
204
+
}
205
+
206
+
return all, nil
207
+
}
208
+
209
+
func DeleteString(e Execer, filters ...filter) error {
210
+
var conditions []string
211
+
var args []any
212
+
for _, filter := range filters {
213
+
conditions = append(conditions, filter.Condition())
214
+
args = append(args, filter.Arg()...)
215
+
}
216
+
217
+
whereClause := ""
218
+
if conditions != nil {
219
+
whereClause = " where " + strings.Join(conditions, " and ")
220
+
}
221
+
222
+
query := fmt.Sprintf(`delete from strings %s`, whereClause)
223
+
224
+
_, err := e.Exec(query, args...)
225
+
return err
226
+
}
227
+
228
+
func countLines(r io.Reader) (int, error) {
229
+
buf := make([]byte, 32*1024)
230
+
bufLen := 0
231
+
count := 0
232
+
nl := []byte{'\n'}
233
+
234
+
for {
235
+
c, err := r.Read(buf)
236
+
if c > 0 {
237
+
bufLen += c
238
+
}
239
+
count += bytes.Count(buf[:c], nl)
240
+
241
+
switch {
242
+
case err == io.EOF:
243
+
/* handle last line not having a newline at the end */
244
+
if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' {
245
+
count++
246
+
}
247
+
return count, nil
248
+
case err != nil:
249
+
return 0, err
250
+
}
251
+
}
252
+
}
+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 := GetFollowerFollowingCount(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
-
}
+291
-32
appview/ingester.go
+291
-32
appview/ingester.go
···
5
5
"encoding/json"
6
6
"fmt"
7
7
"log/slog"
8
+
"strings"
8
9
"time"
9
10
10
11
"github.com/bluesky-social/indigo/atproto/syntax"
···
14
15
"tangled.sh/tangled.sh/core/api/tangled"
15
16
"tangled.sh/tangled.sh/core/appview/config"
16
17
"tangled.sh/tangled.sh/core/appview/db"
17
-
"tangled.sh/tangled.sh/core/appview/idresolver"
18
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
18
19
"tangled.sh/tangled.sh/core/appview/spindleverify"
20
+
"tangled.sh/tangled.sh/core/idresolver"
19
21
"tangled.sh/tangled.sh/core/rbac"
20
22
)
21
23
···
40
42
}
41
43
}()
42
44
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)
45
+
l := i.Logger.With("kind", e.Kind)
46
+
switch e.Kind {
47
+
case models.EventKindAccount:
48
+
if !e.Account.Active && *e.Account.Status == "deactivated" {
49
+
err = i.IdResolver.InvalidateIdent(ctx, e.Account.Did)
50
+
}
51
+
case models.EventKindIdentity:
52
+
err = i.IdResolver.InvalidateIdent(ctx, e.Identity.Did)
53
+
case models.EventKindCommit:
54
+
switch e.Commit.Collection {
55
+
case tangled.GraphFollowNSID:
56
+
err = i.ingestFollow(e)
57
+
case tangled.FeedStarNSID:
58
+
err = i.ingestStar(e)
59
+
case tangled.PublicKeyNSID:
60
+
err = i.ingestPublicKey(e)
61
+
case tangled.RepoArtifactNSID:
62
+
err = i.ingestArtifact(e)
63
+
case tangled.ActorProfileNSID:
64
+
err = i.ingestProfile(e)
65
+
case tangled.SpindleMemberNSID:
66
+
err = i.ingestSpindleMember(ctx, e)
67
+
case tangled.SpindleNSID:
68
+
err = i.ingestSpindle(ctx, e)
69
+
case tangled.StringNSID:
70
+
err = i.ingestString(e)
71
+
case tangled.RepoIssueNSID:
72
+
err = i.ingestIssue(ctx, e)
73
+
case tangled.RepoIssueCommentNSID:
74
+
err = i.ingestIssueComment(e)
75
+
}
76
+
l = i.Logger.With("nsid", e.Commit.Collection)
62
77
}
63
78
64
79
if err != nil {
65
-
l := i.Logger.With("nsid", e.Commit.Collection)
66
-
l.Error("error ingesting record", "err", err)
80
+
l.Debug("error ingesting record", "err", err)
67
81
}
68
82
69
-
return err
83
+
return nil
70
84
}
71
85
}
72
86
···
94
108
l.Error("invalid record", "err", err)
95
109
return err
96
110
}
97
-
err = db.AddStar(i.Db, did, subjectUri, e.Commit.RKey)
111
+
err = db.AddStar(i.Db, &db.Star{
112
+
StarredByDid: did,
113
+
RepoAt: subjectUri,
114
+
Rkey: e.Commit.RKey,
115
+
})
98
116
case models.CommitOperationDelete:
99
117
err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
100
118
}
···
123
141
return err
124
142
}
125
143
126
-
subjectDid := record.Subject
127
-
err = db.AddFollow(i.Db, did, subjectDid, e.Commit.RKey)
144
+
err = db.AddFollow(i.Db, &db.Follow{
145
+
UserDid: did,
146
+
SubjectDid: record.Subject,
147
+
Rkey: e.Commit.RKey,
148
+
})
128
149
case models.CommitOperationDelete:
129
150
err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey)
130
151
}
···
321
342
return nil
322
343
}
323
344
324
-
func (i *Ingester) ingestSpindleMember(e *models.Event) error {
345
+
func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error {
325
346
did := e.Did
326
347
var err error
327
348
···
344
365
return fmt.Errorf("failed to enforce permissions: %w", err)
345
366
}
346
367
347
-
memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject)
368
+
memberId, err := i.IdResolver.ResolveIdent(ctx, record.Subject)
348
369
if err != nil {
349
370
return err
350
371
}
···
372
393
if err != nil {
373
394
return fmt.Errorf("failed to update ACLs: %w", err)
374
395
}
396
+
397
+
l.Info("added spindle member")
375
398
case models.CommitOperationDelete:
376
399
rkey := e.Commit.RKey
377
400
···
418
441
if err = i.Enforcer.E.SavePolicy(); err != nil {
419
442
return fmt.Errorf("failed to save ACLs: %w", err)
420
443
}
444
+
445
+
l.Info("removed spindle member")
421
446
}
422
447
423
448
return nil
424
449
}
425
450
426
-
func (i *Ingester) ingestSpindle(e *models.Event) error {
451
+
func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error {
427
452
did := e.Did
428
453
var err error
429
454
···
456
481
return err
457
482
}
458
483
459
-
err = spindleverify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev)
484
+
err = spindleverify.RunVerification(ctx, instance, did, i.Config.Core.Dev)
460
485
if err != nil {
461
486
l.Error("failed to add spindle to db", "err", err, "instance", instance)
462
487
return err
···
486
511
if err != nil || len(spindles) != 1 {
487
512
return fmt.Errorf("failed to get spindles: %w, len(spindles) = %d", err, len(spindles))
488
513
}
514
+
spindle := spindles[0]
489
515
490
516
tx, err := ddb.Begin()
491
517
if err != nil {
···
496
522
i.Enforcer.E.LoadPolicy()
497
523
}()
498
524
499
-
err = db.DeleteSpindle(
525
+
// remove spindle members first
526
+
err = db.RemoveSpindleMember(
500
527
tx,
501
528
db.FilterEq("owner", did),
502
529
db.FilterEq("instance", instance),
···
505
532
return err
506
533
}
507
534
508
-
err = i.Enforcer.RemoveSpindle(instance)
535
+
err = db.DeleteSpindle(
536
+
tx,
537
+
db.FilterEq("owner", did),
538
+
db.FilterEq("instance", instance),
539
+
)
509
540
if err != nil {
510
541
return err
542
+
}
543
+
544
+
if spindle.Verified != nil {
545
+
err = i.Enforcer.RemoveSpindle(instance)
546
+
if err != nil {
547
+
return err
548
+
}
511
549
}
512
550
513
551
err = tx.Commit()
···
523
561
524
562
return nil
525
563
}
564
+
565
+
func (i *Ingester) ingestString(e *models.Event) error {
566
+
did := e.Did
567
+
rkey := e.Commit.RKey
568
+
569
+
var err error
570
+
571
+
l := i.Logger.With("handler", "ingestString", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
572
+
l.Info("ingesting record")
573
+
574
+
ddb, ok := i.Db.Execer.(*db.DB)
575
+
if !ok {
576
+
return fmt.Errorf("failed to index string record, invalid db cast")
577
+
}
578
+
579
+
switch e.Commit.Operation {
580
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
581
+
raw := json.RawMessage(e.Commit.Record)
582
+
record := tangled.String{}
583
+
err = json.Unmarshal(raw, &record)
584
+
if err != nil {
585
+
l.Error("invalid record", "err", err)
586
+
return err
587
+
}
588
+
589
+
string := db.StringFromRecord(did, rkey, record)
590
+
591
+
if err = string.Validate(); err != nil {
592
+
l.Error("invalid record", "err", err)
593
+
return err
594
+
}
595
+
596
+
if err = db.AddString(ddb, string); err != nil {
597
+
l.Error("failed to add string", "err", err)
598
+
return err
599
+
}
600
+
601
+
return nil
602
+
603
+
case models.CommitOperationDelete:
604
+
if err := db.DeleteString(
605
+
ddb,
606
+
db.FilterEq("did", did),
607
+
db.FilterEq("rkey", rkey),
608
+
); err != nil {
609
+
l.Error("failed to delete", "err", err)
610
+
return fmt.Errorf("failed to delete string record: %w", err)
611
+
}
612
+
613
+
return nil
614
+
}
615
+
616
+
return nil
617
+
}
618
+
619
+
func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error {
620
+
did := e.Did
621
+
rkey := e.Commit.RKey
622
+
623
+
var err error
624
+
625
+
l := i.Logger.With("handler", "ingestIssue", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
626
+
l.Info("ingesting record")
627
+
628
+
ddb, ok := i.Db.Execer.(*db.DB)
629
+
if !ok {
630
+
return fmt.Errorf("failed to index issue record, invalid db cast")
631
+
}
632
+
633
+
switch e.Commit.Operation {
634
+
case models.CommitOperationCreate:
635
+
raw := json.RawMessage(e.Commit.Record)
636
+
record := tangled.RepoIssue{}
637
+
err = json.Unmarshal(raw, &record)
638
+
if err != nil {
639
+
l.Error("invalid record", "err", err)
640
+
return err
641
+
}
642
+
643
+
issue := db.IssueFromRecord(did, rkey, record)
644
+
645
+
sanitizer := markup.NewSanitizer()
646
+
if st := strings.TrimSpace(sanitizer.SanitizeDescription(issue.Title)); st == "" {
647
+
return fmt.Errorf("title is empty after HTML sanitization")
648
+
}
649
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(issue.Body)); sb == "" {
650
+
return fmt.Errorf("body is empty after HTML sanitization")
651
+
}
652
+
653
+
tx, err := ddb.BeginTx(ctx, nil)
654
+
if err != nil {
655
+
l.Error("failed to begin transaction", "err", err)
656
+
return err
657
+
}
658
+
659
+
err = db.NewIssue(tx, &issue)
660
+
if err != nil {
661
+
l.Error("failed to create issue", "err", err)
662
+
return err
663
+
}
664
+
665
+
return nil
666
+
667
+
case models.CommitOperationUpdate:
668
+
raw := json.RawMessage(e.Commit.Record)
669
+
record := tangled.RepoIssue{}
670
+
err = json.Unmarshal(raw, &record)
671
+
if err != nil {
672
+
l.Error("invalid record", "err", err)
673
+
return err
674
+
}
675
+
676
+
body := ""
677
+
if record.Body != nil {
678
+
body = *record.Body
679
+
}
680
+
681
+
sanitizer := markup.NewSanitizer()
682
+
if st := strings.TrimSpace(sanitizer.SanitizeDescription(record.Title)); st == "" {
683
+
return fmt.Errorf("title is empty after HTML sanitization")
684
+
}
685
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" {
686
+
return fmt.Errorf("body is empty after HTML sanitization")
687
+
}
688
+
689
+
err = db.UpdateIssueByRkey(ddb, did, rkey, record.Title, body)
690
+
if err != nil {
691
+
l.Error("failed to update issue", "err", err)
692
+
return err
693
+
}
694
+
695
+
return nil
696
+
697
+
case models.CommitOperationDelete:
698
+
if err := db.DeleteIssueByRkey(ddb, did, rkey); err != nil {
699
+
l.Error("failed to delete", "err", err)
700
+
return fmt.Errorf("failed to delete issue record: %w", err)
701
+
}
702
+
703
+
return nil
704
+
}
705
+
706
+
return fmt.Errorf("unknown operation: %s", e.Commit.Operation)
707
+
}
708
+
709
+
func (i *Ingester) ingestIssueComment(e *models.Event) error {
710
+
did := e.Did
711
+
rkey := e.Commit.RKey
712
+
713
+
var err error
714
+
715
+
l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
716
+
l.Info("ingesting record")
717
+
718
+
ddb, ok := i.Db.Execer.(*db.DB)
719
+
if !ok {
720
+
return fmt.Errorf("failed to index issue comment record, invalid db cast")
721
+
}
722
+
723
+
switch e.Commit.Operation {
724
+
case models.CommitOperationCreate:
725
+
raw := json.RawMessage(e.Commit.Record)
726
+
record := tangled.RepoIssueComment{}
727
+
err = json.Unmarshal(raw, &record)
728
+
if err != nil {
729
+
l.Error("invalid record", "err", err)
730
+
return err
731
+
}
732
+
733
+
comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record)
734
+
if err != nil {
735
+
l.Error("failed to parse comment from record", "err", err)
736
+
return err
737
+
}
738
+
739
+
sanitizer := markup.NewSanitizer()
740
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(comment.Body)); sb == "" {
741
+
return fmt.Errorf("body is empty after HTML sanitization")
742
+
}
743
+
744
+
err = db.NewIssueComment(ddb, &comment)
745
+
if err != nil {
746
+
l.Error("failed to create issue comment", "err", err)
747
+
return err
748
+
}
749
+
750
+
return nil
751
+
752
+
case models.CommitOperationUpdate:
753
+
raw := json.RawMessage(e.Commit.Record)
754
+
record := tangled.RepoIssueComment{}
755
+
err = json.Unmarshal(raw, &record)
756
+
if err != nil {
757
+
l.Error("invalid record", "err", err)
758
+
return err
759
+
}
760
+
761
+
sanitizer := markup.NewSanitizer()
762
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(record.Body)); sb == "" {
763
+
return fmt.Errorf("body is empty after HTML sanitization")
764
+
}
765
+
766
+
err = db.UpdateCommentByRkey(ddb, did, rkey, record.Body)
767
+
if err != nil {
768
+
l.Error("failed to update issue comment", "err", err)
769
+
return err
770
+
}
771
+
772
+
return nil
773
+
774
+
case models.CommitOperationDelete:
775
+
if err := db.DeleteCommentByRkey(ddb, did, rkey); err != nil {
776
+
l.Error("failed to delete", "err", err)
777
+
return fmt.Errorf("failed to delete issue comment record: %w", err)
778
+
}
779
+
780
+
return nil
781
+
}
782
+
783
+
return fmt.Errorf("unknown operation: %s", e.Commit.Operation)
784
+
}
+66
-119
appview/issues/issues.go
+66
-119
appview/issues/issues.go
···
7
7
"net/http"
8
8
"slices"
9
9
"strconv"
10
+
"strings"
10
11
"time"
11
12
12
13
comatproto "github.com/bluesky-social/indigo/api/atproto"
13
14
"github.com/bluesky-social/indigo/atproto/data"
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"
24
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
25
25
"tangled.sh/tangled.sh/core/appview/pagination"
26
26
"tangled.sh/tangled.sh/core/appview/reporesolver"
27
+
"tangled.sh/tangled.sh/core/idresolver"
28
+
"tangled.sh/tangled.sh/core/tid"
27
29
)
28
30
29
31
type Issues struct {
···
33
35
idResolver *idresolver.Resolver
34
36
db *db.DB
35
37
config *config.Config
36
-
posthog posthog.Client
38
+
notifier notify.Notifier
37
39
}
38
40
39
41
func New(
···
43
45
idResolver *idresolver.Resolver,
44
46
db *db.DB,
45
47
config *config.Config,
46
-
posthog posthog.Client,
48
+
notifier notify.Notifier,
47
49
) *Issues {
48
50
return &Issues{
49
51
oauth: oauth,
···
52
54
idResolver: idResolver,
53
55
db: db,
54
56
config: config,
55
-
posthog: posthog,
57
+
notifier: notifier,
56
58
}
57
59
}
58
60
···
72
74
return
73
75
}
74
76
75
-
issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt, issueIdInt)
77
+
issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt(), issueIdInt)
76
78
if err != nil {
77
79
log.Println("failed to get issue and comments", err)
78
80
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
79
81
return
80
82
}
81
83
82
-
issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid)
84
+
reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri())
83
85
if err != nil {
84
-
log.Println("failed to resolve issue owner", err)
86
+
log.Println("failed to get issue reactions")
87
+
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
85
88
}
86
89
87
-
identsToResolve := make([]string, len(comments))
88
-
for i, comment := range comments {
89
-
identsToResolve[i] = comment.OwnerDid
90
+
userReactions := map[db.ReactionKind]bool{}
91
+
if user != nil {
92
+
userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri())
90
93
}
91
-
resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve)
92
-
didHandleMap := make(map[string]string)
93
-
for _, identity := range resolvedIds {
94
-
if !identity.Handle.IsInvalidHandle() {
95
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
96
-
} else {
97
-
didHandleMap[identity.DID.String()] = identity.DID.String()
98
-
}
94
+
95
+
issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid)
96
+
if err != nil {
97
+
log.Println("failed to resolve issue owner", err)
99
98
}
100
99
101
100
rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
102
101
LoggedInUser: user,
103
102
RepoInfo: f.RepoInfo(user),
104
-
Issue: *issue,
103
+
Issue: issue,
105
104
Comments: comments,
106
105
107
106
IssueOwnerHandle: issueOwnerIdent.Handle.String(),
108
-
DidHandleMap: didHandleMap,
107
+
108
+
OrderedReactionKinds: db.OrderedReactionKinds,
109
+
Reactions: reactionCountMap,
110
+
UserReacted: userReactions,
109
111
})
110
112
111
113
}
···
126
128
return
127
129
}
128
130
129
-
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
131
+
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
130
132
if err != nil {
131
133
log.Println("failed to get issue", err)
132
134
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
···
155
157
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
156
158
Collection: tangled.RepoIssueStateNSID,
157
159
Repo: user.Did,
158
-
Rkey: appview.TID(),
160
+
Rkey: tid.TID(),
159
161
Record: &lexutil.LexiconTypeDecoder{
160
162
Val: &tangled.RepoIssueState{
161
-
Issue: issue.IssueAt,
163
+
Issue: issue.AtUri().String(),
162
164
State: closed,
163
165
},
164
166
},
···
170
172
return
171
173
}
172
174
173
-
err = db.CloseIssue(rp.db, f.RepoAt, issueIdInt)
175
+
err = db.CloseIssue(rp.db, f.RepoAt(), issueIdInt)
174
176
if err != nil {
175
177
log.Println("failed to close issue", err)
176
178
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
···
202
204
return
203
205
}
204
206
205
-
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
207
+
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
206
208
if err != nil {
207
209
log.Println("failed to get issue", err)
208
210
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
···
219
221
isIssueOwner := user.Did == issue.OwnerDid
220
222
221
223
if isCollaborator || isIssueOwner {
222
-
err := db.ReopenIssue(rp.db, f.RepoAt, issueIdInt)
224
+
err := db.ReopenIssue(rp.db, f.RepoAt(), issueIdInt)
223
225
if err != nil {
224
226
log.Println("failed to reopen issue", err)
225
227
rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
···
259
261
}
260
262
261
263
commentId := mathrand.IntN(1000000)
262
-
rkey := appview.TID()
264
+
rkey := tid.TID()
263
265
264
266
err := db.NewIssueComment(rp.db, &db.Comment{
265
267
OwnerDid: user.Did,
266
-
RepoAt: f.RepoAt,
268
+
RepoAt: f.RepoAt(),
267
269
Issue: issueIdInt,
268
270
CommentId: commentId,
269
271
Body: body,
···
276
278
}
277
279
278
280
createdAt := time.Now().Format(time.RFC3339)
279
-
commentIdInt64 := int64(commentId)
280
281
ownerDid := user.Did
281
-
issueAt, err := db.GetIssueAt(rp.db, f.RepoAt, issueIdInt)
282
+
issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt)
282
283
if err != nil {
283
284
log.Println("failed to get issue at", err)
284
285
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
285
286
return
286
287
}
287
288
288
-
atUri := f.RepoAt.String()
289
+
atUri := f.RepoAt().String()
289
290
client, err := rp.oauth.AuthorizedClient(r)
290
291
if err != nil {
291
292
log.Println("failed to get authorized client", err)
···
300
301
Val: &tangled.RepoIssueComment{
301
302
Repo: &atUri,
302
303
Issue: issueAt,
303
-
CommentId: &commentIdInt64,
304
304
Owner: &ownerDid,
305
305
Body: body,
306
306
CreatedAt: createdAt,
···
342
342
return
343
343
}
344
344
345
-
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
345
+
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
346
346
if err != nil {
347
347
log.Println("failed to get issue", err)
348
348
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
349
349
return
350
350
}
351
351
352
-
comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
352
+
comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
353
353
if err != nil {
354
354
http.Error(w, "bad comment id", http.StatusBadRequest)
355
355
return
356
356
}
357
357
358
-
identity, err := rp.idResolver.ResolveIdent(r.Context(), comment.OwnerDid)
359
-
if err != nil {
360
-
log.Println("failed to resolve did")
361
-
return
362
-
}
363
-
364
-
didHandleMap := make(map[string]string)
365
-
if !identity.Handle.IsInvalidHandle() {
366
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
367
-
} else {
368
-
didHandleMap[identity.DID.String()] = identity.DID.String()
369
-
}
370
-
371
358
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
372
359
LoggedInUser: user,
373
360
RepoInfo: f.RepoInfo(user),
374
-
DidHandleMap: didHandleMap,
375
361
Issue: issue,
376
362
Comment: comment,
377
363
})
···
401
387
return
402
388
}
403
389
404
-
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
390
+
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
405
391
if err != nil {
406
392
log.Println("failed to get issue", err)
407
393
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
408
394
return
409
395
}
410
396
411
-
comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
397
+
comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
412
398
if err != nil {
413
399
http.Error(w, "bad comment id", http.StatusBadRequest)
414
400
return
···
463
449
repoAt := record["repo"].(string)
464
450
issueAt := record["issue"].(string)
465
451
createdAt := record["createdAt"].(string)
466
-
commentIdInt64 := int64(commentIdInt)
467
452
468
453
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
469
454
Collection: tangled.RepoIssueCommentNSID,
···
474
459
Val: &tangled.RepoIssueComment{
475
460
Repo: &repoAt,
476
461
Issue: issueAt,
477
-
CommentId: &commentIdInt64,
478
462
Owner: &comment.OwnerDid,
479
463
Body: newBody,
480
464
CreatedAt: createdAt,
···
487
471
}
488
472
489
473
// optimistic update for htmx
490
-
didHandleMap := map[string]string{
491
-
user.Did: user.Handle,
492
-
}
493
474
comment.Body = newBody
494
475
comment.Edited = &edited
495
476
···
497
478
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
498
479
LoggedInUser: user,
499
480
RepoInfo: f.RepoInfo(user),
500
-
DidHandleMap: didHandleMap,
501
481
Issue: issue,
502
482
Comment: comment,
503
483
})
···
523
503
return
524
504
}
525
505
526
-
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
506
+
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
527
507
if err != nil {
528
508
log.Println("failed to get issue", err)
529
509
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
···
538
518
return
539
519
}
540
520
541
-
comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
521
+
comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
542
522
if err != nil {
543
523
http.Error(w, "bad comment id", http.StatusBadRequest)
544
524
return
···
556
536
557
537
// optimistic deletion
558
538
deleted := time.Now()
559
-
err = db.DeleteComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
539
+
err = db.DeleteComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
560
540
if err != nil {
561
541
log.Println("failed to delete comment")
562
542
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
···
582
562
}
583
563
584
564
// optimistic update for htmx
585
-
didHandleMap := map[string]string{
586
-
user.Did: user.Handle,
587
-
}
588
565
comment.Body = ""
589
566
comment.Deleted = &deleted
590
567
···
592
569
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
593
570
LoggedInUser: user,
594
571
RepoInfo: f.RepoInfo(user),
595
-
DidHandleMap: didHandleMap,
596
572
Issue: issue,
597
573
Comment: comment,
598
574
})
599
-
return
600
575
}
601
576
602
577
func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
···
625
600
return
626
601
}
627
602
628
-
issues, err := db.GetIssues(rp.db, f.RepoAt, isOpen, page)
603
+
issues, err := db.GetIssuesPaginated(rp.db, f.RepoAt(), isOpen, page)
629
604
if err != nil {
630
605
log.Println("failed to get issues", err)
631
606
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
632
607
return
633
608
}
634
609
635
-
identsToResolve := make([]string, len(issues))
636
-
for i, issue := range issues {
637
-
identsToResolve[i] = issue.OwnerDid
638
-
}
639
-
resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve)
640
-
didHandleMap := make(map[string]string)
641
-
for _, identity := range resolvedIds {
642
-
if !identity.Handle.IsInvalidHandle() {
643
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
644
-
} else {
645
-
didHandleMap[identity.DID.String()] = identity.DID.String()
646
-
}
647
-
}
648
-
649
610
rp.pages.RepoIssues(w, pages.RepoIssuesParams{
650
611
LoggedInUser: rp.oauth.GetUser(r),
651
612
RepoInfo: f.RepoInfo(user),
652
613
Issues: issues,
653
-
DidHandleMap: didHandleMap,
654
614
FilteringByOpen: isOpen,
655
615
Page: page,
656
616
})
657
-
return
658
617
}
659
618
660
619
func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) {
···
681
640
return
682
641
}
683
642
643
+
sanitizer := markup.NewSanitizer()
644
+
if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" {
645
+
rp.pages.Notice(w, "issues", "Title is empty after HTML sanitization")
646
+
return
647
+
}
648
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" {
649
+
rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization")
650
+
return
651
+
}
652
+
684
653
tx, err := rp.db.BeginTx(r.Context(), nil)
685
654
if err != nil {
686
655
rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
687
656
return
688
657
}
689
658
690
-
err = db.NewIssue(tx, &db.Issue{
691
-
RepoAt: f.RepoAt,
659
+
issue := &db.Issue{
660
+
RepoAt: f.RepoAt(),
661
+
Rkey: tid.TID(),
692
662
Title: title,
693
663
Body: body,
694
664
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
665
}
701
-
702
-
issueId, err := db.GetIssueId(rp.db, f.RepoAt)
666
+
err = db.NewIssue(tx, issue)
703
667
if err != nil {
704
-
log.Println("failed to get issue id", err)
668
+
log.Println("failed to create issue", err)
705
669
rp.pages.Notice(w, "issues", "Failed to create issue.")
706
670
return
707
671
}
···
712
676
rp.pages.Notice(w, "issues", "Failed to create issue.")
713
677
return
714
678
}
715
-
atUri := f.RepoAt.String()
716
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
679
+
atUri := f.RepoAt().String()
680
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
717
681
Collection: tangled.RepoIssueNSID,
718
682
Repo: user.Did,
719
-
Rkey: appview.TID(),
683
+
Rkey: issue.Rkey,
720
684
Record: &lexutil.LexiconTypeDecoder{
721
685
Val: &tangled.RepoIssue{
722
-
Repo: atUri,
723
-
Title: title,
724
-
Body: &body,
725
-
Owner: user.Did,
726
-
IssueId: int64(issueId),
686
+
Repo: atUri,
687
+
Title: title,
688
+
Body: &body,
689
+
Owner: user.Did,
727
690
},
728
691
},
729
692
})
···
733
696
return
734
697
}
735
698
736
-
err = db.SetIssueAt(rp.db, f.RepoAt, issueId, resp.Uri)
737
-
if err != nil {
738
-
log.Println("failed to set issue at", err)
739
-
rp.pages.Notice(w, "issues", "Failed to create issue.")
740
-
return
741
-
}
699
+
rp.notifier.NewIssue(r.Context(), issue)
742
700
743
-
if !rp.config.Core.Dev {
744
-
err = rp.posthog.Enqueue(posthog.Capture{
745
-
DistinctId: user.Did,
746
-
Event: "new_issue",
747
-
Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "issue_id": issueId},
748
-
})
749
-
if err != nil {
750
-
log.Println("failed to enqueue posthog event:", err)
751
-
}
752
-
}
753
-
754
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
701
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
755
702
return
756
703
}
757
704
}
+478
appview/knots/knots.go
+478
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
+
k.Pages.Knot(w, pages.KnotParams{
338
+
LoggedInUser: user,
339
+
Registration: reg,
340
+
Members: members,
341
+
Repos: repoByMember,
342
+
IsOwner: true,
343
+
})
344
+
}
345
+
346
+
// list members of domain, requires auth and requires owner status
347
+
func (k *Knots) members(w http.ResponseWriter, r *http.Request) {
348
+
l := k.Logger.With("handler", "members")
349
+
350
+
domain := chi.URLParam(r, "domain")
351
+
if domain == "" {
352
+
http.Error(w, "malformed url", http.StatusBadRequest)
353
+
return
354
+
}
355
+
l = l.With("domain", domain)
356
+
357
+
// list all members for this domain
358
+
memberDids, err := k.Enforcer.GetUserByRole("server:member", domain)
359
+
if err != nil {
360
+
w.Write([]byte("failed to fetch member list"))
361
+
return
362
+
}
363
+
364
+
w.Write([]byte(strings.Join(memberDids, "\n")))
365
+
}
366
+
367
+
// add member to domain, requires auth and requires invite access
368
+
func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) {
369
+
l := k.Logger.With("handler", "members")
370
+
371
+
domain := chi.URLParam(r, "domain")
372
+
if domain == "" {
373
+
http.Error(w, "malformed url", http.StatusBadRequest)
374
+
return
375
+
}
376
+
l = l.With("domain", domain)
377
+
378
+
reg, err := db.RegistrationByDomain(k.Db, domain)
379
+
if err != nil {
380
+
l.Error("failed to get registration by domain", "err", err)
381
+
http.Error(w, "malformed url", http.StatusBadRequest)
382
+
return
383
+
}
384
+
385
+
noticeId := fmt.Sprintf("add-member-error-%d", reg.Id)
386
+
l = l.With("notice-id", noticeId)
387
+
defaultErr := "Failed to add member. Try again later."
388
+
fail := func() {
389
+
k.Pages.Notice(w, noticeId, defaultErr)
390
+
}
391
+
392
+
subjectIdentifier := r.FormValue("subject")
393
+
if subjectIdentifier == "" {
394
+
http.Error(w, "malformed form", http.StatusBadRequest)
395
+
return
396
+
}
397
+
l = l.With("subjectIdentifier", subjectIdentifier)
398
+
399
+
subjectIdentity, err := k.IdResolver.ResolveIdent(r.Context(), subjectIdentifier)
400
+
if err != nil {
401
+
l.Error("failed to resolve identity", "err", err)
402
+
k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
403
+
return
404
+
}
405
+
l = l.With("subjectDid", subjectIdentity.DID)
406
+
407
+
l.Info("adding member to knot")
408
+
409
+
// announce this relation into the firehose, store into owners' pds
410
+
client, err := k.OAuth.AuthorizedClient(r)
411
+
if err != nil {
412
+
l.Error("failed to create client", "err", err)
413
+
fail()
414
+
return
415
+
}
416
+
417
+
currentUser := k.OAuth.GetUser(r)
418
+
createdAt := time.Now().Format(time.RFC3339)
419
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
420
+
Collection: tangled.KnotMemberNSID,
421
+
Repo: currentUser.Did,
422
+
Rkey: tid.TID(),
423
+
Record: &lexutil.LexiconTypeDecoder{
424
+
Val: &tangled.KnotMember{
425
+
Subject: subjectIdentity.DID.String(),
426
+
Domain: domain,
427
+
CreatedAt: createdAt,
428
+
}},
429
+
})
430
+
// invalid record
431
+
if err != nil {
432
+
l.Error("failed to write to PDS", "err", err)
433
+
fail()
434
+
return
435
+
}
436
+
l = l.With("at-uri", resp.Uri)
437
+
l.Info("wrote record to PDS")
438
+
439
+
secret, err := db.GetRegistrationKey(k.Db, domain)
440
+
if err != nil {
441
+
l.Error("failed to get registration key", "err", err)
442
+
fail()
443
+
return
444
+
}
445
+
446
+
ksClient, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev)
447
+
if err != nil {
448
+
l.Error("failed to create client", "err", err)
449
+
fail()
450
+
return
451
+
}
452
+
453
+
ksResp, err := ksClient.AddMember(subjectIdentity.DID.String())
454
+
if err != nil {
455
+
l.Error("failed to reach knotserver", "err", err)
456
+
k.Pages.Notice(w, noticeId, "Failed to reach to knotserver.")
457
+
return
458
+
}
459
+
460
+
if ksResp.StatusCode != http.StatusNoContent {
461
+
l.Error("status mismatch", "got", ksResp.StatusCode, "expected", http.StatusNoContent)
462
+
k.Pages.Notice(w, noticeId, fmt.Sprintf("Unexpected status code from knotserver %d, expected %d", ksResp.StatusCode, http.StatusNoContent))
463
+
return
464
+
}
465
+
466
+
err = k.Enforcer.AddKnotMember(domain, subjectIdentity.DID.String())
467
+
if err != nil {
468
+
l.Error("failed to add member to enforcer", "err", err)
469
+
fail()
470
+
return
471
+
}
472
+
473
+
// success
474
+
k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain))
475
+
}
476
+
477
+
func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) {
478
+
}
+17
-22
appview/middleware/middleware.go
+17
-22
appview/middleware/middleware.go
···
5
5
"fmt"
6
6
"log"
7
7
"net/http"
8
+
"net/url"
8
9
"slices"
9
10
"strconv"
10
11
"strings"
11
-
"time"
12
12
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
···
46
46
func AuthMiddleware(a *oauth.OAuth) middlewareFunc {
47
47
return func(next http.Handler) http.Handler {
48
48
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
49
+
returnURL := "/"
50
+
if u, err := url.Parse(r.Header.Get("Referer")); err == nil {
51
+
returnURL = u.RequestURI()
52
+
}
53
+
54
+
loginURL := fmt.Sprintf("/login?return_url=%s", url.QueryEscape(returnURL))
55
+
49
56
redirectFunc := func(w http.ResponseWriter, r *http.Request) {
50
-
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
57
+
http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect)
51
58
}
52
59
if r.Header.Get("HX-Request") == "true" {
53
60
redirectFunc = func(w http.ResponseWriter, _ *http.Request) {
54
-
w.Header().Set("HX-Redirect", "/login")
61
+
w.Header().Set("HX-Redirect", loginURL)
55
62
w.WriteHeader(http.StatusOK)
56
63
}
57
64
}
···
167
174
}
168
175
}
169
176
170
-
func StripLeadingAt(next http.Handler) http.Handler {
171
-
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
172
-
path := req.URL.EscapedPath()
173
-
if strings.HasPrefix(path, "/@") {
174
-
req.URL.RawPath = "/" + strings.TrimPrefix(path, "/@")
175
-
}
176
-
next.ServeHTTP(w, req)
177
-
})
178
-
}
179
-
180
177
func (mw Middleware) ResolveIdent() middlewareFunc {
181
178
excluded := []string{"favicon.ico"}
182
179
···
188
185
return
189
186
}
190
187
188
+
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
189
+
191
190
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
192
191
if err != nil {
193
192
// invalid did or handle
194
-
log.Println("failed to resolve did/handle:", err)
193
+
log.Printf("failed to resolve did/handle '%s': %s\n", didOrHandle, err)
195
194
mw.pages.Error404(w)
196
195
return
197
196
}
···
222
221
return
223
222
}
224
223
225
-
ctx := context.WithValue(req.Context(), "knot", repo.Knot)
226
-
ctx = context.WithValue(ctx, "repoAt", repo.AtUri)
227
-
ctx = context.WithValue(ctx, "repoDescription", repo.Description)
228
-
ctx = context.WithValue(ctx, "repoSpindle", repo.Spindle)
229
-
ctx = context.WithValue(ctx, "repoAddedAt", repo.Created.Format(time.RFC3339))
224
+
ctx := context.WithValue(req.Context(), "repo", repo)
230
225
next.ServeHTTP(w, req.WithContext(ctx))
231
226
})
232
227
}
···
251
246
return
252
247
}
253
248
254
-
pr, err := db.GetPull(mw.db, f.RepoAt, prIdInt)
249
+
pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt)
255
250
if err != nil {
256
251
log.Println("failed to get pull and comments", err)
257
252
return
···
292
287
return
293
288
}
294
289
295
-
fullName := f.OwnerHandle() + "/" + f.RepoName
290
+
fullName := f.OwnerHandle() + "/" + f.Name
296
291
297
292
if r.Header.Get("User-Agent") == "Go-http-client/1.1" {
298
293
if r.URL.Query().Get("go-get") == "1" {
+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) {}
+159
-3
appview/oauth/handler/handler.go
+159
-3
appview/oauth/handler/handler.go
···
1
1
package oauth
2
2
3
3
import (
4
+
"bytes"
5
+
"context"
4
6
"encoding/json"
5
7
"fmt"
6
8
"log"
7
9
"net/http"
8
10
"net/url"
9
11
"strings"
12
+
"time"
10
13
11
14
"github.com/go-chi/chi/v5"
12
15
"github.com/gorilla/sessions"
13
16
"github.com/lestrrat-go/jwx/v2/jwk"
14
17
"github.com/posthog/posthog-go"
15
18
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
19
+
tangled "tangled.sh/tangled.sh/core/api/tangled"
16
20
sessioncache "tangled.sh/tangled.sh/core/appview/cache/session"
17
21
"tangled.sh/tangled.sh/core/appview/config"
18
22
"tangled.sh/tangled.sh/core/appview/db"
19
-
"tangled.sh/tangled.sh/core/appview/idresolver"
20
23
"tangled.sh/tangled.sh/core/appview/middleware"
21
24
"tangled.sh/tangled.sh/core/appview/oauth"
22
25
"tangled.sh/tangled.sh/core/appview/oauth/client"
23
26
"tangled.sh/tangled.sh/core/appview/pages"
27
+
"tangled.sh/tangled.sh/core/idresolver"
24
28
"tangled.sh/tangled.sh/core/knotclient"
25
29
"tangled.sh/tangled.sh/core/rbac"
30
+
"tangled.sh/tangled.sh/core/tid"
26
31
)
27
32
28
33
const (
···
104
109
func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) {
105
110
switch r.Method {
106
111
case http.MethodGet:
107
-
o.pages.Login(w, pages.LoginParams{})
112
+
returnURL := r.URL.Query().Get("return_url")
113
+
o.pages.Login(w, pages.LoginParams{
114
+
ReturnUrl: returnURL,
115
+
})
108
116
case http.MethodPost:
109
117
handle := r.FormValue("handle")
110
118
···
189
197
DpopAuthserverNonce: parResp.DpopAuthserverNonce,
190
198
DpopPrivateJwk: string(dpopKeyJson),
191
199
State: parResp.State,
200
+
ReturnUrl: r.FormValue("return_url"),
192
201
})
193
202
if err != nil {
194
203
log.Println("failed to save oauth request:", err)
···
244
253
return
245
254
}
246
255
256
+
if iss != oauthRequest.AuthserverIss {
257
+
log.Println("mismatched iss:", iss, "!=", oauthRequest.AuthserverIss, "for state:", state)
258
+
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
259
+
return
260
+
}
261
+
247
262
self := o.oauth.ClientMetadata()
248
263
249
264
oauthClient, err := client.NewClient(
···
294
309
295
310
log.Println("session saved successfully")
296
311
go o.addToDefaultKnot(oauthRequest.Did)
312
+
go o.addToDefaultSpindle(oauthRequest.Did)
297
313
298
314
if !o.config.Core.Dev {
299
315
err = o.posthog.Enqueue(posthog.Capture{
···
305
321
}
306
322
}
307
323
308
-
http.Redirect(w, r, "/", http.StatusFound)
324
+
returnUrl := oauthRequest.ReturnUrl
325
+
if returnUrl == "" {
326
+
returnUrl = "/"
327
+
}
328
+
329
+
http.Redirect(w, r, returnUrl, http.StatusFound)
309
330
}
310
331
311
332
func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) {
···
330
351
return nil, err
331
352
}
332
353
return pubKey, nil
354
+
}
355
+
356
+
func (o *OAuthHandler) addToDefaultSpindle(did string) {
357
+
// use the tangled.sh app password to get an accessJwt
358
+
// and create an sh.tangled.spindle.member record with that
359
+
360
+
defaultSpindle := "spindle.tangled.sh"
361
+
appPassword := o.config.Core.AppPassword
362
+
363
+
spindleMembers, err := db.GetSpindleMembers(
364
+
o.db,
365
+
db.FilterEq("instance", "spindle.tangled.sh"),
366
+
db.FilterEq("subject", did),
367
+
)
368
+
if err != nil {
369
+
log.Printf("failed to get spindle members for did %s: %v", did, err)
370
+
return
371
+
}
372
+
373
+
if len(spindleMembers) != 0 {
374
+
log.Printf("did %s is already a member of the default spindle", did)
375
+
return
376
+
}
377
+
378
+
// TODO: hardcoded tangled handle and did for now
379
+
tangledHandle := "tangled.sh"
380
+
tangledDid := "did:plc:wshs7t2adsemcrrd4snkeqli"
381
+
382
+
if appPassword == "" {
383
+
log.Println("no app password configured, skipping spindle member addition")
384
+
return
385
+
}
386
+
387
+
log.Printf("adding %s to default spindle", did)
388
+
389
+
resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid)
390
+
if err != nil {
391
+
log.Printf("failed to resolve tangled.sh DID %s: %v", tangledDid, err)
392
+
return
393
+
}
394
+
395
+
pdsEndpoint := resolved.PDSEndpoint()
396
+
if pdsEndpoint == "" {
397
+
log.Printf("no PDS endpoint found for tangled.sh DID %s", tangledDid)
398
+
return
399
+
}
400
+
401
+
sessionPayload := map[string]string{
402
+
"identifier": tangledHandle,
403
+
"password": appPassword,
404
+
}
405
+
sessionBytes, err := json.Marshal(sessionPayload)
406
+
if err != nil {
407
+
log.Printf("failed to marshal session payload: %v", err)
408
+
return
409
+
}
410
+
411
+
sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession"
412
+
sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes))
413
+
if err != nil {
414
+
log.Printf("failed to create session request: %v", err)
415
+
return
416
+
}
417
+
sessionReq.Header.Set("Content-Type", "application/json")
418
+
419
+
client := &http.Client{Timeout: 30 * time.Second}
420
+
sessionResp, err := client.Do(sessionReq)
421
+
if err != nil {
422
+
log.Printf("failed to create session: %v", err)
423
+
return
424
+
}
425
+
defer sessionResp.Body.Close()
426
+
427
+
if sessionResp.StatusCode != http.StatusOK {
428
+
log.Printf("failed to create session: HTTP %d", sessionResp.StatusCode)
429
+
return
430
+
}
431
+
432
+
var session struct {
433
+
AccessJwt string `json:"accessJwt"`
434
+
}
435
+
if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
436
+
log.Printf("failed to decode session response: %v", err)
437
+
return
438
+
}
439
+
440
+
record := tangled.SpindleMember{
441
+
LexiconTypeID: "sh.tangled.spindle.member",
442
+
Subject: did,
443
+
Instance: defaultSpindle,
444
+
CreatedAt: time.Now().Format(time.RFC3339),
445
+
}
446
+
447
+
recordBytes, err := json.Marshal(record)
448
+
if err != nil {
449
+
log.Printf("failed to marshal spindle member record: %v", err)
450
+
return
451
+
}
452
+
453
+
payload := map[string]interface{}{
454
+
"repo": tangledDid,
455
+
"collection": tangled.SpindleMemberNSID,
456
+
"rkey": tid.TID(),
457
+
"record": json.RawMessage(recordBytes),
458
+
}
459
+
460
+
payloadBytes, err := json.Marshal(payload)
461
+
if err != nil {
462
+
log.Printf("failed to marshal request payload: %v", err)
463
+
return
464
+
}
465
+
466
+
url := pdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
467
+
req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
468
+
if err != nil {
469
+
log.Printf("failed to create HTTP request: %v", err)
470
+
return
471
+
}
472
+
473
+
req.Header.Set("Content-Type", "application/json")
474
+
req.Header.Set("Authorization", "Bearer "+session.AccessJwt)
475
+
476
+
resp, err := client.Do(req)
477
+
if err != nil {
478
+
log.Printf("failed to add user to default spindle: %v", err)
479
+
return
480
+
}
481
+
defer resp.Body.Close()
482
+
483
+
if resp.StatusCode != http.StatusOK {
484
+
log.Printf("failed to add user to default spindle: HTTP %d", resp.StatusCode)
485
+
return
486
+
}
487
+
488
+
log.Printf("successfully added %s to default spindle", did)
333
489
}
334
490
335
491
func (o *OAuthHandler) addToDefaultKnot(did string) {
+85
-2
appview/oauth/oauth.go
+85
-2
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"
···
102
103
if err != nil {
103
104
return nil, false, fmt.Errorf("error parsing expiry time: %w", err)
104
105
}
105
-
if expiry.Sub(time.Now()) <= 5*time.Minute {
106
+
if time.Until(expiry) <= 5*time.Minute {
106
107
privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk))
107
108
if err != nil {
108
109
return nil, false, err
···
206
207
return xrpcClient, nil
207
208
}
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
+
228
+
// Specify the Duration in seconds for the expiry of this token
229
+
//
230
+
// The time of expiry is calculated as time.Now().Unix() + exp
231
+
func WithExp(exp int64) ServiceClientOpt {
232
+
return func(s *ServiceClientOpts) {
233
+
s.exp = time.Now().Unix() + exp
234
+
}
235
+
}
236
+
237
+
func WithLxm(lxm string) ServiceClientOpt {
238
+
return func(s *ServiceClientOpts) {
239
+
s.lxm = lxm
240
+
}
241
+
}
242
+
243
+
func WithDev(dev bool) ServiceClientOpt {
244
+
return func(s *ServiceClientOpts) {
245
+
s.dev = dev
246
+
}
247
+
}
248
+
249
+
func (s *ServiceClientOpts) Audience() string {
250
+
return fmt.Sprintf("did:web:%s", s.service)
251
+
}
252
+
253
+
func (s *ServiceClientOpts) Host() string {
254
+
scheme := "https://"
255
+
if s.dev {
256
+
scheme = "http://"
257
+
}
258
+
259
+
return scheme + s.service
260
+
}
261
+
262
+
func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) {
263
+
opts := ServiceClientOpts{}
264
+
for _, o := range os {
265
+
o(&opts)
266
+
}
267
+
268
+
authorizedClient, err := o.AuthorizedClient(r)
269
+
if err != nil {
270
+
return nil, err
271
+
}
272
+
273
+
// force expiry to atleast 60 seconds in the future
274
+
sixty := time.Now().Unix() + 60
275
+
if opts.exp < sixty {
276
+
opts.exp = sixty
277
+
}
278
+
279
+
resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm)
280
+
if err != nil {
281
+
return nil, err
282
+
}
283
+
284
+
return &indigo_xrpc.Client{
285
+
Auth: &indigo_xrpc.AuthInfo{
286
+
AccessJwt: resp.Token,
287
+
},
288
+
Host: opts.Host(),
289
+
}, nil
290
+
}
291
+
209
292
type ClientMetadata struct {
210
293
ClientID string `json:"client_id"`
211
294
ClientName string `json:"client_name"`
···
232
315
redirectURIs := makeRedirectURIs(clientURI)
233
316
234
317
if o.config.Core.Dev {
235
-
clientURI = fmt.Sprintf("http://127.0.0.1:3000")
318
+
clientURI = "http://127.0.0.1:3000"
236
319
redirectURIs = makeRedirectURIs(clientURI)
237
320
238
321
query := url.Values{}
+96
-37
appview/pages/funcmap.go
+96
-37
appview/pages/funcmap.go
···
1
1
package pages
2
2
3
3
import (
4
+
"context"
4
5
"crypto/hmac"
5
6
"crypto/sha256"
6
7
"encoding/hex"
···
17
18
"time"
18
19
19
20
"github.com/dustin/go-humanize"
20
-
"github.com/microcosm-cc/bluemonday"
21
+
"github.com/go-enry/go-enry/v2"
21
22
"tangled.sh/tangled.sh/core/appview/filetree"
22
23
"tangled.sh/tangled.sh/core/appview/pages/markup"
23
24
)
···
26
27
return template.FuncMap{
27
28
"split": func(s string) []string {
28
29
return strings.Split(s, "\n")
30
+
},
31
+
"resolve": func(s string) string {
32
+
identity, err := p.resolver.ResolveIdent(context.Background(), s)
33
+
34
+
if err != nil {
35
+
return s
36
+
}
37
+
38
+
if identity.Handle.IsInvalidHandle() {
39
+
return "handle.invalid"
40
+
}
41
+
42
+
return "@" + identity.Handle.String()
29
43
},
30
44
"truncateAt30": func(s string) string {
31
45
if len(s) <= 30 {
···
73
87
"negf64": func(a float64) float64 {
74
88
return -a
75
89
},
76
-
"cond": func(cond interface{}, a, b string) string {
90
+
"cond": func(cond any, a, b string) string {
77
91
if cond == nil {
78
92
return b
79
93
}
···
105
119
s = append(s, values...)
106
120
return s
107
121
},
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 {
122
+
"commaFmt": humanize.Comma,
123
+
"relTimeFmt": humanize.Time,
124
+
"shortRelTimeFmt": func(t time.Time) string {
114
125
return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{
115
126
{time.Second, "now", time.Second},
116
127
{2 * time.Second, "1s %s", 1},
···
129
140
{math.MaxInt64, "a long while %s", 1},
130
141
})
131
142
},
132
-
"durationFmt": func(duration time.Duration) string {
143
+
"longTimeFmt": func(t time.Time) string {
144
+
return t.Format("Jan 2, 2006, 3:04 PM MST")
145
+
},
146
+
"iso8601DateTimeFmt": func(t time.Time) string {
147
+
return t.Format("2006-01-02T15:04:05-07:00")
148
+
},
149
+
"iso8601DurationFmt": func(duration time.Duration) string {
133
150
days := int64(duration.Hours() / 24)
134
151
hours := int64(math.Mod(duration.Hours(), 24))
135
152
minutes := int64(math.Mod(duration.Minutes(), 60))
136
153
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, " ")
154
+
return fmt.Sprintf("P%dD%dH%dM%dS", days, hours, minutes, seconds)
155
+
},
156
+
"durationFmt": func(duration time.Duration) string {
157
+
return durationFmt(duration, [4]string{"d", "hr", "min", "s"})
158
+
},
159
+
"longDurationFmt": func(duration time.Duration) string {
160
+
return durationFmt(duration, [4]string{"days", "hours", "minutes", "seconds"})
157
161
},
158
162
"byteFmt": humanize.Bytes,
159
163
"length": func(slice any) int {
···
176
180
return html.UnescapeString(s)
177
181
},
178
182
"nl2br": func(text string) template.HTML {
179
-
return template.HTML(strings.Replace(template.HTMLEscapeString(text), "\n", "<br>", -1))
183
+
return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(text), "\n", "<br>"))
180
184
},
181
185
"unwrapText": func(text string) string {
182
186
paragraphs := strings.Split(text, "\n\n")
···
200
204
if v.Len() == 0 {
201
205
return nil
202
206
}
203
-
return v.Slice(0, min(n, v.Len()-1)).Interface()
207
+
return v.Slice(0, min(n, v.Len())).Interface()
204
208
},
205
-
206
209
"markdown": func(text string) template.HTML {
207
-
rctx := &markup.RenderContext{RendererType: markup.RendererTypeDefault}
208
-
return template.HTML(bluemonday.UGCPolicy().Sanitize(rctx.RenderMarkdown(text)))
210
+
p.rctx.RendererType = markup.RendererTypeDefault
211
+
htmlString := p.rctx.RenderMarkdown(text)
212
+
sanitized := p.rctx.SanitizeDefault(htmlString)
213
+
return template.HTML(sanitized)
214
+
},
215
+
"description": func(text string) template.HTML {
216
+
p.rctx.RendererType = markup.RendererTypeDefault
217
+
htmlString := p.rctx.RenderMarkdown(text)
218
+
sanitized := p.rctx.SanitizeDescription(htmlString)
219
+
return template.HTML(sanitized)
209
220
},
210
221
"isNil": func(t any) bool {
211
222
// returns false for other "zero" values
···
245
256
},
246
257
"cssContentHash": CssContentHash,
247
258
"fileTree": filetree.FileTree,
259
+
"pathEscape": func(s string) string {
260
+
return url.PathEscape(s)
261
+
},
248
262
"pathUnescape": func(s string) string {
249
263
u, _ := url.PathUnescape(s)
250
264
return u
251
265
},
252
266
253
-
"tinyAvatar": p.tinyAvatar,
267
+
"tinyAvatar": func(handle string) string {
268
+
return p.avatarUri(handle, "tiny")
269
+
},
270
+
"fullAvatar": func(handle string) string {
271
+
return p.avatarUri(handle, "")
272
+
},
273
+
"langColor": enry.GetColor,
274
+
"layoutSide": func() string {
275
+
return "col-span-1 md:col-span-2 lg:col-span-3"
276
+
},
277
+
"layoutCenter": func() string {
278
+
return "col-span-1 md:col-span-8 lg:col-span-6"
279
+
},
254
280
}
255
281
}
256
282
257
-
func (p *Pages) tinyAvatar(handle string) string {
283
+
func (p *Pages) avatarUri(handle, size string) string {
258
284
handle = strings.TrimPrefix(handle, "@")
285
+
259
286
secret := p.avatar.SharedSecret
260
287
h := hmac.New(sha256.New, []byte(secret))
261
288
h.Write([]byte(handle))
262
289
signature := hex.EncodeToString(h.Sum(nil))
263
-
return fmt.Sprintf("%s/%s/%s?size=tiny", p.avatar.Host, signature, handle)
290
+
291
+
sizeArg := ""
292
+
if size != "" {
293
+
sizeArg = fmt.Sprintf("size=%s", size)
294
+
}
295
+
return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg)
264
296
}
265
297
266
298
func icon(name string, classes []string) (template.HTML, error) {
···
288
320
modifiedSVG := svgStr[:svgTagEnd] + classTag + svgStr[svgTagEnd:]
289
321
return template.HTML(modifiedSVG), nil
290
322
}
323
+
324
+
func durationFmt(duration time.Duration, names [4]string) string {
325
+
days := int64(duration.Hours() / 24)
326
+
hours := int64(math.Mod(duration.Hours(), 24))
327
+
minutes := int64(math.Mod(duration.Minutes(), 60))
328
+
seconds := int64(math.Mod(duration.Seconds(), 60))
329
+
330
+
chunks := []struct {
331
+
name string
332
+
amount int64
333
+
}{
334
+
{names[0], days},
335
+
{names[1], hours},
336
+
{names[2], minutes},
337
+
{names[3], seconds},
338
+
}
339
+
340
+
parts := []string{}
341
+
342
+
for _, chunk := range chunks {
343
+
if chunk.amount != 0 {
344
+
parts = append(parts, fmt.Sprintf("%d%s", chunk.amount, chunk.name))
345
+
}
346
+
}
347
+
348
+
return strings.Join(parts, " ")
349
+
}
+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
+61
-31
appview/pages/markup/markdown.go
+61
-31
appview/pages/markup/markdown.go
···
9
9
"path"
10
10
"strings"
11
11
12
-
"github.com/microcosm-cc/bluemonday"
12
+
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
13
+
"github.com/alecthomas/chroma/v2/styles"
13
14
"github.com/yuin/goldmark"
15
+
highlighting "github.com/yuin/goldmark-highlighting/v2"
14
16
"github.com/yuin/goldmark/ast"
15
17
"github.com/yuin/goldmark/extension"
16
18
"github.com/yuin/goldmark/parser"
···
40
42
repoinfo.RepoInfo
41
43
IsDev bool
42
44
RendererType RendererType
45
+
Sanitizer Sanitizer
43
46
}
44
47
45
48
func (rctx *RenderContext) RenderMarkdown(source string) string {
46
49
md := goldmark.New(
47
-
goldmark.WithExtensions(extension.GFM),
50
+
goldmark.WithExtensions(
51
+
extension.GFM,
52
+
highlighting.NewHighlighting(
53
+
highlighting.WithFormatOptions(
54
+
chromahtml.Standalone(false),
55
+
chromahtml.WithClasses(true),
56
+
),
57
+
highlighting.WithCustomStyle(styles.Get("catppuccin-latte")),
58
+
),
59
+
extension.NewFootnote(
60
+
extension.WithFootnoteIDPrefix([]byte("footnote")),
61
+
),
62
+
),
48
63
goldmark.WithParserOptions(
49
64
parser.WithAutoHeadingID(),
50
65
),
···
145
160
}
146
161
}
147
162
148
-
func (rctx *RenderContext) Sanitize(html string) string {
149
-
policy := bluemonday.UGCPolicy()
150
-
151
-
// video
152
-
policy.AllowElements("video")
153
-
policy.AllowAttrs("controls").OnElements("video")
154
-
policy.AllowElements("source")
155
-
policy.AllowAttrs("src", "type").OnElements("source")
156
-
157
-
// centering content
158
-
policy.AllowElements("center")
163
+
func (rctx *RenderContext) SanitizeDefault(html string) string {
164
+
return rctx.Sanitizer.SanitizeDefault(html)
165
+
}
159
166
160
-
policy.AllowAttrs("align", "style", "width", "height").Globally()
161
-
policy.AllowStyles(
162
-
"margin",
163
-
"padding",
164
-
"text-align",
165
-
"font-weight",
166
-
"text-decoration",
167
-
"padding-left",
168
-
"padding-right",
169
-
"padding-top",
170
-
"padding-bottom",
171
-
"margin-left",
172
-
"margin-right",
173
-
"margin-top",
174
-
"margin-bottom",
175
-
)
176
-
return policy.Sanitize(html)
167
+
func (rctx *RenderContext) SanitizeDescription(html string) string {
168
+
return rctx.Sanitizer.SanitizeDescription(html)
177
169
}
178
170
179
171
type MarkdownTransformer struct {
···
189
181
switch a.rctx.RendererType {
190
182
case RendererTypeRepoMarkdown:
191
183
switch n := n.(type) {
184
+
case *ast.Heading:
185
+
a.rctx.anchorHeadingTransformer(n)
192
186
case *ast.Link:
193
187
a.rctx.relativeLinkTransformer(n)
194
188
case *ast.Image:
···
197
191
}
198
192
case RendererTypeDefault:
199
193
switch n := n.(type) {
194
+
case *ast.Heading:
195
+
a.rctx.anchorHeadingTransformer(n)
200
196
case *ast.Image:
201
197
a.rctx.imageFromKnotAstTransformer(n)
202
198
a.rctx.camoImageLinkAstTransformer(n)
···
211
207
212
208
dst := string(link.Destination)
213
209
214
-
if isAbsoluteUrl(dst) {
210
+
if isAbsoluteUrl(dst) || isFragment(dst) || isMail(dst) {
215
211
return
216
212
}
217
213
···
252
248
img.Destination = []byte(rctx.imageFromKnotTransformer(dst))
253
249
}
254
250
251
+
func (rctx *RenderContext) anchorHeadingTransformer(h *ast.Heading) {
252
+
idGeneric, exists := h.AttributeString("id")
253
+
if !exists {
254
+
return // no id, nothing to do
255
+
}
256
+
id, ok := idGeneric.([]byte)
257
+
if !ok {
258
+
return
259
+
}
260
+
261
+
// create anchor link
262
+
anchor := ast.NewLink()
263
+
anchor.Destination = fmt.Appendf(nil, "#%s", string(id))
264
+
anchor.SetAttribute([]byte("class"), []byte("anchor"))
265
+
266
+
// create icon text
267
+
iconText := ast.NewString([]byte("#"))
268
+
anchor.AppendChild(anchor, iconText)
269
+
270
+
// set class on heading
271
+
h.SetAttribute([]byte("class"), []byte("heading"))
272
+
273
+
// append anchor to heading
274
+
h.AppendChild(h, anchor)
275
+
}
276
+
255
277
// actualPath decides when to join the file path with the
256
278
// current repository directory (essentially only when the link
257
279
// destination is relative. if it's absolute then we assume the
···
271
293
}
272
294
return parsed.IsAbs()
273
295
}
296
+
297
+
func isFragment(link string) bool {
298
+
return strings.HasPrefix(link, "#")
299
+
}
300
+
301
+
func isMail(link string) bool {
302
+
return strings.HasPrefix(link, "mailto:")
303
+
}
+117
appview/pages/markup/sanitizer.go
+117
appview/pages/markup/sanitizer.go
···
1
+
package markup
2
+
3
+
import (
4
+
"maps"
5
+
"regexp"
6
+
"slices"
7
+
"strings"
8
+
9
+
"github.com/alecthomas/chroma/v2"
10
+
"github.com/microcosm-cc/bluemonday"
11
+
)
12
+
13
+
type Sanitizer struct {
14
+
defaultPolicy *bluemonday.Policy
15
+
descriptionPolicy *bluemonday.Policy
16
+
}
17
+
18
+
func NewSanitizer() Sanitizer {
19
+
return Sanitizer{
20
+
defaultPolicy: defaultPolicy(),
21
+
descriptionPolicy: descriptionPolicy(),
22
+
}
23
+
}
24
+
25
+
func (s *Sanitizer) SanitizeDefault(html string) string {
26
+
return s.defaultPolicy.Sanitize(html)
27
+
}
28
+
func (s *Sanitizer) SanitizeDescription(html string) string {
29
+
return s.descriptionPolicy.Sanitize(html)
30
+
}
31
+
32
+
func defaultPolicy() *bluemonday.Policy {
33
+
policy := bluemonday.UGCPolicy()
34
+
35
+
// Allow generally safe attributes
36
+
generalSafeAttrs := []string{
37
+
"abbr", "accept", "accept-charset",
38
+
"accesskey", "action", "align", "alt",
39
+
"aria-describedby", "aria-hidden", "aria-label", "aria-labelledby",
40
+
"axis", "border", "cellpadding", "cellspacing", "char",
41
+
"charoff", "charset", "checked",
42
+
"clear", "cols", "colspan", "color",
43
+
"compact", "coords", "datetime", "dir",
44
+
"disabled", "enctype", "for", "frame",
45
+
"headers", "height", "hreflang",
46
+
"hspace", "ismap", "label", "lang",
47
+
"maxlength", "media", "method",
48
+
"multiple", "name", "nohref", "noshade",
49
+
"nowrap", "open", "prompt", "readonly", "rel", "rev",
50
+
"rows", "rowspan", "rules", "scope",
51
+
"selected", "shape", "size", "span",
52
+
"start", "summary", "tabindex", "target",
53
+
"title", "type", "usemap", "valign", "value",
54
+
"vspace", "width", "itemprop",
55
+
}
56
+
57
+
generalSafeElements := []string{
58
+
"h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "br", "b", "i", "strong", "em", "a", "pre", "code", "img", "tt",
59
+
"div", "ins", "del", "sup", "sub", "p", "ol", "ul", "table", "thead", "tbody", "tfoot", "blockquote", "label",
60
+
"dl", "dt", "dd", "kbd", "q", "samp", "var", "hr", "ruby", "rt", "rp", "li", "tr", "td", "th", "s", "strike", "summary",
61
+
"details", "caption", "figure", "figcaption",
62
+
"abbr", "bdo", "cite", "dfn", "mark", "small", "span", "time", "video", "wbr",
63
+
}
64
+
65
+
policy.AllowAttrs(generalSafeAttrs...).OnElements(generalSafeElements...)
66
+
67
+
// video
68
+
policy.AllowAttrs("src", "autoplay", "controls").OnElements("video")
69
+
70
+
// checkboxes
71
+
policy.AllowAttrs("type").Matching(regexp.MustCompile(`^checkbox$`)).OnElements("input")
72
+
policy.AllowAttrs("checked", "disabled", "data-source-position").OnElements("input")
73
+
74
+
// for code blocks
75
+
policy.AllowAttrs("class").Matching(regexp.MustCompile(`chroma`)).OnElements("pre")
76
+
policy.AllowAttrs("class").Matching(regexp.MustCompile(`anchor|footnote-ref|footnote-backref`)).OnElements("a")
77
+
policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8")
78
+
policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span")
79
+
80
+
// centering content
81
+
policy.AllowElements("center")
82
+
83
+
policy.AllowAttrs("align", "style", "width", "height").Globally()
84
+
policy.AllowStyles(
85
+
"margin",
86
+
"padding",
87
+
"text-align",
88
+
"font-weight",
89
+
"text-decoration",
90
+
"padding-left",
91
+
"padding-right",
92
+
"padding-top",
93
+
"padding-bottom",
94
+
"margin-left",
95
+
"margin-right",
96
+
"margin-top",
97
+
"margin-bottom",
98
+
)
99
+
100
+
return policy
101
+
}
102
+
103
+
func descriptionPolicy() *bluemonday.Policy {
104
+
policy := bluemonday.NewPolicy()
105
+
policy.AllowStandardURLs()
106
+
107
+
// allow italics and bold.
108
+
policy.AllowElements("i", "b", "em", "strong")
109
+
110
+
// allow code.
111
+
policy.AllowElements("code")
112
+
113
+
// allow links
114
+
policy.AllowAttrs("href", "target", "rel").OnElements("a")
115
+
116
+
return policy
117
+
}
+279
-70
appview/pages/pages.go
+279
-70
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"
···
22
24
"tangled.sh/tangled.sh/core/appview/pages/markup"
23
25
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
24
26
"tangled.sh/tangled.sh/core/appview/pagination"
27
+
"tangled.sh/tangled.sh/core/idresolver"
25
28
"tangled.sh/tangled.sh/core/patchutil"
26
29
"tangled.sh/tangled.sh/core/types"
27
30
···
29
32
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
30
33
"github.com/alecthomas/chroma/v2/lexers"
31
34
"github.com/alecthomas/chroma/v2/styles"
35
+
"github.com/bluesky-social/indigo/atproto/identity"
32
36
"github.com/bluesky-social/indigo/atproto/syntax"
33
37
"github.com/go-git/go-git/v5/plumbing"
34
38
"github.com/go-git/go-git/v5/plumbing/object"
35
-
"github.com/microcosm-cc/bluemonday"
36
39
)
37
40
38
41
//go:embed templates/* static
39
42
var Files embed.FS
40
43
41
44
type Pages struct {
42
-
t map[string]*template.Template
45
+
mu sync.RWMutex
46
+
t map[string]*template.Template
47
+
43
48
avatar config.AvatarConfig
49
+
resolver *idresolver.Resolver
44
50
dev bool
45
51
embedFS embed.FS
46
52
templateDir string // Path to templates on disk for dev mode
47
53
rctx *markup.RenderContext
48
54
}
49
55
50
-
func NewPages(config *config.Config) *Pages {
56
+
func NewPages(config *config.Config, res *idresolver.Resolver) *Pages {
51
57
// initialized with safe defaults, can be overriden per use
52
58
rctx := &markup.RenderContext{
53
59
IsDev: config.Core.Dev,
54
60
CamoUrl: config.Camo.Host,
55
61
CamoSecret: config.Camo.SharedSecret,
62
+
Sanitizer: markup.NewSanitizer(),
56
63
}
57
64
58
65
p := &Pages{
66
+
mu: sync.RWMutex{},
59
67
t: make(map[string]*template.Template),
60
68
dev: config.Core.Dev,
61
69
avatar: config.Avatar,
62
70
embedFS: Files,
63
71
rctx: rctx,
72
+
resolver: res,
64
73
templateDir: "appview/pages",
65
74
}
66
75
···
147
156
}
148
157
149
158
log.Printf("total templates loaded: %d", len(templates))
159
+
p.mu.Lock()
160
+
defer p.mu.Unlock()
150
161
p.t = templates
151
162
}
152
163
···
207
218
}
208
219
209
220
// Update the template in the map
221
+
p.mu.Lock()
222
+
defer p.mu.Unlock()
210
223
p.t[name] = tmpl
211
224
log.Printf("template reloaded from disk: %s", name)
212
225
return nil
···
221
234
}
222
235
}
223
236
237
+
p.mu.RLock()
238
+
defer p.mu.RUnlock()
224
239
tmpl, exists := p.t[templateName]
225
240
if !exists {
226
241
return fmt.Errorf("template not found: %s", templateName)
···
245
260
return p.executeOrReload(name, w, "layouts/repobase", params)
246
261
}
247
262
263
+
func (p *Pages) Favicon(w io.Writer) error {
264
+
return p.executePlain("favicon", w, nil)
265
+
}
266
+
248
267
type LoginParams struct {
268
+
ReturnUrl string
249
269
}
250
270
251
271
func (p *Pages) Login(w io.Writer, params LoginParams) error {
252
272
return p.executePlain("user/login", w, params)
253
273
}
254
274
275
+
func (p *Pages) Signup(w io.Writer) error {
276
+
return p.executePlain("user/signup", w, nil)
277
+
}
278
+
279
+
func (p *Pages) CompleteSignup(w io.Writer) error {
280
+
return p.executePlain("user/completeSignup", w, nil)
281
+
}
282
+
283
+
type TermsOfServiceParams struct {
284
+
LoggedInUser *oauth.User
285
+
}
286
+
287
+
func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error {
288
+
return p.execute("legal/terms", w, params)
289
+
}
290
+
291
+
type PrivacyPolicyParams struct {
292
+
LoggedInUser *oauth.User
293
+
}
294
+
295
+
func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error {
296
+
return p.execute("legal/privacy", w, params)
297
+
}
298
+
255
299
type TimelineParams struct {
256
300
LoggedInUser *oauth.User
257
301
Timeline []db.TimelineEvent
258
-
DidHandleMap map[string]string
302
+
Repos []db.Repo
259
303
}
260
304
261
305
func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
262
-
return p.execute("timeline", w, params)
306
+
return p.execute("timeline/timeline", w, params)
263
307
}
264
308
265
309
type SettingsParams struct {
···
278
322
}
279
323
280
324
func (p *Pages) Knots(w io.Writer, params KnotsParams) error {
281
-
return p.execute("knots", w, params)
325
+
return p.execute("knots/index", w, params)
282
326
}
283
327
284
328
type KnotParams struct {
285
329
LoggedInUser *oauth.User
286
-
DidHandleMap map[string]string
287
330
Registration *db.Registration
288
331
Members []string
332
+
Repos map[string][]db.Repo
289
333
IsOwner bool
290
334
}
291
335
292
336
func (p *Pages) Knot(w io.Writer, params KnotParams) error {
293
-
return p.execute("knot", w, params)
337
+
return p.execute("knots/dashboard", w, params)
338
+
}
339
+
340
+
type KnotListingParams struct {
341
+
db.Registration
342
+
}
343
+
344
+
func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error {
345
+
return p.executePlain("knots/fragments/knotListing", w, params)
346
+
}
347
+
348
+
type KnotListingFullParams struct {
349
+
Registrations []db.Registration
350
+
}
351
+
352
+
func (p *Pages) KnotListingFull(w io.Writer, params KnotListingFullParams) error {
353
+
return p.executePlain("knots/fragments/knotListingFull", w, params)
354
+
}
355
+
356
+
type KnotSecretParams struct {
357
+
Secret string
358
+
}
359
+
360
+
func (p *Pages) KnotSecret(w io.Writer, params KnotSecretParams) error {
361
+
return p.executePlain("knots/fragments/secret", w, params)
294
362
}
295
363
296
364
type SpindlesParams struct {
···
315
383
Spindle db.Spindle
316
384
Members []string
317
385
Repos map[string][]db.Repo
318
-
DidHandleMap map[string]string
319
386
}
320
387
321
388
func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error {
···
348
415
ProfileTimeline *db.ProfileTimeline
349
416
Card ProfileCard
350
417
Punchcard db.Punchcard
351
-
352
-
DidHandleMap map[string]string
353
418
}
354
419
355
420
type ProfileCard struct {
356
421
UserDid string
357
422
UserHandle string
358
423
FollowStatus db.FollowStatus
359
-
AvatarUri string
360
424
Followers int
361
425
Following int
362
426
···
371
435
LoggedInUser *oauth.User
372
436
Repos []db.Repo
373
437
Card ProfileCard
374
-
375
-
DidHandleMap map[string]string
376
438
}
377
439
378
440
func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error {
···
401
463
LoggedInUser *oauth.User
402
464
Profile *db.Profile
403
465
AllRepos []PinnedRepo
404
-
DidHandleMap map[string]string
405
466
}
406
467
407
468
type PinnedRepo struct {
···
413
474
return p.executePlain("user/fragments/editPins", w, params)
414
475
}
415
476
416
-
type RepoActionsFragmentParams struct {
477
+
type RepoStarFragmentParams struct {
417
478
IsStarred bool
418
479
RepoAt syntax.ATURI
419
480
Stats db.RepoStats
420
481
}
421
482
422
-
func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error {
423
-
return p.executePlain("repo/fragments/repoActions", w, params)
483
+
func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error {
484
+
return p.executePlain("repo/fragments/repoStar", w, params)
424
485
}
425
486
426
487
type RepoDescriptionParams struct {
···
460
521
}
461
522
462
523
p.rctx.RepoInfo = params.RepoInfo
524
+
p.rctx.RepoInfo.Ref = params.Ref
463
525
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
464
526
465
527
if params.ReadmeFileName != "" {
466
-
var htmlString string
467
528
ext := filepath.Ext(params.ReadmeFileName)
468
529
switch ext {
469
530
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
470
-
htmlString = p.rctx.RenderMarkdown(params.Readme)
471
531
params.Raw = false
472
-
params.HTMLReadme = template.HTML(p.rctx.Sanitize(htmlString))
532
+
htmlString := p.rctx.RenderMarkdown(params.Readme)
533
+
sanitized := p.rctx.SanitizeDefault(htmlString)
534
+
params.HTMLReadme = template.HTML(sanitized)
473
535
default:
474
-
htmlString = string(params.Readme)
475
536
params.Raw = true
476
-
params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString))
477
537
}
478
538
}
479
539
···
502
562
Active string
503
563
EmailToDidOrHandle map[string]string
504
564
Pipeline *db.Pipeline
565
+
DiffOpts types.DiffOpts
505
566
506
567
// singular because it's always going to be just one
507
568
VerifiedCommit commitverify.VerifiedCommits
···
519
580
RepoInfo repoinfo.RepoInfo
520
581
Active string
521
582
BreadCrumbs [][]string
522
-
BaseTreeLink string
523
-
BaseBlobLink string
583
+
TreePath string
524
584
types.RepoTreeResponse
525
585
}
526
586
···
590
650
LoggedInUser *oauth.User
591
651
RepoInfo repoinfo.RepoInfo
592
652
Active string
653
+
Unsupported bool
654
+
IsImage bool
655
+
IsVideo bool
656
+
ContentSrc string
593
657
BreadCrumbs [][]string
594
658
ShowRendered bool
595
659
RenderToggle bool
···
606
670
p.rctx.RepoInfo = params.RepoInfo
607
671
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
608
672
htmlString := p.rctx.RenderMarkdown(params.Contents)
609
-
params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString))
673
+
sanitized := p.rctx.SanitizeDefault(htmlString)
674
+
params.RenderedContents = template.HTML(sanitized)
610
675
}
611
676
}
612
677
613
-
if params.Lines < 5000 {
614
-
c := params.Contents
615
-
formatter := chromahtml.New(
616
-
chromahtml.InlineCode(false),
617
-
chromahtml.WithLineNumbers(true),
618
-
chromahtml.WithLinkableLineNumbers(true, "L"),
619
-
chromahtml.Standalone(false),
620
-
chromahtml.WithClasses(true),
621
-
)
678
+
c := params.Contents
679
+
formatter := chromahtml.New(
680
+
chromahtml.InlineCode(false),
681
+
chromahtml.WithLineNumbers(true),
682
+
chromahtml.WithLinkableLineNumbers(true, "L"),
683
+
chromahtml.Standalone(false),
684
+
chromahtml.WithClasses(true),
685
+
)
622
686
623
-
lexer := lexers.Get(filepath.Base(params.Path))
624
-
if lexer == nil {
625
-
lexer = lexers.Fallback
626
-
}
687
+
lexer := lexers.Get(filepath.Base(params.Path))
688
+
if lexer == nil {
689
+
lexer = lexers.Fallback
690
+
}
627
691
628
-
iterator, err := lexer.Tokenise(nil, c)
629
-
if err != nil {
630
-
return fmt.Errorf("chroma tokenize: %w", err)
631
-
}
692
+
iterator, err := lexer.Tokenise(nil, c)
693
+
if err != nil {
694
+
return fmt.Errorf("chroma tokenize: %w", err)
695
+
}
632
696
633
-
var code bytes.Buffer
634
-
err = formatter.Format(&code, style, iterator)
635
-
if err != nil {
636
-
return fmt.Errorf("chroma format: %w", err)
637
-
}
638
-
639
-
params.Contents = code.String()
697
+
var code bytes.Buffer
698
+
err = formatter.Format(&code, style, iterator)
699
+
if err != nil {
700
+
return fmt.Errorf("chroma format: %w", err)
640
701
}
641
702
703
+
params.Contents = code.String()
642
704
params.Active = "overview"
643
705
return p.executeRepo("repo/blob", w, params)
644
706
}
···
657
719
Branches []types.Branch
658
720
Spindles []string
659
721
CurrentSpindle string
722
+
Secrets []*tangled.RepoListSecrets_Secret
723
+
660
724
// TODO: use repoinfo.roles
661
725
IsCollaboratorInviteAllowed bool
662
726
}
···
666
730
return p.executeRepo("repo/settings", w, params)
667
731
}
668
732
733
+
type RepoGeneralSettingsParams struct {
734
+
LoggedInUser *oauth.User
735
+
RepoInfo repoinfo.RepoInfo
736
+
Active string
737
+
Tabs []map[string]any
738
+
Tab string
739
+
Branches []types.Branch
740
+
}
741
+
742
+
func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error {
743
+
params.Active = "settings"
744
+
return p.executeRepo("repo/settings/general", w, params)
745
+
}
746
+
747
+
type RepoAccessSettingsParams struct {
748
+
LoggedInUser *oauth.User
749
+
RepoInfo repoinfo.RepoInfo
750
+
Active string
751
+
Tabs []map[string]any
752
+
Tab string
753
+
Collaborators []Collaborator
754
+
}
755
+
756
+
func (p *Pages) RepoAccessSettings(w io.Writer, params RepoAccessSettingsParams) error {
757
+
params.Active = "settings"
758
+
return p.executeRepo("repo/settings/access", w, params)
759
+
}
760
+
761
+
type RepoPipelineSettingsParams struct {
762
+
LoggedInUser *oauth.User
763
+
RepoInfo repoinfo.RepoInfo
764
+
Active string
765
+
Tabs []map[string]any
766
+
Tab string
767
+
Spindles []string
768
+
CurrentSpindle string
769
+
Secrets []map[string]any
770
+
}
771
+
772
+
func (p *Pages) RepoPipelineSettings(w io.Writer, params RepoPipelineSettingsParams) error {
773
+
params.Active = "settings"
774
+
return p.executeRepo("repo/settings/pipelines", w, params)
775
+
}
776
+
669
777
type RepoIssuesParams struct {
670
778
LoggedInUser *oauth.User
671
779
RepoInfo repoinfo.RepoInfo
672
780
Active string
673
781
Issues []db.Issue
674
-
DidHandleMap map[string]string
675
782
Page pagination.Page
676
783
FilteringByOpen bool
677
784
}
···
685
792
LoggedInUser *oauth.User
686
793
RepoInfo repoinfo.RepoInfo
687
794
Active string
688
-
Issue db.Issue
795
+
Issue *db.Issue
689
796
Comments []db.Comment
690
797
IssueOwnerHandle string
691
-
DidHandleMap map[string]string
798
+
799
+
OrderedReactionKinds []db.ReactionKind
800
+
Reactions map[db.ReactionKind]int
801
+
UserReacted map[db.ReactionKind]bool
692
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 {
···
727
848
728
849
type SingleIssueCommentParams struct {
729
850
LoggedInUser *oauth.User
730
-
DidHandleMap map[string]string
731
851
RepoInfo repoinfo.RepoInfo
732
852
Issue *db.Issue
733
853
Comment *db.Comment
···
759
879
RepoInfo repoinfo.RepoInfo
760
880
Pulls []*db.Pull
761
881
Active string
762
-
DidHandleMap map[string]string
763
882
FilteringBy db.PullState
764
883
Stacks map[string]db.Stack
884
+
Pipelines map[string]db.Pipeline
765
885
}
766
886
767
887
func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
···
791
911
LoggedInUser *oauth.User
792
912
RepoInfo repoinfo.RepoInfo
793
913
Active string
794
-
DidHandleMap map[string]string
795
914
Pull *db.Pull
796
915
Stack db.Stack
797
916
AbandonedPulls []*db.Pull
798
917
MergeCheck types.MergeCheckResponse
799
918
ResubmitCheck ResubmitResult
800
919
Pipelines map[string]db.Pipeline
920
+
921
+
OrderedReactionKinds []db.ReactionKind
922
+
Reactions map[db.ReactionKind]int
923
+
UserReacted map[db.ReactionKind]bool
801
924
}
802
925
803
926
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
806
929
}
807
930
808
931
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
932
+
LoggedInUser *oauth.User
933
+
RepoInfo repoinfo.RepoInfo
934
+
Pull *db.Pull
935
+
Stack db.Stack
936
+
Diff *types.NiceDiff
937
+
Round int
938
+
Submission *db.PullSubmission
939
+
OrderedReactionKinds []db.ReactionKind
940
+
DiffOpts types.DiffOpts
817
941
}
818
942
819
943
// this name is a mouthful
···
822
946
}
823
947
824
948
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
949
+
LoggedInUser *oauth.User
950
+
RepoInfo repoinfo.RepoInfo
951
+
Pull *db.Pull
952
+
Round int
953
+
Interdiff *patchutil.InterdiffResult
954
+
OrderedReactionKinds []db.ReactionKind
955
+
DiffOpts types.DiffOpts
831
956
}
832
957
833
958
// this name is a mouthful
···
918
1043
Base string
919
1044
Head string
920
1045
Diff *types.NiceDiff
1046
+
DiffOpts types.DiffOpts
921
1047
922
1048
Active string
923
1049
}
···
1009
1135
func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error {
1010
1136
params.Active = "pipelines"
1011
1137
return p.executeRepo("repo/pipelines/workflow", w, params)
1138
+
}
1139
+
1140
+
type PutStringParams struct {
1141
+
LoggedInUser *oauth.User
1142
+
Action string
1143
+
1144
+
// this is supplied in the case of editing an existing string
1145
+
String db.String
1146
+
}
1147
+
1148
+
func (p *Pages) PutString(w io.Writer, params PutStringParams) error {
1149
+
return p.execute("strings/put", w, params)
1150
+
}
1151
+
1152
+
type StringsDashboardParams struct {
1153
+
LoggedInUser *oauth.User
1154
+
Card ProfileCard
1155
+
Strings []db.String
1156
+
}
1157
+
1158
+
func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error {
1159
+
return p.execute("strings/dashboard", w, params)
1160
+
}
1161
+
1162
+
type StringTimelineParams struct {
1163
+
LoggedInUser *oauth.User
1164
+
Strings []db.String
1165
+
}
1166
+
1167
+
func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error {
1168
+
return p.execute("strings/timeline", w, params)
1169
+
}
1170
+
1171
+
type SingleStringParams struct {
1172
+
LoggedInUser *oauth.User
1173
+
ShowRendered bool
1174
+
RenderToggle bool
1175
+
RenderedContents template.HTML
1176
+
String db.String
1177
+
Stats db.StringStats
1178
+
Owner identity.Identity
1179
+
}
1180
+
1181
+
func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error {
1182
+
var style *chroma.Style = styles.Get("catpuccin-latte")
1183
+
1184
+
if params.ShowRendered {
1185
+
switch markup.GetFormat(params.String.Filename) {
1186
+
case markup.FormatMarkdown:
1187
+
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
1188
+
htmlString := p.rctx.RenderMarkdown(params.String.Contents)
1189
+
sanitized := p.rctx.SanitizeDefault(htmlString)
1190
+
params.RenderedContents = template.HTML(sanitized)
1191
+
}
1192
+
}
1193
+
1194
+
c := params.String.Contents
1195
+
formatter := chromahtml.New(
1196
+
chromahtml.InlineCode(false),
1197
+
chromahtml.WithLineNumbers(true),
1198
+
chromahtml.WithLinkableLineNumbers(true, "L"),
1199
+
chromahtml.Standalone(false),
1200
+
chromahtml.WithClasses(true),
1201
+
)
1202
+
1203
+
lexer := lexers.Get(filepath.Base(params.String.Filename))
1204
+
if lexer == nil {
1205
+
lexer = lexers.Fallback
1206
+
}
1207
+
1208
+
iterator, err := lexer.Tokenise(nil, c)
1209
+
if err != nil {
1210
+
return fmt.Errorf("chroma tokenize: %w", err)
1211
+
}
1212
+
1213
+
var code bytes.Buffer
1214
+
err = formatter.Format(&code, style, iterator)
1215
+
if err != nil {
1216
+
return fmt.Errorf("chroma format: %w", err)
1217
+
}
1218
+
1219
+
params.String.Contents = code.String()
1220
+
return p.execute("strings/string", w, params)
1012
1221
}
1013
1222
1014
1223
func (p *Pages) Static() http.Handler {
+26
appview/pages/templates/favicon.html
+26
appview/pages/templates/favicon.html
···
1
+
{{ define "favicon" }}
2
+
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
3
+
<style>
4
+
.favicon-text {
5
+
fill: #000000;
6
+
stroke: none;
7
+
}
8
+
9
+
@media (prefers-color-scheme: dark) {
10
+
.favicon-text {
11
+
fill: #ffffff;
12
+
stroke: none;
13
+
}
14
+
}
15
+
</style>
16
+
17
+
<g style="display:inline">
18
+
<path d="M0-2.117h62.177v25.135H0z" style="display:inline;fill:none;fill-opacity:1;stroke-width:.396875" transform="translate(11.01 6.9)"/>
19
+
<path d="M3.64 22.787c-1.697 0-2.943-.45-3.74-1.35-.77-.9-1.156-2.094-1.156-3.585 0-.36.013-.72.038-1.08.052-.385.129-.873.232-1.464L.44 6.826h-5.089l.733-4.394h3.2c.822 0 1.439-.168 1.85-.502.437-.334.72-.938.848-1.812l.771-4.703h5.243L6.84 2.432h7.787l-.733 4.394H6.107L4.257 17.93l.77.27 6.015-4.742 2.775 3.161-2.313 2.005c-.822.694-1.568 1.31-2.236 1.85-.668.515-1.31.952-1.927 1.311a7.406 7.406 0 0 1-1.774.733c-.59.18-1.233.27-1.927.27z"
20
+
aria-label="tangled.sh"
21
+
class="favicon-text"
22
+
style="font-size:16.2278px;font-family:'IBM Plex Mono';-inkscape-font-specification:'IBM Plex Mono, Normal';display:inline;fill-opacity:1"
23
+
transform="translate(11.01 6.9)"/>
24
+
</g>
25
+
</svg>
26
+
{{ end }}
-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 }}
+62
appview/pages/templates/knots/dashboard.html
+62
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
+
{{ template "user/fragments/picHandleLink" . }}
42
+
<span class="ml-2 font-mono text-gray-500">{{.}}</span>
43
+
</div>
44
+
</div>
45
+
<div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700">
46
+
{{ $repos := index $.Repos . }}
47
+
{{ range $repos }}
48
+
<div class="flex gap-2 items-center">
49
+
{{ i "book-marked" "size-4" }}
50
+
<a href="/{{ resolve .Did }}/{{ .Name }}">
51
+
{{ .Name }}
52
+
</a>
53
+
</div>
54
+
{{ else }}
55
+
<div class="text-gray-500 dark:text-gray-400">
56
+
No repositories created yet.
57
+
</div>
58
+
{{ end }}
59
+
</div>
60
+
</div>
61
+
{{ end }}
62
+
{{ 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 }}
+25
-11
appview/pages/templates/layouts/base.html
+25
-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
+
<main class="col-span-1 md:col-span-8">
28
+
{{ block "content" . }}{{ end }}
29
+
</main>
30
+
{{ end }}
31
+
32
+
{{ block "contentAfterLayout" . }}
33
+
<main class="col-span-1 md:col-span-8">
34
+
{{ block "contentAfter" . }}{{ end }}
35
+
</main>
36
+
{{ end }}
37
+
</div>
38
+
{{ end }}
39
+
40
+
{{ block "footerLayout" . }}
41
+
<footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12">
42
+
{{ template "layouts/footer" . }}
29
43
</footer>
30
-
</div>
44
+
{{ end }}
31
45
</body>
32
46
</html>
33
47
{{ end }}
+22
-5
appview/pages/templates/layouts/repobase.html
+22
-5
appview/pages/templates/layouts/repobase.html
···
5
5
{{ if .RepoInfo.Source }}
6
6
<p class="text-sm">
7
7
<div class="flex items-center">
8
-
{{ i "git-fork" "w-3 h-3 mr-1"}}
8
+
{{ i "git-fork" "w-3 h-3 mr-1 shrink-0" }}
9
9
forked from
10
10
{{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }}
11
11
<a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a>
···
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
+
<a
24
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
25
+
href="/{{ .RepoInfo.FullName }}/feed.atom"
26
+
>
27
+
{{ i "rss" "size-4" }}
28
+
</a>
29
+
{{ template "repo/fragments/repoStar" .RepoInfo }}
30
+
<a
31
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
32
+
hx-boost="true"
33
+
href="/{{ .RepoInfo.FullName }}/fork"
34
+
>
35
+
{{ i "git-fork" "w-4 h-4" }}
36
+
fork
37
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
38
+
</a>
39
+
</div>
23
40
</div>
24
41
{{ template "repo/fragments/repoDescription" . }}
25
42
</section>
26
43
27
44
<section
28
-
class="min-h-screen w-full flex flex-col drop-shadow-sm"
45
+
class="w-full flex flex-col drop-shadow-sm"
29
46
>
30
47
<nav class="w-full pl-4 overflow-auto">
31
48
<div class="flex z-60">
···
47
64
{{ if eq $.Active $key }}
48
65
{{ $activeTabStyles }}
49
66
{{ else }}
50
-
group-hover:bg-gray-200 dark:group-hover:bg-gray-700
67
+
group-hover:bg-gray-100/25 group-hover:dark:bg-gray-700/25
51
68
{{ end }}
52
69
"
53
70
>
···
64
81
</div>
65
82
</nav>
66
83
<section
67
-
class="bg-white dark:bg-gray-800 p-6 rounded relative w-full drop-shadow-sm dark:text-white"
84
+
class="bg-white dark:bg-gray-800 p-6 rounded relative w-full dark:text-white"
68
85
>
69
86
{{ block "repoContent" . }}{{ end }}
70
87
</section>
+45
-23
appview/pages/templates/layouts/topbar.html
+45
-23
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
-
<a href="/" hx-boost="true" class="flex gap-2 font-semibold italic">
5
+
<a href="/" hx-boost="true" class="flex gap-2 font-bold 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
9
14
-
<a href="https://web.libera.chat/#tangled" class="inline-flex gap-1 items-center">
15
-
{{ i "hash" "size-4" }} irc
16
-
</a>
17
-
18
-
<a href="https://tangled.sh/@tangled.sh/core" class="inline-flex gap-1 items-center">
19
-
{{ i "code" "size-4" }} source
20
-
</a>
21
-
</div>
22
-
<div id="right-items" class="flex items-center gap-4">
10
+
<div id="right-items" class="flex items-center gap-2">
23
11
{{ with .LoggedInUser }}
24
-
<a href="/repo/new" hx-boost="true" class="btn-create hover:no-underline hover:text-white">
25
-
{{ i "plus" "w-4 h-4" }}
26
-
</a>
12
+
{{ block "newButton" . }} {{ end }}
27
13
{{ block "dropDown" . }} {{ end }}
28
14
{{ else }}
29
15
<a href="/login">login</a>
16
+
<span class="text-gray-500 dark:text-gray-400">or</span>
17
+
<a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2">
18
+
join now {{ i "arrow-right" "size-4" }}
19
+
</a>
30
20
{{ end }}
31
21
</div>
32
22
</div>
33
23
</nav>
34
24
{{ end }}
35
25
26
+
{{ define "newButton" }}
27
+
<details class="relative inline-block text-left nav-dropdown">
28
+
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
29
+
{{ i "plus" "w-4 h-4" }} new
30
+
</summary>
31
+
<div 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">
32
+
<a href="/repo/new" class="flex items-center gap-2">
33
+
{{ i "book-plus" "w-4 h-4" }}
34
+
new repository
35
+
</a>
36
+
<a href="/strings/new" class="flex items-center gap-2">
37
+
{{ i "line-squiggle" "w-4 h-4" }}
38
+
new string
39
+
</a>
40
+
</div>
41
+
</details>
42
+
{{ end }}
43
+
36
44
{{ define "dropDown" }}
37
-
<details class="relative inline-block text-left">
45
+
<details class="relative inline-block text-left nav-dropdown">
38
46
<summary
39
-
class="cursor-pointer list-none"
47
+
class="cursor-pointer list-none flex items-center"
40
48
>
41
-
{{ didOrHandle .Did .Handle }}
49
+
{{ $user := didOrHandle .Did .Handle }}
50
+
{{ template "user/fragments/picHandle" $user }}
42
51
</summary>
43
52
<div
44
53
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
54
>
46
-
<a href="/{{ didOrHandle .Did .Handle }}">profile</a>
55
+
<a href="/{{ $user }}">profile</a>
56
+
<a href="/{{ $user }}?tab=repos">repositories</a>
57
+
<a href="/strings/{{ $user }}">strings</a>
47
58
<a href="/knots">knots</a>
48
59
<a href="/spindles">spindles</a>
49
60
<a href="/settings">settings</a>
···
55
66
</a>
56
67
</div>
57
68
</details>
69
+
70
+
<script>
71
+
document.addEventListener('click', function(event) {
72
+
const dropdowns = document.querySelectorAll('.nav-dropdown');
73
+
dropdowns.forEach(function(dropdown) {
74
+
if (!dropdown.contains(event.target)) {
75
+
dropdown.removeAttribute('open');
76
+
}
77
+
});
78
+
});
79
+
</script>
58
80
{{ end }}
+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 overflow-y-auto">
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 overflow-y-auto">
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 }}
+18
-8
appview/pages/templates/repo/empty.html
+18
-8
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
+
36
+
<p><span class="{{$bullet}}">1</span>First, generate a new <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key" class="underline">SSH key pair</a>.</p>
37
+
<p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p>
38
+
<p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p>
39
+
<p><span class="{{$bullet}}">4</span>Push!</p>
40
+
</div>
41
+
</div>
26
42
{{ 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>
43
+
<p class="text-gray-400 dark:text-gray-500 py-6 text-center">This is an empty repository.</p>
30
44
{{ end }}
31
45
</main>
32
46
{{ end }}
33
-
34
-
{{ define "repoAfter" }}
35
-
{{ template "repo/fragments/cloneInstructions" . }}
36
-
{{ 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>
+104
appview/pages/templates/repo/fragments/cloneDropdown.html
+104
appview/pages/templates/repo/fragments/cloneDropdown.html
···
1
+
{{ define "repo/fragments/cloneDropdown" }}
2
+
{{ $knot := .RepoInfo.Knot }}
3
+
{{ if eq $knot "knot1.tangled.sh" }}
4
+
{{ $knot = "tangled.sh" }}
5
+
{{ end }}
6
+
7
+
<details id="clone-dropdown" class="relative inline-block text-left group">
8
+
<summary class="btn-create cursor-pointer list-none flex items-center gap-2">
9
+
{{ i "download" "w-4 h-4" }}
10
+
<span class="hidden md:inline">code</span>
11
+
<span class="group-open:hidden">
12
+
{{ i "chevron-down" "w-4 h-4" }}
13
+
</span>
14
+
<span class="hidden group-open:flex">
15
+
{{ i "chevron-up" "w-4 h-4" }}
16
+
</span>
17
+
</summary>
18
+
19
+
<div class="absolute right-0 mt-2 w-96 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 drop-shadow-sm dark:text-white z-[9999]">
20
+
<div class="p-4">
21
+
<div class="mb-3">
22
+
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">Clone this repository</h3>
23
+
</div>
24
+
25
+
<!-- HTTPS Clone -->
26
+
<div class="mb-3">
27
+
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">HTTPS</label>
28
+
<div class="flex items-center border border-gray-300 dark:border-gray-600 rounded">
29
+
<code
30
+
class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto"
31
+
onclick="window.getSelection().selectAllChildren(this)"
32
+
data-url="https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}"
33
+
>https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code>
34
+
<button
35
+
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
36
+
class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
37
+
title="Copy to clipboard"
38
+
>
39
+
{{ i "copy" "w-4 h-4" }}
40
+
</button>
41
+
</div>
42
+
</div>
43
+
44
+
<!-- SSH Clone -->
45
+
<div class="mb-3">
46
+
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">SSH</label>
47
+
<div class="flex items-center border border-gray-300 dark:border-gray-600 rounded">
48
+
<code
49
+
class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto"
50
+
onclick="window.getSelection().selectAllChildren(this)"
51
+
data-url="git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}"
52
+
>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
53
+
<button
54
+
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
55
+
class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
56
+
title="Copy to clipboard"
57
+
>
58
+
{{ i "copy" "w-4 h-4" }}
59
+
</button>
60
+
</div>
61
+
</div>
62
+
63
+
<!-- Note for self-hosted -->
64
+
<p class="text-xs text-gray-500 dark:text-gray-400">
65
+
For self-hosted knots, clone URLs may differ based on your setup.
66
+
</p>
67
+
68
+
<!-- Download Archive -->
69
+
<div class="pt-2 mt-2 border-t border-gray-200 dark:border-gray-700">
70
+
<a
71
+
href="/{{ .RepoInfo.FullName }}/archive/{{ .Ref | urlquery }}"
72
+
class="flex items-center gap-2 px-3 py-2 text-sm"
73
+
>
74
+
{{ i "download" "w-4 h-4" }}
75
+
Download tar.gz
76
+
</a>
77
+
</div>
78
+
79
+
</div>
80
+
</div>
81
+
</details>
82
+
83
+
<script>
84
+
function copyToClipboard(button, text) {
85
+
navigator.clipboard.writeText(text).then(() => {
86
+
const originalContent = button.innerHTML;
87
+
button.innerHTML = `{{ i "check" "w-4 h-4" }}`;
88
+
setTimeout(() => {
89
+
button.innerHTML = originalContent;
90
+
}, 2000);
91
+
});
92
+
}
93
+
94
+
// Close clone dropdown when clicking outside
95
+
document.addEventListener('click', function(event) {
96
+
const cloneDropdown = document.getElementById('clone-dropdown');
97
+
if (cloneDropdown && cloneDropdown.hasAttribute('open')) {
98
+
if (!cloneDropdown.contains(event.target)) {
99
+
cloneDropdown.removeAttribute('open');
100
+
}
101
+
}
102
+
});
103
+
</script>
104
+
{{ end }}
-55
appview/pages/templates/repo/fragments/cloneInstructions.html
-55
appview/pages/templates/repo/fragments/cloneInstructions.html
···
1
-
{{ define "repo/fragments/cloneInstructions" }}
2
-
{{ $knot := .RepoInfo.Knot }}
3
-
{{ if eq $knot "knot1.tangled.sh" }}
4
-
{{ $knot = "tangled.sh" }}
5
-
{{ end }}
6
-
<section
7
-
class="mt-4 p-6 rounded drop-shadow-sm bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto flex flex-col gap-4"
8
-
>
9
-
<div class="flex flex-col gap-2">
10
-
<strong>push</strong>
11
-
<div class="md:pl-4 overflow-x-auto whitespace-nowrap">
12
-
<code class="dark:text-gray-100"
13
-
>git remote add origin
14
-
git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code
15
-
>
16
-
</div>
17
-
</div>
18
-
19
-
<div class="flex flex-col gap-2">
20
-
<strong>clone</strong>
21
-
<div class="md:pl-4 flex flex-col gap-2">
22
-
<div class="flex items-center gap-3">
23
-
<span
24
-
class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white"
25
-
>HTTP</span
26
-
>
27
-
<div class="overflow-x-auto whitespace-nowrap flex-1">
28
-
<code class="dark:text-gray-100"
29
-
>git clone
30
-
https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code
31
-
>
32
-
</div>
33
-
</div>
34
-
35
-
<div class="flex items-center gap-3">
36
-
<span
37
-
class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white"
38
-
>SSH</span
39
-
>
40
-
<div class="overflow-x-auto whitespace-nowrap flex-1">
41
-
<code class="dark:text-gray-100"
42
-
>git clone
43
-
git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code
44
-
>
45
-
</div>
46
-
</div>
47
-
</div>
48
-
</div>
49
-
50
-
<p class="py-2 text-gray-500 dark:text-gray-400">
51
-
Note that for self-hosted knots, clone URLs may be different based
52
-
on your setup.
53
-
</p>
54
-
</section>
55
-
{{ end }}
+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" "flex-shrink-0 size-4 fill-current" }}
7
+
<span class="filename truncate 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" "flex-shrink-0 size-4" }}
19
+
<a href="#file-{{ .Path }}" class="filename truncate 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="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 }}
+1
-1
appview/pages/templates/repo/fragments/repoDescription.html
+1
-1
appview/pages/templates/repo/fragments/repoDescription.html
···
1
1
{{ define "repo/fragments/repoDescription" }}
2
2
<span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML">
3
3
{{ if .RepoInfo.Description }}
4
-
{{ .RepoInfo.Description }}
4
+
{{ .RepoInfo.Description | description }}
5
5
{{ else }}
6
6
<span class="italic">this repo has no description</span>
7
7
{{ 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
+
+108
-137
appview/pages/templates/repo/index.html
+108
-137
appview/pages/templates/repo/index.html
···
14
14
{{ end }}
15
15
<div class="flex items-center justify-between pb-5">
16
16
{{ block "branchSelector" . }}{{ end }}
17
-
<div class="flex md:hidden items-center gap-4">
18
-
<a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex items-center text-sm gap-1">
17
+
<div class="flex md:hidden items-center gap-2">
18
+
<a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex items-center text-sm gap-1 font-bold">
19
19
{{ i "git-commit-horizontal" "w-4" "h-4" }} {{ .TotalCommits }}
20
20
</a>
21
-
<a href="/{{ .RepoInfo.FullName }}/branches" class="inline-flex items-center text-sm gap-1">
21
+
<a href="/{{ .RepoInfo.FullName }}/branches" class="inline-flex items-center text-sm gap-1 font-bold">
22
22
{{ i "git-branch" "w-4" "h-4" }} {{ len .Branches }}
23
23
</a>
24
-
<a href="/{{ .RepoInfo.FullName }}/tags" class="inline-flex items-center text-sm gap-1">
24
+
<a href="/{{ .RepoInfo.FullName }}/tags" class="inline-flex items-center text-sm gap-1 font-bold">
25
25
{{ i "tags" "w-4" "h-4" }} {{ len .Tags }}
26
26
</a>
27
+
{{ template "repo/fragments/cloneDropdown" . }}
27
28
</div>
28
29
</div>
29
30
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
···
47
48
48
49
49
50
{{ define "branchSelector" }}
50
-
<div class="flex gap-2 items-center items-stretch justify-center">
51
-
<select
52
-
onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)"
53
-
class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"
54
-
>
55
-
<optgroup label="branches ({{len .Branches}})" class="bold text-sm">
56
-
{{ range .Branches }}
57
-
<option
58
-
value="{{ .Reference.Name }}"
59
-
class="py-1"
60
-
{{ if eq .Reference.Name $.Ref }}
61
-
selected
62
-
{{ end }}
63
-
>
64
-
{{ .Reference.Name }}
65
-
</option>
66
-
{{ end }}
67
-
</optgroup>
68
-
<optgroup label="tags ({{len .Tags}})" class="bold text-sm">
69
-
{{ range .Tags }}
70
-
<option
71
-
value="{{ .Reference.Name }}"
72
-
class="py-1"
73
-
{{ if eq .Reference.Name $.Ref }}
74
-
selected
75
-
{{ end }}
76
-
>
77
-
{{ .Reference.Name }}
78
-
</option>
79
-
{{ else }}
80
-
<option class="py-1" disabled>no tags found</option>
81
-
{{ end }}
82
-
</optgroup>
83
-
</select>
84
-
<div class="flex items-center gap-2">
51
+
<div class="flex gap-2 items-center justify-between w-full">
52
+
<div class="flex gap-2 items-center">
53
+
<select
54
+
onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)"
55
+
class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"
56
+
>
57
+
<optgroup label="branches ({{len .Branches}})" class="bold text-sm">
58
+
{{ range .Branches }}
59
+
<option
60
+
value="{{ .Reference.Name }}"
61
+
class="py-1"
62
+
{{ if eq .Reference.Name $.Ref }}
63
+
selected
64
+
{{ end }}
65
+
>
66
+
{{ .Reference.Name }}
67
+
</option>
68
+
{{ end }}
69
+
</optgroup>
70
+
<optgroup label="tags ({{len .Tags}})" class="bold text-sm">
71
+
{{ range .Tags }}
72
+
<option
73
+
value="{{ .Reference.Name }}"
74
+
class="py-1"
75
+
{{ if eq .Reference.Name $.Ref }}
76
+
selected
77
+
{{ end }}
78
+
>
79
+
{{ .Reference.Name }}
80
+
</option>
81
+
{{ else }}
82
+
<option class="py-1" disabled>no tags found</option>
83
+
{{ end }}
84
+
</optgroup>
85
+
</select>
86
+
<div class="flex items-center gap-2">
85
87
{{ $isOwner := and .LoggedInUser .RepoInfo.Roles.IsOwner }}
86
88
{{ $isCollaborator := and .LoggedInUser .RepoInfo.Roles.IsCollaborator }}
87
89
{{ if and (or $isOwner $isCollaborator) .ForkInfo .ForkInfo.IsFork }}
···
115
117
<span>sync</span>
116
118
</button>
117
119
{{ end }}
118
-
<a
119
-
href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}"
120
-
class="btn flex items-center gap-2 no-underline hover:no-underline"
121
-
title="Compare branches or tags"
122
-
>
123
-
{{ i "git-compare" "w-4 h-4" }}
124
-
</a>
120
+
<a
121
+
href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}"
122
+
class="btn flex items-center gap-2 no-underline hover:no-underline"
123
+
title="Compare branches or tags"
124
+
>
125
+
{{ i "git-compare" "w-4 h-4" }}
126
+
</a>
127
+
</div>
128
+
</div>
129
+
130
+
<!-- Clone dropdown in top right -->
131
+
<div class="hidden md:flex items-center ">
132
+
{{ template "repo/fragments/cloneDropdown" . }}
125
133
</div>
126
-
</div>
134
+
</div>
127
135
{{ end }}
128
136
129
137
{{ 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" }}
138
+
<div id="file-tree" class="col-span-1 pr-2 md:border-r md:border-gray-200 dark:md:border-gray-700" >
139
+
{{ $linkstyle := "no-underline hover:underline dark:text-white" }}
136
140
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>
141
+
{{ range .Files }}
142
+
<div class="grid grid-cols-3 gap-4 items-center py-1">
143
+
<div class="col-span-2">
144
+
{{ $link := printf "/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) .Name }}
145
+
{{ $icon := "folder" }}
146
+
{{ $iconStyle := "size-4 fill-current" }}
150
147
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 }}
148
+
{{ if .IsFile }}
149
+
{{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }}
150
+
{{ $icon = "file" }}
151
+
{{ $iconStyle = "size-4" }}
152
+
{{ end }}
153
+
<a href="{{ $link }}" class="{{ $linkstyle }}">
154
+
<div class="flex items-center gap-2">
155
+
{{ i $icon $iconStyle "flex-shrink-0" }}
156
+
<span class="truncate">{{ .Name }}</span>
157
+
</div>
158
+
</a>
159
+
</div>
160
160
161
-
{{ range .Files }}
162
-
{{ if .IsFile }}
163
-
<div class="{{ $containerstyle }}">
164
-
<div class="flex justify-between items-center">
165
-
<a
166
-
href="/{{ $.RepoInfo.FullName }}/blob/{{ $.Ref | urlquery }}/{{ .Name }}"
167
-
class="{{ $linkstyle }}"
168
-
>
169
-
<div class="flex items-center gap-2">
170
-
{{ i "file" "size-4" }}{{ .Name }}
171
-
</div>
172
-
</a>
173
-
174
-
{{ if .LastCommit }}
175
-
<time class="text-xs text-gray-500 dark:text-gray-400"
176
-
>{{ timeFmt .LastCommit.When }}</time
177
-
>
178
-
{{ end }}
179
-
</div>
180
-
</div>
181
-
{{ end }}
182
-
{{ end }}
183
-
</div>
161
+
<div class="text-sm col-span-1 text-right">
162
+
{{ with .LastCommit }}
163
+
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a>
164
+
{{ end }}
165
+
</div>
166
+
</div>
167
+
{{ end }}
168
+
</div>
184
169
{{ end }}
185
170
186
171
{{ define "rightInfo" }}
···
194
179
{{ define "commitLog" }}
195
180
<div id="commit-log" class="md:col-span-1 px-2 pb-4">
196
181
<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>
182
+
<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">
183
+
{{ i "logs" "w-4 h-4" }} commits
184
+
<span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ .TotalCommits }}</span>
204
185
</a>
205
186
</div>
206
187
<div class="flex flex-col gap-6">
···
238
219
</div>
239
220
240
221
<!-- commit info bar -->
241
-
<div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center">
222
+
<div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center flex-wrap">
242
223
{{ $verified := $.VerifiedCommits.IsVerified .Hash.String }}
243
224
{{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }}
244
225
{{ if $verified }}
···
266
247
{{ end }}"
267
248
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
268
249
>{{ if $didOrHandle }}
269
-
{{ $didOrHandle }}
250
+
{{ template "user/fragments/picHandleLink" $didOrHandle }}
270
251
{{ else }}
271
252
{{ .Author.Name }}
272
253
{{ end }}</a
273
254
>
274
255
</span>
275
256
<div class="inline-block px-1 select-none after:content-['ยท']"></div>
276
-
<span>{{ timeFmt .Committer.When }}</span>
257
+
{{ template "repo/fragments/time" .Committer.When }}
277
258
278
259
<!-- tags/branches -->
279
260
{{ $tagsForCommit := index $.TagMap .Hash.String }}
···
302
283
{{ define "branchList" }}
303
284
{{ if gt (len .BranchesTrunc) 0 }}
304
285
<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>
286
+
<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">
287
+
{{ i "git-branch" "w-4 h-4" }} branches
288
+
<span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Branches }}</span>
312
289
</a>
313
290
<div class="flex flex-col gap-1">
314
291
{{ range .BranchesTrunc }}
315
-
<div class="text-base flex items-center justify-between">
316
-
<div class="flex items-center gap-2">
292
+
<div class="text-base flex items-center justify-between overflow-hidden">
293
+
<div class="flex items-center gap-2 min-w-0 flex-1">
317
294
<a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Reference.Name | urlquery }}"
318
-
class="inline no-underline hover:underline dark:text-white">
295
+
class="inline-block truncate no-underline hover:underline dark:text-white">
319
296
{{ .Reference.Name }}
320
297
</a>
321
298
{{ if .Commit }}
322
-
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>
323
-
<time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .Commit.Committer.When }}</time>
299
+
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท'] shrink-0"></span>
300
+
<span class="whitespace-nowrap text-xs text-gray-500 dark:text-gray-400 shrink-0">{{ template "repo/fragments/time" .Commit.Committer.When }}</span>
324
301
{{ end }}
325
302
{{ if .IsDefault }}
326
-
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>
327
-
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-xs font-mono">default</span>
303
+
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท'] shrink-0"></span>
304
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-xs font-mono shrink-0">default</span>
328
305
{{ end }}
329
306
</div>
330
307
{{ if ne $.Ref .Reference.Name }}
331
308
<a href="/{{ $.RepoInfo.FullName }}/compare/{{ $.Ref | urlquery }}...{{ .Reference.Name | urlquery }}"
332
-
class="text-xs flex gap-2 items-center"
309
+
class="text-xs flex gap-2 items-center shrink-0 ml-2"
333
310
title="Compare branches or tags">
334
311
{{ i "git-compare" "w-3 h-3" }} compare
335
312
</a>
336
-
{{end}}
313
+
{{ end }}
337
314
</div>
338
315
{{ end }}
339
316
</div>
···
345
322
{{ if gt (len .TagsTrunc) 0 }}
346
323
<div id="tags" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700">
347
324
<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>
325
+
<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">
326
+
{{ i "tags" "w-4 h-4" }} tags
327
+
<span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Tags }}</span>
355
328
</a>
356
329
</div>
357
330
<div class="flex flex-col gap-1">
···
366
339
</div>
367
340
<div>
368
341
{{ with .Tag }}
369
-
<time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .Tagger.When }}</time>
342
+
<span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .Tagger.When }}</span>
370
343
{{ end }}
371
344
{{ if eq $idx 0 }}
372
345
{{ with .Tag }}<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>{{ end }}
···
382
355
{{ end }}
383
356
384
357
{{ define "repoAfter" }}
385
-
{{- if .HTMLReadme -}}
358
+
{{- if or .HTMLReadme .Readme -}}
386
359
<section
387
360
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
361
prose dark:prose-invert dark:[&_pre]:bg-gray-900
···
390
363
dark:[&_pre]:border dark:[&_pre]:border-gray-700
391
364
{{ end }}"
392
365
>
393
-
<article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-scroll">
394
-
{{- .HTMLReadme -}}
366
+
<article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto">
367
+
{{- .Readme -}}
395
368
</pre>
396
369
{{- else -}}
397
370
{{ .HTMLReadme }}
398
371
{{- end -}}</article>
399
372
</section>
400
373
{{- end -}}
401
-
402
-
{{ template "repo/fragments/cloneInstructions" . }}
403
374
{{ end }}
+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
-17
appview/pages/templates/repo/issues/fragments/issueComment.html
+15
-17
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">
5
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
6
-
<a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a>
4
+
<div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap">
5
+
{{ template "user/fragments/picHandleLink" .OwnerDid }}
6
+
7
+
<!-- show user "hats" -->
8
+
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
9
+
{{ if $isIssueAuthor }}
10
+
<span class="before:content-['ยท']"></span>
11
+
author
12
+
{{ end }}
7
13
8
14
<span class="before:content-['ยท']"></span>
9
15
<a
···
11
17
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
12
18
id="{{ .CommentId }}">
13
19
{{ if .Deleted }}
14
-
deleted {{ .Deleted | timeFmt }}
20
+
deleted {{ template "repo/fragments/time" .Deleted }}
15
21
{{ else if .Edited }}
16
-
edited {{ .Edited | timeFmt }}
22
+
edited {{ template "repo/fragments/time" .Edited }}
17
23
{{ else }}
18
-
{{ .Created | timeFmt }}
24
+
{{ template "repo/fragments/time" .Created }}
19
25
{{ end }}
20
26
</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
27
30
28
{{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }}
31
29
{{ if and $isCommentOwner (not .Deleted) }}
32
-
<button
33
-
class="btn px-2 py-1 text-sm"
30
+
<button
31
+
class="btn px-2 py-1 text-sm"
34
32
hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
35
33
hx-swap="outerHTML"
36
34
hx-target="#comment-container-{{.CommentId}}"
37
35
>
38
36
{{ i "pencil" "w-4 h-4" }}
39
37
</button>
40
-
<button
38
+
<button
41
39
class="btn px-2 py-1 text-sm text-red-500 flex gap-2 items-center group"
42
40
hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/"
43
41
hx-confirm="Are you sure you want to delete your comment?"
+19
-7
appview/pages/templates/repo/issues/issue.html
+19
-7
appview/pages/templates/repo/issues/issue.html
···
11
11
{{ define "repoContent" }}
12
12
<header class="pb-4">
13
13
<h1 class="text-2xl">
14
-
{{ .Issue.Title }}
14
+
{{ .Issue.Title | description }}
15
15
<span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span>
16
16
</h1>
17
17
</header>
···
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.AtUri)
58
+
}}
59
+
{{ end }}
60
+
</div>
49
61
</section>
50
62
{{ end }}
51
63
···
58
70
{{ if gt $index 0 }}
59
71
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
60
72
{{ end }}
61
-
{{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "DidHandleMap" $.DidHandleMap "Issue" $.Issue "Comment" .)}}
73
+
{{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "Issue" $.Issue "Comment" .)}}
62
74
</div>
63
75
{{ end }}
64
76
</section>
···
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"
+3
-6
appview/pages/templates/repo/issues/issues.html
+3
-6
appview/pages/templates/repo/issues/issues.html
···
45
45
href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}"
46
46
class="no-underline hover:underline"
47
47
>
48
-
{{ .Title }}
48
+
{{ .Title | description }}
49
49
<span class="text-gray-500">#{{ .IssueId }}</span>
50
50
</a>
51
51
</div>
···
65
65
</span>
66
66
67
67
<span class="ml-1">
68
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
69
-
{{ template "user/fragments/picHandle" $owner }}
68
+
{{ template "user/fragments/picHandleLink" .OwnerDid }}
70
69
</span>
71
70
72
71
<span class="before:content-['ยท']">
73
-
<time>
74
-
{{ .Created | timeFmt }}
75
-
</time>
72
+
{{ template "repo/fragments/time" .Created }}
76
73
</span>
77
74
78
75
<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 }}
+2
-2
appview/pages/templates/repo/pipelines/fragments/pipelineSymbol.html
+2
-2
appview/pages/templates/repo/pipelines/fragments/pipelineSymbol.html
···
23
23
</div>
24
24
{{ else if $allFail }}
25
25
<div class="flex gap-1 items-center">
26
-
{{ i "x" "size-4 text-red-600" }}
26
+
{{ i "x" "size-4 text-red-500" }}
27
27
<span>0/{{ $total }}</span>
28
28
</div>
29
29
{{ else if $allTimeout }}
30
30
<div class="flex gap-1 items-center">
31
-
{{ i "clock-alert" "size-4 text-orange-400" }}
31
+
{{ i "clock-alert" "size-4 text-orange-500" }}
32
32
<span>0/{{ $total }}</span>
33
33
</div>
34
34
{{ else }}
+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
-1
appview/pages/templates/repo/pipelines/fragments/workflowSymbol.html
+1
-1
appview/pages/templates/repo/pipelines/fragments/workflowSymbol.html
···
19
19
{{ $color = "text-gray-600 dark:text-gray-500" }}
20
20
{{ else if eq $kind "timeout" }}
21
21
{{ $icon = "clock-alert" }}
22
-
{{ $color = "text-orange-400 dark:text-orange-300" }}
22
+
{{ $color = "text-orange-400 dark:text-orange-500" }}
23
23
{{ else }}
24
24
{{ $icon = "x" }}
25
25
{{ $color = "text-red-600 dark:text-red-500" }}
+1
-3
appview/pages/templates/repo/pipelines/pipelines.html
+1
-3
appview/pages/templates/repo/pipelines/pipelines.html
+10
-14
appview/pages/templates/repo/pipelines/workflow.html
+10
-14
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 }}
22
+
23
+
{{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }}
24
+
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }}
25
+
25
26
{{ with .Pipeline }}
26
27
{{ $id := .Id }}
27
28
<div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700">
28
29
{{ range $name, $all := .Statuses }}
29
30
<a href="/{{ $.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
30
31
<div
31
-
class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }}bg-gray-100/50 dark:bg-gray-700/50{{ end }}">
32
+
class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}">
32
33
{{ $lastStatus := $all.Latest }}
33
34
{{ $kind := $lastStatus.Status.String }}
34
35
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
36
<div id="left" class="flex items-center gap-2 flex-shrink-0">
45
37
{{ template "repo/pipelines/fragments/workflowSymbol" $all }}
46
38
{{ $name }}
47
39
</div>
48
40
<div id="right" class="flex items-center gap-2 flex-shrink-0">
49
41
<span class="font-bold">{{ $kind }}</span>
50
-
<time>{{ $time }}</time>
42
+
{{ if .TimeTaken }}
43
+
{{ template "repo/fragments/duration" .TimeTaken }}
44
+
{{ else }}
45
+
{{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }}
46
+
{{ end }}
51
47
</div>
52
48
</div>
53
49
</a>
+20
-4
appview/pages/templates/repo/pulls/fragments/pullHeader.html
+20
-4
appview/pages/templates/repo/pulls/fragments/pullHeader.html
···
1
1
{{ define "repo/pulls/fragments/pullHeader" }}
2
2
<header class="pb-4">
3
3
<h1 class="text-2xl dark:text-white">
4
-
{{ .Pull.Title }}
4
+
{{ .Pull.Title | description }}
5
5
<span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span>
6
6
</h1>
7
7
</header>
···
17
17
{{ $icon = "git-merge" }}
18
18
{{ end }}
19
19
20
+
{{ $owner := resolve .Pull.OwnerDid }}
20
21
<section class="mt-2">
21
22
<div class="flex items-center gap-2">
22
23
<div
···
28
29
</div>
29
30
<span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1">
30
31
opened by
31
-
{{ $owner := index $.DidHandleMap .Pull.OwnerDid }}
32
-
{{ template "user/fragments/picHandle" $owner }}
32
+
{{ template "user/fragments/picHandleLink" .Pull.OwnerDid }}
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
+7
-9
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
+7
-9
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
···
9
9
</div>
10
10
<span class="truncate text-sm text-gray-800 dark:text-gray-200">
11
11
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
12
-
{{ .Title }}
12
+
{{ .Title | description }}
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 overflow-y-auto">
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 overflow-y-auto">
77
+
{{ template "repo/fragments/diffChangedFiles" .Diff }}
78
+
</div>
79
+
{{end}}
+15
-22
appview/pages/templates/repo/pulls/pull.html
+15
-22
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>
50
-
{{ $owner := index $.DidHandleMap $.Pull.OwnerDid }}
49
+
<span class="gap-1 flex items-center">
50
+
{{ $owner := resolve $.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" $.Pull.OwnerDid }}
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>
···
122
122
{{ end }}
123
123
</div>
124
124
<div class="flex items-center">
125
-
<span>{{ .Title }}</span>
125
+
<span>{{ .Title | description }}</span>
126
126
{{ if gt (len .Body) 0 }}
127
127
<button
128
128
class="py-1/2 px-1 mx-2 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600"
···
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
+
{{ template "user/fragments/picHandleLink" $c.OwnerDid }}
156
155
<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>
156
+
<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
157
</div>
159
158
<div class="prose dark:prose-invert">
160
159
{{ $c.Body | markdown }}
···
179
178
{{ end }}
180
179
</div>
181
180
</details>
182
-
<hr class="md:hidden border-t border-gray-300 dark:border-gray-600"/>
183
181
{{ end }}
184
182
{{ end }}
185
183
{{ end }}
···
277
275
{{ $lastStatus := $all.Latest }}
278
276
{{ $kind := $lastStatus.Status.String }}
279
277
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
278
<div id="left" class="flex items-center gap-2 flex-shrink-0">
290
279
{{ template "repo/pipelines/fragments/workflowSymbol" $all }}
291
280
{{ $name }}
292
281
</div>
293
282
<div id="right" class="flex items-center gap-2 flex-shrink-0">
294
283
<span class="font-bold">{{ $kind }}</span>
295
-
<time>{{ $time }}</time>
284
+
{{ if .TimeTaken }}
285
+
{{ template "repo/fragments/duration" .TimeTaken }}
286
+
{{ else }}
287
+
{{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }}
288
+
{{ end }}
296
289
</div>
297
290
</div>
298
291
</a>
+47
-59
appview/pages/templates/repo/pulls/pulls.html
+47
-59
appview/pages/templates/repo/pulls/pulls.html
···
50
50
<div class="px-6 py-4 z-5">
51
51
<div class="pb-2">
52
52
<a href="/{{ $.RepoInfo.FullName }}/pulls/{{ .PullId }}" class="dark:text-white">
53
-
{{ .Title }}
53
+
{{ .Title | description }}
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">
58
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
57
+
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
59
58
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
60
59
{{ $icon := "ban" }}
61
60
···
76
75
</span>
77
76
78
77
<span class="ml-1">
79
-
{{ template "user/fragments/picHandle" $owner }}
78
+
{{ template "user/fragments/picHandleLink" .OwnerDid }}
80
79
</span>
81
80
82
-
<span>
83
-
<time>
84
-
{{ .Created | timeFmt }}
85
-
</time>
81
+
<span class="before:content-['ยท']">
82
+
{{ template "repo/fragments/time" .Created }}
86
83
</span>
87
84
85
+
86
+
{{ $latestRound := .LastRoundNumber }}
87
+
{{ $lastSubmission := index .Submissions $latestRound }}
88
+
88
89
<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>
90
+
{{ $commentCount := len $lastSubmission.Comments }}
91
+
{{ $s := "s" }}
92
+
{{ if eq $commentCount 1 }}
93
+
{{ $s = "" }}
94
+
{{ end }}
95
+
96
+
{{ len $lastSubmission.Comments}} comment{{$s}}
93
97
</span>
94
-
{{ if not .IsPatchBased }}
95
-
from
96
-
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
97
-
{{ if .IsForkBased }}
98
-
{{ if .PullSource.Repo }}
99
-
<a href="/{{ $owner }}/{{ .PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .PullSource.Repo.Name }}</a>:
100
-
{{- else -}}
101
-
<span class="italic">[deleted fork]</span>
102
-
{{- end -}}
103
-
{{- end -}}
104
-
{{- .PullSource.Branch -}}
105
-
</span>
106
-
{{ end }}
107
-
<span class="before:content-['ยท']">
108
-
{{ $latestRound := .LastRoundNumber }}
109
-
{{ $lastSubmission := index .Submissions $latestRound }}
110
-
round
111
-
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
112
-
#{{ .LastRoundNumber }}
113
-
</span>
114
-
{{ $commentCount := len $lastSubmission.Comments }}
115
-
{{ $s := "s" }}
116
-
{{ if eq $commentCount 1 }}
117
-
{{ $s = "" }}
118
-
{{ end }}
119
98
120
-
{{ if eq $commentCount 0 }}
121
-
awaiting comments
122
-
{{ else }}
123
-
recieved {{ len $lastSubmission.Comments}} comment{{$s}}
124
-
{{ end }}
99
+
<span class="before:content-['ยท']">
100
+
round
101
+
<span class="font-mono">
102
+
#{{ .LastRoundNumber }}
103
+
</span>
125
104
</span>
126
-
</p>
105
+
106
+
{{ $pipeline := index $.Pipelines .LatestSha }}
107
+
{{ if and $pipeline $pipeline.Id }}
108
+
<span class="before:content-['ยท']"></span>
109
+
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
110
+
{{ end }}
111
+
</div>
127
112
</div>
128
113
{{ if .StackId }}
129
114
{{ $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>
115
+
{{ if gt (len $otherPulls) 0 }}
116
+
<details class="bg-white dark:bg-gray-800 group">
117
+
<summary class="pb-4 px-6 text-xs list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
118
+
{{ $s := "s" }}
119
+
{{ if eq (len $otherPulls) 1 }}
120
+
{{ $s = "" }}
121
+
{{ end }}
122
+
<div class="group-open:hidden flex items-center gap-2">
123
+
{{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $otherPulls }} pull{{$s}} in this stack
124
+
</div>
125
+
<div class="hidden group-open:flex items-center gap-2">
126
+
{{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack
127
+
</div>
128
+
</summary>
129
+
{{ block "pullList" (list $otherPulls $) }} {{ end }}
130
+
</details>
131
+
{{ end }}
145
132
{{ end }}
146
133
</div>
147
134
{{ end }}
···
153
140
{{ $root := index . 1 }}
154
141
<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
142
{{ range $pull := $list }}
143
+
{{ $pipeline := index $root.Pipelines $pull.LatestSha }}
156
144
<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
145
<div class="flex gap-2 items-center px-6">
158
146
<div class="flex-grow min-w-0 w-full py-2">
159
-
{{ template "repo/pulls/fragments/summarizedHeader" (list $pull 0) }}
147
+
{{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }}
160
148
</div>
161
149
</div>
162
150
</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 }}
+145
appview/pages/templates/repo/settings/pipelines.html
+145
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
+
{{/* For some reason, we can't use an empty string in a <select> in all scenarios unless it is preceded by a disabled select?? No idea, could just be a Firefox thing? */}}
42
+
<option value="[[none]]" class="py-1" {{ if not $.CurrentSpindle }}selected{{ end }}>
43
+
{{ if not $.CurrentSpindle }}
44
+
Choose a spindle
45
+
{{ else }}
46
+
Disable pipelines
47
+
{{ end }}
48
+
</option>
49
+
{{ range $.Spindles }}
50
+
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
51
+
{{ . }}
52
+
</option>
53
+
{{ end }}
54
+
</select>
55
+
<button class="btn flex gap-2 items-center" type="submit" {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}>
56
+
{{ i "check" "size-4" }}
57
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
58
+
</button>
59
+
</form>
60
+
{{ end }}
61
+
</div>
62
+
{{ end }}
63
+
64
+
{{ define "secretSettings" }}
65
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
66
+
<div class="col-span-1 md:col-span-2">
67
+
<h2 class="text-sm pb-2 uppercase font-bold">SECRETS</h2>
68
+
<p class="text-gray-500 dark:text-gray-400">
69
+
Secrets are accessible in workflow runs via environment variables. Anyone
70
+
with collaborator access to this repository can add and use secrets in
71
+
workflow runs.
72
+
</p>
73
+
</div>
74
+
<div class="col-span-1 md:col-span-1 md:justify-self-end">
75
+
{{ template "addSecretButton" . }}
76
+
</div>
77
+
</div>
78
+
<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">
79
+
{{ range .Secrets }}
80
+
{{ template "repo/settings/fragments/secretListing" (list $ .) }}
81
+
{{ else }}
82
+
<div class="flex items-center justify-center p-2 text-gray-500">
83
+
no secrets added yet
84
+
</div>
85
+
{{ end }}
86
+
</div>
87
+
{{ end }}
88
+
89
+
{{ define "addSecretButton" }}
90
+
<button
91
+
class="btn flex items-center gap-2"
92
+
popovertarget="add-secret-modal"
93
+
popovertargetaction="toggle">
94
+
{{ i "plus" "size-4" }}
95
+
add secret
96
+
</button>
97
+
<div
98
+
id="add-secret-modal"
99
+
popover
100
+
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">
101
+
{{ template "addSecretModal" . }}
102
+
</div>
103
+
{{ end}}
104
+
105
+
{{ define "addSecretModal" }}
106
+
<form
107
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets"
108
+
hx-indicator="#spinner"
109
+
hx-swap="none"
110
+
class="flex flex-col gap-2"
111
+
>
112
+
<p class="uppercase p-0">ADD SECRET</p>
113
+
<p class="text-sm text-gray-500 dark:text-gray-400">Secrets are available as environment variables in the workflow.</p>
114
+
<input
115
+
type="text"
116
+
id="secret-key"
117
+
name="key"
118
+
required
119
+
placeholder="SECRET_NAME"
120
+
/>
121
+
<textarea
122
+
type="text"
123
+
id="secret-value"
124
+
name="value"
125
+
required
126
+
placeholder="secret value"></textarea>
127
+
<div class="flex gap-2 pt-2">
128
+
<button
129
+
type="button"
130
+
popovertarget="add-secret-modal"
131
+
popovertargetaction="hide"
132
+
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"
133
+
>
134
+
{{ i "x" "size-4" }} cancel
135
+
</button>
136
+
<button type="submit" class="btn w-1/2 flex items-center">
137
+
<span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span>
138
+
<span id="spinner" class="group">
139
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
140
+
</span>
141
+
</button>
142
+
</div>
143
+
<div id="add-secret-error" class="text-red-500 dark:text-red-400"></div>
144
+
</form>
145
+
{{ end }}
-138
appview/pages/templates/repo/settings.html
-138
appview/pages/templates/repo/settings.html
···
1
-
{{ define "title" }}settings · {{ .RepoInfo.FullName }}{{ end }}
2
-
{{ define "repoContent" }}
3
-
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
4
-
Collaborators
5
-
</header>
6
-
7
-
<div id="collaborator-list" class="flex flex-col gap-2 mb-2">
8
-
{{ range .Collaborators }}
9
-
<div id="collaborator" class="mb-2">
10
-
<a
11
-
href="/{{ didOrHandle .Did .Handle }}"
12
-
class="no-underline hover:underline text-black dark:text-white"
13
-
>
14
-
{{ didOrHandle .Did .Handle }}
15
-
</a>
16
-
<div>
17
-
<span class="text-sm text-gray-500 dark:text-gray-400">
18
-
{{ .Role }}
19
-
</span>
20
-
</div>
21
-
</div>
22
-
{{ end }}
23
-
</div>
24
-
25
-
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
26
-
<form
27
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"
28
-
class="group"
29
-
>
30
-
<label for="collaborator" class="dark:text-white">
31
-
add collaborator
32
-
</label>
33
-
<input
34
-
type="text"
35
-
id="collaborator"
36
-
name="collaborator"
37
-
required
38
-
class="dark:bg-gray-700 dark:text-white"
39
-
placeholder="enter did or handle"
40
-
>
41
-
<button
42
-
class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700"
43
-
type="text"
44
-
>
45
-
<span>add</span>
46
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
47
-
</button>
48
-
</form>
49
-
{{ end }}
50
-
51
-
<form
52
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default"
53
-
class="mt-6 group"
54
-
>
55
-
<label for="branch">default branch</label>
56
-
<div class="flex gap-2 items-center">
57
-
<select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
58
-
<option
59
-
value=""
60
-
disabled
61
-
selected
62
-
>
63
-
Choose a default branch
64
-
</option>
65
-
{{ range .Branches }}
66
-
<option
67
-
value="{{ .Name }}"
68
-
class="py-1"
69
-
{{ if .IsDefault }}
70
-
selected
71
-
{{ end }}
72
-
>
73
-
{{ .Name }}
74
-
</option>
75
-
{{ end }}
76
-
</select>
77
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
78
-
<span>save</span>
79
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
80
-
</button>
81
-
</div>
82
-
</form>
83
-
84
-
{{ if .RepoInfo.Roles.IsOwner }}
85
-
<form
86
-
hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle"
87
-
class="mt-6 group"
88
-
>
89
-
<label for="spindle">spindle</label>
90
-
<div class="flex gap-2 items-center">
91
-
<select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
92
-
<option
93
-
value=""
94
-
selected
95
-
>
96
-
None
97
-
</option>
98
-
{{ range .Spindles }}
99
-
<option
100
-
value="{{ . }}"
101
-
class="py-1"
102
-
{{ if eq . $.CurrentSpindle }}
103
-
selected
104
-
{{ end }}
105
-
>
106
-
{{ . }}
107
-
</option>
108
-
{{ end }}
109
-
</select>
110
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
111
-
<span>save</span>
112
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
113
-
</button>
114
-
</div>
115
-
</form>
116
-
{{ end }}
117
-
118
-
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
119
-
<form
120
-
hx-confirm="Are you sure you want to delete this repository?"
121
-
hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete"
122
-
class="mt-6"
123
-
hx-indicator="#delete-repo-spinner"
124
-
>
125
-
<label for="branch">delete repository</label>
126
-
<button class="btn my-2 flex items-center" type="text">
127
-
<span>delete</span>
128
-
<span id="delete-repo-spinner" class="group">
129
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
130
-
</span>
131
-
</button>
132
-
<span>
133
-
Deleting a repository is irreversible and permanent.
134
-
</span>
135
-
</form>
136
-
{{ end }}
137
-
138
-
{{ end }}
+29
-30
appview/pages/templates/repo/tree.html
+29
-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-8 md:col-span-4">
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 "flex-shrink-0" }}
69
+
<span class="truncate">{{ .Name }}</span>
70
+
</div>
71
+
</a>
72
+
</div>
73
+
74
+
<div class="col-span-0 md:col-span-6 hidden md:block overflow-hidden">
75
+
{{ with .LastCommit }}
76
+
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400 block truncate">{{ .Message }}</a>
77
+
{{ end }}
68
78
</div>
69
-
</div>
70
-
{{ end }}
71
-
{{ end }}
72
79
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 }}
80
+
<div class="col-span-4 md:col-span-2 text-sm text-right">
81
+
{{ with .LastCommit }}
82
+
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a>
83
+
{{ end }}
85
84
</div>
86
-
</div>
85
+
</div>
87
86
{{ end }}
88
-
{{ end }}
87
+
89
88
</div>
90
89
</main>
91
90
{{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 }}
+2
-4
appview/pages/templates/spindles/dashboard.html
+2
-4
appview/pages/templates/spindles/dashboard.html
···
42
42
<div>
43
43
<div class="flex justify-between items-center">
44
44
<div class="flex items-center gap-2">
45
-
{{ i "user" "size-4" }}
46
-
{{ $user := index $.DidHandleMap . }}
47
-
<a href="/{{ $user }}">{{ $user }}</a>
45
+
{{ template "user/fragments/picHandleLink" . }}
48
46
</div>
49
47
{{ if ne $.LoggedInUser.Did . }}
50
48
{{ block "removeMemberButton" (list $ . ) }} {{ end }}
···
109
107
hx-post="/spindles/{{ $root.Spindle.Instance }}/remove"
110
108
hx-swap="none"
111
109
hx-vals='{"member": "{{$member}}" }'
112
-
hx-confirm="Are you sure you want to remove {{ index $root.DidHandleMap $member }} from this instance?"
110
+
hx-confirm="Are you sure you want to remove {{ resolve $member }} from this instance?"
113
111
>
114
112
{{ i "user-minus" "w-4 h-4" }}
115
113
remove
+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">
+57
appview/pages/templates/strings/dashboard.html
+57
appview/pages/templates/strings/dashboard.html
···
1
+
{{ define "title" }}strings by {{ or .Card.UserHandle .Card.UserDid }}{{ end }}
2
+
3
+
{{ define "extrameta" }}
4
+
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
5
+
<meta property="og:type" content="profile" />
6
+
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" />
7
+
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
+
{{ end }}
9
+
10
+
11
+
{{ define "content" }}
12
+
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
13
+
<div class="md:col-span-3 order-1 md:order-1">
14
+
{{ template "user/fragments/profileCard" .Card }}
15
+
</div>
16
+
<div id="all-strings" class="md:col-span-8 order-2 md:order-2">
17
+
{{ block "allStrings" . }}{{ end }}
18
+
</div>
19
+
</div>
20
+
{{ end }}
21
+
22
+
{{ define "allStrings" }}
23
+
<p class="text-sm font-bold p-2 dark:text-white">ALL STRINGS</p>
24
+
<div id="strings" class="grid grid-cols-1 gap-4 mb-6">
25
+
{{ range .Strings }}
26
+
{{ template "singleString" (list $ .) }}
27
+
{{ else }}
28
+
<p class="px-6 dark:text-white">This user does not have any strings yet.</p>
29
+
{{ end }}
30
+
</div>
31
+
{{ end }}
32
+
33
+
{{ define "singleString" }}
34
+
{{ $root := index . 0 }}
35
+
{{ $s := index . 1 }}
36
+
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
37
+
<div class="font-medium dark:text-white flex gap-2 items-center">
38
+
<a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a>
39
+
</div>
40
+
{{ with $s.Description }}
41
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
42
+
{{ . }}
43
+
</div>
44
+
{{ end }}
45
+
46
+
{{ $stat := $s.Stats }}
47
+
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto">
48
+
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
49
+
<span class="select-none [&:before]:content-['ยท']"></span>
50
+
{{ with $s.Edited }}
51
+
<span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span>
52
+
{{ else }}
53
+
{{ template "repo/fragments/shortTimeAgo" $s.Created }}
54
+
{{ end }}
55
+
</div>
56
+
</div>
57
+
{{ end }}
+90
appview/pages/templates/strings/fragments/form.html
+90
appview/pages/templates/strings/fragments/form.html
···
1
+
{{ define "strings/fragments/form" }}
2
+
<form
3
+
{{ if eq .Action "new" }}
4
+
hx-post="/strings/new"
5
+
{{ else }}
6
+
hx-post="/strings/{{.String.Did}}/{{.String.Rkey}}/edit"
7
+
{{ end }}
8
+
hx-indicator="#new-button"
9
+
class="p-6 pb-4 dark:text-white flex flex-col gap-2 bg-white dark:bg-gray-800 drop-shadow-sm rounded"
10
+
hx-swap="none">
11
+
<div class="flex flex-col md:flex-row md:items-center gap-2">
12
+
<input
13
+
type="text"
14
+
id="filename"
15
+
name="filename"
16
+
placeholder="Filename"
17
+
required
18
+
value="{{ .String.Filename }}"
19
+
class="md:max-w-64 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded"
20
+
>
21
+
<input
22
+
type="text"
23
+
id="description"
24
+
name="description"
25
+
value="{{ .String.Description }}"
26
+
placeholder="Description ..."
27
+
class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded"
28
+
>
29
+
</div>
30
+
<textarea
31
+
name="content"
32
+
id="content-textarea"
33
+
wrap="off"
34
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 font-mono"
35
+
rows="20"
36
+
spellcheck="false"
37
+
placeholder="Paste your string here!"
38
+
required>{{ .String.Contents }}</textarea>
39
+
<div class="flex justify-between items-center">
40
+
<div id="content-stats" class="text-sm text-gray-500 dark:text-gray-400">
41
+
<span id="line-count">0 lines</span>
42
+
<span class="select-none px-1 [&:before]:content-['ยท']"></span>
43
+
<span id="byte-count">0 bytes</span>
44
+
</div>
45
+
<div id="actions" class="flex gap-2 items-center">
46
+
{{ if eq .Action "edit" }}
47
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 "
48
+
href="/strings/{{ .String.Did }}/{{ .String.Rkey }}">
49
+
{{ i "x" "size-4" }}
50
+
<span class="hidden md:inline">cancel</span>
51
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
52
+
</a>
53
+
{{ end }}
54
+
<button
55
+
type="submit"
56
+
id="new-button"
57
+
class="w-fit btn-create rounded flex items-center py-0 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group"
58
+
>
59
+
<span class="inline-flex items-center gap-2">
60
+
{{ i "arrow-up" "w-4 h-4" }}
61
+
publish
62
+
</span>
63
+
<span class="pl-2 hidden group-[.htmx-request]:inline">
64
+
{{ i "loader-circle" "w-4 h-4 animate-spin" }}
65
+
</span>
66
+
</button>
67
+
</div>
68
+
</div>
69
+
<script>
70
+
(function() {
71
+
const textarea = document.getElementById('content-textarea');
72
+
const lineCount = document.getElementById('line-count');
73
+
const byteCount = document.getElementById('byte-count');
74
+
function updateStats() {
75
+
const content = textarea.value;
76
+
const lines = content === '' ? 0 : content.split('\n').length;
77
+
const bytes = new TextEncoder().encode(content).length;
78
+
lineCount.textContent = `${lines} line${lines !== 1 ? 's' : ''}`;
79
+
byteCount.textContent = `${bytes} byte${bytes !== 1 ? 's' : ''}`;
80
+
}
81
+
textarea.addEventListener('input', updateStats);
82
+
textarea.addEventListener('paste', () => {
83
+
setTimeout(updateStats, 0);
84
+
});
85
+
updateStats();
86
+
})();
87
+
</script>
88
+
<div id="error" class="error dark:text-red-400"></div>
89
+
</form>
90
+
{{ end }}
+17
appview/pages/templates/strings/put.html
+17
appview/pages/templates/strings/put.html
···
1
+
{{ define "title" }}publish a new string{{ end }}
2
+
3
+
{{ define "topbar" }}
4
+
{{ template "layouts/topbar" $ }}
5
+
{{ end }}
6
+
7
+
{{ define "content" }}
8
+
<div class="px-6 py-2 mb-4">
9
+
{{ if eq .Action "new" }}
10
+
<p class="text-xl font-bold dark:text-white">Create a new string</p>
11
+
<p class="">Store and share code snippets with ease.</p>
12
+
{{ else }}
13
+
<p class="text-xl font-bold dark:text-white">Edit string</p>
14
+
{{ end }}
15
+
</div>
16
+
{{ template "strings/fragments/form" . }}
17
+
{{ end }}
+88
appview/pages/templates/strings/string.html
+88
appview/pages/templates/strings/string.html
···
1
+
{{ define "title" }}{{ .String.Filename }} ยท by {{ didOrHandle .Owner.DID.String .Owner.Handle.String }}{{ end }}
2
+
3
+
{{ define "extrameta" }}
4
+
{{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }}
5
+
<meta property="og:title" content="{{ .String.Filename }} ยท by {{ $ownerId }}" />
6
+
<meta property="og:type" content="object" />
7
+
<meta property="og:url" content="https://tangled.sh/strings/{{ $ownerId }}/{{ .String.Rkey }}" />
8
+
<meta property="og:description" content="{{ .String.Description }}" />
9
+
{{ end }}
10
+
11
+
{{ define "topbar" }}
12
+
{{ template "layouts/topbar" $ }}
13
+
{{ end }}
14
+
15
+
{{ define "content" }}
16
+
{{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }}
17
+
<section id="string-header" class="mb-4 py-2 px-6 dark:text-white">
18
+
<div class="text-lg flex items-center justify-between">
19
+
<div>
20
+
<a href="/strings/{{ $ownerId }}">{{ $ownerId }}</a>
21
+
<span class="select-none">/</span>
22
+
<a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a>
23
+
</div>
24
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }}
25
+
<div class="flex gap-2 text-base">
26
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
27
+
hx-boost="true"
28
+
href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit">
29
+
{{ i "pencil" "size-4" }}
30
+
<span class="hidden md:inline">edit</span>
31
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
32
+
</a>
33
+
<button
34
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group p-2"
35
+
title="Delete string"
36
+
hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/"
37
+
hx-swap="none"
38
+
hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?"
39
+
>
40
+
{{ i "trash-2" "size-4" }}
41
+
<span class="hidden md:inline">delete</span>
42
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
43
+
</button>
44
+
</div>
45
+
{{ end }}
46
+
</div>
47
+
<span>
48
+
{{ with .String.Description }}
49
+
{{ . }}
50
+
{{ end }}
51
+
</span>
52
+
</section>
53
+
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white">
54
+
<div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
55
+
<span>
56
+
{{ .String.Filename }}
57
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
58
+
<span>
59
+
{{ with .String.Edited }}
60
+
edited {{ template "repo/fragments/shortTimeAgo" . }}
61
+
{{ else }}
62
+
{{ template "repo/fragments/shortTimeAgo" .String.Created }}
63
+
{{ end }}
64
+
</span>
65
+
</span>
66
+
<div>
67
+
<span>{{ .Stats.LineCount }} lines</span>
68
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
69
+
<span>{{ byteFmt .Stats.ByteCount }}</span>
70
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
71
+
<a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}/raw">view raw</a>
72
+
{{ if .RenderToggle }}
73
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
74
+
<a href="?code={{ .ShowRendered }}" hx-boost="true">
75
+
view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}
76
+
</a>
77
+
{{ end }}
78
+
</div>
79
+
</div>
80
+
<div class="overflow-x-auto overflow-y-hidden relative">
81
+
{{ if .ShowRendered }}
82
+
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
83
+
{{ else }}
84
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div>
85
+
{{ end }}
86
+
</div>
87
+
</section>
88
+
{{ end }}
+65
appview/pages/templates/strings/timeline.html
+65
appview/pages/templates/strings/timeline.html
···
1
+
{{ define "title" }} all strings {{ end }}
2
+
3
+
{{ define "topbar" }}
4
+
{{ template "layouts/topbar" $ }}
5
+
{{ end }}
6
+
7
+
{{ define "content" }}
8
+
{{ block "timeline" $ }}{{ end }}
9
+
{{ end }}
10
+
11
+
{{ define "timeline" }}
12
+
<div>
13
+
<div class="p-6">
14
+
<p class="text-xl font-bold dark:text-white">All strings</p>
15
+
</div>
16
+
17
+
<div class="flex flex-col gap-4">
18
+
{{ range $i, $s := .Strings }}
19
+
<div class="relative">
20
+
{{ if ne $i 0 }}
21
+
<div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div>
22
+
{{ end }}
23
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
24
+
{{ template "stringCard" $s }}
25
+
</div>
26
+
</div>
27
+
{{ end }}
28
+
</div>
29
+
</div>
30
+
{{ end }}
31
+
32
+
{{ define "stringCard" }}
33
+
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
34
+
<div class="font-medium dark:text-white flex gap-2 items-center">
35
+
<a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a>
36
+
</div>
37
+
{{ with .Description }}
38
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
39
+
{{ . }}
40
+
</div>
41
+
{{ end }}
42
+
43
+
{{ template "stringCardInfo" . }}
44
+
</div>
45
+
{{ end }}
46
+
47
+
{{ define "stringCardInfo" }}
48
+
{{ $stat := .Stats }}
49
+
{{ $resolved := resolve .Did.String }}
50
+
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto">
51
+
<a href="/strings/{{ $resolved }}" class="flex items-center">
52
+
{{ template "user/fragments/picHandle" $resolved }}
53
+
</a>
54
+
<span class="select-none [&:before]:content-['ยท']"></span>
55
+
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
56
+
<span class="select-none [&:before]:content-['ยท']"></span>
57
+
{{ with .Edited }}
58
+
<span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span>
59
+
{{ else }}
60
+
{{ template "repo/fragments/shortTimeAgo" .Created }}
61
+
{{ end }}
62
+
</div>
63
+
{{ end }}
64
+
65
+
+183
appview/pages/templates/timeline/timeline.html
+183
appview/pages/templates/timeline/timeline.html
···
1
+
{{ define "title" }}timeline{{ end }}
2
+
3
+
{{ define "extrameta" }}
4
+
<meta property="og:title" content="timeline ยท tangled" />
5
+
<meta property="og:type" content="object" />
6
+
<meta property="og:url" content="https://tangled.sh" />
7
+
<meta property="og:description" content="tightly-knit social coding" />
8
+
{{ end }}
9
+
10
+
{{ define "content" }}
11
+
{{ if .LoggedInUser }}
12
+
{{ else }}
13
+
{{ block "hero" $ }}{{ end }}
14
+
{{ end }}
15
+
16
+
{{ block "trending" $ }}{{ end }}
17
+
{{ block "timeline" $ }}{{ end }}
18
+
{{ end }}
19
+
20
+
{{ define "hero" }}
21
+
<div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl">
22
+
<div class="font-bold text-4xl">tightly-knit<br>social coding.</div>
23
+
24
+
<p class="text-lg">
25
+
tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>.
26
+
</p>
27
+
<p class="text-lg">
28
+
we envision a place where developers have complete ownership of their
29
+
code, open source communities can freely self-govern and most
30
+
importantly, coding can be social and fun again.
31
+
</p>
32
+
33
+
<div class="flex gap-6 items-center">
34
+
<a href="/signup" class="no-underline hover:no-underline ">
35
+
<button class="btn-create flex gap-2 px-4 items-center">
36
+
join now {{ i "arrow-right" "size-4" }}
37
+
</button>
38
+
</a>
39
+
</div>
40
+
</div>
41
+
{{ end }}
42
+
43
+
{{ define "trending" }}
44
+
<div class="w-full md:mx-0 py-4">
45
+
<div class="px-6 pb-4">
46
+
<h3 class="text-xl font-bold dark:text-white flex items-center gap-2">
47
+
Trending
48
+
{{ i "trending-up" "size-4 flex-shrink-0" }}
49
+
</h3>
50
+
</div>
51
+
<div class="flex gap-4 overflow-x-auto scrollbar-hide items-stretch">
52
+
{{ range $index, $repo := .Repos }}
53
+
<div class="flex-none h-full border border-gray-200 dark:border-gray-700 rounded-sm w-96">
54
+
{{ template "user/fragments/repoCard" (list $ $repo true) }}
55
+
</div>
56
+
{{ else }}
57
+
<div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm">
58
+
<div class="text-sm text-gray-500 dark:text-gray-400 text-center">
59
+
No trending repositories this week
60
+
</div>
61
+
</div>
62
+
{{ end }}
63
+
</div>
64
+
</div>
65
+
{{ end }}
66
+
67
+
{{ define "timeline" }}
68
+
<div class="py-4">
69
+
<div class="px-6 pb-4">
70
+
<p class="text-xl font-bold dark:text-white">Timeline</p>
71
+
</div>
72
+
73
+
<div class="flex flex-col gap-4">
74
+
{{ range $i, $e := .Timeline }}
75
+
<div class="relative">
76
+
{{ if ne $i 0 }}
77
+
<div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div>
78
+
{{ end }}
79
+
{{ with $e }}
80
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
81
+
{{ if .Repo }}
82
+
{{ block "repoEvent" (list $ .Repo .Source) }} {{ end }}
83
+
{{ else if .Star }}
84
+
{{ block "starEvent" (list $ .Star) }} {{ end }}
85
+
{{ else if .Follow }}
86
+
{{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }}
87
+
{{ end }}
88
+
</div>
89
+
{{ end }}
90
+
</div>
91
+
{{ end }}
92
+
</div>
93
+
</div>
94
+
{{ end }}
95
+
96
+
{{ define "repoEvent" }}
97
+
{{ $root := index . 0 }}
98
+
{{ $repo := index . 1 }}
99
+
{{ $source := index . 2 }}
100
+
{{ $userHandle := resolve $repo.Did }}
101
+
<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">
102
+
{{ template "user/fragments/picHandleLink" $repo.Did }}
103
+
{{ with $source }}
104
+
{{ $sourceDid := resolve .Did }}
105
+
forked
106
+
<a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline">
107
+
{{ $sourceDid }}/{{ .Name }}
108
+
</a>
109
+
to
110
+
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a>
111
+
{{ else }}
112
+
created
113
+
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">
114
+
{{ $repo.Name }}
115
+
</a>
116
+
{{ end }}
117
+
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
118
+
</div>
119
+
{{ with $repo }}
120
+
{{ template "user/fragments/repoCard" (list $root . true) }}
121
+
{{ end }}
122
+
{{ end }}
123
+
124
+
{{ define "starEvent" }}
125
+
{{ $root := index . 0 }}
126
+
{{ $star := index . 1 }}
127
+
{{ with $star }}
128
+
{{ $starrerHandle := resolve .StarredByDid }}
129
+
{{ $repoOwnerHandle := resolve .Repo.Did }}
130
+
<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">
131
+
{{ template "user/fragments/picHandleLink" $starrerHandle }}
132
+
starred
133
+
<a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">
134
+
{{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }}
135
+
</a>
136
+
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
137
+
</div>
138
+
{{ with .Repo }}
139
+
{{ template "user/fragments/repoCard" (list $root . true) }}
140
+
{{ end }}
141
+
{{ end }}
142
+
{{ end }}
143
+
144
+
145
+
{{ define "followEvent" }}
146
+
{{ $root := index . 0 }}
147
+
{{ $follow := index . 1 }}
148
+
{{ $profile := index . 2 }}
149
+
{{ $stat := index . 3 }}
150
+
151
+
{{ $userHandle := resolve $follow.UserDid }}
152
+
{{ $subjectHandle := resolve $follow.SubjectDid }}
153
+
<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">
154
+
{{ template "user/fragments/picHandleLink" $userHandle }}
155
+
followed
156
+
{{ template "user/fragments/picHandleLink" $subjectHandle }}
157
+
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
158
+
</div>
159
+
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
160
+
<div class="flex-shrink-0 max-h-full w-24 h-24">
161
+
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
162
+
</div>
163
+
164
+
<div class="flex-1 min-h-0 justify-around flex flex-col">
165
+
<a href="/{{ $subjectHandle }}">
166
+
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
167
+
</a>
168
+
{{ with $profile }}
169
+
{{ with .Description }}
170
+
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
171
+
{{ end }}
172
+
{{ end }}
173
+
{{ with $stat }}
174
+
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
175
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
176
+
<span id="followers">{{ .Followers }} followers</span>
177
+
<span class="select-none after:content-['ยท']"></span>
178
+
<span id="following">{{ .Following }} following</span>
179
+
</div>
180
+
{{ end }}
181
+
</div>
182
+
</div>
183
+
{{ end }}
-130
appview/pages/templates/timeline.html
-130
appview/pages/templates/timeline.html
···
1
-
{{ define "title" }}timeline{{ end }}
2
-
3
-
{{ define "extrameta" }}
4
-
<meta property="og:title" content="timeline ยท tangled" />
5
-
<meta property="og:type" content="object" />
6
-
<meta property="og:url" content="https://tangled.sh" />
7
-
<meta property="og:description" content="see what's tangling" />
8
-
{{ end }}
9
-
10
-
{{ define "topbar" }}
11
-
{{ template "layouts/topbar" $ }}
12
-
{{ end }}
13
-
14
-
{{ define "content" }}
15
-
{{ with .LoggedInUser }}
16
-
{{ block "timeline" $ }}{{ end }}
17
-
{{ else }}
18
-
{{ block "hero" $ }}{{ end }}
19
-
{{ block "timeline" $ }}{{ end }}
20
-
{{ end }}
21
-
{{ end }}
22
-
23
-
{{ define "hero" }}
24
-
<div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl">
25
-
<div class="font-bold text-4xl">tightly-knit<br>social coding.</div>
26
-
27
-
<p class="text-lg">
28
-
tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>.
29
-
</p>
30
-
<p class="text-lg">
31
-
we envision a place where developers have complete ownership of their
32
-
code, open source communities can freely self-govern and most
33
-
importantly, coding can be social and fun again.
34
-
</p>
35
-
36
-
<div class="flex gap-6 items-center">
37
-
<a href="/login" class="no-underline hover:no-underline ">
38
-
<button class="btn flex gap-2 px-4 items-center">
39
-
join now {{ i "arrow-right" "size-4" }}
40
-
</button>
41
-
</a>
42
-
</div>
43
-
</div>
44
-
{{ end }}
45
-
46
-
{{ define "timeline" }}
47
-
<div>
48
-
<div class="p-6">
49
-
<p class="text-xl font-bold dark:text-white">Timeline</p>
50
-
</div>
51
-
52
-
<div class="flex flex-col gap-3 relative">
53
-
<div
54
-
class="absolute left-8 top-0 bottom-0 w-px bg-gray-300 dark:bg-gray-600"
55
-
></div>
56
-
{{ range .Timeline }}
57
-
<div
58
-
class="px-6 py-2 bg-white dark:bg-gray-800 rounded drop-shadow-sm w-fit"
59
-
>
60
-
{{ if .Repo }}
61
-
{{ $userHandle := index $.DidHandleMap .Repo.Did }}
62
-
<div class="flex items-center">
63
-
<p class="text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2">
64
-
{{ template "user/fragments/picHandle" $userHandle }}
65
-
{{ if .Source }}
66
-
forked
67
-
<a
68
-
href="/{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}"
69
-
class="no-underline hover:underline"
70
-
>
71
-
{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}</a
72
-
>
73
-
to
74
-
<a
75
-
href="/{{ $userHandle }}/{{ .Repo.Name }}"
76
-
class="no-underline hover:underline"
77
-
>{{ .Repo.Name }}</a
78
-
>
79
-
{{ else }}
80
-
created
81
-
<a
82
-
href="/{{ $userHandle }}/{{ .Repo.Name }}"
83
-
class="no-underline hover:underline"
84
-
>{{ .Repo.Name }}</a
85
-
>
86
-
{{ end }}
87
-
<time
88
-
class="text-gray-700 dark:text-gray-400 text-xs"
89
-
>{{ .Repo.Created | timeFmt }}</time
90
-
>
91
-
</p>
92
-
</div>
93
-
{{ else if .Follow }}
94
-
{{ $userHandle := index $.DidHandleMap .Follow.UserDid }}
95
-
{{ $subjectHandle := index $.DidHandleMap .Follow.SubjectDid }}
96
-
<div class="flex items-center">
97
-
<p class="text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2">
98
-
{{ template "user/fragments/picHandle" $userHandle }}
99
-
followed
100
-
{{ template "user/fragments/picHandle" $subjectHandle }}
101
-
<time
102
-
class="text-gray-700 dark:text-gray-400 text-xs"
103
-
>{{ .Follow.FollowedAt | timeFmt }}</time
104
-
>
105
-
</p>
106
-
</div>
107
-
{{ else if .Star }}
108
-
{{ $starrerHandle := index $.DidHandleMap .Star.StarredByDid }}
109
-
{{ $repoOwnerHandle := index $.DidHandleMap .Star.Repo.Did }}
110
-
<div class="flex items-center">
111
-
<p class="text-gray-600 dark:text-gray-300 flex items-center gap-2">
112
-
{{ template "user/fragments/picHandle" $starrerHandle }}
113
-
starred
114
-
<a
115
-
href="/{{ $repoOwnerHandle }}/{{ .Star.Repo.Name }}"
116
-
class="no-underline hover:underline"
117
-
>{{ $repoOwnerHandle | truncateAt30 }}/{{ .Star.Repo.Name }}</a
118
-
>
119
-
<time
120
-
class="text-gray-700 dark:text-gray-400 text-xs"
121
-
>{{ .Star.Created | timeFmt }}</time
122
-
>
123
-
</p>
124
-
</div>
125
-
{{ end }}
126
-
</div>
127
-
{{ end }}
128
-
</div>
129
-
</div>
130
-
{{ end }}
+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 flex flex-col gap-4"
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">
62
+
<label for="username">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">
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 text-base"
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 }}
+1
-1
appview/pages/templates/user/fragments/editPins.html
+1
-1
appview/pages/templates/user/fragments/editPins.html
···
27
27
<input type="checkbox" id="repo-{{$idx}}" name="pinnedRepo{{$idx}}" value="{{.RepoAt}}" {{if .IsPinned}}checked{{end}}>
28
28
<label for="repo-{{$idx}}" class="my-0 py-0 normal-case font-normal w-full">
29
29
<div class="flex justify-between items-center w-full">
30
-
<span class="flex-shrink-0 overflow-hidden text-ellipsis ">{{ index $.DidHandleMap .Did }}/{{.Name}}</span>
30
+
<span class="flex-shrink-0 overflow-hidden text-ellipsis ">{{ resolve .Did }}/{{.Name}}</span>
31
31
<div class="flex gap-1 items-center">
32
32
{{ i "star" "size-4 fill-current" }}
33
33
<span>{{ .RepoStats.StarCount }}</span>
+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 }}
+6
appview/pages/templates/user/fragments/picHandleLink.html
+6
appview/pages/templates/user/fragments/picHandleLink.html
+8
-7
appview/pages/templates/user/fragments/profileCard.html
+8
-7
appview/pages/templates/user/fragments/profileCard.html
···
2
2
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit">
3
3
<div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center">
4
4
<div id="avatar" class="col-span-1 flex justify-center items-center">
5
-
{{ if .AvatarUri }}
6
5
<div class="w-3/4 aspect-square relative">
7
-
<img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ .AvatarUri }}" />
6
+
<img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ fullAvatar .UserDid }}" />
8
7
</div>
9
-
{{ end }}
10
8
</div>
11
9
<div class="col-span-2">
12
-
<p title="{{ didOrHandle .UserDid .UserHandle }}"
13
-
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
14
-
{{ didOrHandle .UserDid .UserHandle }}
15
-
</p>
10
+
<div class="flex items-center flex-row flex-nowrap gap-2">
11
+
<p title="{{ didOrHandle .UserDid .UserHandle }}"
12
+
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
13
+
{{ didOrHandle .UserDid .UserHandle }}
14
+
</p>
15
+
<a href="/{{ didOrHandle .UserDid .UserHandle }}/feed.atom">{{ i "rss" "size-4" }}</a>
16
+
</div>
16
17
17
18
<div class="md:hidden">
18
19
{{ block "followerFollowing" (list .Followers .Following) }} {{ end }}
+63
appview/pages/templates/user/fragments/repoCard.html
+63
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 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32">
8
+
<div class="font-medium dark:text-white flex items-center">
9
+
{{ if .Source }}
10
+
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
11
+
{{ else }}
12
+
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
13
+
{{ end }}
14
+
15
+
{{ $repoOwner := resolve .Did }}
16
+
{{- if $fullName -}}
17
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a>
18
+
{{- else -}}
19
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a>
20
+
{{- end -}}
21
+
</div>
22
+
{{ with .Description }}
23
+
<div class="text-gray-600 dark:text-gray-300 text-sm line-clamp-2">
24
+
{{ . | description }}
25
+
</div>
26
+
{{ end }}
27
+
28
+
{{ if .RepoStats }}
29
+
{{ block "repoStats" .RepoStats }}{{ end }}
30
+
{{ end }}
31
+
</div>
32
+
{{ end }}
33
+
{{ end }}
34
+
35
+
{{ define "repoStats" }}
36
+
<div class="text-gray-400 text-sm font-mono inline-flex gap-4 mt-auto">
37
+
{{ with .Language }}
38
+
<div class="flex gap-2 items-center text-sm">
39
+
<div class="size-2 rounded-full"
40
+
style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ langColor . }} 70%, white), {{ langColor . }} 30%, color-mix(in srgb, {{ langColor . }} 85%, black));"></div>
41
+
<span>{{ . }}</span>
42
+
</div>
43
+
{{ end }}
44
+
{{ with .StarCount }}
45
+
<div class="flex gap-1 items-center text-sm">
46
+
{{ i "star" "w-3 h-3 fill-current" }}
47
+
<span>{{ . }}</span>
48
+
</div>
49
+
{{ end }}
50
+
{{ with .IssueCount.Open }}
51
+
<div class="flex gap-1 items-center text-sm">
52
+
{{ i "circle-dot" "w-3 h-3" }}
53
+
<span>{{ . }}</span>
54
+
</div>
55
+
{{ end }}
56
+
{{ with .PullCount.Open }}
57
+
<div class="flex gap-1 items-center text-sm">
58
+
{{ i "git-pull-request" "w-3 h-3" }}
59
+
<span>{{ . }}</span>
60
+
</div>
61
+
{{ end }}
62
+
</div>
63
+
{{ end }}
+14
-34
appview/pages/templates/user/login.html
+14
-34
appview/pages/templates/user/login.html
···
3
3
<html lang="en" class="dark:bg-gray-900">
4
4
<head>
5
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="login ยท tangled"
13
-
/>
14
-
<meta
15
-
property="og:url"
16
-
content="https://tangled.sh/login"
17
-
/>
18
-
<meta
19
-
property="og:description"
20
-
content="login to tangled"
21
-
/>
6
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+
<meta property="og:title" content="login ยท tangled" />
8
+
<meta property="og:url" content="https://tangled.sh/login" />
9
+
<meta property="og:description" content="login to for tangled" />
22
10
<script src="/static/htmx.min.js"></script>
23
-
<link
24
-
rel="stylesheet"
25
-
href="/static/tw.css?{{ cssContentHash }}"
26
-
type="text/css"
27
-
/>
11
+
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
28
12
<title>login · tangled</title>
29
13
</head>
30
14
<body class="flex items-center justify-center min-h-screen">
31
15
<main class="max-w-md px-6 -mt-4">
32
-
<h1
33
-
class="text-center text-2xl font-semibold italic dark:text-white"
34
-
>
16
+
<h1 class="text-center text-2xl font-semibold italic dark:text-white" >
35
17
tangled
36
18
</h1>
37
19
<h2 class="text-center text-xl italic dark:text-white">
···
51
33
name="handle"
52
34
tabindex="1"
53
35
required
36
+
placeholder="akshay.tngl.sh"
54
37
/>
55
38
<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.
39
+
Use your <a href="https://atproto.com">ATProto</a>
40
+
handle to log in. If you're unsure, this is likely
41
+
your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account.
60
42
</span>
61
43
</div>
44
+
<input type="hidden" name="return_url" value="{{ .ReturnUrl }}">
62
45
63
46
<button
64
-
class="btn w-full my-2 mt-6"
47
+
class="btn w-full my-2 mt-6 text-base "
65
48
type="submit"
66
49
id="login-button"
67
50
tabindex="3"
···
70
53
</button>
71
54
</form>
72
55
<p class="text-sm text-gray-500">
73
-
Join our <a href="https://chat.tangled.sh">Discord</a> or
74
-
IRC channel:
75
-
<a href="https://web.libera.chat/#tangled"
76
-
><code>#tangled</code> on Libera Chat</a
77
-
>.
56
+
Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now!
78
57
</p>
58
+
79
59
<p id="login-msg" class="error w-full"></p>
80
60
</main>
81
61
</body>
+20
-69
appview/pages/templates/user/profile.html
+20
-69
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>
···
50
50
</div>
51
51
{{ else }}
52
52
<div class="flex flex-col gap-1">
53
-
{{ block "repoEvents" (list .RepoEvents $.DidHandleMap) }} {{ end }}
54
-
{{ block "issueEvents" (list .IssueEvents $.DidHandleMap) }} {{ end }}
55
-
{{ block "pullEvents" (list .PullEvents $.DidHandleMap) }} {{ end }}
53
+
{{ block "repoEvents" .RepoEvents }} {{ end }}
54
+
{{ block "issueEvents" .IssueEvents }} {{ end }}
55
+
{{ block "pullEvents" .PullEvents }} {{ end }}
56
56
</div>
57
57
{{ end }}
58
58
</div>
···
66
66
{{ end }}
67
67
68
68
{{ define "repoEvents" }}
69
-
{{ $items := index . 0 }}
70
-
{{ $handleMap := index . 1 }}
71
-
72
-
{{ if gt (len $items) 0 }}
69
+
{{ if gt (len .) 0 }}
73
70
<details>
74
71
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
75
72
<div class="flex flex-wrap items-center gap-2">
76
73
{{ i "book-plus" "w-4 h-4" }}
77
-
created {{ len $items }} {{if eq (len $items) 1 }}repository{{else}}repositories{{end}}
74
+
created {{ len . }} {{if eq (len .) 1 }}repository{{else}}repositories{{end}}
78
75
</div>
79
76
</summary>
80
77
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
81
-
{{ range $items }}
78
+
{{ range . }}
82
79
<div class="flex flex-wrap items-center gap-2">
83
80
<span class="text-gray-500 dark:text-gray-400">
84
81
{{ if .Source }}
···
87
84
{{ i "book-plus" "w-4 h-4" }}
88
85
{{ end }}
89
86
</span>
90
-
<a href="/{{ index $handleMap .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">
87
+
<a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">
91
88
{{- .Repo.Name -}}
92
89
</a>
93
90
</div>
···
98
95
{{ end }}
99
96
100
97
{{ define "issueEvents" }}
101
-
{{ $i := index . 0 }}
102
-
{{ $items := $i.Items }}
103
-
{{ $stats := $i.Stats }}
104
-
{{ $handleMap := index . 1 }}
98
+
{{ $items := .Items }}
99
+
{{ $stats := .Stats }}
105
100
106
101
{{ if gt (len $items) 0 }}
107
102
<details>
···
129
124
</summary>
130
125
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
131
126
{{ range $items }}
132
-
{{ $repoOwner := index $handleMap .Metadata.Repo.Did }}
127
+
{{ $repoOwner := resolve .Metadata.Repo.Did }}
133
128
{{ $repoName := .Metadata.Repo.Name }}
134
129
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
135
130
···
163
158
{{ end }}
164
159
165
160
{{ define "pullEvents" }}
166
-
{{ $i := index . 0 }}
167
-
{{ $items := $i.Items }}
168
-
{{ $stats := $i.Stats }}
169
-
{{ $handleMap := index . 1 }}
161
+
{{ $items := .Items }}
162
+
{{ $stats := .Stats }}
170
163
{{ if gt (len $items) 0 }}
171
164
<details>
172
165
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
···
200
193
</summary>
201
194
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
202
195
{{ range $items }}
203
-
{{ $repoOwner := index $handleMap .Repo.Did }}
196
+
{{ $repoOwner := resolve .Repo.Did }}
204
197
{{ $repoName := .Repo.Name }}
205
198
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
206
199
···
258
251
</button>
259
252
{{ end }}
260
253
</div>
261
-
<div id="repos" class="grid grid-cols-1 gap-4">
254
+
<div id="repos" class="grid grid-cols-1 gap-4 items-stretch">
262
255
{{ 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>
256
+
{{ template "user/fragments/repoCard" (list $ . false) }}
285
257
{{ else }}
286
258
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
287
259
{{ end }}
···
295
267
<p class="text-sm font-bold p-2 dark:text-white">COLLABORATING ON</p>
296
268
<div id="collaborating" class="grid grid-cols-1 gap-4">
297
269
{{ 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>
270
+
{{ template "user/fragments/repoCard" (list $ . true) }}
320
271
{{ else }}
321
272
<p class="px-6 dark:text-white">This user is not collaborating.</p>
322
273
{{ 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 }}
+53
appview/pages/templates/user/signup.html
+53
appview/pages/templates/user/signup.html
···
1
+
{{ define "user/signup" }}
2
+
<!doctype html>
3
+
<html lang="en" class="dark:bg-gray-900">
4
+
<head>
5
+
<meta charset="UTF-8" />
6
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+
<meta property="og:title" content="signup ยท tangled" />
8
+
<meta property="og:url" content="https://tangled.sh/signup" />
9
+
<meta property="og:description" content="sign up for tangled" />
10
+
<script src="/static/htmx.min.js"></script>
11
+
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
+
<title>sign up · tangled</title>
13
+
</head>
14
+
<body class="flex items-center justify-center min-h-screen">
15
+
<main class="max-w-md px-6 -mt-4">
16
+
<h1 class="text-center text-2xl font-semibold italic dark:text-white" >tangled</h1>
17
+
<h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2>
18
+
<form
19
+
class="mt-4 max-w-sm mx-auto"
20
+
hx-post="/signup"
21
+
hx-swap="none"
22
+
hx-disabled-elt="#signup-button"
23
+
>
24
+
<div class="flex flex-col mt-2">
25
+
<label for="email">email</label>
26
+
<input
27
+
type="email"
28
+
id="email"
29
+
name="email"
30
+
tabindex="4"
31
+
required
32
+
placeholder="jason@bourne.co"
33
+
/>
34
+
</div>
35
+
<span class="text-sm text-gray-500 mt-1">
36
+
You will receive an email with an invite code. Enter your
37
+
invite code, desired username, and password in the next
38
+
page to complete your registration.
39
+
</span>
40
+
<button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
41
+
<span>join now</span>
42
+
</button>
43
+
</form>
44
+
<p class="text-sm text-gray-500">
45
+
Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>.
46
+
</p>
47
+
48
+
<p id="signup-msg" class="error w-full"></p>
49
+
</main>
50
+
</body>
51
+
</html>
52
+
{{ end }}
53
+
+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
+
}
+108
-129
appview/pulls/pulls.go
+108
-129
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"
22
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
23
23
"tangled.sh/tangled.sh/core/appview/reporesolver"
24
+
"tangled.sh/tangled.sh/core/idresolver"
24
25
"tangled.sh/tangled.sh/core/knotclient"
25
26
"tangled.sh/tangled.sh/core/patchutil"
27
+
"tangled.sh/tangled.sh/core/tid"
26
28
"tangled.sh/tangled.sh/core/types"
27
29
28
30
"github.com/bluekeyes/go-gitdiff/gitdiff"
29
31
comatproto "github.com/bluesky-social/indigo/api/atproto"
30
-
"github.com/bluesky-social/indigo/atproto/syntax"
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
···
151
151
}
152
152
}
153
153
154
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve)
155
-
didHandleMap := make(map[string]string)
156
-
for _, identity := range resolvedIds {
157
-
if !identity.Handle.IsInvalidHandle() {
158
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
159
-
} else {
160
-
didHandleMap[identity.DID.String()] = identity.DID.String()
161
-
}
162
-
}
163
-
164
154
mergeCheckResponse := s.mergeCheck(f, pull, stack)
165
155
resubmitResult := pages.Unknown
166
156
if user != nil && user.Did == pull.OwnerDid {
···
198
188
m[p.Sha] = p
199
189
}
200
190
191
+
reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt())
192
+
if err != nil {
193
+
log.Println("failed to get pull reactions")
194
+
s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.")
195
+
}
196
+
197
+
userReactions := map[db.ReactionKind]bool{}
198
+
if user != nil {
199
+
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
200
+
}
201
+
201
202
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
202
203
LoggedInUser: user,
203
204
RepoInfo: repoInfo,
204
-
DidHandleMap: didHandleMap,
205
205
Pull: pull,
206
206
Stack: stack,
207
207
AbandonedPulls: abandonedPulls,
208
208
MergeCheck: mergeCheckResponse,
209
209
ResubmitCheck: resubmitResult,
210
210
Pipelines: m,
211
+
212
+
OrderedReactionKinds: db.OrderedReactionKinds,
213
+
Reactions: reactionCountMap,
214
+
UserReacted: userReactions,
211
215
})
212
216
}
213
217
···
242
246
patch = mergeable.CombinedPatch()
243
247
}
244
248
245
-
resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch)
249
+
resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.Name, pull.TargetBranch)
246
250
if err != nil {
247
251
log.Println("failed to check for mergeability:", err)
248
252
return types.MergeCheckResponse{
···
303
307
// pulls within the same repo
304
308
knot = f.Knot
305
309
ownerDid = f.OwnerDid()
306
-
repoName = f.RepoName
310
+
repoName = f.Name
307
311
}
308
312
309
313
us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev)
···
340
344
return
341
345
}
342
346
347
+
var diffOpts types.DiffOpts
348
+
if d := r.URL.Query().Get("diff"); d == "split" {
349
+
diffOpts.Split = true
350
+
}
351
+
343
352
pull, ok := r.Context().Value("pull").(*db.Pull)
344
353
if !ok {
345
354
log.Println("failed to get pull")
···
355
364
http.Error(w, "bad round id", http.StatusBadRequest)
356
365
log.Println("failed to parse round id", err)
357
366
return
358
-
}
359
-
360
-
identsToResolve := []string{pull.OwnerDid}
361
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve)
362
-
didHandleMap := make(map[string]string)
363
-
for _, identity := range resolvedIds {
364
-
if !identity.Handle.IsInvalidHandle() {
365
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
366
-
} else {
367
-
didHandleMap[identity.DID.String()] = identity.DID.String()
368
-
}
369
367
}
370
368
371
369
patch := pull.Submissions[roundIdInt].Patch
···
373
371
374
372
s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
375
373
LoggedInUser: user,
376
-
DidHandleMap: didHandleMap,
377
374
RepoInfo: f.RepoInfo(user),
378
375
Pull: pull,
379
376
Stack: stack,
380
377
Round: roundIdInt,
381
378
Submission: pull.Submissions[roundIdInt],
382
379
Diff: &diff,
380
+
DiffOpts: diffOpts,
383
381
})
384
382
385
383
}
···
391
389
if err != nil {
392
390
log.Println("failed to get repo and knot", err)
393
391
return
392
+
}
393
+
394
+
var diffOpts types.DiffOpts
395
+
if d := r.URL.Query().Get("diff"); d == "split" {
396
+
diffOpts.Split = true
394
397
}
395
398
396
399
pull, ok := r.Context().Value("pull").(*db.Pull)
···
414
417
return
415
418
}
416
419
417
-
identsToResolve := []string{pull.OwnerDid}
418
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve)
419
-
didHandleMap := make(map[string]string)
420
-
for _, identity := range resolvedIds {
421
-
if !identity.Handle.IsInvalidHandle() {
422
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
423
-
} else {
424
-
didHandleMap[identity.DID.String()] = identity.DID.String()
425
-
}
426
-
}
427
-
428
420
currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch)
429
421
if err != nil {
430
422
log.Println("failed to interdiff; current patch malformed")
···
446
438
RepoInfo: f.RepoInfo(user),
447
439
Pull: pull,
448
440
Round: roundIdInt,
449
-
DidHandleMap: didHandleMap,
450
441
Interdiff: interdiff,
442
+
DiffOpts: diffOpts,
451
443
})
452
-
return
453
444
}
454
445
455
446
func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
···
468
459
return
469
460
}
470
461
471
-
identsToResolve := []string{pull.OwnerDid}
472
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve)
473
-
didHandleMap := make(map[string]string)
474
-
for _, identity := range resolvedIds {
475
-
if !identity.Handle.IsInvalidHandle() {
476
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
477
-
} else {
478
-
didHandleMap[identity.DID.String()] = identity.DID.String()
479
-
}
480
-
}
481
-
482
462
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
483
463
w.Write([]byte(pull.Submissions[roundIdInt].Patch))
484
464
}
···
503
483
504
484
pulls, err := db.GetPulls(
505
485
s.db,
506
-
db.FilterEq("repo_at", f.RepoAt),
486
+
db.FilterEq("repo_at", f.RepoAt()),
507
487
db.FilterEq("state", state),
508
488
)
509
489
if err != nil {
···
529
509
530
510
// we want to group all stacked PRs into just one list
531
511
stacks := make(map[string]db.Stack)
512
+
var shas []string
532
513
n := 0
533
514
for _, p := range pulls {
515
+
// store the sha for later
516
+
shas = append(shas, p.LatestSha())
534
517
// this PR is stacked
535
518
if p.StackId != "" {
536
519
// we have already seen this PR stack
···
549
532
}
550
533
pulls = pulls[:n]
551
534
552
-
identsToResolve := make([]string, len(pulls))
553
-
for i, pull := range pulls {
554
-
identsToResolve[i] = pull.OwnerDid
535
+
repoInfo := f.RepoInfo(user)
536
+
ps, err := db.GetPipelineStatuses(
537
+
s.db,
538
+
db.FilterEq("repo_owner", repoInfo.OwnerDid),
539
+
db.FilterEq("repo_name", repoInfo.Name),
540
+
db.FilterEq("knot", repoInfo.Knot),
541
+
db.FilterIn("sha", shas),
542
+
)
543
+
if err != nil {
544
+
log.Printf("failed to fetch pipeline statuses: %s", err)
545
+
// non-fatal
555
546
}
556
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve)
557
-
didHandleMap := make(map[string]string)
558
-
for _, identity := range resolvedIds {
559
-
if !identity.Handle.IsInvalidHandle() {
560
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
561
-
} else {
562
-
didHandleMap[identity.DID.String()] = identity.DID.String()
563
-
}
547
+
m := make(map[string]db.Pipeline)
548
+
for _, p := range ps {
549
+
m[p.Sha] = p
564
550
}
565
551
566
552
s.pages.RepoPulls(w, pages.RepoPullsParams{
567
553
LoggedInUser: s.oauth.GetUser(r),
568
554
RepoInfo: f.RepoInfo(user),
569
555
Pulls: pulls,
570
-
DidHandleMap: didHandleMap,
571
556
FilteringBy: state,
572
557
Stacks: stacks,
558
+
Pipelines: m,
573
559
})
574
-
return
575
560
}
576
561
577
562
func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
···
625
610
createdAt := time.Now().Format(time.RFC3339)
626
611
ownerDid := user.Did
627
612
628
-
pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId)
613
+
pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId)
629
614
if err != nil {
630
615
log.Println("failed to get pull at", err)
631
616
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
632
617
return
633
618
}
634
619
635
-
atUri := f.RepoAt.String()
620
+
atUri := f.RepoAt().String()
636
621
client, err := s.oauth.AuthorizedClient(r)
637
622
if err != nil {
638
623
log.Println("failed to get authorized client", err)
···
642
627
atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
643
628
Collection: tangled.RepoPullCommentNSID,
644
629
Repo: user.Did,
645
-
Rkey: appview.TID(),
630
+
Rkey: tid.TID(),
646
631
Record: &lexutil.LexiconTypeDecoder{
647
632
Val: &tangled.RepoPullComment{
648
633
Repo: &atUri,
···
659
644
return
660
645
}
661
646
662
-
// Create the pull comment in the database with the commentAt field
663
-
commentId, err := db.NewPullComment(tx, &db.PullComment{
647
+
comment := &db.PullComment{
664
648
OwnerDid: user.Did,
665
-
RepoAt: f.RepoAt.String(),
649
+
RepoAt: f.RepoAt().String(),
666
650
PullId: pull.PullId,
667
651
Body: body,
668
652
CommentAt: atResp.Uri,
669
653
SubmissionId: pull.Submissions[roundNumber].ID,
670
-
})
654
+
}
655
+
656
+
// Create the pull comment in the database with the commentAt field
657
+
commentId, err := db.NewPullComment(tx, comment)
671
658
if err != nil {
672
659
log.Println("failed to create pull comment", err)
673
660
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
···
681
668
return
682
669
}
683
670
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
-
}
671
+
s.notifier.NewPullComment(r.Context(), comment)
694
672
695
673
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
696
674
return
···
714
692
return
715
693
}
716
694
717
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
695
+
result, err := us.Branches(f.OwnerDid(), f.Name)
718
696
if err != nil {
719
697
log.Println("failed to fetch branches", err)
720
698
return
···
762
740
s.pages.Notice(w, "pull", "Title is required for git-diff patches.")
763
741
return
764
742
}
743
+
sanitizer := markup.NewSanitizer()
744
+
if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" {
745
+
s.pages.Notice(w, "pull", "Title is empty after HTML sanitization")
746
+
return
747
+
}
765
748
}
766
749
767
750
// Validate we have at least one valid PR creation method
···
838
821
return
839
822
}
840
823
841
-
comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
824
+
comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, targetBranch, sourceBranch)
842
825
if err != nil {
843
826
log.Println("failed to compare", err)
844
827
s.pages.Notice(w, "pull", err.Error())
···
940
923
return
941
924
}
942
925
943
-
forkAtUri, err := syntax.ParseATURI(fork.AtUri)
944
-
if err != nil {
945
-
log.Println("failed to parse fork AT URI", err)
946
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
947
-
return
948
-
}
926
+
forkAtUri := fork.RepoAt()
927
+
forkAtUriStr := forkAtUri.String()
949
928
950
929
pullSource := &db.PullSource{
951
930
Branch: sourceBranch,
···
953
932
}
954
933
recordPullSource := &tangled.RepoPull_Source{
955
934
Branch: sourceBranch,
956
-
Repo: &fork.AtUri,
935
+
Repo: &forkAtUriStr,
957
936
Sha: sourceRev,
958
937
}
959
938
···
1019
998
body = formatPatches[0].Body
1020
999
}
1021
1000
1022
-
rkey := appview.TID()
1001
+
rkey := tid.TID()
1023
1002
initialSubmission := db.PullSubmission{
1024
1003
Patch: patch,
1025
1004
SourceRev: sourceRev,
1026
1005
}
1027
-
err = db.NewPull(tx, &db.Pull{
1006
+
pull := &db.Pull{
1028
1007
Title: title,
1029
1008
Body: body,
1030
1009
TargetBranch: targetBranch,
1031
1010
OwnerDid: user.Did,
1032
-
RepoAt: f.RepoAt,
1011
+
RepoAt: f.RepoAt(),
1033
1012
Rkey: rkey,
1034
1013
Submissions: []*db.PullSubmission{
1035
1014
&initialSubmission,
1036
1015
},
1037
1016
PullSource: pullSource,
1038
-
})
1017
+
}
1018
+
err = db.NewPull(tx, pull)
1039
1019
if err != nil {
1040
1020
log.Println("failed to create pull request", err)
1041
1021
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1042
1022
return
1043
1023
}
1044
-
pullId, err := db.NextPullId(tx, f.RepoAt)
1024
+
pullId, err := db.NextPullId(tx, f.RepoAt())
1045
1025
if err != nil {
1046
1026
log.Println("failed to get pull id", err)
1047
1027
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
···
1056
1036
Val: &tangled.RepoPull{
1057
1037
Title: title,
1058
1038
PullId: int64(pullId),
1059
-
TargetRepo: string(f.RepoAt),
1039
+
TargetRepo: string(f.RepoAt()),
1060
1040
TargetBranch: targetBranch,
1061
1041
Patch: patch,
1062
1042
Source: recordPullSource,
···
1075
1055
return
1076
1056
}
1077
1057
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
-
}
1058
+
s.notifier.NewPull(r.Context(), pull)
1088
1059
1089
1060
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
1090
1061
}
···
1243
1214
return
1244
1215
}
1245
1216
1246
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
1217
+
result, err := us.Branches(f.OwnerDid(), f.Name)
1247
1218
if err != nil {
1248
1219
log.Println("failed to reach knotserver", err)
1249
1220
return
···
1327
1298
return
1328
1299
}
1329
1300
1330
-
targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName)
1301
+
targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.Name)
1331
1302
if err != nil {
1332
1303
log.Println("failed to reach knotserver for target branches", err)
1333
1304
return
···
1443
1414
return
1444
1415
}
1445
1416
1446
-
comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch)
1417
+
comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, pull.TargetBranch, pull.PullSource.Branch)
1447
1418
if err != nil {
1448
1419
log.Printf("compare request failed: %s", err)
1449
1420
s.pages.Notice(w, "resubmit-error", err.Error())
···
1627
1598
Val: &tangled.RepoPull{
1628
1599
Title: pull.Title,
1629
1600
PullId: int64(pull.PullId),
1630
-
TargetRepo: string(f.RepoAt),
1601
+
TargetRepo: string(f.RepoAt()),
1631
1602
TargetBranch: pull.TargetBranch,
1632
1603
Patch: patch, // new patch
1633
1604
Source: recordPullSource,
···
1647
1618
}
1648
1619
1649
1620
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1650
-
return
1651
1621
}
1652
1622
1653
1623
func (s *Pulls) resubmitStackedPullHelper(
···
1744
1714
1745
1715
// deleted pulls are marked as deleted in the DB
1746
1716
for _, p := range deletions {
1717
+
// do not do delete already merged PRs
1718
+
if p.State == db.PullMerged {
1719
+
continue
1720
+
}
1721
+
1747
1722
err := db.DeletePull(tx, p.RepoAt, p.PullId)
1748
1723
if err != nil {
1749
1724
log.Println("failed to delete pull", err, p.PullId)
···
1784
1759
op, _ := origById[id]
1785
1760
np, _ := newById[id]
1786
1761
1762
+
// do not update already merged PRs
1763
+
if op.State == db.PullMerged {
1764
+
continue
1765
+
}
1766
+
1787
1767
submission := np.Submissions[np.LastRoundNumber()]
1788
1768
1789
1769
// resubmit the old pull
···
1891
1871
}
1892
1872
1893
1873
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1894
-
return
1895
1874
}
1896
1875
1897
1876
func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
···
1956
1935
}
1957
1936
1958
1937
// Merge the pull request
1959
-
resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
1938
+
resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.Name, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
1960
1939
if err != nil {
1961
1940
log.Printf("failed to merge pull request: %s", err)
1962
1941
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
···
1978
1957
defer tx.Rollback()
1979
1958
1980
1959
for _, p := range pullsToMerge {
1981
-
err := db.MergePull(tx, f.RepoAt, p.PullId)
1960
+
err := db.MergePull(tx, f.RepoAt(), p.PullId)
1982
1961
if err != nil {
1983
1962
log.Printf("failed to update pull request status in database: %s", err)
1984
1963
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
···
1994
1973
return
1995
1974
}
1996
1975
1997
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
1976
+
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
1998
1977
}
1999
1978
2000
1979
func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) {
···
2015
1994
2016
1995
// auth filter: only owner or collaborators can close
2017
1996
roles := f.RolesInRepo(user)
1997
+
isOwner := roles.IsOwner()
2018
1998
isCollaborator := roles.IsCollaborator()
2019
1999
isPullAuthor := user.Did == pull.OwnerDid
2020
-
isCloseAllowed := isCollaborator || isPullAuthor
2000
+
isCloseAllowed := isOwner || isCollaborator || isPullAuthor
2021
2001
if !isCloseAllowed {
2022
2002
log.Println("failed to close pull")
2023
2003
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
···
2045
2025
2046
2026
for _, p := range pullsToClose {
2047
2027
// Close the pull in the database
2048
-
err = db.ClosePull(tx, f.RepoAt, p.PullId)
2028
+
err = db.ClosePull(tx, f.RepoAt(), p.PullId)
2049
2029
if err != nil {
2050
2030
log.Println("failed to close pull", err)
2051
2031
s.pages.Notice(w, "pull-close", "Failed to close pull.")
···
2061
2041
}
2062
2042
2063
2043
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2064
-
return
2065
2044
}
2066
2045
2067
2046
func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) {
···
2083
2062
2084
2063
// auth filter: only owner or collaborators can close
2085
2064
roles := f.RolesInRepo(user)
2065
+
isOwner := roles.IsOwner()
2086
2066
isCollaborator := roles.IsCollaborator()
2087
2067
isPullAuthor := user.Did == pull.OwnerDid
2088
-
isCloseAllowed := isCollaborator || isPullAuthor
2068
+
isCloseAllowed := isOwner || isCollaborator || isPullAuthor
2089
2069
if !isCloseAllowed {
2090
2070
log.Println("failed to close pull")
2091
2071
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
···
2113
2093
2114
2094
for _, p := range pullsToReopen {
2115
2095
// Close the pull in the database
2116
-
err = db.ReopenPull(tx, f.RepoAt, p.PullId)
2096
+
err = db.ReopenPull(tx, f.RepoAt(), p.PullId)
2117
2097
if err != nil {
2118
2098
log.Println("failed to close pull", err)
2119
2099
s.pages.Notice(w, "pull-close", "Failed to close pull.")
···
2129
2109
}
2130
2110
2131
2111
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2132
-
return
2133
2112
}
2134
2113
2135
2114
func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) {
···
2155
2134
2156
2135
title := fp.Title
2157
2136
body := fp.Body
2158
-
rkey := appview.TID()
2137
+
rkey := tid.TID()
2159
2138
2160
2139
initialSubmission := db.PullSubmission{
2161
2140
Patch: fp.Raw,
···
2166
2145
Body: body,
2167
2146
TargetBranch: targetBranch,
2168
2147
OwnerDid: user.Did,
2169
-
RepoAt: f.RepoAt,
2148
+
RepoAt: f.RepoAt(),
2170
2149
Rkey: rkey,
2171
2150
Submissions: []*db.PullSubmission{
2172
2151
&initialSubmission,
+2
appview/pulls/router.go
+2
appview/pulls/router.go
+8
-8
appview/repo/artifact.go
+8
-8
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{
···
76
76
Artifact: uploadBlobResp.Blob,
77
77
CreatedAt: createdAt.Format(time.RFC3339),
78
78
Name: handler.Filename,
79
-
Repo: f.RepoAt.String(),
79
+
Repo: f.RepoAt().String(),
80
80
Tag: tag.Tag.Hash[:],
81
81
},
82
82
},
···
100
100
artifact := db.Artifact{
101
101
Did: user.Did,
102
102
Rkey: rkey,
103
-
RepoAt: f.RepoAt,
103
+
RepoAt: f.RepoAt(),
104
104
Tag: tag.Tag.Hash,
105
105
CreatedAt: createdAt,
106
106
BlobCid: cid.Cid(uploadBlobResp.Blob.Ref),
···
155
155
156
156
artifacts, err := db.GetArtifact(
157
157
rp.db,
158
-
db.FilterEq("repo_at", f.RepoAt),
158
+
db.FilterEq("repo_at", f.RepoAt()),
159
159
db.FilterEq("tag", tag.Tag.Hash[:]),
160
160
db.FilterEq("name", filename),
161
161
)
···
197
197
198
198
artifacts, err := db.GetArtifact(
199
199
rp.db,
200
-
db.FilterEq("repo_at", f.RepoAt),
200
+
db.FilterEq("repo_at", f.RepoAt()),
201
201
db.FilterEq("tag", tag[:]),
202
202
db.FilterEq("name", filename),
203
203
)
···
239
239
defer tx.Rollback()
240
240
241
241
err = db.DeleteArtifact(tx,
242
-
db.FilterEq("repo_at", f.RepoAt),
242
+
db.FilterEq("repo_at", f.RepoAt()),
243
243
db.FilterEq("tag", artifact.Tag[:]),
244
244
db.FilterEq("name", filename),
245
245
)
···
270
270
return nil, err
271
271
}
272
272
273
-
result, err := us.Tags(f.OwnerDid(), f.RepoName)
273
+
result, err := us.Tags(f.OwnerDid(), f.Name)
274
274
if err != nil {
275
275
log.Println("failed to reach knotserver", err)
276
276
return nil, err
+165
appview/repo/feed.go
+165
appview/repo/feed.go
···
1
+
package repo
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"log"
7
+
"net/http"
8
+
"slices"
9
+
"time"
10
+
11
+
"tangled.sh/tangled.sh/core/appview/db"
12
+
"tangled.sh/tangled.sh/core/appview/reporesolver"
13
+
14
+
"github.com/bluesky-social/indigo/atproto/syntax"
15
+
"github.com/gorilla/feeds"
16
+
)
17
+
18
+
func (rp *Repo) getRepoFeed(ctx context.Context, f *reporesolver.ResolvedRepo) (*feeds.Feed, error) {
19
+
const feedLimitPerType = 100
20
+
21
+
pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt()))
22
+
if err != nil {
23
+
return nil, err
24
+
}
25
+
26
+
issues, err := db.GetIssuesWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt()))
27
+
if err != nil {
28
+
return nil, err
29
+
}
30
+
31
+
feed := &feeds.Feed{
32
+
Title: fmt.Sprintf("activity feed for %s", f.OwnerSlashRepo()),
33
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.AppviewHost, f.OwnerSlashRepo()), Type: "text/html", Rel: "alternate"},
34
+
Items: make([]*feeds.Item, 0),
35
+
Updated: time.UnixMilli(0),
36
+
}
37
+
38
+
for _, pull := range pulls {
39
+
items, err := rp.createPullItems(ctx, pull, f)
40
+
if err != nil {
41
+
return nil, err
42
+
}
43
+
feed.Items = append(feed.Items, items...)
44
+
}
45
+
46
+
for _, issue := range issues {
47
+
item, err := rp.createIssueItem(ctx, issue, f)
48
+
if err != nil {
49
+
return nil, err
50
+
}
51
+
feed.Items = append(feed.Items, item)
52
+
}
53
+
54
+
slices.SortFunc(feed.Items, func(a, b *feeds.Item) int {
55
+
if a.Created.After(b.Created) {
56
+
return -1
57
+
}
58
+
return 1
59
+
})
60
+
61
+
if len(feed.Items) > 0 {
62
+
feed.Updated = feed.Items[0].Created
63
+
}
64
+
65
+
return feed, nil
66
+
}
67
+
68
+
func (rp *Repo) createPullItems(ctx context.Context, pull *db.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) {
69
+
owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid)
70
+
if err != nil {
71
+
return nil, err
72
+
}
73
+
74
+
var items []*feeds.Item
75
+
76
+
state := rp.getPullState(pull)
77
+
description := rp.buildPullDescription(owner.Handle, state, pull, f.OwnerSlashRepo())
78
+
79
+
mainItem := &feeds.Item{
80
+
Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title),
81
+
Description: description,
82
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId)},
83
+
Created: pull.Created,
84
+
Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)},
85
+
}
86
+
items = append(items, mainItem)
87
+
88
+
for _, round := range pull.Submissions {
89
+
if round == nil || round.RoundNumber == 0 {
90
+
continue
91
+
}
92
+
93
+
roundItem := &feeds.Item{
94
+
Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.RoundNumber),
95
+
Description: fmt.Sprintf("@%s submitted changes (at round #%d) on PR #%d in %s", owner.Handle, round.RoundNumber, pull.PullId, f.OwnerSlashRepo()),
96
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId, round.RoundNumber)},
97
+
Created: round.Created,
98
+
Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)},
99
+
}
100
+
items = append(items, roundItem)
101
+
}
102
+
103
+
return items, nil
104
+
}
105
+
106
+
func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) {
107
+
owner, err := rp.idResolver.ResolveIdent(ctx, issue.OwnerDid)
108
+
if err != nil {
109
+
return nil, err
110
+
}
111
+
112
+
state := "closed"
113
+
if issue.Open {
114
+
state = "opened"
115
+
}
116
+
117
+
return &feeds.Item{
118
+
Title: fmt.Sprintf("[Issue #%d] %s", issue.IssueId, issue.Title),
119
+
Description: fmt.Sprintf("@%s %s issue #%d in %s", owner.Handle, state, issue.IssueId, f.OwnerSlashRepo()),
120
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), issue.IssueId)},
121
+
Created: issue.Created,
122
+
Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)},
123
+
}, nil
124
+
}
125
+
126
+
func (rp *Repo) getPullState(pull *db.Pull) string {
127
+
if pull.State == db.PullOpen {
128
+
return "opened"
129
+
}
130
+
return pull.State.String()
131
+
}
132
+
133
+
func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *db.Pull, repoName string) string {
134
+
base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId)
135
+
136
+
if pull.State == db.PullMerged {
137
+
return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName)
138
+
}
139
+
140
+
return fmt.Sprintf("%s in %s", base, repoName)
141
+
}
142
+
143
+
func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) {
144
+
f, err := rp.repoResolver.Resolve(r)
145
+
if err != nil {
146
+
log.Println("failed to fully resolve repo:", err)
147
+
return
148
+
}
149
+
150
+
feed, err := rp.getRepoFeed(r.Context(), f)
151
+
if err != nil {
152
+
log.Println("failed to get repo feed:", err)
153
+
rp.pages.Error500(w)
154
+
return
155
+
}
156
+
157
+
atom, err := feed.ToAtom()
158
+
if err != nil {
159
+
rp.pages.Error500(w)
160
+
return
161
+
}
162
+
163
+
w.Header().Set("content-type", "application/atom+xml")
164
+
w.Write([]byte(atom))
165
+
}
+58
-31
appview/repo/index.go
+58
-31
appview/repo/index.go
···
24
24
25
25
func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) {
26
26
ref := chi.URLParam(r, "ref")
27
+
27
28
f, err := rp.repoResolver.Resolve(r)
28
29
if err != nil {
29
30
log.Println("failed to fully resolve repo", err)
···
37
38
return
38
39
}
39
40
40
-
result, err := us.Index(f.OwnerDid(), f.RepoName, ref)
41
+
result, err := us.Index(f.OwnerDid(), f.Name, ref)
41
42
if err != nil {
42
43
rp.pages.Error503(w)
43
44
log.Println("failed to reach knotserver", err)
···
57
58
hash := branch.Hash
58
59
tagMap[hash] = append(tagMap[hash], branch.Name)
59
60
}
61
+
62
+
sortFiles(result.Files)
60
63
61
64
slices.SortFunc(result.Branches, func(a, b types.Branch) int {
62
65
if a.Name == result.Ref {
···
116
119
117
120
var forkInfo *types.ForkInfo
118
121
if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) {
119
-
forkInfo, err = getForkInfo(repoInfo, rp, f, user, signedClient)
122
+
forkInfo, err = getForkInfo(repoInfo, rp, f, result.Ref, user, signedClient)
120
123
if err != nil {
121
124
log.Printf("Failed to fetch fork information: %v", err)
122
125
return
123
126
}
124
127
}
125
128
126
-
languageInfo, err := getLanguageInfo(f, signedClient, ref)
129
+
// TODO: a bit dirty
130
+
languageInfo, err := rp.getLanguageInfo(f, signedClient, result.Ref, ref == "")
127
131
if err != nil {
128
132
log.Printf("failed to compute language percentages: %s", err)
129
133
// non-fatal
···
153
157
Languages: languageInfo,
154
158
Pipelines: pipelines,
155
159
})
156
-
return
157
160
}
158
161
159
-
func getLanguageInfo(
162
+
func (rp *Repo) getLanguageInfo(
160
163
f *reporesolver.ResolvedRepo,
161
164
signedClient *knotclient.SignedClient,
162
-
ref string,
165
+
currentRef string,
166
+
isDefaultRef bool,
163
167
) ([]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
-
}
168
+
// first attempt to fetch from db
169
+
langs, err := db.GetRepoLanguages(
170
+
rp.db,
171
+
db.FilterEq("repo_at", f.RepoAt()),
172
+
db.FilterEq("ref", currentRef),
173
+
)
171
174
172
-
var totalSize int64
173
-
for _, fileSize := range repoLanguages.Languages {
174
-
totalSize += fileSize
175
-
}
175
+
if err != nil || langs == nil {
176
+
// non-fatal, fetch langs from ks
177
+
ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.Name, currentRef)
178
+
if err != nil {
179
+
return nil, err
180
+
}
181
+
if ls == nil {
182
+
return nil, nil
183
+
}
176
184
177
-
var languageStats []types.RepoLanguageDetails
178
-
var otherPercentage float32 = 0
179
-
180
-
for lang, size := range repoLanguages.Languages {
181
-
percentage := (float32(size) / float32(totalSize)) * 100
185
+
for l, s := range ls.Languages {
186
+
langs = append(langs, db.RepoLanguage{
187
+
RepoAt: f.RepoAt(),
188
+
Ref: currentRef,
189
+
IsDefaultRef: isDefaultRef,
190
+
Language: l,
191
+
Bytes: s,
192
+
})
193
+
}
182
194
183
-
if percentage <= 0.5 {
184
-
otherPercentage += percentage
185
-
continue
195
+
// update appview's cache
196
+
err = db.InsertRepoLanguages(rp.db, langs)
197
+
if err != nil {
198
+
// non-fatal
199
+
log.Println("failed to cache lang results", err)
186
200
}
201
+
}
187
202
188
-
color := enry.GetColor(lang)
203
+
var total int64
204
+
for _, l := range langs {
205
+
total += l.Bytes
206
+
}
189
207
190
-
languageStats = append(languageStats, types.RepoLanguageDetails{Name: lang, Percentage: percentage, Color: color})
208
+
var languageStats []types.RepoLanguageDetails
209
+
for _, l := range langs {
210
+
percentage := float32(l.Bytes) / float32(total) * 100
211
+
color := enry.GetColor(l.Language)
212
+
languageStats = append(languageStats, types.RepoLanguageDetails{
213
+
Name: l.Language,
214
+
Percentage: percentage,
215
+
Color: color,
216
+
})
191
217
}
192
218
193
219
sort.Slice(languageStats, func(i, j int) bool {
···
210
236
repoInfo repoinfo.RepoInfo,
211
237
rp *Repo,
212
238
f *reporesolver.ResolvedRepo,
239
+
currentRef string,
213
240
user *oauth.User,
214
241
signedClient *knotclient.SignedClient,
215
242
) (*types.ForkInfo, error) {
···
240
267
}
241
268
242
269
if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool {
243
-
return branch.Name == f.Ref
270
+
return branch.Name == currentRef
244
271
}) {
245
272
forkInfo.Status = types.MissingBranch
246
273
return &forkInfo, nil
247
274
}
248
275
249
-
newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, f.Ref, f.Ref)
276
+
newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, currentRef, currentRef)
250
277
if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent {
251
278
log.Printf("failed to update tracking branch: %s", err)
252
279
return nil, err
253
280
}
254
281
255
-
hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref)
282
+
hiddenRef := fmt.Sprintf("hidden/%s/%s", currentRef, currentRef)
256
283
257
284
var status types.AncestorCheckResponse
258
-
forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt), repoInfo.Name, f.Ref, hiddenRef)
285
+
forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt()), repoInfo.Name, currentRef, hiddenRef)
259
286
if err != nil {
260
287
log.Printf("failed to check if fork is ahead/behind: %s", err)
261
288
return nil, err
+539
-175
appview/repo/repo.go
+539
-175
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
84
+
func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
85
+
refParam := chi.URLParam(r, "ref")
86
+
f, err := rp.repoResolver.Resolve(r)
87
+
if err != nil {
88
+
log.Println("failed to get repo and knot", err)
89
+
return
90
+
}
91
+
92
+
var uri string
93
+
if rp.config.Core.Dev {
94
+
uri = "http"
95
+
} else {
96
+
uri = "https"
97
+
}
98
+
url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.Name, url.PathEscape(refParam))
99
+
100
+
http.Redirect(w, r, url, http.StatusFound)
101
+
}
102
+
80
103
func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) {
81
104
f, err := rp.repoResolver.Resolve(r)
82
105
if err != nil {
···
100
123
return
101
124
}
102
125
103
-
repolog, err := us.Log(f.OwnerDid(), f.RepoName, ref, page)
126
+
repolog, err := us.Log(f.OwnerDid(), f.Name, ref, page)
104
127
if err != nil {
105
128
log.Println("failed to reach knotserver", err)
106
129
return
107
130
}
108
131
109
-
result, err := us.Tags(f.OwnerDid(), f.RepoName)
132
+
tagResult, err := us.Tags(f.OwnerDid(), f.Name)
110
133
if err != nil {
111
134
log.Println("failed to reach knotserver", err)
112
135
return
113
136
}
114
137
115
138
tagMap := make(map[string][]string)
116
-
for _, tag := range result.Tags {
139
+
for _, tag := range tagResult.Tags {
117
140
hash := tag.Hash
118
141
if tag.Tag != nil {
119
142
hash = tag.Tag.Target.String()
···
121
144
tagMap[hash] = append(tagMap[hash], tag.Name)
122
145
}
123
146
147
+
branchResult, err := us.Branches(f.OwnerDid(), f.Name)
148
+
if err != nil {
149
+
log.Println("failed to reach knotserver", err)
150
+
return
151
+
}
152
+
153
+
for _, branch := range branchResult.Branches {
154
+
hash := branch.Hash
155
+
tagMap[hash] = append(tagMap[hash], branch.Name)
156
+
}
157
+
124
158
user := rp.oauth.GetUser(r)
125
159
126
160
emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true)
···
154
188
VerifiedCommits: vc,
155
189
Pipelines: pipelines,
156
190
})
157
-
return
158
191
}
159
192
160
193
func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
···
169
202
rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
170
203
RepoInfo: f.RepoInfo(user),
171
204
})
172
-
return
173
205
}
174
206
175
207
func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) {
···
180
212
return
181
213
}
182
214
183
-
repoAt := f.RepoAt
215
+
repoAt := f.RepoAt()
184
216
rkey := repoAt.RecordKey().String()
185
217
if rkey == "" {
186
218
log.Println("invalid aturi for repo", err)
···
230
262
Record: &lexutil.LexiconTypeDecoder{
231
263
Val: &tangled.Repo{
232
264
Knot: f.Knot,
233
-
Name: f.RepoName,
265
+
Name: f.Name,
234
266
Owner: user.Did,
235
-
CreatedAt: f.CreatedAt,
267
+
CreatedAt: f.Created.Format(time.RFC3339),
236
268
Description: &newDescription,
237
269
Spindle: &f.Spindle,
238
270
},
···
268
300
protocol = "https"
269
301
}
270
302
303
+
var diffOpts types.DiffOpts
304
+
if d := r.URL.Query().Get("diff"); d == "split" {
305
+
diffOpts.Split = true
306
+
}
307
+
271
308
if !plumbing.IsHash(ref) {
272
309
rp.pages.Error404(w)
273
310
return
274
311
}
275
312
276
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref))
313
+
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref))
277
314
if err != nil {
278
315
log.Println("failed to reach knotserver", err)
279
316
return
···
321
358
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
322
359
VerifiedCommit: vc,
323
360
Pipeline: pipeline,
361
+
DiffOpts: diffOpts,
324
362
})
325
-
return
326
363
}
327
364
328
365
func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) {
···
338
375
if !rp.config.Core.Dev {
339
376
protocol = "https"
340
377
}
341
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath))
378
+
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath))
342
379
if err != nil {
343
380
log.Println("failed to reach knotserver", err)
344
381
return
···
359
396
360
397
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
361
398
// so we can safely redirect to the "parent" (which is the same file).
362
-
if len(result.Files) == 0 && result.Parent == treePath {
399
+
unescapedTreePath, _ := url.PathUnescape(treePath)
400
+
if len(result.Files) == 0 && result.Parent == unescapedTreePath {
363
401
http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound)
364
402
return
365
403
}
···
367
405
user := rp.oauth.GetUser(r)
368
406
369
407
var breadcrumbs [][]string
370
-
breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
408
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
371
409
if treePath != "" {
372
410
for idx, elem := range strings.Split(treePath, "/") {
373
411
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
374
412
}
375
413
}
376
414
377
-
baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath)
378
-
baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath)
415
+
sortFiles(result.Files)
379
416
380
417
rp.pages.RepoTree(w, pages.RepoTreeParams{
381
418
LoggedInUser: user,
382
419
BreadCrumbs: breadcrumbs,
383
-
BaseTreeLink: baseTreeLink,
384
-
BaseBlobLink: baseBlobLink,
420
+
TreePath: treePath,
385
421
RepoInfo: f.RepoInfo(user),
386
422
RepoTreeResponse: result,
387
423
})
388
-
return
389
424
}
390
425
391
426
func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) {
···
401
436
return
402
437
}
403
438
404
-
result, err := us.Tags(f.OwnerDid(), f.RepoName)
439
+
result, err := us.Tags(f.OwnerDid(), f.Name)
405
440
if err != nil {
406
441
log.Println("failed to reach knotserver", err)
407
442
return
408
443
}
409
444
410
-
artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt))
445
+
artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt()))
411
446
if err != nil {
412
447
log.Println("failed grab artifacts", err)
413
448
return
···
443
478
ArtifactMap: artifactMap,
444
479
DanglingArtifacts: danglingArtifacts,
445
480
})
446
-
return
447
481
}
448
482
449
483
func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) {
···
459
493
return
460
494
}
461
495
462
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
496
+
result, err := us.Branches(f.OwnerDid(), f.Name)
463
497
if err != nil {
464
498
log.Println("failed to reach knotserver", err)
465
499
return
466
500
}
467
501
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
-
})
502
+
sortBranches(result.Branches)
484
503
485
504
user := rp.oauth.GetUser(r)
486
505
rp.pages.RepoBranches(w, pages.RepoBranchesParams{
···
488
507
RepoInfo: f.RepoInfo(user),
489
508
RepoBranchesResponse: *result,
490
509
})
491
-
return
492
510
}
493
511
494
512
func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
···
504
522
if !rp.config.Core.Dev {
505
523
protocol = "https"
506
524
}
507
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
525
+
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath))
508
526
if err != nil {
509
527
log.Println("failed to reach knotserver", err)
510
528
return
···
524
542
}
525
543
526
544
var breadcrumbs [][]string
527
-
breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
545
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
528
546
if filePath != "" {
529
547
for idx, elem := range strings.Split(filePath, "/") {
530
548
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
···
539
557
showRendered = r.URL.Query().Get("code") != "true"
540
558
}
541
559
560
+
var unsupported bool
561
+
var isImage bool
562
+
var isVideo bool
563
+
var contentSrc string
564
+
565
+
if result.IsBinary {
566
+
ext := strings.ToLower(filepath.Ext(result.Path))
567
+
switch ext {
568
+
case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
569
+
isImage = true
570
+
case ".mp4", ".webm", ".ogg", ".mov", ".avi":
571
+
isVideo = true
572
+
default:
573
+
unsupported = true
574
+
}
575
+
576
+
// fetch the actual binary content like in RepoBlobRaw
577
+
578
+
blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Name, ref, filePath)
579
+
contentSrc = blobURL
580
+
if !rp.config.Core.Dev {
581
+
contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
582
+
}
583
+
}
584
+
542
585
user := rp.oauth.GetUser(r)
543
586
rp.pages.RepoBlob(w, pages.RepoBlobParams{
544
587
LoggedInUser: user,
···
547
590
BreadCrumbs: breadcrumbs,
548
591
ShowRendered: showRendered,
549
592
RenderToggle: renderToggle,
593
+
Unsupported: unsupported,
594
+
IsImage: isImage,
595
+
IsVideo: isVideo,
596
+
ContentSrc: contentSrc,
550
597
})
551
-
return
552
598
}
553
599
554
600
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
555
601
f, err := rp.repoResolver.Resolve(r)
556
602
if err != nil {
557
603
log.Println("failed to get repo and knot", err)
604
+
w.WriteHeader(http.StatusBadRequest)
558
605
return
559
606
}
560
607
···
565
612
if !rp.config.Core.Dev {
566
613
protocol = "https"
567
614
}
568
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
615
+
616
+
blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath)
617
+
618
+
req, err := http.NewRequest("GET", blobURL, nil)
569
619
if err != nil {
570
-
log.Println("failed to reach knotserver", err)
620
+
log.Println("failed to create request", err)
571
621
return
572
622
}
573
623
574
-
body, err := io.ReadAll(resp.Body)
624
+
// forward the If-None-Match header
625
+
if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
626
+
req.Header.Set("If-None-Match", clientETag)
627
+
}
628
+
629
+
client := &http.Client{}
630
+
resp, err := client.Do(req)
575
631
if err != nil {
576
-
log.Printf("Error reading response body: %v", err)
632
+
log.Println("failed to reach knotserver", err)
633
+
rp.pages.Error503(w)
634
+
return
635
+
}
636
+
defer resp.Body.Close()
637
+
638
+
// forward 304 not modified
639
+
if resp.StatusCode == http.StatusNotModified {
640
+
w.WriteHeader(http.StatusNotModified)
641
+
return
642
+
}
643
+
644
+
if resp.StatusCode != http.StatusOK {
645
+
log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode)
646
+
w.WriteHeader(resp.StatusCode)
647
+
_, _ = io.Copy(w, resp.Body)
577
648
return
578
649
}
579
650
580
-
var result types.RepoBlobResponse
581
-
err = json.Unmarshal(body, &result)
651
+
contentType := resp.Header.Get("Content-Type")
652
+
body, err := io.ReadAll(resp.Body)
582
653
if err != nil {
583
-
log.Println("failed to parse response:", err)
654
+
log.Printf("error reading response body from knotserver: %v", err)
655
+
w.WriteHeader(http.StatusInternalServerError)
584
656
return
585
657
}
586
658
587
-
if result.IsBinary {
588
-
w.Header().Set("Content-Type", "application/octet-stream")
659
+
if strings.Contains(contentType, "text/plain") {
660
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
661
+
w.Write(body)
662
+
} else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
663
+
w.Header().Set("Content-Type", contentType)
589
664
w.Write(body)
665
+
} else {
666
+
w.WriteHeader(http.StatusUnsupportedMediaType)
667
+
w.Write([]byte("unsupported content type"))
590
668
return
591
669
}
592
-
593
-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
594
-
w.Write([]byte(result.Contents))
595
-
return
596
670
}
597
671
598
672
// modify the spindle configured for this repo
599
673
func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
674
+
user := rp.oauth.GetUser(r)
675
+
l := rp.logger.With("handler", "EditSpindle")
676
+
l = l.With("did", user.Did)
677
+
l = l.With("handle", user.Handle)
678
+
679
+
errorId := "operation-error"
680
+
fail := func(msg string, err error) {
681
+
l.Error(msg, "err", err)
682
+
rp.pages.Notice(w, errorId, msg)
683
+
}
684
+
600
685
f, err := rp.repoResolver.Resolve(r)
601
686
if err != nil {
602
-
log.Println("failed to get repo and knot", err)
603
-
w.WriteHeader(http.StatusBadRequest)
687
+
fail("Failed to resolve repo. Try again later", err)
604
688
return
605
689
}
606
690
607
-
repoAt := f.RepoAt
691
+
repoAt := f.RepoAt()
608
692
rkey := repoAt.RecordKey().String()
609
693
if rkey == "" {
610
-
log.Println("invalid aturi for repo", err)
611
-
w.WriteHeader(http.StatusInternalServerError)
694
+
fail("Failed to resolve repo. Try again later", err)
612
695
return
613
696
}
614
697
615
-
user := rp.oauth.GetUser(r)
616
-
617
698
newSpindle := r.FormValue("spindle")
699
+
removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value
618
700
client, err := rp.oauth.AuthorizedClient(r)
619
701
if err != nil {
620
-
log.Println("failed to get client")
621
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
702
+
fail("Failed to authorize. Try again later.", err)
622
703
return
623
704
}
624
705
625
-
// ensure that this is a valid spindle for this user
626
-
validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
627
-
if err != nil {
628
-
log.Println("failed to get valid spindles")
629
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
630
-
return
706
+
if !removingSpindle {
707
+
// ensure that this is a valid spindle for this user
708
+
validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
709
+
if err != nil {
710
+
fail("Failed to find spindles. Try again later.", err)
711
+
return
712
+
}
713
+
714
+
if !slices.Contains(validSpindles, newSpindle) {
715
+
fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles))
716
+
return
717
+
}
631
718
}
632
719
633
-
if !slices.Contains(validSpindles, newSpindle) {
634
-
log.Println("newSpindle not present in validSpindles", "newSpindle", newSpindle, "validSpindles", validSpindles)
635
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
636
-
return
720
+
spindlePtr := &newSpindle
721
+
if removingSpindle {
722
+
spindlePtr = nil
637
723
}
638
724
639
725
// optimistic update
640
-
err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle)
726
+
err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr)
641
727
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.")
728
+
fail("Failed to update spindle. Try again later.", err)
644
729
return
645
730
}
646
731
647
732
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
648
733
if err != nil {
649
-
// failed to get record
650
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, no record found on PDS.")
734
+
fail("Failed to update spindle, no record found on PDS.", err)
651
735
return
652
736
}
653
737
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
···
658
742
Record: &lexutil.LexiconTypeDecoder{
659
743
Val: &tangled.Repo{
660
744
Knot: f.Knot,
661
-
Name: f.RepoName,
745
+
Name: f.Name,
662
746
Owner: user.Did,
663
-
CreatedAt: f.CreatedAt,
747
+
CreatedAt: f.Created.Format(time.RFC3339),
664
748
Description: &f.Description,
665
-
Spindle: &newSpindle,
749
+
Spindle: spindlePtr,
666
750
},
667
751
},
668
752
})
669
753
670
754
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.")
755
+
fail("Failed to update spindle, unable to save to PDS.", err)
674
756
return
675
757
}
676
758
677
-
// add this spindle to spindle stream
678
-
rp.spindlestream.AddSource(
679
-
context.Background(),
680
-
eventconsumer.NewSpindleSource(newSpindle),
681
-
)
759
+
if !removingSpindle {
760
+
// add this spindle to spindle stream
761
+
rp.spindlestream.AddSource(
762
+
context.Background(),
763
+
eventconsumer.NewSpindleSource(newSpindle),
764
+
)
765
+
}
682
766
683
-
w.Write(fmt.Append(nil, "spindle set to: ", newSpindle))
767
+
rp.pages.HxRefresh(w)
684
768
}
685
769
686
770
func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) {
771
+
user := rp.oauth.GetUser(r)
772
+
l := rp.logger.With("handler", "AddCollaborator")
773
+
l = l.With("did", user.Did)
774
+
l = l.With("handle", user.Handle)
775
+
687
776
f, err := rp.repoResolver.Resolve(r)
688
777
if err != nil {
689
-
log.Println("failed to get repo and knot", err)
778
+
l.Error("failed to get repo and knot", "err", err)
690
779
return
691
780
}
692
781
782
+
errorId := "add-collaborator-error"
783
+
fail := func(msg string, err error) {
784
+
l.Error(msg, "err", err)
785
+
rp.pages.Notice(w, errorId, msg)
786
+
}
787
+
693
788
collaborator := r.FormValue("collaborator")
694
789
if collaborator == "" {
695
-
http.Error(w, "malformed form", http.StatusBadRequest)
790
+
fail("Invalid form.", nil)
696
791
return
697
792
}
698
793
794
+
// remove a single leading `@`, to make @handle work with ResolveIdent
795
+
collaborator = strings.TrimPrefix(collaborator, "@")
796
+
699
797
collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator)
700
798
if err != nil {
701
-
w.Write([]byte("failed to resolve collaborator did to a handle"))
799
+
fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err)
800
+
return
801
+
}
802
+
803
+
if collaboratorIdent.DID.String() == user.Did {
804
+
fail("You seem to be adding yourself as a collaborator.", nil)
805
+
return
806
+
}
807
+
l = l.With("collaborator", collaboratorIdent.Handle)
808
+
l = l.With("knot", f.Knot)
809
+
810
+
// announce this relation into the firehose, store into owners' pds
811
+
client, err := rp.oauth.AuthorizedClient(r)
812
+
if err != nil {
813
+
fail("Failed to write to PDS.", err)
702
814
return
703
815
}
704
-
log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot)
705
816
706
-
// TODO: create an atproto record for this
817
+
// emit a record
818
+
currentUser := rp.oauth.GetUser(r)
819
+
rkey := tid.TID()
820
+
createdAt := time.Now()
821
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
822
+
Collection: tangled.RepoCollaboratorNSID,
823
+
Repo: currentUser.Did,
824
+
Rkey: rkey,
825
+
Record: &lexutil.LexiconTypeDecoder{
826
+
Val: &tangled.RepoCollaborator{
827
+
Subject: collaboratorIdent.DID.String(),
828
+
Repo: string(f.RepoAt()),
829
+
CreatedAt: createdAt.Format(time.RFC3339),
830
+
}},
831
+
})
832
+
// invalid record
833
+
if err != nil {
834
+
fail("Failed to write record to PDS.", err)
835
+
return
836
+
}
837
+
l = l.With("at-uri", resp.Uri)
838
+
l.Info("wrote record to PDS")
707
839
840
+
l.Info("adding to knot")
708
841
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
709
842
if err != nil {
710
-
log.Printf("no key found for domain %s: %s\n", f.Knot, err)
843
+
fail("Failed to add to knot.", err)
711
844
return
712
845
}
713
846
714
847
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
715
848
if err != nil {
716
-
log.Println("failed to create client to ", f.Knot)
849
+
fail("Failed to add to knot.", err)
717
850
return
718
851
}
719
852
720
-
ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String())
853
+
ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.Name, collaboratorIdent.DID.String())
721
854
if err != nil {
722
-
log.Printf("failed to make request to %s: %s", f.Knot, err)
855
+
fail("Knot was unreachable.", err)
723
856
return
724
857
}
725
858
726
859
if ksResp.StatusCode != http.StatusNoContent {
727
-
w.Write(fmt.Append(nil, "knotserver failed to add collaborator: ", err))
860
+
fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil)
728
861
return
729
862
}
730
863
731
864
tx, err := rp.db.BeginTx(r.Context(), nil)
732
865
if err != nil {
733
-
log.Println("failed to start tx")
734
-
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
866
+
fail("Failed to add collaborator.", err)
735
867
return
736
868
}
737
869
defer func() {
738
870
tx.Rollback()
739
871
err = rp.enforcer.E.LoadPolicy()
740
872
if err != nil {
741
-
log.Println("failed to rollback policies")
873
+
fail("Failed to add collaborator.", err)
742
874
}
743
875
}()
744
876
745
877
err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
746
878
if err != nil {
747
-
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
879
+
fail("Failed to add collaborator permissions.", err)
748
880
return
749
881
}
750
882
751
-
err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot)
883
+
err = db.AddCollaborator(rp.db, db.Collaborator{
884
+
Did: syntax.DID(currentUser.Did),
885
+
Rkey: rkey,
886
+
SubjectDid: collaboratorIdent.DID,
887
+
RepoAt: f.RepoAt(),
888
+
Created: createdAt,
889
+
})
752
890
if err != nil {
753
-
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
891
+
fail("Failed to add collaborator.", err)
754
892
return
755
893
}
756
894
757
895
err = tx.Commit()
758
896
if err != nil {
759
-
log.Println("failed to commit changes", err)
760
-
http.Error(w, err.Error(), http.StatusInternalServerError)
897
+
fail("Failed to add collaborator.", err)
761
898
return
762
899
}
763
900
764
901
err = rp.enforcer.E.SavePolicy()
765
902
if err != nil {
766
-
log.Println("failed to update ACLs", err)
767
-
http.Error(w, err.Error(), http.StatusInternalServerError)
903
+
fail("Failed to update collaborator permissions.", err)
768
904
return
769
905
}
770
906
771
-
w.Write(fmt.Append(nil, "added collaborator: ", collaboratorIdent.Handle.String()))
772
-
907
+
rp.pages.HxRefresh(w)
773
908
}
774
909
775
910
func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
···
787
922
log.Println("failed to get authorized client", err)
788
923
return
789
924
}
790
-
repoRkey := f.RepoAt.RecordKey().String()
791
925
_, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
792
926
Collection: tangled.RepoNSID,
793
927
Repo: user.Did,
794
-
Rkey: repoRkey,
928
+
Rkey: f.Rkey,
795
929
})
796
930
if err != nil {
797
931
log.Printf("failed to delete record: %s", err)
798
932
rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.")
799
933
return
800
934
}
801
-
log.Println("removed repo record ", f.RepoAt.String())
935
+
log.Println("removed repo record ", f.RepoAt().String())
802
936
803
937
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
804
938
if err != nil {
···
812
946
return
813
947
}
814
948
815
-
ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName)
949
+
ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.Name)
816
950
if err != nil {
817
951
log.Printf("failed to make request to %s: %s", f.Knot, err)
818
952
return
···
858
992
}
859
993
860
994
// remove repo from db
861
-
err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName)
995
+
err = db.RemoveRepo(tx, f.OwnerDid(), f.Name)
862
996
if err != nil {
863
997
rp.pages.Notice(w, "settings-delete", "Failed to update appview")
864
998
return
···
907
1041
return
908
1042
}
909
1043
910
-
ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch)
1044
+
ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.Name, branch)
911
1045
if err != nil {
912
1046
log.Printf("failed to make request to %s: %s", f.Knot, err)
913
1047
return
···
921
1055
w.Write(fmt.Append(nil, "default branch set to: ", branch))
922
1056
}
923
1057
924
-
func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
1058
+
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
1059
+
user := rp.oauth.GetUser(r)
1060
+
l := rp.logger.With("handler", "Secrets")
1061
+
l = l.With("handle", user.Handle)
1062
+
l = l.With("did", user.Did)
1063
+
925
1064
f, err := rp.repoResolver.Resolve(r)
926
1065
if err != nil {
927
1066
log.Println("failed to get repo and knot", err)
928
1067
return
929
1068
}
930
1069
1070
+
if f.Spindle == "" {
1071
+
log.Println("empty spindle cannot add/rm secret", err)
1072
+
return
1073
+
}
1074
+
1075
+
lxm := tangled.RepoAddSecretNSID
1076
+
if r.Method == http.MethodDelete {
1077
+
lxm = tangled.RepoRemoveSecretNSID
1078
+
}
1079
+
1080
+
spindleClient, err := rp.oauth.ServiceClient(
1081
+
r,
1082
+
oauth.WithService(f.Spindle),
1083
+
oauth.WithLxm(lxm),
1084
+
oauth.WithExp(60),
1085
+
oauth.WithDev(rp.config.Core.Dev),
1086
+
)
1087
+
if err != nil {
1088
+
log.Println("failed to create spindle client", err)
1089
+
return
1090
+
}
1091
+
1092
+
key := r.FormValue("key")
1093
+
if key == "" {
1094
+
w.WriteHeader(http.StatusBadRequest)
1095
+
return
1096
+
}
1097
+
931
1098
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
-
}
1099
+
case http.MethodPut:
1100
+
errorId := "add-secret-error"
939
1101
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
-
}
1102
+
value := r.FormValue("value")
1103
+
if value == "" {
1104
+
w.WriteHeader(http.StatusBadRequest)
1105
+
return
946
1106
}
947
1107
948
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1108
+
err = tangled.RepoAddSecret(
1109
+
r.Context(),
1110
+
spindleClient,
1111
+
&tangled.RepoAddSecret_Input{
1112
+
Repo: f.RepoAt().String(),
1113
+
Key: key,
1114
+
Value: value,
1115
+
},
1116
+
)
949
1117
if err != nil {
950
-
log.Println("failed to create unsigned client", err)
1118
+
l.Error("Failed to add secret.", "err", err)
1119
+
rp.pages.Notice(w, errorId, "Failed to add secret.")
951
1120
return
952
1121
}
953
1122
954
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
1123
+
case http.MethodDelete:
1124
+
errorId := "operation-error"
1125
+
1126
+
err = tangled.RepoRemoveSecret(
1127
+
r.Context(),
1128
+
spindleClient,
1129
+
&tangled.RepoRemoveSecret_Input{
1130
+
Repo: f.RepoAt().String(),
1131
+
Key: key,
1132
+
},
1133
+
)
955
1134
if err != nil {
956
-
log.Println("failed to reach knotserver", err)
1135
+
l.Error("Failed to delete secret.", "err", err)
1136
+
rp.pages.Notice(w, errorId, "Failed to delete secret.")
957
1137
return
958
1138
}
1139
+
}
959
1140
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
1141
+
rp.pages.HxRefresh(w)
1142
+
}
1143
+
1144
+
type tab = map[string]any
1145
+
1146
+
var (
1147
+
// would be great to have ordered maps right about now
1148
+
settingsTabs []tab = []tab{
1149
+
{"Name": "general", "Icon": "sliders-horizontal"},
1150
+
{"Name": "access", "Icon": "users"},
1151
+
{"Name": "pipelines", "Icon": "layers-2"},
1152
+
}
1153
+
)
1154
+
1155
+
func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
1156
+
tabVal := r.URL.Query().Get("tab")
1157
+
if tabVal == "" {
1158
+
tabVal = "general"
1159
+
}
1160
+
1161
+
switch tabVal {
1162
+
case "general":
1163
+
rp.generalSettings(w, r)
1164
+
1165
+
case "access":
1166
+
rp.accessSettings(w, r)
1167
+
1168
+
case "pipelines":
1169
+
rp.pipelineSettings(w, r)
1170
+
}
1171
+
1172
+
// user := rp.oauth.GetUser(r)
1173
+
// repoCollaborators, err := f.Collaborators(r.Context())
1174
+
// if err != nil {
1175
+
// log.Println("failed to get collaborators", err)
1176
+
// }
1177
+
1178
+
// isCollaboratorInviteAllowed := false
1179
+
// if user != nil {
1180
+
// ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo())
1181
+
// if err == nil && ok {
1182
+
// isCollaboratorInviteAllowed = true
1183
+
// }
1184
+
// }
1185
+
1186
+
// us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1187
+
// if err != nil {
1188
+
// log.Println("failed to create unsigned client", err)
1189
+
// return
1190
+
// }
1191
+
1192
+
// result, err := us.Branches(f.OwnerDid(), f.Name)
1193
+
// if err != nil {
1194
+
// log.Println("failed to reach knotserver", err)
1195
+
// return
1196
+
// }
1197
+
1198
+
// // all spindles that this user is a member of
1199
+
// spindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
1200
+
// if err != nil {
1201
+
// log.Println("failed to fetch spindles", err)
1202
+
// return
1203
+
// }
1204
+
1205
+
// var secrets []*tangled.RepoListSecrets_Secret
1206
+
// if f.Spindle != "" {
1207
+
// if spindleClient, err := rp.oauth.ServiceClient(
1208
+
// r,
1209
+
// oauth.WithService(f.Spindle),
1210
+
// oauth.WithLxm(tangled.RepoListSecretsNSID),
1211
+
// oauth.WithDev(rp.config.Core.Dev),
1212
+
// ); err != nil {
1213
+
// log.Println("failed to create spindle client", err)
1214
+
// } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
1215
+
// log.Println("failed to fetch secrets", err)
1216
+
// } else {
1217
+
// secrets = resp.Secrets
1218
+
// }
1219
+
// }
1220
+
1221
+
// rp.pages.RepoSettings(w, pages.RepoSettingsParams{
1222
+
// LoggedInUser: user,
1223
+
// RepoInfo: f.RepoInfo(user),
1224
+
// Collaborators: repoCollaborators,
1225
+
// IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
1226
+
// Branches: result.Branches,
1227
+
// Spindles: spindles,
1228
+
// CurrentSpindle: f.Spindle,
1229
+
// Secrets: secrets,
1230
+
// })
1231
+
}
1232
+
1233
+
func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
1234
+
f, err := rp.repoResolver.Resolve(r)
1235
+
user := rp.oauth.GetUser(r)
1236
+
1237
+
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1238
+
if err != nil {
1239
+
log.Println("failed to create unsigned client", err)
1240
+
return
1241
+
}
1242
+
1243
+
result, err := us.Branches(f.OwnerDid(), f.Name)
1244
+
if err != nil {
1245
+
log.Println("failed to reach knotserver", err)
1246
+
return
1247
+
}
1248
+
1249
+
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
1250
+
LoggedInUser: user,
1251
+
RepoInfo: f.RepoInfo(user),
1252
+
Branches: result.Branches,
1253
+
Tabs: settingsTabs,
1254
+
Tab: "general",
1255
+
})
1256
+
}
1257
+
1258
+
func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
1259
+
f, err := rp.repoResolver.Resolve(r)
1260
+
user := rp.oauth.GetUser(r)
1261
+
1262
+
repoCollaborators, err := f.Collaborators(r.Context())
1263
+
if err != nil {
1264
+
log.Println("failed to get collaborators", err)
1265
+
}
1266
+
1267
+
rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
1268
+
LoggedInUser: user,
1269
+
RepoInfo: f.RepoInfo(user),
1270
+
Tabs: settingsTabs,
1271
+
Tab: "access",
1272
+
Collaborators: repoCollaborators,
1273
+
})
1274
+
}
1275
+
1276
+
func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
1277
+
f, err := rp.repoResolver.Resolve(r)
1278
+
user := rp.oauth.GetUser(r)
1279
+
1280
+
// all spindles that the repo owner is a member of
1281
+
spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
1282
+
if err != nil {
1283
+
log.Println("failed to fetch spindles", err)
1284
+
return
1285
+
}
1286
+
1287
+
var secrets []*tangled.RepoListSecrets_Secret
1288
+
if f.Spindle != "" {
1289
+
if spindleClient, err := rp.oauth.ServiceClient(
1290
+
r,
1291
+
oauth.WithService(f.Spindle),
1292
+
oauth.WithLxm(tangled.RepoListSecretsNSID),
1293
+
oauth.WithExp(60),
1294
+
oauth.WithDev(rp.config.Core.Dev),
1295
+
); err != nil {
1296
+
log.Println("failed to create spindle client", err)
1297
+
} else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
1298
+
log.Println("failed to fetch secrets", err)
1299
+
} else {
1300
+
secrets = resp.Secrets
965
1301
}
1302
+
}
966
1303
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,
1304
+
slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
1305
+
return strings.Compare(a.Key, b.Key)
1306
+
})
1307
+
1308
+
var dids []string
1309
+
for _, s := range secrets {
1310
+
dids = append(dids, s.CreatedBy)
1311
+
}
1312
+
resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
1313
+
1314
+
// convert to a more manageable form
1315
+
var niceSecret []map[string]any
1316
+
for id, s := range secrets {
1317
+
when, _ := time.Parse(time.RFC3339, s.CreatedAt)
1318
+
niceSecret = append(niceSecret, map[string]any{
1319
+
"Id": id,
1320
+
"Key": s.Key,
1321
+
"CreatedAt": when,
1322
+
"CreatedBy": resolvedIdents[id].Handle.String(),
975
1323
})
976
1324
}
1325
+
1326
+
rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
1327
+
LoggedInUser: user,
1328
+
RepoInfo: f.RepoInfo(user),
1329
+
Tabs: settingsTabs,
1330
+
Tab: "pipelines",
1331
+
Spindles: spindles,
1332
+
CurrentSpindle: f.Spindle,
1333
+
Secrets: niceSecret,
1334
+
})
977
1335
}
978
1336
979
1337
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
1338
+
ref := chi.URLParam(r, "ref")
1339
+
980
1340
user := rp.oauth.GetUser(r)
981
1341
f, err := rp.repoResolver.Resolve(r)
982
1342
if err != nil {
···
1004
1364
} else {
1005
1365
uri = "https"
1006
1366
}
1007
-
forkName := fmt.Sprintf("%s", f.RepoName)
1008
-
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1367
+
forkName := fmt.Sprintf("%s", f.Name)
1368
+
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name)
1009
1369
1010
-
_, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref)
1370
+
_, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, ref)
1011
1371
if err != nil {
1012
1372
rp.pages.Notice(w, "repo", "Failed to sync repository fork.")
1013
1373
return
···
1055
1415
return
1056
1416
}
1057
1417
1058
-
forkName := fmt.Sprintf("%s", f.RepoName)
1418
+
forkName := fmt.Sprintf("%s", f.Name)
1059
1419
1060
1420
// this check is *only* to see if the forked repo name already exists
1061
1421
// in the user's account.
1062
-
existingRepo, err := db.GetRepo(rp.db, user.Did, f.RepoName)
1422
+
existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name)
1063
1423
if err != nil {
1064
1424
if errors.Is(err, sql.ErrNoRows) {
1065
1425
// no existing repo with this name found, we can use the name as is
···
1090
1450
} else {
1091
1451
uri = "https"
1092
1452
}
1093
-
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1094
-
sourceAt := f.RepoAt.String()
1453
+
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name)
1454
+
sourceAt := f.RepoAt().String()
1095
1455
1096
-
rkey := appview.TID()
1456
+
rkey := tid.TID()
1097
1457
repo := &db.Repo{
1098
1458
Did: user.Did,
1099
1459
Name: forkName,
···
1160
1520
}
1161
1521
log.Println("created repo record: ", atresp.Uri)
1162
1522
1163
-
repo.AtUri = atresp.Uri
1164
1523
err = db.AddRepo(tx, repo)
1165
1524
if err != nil {
1166
1525
log.Println(err)
···
1211
1570
return
1212
1571
}
1213
1572
1214
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
1573
+
result, err := us.Branches(f.OwnerDid(), f.Name)
1215
1574
if err != nil {
1216
1575
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1217
1576
log.Println("failed to reach knotserver", err)
1218
1577
return
1219
1578
}
1220
1579
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
-
})
1580
+
1581
+
sortBranches(branches)
1224
1582
1225
1583
var defaultBranch string
1226
1584
for _, b := range branches {
···
1242
1600
head = queryHead
1243
1601
}
1244
1602
1245
-
tags, err := us.Tags(f.OwnerDid(), f.RepoName)
1603
+
tags, err := us.Tags(f.OwnerDid(), f.Name)
1246
1604
if err != nil {
1247
1605
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1248
1606
log.Println("failed to reach knotserver", err)
···
1269
1627
return
1270
1628
}
1271
1629
1630
+
var diffOpts types.DiffOpts
1631
+
if d := r.URL.Query().Get("diff"); d == "split" {
1632
+
diffOpts.Split = true
1633
+
}
1634
+
1272
1635
// if user is navigating to one of
1273
1636
// /compare/{base}/{head}
1274
1637
// /compare/{base}...{head}
···
1299
1662
return
1300
1663
}
1301
1664
1302
-
branches, err := us.Branches(f.OwnerDid(), f.RepoName)
1665
+
branches, err := us.Branches(f.OwnerDid(), f.Name)
1303
1666
if err != nil {
1304
1667
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1305
1668
log.Println("failed to reach knotserver", err)
1306
1669
return
1307
1670
}
1308
1671
1309
-
tags, err := us.Tags(f.OwnerDid(), f.RepoName)
1672
+
tags, err := us.Tags(f.OwnerDid(), f.Name)
1310
1673
if err != nil {
1311
1674
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1312
1675
log.Println("failed to reach knotserver", err)
1313
1676
return
1314
1677
}
1315
1678
1316
-
formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head)
1679
+
formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head)
1317
1680
if err != nil {
1318
1681
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1319
1682
log.Println("failed to compare", err)
···
1331
1694
Base: base,
1332
1695
Head: head,
1333
1696
Diff: &diff,
1697
+
DiffOpts: diffOpts,
1334
1698
})
1335
1699
1336
1700
}
+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{})
+7
appview/repo/router.go
+7
appview/repo/router.go
···
10
10
func (rp *Repo) Router(mw *middleware.Middleware) http.Handler {
11
11
r := chi.NewRouter()
12
12
r.Get("/", rp.RepoIndex)
13
+
r.Get("/feed.atom", rp.RepoAtomFeed)
13
14
r.Get("/commits/{ref}", rp.RepoLog)
14
15
r.Route("/tree/{ref}", func(r chi.Router) {
15
16
r.Get("/", rp.RepoIndex)
···
37
38
})
38
39
r.Get("/blob/{ref}/*", rp.RepoBlob)
39
40
r.Get("/raw/{ref}/*", rp.RepoBlobRaw)
41
+
42
+
// intentionally doesn't use /* as this isn't
43
+
// a file path
44
+
r.Get("/archive/{ref}", rp.DownloadArchive)
40
45
41
46
r.Route("/fork", func(r chi.Router) {
42
47
r.Use(middleware.AuthMiddleware(rp.oauth))
···
74
79
r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator)
75
80
r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo)
76
81
r.Put("/branches/default", rp.SetDefaultBranch)
82
+
r.Put("/secrets", rp.Secrets)
83
+
r.Delete("/secrets", rp.Secrets)
77
84
})
78
85
})
79
86
+42
-108
appview/reporesolver/resolver.go
+42
-108
appview/reporesolver/resolver.go
···
7
7
"fmt"
8
8
"log"
9
9
"net/http"
10
-
"net/url"
11
10
"path"
11
+
"regexp"
12
12
"strings"
13
13
14
14
"github.com/bluesky-social/indigo/atproto/identity"
15
-
"github.com/bluesky-social/indigo/atproto/syntax"
16
15
securejoin "github.com/cyphar/filepath-securejoin"
17
16
"github.com/go-chi/chi/v5"
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"
21
19
"tangled.sh/tangled.sh/core/appview/oauth"
22
20
"tangled.sh/tangled.sh/core/appview/pages"
23
21
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
24
-
"tangled.sh/tangled.sh/core/knotclient"
22
+
"tangled.sh/tangled.sh/core/idresolver"
25
23
"tangled.sh/tangled.sh/core/rbac"
26
24
)
27
25
28
26
type ResolvedRepo struct {
29
-
Knot string
30
-
OwnerId identity.Identity
31
-
RepoName string
32
-
RepoAt syntax.ATURI
33
-
Description string
34
-
Spindle string
35
-
CreatedAt string
36
-
Ref string
37
-
CurrentDir string
27
+
db.Repo
28
+
OwnerId identity.Identity
29
+
CurrentDir string
30
+
Ref string
38
31
39
32
rr *RepoResolver
40
33
}
···
51
44
}
52
45
53
46
func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) {
54
-
repoName := chi.URLParam(r, "repo")
55
-
knot, ok := r.Context().Value("knot").(string)
47
+
repo, ok := r.Context().Value("repo").(*db.Repo)
56
48
if !ok {
57
-
log.Println("malformed middleware")
49
+
log.Println("malformed middleware: `repo` not exist in context")
58
50
return nil, fmt.Errorf("malformed middleware")
59
51
}
60
52
id, ok := r.Context().Value("resolvedId").(identity.Identity)
···
63
55
return nil, fmt.Errorf("malformed middleware")
64
56
}
65
57
66
-
repoAt, ok := r.Context().Value("repoAt").(string)
67
-
if !ok {
68
-
log.Println("malformed middleware")
69
-
return nil, fmt.Errorf("malformed middleware")
70
-
}
71
-
72
-
parsedRepoAt, err := syntax.ParseATURI(repoAt)
73
-
if err != nil {
74
-
log.Println("malformed repo at-uri")
75
-
return nil, fmt.Errorf("malformed middleware")
76
-
}
77
-
58
+
currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath()))
78
59
ref := chi.URLParam(r, "ref")
79
60
80
-
if ref == "" {
81
-
us, err := knotclient.NewUnsignedClient(knot, rr.config.Core.Dev)
82
-
if err != nil {
83
-
return nil, err
84
-
}
85
-
86
-
defaultBranch, err := us.DefaultBranch(id.DID.String(), repoName)
87
-
if err != nil {
88
-
return nil, err
89
-
}
90
-
91
-
ref = defaultBranch.Branch
92
-
}
93
-
94
-
currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath(), ref))
95
-
96
-
// pass through values from the middleware
97
-
description, ok := r.Context().Value("repoDescription").(string)
98
-
addedAt, ok := r.Context().Value("repoAddedAt").(string)
99
-
spindle, ok := r.Context().Value("repoSpindle").(string)
100
-
101
61
return &ResolvedRepo{
102
-
Knot: knot,
103
-
OwnerId: id,
104
-
RepoName: repoName,
105
-
RepoAt: parsedRepoAt,
106
-
Description: description,
107
-
CreatedAt: addedAt,
108
-
Ref: ref,
109
-
CurrentDir: currentDir,
110
-
Spindle: spindle,
62
+
Repo: *repo,
63
+
OwnerId: id,
64
+
CurrentDir: currentDir,
65
+
Ref: ref,
111
66
112
67
rr: rr,
113
68
}, nil
···
126
81
127
82
var p string
128
83
if handle != "" && !handle.IsInvalidHandle() {
129
-
p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName)
84
+
p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.Name)
130
85
} else {
131
-
p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
86
+
p, _ = securejoin.SecureJoin(f.OwnerDid(), f.Name)
132
87
}
133
88
134
89
return p
135
90
}
136
91
137
-
func (f *ResolvedRepo) DidSlashRepo() string {
138
-
p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
139
-
return p
140
-
}
141
-
142
92
func (f *ResolvedRepo) Collaborators(ctx context.Context) ([]pages.Collaborator, error) {
143
93
repoCollaborators, err := f.rr.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
144
94
if err != nil {
···
149
99
for _, item := range repoCollaborators {
150
100
// currently only two roles: owner and member
151
101
var role string
152
-
if item[3] == "repo:owner" {
102
+
switch item[3] {
103
+
case "repo:owner":
153
104
role = "owner"
154
-
} else if item[3] == "repo:collaborator" {
105
+
case "repo:collaborator":
155
106
role = "collaborator"
156
-
} else {
107
+
default:
157
108
continue
158
109
}
159
110
···
186
137
// this function is a bit weird since it now returns RepoInfo from an entirely different
187
138
// package. we should refactor this or get rid of RepoInfo entirely.
188
139
func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo {
140
+
repoAt := f.RepoAt()
189
141
isStarred := false
190
142
if user != nil {
191
-
isStarred = db.GetStarStatus(f.rr.execer, user.Did, syntax.ATURI(f.RepoAt))
143
+
isStarred = db.GetStarStatus(f.rr.execer, user.Did, repoAt)
192
144
}
193
145
194
-
starCount, err := db.GetStarCount(f.rr.execer, f.RepoAt)
146
+
starCount, err := db.GetStarCount(f.rr.execer, repoAt)
195
147
if err != nil {
196
-
log.Println("failed to get star count for ", f.RepoAt)
148
+
log.Println("failed to get star count for ", repoAt)
197
149
}
198
-
issueCount, err := db.GetIssueCount(f.rr.execer, f.RepoAt)
150
+
issueCount, err := db.GetIssueCount(f.rr.execer, repoAt)
199
151
if err != nil {
200
-
log.Println("failed to get issue count for ", f.RepoAt)
152
+
log.Println("failed to get issue count for ", repoAt)
201
153
}
202
-
pullCount, err := db.GetPullCount(f.rr.execer, f.RepoAt)
154
+
pullCount, err := db.GetPullCount(f.rr.execer, repoAt)
203
155
if err != nil {
204
-
log.Println("failed to get issue count for ", f.RepoAt)
156
+
log.Println("failed to get issue count for ", repoAt)
205
157
}
206
-
source, err := db.GetRepoSource(f.rr.execer, f.RepoAt)
158
+
source, err := db.GetRepoSource(f.rr.execer, repoAt)
207
159
if errors.Is(err, sql.ErrNoRows) {
208
160
source = ""
209
161
} else if err != nil {
210
-
log.Println("failed to get repo source for ", f.RepoAt, err)
162
+
log.Println("failed to get repo source for ", repoAt, err)
211
163
}
212
164
213
165
var sourceRepo *db.Repo
···
227
179
}
228
180
229
181
knot := f.Knot
230
-
var disableFork bool
231
-
us, err := knotclient.NewUnsignedClient(knot, f.rr.config.Core.Dev)
232
-
if err != nil {
233
-
log.Printf("failed to create unsigned client for %s: %v", knot, err)
234
-
} else {
235
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
236
-
if err != nil {
237
-
log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err)
238
-
}
239
-
240
-
if len(result.Branches) == 0 {
241
-
disableFork = true
242
-
}
243
-
}
244
182
245
183
repoInfo := repoinfo.RepoInfo{
246
184
OwnerDid: f.OwnerDid(),
247
185
OwnerHandle: f.OwnerHandle(),
248
-
Name: f.RepoName,
249
-
RepoAt: f.RepoAt,
186
+
Name: f.Name,
187
+
RepoAt: repoAt,
250
188
Description: f.Description,
251
-
Ref: f.Ref,
252
189
IsStarred: isStarred,
253
190
Knot: knot,
254
191
Spindle: f.Spindle,
···
258
195
IssueCount: issueCount,
259
196
PullCount: pullCount,
260
197
},
261
-
DisableFork: disableFork,
262
-
CurrentDir: f.CurrentDir,
198
+
CurrentDir: f.CurrentDir,
199
+
Ref: f.Ref,
263
200
}
264
201
265
202
if sourceRepo != nil {
···
283
220
// after the ref. for example:
284
221
//
285
222
// /@icyphox.sh/foorepo/blob/main/abc/xyz/ => abc/xyz/
286
-
func extractPathAfterRef(fullPath, ref string) string {
223
+
func extractPathAfterRef(fullPath string) string {
287
224
fullPath = strings.TrimPrefix(fullPath, "/")
288
225
289
-
ref = url.PathEscape(ref)
226
+
// match blob/, tree/, or raw/ followed by any ref and then a slash
227
+
//
228
+
// captures everything after the final slash
229
+
pattern := `(?:blob|tree|raw)/[^/]+/(.*)$`
290
230
291
-
prefixes := []string{
292
-
fmt.Sprintf("blob/%s/", ref),
293
-
fmt.Sprintf("tree/%s/", ref),
294
-
fmt.Sprintf("raw/%s/", ref),
295
-
}
231
+
re := regexp.MustCompile(pattern)
232
+
matches := re.FindStringSubmatch(fullPath)
296
233
297
-
for _, prefix := range prefixes {
298
-
idx := strings.Index(fullPath, prefix)
299
-
if idx != -1 {
300
-
return fullPath[idx+len(prefix):]
301
-
}
234
+
if len(matches) > 1 {
235
+
return matches[1]
302
236
}
303
237
304
238
return ""
+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
+
}
+256
appview/signup/signup.go
+256
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.Get("/", s.signup)
108
+
r.Post("/", s.signup)
109
+
r.Get("/complete", s.complete)
110
+
r.Post("/complete", s.complete)
111
+
112
+
return r
113
+
}
114
+
115
+
func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
116
+
switch r.Method {
117
+
case http.MethodGet:
118
+
s.pages.Signup(w)
119
+
case http.MethodPost:
120
+
if s.cf == nil {
121
+
http.Error(w, "signup is disabled", http.StatusFailedDependency)
122
+
}
123
+
emailId := r.FormValue("email")
124
+
125
+
noticeId := "signup-msg"
126
+
if !email.IsValidEmail(emailId) {
127
+
s.pages.Notice(w, noticeId, "Invalid email address.")
128
+
return
129
+
}
130
+
131
+
exists, err := db.CheckEmailExistsAtAll(s.db, emailId)
132
+
if err != nil {
133
+
s.l.Error("failed to check email existence", "error", err)
134
+
s.pages.Notice(w, noticeId, "Failed to complete signup. Try again later.")
135
+
return
136
+
}
137
+
if exists {
138
+
s.pages.Notice(w, noticeId, "Email already exists.")
139
+
return
140
+
}
141
+
142
+
code, err := s.inviteCodeRequest()
143
+
if err != nil {
144
+
s.l.Error("failed to create invite code", "error", err)
145
+
s.pages.Notice(w, noticeId, "Failed to create invite code.")
146
+
return
147
+
}
148
+
149
+
em := email.Email{
150
+
APIKey: s.config.Resend.ApiKey,
151
+
From: s.config.Resend.SentFrom,
152
+
To: emailId,
153
+
Subject: "Verify your Tangled account",
154
+
Text: `Copy and paste this code below to verify your account on Tangled.
155
+
` + code,
156
+
Html: `<p>Copy and paste this code below to verify your account on Tangled.</p>
157
+
<p><code>` + code + `</code></p>`,
158
+
}
159
+
160
+
err = email.SendEmail(em)
161
+
if err != nil {
162
+
s.l.Error("failed to send email", "error", err)
163
+
s.pages.Notice(w, noticeId, "Failed to send email.")
164
+
return
165
+
}
166
+
err = db.AddInflightSignup(s.db, db.InflightSignup{
167
+
Email: emailId,
168
+
InviteCode: code,
169
+
})
170
+
if err != nil {
171
+
s.l.Error("failed to add inflight signup", "error", err)
172
+
s.pages.Notice(w, noticeId, "Failed to complete sign up. Try again later.")
173
+
return
174
+
}
175
+
176
+
s.pages.HxRedirect(w, "/signup/complete")
177
+
}
178
+
}
179
+
180
+
func (s *Signup) complete(w http.ResponseWriter, r *http.Request) {
181
+
switch r.Method {
182
+
case http.MethodGet:
183
+
s.pages.CompleteSignup(w)
184
+
case http.MethodPost:
185
+
username := r.FormValue("username")
186
+
password := r.FormValue("password")
187
+
code := r.FormValue("code")
188
+
189
+
if !userutil.IsValidSubdomain(username) {
190
+
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.")
191
+
return
192
+
}
193
+
194
+
if !s.isNicknameAllowed(username) {
195
+
s.pages.Notice(w, "signup-error", "This username is not available. Please choose a different one.")
196
+
return
197
+
}
198
+
199
+
email, err := db.GetEmailForCode(s.db, code)
200
+
if err != nil {
201
+
s.l.Error("failed to get email for code", "error", err)
202
+
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
203
+
return
204
+
}
205
+
206
+
did, err := s.createAccountRequest(username, password, email, code)
207
+
if err != nil {
208
+
s.l.Error("failed to create account", "error", err)
209
+
s.pages.Notice(w, "signup-error", err.Error())
210
+
return
211
+
}
212
+
213
+
if s.cf == nil {
214
+
s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration")
215
+
s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.")
216
+
return
217
+
}
218
+
219
+
err = s.cf.CreateDNSRecord(r.Context(), dns.Record{
220
+
Type: "TXT",
221
+
Name: "_atproto." + username,
222
+
Content: fmt.Sprintf(`"did=%s"`, did),
223
+
TTL: 6400,
224
+
Proxied: false,
225
+
})
226
+
if err != nil {
227
+
s.l.Error("failed to create DNS record", "error", err)
228
+
s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.")
229
+
return
230
+
}
231
+
232
+
err = db.AddEmail(s.db, db.Email{
233
+
Did: did,
234
+
Address: email,
235
+
Verified: true,
236
+
Primary: true,
237
+
})
238
+
if err != nil {
239
+
s.l.Error("failed to add email", "error", err)
240
+
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
241
+
return
242
+
}
243
+
244
+
s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now
245
+
<a class="underline text-black dark:text-white" href="/login">login</a>
246
+
with <code>%s.tngl.sh</code>.`, username))
247
+
248
+
go func() {
249
+
err := db.DeleteInflightSignup(s.db, email)
250
+
if err != nil {
251
+
s.l.Error("failed to delete inflight signup", "error", err)
252
+
}
253
+
}()
254
+
return
255
+
}
256
+
}
+29
-30
appview/spindles/spindles.go
+29
-30
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 {
···
112
113
return
113
114
}
114
115
115
-
identsToResolve := make([]string, len(members))
116
-
for i, member := range members {
117
-
identsToResolve[i] = member
118
-
}
119
-
resolvedIds := s.IdResolver.ResolveIdents(r.Context(), identsToResolve)
120
-
didHandleMap := make(map[string]string)
121
-
for _, identity := range resolvedIds {
122
-
if !identity.Handle.IsInvalidHandle() {
123
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
124
-
} else {
125
-
didHandleMap[identity.DID.String()] = identity.DID.String()
126
-
}
127
-
}
128
-
129
116
// organize repos by did
130
117
repoMap := make(map[string][]db.Repo)
131
118
for _, r := range repos {
···
137
124
Spindle: spindle,
138
125
Members: members,
139
126
Repos: repoMap,
140
-
DidHandleMap: didHandleMap,
141
127
})
142
128
}
143
129
···
257
243
258
244
// ok
259
245
s.Pages.HxRefresh(w)
260
-
return
261
246
}
262
247
263
248
func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) {
···
305
290
s.Enforcer.E.LoadPolicy()
306
291
}()
307
292
293
+
// remove spindle members first
294
+
err = db.RemoveSpindleMember(
295
+
tx,
296
+
db.FilterEq("did", user.Did),
297
+
db.FilterEq("instance", instance),
298
+
)
299
+
if err != nil {
300
+
l.Error("failed to remove spindle members", "err", err)
301
+
fail()
302
+
return
303
+
}
304
+
308
305
err = db.DeleteSpindle(
309
306
tx,
310
307
db.FilterEq("owner", user.Did),
···
316
313
return
317
314
}
318
315
319
-
err = s.Enforcer.RemoveSpindle(instance)
320
-
if err != nil {
321
-
l.Error("failed to update ACL", "err", err)
322
-
fail()
323
-
return
316
+
// delete from enforcer
317
+
if spindles[0].Verified != nil {
318
+
err = s.Enforcer.RemoveSpindle(instance)
319
+
if err != nil {
320
+
l.Error("failed to update ACL", "err", err)
321
+
fail()
322
+
return
323
+
}
324
324
}
325
325
326
326
client, err := s.OAuth.AuthorizedClient(r)
···
520
520
s.Enforcer.E.LoadPolicy()
521
521
}()
522
522
523
-
rkey := appview.TID()
523
+
rkey := tid.TID()
524
524
525
525
// add member to db
526
526
if err = db.AddSpindleMember(tx, db.SpindleMember{
···
579
579
l := s.Logger.With("handler", "removeMember")
580
580
581
581
noticeId := "operation-error"
582
-
defaultErr := "Failed to add member. Try again later."
582
+
defaultErr := "Failed to remove member. Try again later."
583
583
fail := func() {
584
584
s.Pages.Notice(w, noticeId, defaultErr)
585
585
}
···
606
606
607
607
if string(spindles[0].Owner) != user.Did {
608
608
l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner)
609
-
s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.")
609
+
s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.")
610
610
return
611
611
}
612
612
613
613
member := r.FormValue("member")
614
614
if member == "" {
615
615
l.Error("empty member")
616
-
s.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
616
+
s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
617
617
return
618
618
}
619
619
l = l.With("member", member)
···
621
621
memberId, err := s.IdResolver.ResolveIdent(r.Context(), member)
622
622
if err != nil {
623
623
l.Error("failed to resolve member identity to handle", "err", err)
624
-
s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
624
+
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
625
625
return
626
626
}
627
627
if memberId.Handle.IsInvalidHandle() {
628
628
l.Error("failed to resolve member identity to handle")
629
-
s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
629
+
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
630
630
return
631
631
}
632
632
···
707
707
708
708
// ok
709
709
s.Pages.HxRefresh(w)
710
-
return
711
710
}
+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
}
+9
-12
appview/state/git_http.go
+9
-12
appview/state/git_http.go
···
3
3
import (
4
4
"fmt"
5
5
"io"
6
+
"maps"
6
7
"net/http"
7
8
8
9
"github.com/bluesky-social/indigo/atproto/identity"
9
10
"github.com/go-chi/chi/v5"
11
+
"tangled.sh/tangled.sh/core/appview/db"
10
12
)
11
13
12
14
func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) {
13
15
user := r.Context().Value("resolvedId").(identity.Identity)
14
-
knot := r.Context().Value("knot").(string)
15
-
repo := chi.URLParam(r, "repo")
16
+
repo := r.Context().Value("repo").(*db.Repo)
16
17
17
18
scheme := "https"
18
19
if s.config.Core.Dev {
19
20
scheme = "http"
20
21
}
21
22
22
-
targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, knot, user.DID, repo, r.URL.RawQuery)
23
+
targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery)
23
24
s.proxyRequest(w, r, targetURL)
24
25
25
26
}
···
30
31
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
31
32
return
32
33
}
33
-
knot := r.Context().Value("knot").(string)
34
-
repo := chi.URLParam(r, "repo")
34
+
repo := r.Context().Value("repo").(*db.Repo)
35
35
36
36
scheme := "https"
37
37
if s.config.Core.Dev {
38
38
scheme = "http"
39
39
}
40
40
41
-
targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery)
41
+
targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery)
42
42
s.proxyRequest(w, r, targetURL)
43
43
}
44
44
···
48
48
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
49
49
return
50
50
}
51
-
knot := r.Context().Value("knot").(string)
52
-
repo := chi.URLParam(r, "repo")
51
+
repo := r.Context().Value("repo").(*db.Repo)
53
52
54
53
scheme := "https"
55
54
if s.config.Core.Dev {
56
55
scheme = "http"
57
56
}
58
57
59
-
targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery)
58
+
targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery)
60
59
s.proxyRequest(w, r, targetURL)
61
60
}
62
61
···
85
84
defer resp.Body.Close()
86
85
87
86
// Copy response headers
88
-
for k, v := range resp.Header {
89
-
w.Header()[k] = v
90
-
}
87
+
maps.Copy(w.Header(), resp.Header)
91
88
92
89
// Set response status code
93
90
w.WriteHeader(resp.StatusCode)
+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
+155
-76
appview/state/profile.go
+155
-76
appview/state/profile.go
···
1
1
package state
2
2
3
3
import (
4
-
"crypto/hmac"
5
-
"crypto/sha256"
6
-
"encoding/hex"
4
+
"context"
7
5
"fmt"
8
6
"log"
9
7
"net/http"
···
16
14
"github.com/bluesky-social/indigo/atproto/syntax"
17
15
lexutil "github.com/bluesky-social/indigo/lex/util"
18
16
"github.com/go-chi/chi/v5"
19
-
"github.com/posthog/posthog-go"
17
+
"github.com/gorilla/feeds"
20
18
"tangled.sh/tangled.sh/core/api/tangled"
21
19
"tangled.sh/tangled.sh/core/appview/db"
22
20
"tangled.sh/tangled.sh/core/appview/pages"
···
50
48
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
51
49
}
52
50
53
-
repos, err := db.GetAllReposByDid(s.db, ident.DID.String())
51
+
repos, err := db.GetRepos(
52
+
s.db,
53
+
0,
54
+
db.FilterEq("did", ident.DID.String()),
55
+
)
54
56
if err != nil {
55
57
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
56
58
}
···
87
89
log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err)
88
90
}
89
91
90
-
var didsToResolve []string
91
-
for _, r := range collaboratingRepos {
92
-
didsToResolve = append(didsToResolve, r.Did)
93
-
}
94
-
for _, byMonth := range timeline.ByMonth {
95
-
for _, pe := range byMonth.PullEvents.Items {
96
-
didsToResolve = append(didsToResolve, pe.Repo.Did)
97
-
}
98
-
for _, ie := range byMonth.IssueEvents.Items {
99
-
didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did)
100
-
}
101
-
for _, re := range byMonth.RepoEvents {
102
-
didsToResolve = append(didsToResolve, re.Repo.Did)
103
-
if re.Source != nil {
104
-
didsToResolve = append(didsToResolve, re.Source.Did)
105
-
}
106
-
}
107
-
}
108
-
109
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve)
110
-
didHandleMap := make(map[string]string)
111
-
for _, identity := range resolvedIds {
112
-
if !identity.Handle.IsInvalidHandle() {
113
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
114
-
} else {
115
-
didHandleMap[identity.DID.String()] = identity.DID.String()
116
-
}
117
-
}
118
-
119
-
followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String())
92
+
followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String())
120
93
if err != nil {
121
94
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
122
95
}
···
139
112
log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err)
140
113
}
141
114
142
-
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
143
115
s.pages.ProfilePage(w, pages.ProfilePageParams{
144
116
LoggedInUser: loggedInUser,
145
117
Repos: pinnedRepos,
146
118
CollaboratingRepos: pinnedCollaboratingRepos,
147
-
DidHandleMap: didHandleMap,
148
119
Card: pages.ProfileCard{
149
120
UserDid: ident.DID.String(),
150
121
UserHandle: ident.Handle.String(),
151
-
AvatarUri: profileAvatarUri,
152
122
Profile: profile,
153
123
FollowStatus: followStatus,
154
124
Followers: followers,
···
171
141
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
172
142
}
173
143
174
-
repos, err := db.GetAllReposByDid(s.db, ident.DID.String())
144
+
repos, err := db.GetRepos(
145
+
s.db,
146
+
0,
147
+
db.FilterEq("did", ident.DID.String()),
148
+
)
175
149
if err != nil {
176
150
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
177
151
}
···
182
156
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
183
157
}
184
158
185
-
followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String())
159
+
followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String())
186
160
if err != nil {
187
161
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
188
162
}
189
163
190
-
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
191
-
192
164
s.pages.ReposPage(w, pages.ReposPageParams{
193
165
LoggedInUser: loggedInUser,
194
166
Repos: repos,
195
167
Card: pages.ProfileCard{
196
168
UserDid: ident.DID.String(),
197
169
UserHandle: ident.Handle.String(),
198
-
AvatarUri: profileAvatarUri,
199
170
Profile: profile,
200
171
FollowStatus: followStatus,
201
172
Followers: followers,
···
204
175
})
205
176
}
206
177
207
-
func (s *State) GetAvatarUri(handle string) string {
208
-
secret := s.config.Avatar.SharedSecret
209
-
h := hmac.New(sha256.New, []byte(secret))
210
-
h.Write([]byte(handle))
211
-
signature := hex.EncodeToString(h.Sum(nil))
212
-
return fmt.Sprintf("%s/%s/%s", s.config.Avatar.Host, signature, handle)
178
+
func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) {
179
+
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
180
+
if !ok {
181
+
s.pages.Error404(w)
182
+
return
183
+
}
184
+
185
+
feed, err := s.getProfileFeed(r.Context(), &ident)
186
+
if err != nil {
187
+
s.pages.Error500(w)
188
+
return
189
+
}
190
+
191
+
if feed == nil {
192
+
return
193
+
}
194
+
195
+
atom, err := feed.ToAtom()
196
+
if err != nil {
197
+
s.pages.Error500(w)
198
+
return
199
+
}
200
+
201
+
w.Header().Set("content-type", "application/atom+xml")
202
+
w.Write([]byte(atom))
203
+
}
204
+
205
+
func (s *State) getProfileFeed(ctx context.Context, id *identity.Identity) (*feeds.Feed, error) {
206
+
timeline, err := db.MakeProfileTimeline(s.db, id.DID.String())
207
+
if err != nil {
208
+
return nil, err
209
+
}
210
+
211
+
author := &feeds.Author{
212
+
Name: fmt.Sprintf("@%s", id.Handle),
213
+
}
214
+
215
+
feed := feeds.Feed{
216
+
Title: fmt.Sprintf("%s's timeline", author.Name),
217
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, id.Handle), Type: "text/html", Rel: "alternate"},
218
+
Items: make([]*feeds.Item, 0),
219
+
Updated: time.UnixMilli(0),
220
+
Author: author,
221
+
}
222
+
223
+
for _, byMonth := range timeline.ByMonth {
224
+
if err := s.addPullRequestItems(ctx, &feed, byMonth.PullEvents.Items, author); err != nil {
225
+
return nil, err
226
+
}
227
+
if err := s.addIssueItems(ctx, &feed, byMonth.IssueEvents.Items, author); err != nil {
228
+
return nil, err
229
+
}
230
+
if err := s.addRepoItems(ctx, &feed, byMonth.RepoEvents, author); err != nil {
231
+
return nil, err
232
+
}
233
+
}
234
+
235
+
slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int {
236
+
return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli())
237
+
})
238
+
239
+
if len(feed.Items) > 0 {
240
+
feed.Updated = feed.Items[0].Created
241
+
}
242
+
243
+
return &feed, nil
244
+
}
245
+
246
+
func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error {
247
+
for _, pull := range pulls {
248
+
owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did)
249
+
if err != nil {
250
+
return err
251
+
}
252
+
253
+
// Add pull request creation item
254
+
feed.Items = append(feed.Items, s.createPullRequestItem(pull, owner, author))
255
+
}
256
+
return nil
257
+
}
258
+
259
+
func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error {
260
+
for _, issue := range issues {
261
+
owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did)
262
+
if err != nil {
263
+
return err
264
+
}
265
+
266
+
feed.Items = append(feed.Items, s.createIssueItem(issue, owner, author))
267
+
}
268
+
return nil
269
+
}
270
+
271
+
func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error {
272
+
for _, repo := range repos {
273
+
item, err := s.createRepoItem(ctx, repo, author)
274
+
if err != nil {
275
+
return err
276
+
}
277
+
feed.Items = append(feed.Items, item)
278
+
}
279
+
return nil
280
+
}
281
+
282
+
func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item {
283
+
return &feeds.Item{
284
+
Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name),
285
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"},
286
+
Created: pull.Created,
287
+
Author: author,
288
+
}
289
+
}
290
+
291
+
func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item {
292
+
return &feeds.Item{
293
+
Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name),
294
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Metadata.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"},
295
+
Created: issue.Created,
296
+
Author: author,
297
+
}
298
+
}
299
+
300
+
func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) {
301
+
var title string
302
+
if repo.Source != nil {
303
+
sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did)
304
+
if err != nil {
305
+
return nil, err
306
+
}
307
+
title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, sourceOwner.Handle, repo.Source.Name, repo.Repo.Name)
308
+
} else {
309
+
title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name)
310
+
}
311
+
312
+
return &feeds.Item{
313
+
Title: title,
314
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.AppviewHost, author.Name[1:], repo.Repo.Name), Type: "text/html", Rel: "alternate"}, // Remove @ prefix
315
+
Created: repo.Repo.Created,
316
+
Author: author,
317
+
}, nil
213
318
}
214
319
215
320
func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
···
257
362
}
258
363
259
364
s.updateProfile(profile, w, r)
260
-
return
261
365
}
262
366
263
367
func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) {
···
297
401
profile.PinnedRepos = pinnedRepos
298
402
299
403
s.updateProfile(profile, w, r)
300
-
return
301
404
}
302
405
303
406
func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) {
···
362
465
return
363
466
}
364
467
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
-
}
468
+
s.notifier.UpdateProfile(r.Context(), profile)
374
469
375
470
s.pages.HxRedirect(w, "/"+user.Did)
376
-
return
377
471
}
378
472
379
473
func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) {
···
425
519
})
426
520
}
427
521
428
-
var didsToResolve []string
429
-
for _, r := range allRepos {
430
-
didsToResolve = append(didsToResolve, r.Did)
431
-
}
432
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve)
433
-
didHandleMap := make(map[string]string)
434
-
for _, identity := range resolvedIds {
435
-
if !identity.Handle.IsInvalidHandle() {
436
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
437
-
} else {
438
-
didHandleMap[identity.DID.String()] = identity.DID.String()
439
-
}
440
-
}
441
-
442
522
s.pages.EditPinsFragment(w, pages.EditPinsParams{
443
523
LoggedInUser: user,
444
524
Profile: profile,
445
525
AllRepos: allRepos,
446
-
DidHandleMap: didHandleMap,
447
526
})
448
527
}
+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
+
}
+74
-28
appview/state/router.go
+74
-28
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"
20
+
avstrings "tangled.sh/tangled.sh/core/appview/strings"
18
21
"tangled.sh/tangled.sh/core/log"
19
22
)
20
23
···
29
32
s.pages,
30
33
)
31
34
35
+
router.Get("/favicon.svg", s.Favicon)
36
+
router.Get("/favicon.ico", s.Favicon)
37
+
38
+
userRouter := s.UserRouter(&middleware)
39
+
standardRouter := s.StandardRouter(&middleware)
40
+
32
41
router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
33
42
pat := chi.URLParam(r, "*")
34
43
if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") {
35
-
s.UserRouter(&middleware).ServeHTTP(w, r)
44
+
userRouter.ServeHTTP(w, r)
36
45
} else {
37
46
// Check if the first path element is a valid handle without '@' or a flattened DID
38
47
pathParts := strings.SplitN(pat, "/", 2)
···
55
64
return
56
65
}
57
66
}
58
-
s.StandardRouter(&middleware).ServeHTTP(w, r)
67
+
standardRouter.ServeHTTP(w, r)
59
68
}
60
69
})
61
70
···
65
74
func (s *State) UserRouter(mw *middleware.Middleware) http.Handler {
66
75
r := chi.NewRouter()
67
76
68
-
// strip @ from user
69
-
r.Use(middleware.StripLeadingAt)
70
-
71
77
r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) {
72
78
r.Get("/", s.Profile)
79
+
r.Get("/feed.atom", s.AtomFeedPage)
80
+
81
+
// redirect /@handle/repo.git -> /@handle/repo
82
+
r.Get("/{repo}.git", func(w http.ResponseWriter, r *http.Request) {
83
+
nonDotGitPath := strings.TrimSuffix(r.URL.Path, ".git")
84
+
http.Redirect(w, r, nonDotGitPath, http.StatusMovedPermanently)
85
+
})
73
86
74
87
r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) {
75
88
r.Use(mw.GoImport())
76
-
77
89
r.Mount("/", s.RepoRouter(mw))
78
90
r.Mount("/issues", s.IssuesRouter(mw))
79
91
r.Mount("/pulls", s.PullsRouter(mw))
···
101
113
102
114
r.Get("/", s.Timeline)
103
115
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
116
r.Route("/repo", func(r chi.Router) {
122
117
r.Route("/new", func(r chi.Router) {
123
118
r.Use(middleware.AuthMiddleware(s.oauth))
···
137
132
r.Delete("/", s.Star)
138
133
})
139
134
135
+
r.With(middleware.AuthMiddleware(s.oauth)).Route("/react", func(r chi.Router) {
136
+
r.Post("/", s.React)
137
+
r.Delete("/", s.React)
138
+
})
139
+
140
140
r.Route("/profile", func(r chi.Router) {
141
141
r.Use(middleware.AuthMiddleware(s.oauth))
142
142
r.Get("/edit-bio", s.EditBioFragment)
···
146
146
})
147
147
148
148
r.Mount("/settings", s.SettingsRouter())
149
+
r.Mount("/strings", s.StringsRouter(mw))
150
+
r.Mount("/knots", s.KnotsRouter(mw))
149
151
r.Mount("/spindles", s.SpindlesRouter())
152
+
r.Mount("/signup", s.SignupRouter())
150
153
r.Mount("/", s.OAuthRouter())
151
154
152
155
r.Get("/keys/{user}", s.Keys)
156
+
r.Get("/terms", s.TermsOfService)
157
+
r.Get("/privacy", s.PrivacyPolicy)
153
158
154
159
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
155
160
s.pages.Error404(w)
···
190
195
return spindles.Router()
191
196
}
192
197
198
+
func (s *State) KnotsRouter(mw *middleware.Middleware) http.Handler {
199
+
logger := log.New("knots")
200
+
201
+
knots := &knots.Knots{
202
+
Db: s.db,
203
+
OAuth: s.oauth,
204
+
Pages: s.pages,
205
+
Config: s.config,
206
+
Enforcer: s.enforcer,
207
+
IdResolver: s.idResolver,
208
+
Knotstream: s.knotstream,
209
+
Logger: logger,
210
+
}
211
+
212
+
return knots.Router(mw)
213
+
}
214
+
215
+
func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
216
+
logger := log.New("strings")
217
+
218
+
strs := &avstrings.Strings{
219
+
Db: s.db,
220
+
OAuth: s.oauth,
221
+
Pages: s.pages,
222
+
Config: s.config,
223
+
Enforcer: s.enforcer,
224
+
IdResolver: s.idResolver,
225
+
Knotstream: s.knotstream,
226
+
Logger: logger,
227
+
}
228
+
229
+
return strs.Router(mw)
230
+
}
231
+
193
232
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)
233
+
issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier)
195
234
return issues.Router(mw)
196
-
197
235
}
198
236
199
237
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)
238
+
pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier)
201
239
return pulls.Router(mw)
202
240
}
203
241
204
242
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)
243
+
logger := log.New("repo")
244
+
repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger)
206
245
return repo.Router(mw)
207
246
}
208
247
209
248
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)
249
+
pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer)
211
250
return pipes.Router(mw)
212
251
}
252
+
253
+
func (s *State) SignupRouter() http.Handler {
254
+
logger := log.New("signup")
255
+
256
+
sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, logger)
257
+
return sig.Router()
258
+
}
+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
}
+57
-383
appview/state/state.go
+57
-383
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
-
pgs := pages.NewPages(config)
68
-
69
-
res, err := idresolver.RedisResolver(config.Redis)
64
+
res, err := idresolver.RedisResolver(config.Redis.ToURL())
70
65
if err != nil {
71
66
log.Printf("failed to create redis resolver: %v", err)
72
67
res = idresolver.DefaultResolver()
73
68
}
69
+
70
+
pgs := pages.NewPages(config, res)
74
71
75
72
cache := cache.New(config.Redis.Addr)
76
73
sess := session.New(cache)
···
96
93
tangled.ActorProfileNSID,
97
94
tangled.SpindleMemberNSID,
98
95
tangled.SpindleNSID,
96
+
tangled.StringNSID,
97
+
tangled.RepoIssueNSID,
98
+
tangled.RepoIssueCommentNSID,
99
99
},
100
100
nil,
101
101
slog.Default(),
···
133
133
return nil, fmt.Errorf("failed to start spindlestream consumer: %w", err)
134
134
}
135
135
spindlestream.Start(ctx)
136
+
137
+
var notifiers []notify.Notifier
138
+
if !config.Core.Dev {
139
+
notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog))
140
+
}
141
+
notifier := notify.NewMergedNotifier(notifiers...)
136
142
137
143
state := &State{
138
144
d,
145
+
notifier,
139
146
oauth,
140
147
enforcer,
141
-
clock,
142
148
pgs,
143
149
sess,
144
150
res,
···
153
159
return state, nil
154
160
}
155
161
156
-
func TID(c *syntax.TIDClock) string {
157
-
return c.Next().String()
162
+
func (s *State) Favicon(w http.ResponseWriter, r *http.Request) {
163
+
w.Header().Set("Content-Type", "image/svg+xml")
164
+
w.Header().Set("Cache-Control", "public, max-age=31536000") // one year
165
+
w.Header().Set("ETag", `"favicon-svg-v1"`)
166
+
167
+
if match := r.Header.Get("If-None-Match"); match == `"favicon-svg-v1"` {
168
+
w.WriteHeader(http.StatusNotModified)
169
+
return
170
+
}
171
+
172
+
s.pages.Favicon(w)
173
+
}
174
+
175
+
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
176
+
user := s.oauth.GetUser(r)
177
+
s.pages.TermsOfService(w, pages.TermsOfServiceParams{
178
+
LoggedInUser: user,
179
+
})
180
+
}
181
+
182
+
func (s *State) PrivacyPolicy(w http.ResponseWriter, r *http.Request) {
183
+
user := s.oauth.GetUser(r)
184
+
s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{
185
+
LoggedInUser: user,
186
+
})
158
187
}
159
188
160
189
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
···
166
195
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
167
196
}
168
197
169
-
var didsToResolve []string
170
-
for _, ev := range timeline {
171
-
if ev.Repo != nil {
172
-
didsToResolve = append(didsToResolve, ev.Repo.Did)
173
-
if ev.Source != nil {
174
-
didsToResolve = append(didsToResolve, ev.Source.Did)
175
-
}
176
-
}
177
-
if ev.Follow != nil {
178
-
didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid)
179
-
}
180
-
if ev.Star != nil {
181
-
didsToResolve = append(didsToResolve, ev.Star.StarredByDid, ev.Star.Repo.Did)
182
-
}
183
-
}
184
-
185
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve)
186
-
didHandleMap := make(map[string]string)
187
-
for _, identity := range resolvedIds {
188
-
if !identity.Handle.IsInvalidHandle() {
189
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
190
-
} else {
191
-
didHandleMap[identity.DID.String()] = identity.DID.String()
192
-
}
198
+
repos, err := db.GetTopStarredReposLastWeek(s.db)
199
+
if err != nil {
200
+
log.Println(err)
201
+
s.pages.Notice(w, "topstarredrepos", "Unable to load.")
202
+
return
193
203
}
194
204
195
205
s.pages.Timeline(w, pages.TimelineParams{
196
206
LoggedInUser: user,
197
207
Timeline: timeline,
198
-
DidHandleMap: didHandleMap,
208
+
Repos: repos,
199
209
})
200
-
201
-
return
202
-
}
203
-
204
-
// requires auth
205
-
func (s *State) RegistrationKey(w http.ResponseWriter, r *http.Request) {
206
-
switch r.Method {
207
-
case http.MethodGet:
208
-
// list open registrations under this did
209
-
210
-
return
211
-
case http.MethodPost:
212
-
session, err := s.oauth.Stores().Get(r, oauth.SessionName)
213
-
if err != nil || session.IsNew {
214
-
log.Println("unauthorized attempt to generate registration key")
215
-
http.Error(w, "Forbidden", http.StatusUnauthorized)
216
-
return
217
-
}
218
-
219
-
did := session.Values[oauth.SessionDid].(string)
220
-
221
-
// check if domain is valid url, and strip extra bits down to just host
222
-
domain := r.FormValue("domain")
223
-
if domain == "" {
224
-
http.Error(w, "Invalid form", http.StatusBadRequest)
225
-
return
226
-
}
227
-
228
-
key, err := db.GenerateRegistrationKey(s.db, domain, did)
229
-
230
-
if err != nil {
231
-
log.Println(err)
232
-
http.Error(w, "unable to register this domain", http.StatusNotAcceptable)
233
-
return
234
-
}
235
-
236
-
w.Write([]byte(key))
237
-
}
238
210
}
239
211
240
212
func (s *State) Keys(w http.ResponseWriter, r *http.Request) {
···
269
241
}
270
242
}
271
243
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
244
func validateRepoName(name string) error {
567
245
// check for path traversal attempts
568
246
if name == "." || name == ".." ||
···
595
273
return nil
596
274
}
597
275
276
+
func stripGitExt(name string) string {
277
+
return strings.TrimSuffix(name, ".git")
278
+
}
279
+
598
280
func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) {
599
281
switch r.Method {
600
282
case http.MethodGet:
···
630
312
return
631
313
}
632
314
315
+
repoName = stripGitExt(repoName)
316
+
633
317
defaultBranch := r.FormValue("branch")
634
318
if defaultBranch == "" {
635
319
defaultBranch = "main"
···
661
345
return
662
346
}
663
347
664
-
rkey := appview.TID()
348
+
rkey := tid.TID()
665
349
repo := &db.Repo{
666
350
Did: user.Did,
667
351
Name: repoName,
···
726
410
// continue
727
411
}
728
412
729
-
repo.AtUri = atresp.Uri
730
413
err = db.AddRepo(tx, repo)
731
414
if err != nil {
732
415
log.Println(err)
···
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
}
+465
appview/strings/strings.go
+465
appview/strings/strings.go
···
1
+
package strings
2
+
3
+
import (
4
+
"fmt"
5
+
"log/slog"
6
+
"net/http"
7
+
"path"
8
+
"slices"
9
+
"strconv"
10
+
"time"
11
+
12
+
"tangled.sh/tangled.sh/core/api/tangled"
13
+
"tangled.sh/tangled.sh/core/appview/config"
14
+
"tangled.sh/tangled.sh/core/appview/db"
15
+
"tangled.sh/tangled.sh/core/appview/middleware"
16
+
"tangled.sh/tangled.sh/core/appview/oauth"
17
+
"tangled.sh/tangled.sh/core/appview/pages"
18
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
19
+
"tangled.sh/tangled.sh/core/eventconsumer"
20
+
"tangled.sh/tangled.sh/core/idresolver"
21
+
"tangled.sh/tangled.sh/core/rbac"
22
+
"tangled.sh/tangled.sh/core/tid"
23
+
24
+
"github.com/bluesky-social/indigo/api/atproto"
25
+
"github.com/bluesky-social/indigo/atproto/identity"
26
+
"github.com/bluesky-social/indigo/atproto/syntax"
27
+
lexutil "github.com/bluesky-social/indigo/lex/util"
28
+
"github.com/go-chi/chi/v5"
29
+
)
30
+
31
+
type Strings 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 (s *Strings) Router(mw *middleware.Middleware) http.Handler {
43
+
r := chi.NewRouter()
44
+
45
+
r.
46
+
Get("/", s.timeline)
47
+
48
+
r.
49
+
With(mw.ResolveIdent()).
50
+
Route("/{user}", func(r chi.Router) {
51
+
r.Get("/", s.dashboard)
52
+
53
+
r.Route("/{rkey}", func(r chi.Router) {
54
+
r.Get("/", s.contents)
55
+
r.Delete("/", s.delete)
56
+
r.Get("/raw", s.contents)
57
+
r.Get("/edit", s.edit)
58
+
r.Post("/edit", s.edit)
59
+
r.
60
+
With(middleware.AuthMiddleware(s.OAuth)).
61
+
Post("/comment", s.comment)
62
+
})
63
+
})
64
+
65
+
r.
66
+
With(middleware.AuthMiddleware(s.OAuth)).
67
+
Route("/new", func(r chi.Router) {
68
+
r.Get("/", s.create)
69
+
r.Post("/", s.create)
70
+
})
71
+
72
+
return r
73
+
}
74
+
75
+
func (s *Strings) timeline(w http.ResponseWriter, r *http.Request) {
76
+
l := s.Logger.With("handler", "timeline")
77
+
78
+
strings, err := db.GetStrings(s.Db, 50)
79
+
if err != nil {
80
+
l.Error("failed to fetch string", "err", err)
81
+
w.WriteHeader(http.StatusInternalServerError)
82
+
return
83
+
}
84
+
85
+
s.Pages.StringsTimeline(w, pages.StringTimelineParams{
86
+
LoggedInUser: s.OAuth.GetUser(r),
87
+
Strings: strings,
88
+
})
89
+
}
90
+
91
+
func (s *Strings) contents(w http.ResponseWriter, r *http.Request) {
92
+
l := s.Logger.With("handler", "contents")
93
+
94
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
95
+
if !ok {
96
+
l.Error("malformed middleware")
97
+
w.WriteHeader(http.StatusInternalServerError)
98
+
return
99
+
}
100
+
l = l.With("did", id.DID, "handle", id.Handle)
101
+
102
+
rkey := chi.URLParam(r, "rkey")
103
+
if rkey == "" {
104
+
l.Error("malformed url, empty rkey")
105
+
w.WriteHeader(http.StatusBadRequest)
106
+
return
107
+
}
108
+
l = l.With("rkey", rkey)
109
+
110
+
strings, err := db.GetStrings(
111
+
s.Db,
112
+
0,
113
+
db.FilterEq("did", id.DID),
114
+
db.FilterEq("rkey", rkey),
115
+
)
116
+
if err != nil {
117
+
l.Error("failed to fetch string", "err", err)
118
+
w.WriteHeader(http.StatusInternalServerError)
119
+
return
120
+
}
121
+
if len(strings) < 1 {
122
+
l.Error("string not found")
123
+
s.Pages.Error404(w)
124
+
return
125
+
}
126
+
if len(strings) != 1 {
127
+
l.Error("incorrect number of records returned", "len(strings)", len(strings))
128
+
w.WriteHeader(http.StatusInternalServerError)
129
+
return
130
+
}
131
+
string := strings[0]
132
+
133
+
if path.Base(r.URL.Path) == "raw" {
134
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
135
+
if string.Filename != "" {
136
+
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", string.Filename))
137
+
}
138
+
w.Header().Set("Content-Length", strconv.Itoa(len(string.Contents)))
139
+
140
+
_, err = w.Write([]byte(string.Contents))
141
+
if err != nil {
142
+
l.Error("failed to write raw response", "err", err)
143
+
}
144
+
return
145
+
}
146
+
147
+
var showRendered, renderToggle bool
148
+
if markup.GetFormat(string.Filename) == markup.FormatMarkdown {
149
+
renderToggle = true
150
+
showRendered = r.URL.Query().Get("code") != "true"
151
+
}
152
+
153
+
s.Pages.SingleString(w, pages.SingleStringParams{
154
+
LoggedInUser: s.OAuth.GetUser(r),
155
+
RenderToggle: renderToggle,
156
+
ShowRendered: showRendered,
157
+
String: string,
158
+
Stats: string.Stats(),
159
+
Owner: id,
160
+
})
161
+
}
162
+
163
+
func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) {
164
+
l := s.Logger.With("handler", "dashboard")
165
+
166
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
167
+
if !ok {
168
+
l.Error("malformed middleware")
169
+
w.WriteHeader(http.StatusInternalServerError)
170
+
return
171
+
}
172
+
l = l.With("did", id.DID, "handle", id.Handle)
173
+
174
+
all, err := db.GetStrings(
175
+
s.Db,
176
+
0,
177
+
db.FilterEq("did", id.DID),
178
+
)
179
+
if err != nil {
180
+
l.Error("failed to fetch strings", "err", err)
181
+
w.WriteHeader(http.StatusInternalServerError)
182
+
return
183
+
}
184
+
185
+
slices.SortFunc(all, func(a, b db.String) int {
186
+
if a.Created.After(b.Created) {
187
+
return -1
188
+
} else {
189
+
return 1
190
+
}
191
+
})
192
+
193
+
profile, err := db.GetProfile(s.Db, id.DID.String())
194
+
if err != nil {
195
+
l.Error("failed to fetch user profile", "err", err)
196
+
w.WriteHeader(http.StatusInternalServerError)
197
+
return
198
+
}
199
+
loggedInUser := s.OAuth.GetUser(r)
200
+
followStatus := db.IsNotFollowing
201
+
if loggedInUser != nil {
202
+
followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String())
203
+
}
204
+
205
+
followers, following, err := db.GetFollowerFollowingCount(s.Db, id.DID.String())
206
+
if err != nil {
207
+
l.Error("failed to get follow stats", "err", err)
208
+
}
209
+
210
+
s.Pages.StringsDashboard(w, pages.StringsDashboardParams{
211
+
LoggedInUser: s.OAuth.GetUser(r),
212
+
Card: pages.ProfileCard{
213
+
UserDid: id.DID.String(),
214
+
UserHandle: id.Handle.String(),
215
+
Profile: profile,
216
+
FollowStatus: followStatus,
217
+
Followers: followers,
218
+
Following: following,
219
+
},
220
+
Strings: all,
221
+
})
222
+
}
223
+
224
+
func (s *Strings) edit(w http.ResponseWriter, r *http.Request) {
225
+
l := s.Logger.With("handler", "edit")
226
+
227
+
user := s.OAuth.GetUser(r)
228
+
229
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
230
+
if !ok {
231
+
l.Error("malformed middleware")
232
+
w.WriteHeader(http.StatusInternalServerError)
233
+
return
234
+
}
235
+
l = l.With("did", id.DID, "handle", id.Handle)
236
+
237
+
rkey := chi.URLParam(r, "rkey")
238
+
if rkey == "" {
239
+
l.Error("malformed url, empty rkey")
240
+
w.WriteHeader(http.StatusBadRequest)
241
+
return
242
+
}
243
+
l = l.With("rkey", rkey)
244
+
245
+
// get the string currently being edited
246
+
all, err := db.GetStrings(
247
+
s.Db,
248
+
0,
249
+
db.FilterEq("did", id.DID),
250
+
db.FilterEq("rkey", rkey),
251
+
)
252
+
if err != nil {
253
+
l.Error("failed to fetch string", "err", err)
254
+
w.WriteHeader(http.StatusInternalServerError)
255
+
return
256
+
}
257
+
if len(all) != 1 {
258
+
l.Error("incorrect number of records returned", "len(strings)", len(all))
259
+
w.WriteHeader(http.StatusInternalServerError)
260
+
return
261
+
}
262
+
first := all[0]
263
+
264
+
// verify that the logged in user owns this string
265
+
if user.Did != id.DID.String() {
266
+
l.Error("unauthorized request", "expected", id.DID, "got", user.Did)
267
+
w.WriteHeader(http.StatusUnauthorized)
268
+
return
269
+
}
270
+
271
+
switch r.Method {
272
+
case http.MethodGet:
273
+
// return the form with prefilled fields
274
+
s.Pages.PutString(w, pages.PutStringParams{
275
+
LoggedInUser: s.OAuth.GetUser(r),
276
+
Action: "edit",
277
+
String: first,
278
+
})
279
+
case http.MethodPost:
280
+
fail := func(msg string, err error) {
281
+
l.Error(msg, "err", err)
282
+
s.Pages.Notice(w, "error", msg)
283
+
}
284
+
285
+
filename := r.FormValue("filename")
286
+
if filename == "" {
287
+
fail("Empty filename.", nil)
288
+
return
289
+
}
290
+
291
+
content := r.FormValue("content")
292
+
if content == "" {
293
+
fail("Empty contents.", nil)
294
+
return
295
+
}
296
+
297
+
description := r.FormValue("description")
298
+
299
+
// construct new string from form values
300
+
entry := db.String{
301
+
Did: first.Did,
302
+
Rkey: first.Rkey,
303
+
Filename: filename,
304
+
Description: description,
305
+
Contents: content,
306
+
Created: first.Created,
307
+
}
308
+
309
+
record := entry.AsRecord()
310
+
311
+
client, err := s.OAuth.AuthorizedClient(r)
312
+
if err != nil {
313
+
fail("Failed to create record.", err)
314
+
return
315
+
}
316
+
317
+
// first replace the existing record in the PDS
318
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey)
319
+
if err != nil {
320
+
fail("Failed to updated existing record.", err)
321
+
return
322
+
}
323
+
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
324
+
Collection: tangled.StringNSID,
325
+
Repo: entry.Did.String(),
326
+
Rkey: entry.Rkey,
327
+
SwapRecord: ex.Cid,
328
+
Record: &lexutil.LexiconTypeDecoder{
329
+
Val: &record,
330
+
},
331
+
})
332
+
if err != nil {
333
+
fail("Failed to updated existing record.", err)
334
+
return
335
+
}
336
+
l := l.With("aturi", resp.Uri)
337
+
l.Info("edited string")
338
+
339
+
// if that went okay, updated the db
340
+
if err = db.AddString(s.Db, entry); err != nil {
341
+
fail("Failed to update string.", err)
342
+
return
343
+
}
344
+
345
+
// if that went okay, redir to the string
346
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey)
347
+
}
348
+
349
+
}
350
+
351
+
func (s *Strings) create(w http.ResponseWriter, r *http.Request) {
352
+
l := s.Logger.With("handler", "create")
353
+
user := s.OAuth.GetUser(r)
354
+
355
+
switch r.Method {
356
+
case http.MethodGet:
357
+
s.Pages.PutString(w, pages.PutStringParams{
358
+
LoggedInUser: s.OAuth.GetUser(r),
359
+
Action: "new",
360
+
})
361
+
case http.MethodPost:
362
+
fail := func(msg string, err error) {
363
+
l.Error(msg, "err", err)
364
+
s.Pages.Notice(w, "error", msg)
365
+
}
366
+
367
+
filename := r.FormValue("filename")
368
+
if filename == "" {
369
+
fail("Empty filename.", nil)
370
+
return
371
+
}
372
+
373
+
content := r.FormValue("content")
374
+
if content == "" {
375
+
fail("Empty contents.", nil)
376
+
return
377
+
}
378
+
379
+
description := r.FormValue("description")
380
+
381
+
string := db.String{
382
+
Did: syntax.DID(user.Did),
383
+
Rkey: tid.TID(),
384
+
Filename: filename,
385
+
Description: description,
386
+
Contents: content,
387
+
Created: time.Now(),
388
+
}
389
+
390
+
record := string.AsRecord()
391
+
392
+
client, err := s.OAuth.AuthorizedClient(r)
393
+
if err != nil {
394
+
fail("Failed to create record.", err)
395
+
return
396
+
}
397
+
398
+
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
399
+
Collection: tangled.StringNSID,
400
+
Repo: user.Did,
401
+
Rkey: string.Rkey,
402
+
Record: &lexutil.LexiconTypeDecoder{
403
+
Val: &record,
404
+
},
405
+
})
406
+
if err != nil {
407
+
fail("Failed to create record.", err)
408
+
return
409
+
}
410
+
l := l.With("aturi", resp.Uri)
411
+
l.Info("created record")
412
+
413
+
// insert into DB
414
+
if err = db.AddString(s.Db, string); err != nil {
415
+
fail("Failed to create string.", err)
416
+
return
417
+
}
418
+
419
+
// successful
420
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey)
421
+
}
422
+
}
423
+
424
+
func (s *Strings) delete(w http.ResponseWriter, r *http.Request) {
425
+
l := s.Logger.With("handler", "create")
426
+
user := s.OAuth.GetUser(r)
427
+
fail := func(msg string, err error) {
428
+
l.Error(msg, "err", err)
429
+
s.Pages.Notice(w, "error", msg)
430
+
}
431
+
432
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
433
+
if !ok {
434
+
l.Error("malformed middleware")
435
+
w.WriteHeader(http.StatusInternalServerError)
436
+
return
437
+
}
438
+
l = l.With("did", id.DID, "handle", id.Handle)
439
+
440
+
rkey := chi.URLParam(r, "rkey")
441
+
if rkey == "" {
442
+
l.Error("malformed url, empty rkey")
443
+
w.WriteHeader(http.StatusBadRequest)
444
+
return
445
+
}
446
+
447
+
if user.Did != id.DID.String() {
448
+
fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String()))
449
+
return
450
+
}
451
+
452
+
if err := db.DeleteString(
453
+
s.Db,
454
+
db.FilterEq("did", user.Did),
455
+
db.FilterEq("rkey", rkey),
456
+
); err != nil {
457
+
fail("Failed to delete string.", err)
458
+
return
459
+
}
460
+
461
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle)
462
+
}
463
+
464
+
func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
465
+
}
-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) {
+5
-2
cmd/gen.go
+5
-2
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{},
26
29
tangled.Pipeline_CloneOpts{},
27
-
tangled.Pipeline_Dependency{},
28
30
tangled.Pipeline_ManualTriggerData{},
29
31
tangled.Pipeline_Pair{},
30
32
tangled.Pipeline_PullRequestTriggerData{},
31
33
tangled.Pipeline_PushTriggerData{},
32
34
tangled.PipelineStatus{},
33
-
tangled.Pipeline_Step{},
34
35
tangled.Pipeline_TriggerMetadata{},
35
36
tangled.Pipeline_TriggerRepo{},
36
37
tangled.Pipeline_Workflow{},
37
38
tangled.PublicKey{},
38
39
tangled.Repo{},
39
40
tangled.RepoArtifact{},
41
+
tangled.RepoCollaborator{},
40
42
tangled.RepoIssue{},
41
43
tangled.RepoIssueComment{},
42
44
tangled.RepoIssueState{},
···
46
48
tangled.RepoPullStatus{},
47
49
tangled.Spindle{},
48
50
tangled.SpindleMember{},
51
+
tangled.String{},
49
52
); err != nil {
50
53
panic(err)
51
54
}
+4
cmd/genjwks/main.go
+4
cmd/genjwks/main.go
+1
-1
cmd/punchcardPopulate/main.go
+1
-1
cmd/punchcardPopulate/main.go
+14
-15
docs/contributing.md
+14
-15
docs/contributing.md
···
55
55
- Avoid noisy commit messages like "wip" or "final fix"โrewrite history
56
56
before submitting if necessary.
57
57
58
+
## code formatting
59
+
60
+
We use a variety of tools to format our code, and multiplex them with
61
+
[`treefmt`](https://treefmt.com): all you need to do to format your changes
62
+
is run `nix run .#fmt` (or just `treefmt` if you're in the devshell).
63
+
58
64
## proposals for bigger changes
59
65
60
66
Small fixes like typos, minor bugs, or trivial refactors can be
···
115
121
If you're submitting a PR with multiple commits, make sure each one is
116
122
signed.
117
123
118
-
For [jj](https://jj-vcs.github.io/jj/latest/) users, you can add this to
119
-
your jj config:
124
+
For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command
125
+
to make it sign off commits in the tangled repo:
120
126
121
-
```
122
-
ui.should-sign-off = true
123
-
```
124
-
125
-
and to your `templates.draft_commit_description`, add the following `if`
126
-
block:
127
-
128
-
```
129
-
if(
130
-
config("ui.should-sign-off").as_boolean() && !description.contains("Signed-off-by: " ++ author.name()),
131
-
"\nSigned-off-by: " ++ author.name() ++ " <" ++ author.email() ++ ">",
132
-
),
127
+
```shell
128
+
# Safety check, should say "No matching config key..."
129
+
jj config list templates.commit_trailers
130
+
# The command below may need to be adjusted if the command above returned something.
131
+
jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)"
133
132
```
134
133
135
134
Refer to the [jj
136
-
documentation](https://jj-vcs.github.io/jj/latest/config/#default-description)
135
+
documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers)
137
136
for more information.
+59
-10
docs/hacking.md
+59
-10
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 create a knot with hostname `localhost:6000`. This will
60
+
generate a knot secret. Set `$TANGLED_VM_KNOT_SECRET` to it,
61
+
ideally in a `.envrc` with [direnv](https://direnv.net) so you
62
+
don't lose it.
63
+
64
+
You will also need to set the `$TANGLED_VM_SPINDLE_OWNER`
65
+
variable to some value. If you don't want to [set up a
66
+
spindle](#running-a-spindle), you can use any placeholder
67
+
value.
45
68
46
-
You can now start a lightweight NixOS VM using
47
-
`nixos-shell` like so:
69
+
You can now start a lightweight NixOS VM like so:
48
70
49
71
```bash
50
-
QEMU_NET_OPTS="hostfwd=tcp::6000-:6000,hostfwd=tcp::2222-:22" nixos-shell --flake .#knotVM
72
+
nix run --impure .#vm
51
73
52
-
# hit Ctrl-a + c + q to exit the VM
74
+
# type `poweroff` at the shell to exit the VM
53
75
```
54
76
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:
77
+
This starts a knot on port 6000, a spindle on port 6555
78
+
with `ssh` exposed on port 2222. You can push repositories
79
+
to this VM with this ssh config block on your main machine:
58
80
59
81
```bash
60
82
Host nixos-shell
···
70
92
git remote add local-dev git@nixos-shell:user/repo
71
93
git push local-dev main
72
94
```
95
+
96
+
## running a spindle
97
+
98
+
You will need to find out your DID by entering your login handle into
99
+
<https://pdsls.dev/>. Set `$TANGLED_VM_SPINDLE_OWNER` to your DID.
100
+
101
+
The above VM should already be running a spindle on `localhost:6555`.
102
+
You can head to the spindle dashboard on `http://localhost:3000/spindles`,
103
+
and register a spindle with hostname `localhost:6555`. It should instantly
104
+
be verified. You can then configure each repository to use this spindle
105
+
and run CI jobs.
106
+
107
+
Of interest when debugging spindles:
108
+
109
+
```
110
+
# service logs from journald:
111
+
journalctl -xeu spindle
112
+
113
+
# CI job logs from disk:
114
+
ls /var/log/spindle
115
+
116
+
# debugging spindle db:
117
+
sqlite3 /var/lib/spindle/spindle.db
118
+
119
+
# litecli has a nicer REPL interface:
120
+
litecli /var/lib/spindle/spindle.db
121
+
```
+55
-4
docs/knot-hosting.md
+55
-4
docs/knot-hosting.md
···
2
2
3
3
So you want to run your own knot server? Great! Here are a few prerequisites:
4
4
5
-
1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux of some kind.
5
+
1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind.
6
6
2. A (sub)domain name. People generally use `knot.example.com`.
7
7
3. A valid SSL certificate for your domain.
8
8
···
59
59
EOF
60
60
```
61
61
62
+
Then, reload `sshd`:
63
+
64
+
```
65
+
sudo systemctl reload ssh
66
+
```
67
+
62
68
Next, create the `git` user. We'll use the `git` user's home directory
63
69
to store repositories:
64
70
···
67
73
```
68
74
69
75
Create `/home/git/.knot.env` with the following, updating the values as
70
-
necessary. The `KNOT_SERVER_SECRET` can be obtaind from the
71
-
[/knots](/knots) page on Tangled.
76
+
necessary. The `KNOT_SERVER_SECRET` can be obtained from the
77
+
[/knots](https://tangled.sh/knots) page on Tangled.
72
78
73
79
```
74
80
KNOT_REPO_SCAN_PATH=/home/git
···
89
95
systemctl start knotserver
90
96
```
91
97
98
+
The last step is to configure a reverse proxy like Nginx or Caddy to front your
99
+
knot. Here's an example configuration for Nginx:
100
+
101
+
```
102
+
server {
103
+
listen 80;
104
+
listen [::]:80;
105
+
server_name knot.example.com;
106
+
107
+
location / {
108
+
proxy_pass http://localhost:5555;
109
+
proxy_set_header Host $host;
110
+
proxy_set_header X-Real-IP $remote_addr;
111
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
112
+
proxy_set_header X-Forwarded-Proto $scheme;
113
+
}
114
+
115
+
# wss endpoint for git events
116
+
location /events {
117
+
proxy_set_header X-Forwarded-For $remote_addr;
118
+
proxy_set_header Host $http_host;
119
+
proxy_set_header Upgrade websocket;
120
+
proxy_set_header Connection Upgrade;
121
+
proxy_pass http://localhost:5555;
122
+
}
123
+
# additional config for SSL/TLS go here.
124
+
}
125
+
126
+
```
127
+
128
+
Remember to use Let's Encrypt or similar to procure a certificate for your
129
+
knot domain.
130
+
92
131
You should now have a running knot server! You can finalize your registration by hitting the
93
-
`initialize` button on the [/knots](/knots) page.
132
+
`initialize` button on the [/knots](https://tangled.sh/knots) page.
94
133
95
134
### custom paths
96
135
···
158
197
```
159
198
160
199
Make sure to restart your SSH server!
200
+
201
+
#### MOTD (message of the day)
202
+
203
+
To configure the MOTD used ("Welcome to this knot!" by default), edit the
204
+
`/home/git/motd` file:
205
+
206
+
```
207
+
printf "Hi from this knot!\n" > /home/git/motd
208
+
```
209
+
210
+
Note that you should add a newline at the end if setting a non-empty message
211
+
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 -f -field=secret_id auth/approle/role/spindle/secret-id)
118
+
119
+
echo "Role ID: $ROLE_ID"
120
+
echo "Secret ID: $SECRET_ID"
121
+
```
122
+
123
+
### create proxy configuration
124
+
125
+
Create the credential files:
126
+
127
+
```bash
128
+
# Create directory for OpenBao files
129
+
mkdir -p /tmp/openbao
130
+
131
+
# Save credentials
132
+
echo "$ROLE_ID" > /tmp/openbao/role-id
133
+
echo "$SECRET_ID" > /tmp/openbao/secret-id
134
+
chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id
135
+
```
136
+
137
+
Create a proxy configuration file `/tmp/openbao/proxy.hcl`:
138
+
139
+
```hcl
140
+
# OpenBao server connection
141
+
vault {
142
+
address = "http://localhost:8200"
143
+
}
144
+
145
+
# Auto-Auth using AppRole
146
+
auto_auth {
147
+
method "approle" {
148
+
mount_path = "auth/approle"
149
+
config = {
150
+
role_id_file_path = "/tmp/openbao/role-id"
151
+
secret_id_file_path = "/tmp/openbao/secret-id"
152
+
}
153
+
}
154
+
155
+
# Optional: write token to file for debugging
156
+
sink "file" {
157
+
config = {
158
+
path = "/tmp/openbao/token"
159
+
mode = 0640
160
+
}
161
+
}
162
+
}
163
+
164
+
# Proxy listener for Spindle
165
+
listener "tcp" {
166
+
address = "127.0.0.1:8201"
167
+
tls_disable = true
168
+
}
169
+
170
+
# Enable API proxy with auto-auth token
171
+
api_proxy {
172
+
use_auto_auth_token = true
173
+
}
174
+
175
+
# Enable response caching
176
+
cache {
177
+
use_auto_auth_token = true
178
+
}
179
+
180
+
# Logging
181
+
log_level = "info"
182
+
```
183
+
184
+
### start the proxy
185
+
186
+
Start OpenBao Proxy:
187
+
188
+
```bash
189
+
bao proxy -config=/tmp/openbao/proxy.hcl
190
+
```
191
+
192
+
The proxy will authenticate with OpenBao and start listening on
193
+
`127.0.0.1:8201`.
194
+
195
+
### configure spindle
196
+
197
+
Set these environment variables for Spindle:
198
+
199
+
```bash
200
+
export SPINDLE_SERVER_SECRETS_PROVIDER=openbao
201
+
export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201
202
+
export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle
203
+
```
204
+
205
+
Start Spindle:
206
+
207
+
Spindle will now connect to the local proxy, which handles all
208
+
authentication automatically.
209
+
210
+
## production setup for proxy
211
+
212
+
For production, you'll want to run the proxy as a service:
213
+
214
+
Place your production configuration in `/etc/openbao/proxy.hcl` with
215
+
proper TLS settings for the vault connection.
216
+
217
+
## verifying setup
218
+
219
+
Test the proxy directly:
220
+
221
+
```bash
222
+
# Check proxy health
223
+
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health
224
+
225
+
# Test token lookup through proxy
226
+
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self
227
+
```
228
+
229
+
Test OpenBao operations through the server:
230
+
231
+
```bash
232
+
# List all secrets
233
+
bao kv list spindle/
234
+
235
+
# Add a test secret via Spindle API, then check it exists
236
+
bao kv list spindle/repos/
237
+
238
+
# Get a specific secret
239
+
bao kv get spindle/repos/your_repo_path/SECRET_NAME
240
+
```
241
+
242
+
## how it works
243
+
244
+
- Spindle connects to OpenBao Proxy on localhost (typically port 8200 or 8201)
245
+
- The proxy authenticates with OpenBao using AppRole credentials
246
+
- All Spindle requests go through the proxy, which injects authentication tokens
247
+
- Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}`
248
+
- Repository paths like `did:plc:alice/myrepo` become `did_plc_alice_myrepo`
249
+
- The proxy handles all token renewal automatically
250
+
- Spindle no longer manages tokens or authentication directly
251
+
252
+
## troubleshooting
253
+
254
+
**Connection refused**: Check that the OpenBao Proxy is running and
255
+
listening on the configured address.
256
+
257
+
**403 errors**: Verify the AppRole credentials are correct and the policy
258
+
has the necessary permissions.
259
+
260
+
**404 route errors**: The spindle KV mount probably doesn't exist - run
261
+
the mount creation step again.
262
+
263
+
**Proxy authentication failures**: Check the proxy logs and verify the
264
+
role-id and secret-id files are readable and contain valid credentials.
265
+
266
+
**Secret not found after writing**: This can indicate policy permission
267
+
issues. Verify the policy includes both `spindle/data/*` and
268
+
`spindle/metadata/*` paths with appropriate capabilities.
269
+
270
+
Check proxy logs:
271
+
272
+
```bash
273
+
# If running as systemd service
274
+
journalctl -u openbao-proxy -f
275
+
276
+
# If running directly, check the console output
277
+
```
278
+
279
+
Test AppRole authentication manually:
280
+
281
+
```bash
282
+
bao write auth/approle/login \
283
+
role_id="$(cat /tmp/openbao/role-id)" \
284
+
secret_id="$(cat /tmp/openbao/secret-id)"
285
+
```
+33
-3
docs/spindle/pipeline.md
+33
-3
docs/spindle/pipeline.md
···
4
4
repo. Generally:
5
5
6
6
* Pipelines are defined in YAML.
7
-
* Dependencies can be specified from
8
-
[Nixpkgs](https://search.nixos.org) or custom registries.
9
-
* Environment variables can be set globally or per-step.
7
+
* Workflows can run using different *engines*.
8
+
9
+
The most barebones workflow looks like this:
10
+
11
+
```yaml
12
+
when:
13
+
- event: ["push"]
14
+
branch: ["main"]
15
+
16
+
engine: "nixery"
17
+
18
+
# optional
19
+
clone:
20
+
skip: false
21
+
depth: 50
22
+
submodules: true
23
+
```
24
+
25
+
The `when` and `engine` fields are required, while every other aspect
26
+
of how the definition is parsed is up to the engine. Currently, a spindle
27
+
provides at least one of these built-in engines:
28
+
29
+
## `nixery`
30
+
31
+
The Nixery engine uses an instance of [Nixery](https://nixery.dev) to run
32
+
steps that use dependencies from [Nixpkgs](https://github.com/NixOS/nixpkgs).
10
33
11
34
Here's an example that uses all fields:
12
35
···
57
80
depth: 50
58
81
submodules: true
59
82
```
83
+
84
+
## git push options
85
+
86
+
These are push options that can be used with the `--push-option (-o)` flag of git push:
87
+
88
+
- `verbose-ci`, `ci-verbose`: enables diagnostics reporting for the CI pipeline, allowing you to see any issues when you push.
89
+
- `skip-ci`, `ci-skip`: skips triggering the CI pipeline.
+12
-1
eventconsumer/consumer.go
+12
-1
eventconsumer/consumer.go
···
172
172
func (c *Consumer) startConnectionLoop(ctx context.Context, source Source) {
173
173
defer c.wg.Done()
174
174
175
+
// attempt connection initially
176
+
err := c.runConnection(ctx, source)
177
+
if err != nil {
178
+
c.logger.Error("failed to run connection", "err", err)
179
+
}
180
+
181
+
timer := time.NewTimer(1 * time.Minute)
182
+
defer timer.Stop()
183
+
184
+
// every subsequent attempt is delayed by 1 minute
175
185
for {
176
186
select {
177
187
case <-ctx.Done():
178
188
return
179
-
default:
189
+
case <-timer.C:
180
190
err := c.runConnection(ctx, source)
181
191
if err != nil {
182
192
c.logger.Error("failed to run connection", "err", err)
183
193
}
194
+
timer.Reset(1 * time.Minute)
184
195
}
185
196
}
186
197
}
+1
-1
eventconsumer/cursor/sqlite.go
+1
-1
eventconsumer/cursor/sqlite.go
···
21
21
}
22
22
23
23
func NewSQLiteStore(dbPath string, opts ...SqliteStoreOpt) (*SqliteStore, error) {
24
-
db, err := sql.Open("sqlite3", dbPath)
24
+
db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1")
25
25
if err != nil {
26
26
return nil, fmt.Errorf("failed to open sqlite database: %w", err)
27
27
}
+53
-19
flake.lock
+53
-19
flake.lock
···
1
1
{
2
2
"nodes": {
3
-
"gitignore": {
3
+
"flake-utils": {
4
+
"inputs": {
5
+
"systems": "systems"
6
+
},
7
+
"locked": {
8
+
"lastModified": 1694529238,
9
+
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
10
+
"owner": "numtide",
11
+
"repo": "flake-utils",
12
+
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
13
+
"type": "github"
14
+
},
15
+
"original": {
16
+
"owner": "numtide",
17
+
"repo": "flake-utils",
18
+
"type": "github"
19
+
}
20
+
},
21
+
"gomod2nix": {
4
22
"inputs": {
23
+
"flake-utils": "flake-utils",
5
24
"nixpkgs": [
6
25
"nixpkgs"
7
26
]
8
27
},
9
28
"locked": {
10
-
"lastModified": 1709087332,
11
-
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
12
-
"owner": "hercules-ci",
13
-
"repo": "gitignore.nix",
14
-
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
29
+
"lastModified": 1754078208,
30
+
"narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=",
31
+
"owner": "nix-community",
32
+
"repo": "gomod2nix",
33
+
"rev": "7f963246a71626c7fc70b431a315c4388a0c95cf",
15
34
"type": "github"
16
35
},
17
36
"original": {
18
-
"owner": "hercules-ci",
19
-
"repo": "gitignore.nix",
37
+
"owner": "nix-community",
38
+
"repo": "gomod2nix",
20
39
"type": "github"
21
40
}
22
41
},
···
60
79
"indigo": {
61
80
"flake": false,
62
81
"locked": {
63
-
"lastModified": 1745333930,
64
-
"narHash": "sha256-83fIHqDE+dfnZ88HaNuwfKFO+R0RKAM1WxMfNh/Matk=",
82
+
"lastModified": 1753693716,
83
+
"narHash": "sha256-DMIKnCJRODQXEHUxA+7mLzRALmnZhkkbHlFT2rCQYrE=",
65
84
"owner": "oppiliappan",
66
85
"repo": "indigo",
67
-
"rev": "e4e59280737b8676611fc077a228d47b3e8e9491",
86
+
"rev": "5f170569da9360f57add450a278d73538092d8ca",
68
87
"type": "github"
69
88
},
70
89
"original": {
···
89
108
"lucide-src": {
90
109
"flake": false,
91
110
"locked": {
92
-
"lastModified": 1742302029,
93
-
"narHash": "sha256-OyPVtpnC4/AAmPq84Wt1r1Gcs48d9KG+UBCtZK87e9k=",
111
+
"lastModified": 1754044466,
112
+
"narHash": "sha256-+exBR2OToB1iv7ZQI2S4B0lXA/QRvC9n6U99UxGpJGs=",
94
113
"type": "tarball",
95
-
"url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"
114
+
"url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"
96
115
},
97
116
"original": {
98
117
"type": "tarball",
99
-
"url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"
118
+
"url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"
100
119
}
101
120
},
102
121
"nixpkgs": {
103
122
"locked": {
104
-
"lastModified": 1746904237,
105
-
"narHash": "sha256-3e+AVBczosP5dCLQmMoMEogM57gmZ2qrVSrmq9aResQ=",
123
+
"lastModified": 1751984180,
124
+
"narHash": "sha256-LwWRsENAZJKUdD3SpLluwDmdXY9F45ZEgCb0X+xgOL0=",
106
125
"owner": "nixos",
107
126
"repo": "nixpkgs",
108
-
"rev": "d89fc19e405cb2d55ce7cc114356846a0ee5e956",
127
+
"rev": "9807714d6944a957c2e036f84b0ff8caf9930bc0",
109
128
"type": "github"
110
129
},
111
130
"original": {
···
117
136
},
118
137
"root": {
119
138
"inputs": {
120
-
"gitignore": "gitignore",
139
+
"gomod2nix": "gomod2nix",
121
140
"htmx-src": "htmx-src",
122
141
"htmx-ws-src": "htmx-ws-src",
123
142
"ibm-plex-mono-src": "ibm-plex-mono-src",
···
139
158
"original": {
140
159
"type": "tarball",
141
160
"url": "https://sqlite.org/2024/sqlite-amalgamation-3450100.zip"
161
+
}
162
+
},
163
+
"systems": {
164
+
"locked": {
165
+
"lastModified": 1681028828,
166
+
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
167
+
"owner": "nix-systems",
168
+
"repo": "default",
169
+
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
170
+
"type": "github"
171
+
},
172
+
"original": {
173
+
"owner": "nix-systems",
174
+
"repo": "default",
175
+
"type": "github"
142
176
}
143
177
}
144
178
},
+183
-70
flake.nix
+183
-70
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;
···
18
22
flake = false;
19
23
};
20
24
lucide-src = {
21
-
url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip";
25
+
url = "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip";
22
26
flake = false;
23
27
};
24
28
inter-fonts-src = {
···
33
37
url = "https://sqlite.org/2024/sqlite-amalgamation-3450100.zip";
34
38
flake = false;
35
39
};
36
-
gitignore = {
37
-
url = "github:hercules-ci/gitignore.nix";
38
-
inputs.nixpkgs.follows = "nixpkgs";
39
-
};
40
40
};
41
41
42
42
outputs = {
43
43
self,
44
44
nixpkgs,
45
+
gomod2nix,
45
46
indigo,
46
47
htmx-src,
47
48
htmx-ws-src,
48
49
lucide-src,
49
-
gitignore,
50
50
inter-fonts-src,
51
51
sqlite-lib-src,
52
52
ibm-plex-mono-src,
53
53
}: let
54
54
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"];
55
55
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 {
56
+
nixpkgsFor = forAllSystems (system: nixpkgs.legacyPackages.${system});
57
+
58
+
mkPackageSet = pkgs:
59
+
pkgs.lib.makeScope pkgs.newScope (self: {
60
+
src = let
61
+
fs = pkgs.lib.fileset;
62
+
in
63
+
fs.toSource {
64
+
root = ./.;
65
+
fileset = fs.difference (fs.intersection (fs.gitTracked ./.) (fs.fileFilter (file: !(file.hasExt "nix")) ./.)) (fs.maybeMissing ./.jj);
66
+
};
67
+
buildGoApplication =
68
+
(self.callPackage "${gomod2nix}/builder" {
69
+
gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix;
70
+
}).buildGoApplication;
71
+
modules = ./nix/gomod2nix.toml;
72
+
sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix {
81
73
inherit (pkgs) gcc;
82
74
inherit sqlite-lib-src;
83
75
};
84
-
genjwks = pkgs.callPackage ./nix/pkgs/genjwks.nix {inherit goModHash gitignoreSource;};
85
-
};
86
-
in
87
-
mkPackageSet final;
76
+
genjwks = self.callPackage ./nix/pkgs/genjwks.nix {};
77
+
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
78
+
appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix {
79
+
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src;
80
+
};
81
+
appview = self.callPackage ./nix/pkgs/appview.nix {};
82
+
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
83
+
knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {};
84
+
knot = self.callPackage ./nix/pkgs/knot.nix {};
85
+
});
86
+
in {
87
+
overlays.default = final: prev: {
88
+
inherit (mkPackageSet final) lexgen sqlite-lib genjwks spindle knot-unwrapped knot appview;
89
+
};
88
90
89
91
packages = forAllSystems (system: let
90
92
pkgs = nixpkgsFor.${system};
91
-
staticPkgs = pkgs.pkgsStatic;
92
-
crossPkgs = pkgs.pkgsCross.gnu64.pkgsStatic;
93
+
packages = mkPackageSet pkgs;
94
+
staticPackages = mkPackageSet pkgs.pkgsStatic;
95
+
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
93
96
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;
97
+
inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib;
98
+
99
+
pkgsStatic-appview = staticPackages.appview;
100
+
pkgsStatic-knot = staticPackages.knot;
101
+
pkgsStatic-knot-unwrapped = staticPackages.knot-unwrapped;
102
+
pkgsStatic-spindle = staticPackages.spindle;
103
+
pkgsStatic-sqlite-lib = staticPackages.sqlite-lib;
104
+
105
+
pkgsCross-gnu64-pkgsStatic-appview = crossPackages.appview;
106
+
pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot;
107
+
pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped;
108
+
pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle;
109
+
110
+
treefmt-wrapper = pkgs.treefmt.withConfig {
111
+
settings.formatter = {
112
+
alejandra = {
113
+
command = pkgs.lib.getExe pkgs.alejandra;
114
+
includes = ["*.nix"];
115
+
};
101
116
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;
117
+
gofmt = {
118
+
command = pkgs.lib.getExe' pkgs.go "gofmt";
119
+
options = ["-w"];
120
+
includes = ["*.go"];
121
+
};
107
122
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;
123
+
# prettier = let
124
+
# wrapper = pkgs.runCommandLocal "prettier-wrapper" {nativeBuildInputs = [pkgs.makeWrapper];} ''
125
+
# makeWrapper ${pkgs.prettier}/bin/prettier "$out" --add-flags "--plugin=${pkgs.prettier-plugin-go-template}/lib/node_modules/prettier-plugin-go-template/lib/index.js"
126
+
# '';
127
+
# in {
128
+
# command = wrapper;
129
+
# options = ["-w"];
130
+
# includes = ["*.html"];
131
+
# # causes Go template plugin errors: https://github.com/NiklasPor/prettier-plugin-go-template/issues/120
132
+
# excludes = ["appview/pages/templates/layouts/repobase.html" "appview/pages/templates/repo/tags.html"];
133
+
# };
134
+
};
135
+
};
112
136
});
113
-
defaultPackage = forAllSystems (system: nixpkgsFor.${system}.appview);
114
-
formatter = forAllSystems (system: nixpkgsFor."${system}".alejandra);
137
+
defaultPackage = forAllSystems (system: self.packages.${system}.appview);
115
138
devShells = forAllSystems (system: let
116
139
pkgs = nixpkgsFor.${system};
140
+
packages' = self.packages.${system};
117
141
staticShell = pkgs.mkShell.override {
118
142
stdenv = pkgs.pkgsStatic.stdenv;
119
143
};
···
124
148
pkgs.air
125
149
pkgs.gopls
126
150
pkgs.httpie
127
-
pkgs.lexgen
128
151
pkgs.litecli
129
152
pkgs.websocat
130
153
pkgs.tailwindcss
131
154
pkgs.nixos-shell
132
155
pkgs.redis
156
+
pkgs.coreutils # for those of us who are on systems that use busybox (alpine)
157
+
packages'.lexgen
158
+
packages'.treefmt-wrapper
133
159
];
134
160
shellHook = ''
135
-
mkdir -p appview/pages/static/{fonts,icons}
136
-
cp -f ${htmx-src} appview/pages/static/htmx.min.js
137
-
cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js
138
-
cp -rf ${lucide-src}/*.svg appview/pages/static/icons/
139
-
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/
140
-
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
141
-
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)"
161
+
mkdir -p appview/pages/static
162
+
# no preserve is needed because watch-tailwind will want to be able to overwrite
163
+
cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
164
+
export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)"
143
165
'';
144
166
env.CGO_ENABLED = 1;
145
167
};
146
168
});
147
169
apps = forAllSystems (system: let
148
170
pkgs = nixpkgsFor."${system}";
171
+
packages' = self.packages.${system};
149
172
air-watcher = name: arg:
150
173
pkgs.writeShellScriptBin "run"
151
174
''
152
175
${pkgs.air}/bin/air -c /dev/null \
153
176
-build.cmd "${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \
154
-
-build.bin "./out/${name}.out ${arg}" \
177
+
-build.bin "./out/${name}.out" \
178
+
-build.args_bin "${arg}" \
155
179
-build.stop_on_error "true" \
156
180
-build.include_ext "go"
157
181
'';
···
161
185
${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css
162
186
'';
163
187
in {
188
+
fmt = {
189
+
type = "app";
190
+
program = pkgs.lib.getExe packages'.treefmt-wrapper;
191
+
};
164
192
watch-appview = {
165
193
type = "app";
166
-
program = ''${air-watcher "appview" ""}/bin/run'';
194
+
program = toString (pkgs.writeShellScript "watch-appview" ''
195
+
echo "copying static files to appview/pages/static..."
196
+
${pkgs.coreutils}/bin/cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
197
+
${air-watcher "appview" ""}/bin/run
198
+
'');
167
199
};
168
200
watch-knot = {
169
201
type = "app";
···
173
205
type = "app";
174
206
program = ''${tailwind-watcher}/bin/run'';
175
207
};
208
+
vm = let
209
+
guestSystem =
210
+
if pkgs.stdenv.hostPlatform.isAarch64
211
+
then "aarch64-linux"
212
+
else "x86_64-linux";
213
+
in {
214
+
type = "app";
215
+
program =
216
+
(pkgs.writeShellApplication {
217
+
name = "launch-vm";
218
+
text = ''
219
+
rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1)
220
+
cd "$rootDir"
221
+
222
+
mkdir -p nix/vm-data/{knot,repos,spindle,spindle-logs}
223
+
224
+
export TANGLED_VM_DATA_DIR="$rootDir/nix/vm-data"
225
+
exec ${pkgs.lib.getExe
226
+
(import ./nix/vm.nix {
227
+
inherit nixpkgs self;
228
+
system = guestSystem;
229
+
hostSystem = system;
230
+
}).config.system.build.vm}
231
+
'';
232
+
})
233
+
+ /bin/launch-vm;
234
+
};
235
+
gomod2nix = {
236
+
type = "app";
237
+
program = toString (pkgs.writeShellScript "gomod2nix" ''
238
+
${gomod2nix.legacyPackages.${system}.gomod2nix}/bin/gomod2nix generate --outdir ./nix
239
+
'');
240
+
};
241
+
lexgen = {
242
+
type = "app";
243
+
program =
244
+
(pkgs.writeShellApplication {
245
+
name = "lexgen";
246
+
text = ''
247
+
if ! command -v lexgen > /dev/null; then
248
+
echo "error: must be executed from devshell"
249
+
exit 1
250
+
fi
251
+
252
+
rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1)
253
+
cd "$rootDir"
254
+
255
+
rm api/tangled/*
256
+
lexgen --build-file lexicon-build-config.json lexicons
257
+
sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/*
258
+
${pkgs.gotools}/bin/goimports -w api/tangled/*
259
+
go run cmd/gen.go
260
+
lexgen --build-file lexicon-build-config.json lexicons
261
+
rm api/tangled/*.bak
262
+
'';
263
+
})
264
+
+ /bin/lexgen;
265
+
};
176
266
});
177
267
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;};
181
-
nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;};
268
+
nixosModules.appview = {
269
+
lib,
270
+
pkgs,
271
+
...
272
+
}: {
273
+
imports = [./nix/modules/appview.nix];
274
+
275
+
services.tangled-appview.package = lib.mkDefault self.packages.${pkgs.system}.appview;
276
+
};
277
+
nixosModules.knot = {
278
+
lib,
279
+
pkgs,
280
+
...
281
+
}: {
282
+
imports = [./nix/modules/knot.nix];
283
+
284
+
services.tangled-knot.package = lib.mkDefault self.packages.${pkgs.system}.knot;
285
+
};
286
+
nixosModules.spindle = {
287
+
lib,
288
+
pkgs,
289
+
...
290
+
}: {
291
+
imports = [./nix/modules/spindle.nix];
292
+
293
+
services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
294
+
};
182
295
};
183
296
}
+57
-35
go.mod
+57
-35
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
···
21
22
github.com/go-enry/go-enry/v2 v2.9.2
22
23
github.com/go-git/go-git/v5 v5.14.0
23
24
github.com/google/uuid v1.6.0
25
+
github.com/gorilla/feeds v1.2.0
24
26
github.com/gorilla/sessions v1.4.0
25
-
github.com/gorilla/websocket v1.5.3
27
+
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
26
28
github.com/hiddeco/sshsig v0.2.0
27
29
github.com/hpcloud/tail v1.0.0
28
30
github.com/ipfs/go-cid v0.5.0
29
31
github.com/lestrrat-go/jwx/v2 v2.1.6
30
32
github.com/mattn/go-sqlite3 v1.14.24
31
33
github.com/microcosm-cc/bluemonday v1.0.27
34
+
github.com/openbao/openbao/api/v2 v2.3.0
32
35
github.com/posthog/posthog-go v1.5.5
33
-
github.com/redis/go-redis/v9 v9.3.0
36
+
github.com/redis/go-redis/v9 v9.7.3
34
37
github.com/resend/resend-go/v2 v2.15.0
35
38
github.com/sethvargo/go-envconfig v1.1.0
36
39
github.com/stretchr/testify v1.10.0
37
40
github.com/urfave/cli/v3 v3.3.3
38
41
github.com/whyrusleeping/cbor-gen v0.3.1
39
-
github.com/yuin/goldmark v1.4.13
40
-
golang.org/x/crypto v0.38.0
41
-
golang.org/x/net v0.40.0
42
+
github.com/yuin/goldmark v1.4.15
43
+
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
44
+
golang.org/x/crypto v0.40.0
45
+
golang.org/x/net v0.42.0
46
+
golang.org/x/sync v0.16.0
42
47
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
43
48
gopkg.in/yaml.v3 v3.0.1
44
-
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421
49
+
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1
45
50
)
46
51
47
52
require (
48
53
dario.cat/mergo v1.0.1 // indirect
49
54
github.com/Microsoft/go-winio v0.6.2 // indirect
50
-
github.com/ProtonMail/go-crypto v1.2.0 // indirect
55
+
github.com/ProtonMail/go-crypto v1.3.0 // indirect
56
+
github.com/alecthomas/repr v0.4.0 // indirect
51
57
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
52
-
github.com/avast/retry-go/v4 v4.6.1 // indirect
53
58
github.com/aymerick/douceur v0.2.0 // indirect
54
59
github.com/beorn7/perks v1.0.1 // indirect
55
60
github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect
56
61
github.com/casbin/govaluate v1.3.0 // indirect
62
+
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
57
63
github.com/cespare/xxhash/v2 v2.3.0 // indirect
58
-
github.com/cloudflare/circl v1.6.0 // indirect
64
+
github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect
59
65
github.com/containerd/errdefs v1.0.0 // indirect
60
66
github.com/containerd/errdefs/pkg v0.3.0 // indirect
61
67
github.com/containerd/log v0.1.0 // indirect
···
68
74
github.com/docker/go-units v0.5.0 // indirect
69
75
github.com/emirpasic/gods v1.18.1 // indirect
70
76
github.com/felixge/httpsnoop v1.0.4 // indirect
77
+
github.com/fsnotify/fsnotify v1.6.0 // indirect
71
78
github.com/go-enry/go-oniguruma v1.2.1 // indirect
72
79
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
73
80
github.com/go-git/go-billy/v5 v5.6.2 // indirect
74
-
github.com/go-logr/logr v1.4.2 // indirect
81
+
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
82
+
github.com/go-logr/logr v1.4.3 // indirect
75
83
github.com/go-logr/stdr v1.2.2 // indirect
76
84
github.com/go-redis/cache/v9 v9.0.0 // indirect
85
+
github.com/go-test/deep v1.1.1 // indirect
77
86
github.com/goccy/go-json v0.10.5 // indirect
78
87
github.com/gogo/protobuf v1.3.2 // indirect
79
-
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
88
+
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
80
89
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
90
+
github.com/golang/mock v1.6.0 // indirect
91
+
github.com/google/go-querystring v1.1.0 // indirect
81
92
github.com/gorilla/css v1.0.1 // indirect
82
93
github.com/gorilla/securecookie v1.1.2 // indirect
94
+
github.com/hashicorp/errwrap v1.1.0 // indirect
83
95
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
84
-
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
96
+
github.com/hashicorp/go-multierror v1.1.1 // indirect
97
+
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
98
+
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect
99
+
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
100
+
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
85
101
github.com/hashicorp/golang-lru v1.0.2 // indirect
86
102
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
103
+
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
104
+
github.com/hexops/gotextdiff v1.0.3 // indirect
87
105
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
106
+
github.com/ipfs/boxo v0.33.0 // indirect
107
+
github.com/ipfs/go-block-format v0.2.2 // indirect
90
108
github.com/ipfs/go-datastore v0.8.2 // indirect
91
109
github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect
92
110
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
111
+
github.com/ipfs/go-ipld-cbor v0.2.1 // indirect
112
+
github.com/ipfs/go-ipld-format v0.6.2 // indirect
95
113
github.com/ipfs/go-log v1.0.5 // indirect
96
114
github.com/ipfs/go-log/v2 v2.6.0 // indirect
97
115
github.com/ipfs/go-metrics-interface v0.3.0 // indirect
98
116
github.com/kevinburke/ssh_config v1.2.0 // indirect
99
117
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
118
+
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
119
+
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
102
120
github.com/lestrrat-go/httpcc v1.0.1 // indirect
103
121
github.com/lestrrat-go/httprc v1.0.6 // indirect
104
122
github.com/lestrrat-go/iter v1.0.2 // indirect
105
123
github.com/lestrrat-go/option v1.0.1 // indirect
106
124
github.com/mattn/go-isatty v0.0.20 // indirect
107
125
github.com/minio/sha256-simd v1.0.1 // indirect
126
+
github.com/mitchellh/mapstructure v1.5.0 // indirect
108
127
github.com/moby/docker-image-spec v1.3.1 // indirect
109
128
github.com/moby/sys/atomicwriter v0.1.0 // indirect
110
129
github.com/moby/term v0.5.2 // indirect
···
116
135
github.com/multiformats/go-multihash v0.2.3 // indirect
117
136
github.com/multiformats/go-varint v0.0.7 // indirect
118
137
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
138
+
github.com/onsi/gomega v1.37.0 // indirect
119
139
github.com/opencontainers/go-digest v1.0.0 // indirect
120
140
github.com/opencontainers/image-spec v1.1.1 // indirect
121
-
github.com/opentracing/opentracing-go v1.2.0 // indirect
141
+
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
122
142
github.com/pjbgf/sha1cd v0.3.2 // indirect
123
143
github.com/pkg/errors v0.9.1 // indirect
124
144
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
125
145
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
126
146
github.com/prometheus/client_golang v1.22.0 // indirect
127
147
github.com/prometheus/client_model v0.6.2 // indirect
128
-
github.com/prometheus/common v0.63.0 // indirect
148
+
github.com/prometheus/common v0.64.0 // indirect
129
149
github.com/prometheus/procfs v0.16.1 // indirect
150
+
github.com/ryanuber/go-glob v1.0.0 // indirect
130
151
github.com/segmentio/asm v1.2.0 // indirect
131
152
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
132
153
github.com/spaolacci/murmur3 v1.1.0 // indirect
···
136
157
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
137
158
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
138
159
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
160
+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
161
+
go.opentelemetry.io/otel v1.37.0 // indirect
162
+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect
163
+
go.opentelemetry.io/otel/metric v1.37.0 // indirect
164
+
go.opentelemetry.io/otel/trace v1.37.0 // indirect
143
165
go.opentelemetry.io/proto/otlp v1.6.0 // indirect
144
166
go.uber.org/atomic v1.11.0 // indirect
145
167
go.uber.org/multierr v1.11.0 // indirect
146
168
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
169
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
170
+
golang.org/x/sys v0.34.0 // indirect
171
+
golang.org/x/text v0.27.0 // indirect
172
+
golang.org/x/time v0.12.0 // indirect
173
+
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
174
+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
175
+
google.golang.org/grpc v1.73.0 // indirect
154
176
google.golang.org/protobuf v1.36.6 // indirect
155
177
gopkg.in/fsnotify.v1 v1.4.7 // indirect
156
178
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
+136
-88
go.sum
+136
-88
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=
···
77
79
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
78
80
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
79
81
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
82
+
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
80
83
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
81
84
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
82
85
github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw=
···
91
94
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
92
95
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
93
96
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=
97
+
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
98
+
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
96
99
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
97
100
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
98
101
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
102
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
103
+
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
104
+
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
101
105
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
102
106
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
103
107
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
···
114
118
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
115
119
github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03 h1:LumE+tQdnYW24a9RoO08w64LHTzkNkdUqBD/0QPtlEY=
116
120
github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM=
121
+
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
122
+
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
117
123
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
118
124
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=
125
+
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
126
+
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
121
127
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
122
128
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
123
129
github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0=
124
130
github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI=
125
131
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
132
+
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
133
+
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
126
134
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
127
135
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
128
136
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
129
137
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
130
138
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=
139
+
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
140
+
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
133
141
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
134
142
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
143
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
144
+
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
145
+
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
137
146
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
138
147
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
139
148
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
···
146
155
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
147
156
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
148
157
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
158
+
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
149
159
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
150
160
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
151
161
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
152
162
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
153
163
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
164
+
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
165
+
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
154
166
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
155
167
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
156
168
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
···
162
174
github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
163
175
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
164
176
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
177
+
github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc=
178
+
github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y=
165
179
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
166
180
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
167
181
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
168
182
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=
183
+
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
184
+
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
171
185
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
172
186
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
187
+
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
188
+
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
189
+
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
173
190
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
174
191
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
175
192
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
176
193
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=
194
+
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
195
+
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
196
+
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
197
+
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
198
+
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM=
199
+
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0=
200
+
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
201
+
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
202
+
github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw=
203
+
github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw=
179
204
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
180
205
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
181
206
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
182
207
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
208
+
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
209
+
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
183
210
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
184
211
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
185
212
github.com/hiddeco/sshsig v0.2.0 h1:gMWllgKCITXdydVkDL+Zro0PU96QI55LwUwebSwNTSw=
···
189
216
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
190
217
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
191
218
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=
219
+
github.com/ipfs/boxo v0.33.0 h1:9ow3chwkDzMj0Deq4AWRUEI7WnIIV7SZhPTzzG2mmfw=
220
+
github.com/ipfs/boxo v0.33.0/go.mod h1:3IPh7YFcCIcKp6o02mCHovrPntoT5Pctj/7j4syh/RM=
221
+
github.com/ipfs/go-block-format v0.2.2 h1:uecCTgRwDIXyZPgYspaLXoMiMmxQpSx2aq34eNc4YvQ=
222
+
github.com/ipfs/go-block-format v0.2.2/go.mod h1:vmuefuWU6b+9kIU0vZJgpiJt1yicQz9baHXE8qR+KB8=
196
223
github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg=
197
224
github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk=
198
225
github.com/ipfs/go-datastore v0.8.2 h1:Jy3wjqQR6sg/LhyY0NIePZC3Vux19nLtg7dx0TVqr6U=
···
205
232
github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo=
206
233
github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0=
207
234
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=
235
+
github.com/ipfs/go-ipld-cbor v0.2.1 h1:H05yEJbK/hxg0uf2AJhyerBDbjOuHX4yi+1U/ogRa7E=
236
+
github.com/ipfs/go-ipld-cbor v0.2.1/go.mod h1:x9Zbeq8CoE5R2WicYgBMcr/9mnkQ0lHddYWJP2sMV3A=
237
+
github.com/ipfs/go-ipld-format v0.6.2 h1:bPZQ+A05ol0b3lsJSl0bLvwbuQ+HQbSsdGTy4xtYUkU=
238
+
github.com/ipfs/go-ipld-format v0.6.2/go.mod h1:nni2xFdHKx5lxvXJ6brt/pndtGxKAE+FPR1rg4jTkyk=
212
239
github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8=
213
240
github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo=
214
241
github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g=
···
216
243
github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8=
217
244
github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU=
218
245
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
246
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
222
247
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
223
248
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
···
229
254
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
230
255
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
231
256
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=
257
+
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
258
+
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
234
259
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
235
260
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
236
261
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
···
239
264
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
240
265
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
241
266
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=
267
+
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
268
+
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
244
269
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
245
270
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
246
271
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
···
251
276
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
252
277
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
253
278
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=
279
+
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
280
+
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
260
281
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
261
282
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
262
283
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
···
265
286
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
266
287
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
267
288
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
289
+
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
290
+
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
268
291
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
269
292
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
270
293
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
···
281
304
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
282
305
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
283
306
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
307
github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g=
287
308
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
309
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
291
310
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
292
311
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
···
318
337
github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg=
319
338
github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM=
320
339
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=
340
+
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
341
+
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
342
+
github.com/openbao/openbao/api/v2 v2.3.0 h1:61FO3ILtpKoxbD9kTWeGaCq8pz1sdt4dv2cmTXsiaAc=
343
+
github.com/openbao/openbao/api/v2 v2.3.0/go.mod h1:T47WKHb7DqHa3Ms3xicQtl5EiPE+U8diKjb9888okWs=
323
344
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
324
345
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
325
346
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
326
347
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
348
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
349
+
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
350
+
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
329
351
github.com/oppiliappan/chroma/v2 v2.19.0 h1:PN7/pb+6JRKCva30NPTtRJMlrOyzgpPpIroNzy4ekHU=
330
352
github.com/oppiliappan/chroma/v2 v2.19.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
331
353
github.com/oppiliappan/go-git/v5 v5.17.0 h1:CuJnpcIDxr0oiNaSHMconovSWnowHznVDG+AhjGuSEo=
···
346
368
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
347
369
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
348
370
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=
371
+
github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4=
372
+
github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
351
373
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
352
374
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
353
375
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=
376
+
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
377
+
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
356
378
github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE=
357
379
github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
358
380
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
···
360
382
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
361
383
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
362
384
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
385
+
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
386
+
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
363
387
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
364
388
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
365
389
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
···
404
428
github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
405
429
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
406
430
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
431
+
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
407
432
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
408
-
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
409
433
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
434
+
github.com/yuin/goldmark v1.4.15 h1:CFa84T0goNn/UIXYS+dmjjVxMyTAvpOmzld40N/nfK0=
435
+
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
436
+
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
437
+
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
410
438
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
411
439
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
412
440
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
413
441
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
414
442
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
415
443
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=
444
+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
445
+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
446
+
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
447
+
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
448
+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA=
449
+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI=
422
450
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU=
423
451
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=
452
+
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
453
+
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
454
+
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
455
+
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
456
+
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
457
+
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
458
+
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
459
+
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
432
460
go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI=
433
461
go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc=
434
462
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
···
451
479
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
452
480
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
453
481
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=
482
+
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
483
+
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
484
+
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
485
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
486
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
458
487
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
459
488
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
460
489
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
461
490
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
491
+
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
462
492
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
463
493
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
464
494
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
465
495
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
496
+
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
466
497
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
467
498
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
468
499
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
···
471
502
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
472
503
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
473
504
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
505
+
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
474
506
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
475
507
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
476
508
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
···
480
512
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
481
513
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
482
514
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=
515
+
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
516
+
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
517
+
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
518
+
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
485
519
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
486
520
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
487
521
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
···
489
523
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
490
524
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
491
525
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=
526
+
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
527
+
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
494
528
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
495
529
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
496
530
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
···
502
536
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
503
537
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
504
538
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
539
+
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
505
540
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
541
+
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
506
542
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
507
543
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
508
544
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
···
510
546
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
511
547
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
512
548
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
549
+
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
513
550
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
514
551
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
515
552
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
516
553
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
554
+
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
517
555
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=
556
+
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
557
+
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
558
+
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
559
+
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
520
560
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
521
561
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
522
562
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
523
563
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
524
564
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
525
565
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=
566
+
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
567
+
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
568
+
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
569
+
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
570
+
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
528
571
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
529
572
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
530
573
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
···
532
575
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
533
576
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
534
577
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=
578
+
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
579
+
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
580
+
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
581
+
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
582
+
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
583
+
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
584
+
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
539
585
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
540
586
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
541
587
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
···
547
593
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
548
594
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
549
595
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
596
+
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
550
597
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
551
598
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
552
599
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
553
600
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
601
+
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
554
602
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
555
603
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
556
604
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
557
605
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
558
606
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
559
607
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=
608
+
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
609
+
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
610
+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
611
+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
612
+
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
613
+
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
566
614
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
567
615
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
568
616
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
···
599
647
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
600
648
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
601
649
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=
650
+
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 h1:z1os1aRIqeo5e8d0Tx7hk+LH8OdZZeIOY0zw9VB/ZoU=
651
+
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1/go.mod h1:+oQi9S6IIDll0nxLZVhuzOPX8WKLCYEnE6M5kUKupDg=
604
652
tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc=
605
653
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
+
}
+84
-8
input.css
+84
-8
input.css
···
13
13
@font-face {
14
14
font-family: "InterVariable";
15
15
src: url("/static/fonts/InterVariable-Italic.woff2") format("woff2");
16
-
font-weight: 400;
16
+
font-weight: normal;
17
17
font-style: italic;
18
18
font-display: swap;
19
19
}
20
20
21
21
@font-face {
22
22
font-family: "InterVariable";
23
-
src: url("/static/fonts/InterVariable.woff2") format("woff2");
24
-
font-weight: 600;
23
+
src: url("/static/fonts/InterDisplay-Bold.woff2") format("woff2");
24
+
font-weight: bold;
25
25
font-style: normal;
26
26
font-display: swap;
27
27
}
28
28
29
29
@font-face {
30
+
font-family: "InterVariable";
31
+
src: url("/static/fonts/InterDisplay-BoldItalic.woff2") format("woff2");
32
+
font-weight: bold;
33
+
font-style: italic;
34
+
font-display: swap;
35
+
}
36
+
37
+
@font-face {
30
38
font-family: "IBMPlexMono";
31
39
src: url("/static/fonts/IBMPlexMono-Regular.woff2") format("woff2");
32
40
font-weight: normal;
41
+
font-style: normal;
42
+
font-display: swap;
43
+
}
44
+
45
+
@font-face {
46
+
font-family: "IBMPlexMono";
47
+
src: url("/static/fonts/IBMPlexMono-Italic.woff2") format("woff2");
48
+
font-weight: normal;
49
+
font-style: italic;
50
+
font-display: swap;
51
+
}
52
+
53
+
@font-face {
54
+
font-family: "IBMPlexMono";
55
+
src: url("/static/fonts/IBMPlexMono-Bold.woff2") format("woff2");
56
+
font-weight: bold;
57
+
font-style: normal;
58
+
font-display: swap;
59
+
}
60
+
61
+
@font-face {
62
+
font-family: "IBMPlexMono";
63
+
src: url("/static/fonts/IBMPlexMono-BoldItalic.woff2") format("woff2");
64
+
font-weight: bold;
33
65
font-style: italic;
34
66
font-display: swap;
35
67
}
···
46
78
@supports (font-variation-settings: normal) {
47
79
html {
48
80
font-feature-settings:
49
-
"ss01" 1,
50
81
"kern" 1,
51
82
"liga" 1,
52
83
"cv05" 1,
···
70
101
details summary::-webkit-details-marker {
71
102
display: none;
72
103
}
104
+
105
+
code {
106
+
@apply font-mono rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white;
107
+
}
73
108
}
74
109
75
110
@layer components {
···
98
133
disabled:before:bg-green-400 dark:disabled:before:bg-green-600;
99
134
}
100
135
136
+
.prose hr {
137
+
@apply my-2;
138
+
}
139
+
140
+
.prose li:has(input) {
141
+
@apply list-none;
142
+
}
143
+
144
+
.prose ul:has(input) {
145
+
@apply pl-2;
146
+
}
147
+
148
+
.prose .heading .anchor {
149
+
@apply no-underline mx-2 opacity-0;
150
+
}
151
+
152
+
.prose .heading:hover .anchor {
153
+
@apply opacity-70;
154
+
}
155
+
156
+
.prose .heading .anchor:hover {
157
+
@apply opacity-70;
158
+
}
159
+
160
+
.prose a.footnote-backref {
161
+
@apply no-underline;
162
+
}
163
+
164
+
.prose li {
165
+
@apply my-0 py-0;
166
+
}
167
+
168
+
.prose ul, .prose ol {
169
+
@apply my-1 py-0;
170
+
}
171
+
101
172
.prose img {
102
173
display: inline;
103
-
margin-left: 0;
104
-
margin-right: 0;
174
+
margin: 0;
105
175
vertical-align: middle;
176
+
}
177
+
178
+
.prose input {
179
+
@apply inline-block my-0 mb-1 mx-1;
180
+
}
181
+
182
+
.prose input[type="checkbox"] {
183
+
@apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500;
106
184
}
107
185
}
108
186
@layer utilities {
···
123
201
/* PreWrapper */
124
202
.chroma {
125
203
color: #4c4f69;
126
-
background-color: #eff1f5;
127
204
}
128
205
/* Error */
129
206
.chroma .err {
···
460
537
/* PreWrapper */
461
538
.chroma {
462
539
color: #cad3f5;
463
-
background-color: #24273a;
464
540
}
465
541
/* Error */
466
542
.chroma .err {
+19
-4
jetstream/jetstream.go
+19
-4
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 {
58
-
// empty filter => all dids allowed
59
-
if len(j.wantedDids) == 0 {
60
-
return processFunc
61
-
}
62
71
// since this closure references j.WantedDids; it should auto-update
63
72
// existing instances of the closure when j.WantedDids is mutated
64
73
return func(ctx context.Context, evt *models.Event) error {
74
+
75
+
// empty filter => all dids allowed
76
+
if len(j.wantedDids) == 0 {
77
+
return processFunc(ctx, evt)
78
+
}
79
+
65
80
if _, ok := j.wantedDids[evt.Did]; ok {
66
81
return processFunc(ctx, evt)
67
82
} else {
+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 {
+14
-10
knotserver/db/init.go
+14
-10
knotserver/db/init.go
···
2
2
3
3
import (
4
4
"database/sql"
5
+
"strings"
5
6
6
7
_ "github.com/mattn/go-sqlite3"
7
8
)
···
11
12
}
12
13
13
14
func Setup(dbPath string) (*DB, error) {
14
-
db, err := sql.Open("sqlite3", dbPath)
15
+
// https://github.com/mattn/go-sqlite3#connection-string
16
+
opts := []string{
17
+
"_foreign_keys=1",
18
+
"_journal_mode=WAL",
19
+
"_synchronous=NORMAL",
20
+
"_auto_vacuum=incremental",
21
+
}
22
+
23
+
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
15
24
if err != nil {
16
25
return nil, err
17
26
}
18
27
19
-
_, err = db.Exec(`
20
-
pragma journal_mode = WAL;
21
-
pragma synchronous = normal;
22
-
pragma foreign_keys = on;
23
-
pragma temp_store = memory;
24
-
pragma mmap_size = 30000000000;
25
-
pragma page_size = 32768;
26
-
pragma auto_vacuum = incremental;
27
-
pragma busy_timeout = 5000;
28
+
// NOTE: If any other migration is added here, you MUST
29
+
// copy the pattern in appview: use a single sql.Conn
30
+
// for every migration.
28
31
32
+
_, err = db.Exec(`
29
33
create table if not exists known_dids (
30
34
did text primary key
31
35
);
-8
knotserver/file.go
-8
knotserver/file.go
···
10
10
"tangled.sh/tangled.sh/core/types"
11
11
)
12
12
13
-
func (h *Handle) listFiles(files []types.NiceTree, data map[string]any, w http.ResponseWriter) {
14
-
data["files"] = files
15
-
16
-
writeJSON(w, data)
17
-
return
18
-
}
19
-
20
13
func countLines(r io.Reader) (int, error) {
21
14
buf := make([]byte, 32*1024)
22
15
bufLen := 0
···
52
45
53
46
resp.Lines = lc
54
47
writeJSON(w, resp)
55
-
return
56
48
}
+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
+
}
+8
-10
knotserver/git/fork.go
+8
-10
knotserver/git/fork.go
···
10
10
)
11
11
12
12
func Fork(repoPath, source string) error {
13
-
_, err := git.PlainClone(repoPath, true, &git.CloneOptions{
14
-
URL: source,
15
-
SingleBranch: false,
16
-
})
17
-
18
-
if err != nil {
13
+
cloneCmd := exec.Command("git", "clone", "--bare", source, repoPath)
14
+
if err := cloneCmd.Run(); err != nil {
19
15
return fmt.Errorf("failed to bare clone repository: %w", err)
20
16
}
21
17
22
-
err = exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden").Run()
23
-
if err != nil {
18
+
configureCmd := exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden")
19
+
if err := configureCmd.Run(); err != nil {
24
20
return fmt.Errorf("failed to configure hidden refs: %w", err)
25
21
}
26
22
27
23
return nil
28
24
}
29
25
30
-
func (g *GitRepo) Sync(branch string) error {
26
+
func (g *GitRepo) Sync() error {
27
+
branch := g.h.String()
28
+
31
29
fetchOpts := &git.FetchOptions{
32
30
RefSpecs: []config.RefSpec{
33
-
config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/heads/%s", branch, branch)),
31
+
config.RefSpec("+" + branch + ":" + branch), // +refs/heads/master:refs/heads/master
34
32
},
35
33
}
36
34
+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
+
}
+69
-30
knotserver/git/post_receive.go
+69
-30
knotserver/git/post_receive.go
···
2
2
3
3
import (
4
4
"bufio"
5
+
"context"
6
+
"errors"
5
7
"fmt"
6
8
"io"
7
9
"strings"
10
+
"time"
8
11
9
12
"tangled.sh/tangled.sh/core/api/tangled"
10
13
···
46
49
}
47
50
48
51
type RefUpdateMeta struct {
49
-
CommitCount CommitCount
50
-
IsDefaultRef bool
52
+
CommitCount CommitCount
53
+
IsDefaultRef bool
54
+
LangBreakdown LangBreakdown
51
55
}
52
56
53
57
type CommitCount struct {
54
58
ByEmail map[string]int
55
59
}
56
60
57
-
func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) RefUpdateMeta {
61
+
func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) (RefUpdateMeta, error) {
62
+
var errs error
63
+
58
64
commitCount, err := g.newCommitCount(line)
59
-
if err != nil {
60
-
// TODO: non-fatal, log this
61
-
}
65
+
errors.Join(errs, err)
62
66
63
67
isDefaultRef, err := g.isDefaultBranch(line)
64
-
if err != nil {
65
-
// TODO: non-fatal, log this
66
-
}
68
+
errors.Join(errs, err)
69
+
70
+
ctx, cancel := context.WithTimeout(context.Background(), time.Second*2)
71
+
defer cancel()
72
+
breakdown, err := g.AnalyzeLanguages(ctx)
73
+
errors.Join(errs, err)
67
74
68
75
return RefUpdateMeta{
69
-
CommitCount: commitCount,
70
-
IsDefaultRef: isDefaultRef,
71
-
}
76
+
CommitCount: commitCount,
77
+
IsDefaultRef: isDefaultRef,
78
+
LangBreakdown: breakdown,
79
+
}, errs
72
80
}
73
81
74
82
func (g *GitRepo) newCommitCount(line PostReceiveLine) (CommitCount, error) {
···
77
85
ByEmail: byEmail,
78
86
}
79
87
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)
88
+
if line.NewSha.IsZero() {
89
+
return commitCount, nil
90
+
}
91
+
92
+
args := []string{fmt.Sprintf("--max-count=%d", 100)}
93
+
94
+
if line.OldSha.IsZero() {
95
+
// git rev-list <newsha> ^other-branches --not ^this-branch
96
+
args = append(args, line.NewSha.String())
97
+
98
+
branches, _ := g.Branches()
99
+
for _, b := range branches {
100
+
if !strings.Contains(line.Ref, b.Name) {
101
+
args = append(args, fmt.Sprintf("^%s", b.Name))
102
+
}
87
103
}
88
104
89
-
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
90
-
if len(lines) == 1 && lines[0] == "" {
91
-
return commitCount, nil
92
-
}
105
+
args = append(args, "--not")
106
+
args = append(args, fmt.Sprintf("^%s", line.Ref))
107
+
} else {
108
+
// git rev-list <oldsha>..<newsha>
109
+
args = append(args, fmt.Sprintf("%s..%s", line.OldSha.String(), line.NewSha.String()))
110
+
}
111
+
112
+
output, err := g.revList(args...)
113
+
if err != nil {
114
+
return commitCount, fmt.Errorf("failed to run rev-list: %w", err)
115
+
}
93
116
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
117
+
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
118
+
if len(lines) == 1 && lines[0] == "" {
119
+
return commitCount, nil
120
+
}
121
+
122
+
for _, item := range lines {
123
+
obj, err := g.r.CommitObject(plumbing.NewHash(item))
124
+
if err != nil {
125
+
continue
100
126
}
127
+
commitCount.ByEmail[obj.Author.Email] += 1
101
128
}
102
129
103
130
return commitCount, nil
···
126
153
})
127
154
}
128
155
156
+
var langs []*tangled.GitRefUpdate_Pair
157
+
for lang, size := range m.LangBreakdown {
158
+
langs = append(langs, &tangled.GitRefUpdate_Pair{
159
+
Lang: lang,
160
+
Size: size,
161
+
})
162
+
}
163
+
langBreakdown := &tangled.GitRefUpdate_Meta_LangBreakdown{
164
+
Inputs: langs,
165
+
}
166
+
129
167
return tangled.GitRefUpdate_Meta{
130
168
CommitCount: &tangled.GitRefUpdate_Meta_CommitCount{
131
169
ByEmail: byEmail,
132
170
},
133
-
IsDefaultRef: m.IsDefaultRef,
171
+
IsDefaultRef: m.IsDefaultRef,
172
+
LangBreakdown: langBreakdown,
134
173
}
135
174
}
+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
+
}
+5
knotserver/git.go
+5
knotserver/git.go
···
129
129
// If the appview gave us the repository owner's handle we can attempt to
130
130
// construct the correct ssh url.
131
131
ownerHandle := r.Header.Get("x-tangled-repo-owner-handle")
132
+
ownerHandle = strings.TrimPrefix(ownerHandle, "@")
132
133
if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") {
133
134
hostname := d.c.Server.Hostname
134
135
if strings.Contains(hostname, ":") {
135
136
hostname = strings.Split(hostname, ":")[0]
137
+
}
138
+
139
+
if hostname == "knot1.tangled.sh" {
140
+
hostname = "tangled.sh"
136
141
}
137
142
138
143
fmt.Fprintf(w, " Try:\ngit remote set-url --push origin git@%s:%s/%s\n\n... and push again.", hostname, ownerHandle, unqualifiedRepoName)
+44
-25
knotserver/handler.go
+44
-25
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
19
)
17
20
18
-
const (
19
-
ThisServer = "thisserver" // resource identifier for rbac enforcement
20
-
)
21
-
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
-
54
-
err = h.jc.StartJetstream(ctx, h.processMessages)
55
-
if err != nil {
56
-
return nil, fmt.Errorf("failed to start jetstream: %w", err)
57
53
}
58
54
59
55
// Check if the knot knows about any Dids;
···
70
66
for _, d := range dids {
71
67
h.jc.AddDid(d)
72
68
}
69
+
}
70
+
71
+
err = h.jc.StartJetstream(ctx, h.processMessages)
72
+
if err != nil {
73
+
return nil, fmt.Errorf("failed to start jetstream: %w", err)
73
74
}
74
75
75
76
r.Get("/", h.Index)
···
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)
···
138
142
r.Delete("/", h.RemoveRepo)
139
143
r.Route("/fork", func(r chi.Router) {
140
144
r.Post("/", h.RepoFork)
141
-
r.Post("/sync/{branch}", h.RepoForkSync)
142
-
r.Get("/sync/{branch}", h.RepoForkAheadBehind)
145
+
r.Post("/sync/*", h.RepoForkSync)
146
+
r.Get("/sync/*", h.RepoForkAheadBehind)
143
147
})
144
148
})
145
149
···
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.
+106
-45
knotserver/ingester.go
+106
-45
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
27
-
func (h *Handle) processPublicKey(ctx context.Context, did string, record tangled.PublicKey) error {
28
+
func (h *Handle) processPublicKey(ctx context.Context, event *models.Event) error {
28
29
l := log.FromContext(ctx)
30
+
raw := json.RawMessage(event.Commit.Record)
31
+
did := event.Did
32
+
33
+
var record tangled.PublicKey
34
+
if err := json.Unmarshal(raw, &record); err != nil {
35
+
return fmt.Errorf("failed to unmarshal record: %w", err)
36
+
}
37
+
29
38
pk := db.PublicKey{
30
39
Did: did,
31
40
PublicKey: record,
···
38
47
return nil
39
48
}
40
49
41
-
func (h *Handle) processKnotMember(ctx context.Context, did string, record tangled.KnotMember) error {
50
+
func (h *Handle) processKnotMember(ctx context.Context, event *models.Event) error {
42
51
l := log.FromContext(ctx)
52
+
raw := json.RawMessage(event.Commit.Record)
53
+
did := event.Did
54
+
55
+
var record tangled.KnotMember
56
+
if err := json.Unmarshal(raw, &record); err != nil {
57
+
return fmt.Errorf("failed to unmarshal record: %w", err)
58
+
}
43
59
44
60
if record.Domain != h.c.Server.Hostname {
45
61
l.Error("domain mismatch", "domain", record.Domain, "expected", h.c.Server.Hostname)
46
62
return fmt.Errorf("domain mismatch: %s != %s", record.Domain, h.c.Server.Hostname)
47
63
}
48
64
49
-
ok, err := h.e.E.Enforce(did, ThisServer, ThisServer, "server:invite")
65
+
ok, err := h.e.E.Enforce(did, rbac.ThisServer, rbac.ThisServer, "server:invite")
50
66
if err != nil || !ok {
51
67
l.Error("failed to add member", "did", did)
52
68
return fmt.Errorf("failed to enforce permissions: %w", err)
53
69
}
54
70
55
-
if err := h.e.AddKnotMember(ThisServer, record.Subject); err != nil {
71
+
if err := h.e.AddKnotMember(rbac.ThisServer, record.Subject); err != nil {
56
72
l.Error("failed to add member", "error", err)
57
73
return fmt.Errorf("failed to add member: %w", err)
58
74
}
···
71
87
return nil
72
88
}
73
89
74
-
func (h *Handle) processPull(ctx context.Context, did string, record tangled.RepoPull) error {
90
+
func (h *Handle) processPull(ctx context.Context, event *models.Event) error {
91
+
raw := json.RawMessage(event.Commit.Record)
92
+
did := event.Did
93
+
94
+
var record tangled.RepoPull
95
+
if err := json.Unmarshal(raw, &record); err != nil {
96
+
return fmt.Errorf("failed to unmarshal record: %w", err)
97
+
}
98
+
75
99
l := log.FromContext(ctx)
76
100
l = l.With("handler", "processPull")
77
101
l = l.With("did", did)
···
151
175
return err
152
176
}
153
177
154
-
var pipeline workflow.Pipeline
178
+
var pipeline workflow.RawPipeline
155
179
for _, e := range workflowDir {
156
180
if !e.IsFile {
157
181
continue
···
163
187
continue
164
188
}
165
189
166
-
wf, err := workflow.FromFile(e.Name, contents)
167
-
if err != nil {
168
-
// TODO: log here, respond to client that is pushing
169
-
h.l.Error("failed to parse workflow", "err", err, "path", fpath)
170
-
continue
171
-
}
172
-
173
-
pipeline = append(pipeline, wf)
190
+
pipeline = append(pipeline, workflow.RawWorkflow{
191
+
Name: e.Name,
192
+
Contents: contents,
193
+
})
174
194
}
175
195
176
196
trigger := tangled.Pipeline_PullRequestTriggerData{
···
192
212
},
193
213
}
194
214
195
-
cp := compiler.Compile(pipeline)
215
+
cp := compiler.Compile(compiler.Parse(pipeline))
196
216
eventJson, err := json.Marshal(cp)
197
217
if err != nil {
198
218
return err
···
203
223
return nil
204
224
}
205
225
206
-
event := db.Event{
226
+
ev := db.Event{
207
227
Rkey: TID(),
208
228
Nsid: tangled.PipelineNSID,
209
229
EventJson: string(eventJson),
210
230
}
211
231
212
-
return h.db.InsertEvent(event, h.n)
232
+
return h.db.InsertEvent(ev, h.n)
233
+
}
234
+
235
+
// duplicated from add collaborator
236
+
func (h *Handle) processCollaborator(ctx context.Context, event *models.Event) error {
237
+
raw := json.RawMessage(event.Commit.Record)
238
+
did := event.Did
239
+
240
+
var record tangled.RepoCollaborator
241
+
if err := json.Unmarshal(raw, &record); err != nil {
242
+
return fmt.Errorf("failed to unmarshal record: %w", err)
243
+
}
244
+
245
+
repoAt, err := syntax.ParseATURI(record.Repo)
246
+
if err != nil {
247
+
return err
248
+
}
249
+
250
+
resolver := idresolver.DefaultResolver()
251
+
252
+
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
253
+
if err != nil || subjectId.Handle.IsInvalidHandle() {
254
+
return err
255
+
}
256
+
257
+
// TODO: fix this for good, we need to fetch the record here unfortunately
258
+
// resolve this aturi to extract the repo record
259
+
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
260
+
if err != nil || owner.Handle.IsInvalidHandle() {
261
+
return fmt.Errorf("failed to resolve handle: %w", err)
262
+
}
263
+
264
+
xrpcc := xrpc.Client{
265
+
Host: owner.PDSEndpoint(),
266
+
}
267
+
268
+
resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
269
+
if err != nil {
270
+
return err
271
+
}
272
+
273
+
repo := resp.Value.Val.(*tangled.Repo)
274
+
didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name)
275
+
276
+
// check perms for this user
277
+
if ok, err := h.e.IsCollaboratorInviteAllowed(did, rbac.ThisServer, didSlashRepo); !ok || err != nil {
278
+
return fmt.Errorf("insufficient permissions: %w", err)
279
+
}
280
+
281
+
if err := h.db.AddDid(subjectId.DID.String()); err != nil {
282
+
return err
283
+
}
284
+
h.jc.AddDid(subjectId.DID.String())
285
+
286
+
if err := h.e.AddCollaborator(subjectId.DID.String(), rbac.ThisServer, didSlashRepo); err != nil {
287
+
return err
288
+
}
289
+
290
+
return h.fetchAndAddKeys(ctx, subjectId.DID.String())
213
291
}
214
292
215
293
func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error {
···
256
334
}
257
335
258
336
func (h *Handle) processMessages(ctx context.Context, event *models.Event) error {
259
-
did := event.Did
260
337
if event.Kind != models.EventKindCommit {
261
338
return nil
262
339
}
···
265
342
defer func() {
266
343
eventTime := event.TimeUS
267
344
lastTimeUs := eventTime + 1
268
-
fmt.Println("lastTimeUs", lastTimeUs)
269
345
if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil {
270
346
err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
271
347
}
272
348
}()
273
349
274
-
raw := json.RawMessage(event.Commit.Record)
275
-
276
350
switch event.Commit.Collection {
277
351
case tangled.PublicKeyNSID:
278
-
var record tangled.PublicKey
279
-
if err := json.Unmarshal(raw, &record); err != nil {
280
-
return fmt.Errorf("failed to unmarshal record: %w", err)
281
-
}
282
-
if err := h.processPublicKey(ctx, did, record); err != nil {
283
-
return fmt.Errorf("failed to process public key: %w", err)
284
-
}
285
-
352
+
err = h.processPublicKey(ctx, event)
286
353
case tangled.KnotMemberNSID:
287
-
var record tangled.KnotMember
288
-
if err := json.Unmarshal(raw, &record); err != nil {
289
-
return fmt.Errorf("failed to unmarshal record: %w", err)
290
-
}
291
-
if err := h.processKnotMember(ctx, did, record); err != nil {
292
-
return fmt.Errorf("failed to process knot member: %w", err)
293
-
}
354
+
err = h.processKnotMember(ctx, event)
294
355
case tangled.RepoPullNSID:
295
-
var record tangled.RepoPull
296
-
if err := json.Unmarshal(raw, &record); err != nil {
297
-
return fmt.Errorf("failed to unmarshal record: %w", err)
298
-
}
299
-
if err := h.processPull(ctx, did, record); err != nil {
300
-
return fmt.Errorf("failed to process knot member: %w", err)
301
-
}
356
+
err = h.processPull(ctx, event)
357
+
case tangled.RepoCollaboratorNSID:
358
+
err = h.processCollaborator(ctx, event)
302
359
}
303
360
304
-
return err
361
+
if err != nil {
362
+
h.l.Debug("failed to process event", "nsid", event.Commit.Collection, "err", err)
363
+
}
364
+
365
+
return nil
305
366
}
+60
-18
knotserver/internal.go
+60
-18
knotserver/internal.go
···
3
3
import (
4
4
"context"
5
5
"encoding/json"
6
+
"errors"
7
+
"fmt"
6
8
"log/slog"
7
9
"net/http"
8
10
"path/filepath"
···
12
14
"github.com/go-chi/chi/v5"
13
15
"github.com/go-chi/chi/v5/middleware"
14
16
"tangled.sh/tangled.sh/core/api/tangled"
17
+
"tangled.sh/tangled.sh/core/hook"
15
18
"tangled.sh/tangled.sh/core/knotserver/config"
16
19
"tangled.sh/tangled.sh/core/knotserver/db"
17
20
"tangled.sh/tangled.sh/core/knotserver/git"
···
37
40
return
38
41
}
39
42
40
-
ok, err := h.e.IsPushAllowed(user, ThisServer, repo)
43
+
ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo)
41
44
if err != nil || !ok {
42
45
w.WriteHeader(http.StatusForbidden)
43
46
return
···
61
64
}
62
65
writeJSON(w, data)
63
66
return
67
+
}
68
+
69
+
type PushOptions struct {
70
+
skipCi bool
71
+
verboseCi bool
64
72
}
65
73
66
74
func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) {
···
89
97
// non-fatal
90
98
}
91
99
100
+
// extract any push options
101
+
pushOptionsRaw := r.Header.Values("X-Git-Push-Option")
102
+
pushOptions := PushOptions{}
103
+
for _, option := range pushOptionsRaw {
104
+
if option == "skip-ci" || option == "ci-skip" {
105
+
pushOptions.skipCi = true
106
+
}
107
+
if option == "verbose-ci" || option == "ci-verbose" {
108
+
pushOptions.verboseCi = true
109
+
}
110
+
}
111
+
112
+
resp := hook.HookResponse{
113
+
Messages: make([]string, 0),
114
+
}
115
+
92
116
for _, line := range lines {
93
117
err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName)
94
118
if err != nil {
···
96
120
// non-fatal
97
121
}
98
122
99
-
err = h.triggerPipeline(line, gitUserDid, repoDid, repoName)
123
+
err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
100
124
if err != nil {
101
125
l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
102
126
// non-fatal
103
127
}
104
128
}
129
+
130
+
writeJSON(w, resp)
105
131
}
106
132
107
133
func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
···
115
141
return err
116
142
}
117
143
118
-
gr, err := git.PlainOpen(repoPath)
144
+
gr, err := git.Open(repoPath, line.Ref)
119
145
if err != nil {
120
-
return err
146
+
return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err)
121
147
}
122
148
123
-
meta := gr.RefUpdateMeta(line)
149
+
var errs error
150
+
meta, err := gr.RefUpdateMeta(line)
151
+
errors.Join(errs, err)
152
+
124
153
metaRecord := meta.AsRecord()
125
154
126
155
refUpdate := tangled.GitRefUpdate{
···
143
172
EventJson: string(eventJson),
144
173
}
145
174
146
-
return h.db.InsertEvent(event, h.n)
175
+
return errors.Join(errs, h.db.InsertEvent(event, h.n))
147
176
}
148
177
149
-
func (h *InternalHandle) triggerPipeline(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
178
+
func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error {
179
+
if pushOptions.skipCi {
180
+
return nil
181
+
}
182
+
150
183
didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
151
184
if err != nil {
152
185
return err
···
167
200
return err
168
201
}
169
202
170
-
var pipeline workflow.Pipeline
203
+
var pipeline workflow.RawPipeline
171
204
for _, e := range workflowDir {
172
205
if !e.IsFile {
173
206
continue
···
179
212
continue
180
213
}
181
214
182
-
wf, err := workflow.FromFile(e.Name, contents)
183
-
if err != nil {
184
-
// TODO: log here, respond to client that is pushing
185
-
h.l.Error("failed to parse workflow", "err", err, "path", fpath)
186
-
continue
187
-
}
188
-
189
-
pipeline = append(pipeline, wf)
215
+
pipeline = append(pipeline, workflow.RawWorkflow{
216
+
Name: e.Name,
217
+
Contents: contents,
218
+
})
190
219
}
191
220
192
221
trigger := tangled.Pipeline_PushTriggerData{
···
207
236
},
208
237
}
209
238
210
-
// TODO: send the diagnostics back to the user here via stderr
211
-
cp := compiler.Compile(pipeline)
239
+
cp := compiler.Compile(compiler.Parse(pipeline))
212
240
eventJson, err := json.Marshal(cp)
213
241
if err != nil {
214
242
return err
243
+
}
244
+
245
+
for _, e := range compiler.Diagnostics.Errors {
246
+
*clientMsgs = append(*clientMsgs, e.String())
247
+
}
248
+
249
+
if pushOptions.verboseCi {
250
+
if compiler.Diagnostics.IsEmpty() {
251
+
*clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics")
252
+
}
253
+
254
+
for _, w := range compiler.Diagnostics.Warnings {
255
+
*clientMsgs = append(*clientMsgs, w.String())
256
+
}
215
257
}
216
258
217
259
// do not run empty pipelines
+60
-79
knotserver/routes.go
+60
-79
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
+
contentHash := sha256.Sum256(contents)
290
+
eTag := fmt.Sprintf("\"%x\"", contentHash)
291
+
292
+
// allow image, video, and text/plain files to be served directly
293
+
switch {
294
+
case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"):
295
+
if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag {
296
+
w.WriteHeader(http.StatusNotModified)
297
+
return
298
+
}
299
+
w.Header().Set("ETag", eTag)
300
+
301
+
case strings.HasPrefix(mimeType, "text/plain"):
302
+
w.Header().Set("Cache-Control", "public, no-cache")
303
+
304
+
default:
305
+
l.Error("attempted to serve disallowed file type", "mimetype", mimeType)
306
+
writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden)
289
307
return
290
308
}
291
309
292
-
w.Header().Set("Cache-Control", "public, max-age=86400") // cache for 24 hours
293
-
w.Header().Set("ETag", fmt.Sprintf("%x", sha256.Sum256(contents)))
294
310
w.Header().Set("Content-Type", mimeType)
295
311
w.Write(contents)
296
312
}
···
350
366
351
367
ref := strings.TrimSuffix(file, ".tar.gz")
352
368
369
+
unescapedRef, err := url.PathUnescape(ref)
370
+
if err != nil {
371
+
notFound(w)
372
+
return
373
+
}
374
+
375
+
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-")
376
+
353
377
// This allows the browser to use a proper name for the file when
354
378
// downloading
355
-
filename := fmt.Sprintf("%s-%s.tar.gz", name, ref)
379
+
filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename)
356
380
setContentDisposition(w, filename)
357
381
setGZipMIME(w)
358
382
359
383
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
360
-
gr, err := git.Open(path, ref)
384
+
gr, err := git.Open(path, unescapedRef)
361
385
if err != nil {
362
386
notFound(w)
363
387
return
···
366
390
gw := gzip.NewWriter(w)
367
391
defer gw.Close()
368
392
369
-
prefix := fmt.Sprintf("%s-%s", name, ref)
393
+
prefix := fmt.Sprintf("%s-%s", name, safeRefFilename)
370
394
err = gr.WriteTar(gw, prefix)
371
395
if err != nil {
372
396
// once we start writing to the body we can't report error anymore
···
488
512
489
513
rtags := []*types.TagReference{}
490
514
for _, tag := range tags {
515
+
var target *object.Tag
516
+
if tag.Target != plumbing.ZeroHash {
517
+
target = &tag
518
+
}
491
519
tr := types.TagReference{
492
-
Tag: tag.TagObject(),
520
+
Tag: target,
493
521
}
494
522
495
523
tr.Reference = types.Reference{
496
-
Name: tag.Name(),
497
-
Hash: tag.Hash().String(),
524
+
Name: tag.Name,
525
+
Hash: tag.Hash.String(),
498
526
}
499
527
500
-
if tag.Message() != "" {
501
-
tr.Message = tag.Message()
528
+
if tag.Message != "" {
529
+
tr.Message = tag.Message
502
530
}
503
531
504
532
rtags = append(rtags, &tr)
···
668
696
}
669
697
670
698
// add perms for this user to access the repo
671
-
err = h.e.AddRepo(did, ThisServer, relativeRepoPath)
699
+
err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath)
672
700
if err != nil {
673
701
l.Error("adding repo permissions", "error", err.Error())
674
702
writeError(w, err.Error(), http.StatusInternalServerError)
···
687
715
}
688
716
689
717
func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) {
690
-
l := h.l.With("handler", "RepoForkSync")
718
+
l := h.l.With("handler", "RepoForkAheadBehind")
691
719
692
720
data := struct {
693
721
Did string `json:"did"`
···
777
805
return
778
806
}
779
807
780
-
sizes := make(map[string]int64)
781
-
782
808
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
783
809
defer cancel()
784
810
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
-
})
811
+
sizes, err := gr.AnalyzeLanguages(ctx)
812
812
if err != nil {
813
-
l.Error("failed to recurse file tree", "error", err.Error())
813
+
l.Error("failed to analyze languages", "error", err.Error())
814
814
writeError(w, err.Error(), http.StatusNoContent)
815
815
return
816
816
}
···
818
818
resp := types.RepoLanguageResponse{Languages: sizes}
819
819
820
820
writeJSON(w, resp)
821
-
return
822
-
}
823
-
824
-
func analyzeLanguage(node object.TreeEntry, content []byte) string {
825
-
language, ok := enry.GetLanguageByExtension(node.Name)
826
-
if ok {
827
-
return language
828
-
}
829
-
830
-
language, ok = enry.GetLanguageByFilename(node.Name)
831
-
if ok {
832
-
return language
833
-
}
834
-
835
-
if len(content) == 0 {
836
-
return enry.OtherLanguage
837
-
}
838
-
839
-
return enry.GetLanguage(node.Name, content)
840
821
}
841
822
842
823
func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) {
···
869
850
name = filepath.Base(source)
870
851
}
871
852
872
-
branch := chi.URLParam(r, "branch")
853
+
branch := chi.URLParam(r, "*")
873
854
branch, _ = url.PathUnescape(branch)
874
855
875
856
relativeRepoPath := filepath.Join(did, name)
876
857
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
877
858
878
-
gr, err := git.PlainOpen(repoPath)
859
+
gr, err := git.Open(repoPath, branch)
879
860
if err != nil {
880
861
log.Println(err)
881
862
notFound(w)
882
863
return
883
864
}
884
865
885
-
err = gr.Sync(branch)
866
+
err = gr.Sync()
886
867
if err != nil {
887
868
l.Error("error syncing repo fork", "error", err.Error())
888
869
writeError(w, err.Error(), http.StatusInternalServerError)
···
933
914
}
934
915
935
916
// add perms for this user to access the repo
936
-
err = h.e.AddRepo(did, ThisServer, relativeRepoPath)
917
+
err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath)
937
918
if err != nil {
938
919
l.Error("adding repo permissions", "error", err.Error())
939
920
writeError(w, err.Error(), http.StatusInternalServerError)
···
1187
1168
}
1188
1169
h.jc.AddDid(did)
1189
1170
1190
-
if err := h.e.AddKnotMember(ThisServer, did); err != nil {
1171
+
if err := h.e.AddKnotMember(rbac.ThisServer, did); err != nil {
1191
1172
l.Error("adding member", "error", err.Error())
1192
1173
writeError(w, err.Error(), http.StatusInternalServerError)
1193
1174
return
···
1225
1206
h.jc.AddDid(data.Did)
1226
1207
1227
1208
repoName, _ := securejoin.SecureJoin(ownerDid, repo)
1228
-
if err := h.e.AddCollaborator(data.Did, ThisServer, repoName); err != nil {
1209
+
if err := h.e.AddCollaborator(data.Did, rbac.ThisServer, repoName); err != nil {
1229
1210
l.Error("adding repo collaborator", "error", err.Error())
1230
1211
writeError(w, err.Error(), http.StatusInternalServerError)
1231
1212
return
···
1322
1303
}
1323
1304
h.jc.AddDid(data.Did)
1324
1305
1325
-
if err := h.e.AddKnotOwner(ThisServer, data.Did); err != nil {
1306
+
if err := h.e.AddKnotOwner(rbac.ThisServer, data.Did); err != nil {
1326
1307
l.Error("adding owner", "error", err.Error())
1327
1308
writeError(w, err.Error(), http.StatusInternalServerError)
1328
1309
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
}
+1
-8
lexicons/issue/comment.json
+1
-8
lexicons/issue/comment.json
···
9
9
"key": "tid",
10
10
"record": {
11
11
"type": "object",
12
-
"required": [
13
-
"issue",
14
-
"body",
15
-
"createdAt"
16
-
],
12
+
"required": ["issue", "body", "createdAt"],
17
13
"properties": {
18
14
"issue": {
19
15
"type": "string",
···
22
18
"repo": {
23
19
"type": "string",
24
20
"format": "at-uri"
25
-
},
26
-
"commentId": {
27
-
"type": "integer"
28
21
},
29
22
"owner": {
30
23
"type": "string",
+1
-10
lexicons/issue/issue.json
+1
-10
lexicons/issue/issue.json
···
9
9
"key": "tid",
10
10
"record": {
11
11
"type": "object",
12
-
"required": [
13
-
"repo",
14
-
"issueId",
15
-
"owner",
16
-
"title",
17
-
"createdAt"
18
-
],
12
+
"required": ["repo", "owner", "title", "createdAt"],
19
13
"properties": {
20
14
"repo": {
21
15
"type": "string",
22
16
"format": "at-uri"
23
-
},
24
-
"issueId": {
25
-
"type": "integer"
26
17
},
27
18
"owner": {
28
19
"type": "string",
+207
lexicons/pipeline/pipeline.json
+207
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
+
"engine",
153
+
"clone",
154
+
"raw"
155
+
],
156
+
"properties": {
157
+
"name": {
158
+
"type": "string"
159
+
},
160
+
"engine": {
161
+
"type": "string"
162
+
},
163
+
"clone": {
164
+
"type": "ref",
165
+
"ref": "#cloneOpts"
166
+
},
167
+
"raw": {
168
+
"type": "string"
169
+
}
170
+
}
171
+
},
172
+
"cloneOpts": {
173
+
"type": "object",
174
+
"required": [
175
+
"skip",
176
+
"depth",
177
+
"submodules"
178
+
],
179
+
"properties": {
180
+
"skip": {
181
+
"type": "boolean"
182
+
},
183
+
"depth": {
184
+
"type": "integer"
185
+
},
186
+
"submodules": {
187
+
"type": "boolean"
188
+
}
189
+
}
190
+
},
191
+
"pair": {
192
+
"type": "object",
193
+
"required": [
194
+
"key",
195
+
"value"
196
+
],
197
+
"properties": {
198
+
"key": {
199
+
"type": "string"
200
+
},
201
+
"value": {
202
+
"type": "string"
203
+
}
204
+
}
205
+
}
206
+
}
207
+
}
-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
-
+40
lexicons/string/string.json
+40
lexicons/string/string.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.string",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"filename",
14
+
"description",
15
+
"createdAt",
16
+
"contents"
17
+
],
18
+
"properties": {
19
+
"filename": {
20
+
"type": "string",
21
+
"maxGraphemes": 140,
22
+
"minGraphemes": 1
23
+
},
24
+
"description": {
25
+
"type": "string",
26
+
"maxGraphemes": 280
27
+
},
28
+
"createdAt": {
29
+
"type": "string",
30
+
"format": "datetime"
31
+
},
32
+
"contents": {
33
+
"type": "string",
34
+
"minGraphemes": 1
35
+
}
36
+
}
37
+
}
38
+
}
39
+
}
40
+
}
+3
-1
log/log.go
+3
-1
log/log.go
···
9
9
// NewHandler sets up a new slog.Handler with the service name
10
10
// as an attribute
11
11
func NewHandler(name string) slog.Handler {
12
-
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{})
12
+
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
13
+
Level: slog.LevelDebug,
14
+
})
13
15
14
16
var attrs []slog.Attr
15
17
attrs = append(attrs, slog.Attr{Key: "service", Value: slog.StringValue(name)})
+526
nix/gomod2nix.toml
+526
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/cloudflare/cloudflare-go"]
70
+
version = "v0.115.0"
71
+
hash = "sha256-jezmDs6IsHA4rag7DzcHDfDgde0vU4iKgCN9+0XDViw="
72
+
[mod."github.com/containerd/errdefs"]
73
+
version = "v1.0.0"
74
+
hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI="
75
+
[mod."github.com/containerd/errdefs/pkg"]
76
+
version = "v0.3.0"
77
+
hash = "sha256-BILJ0Be4cc8xfvLPylc/Pvwwa+w88+Hd0njzetUCeTg="
78
+
[mod."github.com/containerd/log"]
79
+
version = "v0.1.0"
80
+
hash = "sha256-vuE6Mie2gSxiN3jTKTZovjcbdBd1YEExb7IBe3GM+9s="
81
+
[mod."github.com/cyphar/filepath-securejoin"]
82
+
version = "v0.4.1"
83
+
hash = "sha256-NOV6MfbkcQbfhNmfADQw2SJmZ6q1nw0wwg8Pm2tf2DM="
84
+
[mod."github.com/davecgh/go-spew"]
85
+
version = "v1.1.2-0.20180830191138-d8f796af33cc"
86
+
hash = "sha256-fV9oI51xjHdOmEx6+dlq7Ku2Ag+m/bmbzPo6A4Y74qc="
87
+
[mod."github.com/decred/dcrd/dcrec/secp256k1/v4"]
88
+
version = "v4.4.0"
89
+
hash = "sha256-qrhEIwhDll3cxoVpMbm1NQ9/HTI42S7ms8Buzlo5HCg="
90
+
[mod."github.com/dgraph-io/ristretto"]
91
+
version = "v0.2.0"
92
+
hash = "sha256-bnpxX+oO/Qf7IJevA0gsbloVoqRx+5bh7RQ9d9eLNYw="
93
+
[mod."github.com/dgryski/go-rendezvous"]
94
+
version = "v0.0.0-20200823014737-9f7001d12a5f"
95
+
hash = "sha256-n/7xo5CQqo4yLaWMSzSN1Muk/oqK6O5dgDOFWapeDUI="
96
+
[mod."github.com/distribution/reference"]
97
+
version = "v0.6.0"
98
+
hash = "sha256-gr4tL+qz4jKyAtl8LINcxMSanztdt+pybj1T+2ulQv4="
99
+
[mod."github.com/dlclark/regexp2"]
100
+
version = "v1.11.5"
101
+
hash = "sha256-jN5+2ED+YbIoPIuyJ4Ou5pqJb2w1uNKzp5yTjKY6rEQ="
102
+
[mod."github.com/docker/docker"]
103
+
version = "v28.2.2+incompatible"
104
+
hash = "sha256-5FnlTcygdxpHyFB0/7EsYocFhADUAjC/Dku0Xn4W8so="
105
+
[mod."github.com/docker/go-connections"]
106
+
version = "v0.5.0"
107
+
hash = "sha256-aGbMRrguh98DupIHgcpLkVUZpwycx1noQXbtTl5Sbms="
108
+
[mod."github.com/docker/go-units"]
109
+
version = "v0.5.0"
110
+
hash = "sha256-iK/V/jJc+borzqMeqLY+38Qcts2KhywpsTk95++hImE="
111
+
[mod."github.com/dustin/go-humanize"]
112
+
version = "v1.0.1"
113
+
hash = "sha256-yuvxYYngpfVkUg9yAmG99IUVmADTQA0tMbBXe0Fq0Mc="
114
+
[mod."github.com/emirpasic/gods"]
115
+
version = "v1.18.1"
116
+
hash = "sha256-hGDKddjLj+5dn2woHtXKUdd49/3xdsqnhx7VEdCu1m4="
117
+
[mod."github.com/felixge/httpsnoop"]
118
+
version = "v1.0.4"
119
+
hash = "sha256-c1JKoRSndwwOyOxq9ddCe+8qn7mG9uRq2o/822x5O/c="
120
+
[mod."github.com/fsnotify/fsnotify"]
121
+
version = "v1.6.0"
122
+
hash = "sha256-DQesOCweQPEwmAn6s7DCP/Dwy8IypC+osbpfsvpkdP0="
123
+
[mod."github.com/gliderlabs/ssh"]
124
+
version = "v0.3.8"
125
+
hash = "sha256-FW+91qCB3rfTm0I1VmqfwA7o+2kDys2JHOudKKyxWwc="
126
+
[mod."github.com/go-chi/chi/v5"]
127
+
version = "v5.2.0"
128
+
hash = "sha256-rCZ2W5BdWwjtv7SSpHOgpYEHf9ketzdPX+r2500JL8A="
129
+
[mod."github.com/go-enry/go-enry/v2"]
130
+
version = "v2.9.2"
131
+
hash = "sha256-LkCSW+4+DkTok1JcOQR0rt3UKNKVn4KPaiDeatdQhCU="
132
+
[mod."github.com/go-enry/go-oniguruma"]
133
+
version = "v1.2.1"
134
+
hash = "sha256-DoCNyX75CuCgFnfSZs63VB4+HAIMDBgwcQglXXHRj/I="
135
+
[mod."github.com/go-git/gcfg"]
136
+
version = "v1.5.1-0.20230307220236-3a3c6141e376"
137
+
hash = "sha256-f4k0gSYuo0/q3WOoTxl2eFaj7WZpdz29ih6CKc8Ude8="
138
+
[mod."github.com/go-git/go-billy/v5"]
139
+
version = "v5.6.2"
140
+
hash = "sha256-VgbxcLkHjiSyRIfKS7E9Sn8OynCrMGUDkwFz6K2TVL4="
141
+
[mod."github.com/go-git/go-git/v5"]
142
+
version = "v5.17.0"
143
+
hash = "sha256-gya68abB6GtejUqr60DyU7NIGtNzHQVCAeDTYKk1evQ="
144
+
replaced = "github.com/oppiliappan/go-git/v5"
145
+
[mod."github.com/go-jose/go-jose/v3"]
146
+
version = "v3.0.4"
147
+
hash = "sha256-RrLHCu9l6k0XVobdZQJ9Sx/VTQcWjrdLR5BEG7yXTEQ="
148
+
[mod."github.com/go-logr/logr"]
149
+
version = "v1.4.3"
150
+
hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA="
151
+
[mod."github.com/go-logr/stdr"]
152
+
version = "v1.2.2"
153
+
hash = "sha256-rRweAP7XIb4egtT1f2gkz4sYOu7LDHmcJ5iNsJUd0sE="
154
+
[mod."github.com/go-redis/cache/v9"]
155
+
version = "v9.0.0"
156
+
hash = "sha256-b4S3K4KoZhF0otw6FRIOq/PTdHGrb/LumB4GKo4khsY="
157
+
[mod."github.com/go-test/deep"]
158
+
version = "v1.1.1"
159
+
hash = "sha256-WvPrTvUPmbQb4R6DrvSB9O3zm0IOk+n14YpnSl2deR8="
160
+
[mod."github.com/goccy/go-json"]
161
+
version = "v0.10.5"
162
+
hash = "sha256-/EtlGihP0/7oInzMC5E0InZ4b5Ad3s4xOpqotloi3xw="
163
+
[mod."github.com/gogo/protobuf"]
164
+
version = "v1.3.2"
165
+
hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c="
166
+
[mod."github.com/golang-jwt/jwt/v5"]
167
+
version = "v5.2.3"
168
+
hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo="
169
+
[mod."github.com/golang/groupcache"]
170
+
version = "v0.0.0-20241129210726-2c02b8208cf8"
171
+
hash = "sha256-AdLZ3dJLe/yduoNvZiXugZxNfmwJjNQyQGsIdzYzH74="
172
+
[mod."github.com/golang/mock"]
173
+
version = "v1.6.0"
174
+
hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno="
175
+
[mod."github.com/google/go-querystring"]
176
+
version = "v1.1.0"
177
+
hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY="
178
+
[mod."github.com/google/uuid"]
179
+
version = "v1.6.0"
180
+
hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw="
181
+
[mod."github.com/gorilla/css"]
182
+
version = "v1.0.1"
183
+
hash = "sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A="
184
+
[mod."github.com/gorilla/feeds"]
185
+
version = "v1.2.0"
186
+
hash = "sha256-ptczizo27t6Bsq6rHJ4WiHmBRP54UC5yNfHghAqOBQk="
187
+
[mod."github.com/gorilla/securecookie"]
188
+
version = "v1.1.2"
189
+
hash = "sha256-KeMHNM9emxX+N0WYiZsTii7n8sNsmjWwbnQ9SaJfTKE="
190
+
[mod."github.com/gorilla/sessions"]
191
+
version = "v1.4.0"
192
+
hash = "sha256-cLK2z1uOEz7Wah/LclF65ptYMqzuvaRnfIGYqtn3b7g="
193
+
[mod."github.com/gorilla/websocket"]
194
+
version = "v1.5.4-0.20250319132907-e064f32e3674"
195
+
hash = "sha256-a8n6oe20JDpwThClgAyVhJDi6QVaS0qzT4PvRxlQ9to="
196
+
[mod."github.com/hashicorp/errwrap"]
197
+
version = "v1.1.0"
198
+
hash = "sha256-6lwuMQOfBq+McrViN3maJTIeh4f8jbEqvLy2c9FvvFw="
199
+
[mod."github.com/hashicorp/go-cleanhttp"]
200
+
version = "v0.5.2"
201
+
hash = "sha256-N9GOKYo7tK6XQUFhvhImtL7PZW/mr4C4Manx/yPVvcQ="
202
+
[mod."github.com/hashicorp/go-multierror"]
203
+
version = "v1.1.1"
204
+
hash = "sha256-ANzPEUJIZIlToxR89Mn7Db73d9LGI51ssy7eNnUgmlA="
205
+
[mod."github.com/hashicorp/go-retryablehttp"]
206
+
version = "v0.7.8"
207
+
hash = "sha256-4LZwKaFBbpKi9lSq5y6lOlYHU6WMnQdGNMxTd33rN80="
208
+
[mod."github.com/hashicorp/go-secure-stdlib/parseutil"]
209
+
version = "v0.2.0"
210
+
hash = "sha256-mb27ZKw5VDTmNj1QJvxHVR0GyY7UdacLJ0jWDV3nQd8="
211
+
[mod."github.com/hashicorp/go-secure-stdlib/strutil"]
212
+
version = "v0.1.2"
213
+
hash = "sha256-UmCMzjamCW1d9KNvNzELqKf1ElHOXPz+ZtdJkI+DV0A="
214
+
[mod."github.com/hashicorp/go-sockaddr"]
215
+
version = "v1.0.7"
216
+
hash = "sha256-p6eDOrGzN1jMmT/F/f/VJMq0cKNFhUcEuVVwTE6vSrs="
217
+
[mod."github.com/hashicorp/golang-lru"]
218
+
version = "v1.0.2"
219
+
hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48="
220
+
[mod."github.com/hashicorp/golang-lru/v2"]
221
+
version = "v2.0.7"
222
+
hash = "sha256-t1bcXLgrQNOYUVyYEZ0knxcXpsTk4IuJZDjKvyJX75g="
223
+
[mod."github.com/hashicorp/hcl"]
224
+
version = "v1.0.1-vault-7"
225
+
hash = "sha256-xqYtjCJQVsg04Yj2Uy2Q5bi6X6cDRYhJD/SUEWaHMDM="
226
+
[mod."github.com/hexops/gotextdiff"]
227
+
version = "v1.0.3"
228
+
hash = "sha256-wVs5uJs2KHU1HnDCDdSe0vIgNZylvs8oNidDxwA3+O0="
229
+
[mod."github.com/hiddeco/sshsig"]
230
+
version = "v0.2.0"
231
+
hash = "sha256-Yc8Ip4XxrL5plb7Lq0ziYFznteVDZnskoyOZDIMsWOU="
232
+
[mod."github.com/hpcloud/tail"]
233
+
version = "v1.0.0"
234
+
hash = "sha256-7ByBr/RcOwIsGPCiCUpfNwUSvU18QAY+HMnCJr8uU1w="
235
+
[mod."github.com/ipfs/bbloom"]
236
+
version = "v0.0.4"
237
+
hash = "sha256-4k778kBlNul2Rc4xuNQ9WA4kT0V7x5X9odZrT+2xjTU="
238
+
[mod."github.com/ipfs/boxo"]
239
+
version = "v0.33.0"
240
+
hash = "sha256-C85D/TjyoWNKvRFvl2A9hBjpDPhC//hGB2jRoDmXM38="
241
+
[mod."github.com/ipfs/go-block-format"]
242
+
version = "v0.2.2"
243
+
hash = "sha256-kz87tlGneqEARGnjA1Lb0K5CqD1lgxhBD68rfmzZZGU="
244
+
[mod."github.com/ipfs/go-cid"]
245
+
version = "v0.5.0"
246
+
hash = "sha256-BuZKkcBXrnx7mM1c9SP4LdzZoaAoai9U49vITGrYJQk="
247
+
[mod."github.com/ipfs/go-datastore"]
248
+
version = "v0.8.2"
249
+
hash = "sha256-9Q7+bi04srAE3AcXzWSGs/HP6DWnE1Edtx3NnjMQi8U="
250
+
[mod."github.com/ipfs/go-ipfs-blockstore"]
251
+
version = "v1.3.1"
252
+
hash = "sha256-NFlKr8bdJcM5FLlkc51sKt4AnMMlHS4wbdKiiaoDaqg="
253
+
[mod."github.com/ipfs/go-ipfs-ds-help"]
254
+
version = "v1.1.1"
255
+
hash = "sha256-cpEohOsf4afYRGTdsWh84TCVGIDzJo2hSjWy7NtNtvY="
256
+
[mod."github.com/ipfs/go-ipld-cbor"]
257
+
version = "v0.2.1"
258
+
hash = "sha256-ONBX/YO/knnmp+12fC13KsKVeo/vdWOI3SDyqCBxRE4="
259
+
[mod."github.com/ipfs/go-ipld-format"]
260
+
version = "v0.6.2"
261
+
hash = "sha256-nGLM/n/hy+0q1VIzQUvF4D3aKvL238ALIEziQQhVMkU="
262
+
[mod."github.com/ipfs/go-log"]
263
+
version = "v1.0.5"
264
+
hash = "sha256-WQarHZo2y/rH6ixLsOlN5fFZeLUqsOTMnvdxszP2Qj4="
265
+
[mod."github.com/ipfs/go-log/v2"]
266
+
version = "v2.6.0"
267
+
hash = "sha256-cZ+rsx7LIROoNITyu/s0B6hq8lNQsUC1ynvx2f2o4Gk="
268
+
[mod."github.com/ipfs/go-metrics-interface"]
269
+
version = "v0.3.0"
270
+
hash = "sha256-b3tp3jxecLmJEGx2kW7MiKGlAKPEWg/LJ7hXylSC8jQ="
271
+
[mod."github.com/kevinburke/ssh_config"]
272
+
version = "v1.2.0"
273
+
hash = "sha256-Ta7ZOmyX8gG5tzWbY2oES70EJPfI90U7CIJS9EAce0s="
274
+
[mod."github.com/klauspost/compress"]
275
+
version = "v1.18.0"
276
+
hash = "sha256-jc5pMU/HCBFOShMcngVwNMhz9wolxjOb579868LtOuk="
277
+
[mod."github.com/klauspost/cpuid/v2"]
278
+
version = "v2.3.0"
279
+
hash = "sha256-50JhbQyT67BK38HIdJihPtjV7orYp96HknI2VP7A9Yc="
280
+
[mod."github.com/lestrrat-go/blackmagic"]
281
+
version = "v1.0.4"
282
+
hash = "sha256-HmWOpwoPDNMwLdOi7onNn3Sb+ZsAa3Ai3gVBbXmQ0e8="
283
+
[mod."github.com/lestrrat-go/httpcc"]
284
+
version = "v1.0.1"
285
+
hash = "sha256-SMRSwJpqDIs/xL0l2e8vP0W65qtCHX2wigcOeqPJmos="
286
+
[mod."github.com/lestrrat-go/httprc"]
287
+
version = "v1.0.6"
288
+
hash = "sha256-mfZzePEhrmyyu/avEBd2MsDXyto8dq5+fyu5lA8GUWM="
289
+
[mod."github.com/lestrrat-go/iter"]
290
+
version = "v1.0.2"
291
+
hash = "sha256-30tErRf7Qu/NOAt1YURXY/XJSA6sCr6hYQfO8QqHrtw="
292
+
[mod."github.com/lestrrat-go/jwx/v2"]
293
+
version = "v2.1.6"
294
+
hash = "sha256-0LszXRZIba+X8AOrs3T4uanAUafBdlVB8/MpUNEFpbc="
295
+
[mod."github.com/lestrrat-go/option"]
296
+
version = "v1.0.1"
297
+
hash = "sha256-jVcIYYVsxElIS/l2akEw32vdEPR8+anR6oeT1FoYULI="
298
+
[mod."github.com/mattn/go-isatty"]
299
+
version = "v0.0.20"
300
+
hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ="
301
+
[mod."github.com/mattn/go-sqlite3"]
302
+
version = "v1.14.24"
303
+
hash = "sha256-taGKFZFQlR5++5b2oZ1dYS3RERKv6yh1gniNWhb4egg="
304
+
[mod."github.com/microcosm-cc/bluemonday"]
305
+
version = "v1.0.27"
306
+
hash = "sha256-EZSya9FLPQ83CL7N2cZy21fdS35hViTkiMK5f3op8Es="
307
+
[mod."github.com/minio/sha256-simd"]
308
+
version = "v1.0.1"
309
+
hash = "sha256-4hfGDIQaWq8fvtGzHDhoK9v2IocXnJY7OAL6saMJbmA="
310
+
[mod."github.com/mitchellh/mapstructure"]
311
+
version = "v1.5.0"
312
+
hash = "sha256-ztVhGQXs67MF8UadVvG72G3ly0ypQW0IRDdOOkjYwoE="
313
+
[mod."github.com/moby/docker-image-spec"]
314
+
version = "v1.3.1"
315
+
hash = "sha256-xwSNLmMagzywdGJIuhrWl1r7cIWBYCOMNYbuDDT6Jhs="
316
+
[mod."github.com/moby/sys/atomicwriter"]
317
+
version = "v0.1.0"
318
+
hash = "sha256-i46GNrsICnJ0AYkN+ocbVZ2GNTQVEsrVX5WcjKzjtBM="
319
+
[mod."github.com/moby/term"]
320
+
version = "v0.5.2"
321
+
hash = "sha256-/G20jUZKx36ktmPU/nEw/gX7kRTl1Dbu7zvNBYNt4xU="
322
+
[mod."github.com/morikuni/aec"]
323
+
version = "v1.0.0"
324
+
hash = "sha256-5zYgLeGr3K+uhGKlN3xv0PO67V+2Zw+cezjzNCmAWOE="
325
+
[mod."github.com/mr-tron/base58"]
326
+
version = "v1.2.0"
327
+
hash = "sha256-8FzMu3kHUbBX10pUdtGf59Ag7BNupx8ZHeUaodR1/Vk="
328
+
[mod."github.com/multiformats/go-base32"]
329
+
version = "v0.1.0"
330
+
hash = "sha256-O2IM7FB+Y9MkDdZztyQL5F8oEnmON2Yew7XkotQziio="
331
+
[mod."github.com/multiformats/go-base36"]
332
+
version = "v0.2.0"
333
+
hash = "sha256-GKNnAGA0Lb39BDGYBm1ieKdXmho8Pu7ouyfVPXvV0PE="
334
+
[mod."github.com/multiformats/go-multibase"]
335
+
version = "v0.2.0"
336
+
hash = "sha256-w+hp6u5bWyd34qe0CX+bq487ADqq6SgRR/JuqRB578s="
337
+
[mod."github.com/multiformats/go-multihash"]
338
+
version = "v0.2.3"
339
+
hash = "sha256-zqIIE5jMFzm+qhUrouSF+WdXGeHUEYIQvVnKWWU6mRs="
340
+
[mod."github.com/multiformats/go-varint"]
341
+
version = "v0.0.7"
342
+
hash = "sha256-To3Uuv7uSUJEr5OTwxE1LEIpA62xY3M/KKMNlscHmlA="
343
+
[mod."github.com/munnerz/goautoneg"]
344
+
version = "v0.0.0-20191010083416-a7dc8b61c822"
345
+
hash = "sha256-79URDDFenmGc9JZu+5AXHToMrtTREHb3BC84b/gym9Q="
346
+
[mod."github.com/onsi/gomega"]
347
+
version = "v1.37.0"
348
+
hash = "sha256-PfHFYp365MwBo+CUZs+mN5QEk3Kqe9xrBX+twWfIc9o="
349
+
[mod."github.com/openbao/openbao/api/v2"]
350
+
version = "v2.3.0"
351
+
hash = "sha256-1bIyvL3GdzPUfsM+gxuKMaH5jKxMaucZQgL6/DfbmDM="
352
+
[mod."github.com/opencontainers/go-digest"]
353
+
version = "v1.0.0"
354
+
hash = "sha256-cfVDjHyWItmUGZ2dzQhCHgmOmou8v7N+itDkLZVkqkQ="
355
+
[mod."github.com/opencontainers/image-spec"]
356
+
version = "v1.1.1"
357
+
hash = "sha256-bxBjtl+6846Ed3QHwdssOrNvlHV6b+Dn17zPISSQGP8="
358
+
[mod."github.com/opentracing/opentracing-go"]
359
+
version = "v1.2.1-0.20220228012449-10b1cf09e00b"
360
+
hash = "sha256-77oWcDviIoGWHVAotbgmGRpLGpH5AUy+pM15pl3vRrw="
361
+
[mod."github.com/pjbgf/sha1cd"]
362
+
version = "v0.3.2"
363
+
hash = "sha256-jdbiRhU8xc1C5c8m7BSCj71PUXHY3f7TWFfxDKKpUMk="
364
+
[mod."github.com/pkg/errors"]
365
+
version = "v0.9.1"
366
+
hash = "sha256-mNfQtcrQmu3sNg/7IwiieKWOgFQOVVe2yXgKBpe/wZw="
367
+
[mod."github.com/pmezard/go-difflib"]
368
+
version = "v1.0.1-0.20181226105442-5d4384ee4fb2"
369
+
hash = "sha256-XA4Oj1gdmdV/F/+8kMI+DBxKPthZ768hbKsO3d9Gx90="
370
+
[mod."github.com/polydawn/refmt"]
371
+
version = "v0.89.1-0.20221221234430-40501e09de1f"
372
+
hash = "sha256-wBdFROClTHNPYU4IjeKbBXaG7F6j5hZe15gMxiqKvi4="
373
+
[mod."github.com/posthog/posthog-go"]
374
+
version = "v1.5.5"
375
+
hash = "sha256-ouhfDUCXsfpcgaCLfJE9oYprAQHuV61OJzb/aEhT0j8="
376
+
[mod."github.com/prometheus/client_golang"]
377
+
version = "v1.22.0"
378
+
hash = "sha256-OJ/9rlWG1DIPQJAZUTzjykkX0o+f+4IKLvW8YityaMQ="
379
+
[mod."github.com/prometheus/client_model"]
380
+
version = "v0.6.2"
381
+
hash = "sha256-q6Fh6v8iNJN9ypD47LjWmx66YITa3FyRjZMRsuRTFeQ="
382
+
[mod."github.com/prometheus/common"]
383
+
version = "v0.64.0"
384
+
hash = "sha256-uy3KO60F2Cvhamz3fWQALGSsy13JiTk3NfpXgRuwqtI="
385
+
[mod."github.com/prometheus/procfs"]
386
+
version = "v0.16.1"
387
+
hash = "sha256-OBCvKlLW2obct35p0L9Q+1ZrxZjpTmbgHMP2rng9hpo="
388
+
[mod."github.com/redis/go-redis/v9"]
389
+
version = "v9.7.3"
390
+
hash = "sha256-7ip5Ns/NEnFmVLr5iN8m3gS4RrzVAYJ7pmJeeaTmjjo="
391
+
[mod."github.com/resend/resend-go/v2"]
392
+
version = "v2.15.0"
393
+
hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg="
394
+
[mod."github.com/ryanuber/go-glob"]
395
+
version = "v1.0.0"
396
+
hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY="
397
+
[mod."github.com/segmentio/asm"]
398
+
version = "v1.2.0"
399
+
hash = "sha256-zbNuKxNrUDUc6IlmRQNuJQzVe5Ol/mqp7srDg9IMMqs="
400
+
[mod."github.com/sergi/go-diff"]
401
+
version = "v1.1.0"
402
+
hash = "sha256-8NJMabldpf40uwQN20T6QXx5KORDibCBJL02KD661xY="
403
+
replaced = "github.com/sergi/go-diff"
404
+
[mod."github.com/sethvargo/go-envconfig"]
405
+
version = "v1.1.0"
406
+
hash = "sha256-WelRHfyZG9hrA4fbQcfBawb2ZXBQNT1ourEYHzQdZ4w="
407
+
[mod."github.com/spaolacci/murmur3"]
408
+
version = "v1.1.0"
409
+
hash = "sha256-RWD4PPrlAsZZ8Xy356MBxpj+/NZI7w2XOU14Ob7/Y9M="
410
+
[mod."github.com/stretchr/testify"]
411
+
version = "v1.10.0"
412
+
hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI="
413
+
[mod."github.com/urfave/cli/v3"]
414
+
version = "v3.3.3"
415
+
hash = "sha256-FdPiu7koY1qBinkfca4A05zCrX+Vu4eRz8wlRDZJyGg="
416
+
[mod."github.com/vmihailenco/go-tinylfu"]
417
+
version = "v0.2.2"
418
+
hash = "sha256-ZHr4g7DJAV6rLcfrEWZwo9wJSeZcXB9KSP38UIOFfaM="
419
+
[mod."github.com/vmihailenco/msgpack/v5"]
420
+
version = "v5.4.1"
421
+
hash = "sha256-pDplX6xU6UpNLcFbO1pRREW5vCnSPvSU+ojAwFDv3Hk="
422
+
[mod."github.com/vmihailenco/tagparser/v2"]
423
+
version = "v2.0.0"
424
+
hash = "sha256-M9QyaKhSmmYwsJk7gkjtqu9PuiqZHSmTkous8VWkWY0="
425
+
[mod."github.com/whyrusleeping/cbor-gen"]
426
+
version = "v0.3.1"
427
+
hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc="
428
+
[mod."github.com/yuin/goldmark"]
429
+
version = "v1.4.15"
430
+
hash = "sha256-MvSOT6dwf5hVYkIg4MnqMpsy5ZtWZ7amAE7Zo9HkEa0="
431
+
[mod."github.com/yuin/goldmark-highlighting/v2"]
432
+
version = "v2.0.0-20230729083705-37449abec8cc"
433
+
hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
434
+
[mod."gitlab.com/yawning/secp256k1-voi"]
435
+
version = "v0.0.0-20230925100816-f2616030848b"
436
+
hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA="
437
+
[mod."gitlab.com/yawning/tuplehash"]
438
+
version = "v0.0.0-20230713102510-df83abbf9a02"
439
+
hash = "sha256-pehQduoaJRLchebhgvMYacVvbuNIBA++XkiqCuqdato="
440
+
[mod."go.opentelemetry.io/auto/sdk"]
441
+
version = "v1.1.0"
442
+
hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo="
443
+
[mod."go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"]
444
+
version = "v0.62.0"
445
+
hash = "sha256-WcDogpsvFxGOVqc+/ljlZ10nrOU2q0rPteukGyWWmfc="
446
+
[mod."go.opentelemetry.io/otel"]
447
+
version = "v1.37.0"
448
+
hash = "sha256-zWpyp9K8/Te86uhNjamchZctTdAnmHhoVw9m4ACfSoo="
449
+
[mod."go.opentelemetry.io/otel/exporters/otlp/otlptrace"]
450
+
version = "v1.33.0"
451
+
hash = "sha256-D5BMzmtN1d3pRnxIcvDOyQrjerK1JoavtYjJLhPKv/I="
452
+
[mod."go.opentelemetry.io/otel/metric"]
453
+
version = "v1.37.0"
454
+
hash = "sha256-BWnkdldA3xzGhnaConzMAuQzOnugytIvrP6GjkZVAYg="
455
+
[mod."go.opentelemetry.io/otel/trace"]
456
+
version = "v1.37.0"
457
+
hash = "sha256-FBeLOb5qmIiE9VmbgCf1l/xpndBqHkRiaPt1PvoKrVY="
458
+
[mod."go.opentelemetry.io/proto/otlp"]
459
+
version = "v1.6.0"
460
+
hash = "sha256-1kjkJ9cqkvx3ib6ytLcw+Adp4xqD3ShF97lpzt/BeCg="
461
+
[mod."go.uber.org/atomic"]
462
+
version = "v1.11.0"
463
+
hash = "sha256-TyYws/cSPVqYNffFX0gbDml1bD4bBGcysrUWU7mHPIY="
464
+
[mod."go.uber.org/multierr"]
465
+
version = "v1.11.0"
466
+
hash = "sha256-Lb6rHHfR62Ozg2j2JZy3MKOMKdsfzd1IYTR57r3Mhp0="
467
+
[mod."go.uber.org/zap"]
468
+
version = "v1.27.0"
469
+
hash = "sha256-8655KDrulc4Das3VRduO9MjCn8ZYD5WkULjCvruaYsU="
470
+
[mod."golang.org/x/crypto"]
471
+
version = "v0.40.0"
472
+
hash = "sha256-I6p2fqvz63P9MwAuoQrljI7IUbfZQvCem0ii4Q2zZng="
473
+
[mod."golang.org/x/exp"]
474
+
version = "v0.0.0-20250620022241-b7579e27df2b"
475
+
hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig="
476
+
[mod."golang.org/x/net"]
477
+
version = "v0.42.0"
478
+
hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s="
479
+
[mod."golang.org/x/sync"]
480
+
version = "v0.16.0"
481
+
hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks="
482
+
[mod."golang.org/x/sys"]
483
+
version = "v0.34.0"
484
+
hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50="
485
+
[mod."golang.org/x/text"]
486
+
version = "v0.27.0"
487
+
hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8="
488
+
[mod."golang.org/x/time"]
489
+
version = "v0.12.0"
490
+
hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw="
491
+
[mod."golang.org/x/xerrors"]
492
+
version = "v0.0.0-20240903120638-7835f813f4da"
493
+
hash = "sha256-bE7CcrnAvryNvM26ieJGXqbAtuLwHaGcmtVMsVnksqo="
494
+
[mod."google.golang.org/genproto/googleapis/api"]
495
+
version = "v0.0.0-20250603155806-513f23925822"
496
+
hash = "sha256-0CS432v9zVhkVLqFpZtxBX8rvVqP67lb7qQ3es7RqIU="
497
+
[mod."google.golang.org/genproto/googleapis/rpc"]
498
+
version = "v0.0.0-20250603155806-513f23925822"
499
+
hash = "sha256-WK7iDtAhH19NPe3TywTQlGjDawNaDKWnxhFL9PgVUwM="
500
+
[mod."google.golang.org/grpc"]
501
+
version = "v1.73.0"
502
+
hash = "sha256-LfVlwip++q2DX70RU6CxoXglx1+r5l48DwlFD05G11c="
503
+
[mod."google.golang.org/protobuf"]
504
+
version = "v1.36.6"
505
+
hash = "sha256-lT5qnefI5FDJnowz9PEkAGylH3+fE+A3DJDkAyy9RMc="
506
+
[mod."gopkg.in/fsnotify.v1"]
507
+
version = "v1.4.7"
508
+
hash = "sha256-j/Ts92oXa3k1MFU7Yd8/AqafRTsFn7V2pDKCyDJLah8="
509
+
[mod."gopkg.in/tomb.v1"]
510
+
version = "v1.0.0-20141024135613-dd632973f1e7"
511
+
hash = "sha256-W/4wBAvuaBFHhowB67SZZfXCRDp5tzbYG4vo81TAFdM="
512
+
[mod."gopkg.in/warnings.v0"]
513
+
version = "v0.1.2"
514
+
hash = "sha256-ATVL9yEmgYbkJ1DkltDGRn/auGAjqGOfjQyBYyUo8s8="
515
+
[mod."gopkg.in/yaml.v3"]
516
+
version = "v3.0.1"
517
+
hash = "sha256-FqL9TKYJ0XkNwJFnq9j0VvJ5ZUU1RvH/52h/f5bkYAU="
518
+
[mod."gotest.tools/v3"]
519
+
version = "v3.5.2"
520
+
hash = "sha256-eAxnRrF2bQugeFYzGLOr+4sLyCPOpaTWpoZsIKNP1WE="
521
+
[mod."lukechampine.com/blake3"]
522
+
version = "v1.4.1"
523
+
hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc="
524
+
[mod."tangled.sh/icyphox.sh/atproto-oauth"]
525
+
version = "v0.0.0-20250724194903-28e660378cb1"
526
+
hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
+54
-35
nix/modules/appview.nix
+54
-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
+
};
30
+
environmentFile = mkOption {
31
+
type = with types; nullOr path;
32
+
default = null;
33
+
example = "/etc/tangled-appview.env";
34
+
description = ''
35
+
Additional environment file as defined in {manpage}`systemd.exec(5)`.
36
+
37
+
Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be
38
+
passed to the service without makeing them world readable in the
39
+
nix store.
40
+
41
+
'';
42
+
};
24
43
};
25
44
};
26
-
};
27
45
28
-
config = mkIf config.services.tangled-appview.enable {
29
-
systemd.services.tangled-appview = {
30
-
description = "tangled appview service";
31
-
wantedBy = ["multi-user.target"];
46
+
config = mkIf cfg.enable {
47
+
systemd.services.tangled-appview = {
48
+
description = "tangled appview service";
49
+
wantedBy = ["multi-user.target"];
32
50
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
-
};
51
+
serviceConfig = {
52
+
ListenStream = "0.0.0.0:${toString cfg.port}";
53
+
ExecStart = "${cfg.package}/bin/appview";
54
+
Restart = "always";
55
+
EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile;
56
+
};
38
57
39
-
environment = {
40
-
TANGLED_DB_PATH = "appview.db";
41
-
TANGLED_COOKIE_SECRET = config.services.tangled-appview.cookie_secret;
58
+
environment = {
59
+
TANGLED_DB_PATH = "appview.db";
60
+
TANGLED_COOKIE_SECRET = cfg.cookie_secret;
61
+
};
42
62
};
43
63
};
44
-
};
45
-
}
64
+
}
+60
-19
nix/modules/knot.nix
+60
-19
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 = ''
103
-
mkdir -p "${cfg.repo.scanPath}"
104
-
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}"
105
-
106
-
mkdir -p "${cfg.stateDir}/.config/git"
107
-
cat > "${cfg.stateDir}/.config/git/config" << EOF
108
-
[user]
109
-
name = Git User
110
-
email = git@example.com
111
-
EOF
112
-
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
113
-
'';
114
-
115
129
users.users.${cfg.gitUser} = {
116
130
isSystemUser = true;
117
131
useDefaultShell = true;
···
135
149
mode = "0555";
136
150
text = ''
137
151
#!${pkgs.stdenv.shell}
138
-
${self.packages.${pkgs.system}.knot}/bin/knot keys \
152
+
${cfg.package}/bin/knot keys \
139
153
-output authorized-keys \
140
154
-internal-api "http://${cfg.server.internalListenAddr}" \
141
155
-git-dir "${cfg.repo.scanPath}" \
···
147
161
description = "knot service";
148
162
after = ["network.target" "sshd.service"];
149
163
wantedBy = ["multi-user.target"];
164
+
enableStrictShellChecks = true;
165
+
166
+
preStart = let
167
+
setMotd =
168
+
if cfg.motdFile != null && cfg.motd != null
169
+
then throw "motdFile and motd cannot be both set"
170
+
else ''
171
+
${optionalString (cfg.motdFile != null) "cat ${cfg.motdFile} > ${cfg.stateDir}/motd"}
172
+
${optionalString (cfg.motd != null) ''printf "${cfg.motd}" > ${cfg.stateDir}/motd''}
173
+
'';
174
+
in ''
175
+
mkdir -p "${cfg.repo.scanPath}"
176
+
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}"
177
+
178
+
mkdir -p "${cfg.stateDir}/.config/git"
179
+
cat > "${cfg.stateDir}/.config/git/config" << EOF
180
+
[user]
181
+
name = Git User
182
+
email = git@example.com
183
+
[receive]
184
+
advertisePushOptions = true
185
+
EOF
186
+
${setMotd}
187
+
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
188
+
'';
189
+
150
190
serviceConfig = {
151
191
User = cfg.gitUser;
192
+
PermissionsStartOnly = true;
152
193
WorkingDirectory = cfg.stateDir;
153
194
Environment = [
154
195
"KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}"
···
160
201
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
161
202
];
162
203
EnvironmentFile = cfg.server.secretFile;
163
-
ExecStart = "${self.packages.${pkgs.system}.knot}/bin/knot server";
204
+
ExecStart = "${cfg.package}/bin/knot server";
164
205
Restart = "always";
165
206
};
166
207
};
+31
-6
nix/modules/spindle.nix
+31
-6
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 = {
···
51
54
example = "did:plc:qfpnj4og54vl56wngdriaxug";
52
55
description = "DID of owner (required)";
53
56
};
57
+
58
+
secrets = {
59
+
provider = mkOption {
60
+
type = types.str;
61
+
default = "sqlite";
62
+
description = "Backend to use for secret management, valid options are 'sqlite', and 'openbao'.";
63
+
};
64
+
65
+
openbao = {
66
+
proxyAddr = mkOption {
67
+
type = types.str;
68
+
default = "http://127.0.0.1:8200";
69
+
};
70
+
mount = mkOption {
71
+
type = types.str;
72
+
default = "spindle";
73
+
};
74
+
};
75
+
};
54
76
};
55
77
56
78
pipelines = {
···
60
82
description = "Nixery instance to use";
61
83
};
62
84
63
-
stepTimeout = mkOption {
85
+
workflowTimeout = mkOption {
64
86
type = types.str;
65
87
default = "5m";
66
88
description = "Timeout for each step of a pipeline";
···
86
108
"SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}"
87
109
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
88
110
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
89
-
"SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}"
90
-
"SPINDLE_PIPELINES_STEP_TIMEOUT=${cfg.pipelines.stepTimeout}"
111
+
"SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}"
112
+
"SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}"
113
+
"SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}"
114
+
"SPINDLE_NIXERY_PIPELINES_NIXERY=${cfg.pipelines.nixery}"
115
+
"SPINDLE_NIXERY_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}"
91
116
];
92
-
ExecStart = "${self.packages.${pkgs.system}.spindle}/bin/spindle";
117
+
ExecStart = "${cfg.package}/bin/spindle";
93
118
Restart = "always";
94
119
};
95
120
};
+29
nix/pkgs/appview-static-files.nix
+29
nix/pkgs/appview-static-files.nix
···
1
+
{
2
+
runCommandLocal,
3
+
htmx-src,
4
+
htmx-ws-src,
5
+
lucide-src,
6
+
inter-fonts-src,
7
+
ibm-plex-mono-src,
8
+
sqlite-lib,
9
+
tailwindcss,
10
+
src,
11
+
}:
12
+
runCommandLocal "appview-static-files" {
13
+
# TOOD(winter): figure out why this is even required after
14
+
# changing the libraries that the tailwindcss binary loads
15
+
sandboxProfile = ''
16
+
(allow file-read* (subpath "/System/Library/OpenSSL"))
17
+
'';
18
+
} ''
19
+
mkdir -p $out/{fonts,icons} && cd $out
20
+
cp -f ${htmx-src} htmx.min.js
21
+
cp -f ${htmx-ws-src} htmx-ext-ws.min.js
22
+
cp -rf ${lucide-src}/*.svg icons/
23
+
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/
24
+
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
25
+
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
26
+
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
27
+
# for whatever reason (produces broken css), so we are doing this instead
28
+
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
29
+
''
+10
-25
nix/pkgs/appview.nix
+10
-25
nix/pkgs/appview.nix
···
1
1
{
2
-
buildGoModule,
3
-
stdenv,
4
-
htmx-src,
5
-
htmx-ws-src,
6
-
lucide-src,
7
-
inter-fonts-src,
8
-
ibm-plex-mono-src,
9
-
tailwindcss,
2
+
buildGoApplication,
3
+
modules,
4
+
appview-static-files,
10
5
sqlite-lib,
11
-
goModHash,
12
-
gitignoreSource,
6
+
src,
13
7
}:
14
-
buildGoModule {
15
-
inherit stdenv;
16
-
8
+
buildGoApplication {
17
9
pname = "appview";
18
10
version = "0.1.0";
19
-
src = gitignoreSource ../..;
11
+
inherit src modules;
20
12
21
13
postUnpack = ''
22
14
pushd source
23
-
mkdir -p appview/pages/static/{fonts,icons}
24
-
cp -f ${htmx-src} appview/pages/static/htmx.min.js
25
-
cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js
26
-
cp -rf ${lucide-src}/*.svg appview/pages/static/icons/
27
-
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/
28
-
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
29
-
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/
30
-
${tailwindcss}/bin/tailwindcss -i input.css -o appview/pages/static/tw.css
15
+
mkdir -p appview/pages/static
16
+
cp -frv ${appview-static-files}/* appview/pages/static
31
17
popd
32
18
'';
33
19
34
20
doCheck = false;
35
21
subPackages = ["cmd/appview"];
36
-
vendorHash = goModHash;
37
22
38
-
tags = "libsqlite3";
23
+
tags = ["libsqlite3"];
39
24
env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
40
25
env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib";
41
-
env.CGO_ENABLED = 1;
26
+
CGO_ENABLED = 1;
42
27
}
+12
-8
nix/pkgs/genjwks.nix
+12
-8
nix/pkgs/genjwks.nix
···
1
1
{
2
-
buildGoModule,
3
-
goModHash,
4
-
gitignoreSource,
2
+
buildGoApplication,
3
+
modules,
5
4
}:
6
-
buildGoModule {
5
+
buildGoApplication {
7
6
pname = "genjwks";
8
7
version = "0.1.0";
9
-
src = gitignoreSource ../..;
10
-
subPackages = ["cmd/genjwks"];
11
-
vendorHash = goModHash;
8
+
src = ../../cmd/genjwks;
9
+
postPatch = ''
10
+
ln -s ${../../go.mod} ./go.mod
11
+
'';
12
+
postInstall = ''
13
+
mv $out/bin/core $out/bin/genjwks
14
+
'';
15
+
inherit modules;
12
16
doCheck = false;
13
-
env.CGO_ENABLED = 0;
17
+
CGO_ENABLED = 0;
14
18
}
+7
-9
nix/pkgs/knot-unwrapped.nix
+7
-9
nix/pkgs/knot-unwrapped.nix
···
1
1
{
2
-
buildGoModule,
3
-
stdenv,
2
+
buildGoApplication,
3
+
modules,
4
4
sqlite-lib,
5
-
goModHash,
6
-
gitignoreSource,
5
+
src,
7
6
}:
8
-
buildGoModule {
7
+
buildGoApplication {
9
8
pname = "knot";
10
9
version = "0.1.0";
11
-
src = gitignoreSource ../..;
10
+
inherit src modules;
12
11
13
12
doCheck = false;
14
13
15
14
subPackages = ["cmd/knot"];
16
-
vendorHash = goModHash;
17
-
tags = "libsqlite3";
15
+
tags = ["libsqlite3"];
18
16
19
17
env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
20
18
env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib";
21
-
env.CGO_ENABLED = 1;
19
+
CGO_ENABLED = 1;
22
20
}
+1
-1
nix/pkgs/lexgen.nix
+1
-1
nix/pkgs/lexgen.nix
+7
-9
nix/pkgs/spindle.nix
+7
-9
nix/pkgs/spindle.nix
···
1
1
{
2
-
buildGoModule,
3
-
stdenv,
2
+
buildGoApplication,
3
+
modules,
4
4
sqlite-lib,
5
-
goModHash,
6
-
gitignoreSource,
5
+
src,
7
6
}:
8
-
buildGoModule {
7
+
buildGoApplication {
9
8
pname = "spindle";
10
9
version = "0.1.0";
11
-
src = gitignoreSource ../..;
10
+
inherit src modules;
12
11
13
12
doCheck = false;
14
13
15
14
subPackages = ["cmd/spindle"];
16
-
vendorHash = goModHash;
17
-
tags = "libsqlite3";
15
+
tags = ["libsqlite3"];
18
16
19
17
env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
20
18
env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib";
21
-
env.CGO_ENABLED = 1;
19
+
CGO_ENABLED = 1;
22
20
}
+120
-63
nix/vm.nix
+120
-63
nix/vm.nix
···
1
1
{
2
2
nixpkgs,
3
+
system,
4
+
hostSystem,
3
5
self,
4
-
}:
5
-
nixpkgs.lib.nixosSystem {
6
-
system = "x86_64-linux";
7
-
modules = [
8
-
self.nixosModules.knot
9
-
self.nixosModules.spindle
10
-
({
11
-
config,
12
-
pkgs,
13
-
...
14
-
}: {
15
-
virtualisation = {
16
-
memorySize = 2048;
17
-
diskSize = 10 * 1024;
18
-
cores = 2;
19
-
forwardPorts = [
20
-
# ssh
21
-
{
22
-
from = "host";
23
-
host.port = 2222;
24
-
guest.port = 22;
25
-
}
26
-
# knot
27
-
{
28
-
from = "host";
29
-
host.port = 6000;
30
-
guest.port = 6000;
31
-
}
32
-
# spindle
33
-
{
34
-
from = "host";
35
-
host.port = 6555;
36
-
guest.port = 6555;
37
-
}
38
-
];
39
-
};
40
-
services.getty.autologinUser = "root";
41
-
environment.systemPackages = with pkgs; [curl vim git];
42
-
systemd.tmpfiles.rules = let
43
-
u = config.services.tangled-knot.gitUser;
44
-
g = config.services.tangled-knot.gitUser;
45
-
in [
46
-
"d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first
47
-
"f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=168c426fa6d9829fcbe85c96bdf144e800fb9737d6ca87f21acc543b1aa3e440"
48
-
];
49
-
services.tangled-knot = {
50
-
enable = true;
51
-
server = {
52
-
secretFile = "/var/lib/knot/secret";
53
-
hostname = "localhost:6000";
54
-
listenAddr = "0.0.0.0:6000";
6
+
}: let
7
+
envVar = name: let
8
+
var = builtins.getEnv name;
9
+
in
10
+
if var == ""
11
+
then throw "\$${name} must be defined, see docs/hacking.md for more details"
12
+
else var;
13
+
in
14
+
nixpkgs.lib.nixosSystem {
15
+
inherit system;
16
+
modules = [
17
+
self.nixosModules.knot
18
+
self.nixosModules.spindle
19
+
({
20
+
lib,
21
+
config,
22
+
pkgs,
23
+
...
24
+
}: {
25
+
virtualisation.vmVariant.virtualisation = {
26
+
host.pkgs = import nixpkgs {system = hostSystem;};
27
+
28
+
graphics = false;
29
+
memorySize = 2048;
30
+
diskSize = 10 * 1024;
31
+
cores = 2;
32
+
forwardPorts = [
33
+
# ssh
34
+
{
35
+
from = "host";
36
+
host.port = 2222;
37
+
guest.port = 22;
38
+
}
39
+
# knot
40
+
{
41
+
from = "host";
42
+
host.port = 6000;
43
+
guest.port = 6000;
44
+
}
45
+
# spindle
46
+
{
47
+
from = "host";
48
+
host.port = 6555;
49
+
guest.port = 6555;
50
+
}
51
+
];
52
+
sharedDirectories = {
53
+
# We can't use the 9p mounts directly for most of these
54
+
# as SQLite is incompatible with them. So instead we
55
+
# mount the shared directories to a different location
56
+
# and copy the contents around on service start/stop.
57
+
knotData = {
58
+
source = "$TANGLED_VM_DATA_DIR/knot";
59
+
target = "/mnt/knot-data";
60
+
};
61
+
spindleData = {
62
+
source = "$TANGLED_VM_DATA_DIR/spindle";
63
+
target = "/mnt/spindle-data";
64
+
};
65
+
spindleLogs = {
66
+
source = "$TANGLED_VM_DATA_DIR/spindle-logs";
67
+
target = "/var/log/spindle";
68
+
};
69
+
};
70
+
};
71
+
# This is fine because any and all ports that are forwarded to host are explicitly marked above, we don't need a separate guest firewall
72
+
networking.firewall.enable = false;
73
+
services.getty.autologinUser = "root";
74
+
environment.systemPackages = with pkgs; [curl vim git sqlite litecli];
75
+
services.tangled-knot = {
76
+
enable = true;
77
+
motd = "Welcome to the development knot!\n";
78
+
server = {
79
+
secretFile = builtins.toFile "knot-secret" ("KNOT_SERVER_SECRET=" + (envVar "TANGLED_VM_KNOT_SECRET"));
80
+
hostname = "localhost:6000";
81
+
listenAddr = "0.0.0.0:6000";
82
+
};
83
+
};
84
+
services.tangled-spindle = {
85
+
enable = true;
86
+
server = {
87
+
owner = envVar "TANGLED_VM_SPINDLE_OWNER";
88
+
hostname = "localhost:6555";
89
+
listenAddr = "0.0.0.0:6555";
90
+
dev = true;
91
+
secrets = {
92
+
provider = "sqlite";
93
+
};
94
+
};
95
+
};
96
+
users = {
97
+
# So we don't have to deal with permission clashing between
98
+
# blank disk VMs and existing state
99
+
users.${config.services.tangled-knot.gitUser}.uid = 666;
100
+
groups.${config.services.tangled-knot.gitUser}.gid = 666;
101
+
102
+
# TODO: separate spindle user
55
103
};
56
-
};
57
-
services.tangled-spindle = {
58
-
enable = true;
59
-
server = {
60
-
owner = "did:plc:qfpnj4og54vl56wngdriaxug";
61
-
hostname = "localhost:6555";
62
-
listenAddr = "0.0.0.0:6555";
63
-
dev = true;
104
+
systemd.services = let
105
+
mkDataSyncScripts = source: target: {
106
+
enableStrictShellChecks = true;
107
+
108
+
preStart = lib.mkBefore ''
109
+
mkdir -p ${target}
110
+
${lib.getExe pkgs.rsync} -a ${source}/ ${target}
111
+
'';
112
+
113
+
postStop = lib.mkAfter ''
114
+
${lib.getExe pkgs.rsync} -a ${target}/ ${source}
115
+
'';
116
+
117
+
serviceConfig.PermissionsStartOnly = true;
118
+
};
119
+
in {
120
+
knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled-knot.stateDir;
121
+
spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled-spindle.server.dbPath);
64
122
};
65
-
};
66
-
})
67
-
];
68
-
}
123
+
})
124
+
];
125
+
}
+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 {
+5
-1
rbac/rbac.go
+5
-1
rbac/rbac.go
···
11
11
)
12
12
13
13
const (
14
+
ThisServer = "thisserver" // resource identifier for local rbac enforcement
15
+
)
16
+
17
+
const (
14
18
Model = `
15
19
[request_definition]
16
20
r = sub, dom, obj, act
···
39
43
return nil, err
40
44
}
41
45
42
-
db, err := sql.Open("sqlite3", path)
46
+
db, err := sql.Open("sqlite3", path+"?_foreign_keys=1")
43
47
if err != nil {
44
48
return nil, err
45
49
}
+1
-1
rbac/rbac_test.go
+1
-1
rbac/rbac_test.go
+27
-10
spindle/config/config.go
+27
-10
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
+
LogDir string `env:"LOG_DIR, default=/var/log/spindle"`
20
+
}
21
+
22
+
func (s Server) Did() syntax.DID {
23
+
return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname))
24
+
}
25
+
26
+
type Secrets struct {
27
+
Provider string `env:"PROVIDER, default=sqlite"`
28
+
OpenBao OpenBaoConfig `env:",prefix=OPENBAO_"`
16
29
}
17
30
18
-
type Pipelines struct {
31
+
type OpenBaoConfig struct {
32
+
ProxyAddr string `env:"PROXY_ADDR, default=http://127.0.0.1:8200"`
33
+
Mount string `env:"MOUNT, default=spindle"`
34
+
}
35
+
36
+
type NixeryPipelines struct {
19
37
Nixery string `env:"NIXERY, default=nixery.tangled.sh"`
20
38
WorkflowTimeout string `env:"WORKFLOW_TIMEOUT, default=5m"`
21
-
LogDir string `env:"LOG_DIR, default=/var/log/spindle"`
22
39
}
23
40
24
41
type Config struct {
25
-
Server Server `env:",prefix=SPINDLE_SERVER_"`
26
-
Pipelines Pipelines `env:",prefix=SPINDLE_PIPELINES_"`
42
+
Server Server `env:",prefix=SPINDLE_SERVER_"`
43
+
NixeryPipelines NixeryPipelines `env:",prefix=SPINDLE_NIXERY_PIPELINES_"`
27
44
}
28
45
29
46
func Load(ctx context.Context) (*Config, error) {
+29
-10
spindle/db/db.go
+29
-10
spindle/db/db.go
···
2
2
3
3
import (
4
4
"database/sql"
5
+
"strings"
5
6
6
7
_ "github.com/mattn/go-sqlite3"
7
8
)
···
11
12
}
12
13
13
14
func Make(dbPath string) (*DB, error) {
14
-
db, err := sql.Open("sqlite3", dbPath)
15
+
// https://github.com/mattn/go-sqlite3#connection-string
16
+
opts := []string{
17
+
"_foreign_keys=1",
18
+
"_journal_mode=WAL",
19
+
"_synchronous=NORMAL",
20
+
"_auto_vacuum=incremental",
21
+
}
22
+
23
+
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
15
24
if err != nil {
16
25
return nil, err
17
26
}
27
+
28
+
// NOTE: If any other migration is added here, you MUST
29
+
// copy the pattern in appview: use a single sql.Conn
30
+
// for every migration.
18
31
19
32
_, err = db.Exec(`
20
-
pragma journal_mode = WAL;
21
-
pragma synchronous = normal;
22
-
pragma foreign_keys = on;
23
-
pragma temp_store = memory;
24
-
pragma mmap_size = 30000000000;
25
-
pragma page_size = 32768;
26
-
pragma auto_vacuum = incremental;
27
-
pragma busy_timeout = 5000;
28
-
29
33
create table if not exists _jetstream (
30
34
id integer primary key autoincrement,
31
35
last_time_us integer not null
···
43
47
addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
44
48
45
49
unique(owner, name)
50
+
);
51
+
52
+
create table if not exists spindle_members (
53
+
-- identifiers for the record
54
+
id integer primary key autoincrement,
55
+
did text not null,
56
+
rkey text not null,
57
+
58
+
-- data
59
+
instance text not null,
60
+
subject text not null,
61
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
62
+
63
+
-- constraints
64
+
unique (did, instance, subject)
46
65
);
47
66
48
67
-- status event for a single workflow
+59
spindle/db/member.go
+59
spindle/db/member.go
···
1
+
package db
2
+
3
+
import (
4
+
"time"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
)
8
+
9
+
type SpindleMember struct {
10
+
Id int
11
+
Did syntax.DID // owner of the record
12
+
Rkey string // rkey of the record
13
+
Instance string
14
+
Subject syntax.DID // the member being added
15
+
Created time.Time
16
+
}
17
+
18
+
func AddSpindleMember(db *DB, member SpindleMember) error {
19
+
_, err := db.Exec(
20
+
`insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`,
21
+
member.Did,
22
+
member.Rkey,
23
+
member.Instance,
24
+
member.Subject,
25
+
)
26
+
return err
27
+
}
28
+
29
+
func RemoveSpindleMember(db *DB, owner_did, rkey string) error {
30
+
_, err := db.Exec(
31
+
"delete from spindle_members where did = ? and rkey = ?",
32
+
owner_did,
33
+
rkey,
34
+
)
35
+
return err
36
+
}
37
+
38
+
func GetSpindleMember(db *DB, did, rkey string) (*SpindleMember, error) {
39
+
query :=
40
+
`select id, did, rkey, instance, subject, created
41
+
from spindle_members
42
+
where did = ? and rkey = ?`
43
+
44
+
var member SpindleMember
45
+
var createdAt string
46
+
err := db.QueryRow(query, did, rkey).Scan(
47
+
&member.Id,
48
+
&member.Did,
49
+
&member.Rkey,
50
+
&member.Instance,
51
+
&member.Subject,
52
+
&createdAt,
53
+
)
54
+
if err != nil {
55
+
return nil, err
56
+
}
57
+
58
+
return &member, nil
59
+
}
-21
spindle/engine/ansi_stripper.go
-21
spindle/engine/ansi_stripper.go
···
1
-
package engine
2
-
3
-
import (
4
-
"io"
5
-
6
-
"regexp"
7
-
)
8
-
9
-
// regex to match ANSI escape codes (e.g., color codes, cursor moves)
10
-
const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))"
11
-
12
-
var re = regexp.MustCompile(ansi)
13
-
14
-
type ansiStrippingWriter struct {
15
-
underlying io.Writer
16
-
}
17
-
18
-
func (w *ansiStrippingWriter) Write(p []byte) (int, error) {
19
-
clean := re.ReplaceAll(p, []byte{})
20
-
return w.underlying.Write(clean)
21
-
}
+77
-401
spindle/engine/engine.go
+77
-401
spindle/engine/engine.go
···
4
4
"context"
5
5
"errors"
6
6
"fmt"
7
-
"io"
8
7
"log/slog"
9
-
"os"
10
-
"strings"
11
-
"sync"
12
-
"time"
13
8
14
-
"github.com/docker/docker/api/types/container"
15
-
"github.com/docker/docker/api/types/image"
16
-
"github.com/docker/docker/api/types/mount"
17
-
"github.com/docker/docker/api/types/network"
18
-
"github.com/docker/docker/api/types/volume"
19
-
"github.com/docker/docker/client"
20
-
"github.com/docker/docker/pkg/stdcopy"
21
-
"tangled.sh/tangled.sh/core/log"
9
+
securejoin "github.com/cyphar/filepath-securejoin"
10
+
"golang.org/x/sync/errgroup"
22
11
"tangled.sh/tangled.sh/core/notifier"
23
12
"tangled.sh/tangled.sh/core/spindle/config"
24
13
"tangled.sh/tangled.sh/core/spindle/db"
25
14
"tangled.sh/tangled.sh/core/spindle/models"
15
+
"tangled.sh/tangled.sh/core/spindle/secrets"
26
16
)
27
17
28
-
const (
29
-
workspaceDir = "/tangled/workspace"
18
+
var (
19
+
ErrTimedOut = errors.New("timed out")
20
+
ErrWorkflowFailed = errors.New("workflow failed")
30
21
)
31
22
32
-
type cleanupFunc func(context.Context) error
33
-
34
-
type Engine struct {
35
-
docker client.APIClient
36
-
l *slog.Logger
37
-
db *db.DB
38
-
n *notifier.Notifier
39
-
cfg *config.Config
40
-
41
-
cleanupMu sync.Mutex
42
-
cleanup map[string][]cleanupFunc
43
-
}
23
+
func StartWorkflows(l *slog.Logger, vault secrets.Manager, cfg *config.Config, db *db.DB, n *notifier.Notifier, ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) {
24
+
l.Info("starting all workflows in parallel", "pipeline", pipelineId)
44
25
45
-
func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier) (*Engine, error) {
46
-
dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
47
-
if err != nil {
48
-
return nil, err
49
-
}
50
-
51
-
l := log.FromContext(ctx).With("component", "spindle")
52
-
53
-
e := &Engine{
54
-
docker: dcli,
55
-
l: l,
56
-
db: db,
57
-
n: n,
58
-
cfg: cfg,
26
+
// extract secrets
27
+
var allSecrets []secrets.UnlockedSecret
28
+
if didSlashRepo, err := securejoin.SecureJoin(pipeline.RepoOwner, pipeline.RepoName); err == nil {
29
+
if res, err := vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)); err == nil {
30
+
allSecrets = res
31
+
}
59
32
}
60
33
61
-
e.cleanup = make(map[string][]cleanupFunc)
62
-
63
-
return e, nil
64
-
}
65
-
66
-
func (e *Engine) StartWorkflows(ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) {
67
-
e.l.Info("starting all workflows in parallel", "pipeline", pipelineId)
68
-
69
-
wg := sync.WaitGroup{}
70
-
for _, w := range pipeline.Workflows {
71
-
wg.Add(1)
72
-
go func() error {
73
-
defer wg.Done()
74
-
wid := models.WorkflowId{
75
-
PipelineId: pipelineId,
76
-
Name: w.Name,
77
-
}
78
-
79
-
err := e.db.StatusRunning(wid, e.n)
80
-
if err != nil {
81
-
return err
82
-
}
34
+
eg, ctx := errgroup.WithContext(ctx)
35
+
for eng, wfs := range pipeline.Workflows {
36
+
workflowTimeout := eng.WorkflowTimeout()
37
+
l.Info("using workflow timeout", "timeout", workflowTimeout)
83
38
84
-
err = e.SetupWorkflow(ctx, wid)
85
-
if err != nil {
86
-
e.l.Error("setting up worklow", "wid", wid, "err", err)
87
-
return err
88
-
}
89
-
defer e.DestroyWorkflow(ctx, wid)
90
-
91
-
reader, err := e.docker.ImagePull(ctx, w.Image, image.PullOptions{})
92
-
if err != nil {
93
-
e.l.Error("pipeline failed!", "workflowId", wid, "error", err.Error())
39
+
for _, w := range wfs {
40
+
eg.Go(func() error {
41
+
wid := models.WorkflowId{
42
+
PipelineId: pipelineId,
43
+
Name: w.Name,
44
+
}
94
45
95
-
err := e.db.StatusFailed(wid, err.Error(), -1, e.n)
46
+
err := db.StatusRunning(wid, n)
96
47
if err != nil {
97
48
return err
98
49
}
99
50
100
-
return fmt.Errorf("pulling image: %w", err)
101
-
}
102
-
defer reader.Close()
103
-
io.Copy(os.Stdout, reader)
51
+
err = eng.SetupWorkflow(ctx, wid, &w)
52
+
if err != nil {
53
+
// TODO(winter): Should this always set StatusFailed?
54
+
// In the original, we only do in a subset of cases.
55
+
l.Error("setting up worklow", "wid", wid, "err", err)
104
56
105
-
workflowTimeoutStr := e.cfg.Pipelines.WorkflowTimeout
106
-
workflowTimeout, err := time.ParseDuration(workflowTimeoutStr)
107
-
if err != nil {
108
-
e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr)
109
-
workflowTimeout = 5 * time.Minute
110
-
}
111
-
e.l.Info("using workflow timeout", "timeout", workflowTimeout)
112
-
ctx, cancel := context.WithTimeout(ctx, workflowTimeout)
113
-
defer cancel()
57
+
destroyErr := eng.DestroyWorkflow(ctx, wid)
58
+
if destroyErr != nil {
59
+
l.Error("failed to destroy workflow after setup failure", "error", destroyErr)
60
+
}
114
61
115
-
err = e.StartSteps(ctx, w.Steps, wid, w.Image)
116
-
if err != nil {
117
-
if errors.Is(err, ErrTimedOut) {
118
-
dbErr := e.db.StatusTimeout(wid, e.n)
62
+
dbErr := db.StatusFailed(wid, err.Error(), -1, n)
119
63
if dbErr != nil {
120
64
return dbErr
121
65
}
122
-
} else {
123
-
dbErr := e.db.StatusFailed(wid, err.Error(), -1, e.n)
124
-
if dbErr != nil {
125
-
return dbErr
126
-
}
66
+
return err
127
67
}
128
-
129
-
return fmt.Errorf("starting steps image: %w", err)
130
-
}
131
-
132
-
err = e.db.StatusSuccess(wid, e.n)
133
-
if err != nil {
134
-
return err
135
-
}
136
-
137
-
return nil
138
-
}()
139
-
}
140
-
141
-
wg.Wait()
142
-
}
143
-
144
-
// SetupWorkflow sets up a new network for the workflow and volumes for
145
-
// the workspace and Nix store. These are persisted across steps and are
146
-
// destroyed at the end of the workflow.
147
-
func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId) error {
148
-
e.l.Info("setting up workflow", "workflow", wid)
149
-
150
-
_, err := e.docker.VolumeCreate(ctx, volume.CreateOptions{
151
-
Name: workspaceVolume(wid),
152
-
Driver: "local",
153
-
})
154
-
if err != nil {
155
-
return err
156
-
}
157
-
e.registerCleanup(wid, func(ctx context.Context) error {
158
-
return e.docker.VolumeRemove(ctx, workspaceVolume(wid), true)
159
-
})
160
-
161
-
_, err = e.docker.VolumeCreate(ctx, volume.CreateOptions{
162
-
Name: nixVolume(wid),
163
-
Driver: "local",
164
-
})
165
-
if err != nil {
166
-
return err
167
-
}
168
-
e.registerCleanup(wid, func(ctx context.Context) error {
169
-
return e.docker.VolumeRemove(ctx, nixVolume(wid), true)
170
-
})
171
-
172
-
_, err = e.docker.NetworkCreate(ctx, networkName(wid), network.CreateOptions{
173
-
Driver: "bridge",
174
-
})
175
-
if err != nil {
176
-
return err
177
-
}
178
-
e.registerCleanup(wid, func(ctx context.Context) error {
179
-
return e.docker.NetworkRemove(ctx, networkName(wid))
180
-
})
181
-
182
-
return nil
183
-
}
184
-
185
-
// StartSteps starts all steps sequentially with the same base image.
186
-
// ONLY marks pipeline as failed if container's exit code is non-zero.
187
-
// All other errors are bubbled up.
188
-
// Fixed version of the step execution logic
189
-
func (e *Engine) StartSteps(ctx context.Context, steps []models.Step, wid models.WorkflowId, image string) error {
190
-
191
-
for stepIdx, step := range steps {
192
-
select {
193
-
case <-ctx.Done():
194
-
return ctx.Err()
195
-
default:
196
-
}
197
-
198
-
envs := ConstructEnvs(step.Environment)
199
-
envs.AddEnv("HOME", workspaceDir)
200
-
e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice())
201
-
202
-
hostConfig := hostConfig(wid)
203
-
resp, err := e.docker.ContainerCreate(ctx, &container.Config{
204
-
Image: image,
205
-
Cmd: []string{"bash", "-c", step.Command},
206
-
WorkingDir: workspaceDir,
207
-
Tty: false,
208
-
Hostname: "spindle",
209
-
Env: envs.Slice(),
210
-
}, hostConfig, nil, nil, "")
211
-
defer e.DestroyStep(ctx, resp.ID)
212
-
if err != nil {
213
-
return fmt.Errorf("creating container: %w", err)
214
-
}
215
-
216
-
err = e.docker.NetworkConnect(ctx, networkName(wid), resp.ID, nil)
217
-
if err != nil {
218
-
return fmt.Errorf("connecting network: %w", err)
219
-
}
220
-
221
-
err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{})
222
-
if err != nil {
223
-
return err
224
-
}
225
-
e.l.Info("started container", "name", resp.ID, "step", step.Name)
226
-
227
-
// start tailing logs in background
228
-
tailDone := make(chan error, 1)
229
-
go func() {
230
-
tailDone <- e.TailStep(ctx, resp.ID, wid, stepIdx, step)
231
-
}()
232
-
233
-
// wait for container completion or timeout
234
-
waitDone := make(chan struct{})
235
-
var state *container.State
236
-
var waitErr error
237
-
238
-
go func() {
239
-
defer close(waitDone)
240
-
state, waitErr = e.WaitStep(ctx, resp.ID)
241
-
}()
242
-
243
-
select {
244
-
case <-waitDone:
245
-
246
-
// wait for tailing to complete
247
-
<-tailDone
248
-
249
-
case <-ctx.Done():
250
-
e.l.Warn("step timed out; killing container", "container", resp.ID, "step", step.Name)
251
-
err = e.DestroyStep(context.Background(), resp.ID)
252
-
if err != nil {
253
-
e.l.Error("failed to destroy step", "container", resp.ID, "error", err)
254
-
}
68
+
defer eng.DestroyWorkflow(ctx, wid)
255
69
256
-
// wait for both goroutines to finish
257
-
<-waitDone
258
-
<-tailDone
259
-
260
-
return ErrTimedOut
261
-
}
70
+
wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid)
71
+
if err != nil {
72
+
l.Warn("failed to setup step logger; logs will not be persisted", "error", err)
73
+
wfLogger = nil
74
+
} else {
75
+
defer wfLogger.Close()
76
+
}
262
77
263
-
select {
264
-
case <-ctx.Done():
265
-
return ctx.Err()
266
-
default:
267
-
}
78
+
ctx, cancel := context.WithTimeout(ctx, workflowTimeout)
79
+
defer cancel()
268
80
269
-
if waitErr != nil {
270
-
return waitErr
271
-
}
81
+
for stepIdx, step := range w.Steps {
82
+
if wfLogger != nil {
83
+
ctl := wfLogger.ControlWriter(stepIdx, step)
84
+
ctl.Write([]byte(step.Name()))
85
+
}
272
86
273
-
err = e.DestroyStep(ctx, resp.ID)
274
-
if err != nil {
275
-
return err
276
-
}
87
+
err = eng.RunStep(ctx, wid, &w, stepIdx, allSecrets, wfLogger)
88
+
if err != nil {
89
+
if errors.Is(err, ErrTimedOut) {
90
+
dbErr := db.StatusTimeout(wid, n)
91
+
if dbErr != nil {
92
+
return dbErr
93
+
}
94
+
} else {
95
+
dbErr := db.StatusFailed(wid, err.Error(), -1, n)
96
+
if dbErr != nil {
97
+
return dbErr
98
+
}
99
+
}
277
100
278
-
if state.ExitCode != 0 {
279
-
e.l.Error("workflow failed!", "workflow_id", wid.String(), "error", state.Error, "exit_code", state.ExitCode, "oom_killed", state.OOMKilled)
280
-
if state.OOMKilled {
281
-
return ErrOOMKilled
282
-
}
283
-
return ErrWorkflowFailed
284
-
}
285
-
}
101
+
return fmt.Errorf("starting steps image: %w", err)
102
+
}
103
+
}
286
104
287
-
return nil
288
-
}
105
+
err = db.StatusSuccess(wid, n)
106
+
if err != nil {
107
+
return err
108
+
}
289
109
290
-
func (e *Engine) WaitStep(ctx context.Context, containerID string) (*container.State, error) {
291
-
wait, errCh := e.docker.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
292
-
select {
293
-
case err := <-errCh:
294
-
if err != nil {
295
-
return nil, err
110
+
return nil
111
+
})
296
112
}
297
-
case <-wait:
298
113
}
299
114
300
-
e.l.Info("waited for container", "name", containerID)
301
-
302
-
info, err := e.docker.ContainerInspect(ctx, containerID)
303
-
if err != nil {
304
-
return nil, err
305
-
}
306
-
307
-
return info.State, nil
308
-
}
309
-
310
-
func (e *Engine) TailStep(ctx context.Context, containerID string, wid models.WorkflowId, stepIdx int, step models.Step) error {
311
-
wfLogger, err := NewWorkflowLogger(e.cfg.Pipelines.LogDir, wid)
312
-
if err != nil {
313
-
e.l.Warn("failed to setup step logger; logs will not be persisted", "error", err)
314
-
return err
115
+
if err := eg.Wait(); err != nil {
116
+
l.Error("failed to run one or more workflows", "err", err)
117
+
} else {
118
+
l.Error("successfully ran full pipeline")
315
119
}
316
-
defer wfLogger.Close()
317
-
318
-
ctl := wfLogger.ControlWriter(stepIdx, step)
319
-
ctl.Write([]byte(step.Name))
320
-
321
-
logs, err := e.docker.ContainerLogs(ctx, containerID, container.LogsOptions{
322
-
Follow: true,
323
-
ShowStdout: true,
324
-
ShowStderr: true,
325
-
Details: false,
326
-
Timestamps: false,
327
-
})
328
-
if err != nil {
329
-
return err
330
-
}
331
-
332
-
_, err = stdcopy.StdCopy(
333
-
wfLogger.DataWriter("stdout"),
334
-
wfLogger.DataWriter("stderr"),
335
-
logs,
336
-
)
337
-
if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) {
338
-
return fmt.Errorf("failed to copy logs: %w", err)
339
-
}
340
-
341
-
return nil
342
-
}
343
-
344
-
func (e *Engine) DestroyStep(ctx context.Context, containerID string) error {
345
-
err := e.docker.ContainerKill(ctx, containerID, "9") // SIGKILL
346
-
if err != nil && !isErrContainerNotFoundOrNotRunning(err) {
347
-
return err
348
-
}
349
-
350
-
if err := e.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{
351
-
RemoveVolumes: true,
352
-
RemoveLinks: false,
353
-
Force: false,
354
-
}); err != nil && !isErrContainerNotFoundOrNotRunning(err) {
355
-
return err
356
-
}
357
-
358
-
return nil
359
-
}
360
-
361
-
func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error {
362
-
e.cleanupMu.Lock()
363
-
key := wid.String()
364
-
365
-
fns := e.cleanup[key]
366
-
delete(e.cleanup, key)
367
-
e.cleanupMu.Unlock()
368
-
369
-
for _, fn := range fns {
370
-
if err := fn(ctx); err != nil {
371
-
e.l.Error("failed to cleanup workflow resource", "workflowId", wid, "error", err)
372
-
}
373
-
}
374
-
return nil
375
-
}
376
-
377
-
func (e *Engine) registerCleanup(wid models.WorkflowId, fn cleanupFunc) {
378
-
e.cleanupMu.Lock()
379
-
defer e.cleanupMu.Unlock()
380
-
381
-
key := wid.String()
382
-
e.cleanup[key] = append(e.cleanup[key], fn)
383
-
}
384
-
385
-
func workspaceVolume(wid models.WorkflowId) string {
386
-
return fmt.Sprintf("workspace-%s", wid)
387
-
}
388
-
389
-
func nixVolume(wid models.WorkflowId) string {
390
-
return fmt.Sprintf("nix-%s", wid)
391
-
}
392
-
393
-
func networkName(wid models.WorkflowId) string {
394
-
return fmt.Sprintf("workflow-network-%s", wid)
395
-
}
396
-
397
-
func hostConfig(wid models.WorkflowId) *container.HostConfig {
398
-
hostConfig := &container.HostConfig{
399
-
Mounts: []mount.Mount{
400
-
{
401
-
Type: mount.TypeVolume,
402
-
Source: workspaceVolume(wid),
403
-
Target: workspaceDir,
404
-
},
405
-
{
406
-
Type: mount.TypeVolume,
407
-
Source: nixVolume(wid),
408
-
Target: "/nix",
409
-
},
410
-
{
411
-
Type: mount.TypeTmpfs,
412
-
Target: "/tmp",
413
-
ReadOnly: false,
414
-
TmpfsOptions: &mount.TmpfsOptions{
415
-
Mode: 0o1777, // world-writeable sticky bit
416
-
Options: [][]string{
417
-
{"exec"},
418
-
},
419
-
},
420
-
},
421
-
{
422
-
Type: mount.TypeVolume,
423
-
Source: "etc-nix-" + wid.String(),
424
-
Target: "/etc/nix",
425
-
},
426
-
},
427
-
ReadonlyRootfs: false,
428
-
CapDrop: []string{"ALL"},
429
-
CapAdd: []string{"CAP_DAC_OVERRIDE"},
430
-
SecurityOpt: []string{"no-new-privileges"},
431
-
ExtraHosts: []string{"host.docker.internal:host-gateway"},
432
-
}
433
-
434
-
return hostConfig
435
-
}
436
-
437
-
// thanks woodpecker
438
-
func isErrContainerNotFoundOrNotRunning(err error) bool {
439
-
// Error response from daemon: Cannot kill container: ...: No such container: ...
440
-
// Error response from daemon: Cannot kill container: ...: Container ... is not running"
441
-
// Error response from podman daemon: can only kill running containers. ... is in state exited
442
-
// Error: No such container: ...
443
-
return err != nil && (strings.Contains(err.Error(), "No such container") || strings.Contains(err.Error(), "is not running") || strings.Contains(err.Error(), "can only kill running containers"))
444
120
}
-28
spindle/engine/envs.go
-28
spindle/engine/envs.go
···
1
-
package engine
2
-
3
-
import (
4
-
"fmt"
5
-
)
6
-
7
-
type EnvVars []string
8
-
9
-
// ConstructEnvs converts a tangled.Pipeline_Step_Environment_Elem.{Key,Value}
10
-
// representation into a docker-friendly []string{"KEY=value", ...} slice.
11
-
func ConstructEnvs(envs map[string]string) EnvVars {
12
-
var dockerEnvs EnvVars
13
-
for k, v := range envs {
14
-
ev := fmt.Sprintf("%s=%s", k, v)
15
-
dockerEnvs = append(dockerEnvs, ev)
16
-
}
17
-
return dockerEnvs
18
-
}
19
-
20
-
// Slice returns the EnvVar as a []string slice.
21
-
func (ev EnvVars) Slice() []string {
22
-
return ev
23
-
}
24
-
25
-
// AddEnv adds a key=value string to the EnvVar.
26
-
func (ev *EnvVars) AddEnv(key, value string) {
27
-
*ev = append(*ev, fmt.Sprintf("%s=%s", key, value))
28
-
}
-48
spindle/engine/envs_test.go
-48
spindle/engine/envs_test.go
···
1
-
package engine
2
-
3
-
import (
4
-
"testing"
5
-
6
-
"github.com/stretchr/testify/assert"
7
-
)
8
-
9
-
func TestConstructEnvs(t *testing.T) {
10
-
tests := []struct {
11
-
name string
12
-
in map[string]string
13
-
want EnvVars
14
-
}{
15
-
{
16
-
name: "empty input",
17
-
in: make(map[string]string),
18
-
want: EnvVars{},
19
-
},
20
-
{
21
-
name: "single env var",
22
-
in: map[string]string{"FOO": "bar"},
23
-
want: EnvVars{"FOO=bar"},
24
-
},
25
-
{
26
-
name: "multiple env vars",
27
-
in: map[string]string{"FOO": "bar", "BAZ": "qux"},
28
-
want: EnvVars{"FOO=bar", "BAZ=qux"},
29
-
},
30
-
}
31
-
for _, tt := range tests {
32
-
t.Run(tt.name, func(t *testing.T) {
33
-
got := ConstructEnvs(tt.in)
34
-
if got == nil {
35
-
got = EnvVars{}
36
-
}
37
-
assert.Equal(t, tt.want, got)
38
-
})
39
-
}
40
-
}
41
-
42
-
func TestAddEnv(t *testing.T) {
43
-
ev := EnvVars{}
44
-
ev.AddEnv("FOO", "bar")
45
-
ev.AddEnv("BAZ", "qux")
46
-
want := EnvVars{"FOO=bar", "BAZ=qux"}
47
-
assert.ElementsMatch(t, want, ev)
48
-
}
-9
spindle/engine/errors.go
-9
spindle/engine/errors.go
-84
spindle/engine/logger.go
-84
spindle/engine/logger.go
···
1
-
package engine
2
-
3
-
import (
4
-
"encoding/json"
5
-
"fmt"
6
-
"io"
7
-
"os"
8
-
"path/filepath"
9
-
"strings"
10
-
11
-
"tangled.sh/tangled.sh/core/spindle/models"
12
-
)
13
-
14
-
type WorkflowLogger struct {
15
-
file *os.File
16
-
encoder *json.Encoder
17
-
}
18
-
19
-
func NewWorkflowLogger(baseDir string, wid models.WorkflowId) (*WorkflowLogger, error) {
20
-
path := LogFilePath(baseDir, wid)
21
-
22
-
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
23
-
if err != nil {
24
-
return nil, fmt.Errorf("creating log file: %w", err)
25
-
}
26
-
27
-
return &WorkflowLogger{
28
-
file: file,
29
-
encoder: json.NewEncoder(file),
30
-
}, nil
31
-
}
32
-
33
-
func LogFilePath(baseDir string, workflowID models.WorkflowId) string {
34
-
logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String()))
35
-
return logFilePath
36
-
}
37
-
38
-
func (l *WorkflowLogger) Close() error {
39
-
return l.file.Close()
40
-
}
41
-
42
-
func (l *WorkflowLogger) DataWriter(stream string) io.Writer {
43
-
// TODO: emit stream
44
-
return &dataWriter{
45
-
logger: l,
46
-
stream: stream,
47
-
}
48
-
}
49
-
50
-
func (l *WorkflowLogger) ControlWriter(idx int, step models.Step) io.Writer {
51
-
return &controlWriter{
52
-
logger: l,
53
-
idx: idx,
54
-
step: step,
55
-
}
56
-
}
57
-
58
-
type dataWriter struct {
59
-
logger *WorkflowLogger
60
-
stream string
61
-
}
62
-
63
-
func (w *dataWriter) Write(p []byte) (int, error) {
64
-
line := strings.TrimRight(string(p), "\r\n")
65
-
entry := models.NewDataLogLine(line, w.stream)
66
-
if err := w.logger.encoder.Encode(entry); err != nil {
67
-
return 0, err
68
-
}
69
-
return len(p), nil
70
-
}
71
-
72
-
type controlWriter struct {
73
-
logger *WorkflowLogger
74
-
idx int
75
-
step models.Step
76
-
}
77
-
78
-
func (w *controlWriter) Write(_ []byte) (int, error) {
79
-
entry := models.NewControlLogLine(w.idx, w.step)
80
-
if err := w.logger.encoder.Encode(entry); err != nil {
81
-
return 0, err
82
-
}
83
-
return len(w.step.Name), nil
84
-
}
+21
spindle/engines/nixery/ansi_stripper.go
+21
spindle/engines/nixery/ansi_stripper.go
···
1
+
package nixery
2
+
3
+
import (
4
+
"io"
5
+
6
+
"regexp"
7
+
)
8
+
9
+
// regex to match ANSI escape codes (e.g., color codes, cursor moves)
10
+
const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))"
11
+
12
+
var re = regexp.MustCompile(ansi)
13
+
14
+
type ansiStrippingWriter struct {
15
+
underlying io.Writer
16
+
}
17
+
18
+
func (w *ansiStrippingWriter) Write(p []byte) (int, error) {
19
+
clean := re.ReplaceAll(p, []byte{})
20
+
return w.underlying.Write(clean)
21
+
}
+418
spindle/engines/nixery/engine.go
+418
spindle/engines/nixery/engine.go
···
1
+
package nixery
2
+
3
+
import (
4
+
"context"
5
+
"errors"
6
+
"fmt"
7
+
"io"
8
+
"log/slog"
9
+
"os"
10
+
"path"
11
+
"runtime"
12
+
"sync"
13
+
"time"
14
+
15
+
"github.com/docker/docker/api/types/container"
16
+
"github.com/docker/docker/api/types/image"
17
+
"github.com/docker/docker/api/types/mount"
18
+
"github.com/docker/docker/api/types/network"
19
+
"github.com/docker/docker/client"
20
+
"github.com/docker/docker/pkg/stdcopy"
21
+
"gopkg.in/yaml.v3"
22
+
"tangled.sh/tangled.sh/core/api/tangled"
23
+
"tangled.sh/tangled.sh/core/log"
24
+
"tangled.sh/tangled.sh/core/spindle/config"
25
+
"tangled.sh/tangled.sh/core/spindle/engine"
26
+
"tangled.sh/tangled.sh/core/spindle/models"
27
+
"tangled.sh/tangled.sh/core/spindle/secrets"
28
+
)
29
+
30
+
const (
31
+
workspaceDir = "/tangled/workspace"
32
+
homeDir = "/tangled/home"
33
+
)
34
+
35
+
type cleanupFunc func(context.Context) error
36
+
37
+
type Engine struct {
38
+
docker client.APIClient
39
+
l *slog.Logger
40
+
cfg *config.Config
41
+
42
+
cleanupMu sync.Mutex
43
+
cleanup map[string][]cleanupFunc
44
+
}
45
+
46
+
type Step struct {
47
+
name string
48
+
kind models.StepKind
49
+
command string
50
+
environment map[string]string
51
+
}
52
+
53
+
func (s Step) Name() string {
54
+
return s.name
55
+
}
56
+
57
+
func (s Step) Command() string {
58
+
return s.command
59
+
}
60
+
61
+
func (s Step) Kind() models.StepKind {
62
+
return s.kind
63
+
}
64
+
65
+
// setupSteps get added to start of Steps
66
+
type setupSteps []models.Step
67
+
68
+
// addStep adds a step to the beginning of the workflow's steps.
69
+
func (ss *setupSteps) addStep(step models.Step) {
70
+
*ss = append(*ss, step)
71
+
}
72
+
73
+
type addlFields struct {
74
+
image string
75
+
container string
76
+
env map[string]string
77
+
}
78
+
79
+
func (e *Engine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) {
80
+
swf := &models.Workflow{}
81
+
addl := addlFields{}
82
+
83
+
dwf := &struct {
84
+
Steps []struct {
85
+
Command string `yaml:"command"`
86
+
Name string `yaml:"name"`
87
+
Environment map[string]string `yaml:"environment"`
88
+
} `yaml:"steps"`
89
+
Dependencies map[string][]string `yaml:"dependencies"`
90
+
Environment map[string]string `yaml:"environment"`
91
+
}{}
92
+
err := yaml.Unmarshal([]byte(twf.Raw), &dwf)
93
+
if err != nil {
94
+
return nil, err
95
+
}
96
+
97
+
for _, dstep := range dwf.Steps {
98
+
sstep := Step{}
99
+
sstep.environment = dstep.Environment
100
+
sstep.command = dstep.Command
101
+
sstep.name = dstep.Name
102
+
sstep.kind = models.StepKindUser
103
+
swf.Steps = append(swf.Steps, sstep)
104
+
}
105
+
swf.Name = twf.Name
106
+
addl.env = dwf.Environment
107
+
addl.image = workflowImage(dwf.Dependencies, e.cfg.NixeryPipelines.Nixery)
108
+
109
+
setup := &setupSteps{}
110
+
111
+
setup.addStep(nixConfStep())
112
+
setup.addStep(cloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev))
113
+
// this step could be empty
114
+
if s := dependencyStep(dwf.Dependencies); s != nil {
115
+
setup.addStep(*s)
116
+
}
117
+
118
+
// append setup steps in order to the start of workflow steps
119
+
swf.Steps = append(*setup, swf.Steps...)
120
+
swf.Data = addl
121
+
122
+
return swf, nil
123
+
}
124
+
125
+
func (e *Engine) WorkflowTimeout() time.Duration {
126
+
workflowTimeoutStr := e.cfg.NixeryPipelines.WorkflowTimeout
127
+
workflowTimeout, err := time.ParseDuration(workflowTimeoutStr)
128
+
if err != nil {
129
+
e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr)
130
+
workflowTimeout = 5 * time.Minute
131
+
}
132
+
133
+
return workflowTimeout
134
+
}
135
+
136
+
func workflowImage(deps map[string][]string, nixery string) string {
137
+
var dependencies string
138
+
for reg, ds := range deps {
139
+
if reg == "nixpkgs" {
140
+
dependencies = path.Join(ds...)
141
+
}
142
+
}
143
+
144
+
// load defaults from somewhere else
145
+
dependencies = path.Join(dependencies, "bash", "git", "coreutils", "nix")
146
+
147
+
if runtime.GOARCH == "arm64" {
148
+
dependencies = path.Join("arm64", dependencies)
149
+
}
150
+
151
+
return path.Join(nixery, dependencies)
152
+
}
153
+
154
+
func New(ctx context.Context, cfg *config.Config) (*Engine, error) {
155
+
dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
156
+
if err != nil {
157
+
return nil, err
158
+
}
159
+
160
+
l := log.FromContext(ctx).With("component", "spindle")
161
+
162
+
e := &Engine{
163
+
docker: dcli,
164
+
l: l,
165
+
cfg: cfg,
166
+
}
167
+
168
+
e.cleanup = make(map[string][]cleanupFunc)
169
+
170
+
return e, nil
171
+
}
172
+
173
+
func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId, wf *models.Workflow) error {
174
+
e.l.Info("setting up workflow", "workflow", wid)
175
+
176
+
_, err := e.docker.NetworkCreate(ctx, networkName(wid), network.CreateOptions{
177
+
Driver: "bridge",
178
+
})
179
+
if err != nil {
180
+
return err
181
+
}
182
+
e.registerCleanup(wid, func(ctx context.Context) error {
183
+
return e.docker.NetworkRemove(ctx, networkName(wid))
184
+
})
185
+
186
+
addl := wf.Data.(addlFields)
187
+
188
+
reader, err := e.docker.ImagePull(ctx, addl.image, image.PullOptions{})
189
+
if err != nil {
190
+
e.l.Error("pipeline image pull failed!", "image", addl.image, "workflowId", wid, "error", err.Error())
191
+
192
+
return fmt.Errorf("pulling image: %w", err)
193
+
}
194
+
defer reader.Close()
195
+
io.Copy(os.Stdout, reader)
196
+
197
+
resp, err := e.docker.ContainerCreate(ctx, &container.Config{
198
+
Image: addl.image,
199
+
Cmd: []string{"cat"},
200
+
OpenStdin: true, // so cat stays alive :3
201
+
Tty: false,
202
+
Hostname: "spindle",
203
+
WorkingDir: workspaceDir,
204
+
// TODO(winter): investigate whether environment variables passed here
205
+
// get propagated to ContainerExec processes
206
+
}, &container.HostConfig{
207
+
Mounts: []mount.Mount{
208
+
{
209
+
Type: mount.TypeTmpfs,
210
+
Target: "/tmp",
211
+
ReadOnly: false,
212
+
TmpfsOptions: &mount.TmpfsOptions{
213
+
Mode: 0o1777, // world-writeable sticky bit
214
+
Options: [][]string{
215
+
{"exec"},
216
+
},
217
+
},
218
+
},
219
+
},
220
+
ReadonlyRootfs: false,
221
+
CapDrop: []string{"ALL"},
222
+
CapAdd: []string{"CAP_DAC_OVERRIDE"},
223
+
SecurityOpt: []string{"no-new-privileges"},
224
+
ExtraHosts: []string{"host.docker.internal:host-gateway"},
225
+
}, nil, nil, "")
226
+
if err != nil {
227
+
return fmt.Errorf("creating container: %w", err)
228
+
}
229
+
e.registerCleanup(wid, func(ctx context.Context) error {
230
+
err = e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{})
231
+
if err != nil {
232
+
return err
233
+
}
234
+
235
+
return e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{
236
+
RemoveVolumes: true,
237
+
RemoveLinks: false,
238
+
Force: false,
239
+
})
240
+
})
241
+
242
+
err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{})
243
+
if err != nil {
244
+
return fmt.Errorf("starting container: %w", err)
245
+
}
246
+
247
+
mkExecResp, err := e.docker.ContainerExecCreate(ctx, resp.ID, container.ExecOptions{
248
+
Cmd: []string{"mkdir", "-p", workspaceDir, homeDir},
249
+
AttachStdout: true, // NOTE(winter): pretty sure this will make it so that when stdout read is done below, mkdir is done. maybe??
250
+
AttachStderr: true, // for good measure, backed up by docker/cli ("If -d is not set, attach to everything by default")
251
+
})
252
+
if err != nil {
253
+
return err
254
+
}
255
+
256
+
// This actually *starts* the command. Thanks, Docker!
257
+
execResp, err := e.docker.ContainerExecAttach(ctx, mkExecResp.ID, container.ExecAttachOptions{})
258
+
if err != nil {
259
+
return err
260
+
}
261
+
defer execResp.Close()
262
+
263
+
// This is apparently best way to wait for the command to complete.
264
+
_, err = io.ReadAll(execResp.Reader)
265
+
if err != nil {
266
+
return err
267
+
}
268
+
269
+
execInspectResp, err := e.docker.ContainerExecInspect(ctx, mkExecResp.ID)
270
+
if err != nil {
271
+
return err
272
+
}
273
+
274
+
if execInspectResp.ExitCode != 0 {
275
+
return fmt.Errorf("mkdir exited with exit code %d", execInspectResp.ExitCode)
276
+
} else if execInspectResp.Running {
277
+
return errors.New("mkdir is somehow still running??")
278
+
}
279
+
280
+
addl.container = resp.ID
281
+
wf.Data = addl
282
+
283
+
return nil
284
+
}
285
+
286
+
func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *models.WorkflowLogger) error {
287
+
addl := w.Data.(addlFields)
288
+
workflowEnvs := ConstructEnvs(addl.env)
289
+
// TODO(winter): should SetupWorkflow also have secret access?
290
+
// IMO yes, but probably worth thinking on.
291
+
for _, s := range secrets {
292
+
workflowEnvs.AddEnv(s.Key, s.Value)
293
+
}
294
+
295
+
step := w.Steps[idx].(Step)
296
+
297
+
select {
298
+
case <-ctx.Done():
299
+
return ctx.Err()
300
+
default:
301
+
}
302
+
303
+
envs := append(EnvVars(nil), workflowEnvs...)
304
+
for k, v := range step.environment {
305
+
envs.AddEnv(k, v)
306
+
}
307
+
envs.AddEnv("HOME", homeDir)
308
+
309
+
mkExecResp, err := e.docker.ContainerExecCreate(ctx, addl.container, container.ExecOptions{
310
+
Cmd: []string{"bash", "-c", step.command},
311
+
AttachStdout: true,
312
+
AttachStderr: true,
313
+
Env: envs,
314
+
})
315
+
if err != nil {
316
+
return fmt.Errorf("creating exec: %w", err)
317
+
}
318
+
319
+
// start tailing logs in background
320
+
tailDone := make(chan error, 1)
321
+
go func() {
322
+
tailDone <- e.tailStep(ctx, wfLogger, mkExecResp.ID, wid, idx, step)
323
+
}()
324
+
325
+
select {
326
+
case <-tailDone:
327
+
328
+
case <-ctx.Done():
329
+
// cleanup will be handled by DestroyWorkflow, since
330
+
// Docker doesn't provide an API to kill an exec run
331
+
// (sure, we could grab the PID and kill it ourselves,
332
+
// but that's wasted effort)
333
+
e.l.Warn("step timed out", "step", step.Name)
334
+
335
+
<-tailDone
336
+
337
+
return engine.ErrTimedOut
338
+
}
339
+
340
+
select {
341
+
case <-ctx.Done():
342
+
return ctx.Err()
343
+
default:
344
+
}
345
+
346
+
execInspectResp, err := e.docker.ContainerExecInspect(ctx, mkExecResp.ID)
347
+
if err != nil {
348
+
return err
349
+
}
350
+
351
+
if execInspectResp.ExitCode != 0 {
352
+
inspectResp, err := e.docker.ContainerInspect(ctx, addl.container)
353
+
if err != nil {
354
+
return err
355
+
}
356
+
357
+
e.l.Error("workflow failed!", "workflow_id", wid.String(), "exit_code", execInspectResp.ExitCode, "oom_killed", inspectResp.State.OOMKilled)
358
+
359
+
if inspectResp.State.OOMKilled {
360
+
return ErrOOMKilled
361
+
}
362
+
return engine.ErrWorkflowFailed
363
+
}
364
+
365
+
return nil
366
+
}
367
+
368
+
func (e *Engine) tailStep(ctx context.Context, wfLogger *models.WorkflowLogger, execID string, wid models.WorkflowId, stepIdx int, step models.Step) error {
369
+
if wfLogger == nil {
370
+
return nil
371
+
}
372
+
373
+
// This actually *starts* the command. Thanks, Docker!
374
+
logs, err := e.docker.ContainerExecAttach(ctx, execID, container.ExecAttachOptions{})
375
+
if err != nil {
376
+
return err
377
+
}
378
+
defer logs.Close()
379
+
380
+
_, err = stdcopy.StdCopy(
381
+
wfLogger.DataWriter("stdout"),
382
+
wfLogger.DataWriter("stderr"),
383
+
logs.Reader,
384
+
)
385
+
if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) {
386
+
return fmt.Errorf("failed to copy logs: %w", err)
387
+
}
388
+
389
+
return nil
390
+
}
391
+
392
+
func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error {
393
+
e.cleanupMu.Lock()
394
+
key := wid.String()
395
+
396
+
fns := e.cleanup[key]
397
+
delete(e.cleanup, key)
398
+
e.cleanupMu.Unlock()
399
+
400
+
for _, fn := range fns {
401
+
if err := fn(ctx); err != nil {
402
+
e.l.Error("failed to cleanup workflow resource", "workflowId", wid, "error", err)
403
+
}
404
+
}
405
+
return nil
406
+
}
407
+
408
+
func (e *Engine) registerCleanup(wid models.WorkflowId, fn cleanupFunc) {
409
+
e.cleanupMu.Lock()
410
+
defer e.cleanupMu.Unlock()
411
+
412
+
key := wid.String()
413
+
e.cleanup[key] = append(e.cleanup[key], fn)
414
+
}
415
+
416
+
func networkName(wid models.WorkflowId) string {
417
+
return fmt.Sprintf("workflow-network-%s", wid)
418
+
}
+28
spindle/engines/nixery/envs.go
+28
spindle/engines/nixery/envs.go
···
1
+
package nixery
2
+
3
+
import (
4
+
"fmt"
5
+
)
6
+
7
+
type EnvVars []string
8
+
9
+
// ConstructEnvs converts a tangled.Pipeline_Step_Environment_Elem.{Key,Value}
10
+
// representation into a docker-friendly []string{"KEY=value", ...} slice.
11
+
func ConstructEnvs(envs map[string]string) EnvVars {
12
+
var dockerEnvs EnvVars
13
+
for k, v := range envs {
14
+
ev := fmt.Sprintf("%s=%s", k, v)
15
+
dockerEnvs = append(dockerEnvs, ev)
16
+
}
17
+
return dockerEnvs
18
+
}
19
+
20
+
// Slice returns the EnvVar as a []string slice.
21
+
func (ev EnvVars) Slice() []string {
22
+
return ev
23
+
}
24
+
25
+
// AddEnv adds a key=value string to the EnvVar.
26
+
func (ev *EnvVars) AddEnv(key, value string) {
27
+
*ev = append(*ev, fmt.Sprintf("%s=%s", key, value))
28
+
}
+48
spindle/engines/nixery/envs_test.go
+48
spindle/engines/nixery/envs_test.go
···
1
+
package nixery
2
+
3
+
import (
4
+
"testing"
5
+
6
+
"github.com/stretchr/testify/assert"
7
+
)
8
+
9
+
func TestConstructEnvs(t *testing.T) {
10
+
tests := []struct {
11
+
name string
12
+
in map[string]string
13
+
want EnvVars
14
+
}{
15
+
{
16
+
name: "empty input",
17
+
in: make(map[string]string),
18
+
want: EnvVars{},
19
+
},
20
+
{
21
+
name: "single env var",
22
+
in: map[string]string{"FOO": "bar"},
23
+
want: EnvVars{"FOO=bar"},
24
+
},
25
+
{
26
+
name: "multiple env vars",
27
+
in: map[string]string{"FOO": "bar", "BAZ": "qux"},
28
+
want: EnvVars{"FOO=bar", "BAZ=qux"},
29
+
},
30
+
}
31
+
for _, tt := range tests {
32
+
t.Run(tt.name, func(t *testing.T) {
33
+
got := ConstructEnvs(tt.in)
34
+
if got == nil {
35
+
got = EnvVars{}
36
+
}
37
+
assert.ElementsMatch(t, tt.want, got)
38
+
})
39
+
}
40
+
}
41
+
42
+
func TestAddEnv(t *testing.T) {
43
+
ev := EnvVars{}
44
+
ev.AddEnv("FOO", "bar")
45
+
ev.AddEnv("BAZ", "qux")
46
+
want := EnvVars{"FOO=bar", "BAZ=qux"}
47
+
assert.ElementsMatch(t, want, ev)
48
+
}
+7
spindle/engines/nixery/errors.go
+7
spindle/engines/nixery/errors.go
+126
spindle/engines/nixery/setup_steps.go
+126
spindle/engines/nixery/setup_steps.go
···
1
+
package nixery
2
+
3
+
import (
4
+
"fmt"
5
+
"path"
6
+
"strings"
7
+
8
+
"tangled.sh/tangled.sh/core/api/tangled"
9
+
"tangled.sh/tangled.sh/core/workflow"
10
+
)
11
+
12
+
func nixConfStep() Step {
13
+
setupCmd := `mkdir -p /etc/nix
14
+
echo 'extra-experimental-features = nix-command flakes' >> /etc/nix/nix.conf
15
+
echo 'build-users-group = ' >> /etc/nix/nix.conf`
16
+
return Step{
17
+
command: setupCmd,
18
+
name: "Configure Nix",
19
+
}
20
+
}
21
+
22
+
// cloneOptsAsSteps processes clone options and adds corresponding steps
23
+
// to the beginning of the workflow's step list if cloning is not skipped.
24
+
//
25
+
// the steps to do here are:
26
+
// - git init
27
+
// - git remote add origin <url>
28
+
// - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha>
29
+
// - git checkout FETCH_HEAD
30
+
func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step {
31
+
if twf.Clone.Skip {
32
+
return Step{}
33
+
}
34
+
35
+
var commands []string
36
+
37
+
// initialize git repo in workspace
38
+
commands = append(commands, "git init")
39
+
40
+
// add repo as git remote
41
+
scheme := "https://"
42
+
if dev {
43
+
scheme = "http://"
44
+
tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal")
45
+
}
46
+
url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo)
47
+
commands = append(commands, fmt.Sprintf("git remote add origin %s", url))
48
+
49
+
// run git fetch
50
+
{
51
+
var fetchArgs []string
52
+
53
+
// default clone depth is 1
54
+
depth := 1
55
+
if twf.Clone.Depth > 1 {
56
+
depth = int(twf.Clone.Depth)
57
+
}
58
+
fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth))
59
+
60
+
// optionally recurse submodules
61
+
if twf.Clone.Submodules {
62
+
fetchArgs = append(fetchArgs, "--recurse-submodules=yes")
63
+
}
64
+
65
+
// set remote to fetch from
66
+
fetchArgs = append(fetchArgs, "origin")
67
+
68
+
// set revision to checkout
69
+
switch workflow.TriggerKind(tr.Kind) {
70
+
case workflow.TriggerKindManual:
71
+
// TODO: unimplemented
72
+
case workflow.TriggerKindPush:
73
+
fetchArgs = append(fetchArgs, tr.Push.NewSha)
74
+
case workflow.TriggerKindPullRequest:
75
+
fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha)
76
+
}
77
+
78
+
commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " ")))
79
+
}
80
+
81
+
// run git checkout
82
+
commands = append(commands, "git checkout FETCH_HEAD")
83
+
84
+
cloneStep := Step{
85
+
command: strings.Join(commands, "\n"),
86
+
name: "Clone repository into workspace",
87
+
}
88
+
return cloneStep
89
+
}
90
+
91
+
// dependencyStep processes dependencies defined in the workflow.
92
+
// For dependencies using a custom registry (i.e. not nixpkgs), it collects
93
+
// all packages and adds a single 'nix profile install' step to the
94
+
// beginning of the workflow's step list.
95
+
func dependencyStep(deps map[string][]string) *Step {
96
+
var customPackages []string
97
+
98
+
for registry, packages := range deps {
99
+
if registry == "nixpkgs" {
100
+
continue
101
+
}
102
+
103
+
if len(packages) == 0 {
104
+
customPackages = append(customPackages, registry)
105
+
}
106
+
// collect packages from custom registries
107
+
for _, pkg := range packages {
108
+
customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg))
109
+
}
110
+
}
111
+
112
+
if len(customPackages) > 0 {
113
+
installCmd := "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install"
114
+
cmd := fmt.Sprintf("%s %s", installCmd, strings.Join(customPackages, " "))
115
+
installStep := Step{
116
+
command: cmd,
117
+
name: "Install custom dependencies",
118
+
environment: map[string]string{
119
+
"NIX_NO_COLOR": "1",
120
+
"NIX_SHOW_DOWNLOAD_PROGRESS": "0",
121
+
},
122
+
}
123
+
return &installStep
124
+
}
125
+
return nil
126
+
}
+175
-9
spindle/ingester.go
+175
-9
spindle/ingester.go
···
3
3
import (
4
4
"context"
5
5
"encoding/json"
6
+
"errors"
6
7
"fmt"
8
+
"time"
7
9
8
10
"tangled.sh/tangled.sh/core/api/tangled"
9
11
"tangled.sh/tangled.sh/core/eventconsumer"
12
+
"tangled.sh/tangled.sh/core/idresolver"
13
+
"tangled.sh/tangled.sh/core/rbac"
14
+
"tangled.sh/tangled.sh/core/spindle/db"
10
15
16
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
17
+
"github.com/bluesky-social/indigo/atproto/identity"
18
+
"github.com/bluesky-social/indigo/atproto/syntax"
19
+
"github.com/bluesky-social/indigo/xrpc"
11
20
"github.com/bluesky-social/jetstream/pkg/models"
21
+
securejoin "github.com/cyphar/filepath-securejoin"
12
22
)
13
23
14
24
type Ingester func(ctx context.Context, e *models.Event) error
···
30
40
31
41
switch e.Commit.Collection {
32
42
case tangled.SpindleMemberNSID:
33
-
s.ingestMember(ctx, e)
43
+
err = s.ingestMember(ctx, e)
34
44
case tangled.RepoNSID:
35
-
s.ingestRepo(ctx, e)
45
+
err = s.ingestRepo(ctx, e)
46
+
case tangled.RepoCollaboratorNSID:
47
+
err = s.ingestCollaborator(ctx, e)
48
+
}
49
+
50
+
if err != nil {
51
+
s.l.Debug("failed to process message", "nsid", e.Commit.Collection, "err", err)
36
52
}
37
53
38
-
return err
54
+
return nil
39
55
}
40
56
}
41
57
42
58
func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error {
43
-
did := e.Did
44
59
var err error
60
+
did := e.Did
61
+
rkey := e.Commit.RKey
45
62
46
63
l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID)
47
64
···
56
73
}
57
74
58
75
domain := s.cfg.Server.Hostname
59
-
if s.cfg.Server.Dev {
60
-
domain = s.cfg.Server.ListenAddr
61
-
}
62
76
recordInstance := record.Instance
63
77
64
78
if recordInstance != domain {
···
72
86
return fmt.Errorf("failed to enforce permissions: %w", err)
73
87
}
74
88
75
-
if err := s.e.AddKnotMember(rbacDomain, record.Subject); err != nil {
89
+
if err := db.AddSpindleMember(s.db, db.SpindleMember{
90
+
Did: syntax.DID(did),
91
+
Rkey: rkey,
92
+
Instance: recordInstance,
93
+
Subject: syntax.DID(record.Subject),
94
+
Created: time.Now(),
95
+
}); err != nil {
96
+
l.Error("failed to add member", "error", err)
97
+
return fmt.Errorf("failed to add member: %w", err)
98
+
}
99
+
100
+
if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil {
76
101
l.Error("failed to add member", "error", err)
77
102
return fmt.Errorf("failed to add member: %w", err)
78
103
}
···
86
111
87
112
return nil
88
113
114
+
case models.CommitOperationDelete:
115
+
record, err := db.GetSpindleMember(s.db, did, rkey)
116
+
if err != nil {
117
+
l.Error("failed to find member", "error", err)
118
+
return fmt.Errorf("failed to find member: %w", err)
119
+
}
120
+
121
+
if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil {
122
+
l.Error("failed to remove member", "error", err)
123
+
return fmt.Errorf("failed to remove member: %w", err)
124
+
}
125
+
126
+
if err := s.e.RemoveSpindleMember(rbacDomain, record.Subject.String()); err != nil {
127
+
l.Error("failed to add member", "error", err)
128
+
return fmt.Errorf("failed to add member: %w", err)
129
+
}
130
+
l.Info("added member from firehose", "member", record.Subject)
131
+
132
+
if err := s.db.RemoveDid(record.Subject.String()); err != nil {
133
+
l.Error("failed to add did", "error", err)
134
+
return fmt.Errorf("failed to add did: %w", err)
135
+
}
136
+
s.jc.RemoveDid(record.Subject.String())
137
+
89
138
}
90
139
return nil
91
140
}
92
141
93
-
func (s *Spindle) ingestRepo(_ context.Context, e *models.Event) error {
142
+
func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error {
94
143
var err error
144
+
did := e.Did
145
+
resolver := idresolver.DefaultResolver()
95
146
96
147
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
97
148
···
127
178
return fmt.Errorf("failed to add repo: %w", err)
128
179
}
129
180
181
+
didSlashRepo, err := securejoin.SecureJoin(record.Owner, record.Name)
182
+
if err != nil {
183
+
return err
184
+
}
185
+
186
+
// add repo to rbac
187
+
if err := s.e.AddRepo(record.Owner, rbac.ThisServer, didSlashRepo); err != nil {
188
+
l.Error("failed to add repo to enforcer", "error", err)
189
+
return fmt.Errorf("failed to add repo: %w", err)
190
+
}
191
+
192
+
// add collaborators to rbac
193
+
owner, err := resolver.ResolveIdent(ctx, did)
194
+
if err != nil || owner.Handle.IsInvalidHandle() {
195
+
return err
196
+
}
197
+
if err := s.fetchAndAddCollaborators(ctx, owner, didSlashRepo); err != nil {
198
+
return err
199
+
}
200
+
130
201
// add this knot to the event consumer
131
202
src := eventconsumer.NewKnotSource(record.Knot)
132
203
s.ks.AddSource(context.Background(), src)
···
136
207
}
137
208
return nil
138
209
}
210
+
211
+
func (s *Spindle) ingestCollaborator(ctx context.Context, e *models.Event) error {
212
+
var err error
213
+
214
+
l := s.l.With("component", "ingester", "record", tangled.RepoCollaboratorNSID, "did", e.Did)
215
+
216
+
l.Info("ingesting collaborator record")
217
+
218
+
switch e.Commit.Operation {
219
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
220
+
raw := e.Commit.Record
221
+
record := tangled.RepoCollaborator{}
222
+
err = json.Unmarshal(raw, &record)
223
+
if err != nil {
224
+
l.Error("invalid record", "error", err)
225
+
return err
226
+
}
227
+
228
+
resolver := idresolver.DefaultResolver()
229
+
230
+
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
231
+
if err != nil || subjectId.Handle.IsInvalidHandle() {
232
+
return err
233
+
}
234
+
235
+
repoAt, err := syntax.ParseATURI(record.Repo)
236
+
if err != nil {
237
+
l.Info("rejecting record, invalid repoAt", "repoAt", record.Repo)
238
+
return nil
239
+
}
240
+
241
+
// TODO: get rid of this entirely
242
+
// resolve this aturi to extract the repo record
243
+
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
244
+
if err != nil || owner.Handle.IsInvalidHandle() {
245
+
return fmt.Errorf("failed to resolve handle: %w", err)
246
+
}
247
+
248
+
xrpcc := xrpc.Client{
249
+
Host: owner.PDSEndpoint(),
250
+
}
251
+
252
+
resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
253
+
if err != nil {
254
+
return err
255
+
}
256
+
257
+
repo := resp.Value.Val.(*tangled.Repo)
258
+
didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name)
259
+
260
+
// check perms for this user
261
+
if ok, err := s.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil {
262
+
return fmt.Errorf("insufficient permissions: %w", err)
263
+
}
264
+
265
+
// add collaborator to rbac
266
+
if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil {
267
+
l.Error("failed to add repo to enforcer", "error", err)
268
+
return fmt.Errorf("failed to add repo: %w", err)
269
+
}
270
+
271
+
return nil
272
+
}
273
+
return nil
274
+
}
275
+
276
+
func (s *Spindle) fetchAndAddCollaborators(ctx context.Context, owner *identity.Identity, didSlashRepo string) error {
277
+
l := s.l.With("component", "ingester", "handler", "fetchAndAddCollaborators")
278
+
279
+
l.Info("fetching and adding existing collaborators")
280
+
281
+
xrpcc := xrpc.Client{
282
+
Host: owner.PDSEndpoint(),
283
+
}
284
+
285
+
resp, err := comatproto.RepoListRecords(ctx, &xrpcc, tangled.RepoCollaboratorNSID, "", 50, owner.DID.String(), false)
286
+
if err != nil {
287
+
return err
288
+
}
289
+
290
+
var errs error
291
+
for _, r := range resp.Records {
292
+
if r == nil {
293
+
continue
294
+
}
295
+
record := r.Value.Val.(*tangled.RepoCollaborator)
296
+
297
+
if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil {
298
+
l.Error("failed to add repo to enforcer", "error", err)
299
+
errors.Join(errs, fmt.Errorf("failed to add repo: %w", err))
300
+
}
301
+
}
302
+
303
+
return errs
304
+
}
+17
spindle/models/engine.go
+17
spindle/models/engine.go
···
1
+
package models
2
+
3
+
import (
4
+
"context"
5
+
"time"
6
+
7
+
"tangled.sh/tangled.sh/core/api/tangled"
8
+
"tangled.sh/tangled.sh/core/spindle/secrets"
9
+
)
10
+
11
+
type Engine interface {
12
+
InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*Workflow, error)
13
+
SetupWorkflow(ctx context.Context, wid WorkflowId, wf *Workflow) error
14
+
WorkflowTimeout() time.Duration
15
+
DestroyWorkflow(ctx context.Context, wid WorkflowId) error
16
+
RunStep(ctx context.Context, wid WorkflowId, w *Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *WorkflowLogger) error
17
+
}
+82
spindle/models/logger.go
+82
spindle/models/logger.go
···
1
+
package models
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"io"
7
+
"os"
8
+
"path/filepath"
9
+
"strings"
10
+
)
11
+
12
+
type WorkflowLogger struct {
13
+
file *os.File
14
+
encoder *json.Encoder
15
+
}
16
+
17
+
func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) {
18
+
path := LogFilePath(baseDir, wid)
19
+
20
+
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
21
+
if err != nil {
22
+
return nil, fmt.Errorf("creating log file: %w", err)
23
+
}
24
+
25
+
return &WorkflowLogger{
26
+
file: file,
27
+
encoder: json.NewEncoder(file),
28
+
}, nil
29
+
}
30
+
31
+
func LogFilePath(baseDir string, workflowID WorkflowId) string {
32
+
logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String()))
33
+
return logFilePath
34
+
}
35
+
36
+
func (l *WorkflowLogger) Close() error {
37
+
return l.file.Close()
38
+
}
39
+
40
+
func (l *WorkflowLogger) DataWriter(stream string) io.Writer {
41
+
// TODO: emit stream
42
+
return &dataWriter{
43
+
logger: l,
44
+
stream: stream,
45
+
}
46
+
}
47
+
48
+
func (l *WorkflowLogger) ControlWriter(idx int, step Step) io.Writer {
49
+
return &controlWriter{
50
+
logger: l,
51
+
idx: idx,
52
+
step: step,
53
+
}
54
+
}
55
+
56
+
type dataWriter struct {
57
+
logger *WorkflowLogger
58
+
stream string
59
+
}
60
+
61
+
func (w *dataWriter) Write(p []byte) (int, error) {
62
+
line := strings.TrimRight(string(p), "\r\n")
63
+
entry := NewDataLogLine(line, w.stream)
64
+
if err := w.logger.encoder.Encode(entry); err != nil {
65
+
return 0, err
66
+
}
67
+
return len(p), nil
68
+
}
69
+
70
+
type controlWriter struct {
71
+
logger *WorkflowLogger
72
+
idx int
73
+
step Step
74
+
}
75
+
76
+
func (w *controlWriter) Write(_ []byte) (int, error) {
77
+
entry := NewControlLogLine(w.idx, w.step)
78
+
if err := w.logger.encoder.Encode(entry); err != nil {
79
+
return 0, err
80
+
}
81
+
return len(w.step.Name()), nil
82
+
}
+3
-3
spindle/models/models.go
+3
-3
spindle/models/models.go
···
104
104
func NewControlLogLine(idx int, step Step) LogLine {
105
105
return LogLine{
106
106
Kind: LogKindControl,
107
-
Content: step.Name,
107
+
Content: step.Name(),
108
108
StepId: idx,
109
-
StepKind: step.Kind,
110
-
StepCommand: step.Command,
109
+
StepKind: step.Kind(),
110
+
StepCommand: step.Command(),
111
111
}
112
112
}
+10
-108
spindle/models/pipeline.go
+10
-108
spindle/models/pipeline.go
···
1
1
package models
2
2
3
-
import (
4
-
"path"
5
-
6
-
"tangled.sh/tangled.sh/core/api/tangled"
7
-
"tangled.sh/tangled.sh/core/spindle/config"
8
-
)
9
-
10
3
type Pipeline struct {
11
-
Workflows []Workflow
4
+
RepoOwner string
5
+
RepoName string
6
+
Workflows map[Engine][]Workflow
12
7
}
13
8
14
-
type Step struct {
15
-
Command string
16
-
Name string
17
-
Environment map[string]string
18
-
Kind StepKind
9
+
type Step interface {
10
+
Name() string
11
+
Command() string
12
+
Kind() StepKind
19
13
}
20
14
21
15
type StepKind int
···
28
22
)
29
23
30
24
type Workflow struct {
31
-
Steps []Step
32
-
Environment map[string]string
33
-
Name string
34
-
Image string
35
-
}
36
-
37
-
// setupSteps get added to start of Steps
38
-
type setupSteps []Step
39
-
40
-
// addStep adds a step to the beginning of the workflow's steps.
41
-
func (ss *setupSteps) addStep(step Step) {
42
-
*ss = append(*ss, step)
43
-
}
44
-
45
-
// ToPipeline converts a tangled.Pipeline into a model.Pipeline.
46
-
// In the process, dependencies are resolved: nixpkgs deps
47
-
// are constructed atop nixery and set as the Workflow.Image,
48
-
// and ones from custom registries
49
-
func ToPipeline(pl tangled.Pipeline, cfg config.Config) *Pipeline {
50
-
workflows := []Workflow{}
51
-
52
-
for _, twf := range pl.Workflows {
53
-
swf := &Workflow{}
54
-
for _, tstep := range twf.Steps {
55
-
sstep := Step{}
56
-
sstep.Environment = stepEnvToMap(tstep.Environment)
57
-
sstep.Command = tstep.Command
58
-
sstep.Name = tstep.Name
59
-
sstep.Kind = StepKindUser
60
-
swf.Steps = append(swf.Steps, sstep)
61
-
}
62
-
swf.Name = twf.Name
63
-
swf.Environment = workflowEnvToMap(twf.Environment)
64
-
swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery)
65
-
66
-
swf.addNixProfileToPath()
67
-
swf.setGlobalEnvs()
68
-
setup := &setupSteps{}
69
-
70
-
setup.addStep(nixConfStep())
71
-
setup.addStep(cloneStep(*twf, *pl.TriggerMetadata, cfg.Server.Dev))
72
-
// this step could be empty
73
-
if s := dependencyStep(*twf); s != nil {
74
-
setup.addStep(*s)
75
-
}
76
-
77
-
// append setup steps in order to the start of workflow steps
78
-
swf.Steps = append(*setup, swf.Steps...)
79
-
80
-
workflows = append(workflows, *swf)
81
-
}
82
-
return &Pipeline{Workflows: workflows}
83
-
}
84
-
85
-
func workflowEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string {
86
-
envMap := map[string]string{}
87
-
for _, env := range envs {
88
-
if env != nil {
89
-
envMap[env.Key] = env.Value
90
-
}
91
-
}
92
-
return envMap
93
-
}
94
-
95
-
func stepEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string {
96
-
envMap := map[string]string{}
97
-
for _, env := range envs {
98
-
if env != nil {
99
-
envMap[env.Key] = env.Value
100
-
}
101
-
}
102
-
return envMap
103
-
}
104
-
105
-
func workflowImage(deps []*tangled.Pipeline_Dependency, nixery string) string {
106
-
var dependencies string
107
-
for _, d := range deps {
108
-
if d.Registry == "nixpkgs" {
109
-
dependencies = path.Join(d.Packages...)
110
-
}
111
-
}
112
-
113
-
// load defaults from somewhere else
114
-
dependencies = path.Join(dependencies, "bash", "git", "coreutils", "nix")
115
-
116
-
return path.Join(nixery, dependencies)
117
-
}
118
-
119
-
func (wf *Workflow) addNixProfileToPath() {
120
-
wf.Environment["PATH"] = "$PATH:/.nix-profile/bin"
121
-
}
122
-
123
-
func (wf *Workflow) setGlobalEnvs() {
124
-
wf.Environment["NIX_CONFIG"] = "experimental-features = nix-command flakes"
125
-
wf.Environment["HOME"] = "/tangled/workspace"
25
+
Steps []Step
26
+
Name string
27
+
Data any
126
28
}
-125
spindle/models/setup_steps.go
-125
spindle/models/setup_steps.go
···
1
-
package models
2
-
3
-
import (
4
-
"fmt"
5
-
"path"
6
-
"strings"
7
-
8
-
"tangled.sh/tangled.sh/core/api/tangled"
9
-
"tangled.sh/tangled.sh/core/workflow"
10
-
)
11
-
12
-
func nixConfStep() Step {
13
-
setupCmd := `echo 'extra-experimental-features = nix-command flakes' >> /etc/nix/nix.conf
14
-
echo 'build-users-group = ' >> /etc/nix/nix.conf`
15
-
return Step{
16
-
Command: setupCmd,
17
-
Name: "Configure Nix",
18
-
}
19
-
}
20
-
21
-
// cloneOptsAsSteps processes clone options and adds corresponding steps
22
-
// to the beginning of the workflow's step list if cloning is not skipped.
23
-
//
24
-
// the steps to do here are:
25
-
// - git init
26
-
// - git remote add origin <url>
27
-
// - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha>
28
-
// - git checkout FETCH_HEAD
29
-
func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step {
30
-
if twf.Clone.Skip {
31
-
return Step{}
32
-
}
33
-
34
-
var commands []string
35
-
36
-
// initialize git repo in workspace
37
-
commands = append(commands, "git init")
38
-
39
-
// add repo as git remote
40
-
scheme := "https://"
41
-
if dev {
42
-
scheme = "http://"
43
-
tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal")
44
-
}
45
-
url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo)
46
-
commands = append(commands, fmt.Sprintf("git remote add origin %s", url))
47
-
48
-
// run git fetch
49
-
{
50
-
var fetchArgs []string
51
-
52
-
// default clone depth is 1
53
-
depth := 1
54
-
if twf.Clone.Depth > 1 {
55
-
depth = int(twf.Clone.Depth)
56
-
}
57
-
fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth))
58
-
59
-
// optionally recurse submodules
60
-
if twf.Clone.Submodules {
61
-
fetchArgs = append(fetchArgs, "--recurse-submodules=yes")
62
-
}
63
-
64
-
// set remote to fetch from
65
-
fetchArgs = append(fetchArgs, "origin")
66
-
67
-
// set revision to checkout
68
-
switch workflow.TriggerKind(tr.Kind) {
69
-
case workflow.TriggerKindManual:
70
-
// TODO: unimplemented
71
-
case workflow.TriggerKindPush:
72
-
fetchArgs = append(fetchArgs, tr.Push.NewSha)
73
-
case workflow.TriggerKindPullRequest:
74
-
fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha)
75
-
}
76
-
77
-
commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " ")))
78
-
}
79
-
80
-
// run git checkout
81
-
commands = append(commands, "git checkout FETCH_HEAD")
82
-
83
-
cloneStep := Step{
84
-
Command: strings.Join(commands, "\n"),
85
-
Name: "Clone repository into workspace",
86
-
}
87
-
return cloneStep
88
-
}
89
-
90
-
// dependencyStep processes dependencies defined in the workflow.
91
-
// For dependencies using a custom registry (i.e. not nixpkgs), it collects
92
-
// all packages and adds a single 'nix profile install' step to the
93
-
// beginning of the workflow's step list.
94
-
func dependencyStep(twf tangled.Pipeline_Workflow) *Step {
95
-
var customPackages []string
96
-
97
-
for _, d := range twf.Dependencies {
98
-
registry := d.Registry
99
-
packages := d.Packages
100
-
101
-
if registry == "nixpkgs" {
102
-
continue
103
-
}
104
-
105
-
// collect packages from custom registries
106
-
for _, pkg := range packages {
107
-
customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg))
108
-
}
109
-
}
110
-
111
-
if len(customPackages) > 0 {
112
-
installCmd := "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install"
113
-
cmd := fmt.Sprintf("%s %s", installCmd, strings.Join(customPackages, " "))
114
-
installStep := Step{
115
-
Command: cmd,
116
-
Name: "Install custom dependencies",
117
-
Environment: map[string]string{
118
-
"NIX_NO_COLOR": "1",
119
-
"NIX_SHOW_DOWNLOAD_PROGRESS": "0",
120
-
},
121
-
}
122
-
return &installStep
123
-
}
124
-
return nil
125
-
}
+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).DeleteMetadata(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+"?_foreign_keys=1")
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
+
}
+129
-47
spindle/server.go
+129
-47
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"
···
18
20
"tangled.sh/tangled.sh/core/spindle/config"
19
21
"tangled.sh/tangled.sh/core/spindle/db"
20
22
"tangled.sh/tangled.sh/core/spindle/engine"
23
+
"tangled.sh/tangled.sh/core/spindle/engines/nixery"
21
24
"tangled.sh/tangled.sh/core/spindle/models"
22
25
"tangled.sh/tangled.sh/core/spindle/queue"
26
+
"tangled.sh/tangled.sh/core/spindle/secrets"
27
+
"tangled.sh/tangled.sh/core/spindle/xrpc"
23
28
)
24
29
30
+
//go:embed motd
31
+
var motd []byte
32
+
25
33
const (
26
34
rbacDomain = "thisserver"
27
35
)
28
36
29
37
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
38
+
jc *jetstream.JetstreamClient
39
+
db *db.DB
40
+
e *rbac.Enforcer
41
+
l *slog.Logger
42
+
n *notifier.Notifier
43
+
engs map[string]models.Engine
44
+
jq *queue.Queue
45
+
cfg *config.Config
46
+
ks *eventconsumer.Consumer
47
+
res *idresolver.Resolver
48
+
vault secrets.Manager
39
49
}
40
50
41
51
func Run(ctx context.Context) error {
···
59
69
60
70
n := notifier.New()
61
71
62
-
eng, err := engine.New(ctx, cfg, d, &n)
72
+
var vault secrets.Manager
73
+
switch cfg.Server.Secrets.Provider {
74
+
case "openbao":
75
+
if cfg.Server.Secrets.OpenBao.ProxyAddr == "" {
76
+
return fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
77
+
}
78
+
vault, err = secrets.NewOpenBaoManager(
79
+
cfg.Server.Secrets.OpenBao.ProxyAddr,
80
+
logger,
81
+
secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount),
82
+
)
83
+
if err != nil {
84
+
return fmt.Errorf("failed to setup openbao secrets provider: %w", err)
85
+
}
86
+
logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount)
87
+
case "sqlite", "":
88
+
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
89
+
if err != nil {
90
+
return fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
91
+
}
92
+
logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath)
93
+
default:
94
+
return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
95
+
}
96
+
97
+
nixeryEng, err := nixery.New(ctx, cfg)
63
98
if err != nil {
64
99
return err
65
100
}
66
101
67
-
jq := queue.NewQueue(100, 2)
102
+
jq := queue.NewQueue(100, 5)
68
103
69
104
collections := []string{
70
105
tangled.SpindleMemberNSID,
71
106
tangled.RepoNSID,
107
+
tangled.RepoCollaboratorNSID,
72
108
}
73
109
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true)
74
110
if err != nil {
···
76
112
}
77
113
jc.AddDid(cfg.Server.Owner)
78
114
115
+
// Check if the spindle knows about any Dids;
116
+
dids, err := d.GetAllDids()
117
+
if err != nil {
118
+
return fmt.Errorf("failed to get all dids: %w", err)
119
+
}
120
+
for _, d := range dids {
121
+
jc.AddDid(d)
122
+
}
123
+
124
+
resolver := idresolver.DefaultResolver()
125
+
79
126
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,
127
+
jc: jc,
128
+
e: e,
129
+
db: d,
130
+
l: logger,
131
+
n: &n,
132
+
engs: map[string]models.Engine{"nixery": nixeryEng},
133
+
jq: jq,
134
+
cfg: cfg,
135
+
res: resolver,
136
+
vault: vault,
88
137
}
89
138
90
139
err = e.AddSpindle(rbacDomain)
···
101
150
jq.Start()
102
151
defer jq.Stop()
103
152
153
+
// Stop vault token renewal if it implements Stopper
154
+
if stopper, ok := vault.(secrets.Stopper); ok {
155
+
defer stopper.Stop()
156
+
}
157
+
104
158
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
105
159
if err != nil {
106
160
return fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
···
144
198
mux := chi.NewRouter()
145
199
146
200
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`))
201
+
w.Write(motd)
171
202
})
172
203
mux.HandleFunc("/events", s.Events)
173
204
mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) {
174
205
w.Write([]byte(s.cfg.Server.Owner))
175
206
})
176
207
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
208
+
209
+
mux.Mount("/xrpc", s.XrpcRouter())
177
210
return mux
178
211
}
179
212
213
+
func (s *Spindle) XrpcRouter() http.Handler {
214
+
logger := s.l.With("route", "xrpc")
215
+
216
+
x := xrpc.Xrpc{
217
+
Logger: logger,
218
+
Db: s.db,
219
+
Enforcer: s.e,
220
+
Engines: s.engs,
221
+
Config: s.cfg,
222
+
Resolver: s.res,
223
+
Vault: s.vault,
224
+
}
225
+
226
+
return x.Router()
227
+
}
228
+
180
229
func (s *Spindle) processPipeline(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error {
181
230
if msg.Nsid == tangled.PipelineNSID {
182
231
tpl := tangled.Pipeline{}
···
194
243
return fmt.Errorf("no repo data found")
195
244
}
196
245
246
+
if src.Key() != tpl.TriggerMetadata.Repo.Knot {
247
+
return fmt.Errorf("repo knot does not match event source: %s != %s", src.Key(), tpl.TriggerMetadata.Repo.Knot)
248
+
}
249
+
197
250
// filter by repos
198
251
_, err = s.db.GetRepo(
199
252
tpl.TriggerMetadata.Repo.Knot,
···
209
262
Rkey: msg.Rkey,
210
263
}
211
264
265
+
workflows := make(map[models.Engine][]models.Workflow)
266
+
212
267
for _, w := range tpl.Workflows {
213
268
if w != nil {
214
-
err := s.db.StatusPending(models.WorkflowId{
269
+
if _, ok := s.engs[w.Engine]; !ok {
270
+
err = s.db.StatusFailed(models.WorkflowId{
271
+
PipelineId: pipelineId,
272
+
Name: w.Name,
273
+
}, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n)
274
+
if err != nil {
275
+
return err
276
+
}
277
+
278
+
continue
279
+
}
280
+
281
+
eng := s.engs[w.Engine]
282
+
283
+
if _, ok := workflows[eng]; !ok {
284
+
workflows[eng] = []models.Workflow{}
285
+
}
286
+
287
+
ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl)
288
+
if err != nil {
289
+
return err
290
+
}
291
+
292
+
workflows[eng] = append(workflows[eng], *ewf)
293
+
294
+
err = s.db.StatusPending(models.WorkflowId{
215
295
PipelineId: pipelineId,
216
296
Name: w.Name,
217
297
}, s.n)
···
221
301
}
222
302
}
223
303
224
-
spl := models.ToPipeline(tpl, *s.cfg)
225
-
226
304
ok := s.jq.Enqueue(queue.Job{
227
305
Run: func() error {
228
-
s.eng.StartWorkflows(ctx, spl, pipelineId)
306
+
engine.StartWorkflows(s.l, s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{
307
+
RepoOwner: tpl.TriggerMetadata.Repo.Did,
308
+
RepoName: tpl.TriggerMetadata.Repo.Repo,
309
+
Workflows: workflows,
310
+
}, pipelineId)
229
311
return nil
230
312
},
231
313
OnFail: func(jobError error) {
+32
-2
spindle/stream.go
+32
-2
spindle/stream.go
···
6
6
"fmt"
7
7
"io"
8
8
"net/http"
9
+
"os"
9
10
"strconv"
10
11
"time"
11
12
12
-
"tangled.sh/tangled.sh/core/spindle/engine"
13
13
"tangled.sh/tangled.sh/core/spindle/models"
14
14
15
15
"github.com/go-chi/chi/v5"
···
143
143
}
144
144
isFinished := models.StatusKind(status.Status).IsFinish()
145
145
146
-
filePath := engine.LogFilePath(s.cfg.Pipelines.LogDir, wid)
146
+
filePath := models.LogFilePath(s.cfg.Server.LogDir, wid)
147
+
148
+
if status.Status == models.StatusKindFailed.String() && status.Error != nil {
149
+
if _, err := os.Stat(filePath); os.IsNotExist(err) {
150
+
msgs := []models.LogLine{
151
+
{
152
+
Kind: models.LogKindControl,
153
+
Content: "",
154
+
StepId: 0,
155
+
StepKind: models.StepKindUser,
156
+
},
157
+
{
158
+
Kind: models.LogKindData,
159
+
Content: *status.Error,
160
+
},
161
+
}
162
+
163
+
for _, msg := range msgs {
164
+
b, err := json.Marshal(msg)
165
+
if err != nil {
166
+
return err
167
+
}
168
+
169
+
if err := conn.WriteMessage(websocket.TextMessage, b); err != nil {
170
+
return fmt.Errorf("failed to write to websocket: %w", err)
171
+
}
172
+
}
173
+
174
+
return nil
175
+
}
176
+
}
147
177
148
178
config := tail.Config{
149
179
Follow: !isFinished,
+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/models"
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
+
Engines map[string]models.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
+
}
+1
-3
tailwind.config.js
+1
-3
tailwind.config.js
···
36
36
css: {
37
37
maxWidth: "none",
38
38
pre: {
39
-
backgroundColor: colors.gray[100],
40
-
color: colors.black,
41
-
"@apply font-normal text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 dark:border": {},
39
+
"@apply font-normal text-black bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 border": {},
42
40
},
43
41
code: {
44
42
"@apply font-normal font-mono p-1 rounded text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700": {},
+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
+
}
+62
-41
workflow/compile.go
+62
-41
workflow/compile.go
···
1
1
package workflow
2
2
3
3
import (
4
+
"errors"
4
5
"fmt"
5
6
6
7
"tangled.sh/tangled.sh/core/api/tangled"
7
8
)
8
9
10
+
type RawWorkflow struct {
11
+
Name string
12
+
Contents []byte
13
+
}
14
+
15
+
type RawPipeline = []RawWorkflow
16
+
9
17
type Compiler struct {
10
18
Trigger tangled.Pipeline_TriggerMetadata
11
19
Diagnostics Diagnostics
12
20
}
13
21
14
22
type Diagnostics struct {
15
-
Errors []error
23
+
Errors []Error
16
24
Warnings []Warning
17
25
}
18
26
27
+
func (d *Diagnostics) IsEmpty() bool {
28
+
return len(d.Errors) == 0 && len(d.Warnings) == 0
29
+
}
30
+
19
31
func (d *Diagnostics) Combine(o Diagnostics) {
20
32
d.Errors = append(d.Errors, o.Errors...)
21
33
d.Warnings = append(d.Warnings, o.Warnings...)
···
25
37
d.Warnings = append(d.Warnings, Warning{path, kind, reason})
26
38
}
27
39
28
-
func (d *Diagnostics) AddError(err error) {
29
-
d.Errors = append(d.Errors, err)
40
+
func (d *Diagnostics) AddError(path string, err error) {
41
+
d.Errors = append(d.Errors, Error{path, err})
30
42
}
31
43
32
44
func (d Diagnostics) IsErr() bool {
33
45
return len(d.Errors) != 0
34
46
}
35
47
48
+
type Error struct {
49
+
Path string
50
+
Error error
51
+
}
52
+
53
+
func (e Error) String() string {
54
+
return fmt.Sprintf("error: %s: %s", e.Path, e.Error.Error())
55
+
}
56
+
36
57
type Warning struct {
37
58
Path string
38
59
Type WarningKind
39
60
Reason string
40
61
}
41
62
63
+
func (w Warning) String() string {
64
+
return fmt.Sprintf("warning: %s: %s: %s", w.Path, w.Type, w.Reason)
65
+
}
66
+
67
+
var (
68
+
MissingEngine error = errors.New("missing engine")
69
+
)
70
+
42
71
type WarningKind string
43
72
44
73
var (
···
46
75
InvalidConfiguration WarningKind = "invalid configuration"
47
76
)
48
77
78
+
func (compiler *Compiler) Parse(p RawPipeline) Pipeline {
79
+
var pp Pipeline
80
+
81
+
for _, w := range p {
82
+
wf, err := FromFile(w.Name, w.Contents)
83
+
if err != nil {
84
+
compiler.Diagnostics.AddError(w.Name, err)
85
+
continue
86
+
}
87
+
88
+
pp = append(pp, wf)
89
+
}
90
+
91
+
return pp
92
+
}
93
+
49
94
// convert a repositories' workflow files into a fully compiled pipeline that runners accept
50
95
func (compiler *Compiler) Compile(p Pipeline) tangled.Pipeline {
51
96
cp := tangled.Pipeline{
52
97
TriggerMetadata: &compiler.Trigger,
53
98
}
54
99
55
-
for _, w := range p {
56
-
cw := compiler.compileWorkflow(w)
100
+
for _, wf := range p {
101
+
cw := compiler.compileWorkflow(wf)
57
102
58
-
// empty workflows are not added to the pipeline
59
-
if len(cw.Steps) == 0 {
103
+
if cw == nil {
60
104
continue
61
105
}
62
106
63
-
cp.Workflows = append(cp.Workflows, &cw)
107
+
cp.Workflows = append(cp.Workflows, cw)
64
108
}
65
109
66
110
return cp
67
111
}
68
112
69
-
func (compiler *Compiler) compileWorkflow(w Workflow) tangled.Pipeline_Workflow {
70
-
cw := tangled.Pipeline_Workflow{}
113
+
func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow {
114
+
cw := &tangled.Pipeline_Workflow{}
71
115
72
116
if !w.Match(compiler.Trigger) {
73
117
compiler.Diagnostics.AddWarning(
···
75
119
WorkflowSkipped,
76
120
fmt.Sprintf("did not match trigger %s", compiler.Trigger.Kind),
77
121
)
78
-
return cw
79
-
}
80
-
81
-
if len(w.Steps) == 0 {
82
-
compiler.Diagnostics.AddWarning(
83
-
w.Name,
84
-
WorkflowSkipped,
85
-
"empty workflow",
86
-
)
87
-
return cw
122
+
return nil
88
123
}
89
124
90
125
// validate clone options
91
126
compiler.analyzeCloneOptions(w)
92
127
93
128
cw.Name = w.Name
94
-
cw.Dependencies = w.Dependencies.AsRecord()
95
-
for _, s := range w.Steps {
96
-
step := tangled.Pipeline_Step{
97
-
Command: s.Command,
98
-
Name: s.Name,
99
-
}
100
-
for k, v := range s.Environment {
101
-
e := &tangled.Pipeline_Pair{
102
-
Key: k,
103
-
Value: v,
104
-
}
105
-
step.Environment = append(step.Environment, e)
106
-
}
107
-
cw.Steps = append(cw.Steps, &step)
129
+
130
+
if w.Engine == "" {
131
+
compiler.Diagnostics.AddError(w.Name, MissingEngine)
132
+
return nil
108
133
}
109
-
for k, v := range w.Environment {
110
-
e := &tangled.Pipeline_Pair{
111
-
Key: k,
112
-
Value: v,
113
-
}
114
-
cw.Environment = append(cw.Environment, e)
115
-
}
134
+
135
+
cw.Engine = w.Engine
136
+
cw.Raw = w.Raw
116
137
117
138
o := w.CloneOpts.AsRecord()
118
139
cw.Clone = &o
+23
-29
workflow/compile_test.go
+23
-29
workflow/compile_test.go
···
26
26
27
27
func TestCompileWorkflow_MatchingWorkflowWithSteps(t *testing.T) {
28
28
wf := Workflow{
29
-
Name: ".tangled/workflows/test.yml",
30
-
When: when,
31
-
Steps: []Step{
32
-
{Name: "Test", Command: "go test ./..."},
33
-
},
29
+
Name: ".tangled/workflows/test.yml",
30
+
Engine: "nixery",
31
+
When: when,
34
32
CloneOpts: CloneOpts{}, // default true
35
33
}
36
34
···
43
41
assert.False(t, c.Diagnostics.IsErr())
44
42
}
45
43
46
-
func TestCompileWorkflow_EmptySteps(t *testing.T) {
47
-
wf := Workflow{
48
-
Name: ".tangled/workflows/empty.yml",
49
-
When: when,
50
-
Steps: []Step{}, // no steps
51
-
}
52
-
53
-
c := Compiler{Trigger: trigger}
54
-
cp := c.Compile([]Workflow{wf})
55
-
56
-
assert.Len(t, cp.Workflows, 0)
57
-
assert.Len(t, c.Diagnostics.Warnings, 1)
58
-
assert.Equal(t, WorkflowSkipped, c.Diagnostics.Warnings[0].Type)
59
-
}
60
-
61
44
func TestCompileWorkflow_TriggerMismatch(t *testing.T) {
62
45
wf := Workflow{
63
-
Name: ".tangled/workflows/mismatch.yml",
46
+
Name: ".tangled/workflows/mismatch.yml",
47
+
Engine: "nixery",
64
48
When: []Constraint{
65
49
{
66
50
Event: []string{"push"},
67
51
Branch: []string{"master"}, // different branch
68
52
},
69
53
},
70
-
Steps: []Step{
71
-
{Name: "Lint", Command: "golint ./..."},
72
-
},
73
54
}
74
55
75
56
c := Compiler{Trigger: trigger}
···
82
63
83
64
func TestCompileWorkflow_CloneFalseWithShallowTrue(t *testing.T) {
84
65
wf := Workflow{
85
-
Name: ".tangled/workflows/clone_skip.yml",
86
-
When: when,
87
-
Steps: []Step{
88
-
{Name: "Skip", Command: "echo skip"},
89
-
},
66
+
Name: ".tangled/workflows/clone_skip.yml",
67
+
Engine: "nixery",
68
+
When: when,
90
69
CloneOpts: CloneOpts{
91
70
Skip: true,
92
71
Depth: 1,
···
101
80
assert.Len(t, c.Diagnostics.Warnings, 1)
102
81
assert.Equal(t, InvalidConfiguration, c.Diagnostics.Warnings[0].Type)
103
82
}
83
+
84
+
func TestCompileWorkflow_MissingEngine(t *testing.T) {
85
+
wf := Workflow{
86
+
Name: ".tangled/workflows/missing_engine.yml",
87
+
When: when,
88
+
Engine: "",
89
+
}
90
+
91
+
c := Compiler{Trigger: trigger}
92
+
cp := c.Compile([]Workflow{wf})
93
+
94
+
assert.Len(t, cp.Workflows, 0)
95
+
assert.Len(t, c.Diagnostics.Errors, 1)
96
+
assert.Equal(t, MissingEngine, c.Diagnostics.Errors[0].Error)
97
+
}
+6
-33
workflow/def.go
+6
-33
workflow/def.go
···
24
24
25
25
// this is simply a structural representation of the workflow file
26
26
Workflow struct {
27
-
Name string `yaml:"-"` // name of the workflow file
28
-
When []Constraint `yaml:"when"`
29
-
Dependencies Dependencies `yaml:"dependencies"`
30
-
Steps []Step `yaml:"steps"`
31
-
Environment map[string]string `yaml:"environment"`
32
-
CloneOpts CloneOpts `yaml:"clone"`
27
+
Name string `yaml:"-"` // name of the workflow file
28
+
Engine string `yaml:"engine"`
29
+
When []Constraint `yaml:"when"`
30
+
CloneOpts CloneOpts `yaml:"clone"`
31
+
Raw string `yaml:"-"`
33
32
}
34
33
35
34
Constraint struct {
36
35
Event StringList `yaml:"event"`
37
36
Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events
38
37
}
39
-
40
-
Dependencies map[string][]string
41
38
42
39
CloneOpts struct {
43
40
Skip bool `yaml:"skip"`
44
41
Depth int `yaml:"depth"`
45
42
IncludeSubmodules bool `yaml:"submodules"`
46
-
}
47
-
48
-
Step struct {
49
-
Name string `yaml:"name"`
50
-
Command string `yaml:"command"`
51
-
Environment map[string]string `yaml:"environment"`
52
43
}
53
44
54
45
StringList []string
···
77
68
}
78
69
79
70
wf.Name = name
71
+
wf.Raw = string(contents)
80
72
81
73
return wf, nil
82
74
}
···
173
165
}
174
166
175
167
return errors.New("failed to unmarshal StringOrSlice")
176
-
}
177
-
178
-
// conversion utilities to atproto records
179
-
func (d Dependencies) AsRecord() []*tangled.Pipeline_Dependency {
180
-
var deps []*tangled.Pipeline_Dependency
181
-
for registry, packages := range d {
182
-
deps = append(deps, &tangled.Pipeline_Dependency{
183
-
Registry: registry,
184
-
Packages: packages,
185
-
})
186
-
}
187
-
return deps
188
-
}
189
-
190
-
func (s Step) AsRecord() tangled.Pipeline_Step {
191
-
return tangled.Pipeline_Step{
192
-
Command: s.Command,
193
-
Name: s.Name,
194
-
}
195
168
}
196
169
197
170
func (c CloneOpts) AsRecord() tangled.Pipeline_CloneOpts {
+1
-86
workflow/def_test.go
+1
-86
workflow/def_test.go
···
10
10
yamlData := `
11
11
when:
12
12
- event: ["push", "pull_request"]
13
-
branch: ["main", "develop"]
14
-
15
-
dependencies:
16
-
nixpkgs:
17
-
- go
18
-
- git
19
-
- curl
20
-
21
-
steps:
22
-
- name: "Test"
23
-
command: |
24
-
go test ./...`
13
+
branch: ["main", "develop"]`
25
14
26
15
wf, err := FromFile("test.yml", []byte(yamlData))
27
16
assert.NoError(t, err, "YAML should unmarshal without error")
···
30
19
assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch)
31
20
assert.ElementsMatch(t, []string{"push", "pull_request"}, wf.When[0].Event)
32
21
33
-
assert.Len(t, wf.Steps, 1)
34
-
assert.Equal(t, "Test", wf.Steps[0].Name)
35
-
assert.Equal(t, "go test ./...", wf.Steps[0].Command)
36
-
37
-
pkgs, ok := wf.Dependencies["nixpkgs"]
38
-
assert.True(t, ok, "`nixpkgs` should be present in dependencies")
39
-
assert.ElementsMatch(t, []string{"go", "git", "curl"}, pkgs)
40
-
41
22
assert.False(t, wf.CloneOpts.Skip, "Skip should default to false")
42
23
}
43
24
44
-
func TestUnmarshalCustomRegistry(t *testing.T) {
45
-
yamlData := `
46
-
when:
47
-
- event: push
48
-
branch: main
49
-
50
-
dependencies:
51
-
git+https://tangled.sh/@oppi.li/tbsp:
52
-
- tbsp
53
-
git+https://git.peppe.rs/languages/statix:
54
-
- statix
55
-
56
-
steps:
57
-
- name: "Check"
58
-
command: |
59
-
statix check`
60
-
61
-
wf, err := FromFile("test.yml", []byte(yamlData))
62
-
assert.NoError(t, err, "YAML should unmarshal without error")
63
-
64
-
assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event)
65
-
assert.ElementsMatch(t, []string{"main"}, wf.When[0].Branch)
66
-
67
-
assert.ElementsMatch(t, []string{"tbsp"}, wf.Dependencies["git+https://tangled.sh/@oppi.li/tbsp"])
68
-
assert.ElementsMatch(t, []string{"statix"}, wf.Dependencies["git+https://git.peppe.rs/languages/statix"])
69
-
}
70
-
71
25
func TestUnmarshalCloneFalse(t *testing.T) {
72
26
yamlData := `
73
27
when:
···
75
29
76
30
clone:
77
31
skip: true
78
-
79
-
dependencies:
80
-
nixpkgs:
81
-
- python3
82
-
83
-
steps:
84
-
- name: Notify
85
-
command: |
86
-
python3 ./notify.py
87
32
`
88
33
89
34
wf, err := FromFile("test.yml", []byte(yamlData))
···
93
38
94
39
assert.True(t, wf.CloneOpts.Skip, "Skip should be false")
95
40
}
96
-
97
-
func TestUnmarshalEnv(t *testing.T) {
98
-
yamlData := `
99
-
when:
100
-
- event: ["pull_request_close"]
101
-
102
-
clone:
103
-
skip: false
104
-
105
-
environment:
106
-
HOME: /home/foo bar/baz
107
-
CGO_ENABLED: 1
108
-
109
-
steps:
110
-
- name: Something
111
-
command: echo "hello"
112
-
environment:
113
-
FOO: bar
114
-
BAZ: qux
115
-
`
116
-
117
-
wf, err := FromFile("test.yml", []byte(yamlData))
118
-
assert.NoError(t, err)
119
-
120
-
assert.Len(t, wf.Environment, 2)
121
-
assert.Equal(t, "/home/foo bar/baz", wf.Environment["HOME"])
122
-
assert.Equal(t, "1", wf.Environment["CGO_ENABLED"])
123
-
assert.Equal(t, "bar", wf.Steps[0].Environment["FOO"])
124
-
assert.Equal(t, "qux", wf.Steps[0].Environment["BAZ"])
125
-
}